Skip to content

API reference

earthcarekit

earthcarekit

A Python package to simplify working with EarthCARE satellite data

See also:


Copyright © 2025 TROPOS


cmaps module-attribute

cmaps = _get_custom_cmaps()

List of custom colormaps for earthcarekit.

Cmap

Bases: ListedColormap

Colormap with categorical, gradient, and circular support.

This subclass of matplotlib.colors.ListedColormap adds utilities for continuous and categorical color mappings. Supports labels, ticks, normalization, blending, and transparency adjustments.

Attributes:

Name Type Description
categorical bool

Whether the colormap is discrete/categorical.

gradient bool

Whether the colormap was generated from a gradient.

circular bool

Whether the colormap wraps around cyclically.

ticks list[float]

Optional tick positions for categorical plots.

labels list[str]

Optional labels corresponding to ticks.

norm Normalize | None

Normalization strategy for value mapping.

values list

Associated values for categorical mapping.

Source code in earthcarekit/plot/color/colormap/cmap.py
class Cmap(ListedColormap):
    """Colormap with categorical, gradient, and circular support.

    This subclass of `matplotlib.colors.ListedColormap` adds utilities for
    continuous and categorical color mappings. Supports labels, ticks,
    normalization, blending, and transparency adjustments.

    Attributes:
        categorical (bool): Whether the colormap is discrete/categorical.
        gradient (bool): Whether the colormap was generated from a gradient.
        circular (bool): Whether the colormap wraps around cyclically.
        ticks (list[float]): Optional tick positions for categorical plots.
        labels (list[str]): Optional labels corresponding to ticks.
        norm (Normalize | None): Normalization strategy for value mapping.
        values (list): Associated values for categorical mapping.
    """

    def __init__(
        self,
        colors,
        name: str = "colormap",
        N: int | None = None,
        categorical: bool = False,
        ticks: List[float] | None = None,
        labels: List[str] | None = None,
        norm: Normalize | None = None,
        values: List | None = None,
        gradient: bool = False,
        circular: bool = False,
    ):
        """Initialize a Cmap.

        Args:
            colors: Sequence of colors (strings or ColorLike objects).
            name (str): Name of the colormap. Defaults to "colormap".
            N (int | None): Number of discrete colors. Defaults to None.
            categorical (bool): Whether the colormap is discrete/categorical. Defaults to False.
            ticks (list[float] | None): Optional tick positions for categorical plots. Defaults to None.
            labels (list[str] | None): Optional labels corresponding to ticks. Defaults to None.
            norm (Normalize | None): Normalization strategy for value mapping. Defaults to None.
            gradient (bool): If True, generate intermediate gradient colors. Defaults to False.
            circular (bool): If True, colormap wraps around cyclically. Defaults to False.
        """
        colors = [Color(c) if isinstance(c, str) else c for c in colors]

        if gradient:
            tmp_cmap = LinearSegmentedColormap.from_list("tmp_cmap", colors, N=256)
            colors = [tmp_cmap(i) for i in range(256)]

        super().__init__(colors, name=name, N=N or len(colors))
        self.categorical = categorical
        self.gradient = gradient
        self.circular = circular
        self.ticks = ticks or []
        self.labels = labels or []
        self.norm = norm
        self.values = values or []

    @classmethod
    def from_colormap(cls, cmap: Colormap, N: int = 256) -> "Cmap":
        """Create a Cmap instance from an existing Matplotlib colormap.

        Args:
            cmap (Colormap): Source colormap to convert.
            N (int): Number of discrete colors (if needed, e.g, for categorical
                colormaps with limited number of colors). Defaults to 256.

        Returns:
            Cmap: New colormap.
        """
        if isinstance(cmap, cls):
            return cmap
        elif isinstance(cmap, ListedColormap):
            colors = list(cmap.colors)  # type: ignore
            if isinstance(colors, np.ndarray) and colors.ndim == 2:
                N = len(colors)
            else:
                N = cmap.N
        else:
            colors = [cmap(x) for x in np.linspace(0, 1, N)]

        _colors = []
        for c in colors:
            if (
                isinstance(c, (np.ndarray, list, tuple))
                and not isinstance(c, str)
                and all([_c <= 1 for _c in c])
            ):
                _colors.append(Color(c, is_normalized=True))  # type: ignore
            else:
                _colors = [Color(c) for c in colors]  # type: ignore
                continue
        new_cmap = cls([c.hex for c in _colors], name=cmap.name, N=N)
        new_cmap = copy_extremes(cmap, new_cmap)
        return new_cmap

    def to_categorical(
        self,
        values_to_labels: Dict[Any, str] | int,
        endpoint: bool | None = None,
        use_discrete: bool | None = None,
    ) -> "Cmap":
        """Convert a colormap to categorical.

        Args:
            values_to_labels (dict | int): Mapping from values to labels, or
                number of categories if int.
            endpoint (bool | None): Whether the last color is included at 1.0.
            use_discrete (bool | None): If True, use the colormap's defined colors directly rather than sampling across its range.

        Returns:
            Cmap: Categorical version of the colormap.
        """
        if isinstance(values_to_labels, int):
            values_to_labels = {i: str(i) for i in range(values_to_labels)}

        values_to_labels = dict(sorted(values_to_labels.items()))

        keys = list(values_to_labels.keys())
        labels = list(values_to_labels.values())
        sorted_values = keys

        n_classes = len(sorted_values)
        bounds = np.array(sorted_values + [sorted_values[-1] + 1]) - 0.5
        norm = BoundaryNorm(bounds, n_classes)

        ticks = [float(t) for t in np.arange(0.5, n_classes)]

        if use_discrete:
            colors = [self(i) for i in range(n_classes)]
        else:
            if not isinstance(endpoint, bool):
                endpoint = not self.circular
            offset = -1 if endpoint else 0
            colors = [self(i / max(n_classes + offset, 1)) for i in range(n_classes)]

        return Cmap(
            colors=colors,
            name=self.name,
            N=n_classes,
            categorical=True,
            gradient=False,
            circular=self.circular,
            ticks=ticks,
            labels=labels,
            norm=norm,
            values=sorted_values,
        )

    def to_discrete(self, n: int) -> "Cmap":
        """Convert a colormap to a discretized version of itself.

        Args:
            n (int): Number of steps (i.e., discrete colors).

        Returns:
            Cmap: Discretized version of the colormap.
        """
        new_cmap = self.to_categorical(n)
        new_cmap.categorical = False
        new_cmap.ticks = []
        new_cmap.labels = []
        new_cmap.norm = None
        new_cmap.values = []
        return new_cmap

    def set_alpha(self, value: float) -> "Cmap":
        """Return a copy of the colormap with modified alpha transparency.

        Args:
            value (float): Alpha value in the range [0, 1].

        Returns:
            Cmap: Colormap with updated transparency.
        """
        if not 0 <= value <= 1:
            raise ValueError(
                f"Invalid alpha value: '{value}' (must be in the 0-1 range)"
            )

        new_cmap = Cmap(
            colors=[Color(c).set_alpha(value) for c in np.asarray(self.colors)],
            name=self.name,
            N=self.N,
            categorical=self.categorical,
            gradient=self.gradient,
            circular=self.circular,
            ticks=self.ticks,
            labels=self.labels,
            norm=self.norm,
        )

        if self._rgba_bad is not None:  # type: ignore
            new_cmap._rgba_bad = Color(self._rgba_bad, is_normalized=True).set_alpha(value).rgba  # type: ignore
        if self._rgba_over is not None:  # type: ignore
            new_cmap._rgba_over = Color(self._rgba_over, is_normalized=True).set_alpha(value).rgba  # type: ignore
        if self._rgba_under is not None:  # type: ignore
            new_cmap._rgba_under = Color(self._rgba_under, is_normalized=True).set_alpha(value).rgba  # type: ignore

        return new_cmap

    def blend(self, value: float, blend_color: Color | ColorLike = "white") -> "Cmap":
        """Return a copy of the colormap blended with a second color.

        Args:
            value (float): Blend factor in the range [0, 1].
            blend_color (Color | str): Color to blend with.

        Returns:
            Cmap: Blended colormap.
        """
        if not 0 <= value <= 1:
            raise ValueError(
                f"Invalid blend value: '{value}' (must be in the 0-1 range)"
            )

        new_cmap = Cmap(
            colors=[
                Color(c).blend(value, blend_color) for c in np.asarray(self.colors)
            ],
            name=self.name,
            N=self.N,
            categorical=self.categorical,
            gradient=self.gradient,
            circular=self.circular,
            ticks=self.ticks,
            labels=self.labels,
            norm=self.norm,
        )

        if self._rgba_bad is not None:  # type: ignore
            new_cmap._rgba_bad = Color(self._rgba_bad, is_normalized=True).blend(value, blend_color).rgba  # type: ignore
        if self._rgba_over is not None:  # type: ignore
            new_cmap._rgba_over = Color(self._rgba_over, is_normalized=True).blend(value, blend_color).rgba  # type: ignore
        if self._rgba_under is not None:  # type: ignore
            new_cmap._rgba_under = Color(self._rgba_under, is_normalized=True).blend(value, blend_color).rgba  # type: ignore

        return new_cmap

    @property
    def rgba_list(self) -> list[tuple[float, ...]]:
        """List of RGBA tuples representing all colors in the colormap."""
        return [Color(c, is_normalized=True).rgba for c in np.array(self.colors)]

    # def set_alpha_gradient(self, alpha_input: list) -> "Cmap":
    #     from matplotlib.colors import ListedColormap
    #     from scipy.interpolate import interp1d

    #     # Interpolate to 256 values
    #     n_colors = 256
    #     x_old = np.linspace(0, 1, len(alpha_input))
    #     x_new = np.linspace(0, 1, n_colors)
    #     alpha_interp = interp1d(x_old, alpha_input, kind="linear")(x_new)

    #     # Get base colormap and apply interpolated alpha
    #     colors = self(np.linspace(0, 1, n_colors))
    #     colors[:, 3] = alpha_interp  # Replace alpha channel

    #     # Create transparent colormap
    #     new_cmap = Cmap(colors, name=self.name)

    @property
    def opaque(self) -> "Cmap":
        """Return an opaque version of the colormap (alpha set to 1)."""
        return colormap_to_opaque(self)

    @property
    def alphamap(self) -> "Cmap":
        """Return the alpha-mapped version of the colormap."""
        return colormap_to_alphamap(self)

    @property
    def blended(self) -> "Cmap":
        """Return a blended version of the colormap (predefined blending)."""
        return colormap_to_blended(self)

    def __new__(cls, *args, **kwargs):
        """Allow instantiation from an existing Colormap or standard arguments."""
        if len(args) == 1 and isinstance(args[0], Colormap):
            return cls.from_colormap(args[0])
        return super().__new__(cls)

alphamap property

alphamap

Return the alpha-mapped version of the colormap.

blended property

blended

Return a blended version of the colormap (predefined blending).

opaque property

opaque

Return an opaque version of the colormap (alpha set to 1).

rgba_list property

rgba_list

List of RGBA tuples representing all colors in the colormap.

__init__

__init__(
    colors,
    name="colormap",
    N=None,
    categorical=False,
    ticks=None,
    labels=None,
    norm=None,
    values=None,
    gradient=False,
    circular=False,
)

Initialize a Cmap.

Parameters:

Name Type Description Default
colors

Sequence of colors (strings or ColorLike objects).

required
name str

Name of the colormap. Defaults to "colormap".

'colormap'
N int | None

Number of discrete colors. Defaults to None.

None
categorical bool

Whether the colormap is discrete/categorical. Defaults to False.

False
ticks list[float] | None

Optional tick positions for categorical plots. Defaults to None.

None
labels list[str] | None

Optional labels corresponding to ticks. Defaults to None.

None
norm Normalize | None

Normalization strategy for value mapping. Defaults to None.

None
gradient bool

If True, generate intermediate gradient colors. Defaults to False.

False
circular bool

If True, colormap wraps around cyclically. Defaults to False.

False
Source code in earthcarekit/plot/color/colormap/cmap.py
def __init__(
    self,
    colors,
    name: str = "colormap",
    N: int | None = None,
    categorical: bool = False,
    ticks: List[float] | None = None,
    labels: List[str] | None = None,
    norm: Normalize | None = None,
    values: List | None = None,
    gradient: bool = False,
    circular: bool = False,
):
    """Initialize a Cmap.

    Args:
        colors: Sequence of colors (strings or ColorLike objects).
        name (str): Name of the colormap. Defaults to "colormap".
        N (int | None): Number of discrete colors. Defaults to None.
        categorical (bool): Whether the colormap is discrete/categorical. Defaults to False.
        ticks (list[float] | None): Optional tick positions for categorical plots. Defaults to None.
        labels (list[str] | None): Optional labels corresponding to ticks. Defaults to None.
        norm (Normalize | None): Normalization strategy for value mapping. Defaults to None.
        gradient (bool): If True, generate intermediate gradient colors. Defaults to False.
        circular (bool): If True, colormap wraps around cyclically. Defaults to False.
    """
    colors = [Color(c) if isinstance(c, str) else c for c in colors]

    if gradient:
        tmp_cmap = LinearSegmentedColormap.from_list("tmp_cmap", colors, N=256)
        colors = [tmp_cmap(i) for i in range(256)]

    super().__init__(colors, name=name, N=N or len(colors))
    self.categorical = categorical
    self.gradient = gradient
    self.circular = circular
    self.ticks = ticks or []
    self.labels = labels or []
    self.norm = norm
    self.values = values or []

__new__

__new__(*args, **kwargs)

Allow instantiation from an existing Colormap or standard arguments.

Source code in earthcarekit/plot/color/colormap/cmap.py
def __new__(cls, *args, **kwargs):
    """Allow instantiation from an existing Colormap or standard arguments."""
    if len(args) == 1 and isinstance(args[0], Colormap):
        return cls.from_colormap(args[0])
    return super().__new__(cls)

blend

blend(value, blend_color='white')

Return a copy of the colormap blended with a second color.

Parameters:

Name Type Description Default
value float

Blend factor in the range [0, 1].

required
blend_color Color | str

Color to blend with.

'white'

Returns:

Name Type Description
Cmap Cmap

Blended colormap.

Source code in earthcarekit/plot/color/colormap/cmap.py
def blend(self, value: float, blend_color: Color | ColorLike = "white") -> "Cmap":
    """Return a copy of the colormap blended with a second color.

    Args:
        value (float): Blend factor in the range [0, 1].
        blend_color (Color | str): Color to blend with.

    Returns:
        Cmap: Blended colormap.
    """
    if not 0 <= value <= 1:
        raise ValueError(
            f"Invalid blend value: '{value}' (must be in the 0-1 range)"
        )

    new_cmap = Cmap(
        colors=[
            Color(c).blend(value, blend_color) for c in np.asarray(self.colors)
        ],
        name=self.name,
        N=self.N,
        categorical=self.categorical,
        gradient=self.gradient,
        circular=self.circular,
        ticks=self.ticks,
        labels=self.labels,
        norm=self.norm,
    )

    if self._rgba_bad is not None:  # type: ignore
        new_cmap._rgba_bad = Color(self._rgba_bad, is_normalized=True).blend(value, blend_color).rgba  # type: ignore
    if self._rgba_over is not None:  # type: ignore
        new_cmap._rgba_over = Color(self._rgba_over, is_normalized=True).blend(value, blend_color).rgba  # type: ignore
    if self._rgba_under is not None:  # type: ignore
        new_cmap._rgba_under = Color(self._rgba_under, is_normalized=True).blend(value, blend_color).rgba  # type: ignore

    return new_cmap

from_colormap classmethod

from_colormap(cmap, N=256)

Create a Cmap instance from an existing Matplotlib colormap.

Parameters:

Name Type Description Default
cmap Colormap

Source colormap to convert.

required
N int

Number of discrete colors (if needed, e.g, for categorical colormaps with limited number of colors). Defaults to 256.

256

Returns:

Name Type Description
Cmap Cmap

New colormap.

Source code in earthcarekit/plot/color/colormap/cmap.py
@classmethod
def from_colormap(cls, cmap: Colormap, N: int = 256) -> "Cmap":
    """Create a Cmap instance from an existing Matplotlib colormap.

    Args:
        cmap (Colormap): Source colormap to convert.
        N (int): Number of discrete colors (if needed, e.g, for categorical
            colormaps with limited number of colors). Defaults to 256.

    Returns:
        Cmap: New colormap.
    """
    if isinstance(cmap, cls):
        return cmap
    elif isinstance(cmap, ListedColormap):
        colors = list(cmap.colors)  # type: ignore
        if isinstance(colors, np.ndarray) and colors.ndim == 2:
            N = len(colors)
        else:
            N = cmap.N
    else:
        colors = [cmap(x) for x in np.linspace(0, 1, N)]

    _colors = []
    for c in colors:
        if (
            isinstance(c, (np.ndarray, list, tuple))
            and not isinstance(c, str)
            and all([_c <= 1 for _c in c])
        ):
            _colors.append(Color(c, is_normalized=True))  # type: ignore
        else:
            _colors = [Color(c) for c in colors]  # type: ignore
            continue
    new_cmap = cls([c.hex for c in _colors], name=cmap.name, N=N)
    new_cmap = copy_extremes(cmap, new_cmap)
    return new_cmap

set_alpha

set_alpha(value)

Return a copy of the colormap with modified alpha transparency.

Parameters:

Name Type Description Default
value float

Alpha value in the range [0, 1].

required

Returns:

Name Type Description
Cmap Cmap

Colormap with updated transparency.

Source code in earthcarekit/plot/color/colormap/cmap.py
def set_alpha(self, value: float) -> "Cmap":
    """Return a copy of the colormap with modified alpha transparency.

    Args:
        value (float): Alpha value in the range [0, 1].

    Returns:
        Cmap: Colormap with updated transparency.
    """
    if not 0 <= value <= 1:
        raise ValueError(
            f"Invalid alpha value: '{value}' (must be in the 0-1 range)"
        )

    new_cmap = Cmap(
        colors=[Color(c).set_alpha(value) for c in np.asarray(self.colors)],
        name=self.name,
        N=self.N,
        categorical=self.categorical,
        gradient=self.gradient,
        circular=self.circular,
        ticks=self.ticks,
        labels=self.labels,
        norm=self.norm,
    )

    if self._rgba_bad is not None:  # type: ignore
        new_cmap._rgba_bad = Color(self._rgba_bad, is_normalized=True).set_alpha(value).rgba  # type: ignore
    if self._rgba_over is not None:  # type: ignore
        new_cmap._rgba_over = Color(self._rgba_over, is_normalized=True).set_alpha(value).rgba  # type: ignore
    if self._rgba_under is not None:  # type: ignore
        new_cmap._rgba_under = Color(self._rgba_under, is_normalized=True).set_alpha(value).rgba  # type: ignore

    return new_cmap

to_categorical

to_categorical(values_to_labels, endpoint=None, use_discrete=None)

Convert a colormap to categorical.

Parameters:

Name Type Description Default
values_to_labels dict | int

Mapping from values to labels, or number of categories if int.

required
endpoint bool | None

Whether the last color is included at 1.0.

None
use_discrete bool | None

If True, use the colormap's defined colors directly rather than sampling across its range.

None

Returns:

Name Type Description
Cmap Cmap

Categorical version of the colormap.

Source code in earthcarekit/plot/color/colormap/cmap.py
def to_categorical(
    self,
    values_to_labels: Dict[Any, str] | int,
    endpoint: bool | None = None,
    use_discrete: bool | None = None,
) -> "Cmap":
    """Convert a colormap to categorical.

    Args:
        values_to_labels (dict | int): Mapping from values to labels, or
            number of categories if int.
        endpoint (bool | None): Whether the last color is included at 1.0.
        use_discrete (bool | None): If True, use the colormap's defined colors directly rather than sampling across its range.

    Returns:
        Cmap: Categorical version of the colormap.
    """
    if isinstance(values_to_labels, int):
        values_to_labels = {i: str(i) for i in range(values_to_labels)}

    values_to_labels = dict(sorted(values_to_labels.items()))

    keys = list(values_to_labels.keys())
    labels = list(values_to_labels.values())
    sorted_values = keys

    n_classes = len(sorted_values)
    bounds = np.array(sorted_values + [sorted_values[-1] + 1]) - 0.5
    norm = BoundaryNorm(bounds, n_classes)

    ticks = [float(t) for t in np.arange(0.5, n_classes)]

    if use_discrete:
        colors = [self(i) for i in range(n_classes)]
    else:
        if not isinstance(endpoint, bool):
            endpoint = not self.circular
        offset = -1 if endpoint else 0
        colors = [self(i / max(n_classes + offset, 1)) for i in range(n_classes)]

    return Cmap(
        colors=colors,
        name=self.name,
        N=n_classes,
        categorical=True,
        gradient=False,
        circular=self.circular,
        ticks=ticks,
        labels=labels,
        norm=norm,
        values=sorted_values,
    )

to_discrete

to_discrete(n)

Convert a colormap to a discretized version of itself.

Parameters:

Name Type Description Default
n int

Number of steps (i.e., discrete colors).

required

Returns:

Name Type Description
Cmap Cmap

Discretized version of the colormap.

Source code in earthcarekit/plot/color/colormap/cmap.py
def to_discrete(self, n: int) -> "Cmap":
    """Convert a colormap to a discretized version of itself.

    Args:
        n (int): Number of steps (i.e., discrete colors).

    Returns:
        Cmap: Discretized version of the colormap.
    """
    new_cmap = self.to_categorical(n)
    new_cmap.categorical = False
    new_cmap.ticks = []
    new_cmap.labels = []
    new_cmap.norm = None
    new_cmap.values = []
    return new_cmap

Color dataclass

Bases: str

Represents a color with convenient conversion, blending, and alpha support.

Extends str to store a color as a hex string while providing methods to access RGB/RGBA, set transparency, blend with other colors, and normalize input from various formats.

Attributes:

Name Type Description
input ColorLike

Original input used to create the color.

name str | None

Optional name of the color.

is_normalized bool

Whether the color values are normalized (0-1).

Source code in earthcarekit/plot/color/color.py
@dataclass(frozen=True)
class Color(str):
    """Represents a color with convenient conversion, blending, and alpha support.

    Extends `str` to store a color as a hex string while providing methods
    to access RGB/RGBA, set transparency, blend with other colors, and
    normalize input from various formats.

    Attributes:
        input (ColorLike): Original input used to create the color.
        name (str | None): Optional name of the color.
        is_normalized (bool): Whether the color values are normalized (0-1).
    """

    input: ColorLike
    name: str | None = None
    is_normalized: bool = False

    def __new__(
        cls,
        color_input: "Color" | ColorLike,
        name: str | None = None,
        is_normalized: bool = False,
    ):
        """Create a Color instance from a color-like input."""
        hex_color = cls._to_hex(color_input, is_normalized=is_normalized)
        return str.__new__(cls, hex_color)

    def __init__(
        self,
        color_input: "Color" | ColorLike,
        name: str | None = None,
        is_normalized: bool = False,
    ):
        """Initialize Color attributes."""
        object.__setattr__(self, "input", color_input)
        object.__setattr__(self, "name", name)
        object.__setattr__(self, "is_normalized", is_normalized)

    def __hash__(self):
        """Return the hash of the color string."""
        return hash(str(self))

    @classmethod
    def _rgb_str_to_hex(
        cls,
        rgb_string: str,
        is_normalized: bool = False,
    ) -> str:
        """Convert an 'rgb(...)' string to a hex color."""
        if (not is_normalized and re.match(r"^rgb\(\d*,\d*,\d*\)$", rgb_string)) or (
            is_normalized
            and re.match(r"^rgb\(\d*\.?\d*,\d*\.?\d*,\d*\.?\d*\)$", rgb_string)
        ):
            rgb_str_list = rgb_string[4:-1].split(",")
            if is_normalized:
                rgb_tuple = tuple(float(v) for v in rgb_str_list)
            else:
                rgb_tuple = tuple(int(v) for v in rgb_str_list)
            return cls._rgb_tuple_to_hex(rgb_tuple, is_normalized=is_normalized)
        raise ValueError(f"Invalid rgb color: '{rgb_string}'")

    @classmethod
    def _rgba_str_to_hex(
        cls,
        rgba_string: str,
        is_normalized: bool = False,
    ) -> str:
        """Convert an 'rgba(...)' string to a hex color."""
        if (
            not is_normalized
            and re.match(r"^rgba\(\d*,\d*,\d*,\d*\.?\d*\)$", rgba_string)
        ) or (
            is_normalized
            and re.match(
                r"^rgba\(\d*\.?\d*,\d*\.?\d*,\d*\.?\d*,\d*\.?\d*\)$", rgba_string
            )
        ):
            rgba_str_list = rgba_string[5:-1].split(",")
            if float(rgba_str_list[-1]) > 1:
                raise ValueError(
                    f"Invalid alpha value (must be float between 0 and 1): '{rgba_string}'"
                )
            if is_normalized:
                rgba_tuple = tuple(float(v) for v in rgba_str_list)
            else:
                rgba_tuple = tuple(
                    int(v) if i < 3 else float(v) for i, v in enumerate(rgba_str_list)
                )
            return cls._rgba_tuple_to_hex(rgba_tuple, is_normalized=is_normalized)
        raise ValueError(f"Invalid rgba color: '{rgba_string}'")

    @classmethod
    def _rgb_tuple_to_hex(
        cls,
        rgb_tuple: Tuple[int, ...] | Tuple[float, ...],
        is_normalized: bool = False,
    ) -> str:
        """Convert an RGB tuple to a hex color string."""
        if is_normalized:
            rgb_tuple = tuple(int(v * 255) for v in rgb_tuple)
        is_all_int = all(isinstance(v, int | np.integer) for v in rgb_tuple)
        is_all_in_range = all(0 <= v <= 255 for v in rgb_tuple)
        if is_all_int and is_all_in_range:
            return "#{:02X}{:02X}{:02X}".format(*rgb_tuple)
        raise ValueError(f"Invalid rgb tuple: '{rgb_tuple}'")

    @classmethod
    def _rgba_tuple_to_hex(
        cls,
        rgba_tuple: Tuple[int | float, ...],
        is_normalized: bool = False,
    ) -> str:
        """Convert an RGBA tuple to a hex color string."""
        if is_normalized:
            rgba_tuple = tuple(
                int(v * 255) if i < 3 else v for i, v in enumerate(rgba_tuple)
            )
        is_all_int = all(
            isinstance(v, int | np.integer | float | np.floating) for v in rgba_tuple
        )
        is_all_in_range = all(
            0 <= v <= 255 if i < 3 else 0 <= v <= 1 for i, v in enumerate(rgba_tuple)
        )
        if is_all_int and is_all_in_range:
            rgba_tuple = tuple(
                int(v) if i < 3 else float(v) for i, v in enumerate(rgba_tuple)
            )
            rgba_int_tuple = tuple(
                int(v) if i < 3 else int(float(v) * 255)
                for i, v in enumerate(rgba_tuple)
            )
            return "#{:02X}{:02X}{:02X}{:02X}".format(*rgba_int_tuple)
        raise ValueError(f"Invalid rgba tuple: '{rgba_tuple}'")

    @classmethod
    def _hex_str_to_hex(
        cls,
        hex_string: str,
    ) -> str:
        """Normalize a hex string to standard 6- or 8-character format."""
        c = hex_string.upper()
        if re.match(r"^#[A-F0-9]{3}$", c):
            c = f"#{c[1]*2}{c[2]*2}{c[3]*2}"
        elif re.match(r"^#[A-F0-9]{4}$", c):
            c = f"#{c[1]*2}{c[2]*2}{c[3]*2}{c[4]*2}"
        if not re.match(r"^#[A-F0-9]{6}$", c) and not re.match(r"^#[A-F0-9]{8}$", c):
            raise ValueError(f"Invalid hex color: '{hex_string}'")
        return c

    @classmethod
    def _to_hex(
        cls,
        color: str | Sequence,
        is_normalized: bool = False,
    ) -> str:
        """Convert a color input of various formats to a hex string."""
        if isinstance(color, str):
            c_str = color.strip().replace(" ", "").lower()
            if c_str.startswith("#"):
                return cls._hex_str_to_hex(c_str)
            elif c_str.startswith("rgb("):
                return cls._rgb_str_to_hex(c_str)
            elif c_str.startswith("rgba("):
                return cls._rgba_str_to_hex(c_str)
            elif c_str.startswith("rgb255("):
                return cls._rgb_str_to_hex(c_str.replace("rgb255(", "rgb("))
            elif c_str.startswith("rgba255("):
                return cls._rgba_str_to_hex(c_str.replace("rgba255(", "rgba("))
            elif c_str.startswith("rgb01("):
                return cls._rgb_str_to_hex(
                    c_str.replace("rgb01(", "rgb("), is_normalized=True
                )
            elif c_str.startswith("rgba01("):
                return cls._rgba_str_to_hex(
                    c_str.replace("rgba01(", "rgba("), is_normalized=True
                )
            else:
                try:
                    return _custom_colors[color].upper()
                except KeyError as e:
                    pass
                return mcolors.to_hex(color).upper()
        elif isinstance(color, (Sequence, np.ndarray)):
            if len(color) > 0:
                if isinstance(color[0], float):
                    is_normalized = True
                else:
                    is_normalized = False
            c_tup = tuple(float(v) for v in color)
            if len(c_tup) == 3:
                if is_normalized:
                    c_tup = tuple(float(v) for v in color)
                else:
                    c_tup = tuple(int(v) for v in color)
                return cls._rgb_tuple_to_hex(c_tup, is_normalized=is_normalized)
            elif len(c_tup) == 4:
                return cls._rgba_tuple_to_hex(c_tup, is_normalized=is_normalized)
        raise TypeError(f"Invalid type for input color ({type(color)}: {color})")

    @property
    def hex(self) -> str:
        """Returns the hex color string."""
        return str(self).upper()

    @property
    def rgb(self) -> Tuple[int, int, int]:
        """Returns the RGB tuple with values in the 0-255 range."""
        hex_str = self.lstrip("#")
        return (
            int(hex_str[0:2], 16),
            int(hex_str[2:4], 16),
            int(hex_str[4:6], 16),
        )

    @property
    def alpha(self) -> float:
        """Returns the transparency alpha value in the 0-1 range."""
        if len(self) == 9:
            return int(str(self).upper()[7::], 16) / 255
        return 1.0

    @property
    def rgba(self) -> Tuple[float, float, float, float]:
        """Returns the RGBA tuple with values in the 0-1 range."""
        hex_str = self.lstrip("#")
        return (
            int(hex_str[0:2], 16) / 255,
            int(hex_str[2:4], 16) / 255,
            int(hex_str[4:6], 16) / 255,
            self.alpha,
        )

    def set_alpha(self, value: float) -> "Color":
        """Returns the same color with the given transparency alpha value applied."""
        if not 0 <= value <= 1:
            raise ValueError(
                f"Invalid alpha value: '{value}' (must be in the 0-1 range)"
            )
        return Color(self.hex[0:7] + "{:02X}".format(int(value * 255)), name=self.name)

    def blend(
        self, value: float, blend_color: "Color" | ColorLike = "white"
    ) -> "Color":
        """Returns the same color blended with a second color."""
        original_color = self.rgb
        blend_color = Color(blend_color).rgb
        new_color = Color(
            tuple(
                int(round((1 - value) * bc + value * oc))
                for oc, bc in zip(original_color, blend_color)
            )
        )
        return new_color

    @classmethod
    def from_optional(
        cls,
        color_input: "Color" | ColorLike | None,
        alpha: float | None = None,
    ) -> Union["Color", None]:
        """Parses optional color input and returns a `Color` instance or `None`."""
        if color_input is None:
            return None
        elif isinstance(alpha, float):
            return cls(color_input).set_alpha(alpha)
        else:
            return cls(color_input)

    def is_close_to_white(self, threshold: float = 0.1) -> bool:
        """Check if the color is close to white."""
        rgb01 = 1 - (np.array(self.rgb) / 255)
        return bool(np.all(rgb01 < threshold))

    def get_best_bw_contrast_color(self) -> "Color":
        """
        Return black or white color depending on best contrast according to WCAG 2.0.

        See https://www.w3.org/TR/WCAG20/
        """
        return Color(get_best_bw_contrast_color(self.rgb))

alpha property

alpha

Returns the transparency alpha value in the 0-1 range.

hex property

hex

Returns the hex color string.

rgb property

rgb

Returns the RGB tuple with values in the 0-255 range.

rgba property

rgba

Returns the RGBA tuple with values in the 0-1 range.

__hash__

__hash__()

Return the hash of the color string.

Source code in earthcarekit/plot/color/color.py
def __hash__(self):
    """Return the hash of the color string."""
    return hash(str(self))

__init__

__init__(color_input, name=None, is_normalized=False)

Initialize Color attributes.

Source code in earthcarekit/plot/color/color.py
def __init__(
    self,
    color_input: "Color" | ColorLike,
    name: str | None = None,
    is_normalized: bool = False,
):
    """Initialize Color attributes."""
    object.__setattr__(self, "input", color_input)
    object.__setattr__(self, "name", name)
    object.__setattr__(self, "is_normalized", is_normalized)

__new__

__new__(color_input, name=None, is_normalized=False)

Create a Color instance from a color-like input.

Source code in earthcarekit/plot/color/color.py
def __new__(
    cls,
    color_input: "Color" | ColorLike,
    name: str | None = None,
    is_normalized: bool = False,
):
    """Create a Color instance from a color-like input."""
    hex_color = cls._to_hex(color_input, is_normalized=is_normalized)
    return str.__new__(cls, hex_color)

blend

blend(value, blend_color='white')

Returns the same color blended with a second color.

Source code in earthcarekit/plot/color/color.py
def blend(
    self, value: float, blend_color: "Color" | ColorLike = "white"
) -> "Color":
    """Returns the same color blended with a second color."""
    original_color = self.rgb
    blend_color = Color(blend_color).rgb
    new_color = Color(
        tuple(
            int(round((1 - value) * bc + value * oc))
            for oc, bc in zip(original_color, blend_color)
        )
    )
    return new_color

from_optional classmethod

from_optional(color_input, alpha=None)

Parses optional color input and returns a Color instance or None.

Source code in earthcarekit/plot/color/color.py
@classmethod
def from_optional(
    cls,
    color_input: "Color" | ColorLike | None,
    alpha: float | None = None,
) -> Union["Color", None]:
    """Parses optional color input and returns a `Color` instance or `None`."""
    if color_input is None:
        return None
    elif isinstance(alpha, float):
        return cls(color_input).set_alpha(alpha)
    else:
        return cls(color_input)

get_best_bw_contrast_color

get_best_bw_contrast_color()

Return black or white color depending on best contrast according to WCAG 2.0.

See https://www.w3.org/TR/WCAG20/

Source code in earthcarekit/plot/color/color.py
def get_best_bw_contrast_color(self) -> "Color":
    """
    Return black or white color depending on best contrast according to WCAG 2.0.

    See https://www.w3.org/TR/WCAG20/
    """
    return Color(get_best_bw_contrast_color(self.rgb))

is_close_to_white

is_close_to_white(threshold=0.1)

Check if the color is close to white.

Source code in earthcarekit/plot/color/color.py
def is_close_to_white(self, threshold: float = 0.1) -> bool:
    """Check if the color is close to white."""
    rgb01 = 1 - (np.array(self.rgb) / 255)
    return bool(np.all(rgb01 < threshold))

set_alpha

set_alpha(value)

Returns the same color with the given transparency alpha value applied.

Source code in earthcarekit/plot/color/color.py
def set_alpha(self, value: float) -> "Color":
    """Returns the same color with the given transparency alpha value applied."""
    if not 0 <= value <= 1:
        raise ValueError(
            f"Invalid alpha value: '{value}' (must be in the 0-1 range)"
        )
    return Color(self.hex[0:7] + "{:02X}".format(int(value * 255)), name=self.name)

CurtainFigure

Figure object for displaying EarthCARE curtain data (e.g., ATLID and CPR L1/L2 profiles) along the satellite track.

This class sets up a horizontal-along-track or time vs. vertical-height plot (a "curtain" view), for profiling atmospheric quantities retrieved from ground-based or nadir-viewing air/space-bourne instruments (like EarthCARE). It displays dual top/bottom x-axes (e.g., geolocation and time), and left/right y-axes for height labels.

Attributes:

Name Type Description
ax Axes | None

Existing matplotlib axes to plot on; if not provided, a new figure and axes will be created. Defaults to None.

figsize tuple[float, float]

Size of the figure in inches. Defaults to (FIGURE_WIDTH_CURTAIN, FIGURE_HEIGHT_CURTAIN).

dpi int | None

Resolution of the figure in dots per inch. Defaults to None.

title str | None

Title to display above the curtain plot. Defaults to None.

ax_style_top AlongTrackAxisStyle | str

Style of the top x-axis, e.g., "geo", "time", or "frame". Defaults to "geo".

ax_style_bottom AlongTrackAxisStyle | str

Style of the bottom x-axis, e.g., "geo", "time", or "frame". Defaults to "time".

num_ticks int

Maximum number of tick marks to be place along the x-axis. Defaults to 10.

show_height_left bool

Whether to show height labels on the left y-axis. Defaults to True.

show_height_right bool

Whether to show height labels on the right y-axis. Defaults to False.

mode Literal['exact', 'fast']

Curtain plotting mode. Use "fast" to speed up plotting by coarsening data to at least min_num_profiles; "exact" plots full resolution. Defaults to None.

min_num_profiles int

Minimum number of profiles to keep when using "fast" mode. Defaults to 1000.

Source code in earthcarekit/plot/figure/curtain.py
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
class CurtainFigure:
    """Figure object for displaying EarthCARE curtain data (e.g., ATLID and CPR L1/L2 profiles) along the satellite track.

    This class sets up a horizontal-along-track or time vs. vertical-height plot (a "curtain" view), for profiling
    atmospheric quantities retrieved from ground-based or nadir-viewing air/space-bourne instruments (like EarthCARE).
    It displays dual top/bottom x-axes (e.g., geolocation and time), and left/right y-axes for height labels.

    Attributes:
        ax (Axes | None, optional): Existing matplotlib axes to plot on; if not provided, a new figure and axes will be created. Defaults to None.
        figsize (tuple[float, float], optional): Size of the figure in inches. Defaults to (FIGURE_WIDTH_CURTAIN, FIGURE_HEIGHT_CURTAIN).
        dpi (int | None, optional): Resolution of the figure in dots per inch. Defaults to None.
        title (str | None, optional): Title to display above the curtain plot. Defaults to None.
        ax_style_top (AlongTrackAxisStyle | str, optional): Style of the top x-axis, e.g., "geo", "time", or "frame". Defaults to "geo".
        ax_style_bottom (AlongTrackAxisStyle | str, optional): Style of the bottom x-axis, e.g., "geo", "time", or "frame". Defaults to "time".
        num_ticks (int, optional): Maximum number of tick marks to be place along the x-axis. Defaults to 10.
        show_height_left (bool, optional): Whether to show height labels on the left y-axis. Defaults to True.
        show_height_right (bool, optional): Whether to show height labels on the right y-axis. Defaults to False.
        mode (Literal["exact", "fast"], optional): Curtain plotting mode. Use "fast" to speed up plotting by coarsening data to at least `min_num_profiles`; "exact" plots full resolution. Defaults to None.
        min_num_profiles (int, optional): Minimum number of profiles to keep when using "fast" mode. Defaults to 1000.
    """

    def __init__(
        self,
        ax: Axes | None = None,
        figsize: tuple[float, float] = (FIGURE_WIDTH_CURTAIN, FIGURE_HEIGHT_CURTAIN),
        dpi: int | None = None,
        title: str | None = None,
        ax_style_top: AlongTrackAxisStyle | str = "geo",
        ax_style_bottom: AlongTrackAxisStyle | str = "time",
        num_ticks: int = 10,
        show_height_left: bool = True,
        show_height_right: bool = False,
        mode: Literal["exact", "fast"] = "fast",
        min_num_profiles: int = 1000,
        colorbar_tick_scale: float | None = None,
        fig_height_scale: float = 1.0,
        fig_width_scale: float = 1.0,
    ):
        self.fig: Figure
        figsize = (figsize[0] * fig_width_scale, figsize[1] * fig_height_scale)
        if isinstance(ax, Axes):
            tmp = ax.get_figure()
            if not isinstance(tmp, (Figure, SubFigure)):
                raise ValueError(f"Invalid Figure")
            self.fig = tmp  # type: ignore
            self.ax = ax
        else:
            self.fig = plt.figure(figsize=figsize, dpi=dpi)
            self.ax = self.fig.add_axes((0.0, 0.0, 1.0, 1.0))
        self.title = title
        if self.title:
            self.fig.suptitle(self.title)

        self.ax_top: Axes | None = None
        self.ax_right: Axes | None = None
        self.colorbar: Colorbar | None = None
        self.colorbar_tick_scale: float | None = colorbar_tick_scale
        self.selection_time_range: tuple[pd.Timestamp, pd.Timestamp] | None = None
        self.ax_style_top: AlongTrackAxisStyle = AlongTrackAxisStyle.from_input(
            ax_style_top
        )
        self.ax_style_bottom: AlongTrackAxisStyle = AlongTrackAxisStyle.from_input(
            ax_style_bottom
        )

        self.info_text: AnchoredText | None = None
        self.info_text_loc: str = "upper right"
        self.num_ticks = num_ticks
        self.show_height_left = show_height_left
        self.show_height_right = show_height_right

        if mode in ["exact", "fast"]:
            self.mode = mode
        else:
            self.mode = "fast"

        if isinstance(min_num_profiles, int):
            self.min_num_profiles = min_num_profiles
        else:
            self.min_num_profiles = 1000

        self.legend: Legend | None = self.ax.get_legend()
        self._legend_handles: list = []
        self._legend_labels: list = []

    def _set_info_text_loc(self, info_text_loc: str | None) -> None:
        if isinstance(info_text_loc, str):
            self.info_text_loc = info_text_loc

    def _set_axes(
        self,
        tmin: np.datetime64,
        tmax: np.datetime64,
        hmin: float,
        hmax: float,
        time: NDArray,
        tmin_original: np.datetime64 | None = None,
        tmax_original: np.datetime64 | None = None,
        longitude: NDArray | None = None,
        latitude: NDArray | None = None,
        ax_style_top: AlongTrackAxisStyle | str | None = None,
        ax_style_bottom: AlongTrackAxisStyle | str | None = None,
    ) -> "CurtainFigure":

        self.set_colorbar_tick_scale(multiplier=self.colorbar_tick_scale)

        if ax_style_top is not None:
            self.ax_style_top = AlongTrackAxisStyle.from_input(ax_style_top)
        if ax_style_bottom is not None:
            self.ax_style_bottom = AlongTrackAxisStyle.from_input(ax_style_bottom)
        if not isinstance(tmin_original, np.datetime64):
            tmin_original = tmin
        if not isinstance(tmax_original, np.datetime64):
            tmax_original = tmax

        self.ax.set_xlim((tmin, tmax))  # type: ignore
        self.ax.set_ylim((hmin, hmax))

        self.ax_right = self.ax.twinx()
        self.ax_right.set_ylim(self.ax.get_ylim())

        self.ax_top = self.ax.twiny()
        self.ax_top.set_xlim(self.ax.get_xlim())

        format_height_ticks(
            self.ax,
            show_tick_labels=self.show_height_left,
            show_units=self.show_height_left,
            label="Height" if self.show_height_left else "",
        )
        format_height_ticks(
            self.ax_right,
            show_tick_labels=self.show_height_right,
            show_units=self.show_height_right,
            label="Height" if self.show_height_right else "",
        )

        format_along_track_axis(
            self.ax,
            self.ax_style_bottom,
            time,
            tmin,
            tmax,
            tmin_original,
            tmax_original,
            longitude,
            latitude,
            num_ticks=self.num_ticks,
        )
        format_along_track_axis(
            self.ax_top,
            self.ax_style_top,
            time,
            tmin,
            tmax,
            tmin_original,
            tmax_original,
            longitude,
            latitude,
            num_ticks=self.num_ticks,
        )
        return self

    def plot(
        self,
        profiles: ProfileData | None = None,
        *,
        values: NDArray | None = None,
        time: NDArray | None = None,
        height: NDArray | None = None,
        latitude: NDArray | None = None,
        longitude: NDArray | None = None,
        values_temperature: NDArray | None = None,
        # Common args for wrappers
        value_range: ValueRangeLike | None = None,
        log_scale: bool | None = None,
        norm: Normalize | None = None,
        time_range: TimeRangeLike | None = None,
        height_range: DistanceRangeLike | None = (0, 40e3),
        label: str | None = None,
        units: str | None = None,
        cmap: str | Colormap | None = None,
        colorbar: bool = True,
        colorbar_ticks: ArrayLike | None = None,
        colorbar_tick_labels: ArrayLike | None = None,
        colorbar_position: str | Literal["left", "right", "top", "bottom"] = "right",
        colorbar_alignment: str | Literal["left", "center", "right"] = "center",
        colorbar_width: float = DEFAULT_COLORBAR_WIDTH,
        colorbar_spacing: float = 0.2,
        colorbar_length_ratio: float | str = "100%",
        colorbar_label_outside: bool = True,
        colorbar_ticks_outside: bool = True,
        colorbar_ticks_both: bool = False,
        rolling_mean: int | None = None,
        selection_time_range: TimeRangeLike | None = None,
        selection_color: str | None = Color("ec:earthcare"),
        selection_linestyle: str | None = "dashed",
        selection_linewidth: float | int | None = 2.5,
        selection_highlight: bool = False,
        selection_highlight_inverted: bool = True,
        selection_highlight_color: str | None = Color("white"),
        selection_highlight_alpha: float = 0.5,
        selection_max_time_margin: (
            TimedeltaLike | Sequence[TimedeltaLike] | None
        ) = None,
        ax_style_top: AlongTrackAxisStyle | str | None = None,
        ax_style_bottom: AlongTrackAxisStyle | str | None = None,
        show_temperature: bool = False,
        mode: Literal["exact", "fast"] | None = None,
        min_num_profiles: int = 1000,
        mark_profiles_at: Sequence[TimestampLike] | None = None,
        mark_profiles_at_color: (
            str | Color | Sequence[str | Color | None] | None
        ) = None,
        mark_profiles_at_linestyle: str | Sequence[str] = "solid",
        mark_profiles_at_linewidth: float | Sequence[float] = 2.5,
        label_length: int = 40,
        **kwargs,
    ) -> "CurtainFigure":
        # Parse colors
        selection_color = Color.from_optional(selection_color)
        selection_highlight_color = Color.from_optional(selection_highlight_color)

        _mark_profiles_at_color: list[Color | None] = []
        _mark_profiles_at_linestyle: list[str] = []
        _mark_profiles_at_linewidth: list[float] = []
        if isinstance(mark_profiles_at, (Sequence, np.ndarray)):
            if mark_profiles_at_color is None:
                _mark_profiles_at_color = [selection_color] * len(mark_profiles_at)
            elif isinstance(mark_profiles_at_color, (str, Color)):
                _mark_profiles_at_color = [
                    Color.from_optional(mark_profiles_at_color)
                ] * len(mark_profiles_at)
            elif len(mark_profiles_at_color) != len(mark_profiles_at):
                raise ValueError(
                    f"length of mark_profiles_at_color ({len(mark_profiles_at_color)}) must be same as length of mark_profiles_at ({len(mark_profiles_at)})"
                )
            else:
                _mark_profiles_at_color = [
                    Color.from_optional(c) for c in mark_profiles_at_color
                ]

            if isinstance(mark_profiles_at_linestyle, str):
                _mark_profiles_at_linestyle = [mark_profiles_at_linestyle] * len(
                    mark_profiles_at
                )
            elif len(mark_profiles_at_linestyle) != len(mark_profiles_at):
                raise ValueError(
                    f"length of mark_profiles_at_linestyle ({len(mark_profiles_at_linestyle)}) must be same as length of mark_profiles_at ({len(mark_profiles_at)})"
                )
            else:
                _mark_profiles_at_linestyle = [ls for ls in mark_profiles_at_linestyle]

            if isinstance(mark_profiles_at_linewidth, (int, float)):
                _mark_profiles_at_linewidth = [mark_profiles_at_linewidth] * len(
                    mark_profiles_at
                )
            elif len(mark_profiles_at_linewidth) != len(mark_profiles_at):
                raise ValueError(
                    f"length of mark_profiles_at_linewidth ({len(mark_profiles_at_linewidth)}) must be same as length of mark_profiles_at ({len(mark_profiles_at)})"
                )
            else:
                _mark_profiles_at_linewidth = [lw for lw in mark_profiles_at_linewidth]

        if mode in ["exact", "fast"]:
            self.mode = mode

        if isinstance(min_num_profiles, int):
            self.min_num_profiles = min_num_profiles

        if isinstance(value_range, Sequence):
            if len(value_range) != 2:
                raise ValueError(
                    f"invalid `value_range`: {value_range}, expecting (vmin, vmax)"
                )
        else:
            value_range = (None, None)

        cmap = get_cmap(cmap)

        if cmap.categorical:
            norm = cmap.norm
        if isinstance(norm, Normalize):
            if log_scale == True and not isinstance(norm, LogNorm):
                norm = LogNorm(norm.vmin, norm.vmax)
            elif log_scale == False and isinstance(norm, LogNorm):
                norm = Normalize(norm.vmin, norm.vmax)
            if value_range[0] is not None:
                norm.vmin = value_range[0]  # type: ignore
            if value_range[1] is not None:
                norm.vmax = value_range[1]  # type: ignore
        else:
            if log_scale == True:
                norm = LogNorm(value_range[0], value_range[1])  # type: ignore
            else:
                norm = Normalize(value_range[0], value_range[1])  # type: ignore
        value_range = (norm.vmin, norm.vmax)

        if isinstance(profiles, ProfileData):
            values = profiles.values
            time = profiles.time
            height = profiles.height
            latitude = profiles.latitude
            longitude = profiles.longitude
            label = profiles.label
            units = profiles.units
        elif values is None or time is None or height is None:
            raise ValueError(
                "Missing required arguments. Provide either a `VerticalProfiles` or all of `values`, `time`, and `height`"
            )

        values = np.asarray(values)
        time = np.asarray(time)
        height = np.asarray(height)
        if latitude is not None:
            latitude = np.asarray(latitude)
        if longitude is not None:
            longitude = np.asarray(longitude)

        # Validate inputs
        if len(values.shape) != 2:
            raise ValueError(
                f"Values must be either 2D, but has {len(values.shape)} dimensions (shape={values.shape})"
            )

        validate_profile_data_dimensions(
            values=values,
            time=time,
            height=height,
            latitude=latitude,
            longitude=longitude,
        )

        vp = ProfileData(
            values=values,
            time=time,
            height=height,
            latitude=latitude,
            longitude=longitude,
            label=label,
            units=units,
        )

        tmin_original = vp.time[0]
        tmax_original = vp.time[-1]
        hmin_original = vp.height[0]
        hmax_original = vp.height[-1]

        if selection_time_range is not None:
            if selection_max_time_margin is not None and not (
                isinstance(selection_max_time_margin, (Sequence, np.ndarray))
                and not isinstance(selection_max_time_margin, str)
            ):
                selection_max_time_margin = (
                    to_timedelta(selection_max_time_margin),
                    to_timedelta(selection_max_time_margin),
                )

            self.selection_time_range = validate_time_range(selection_time_range)
            _selection_max_time_margin: tuple[pd.Timedelta, pd.Timedelta] | None = None
            if isinstance(selection_max_time_margin, (Sequence, np.ndarray)):
                _selection_max_time_margin = (
                    to_timedelta(selection_max_time_margin[0]),
                    to_timedelta(selection_max_time_margin[1]),
                )
            elif selection_max_time_margin is not None:
                _selection_max_time_margin = (
                    to_timedelta(selection_max_time_margin),
                    to_timedelta(selection_max_time_margin),
                )

            if _selection_max_time_margin is not None:
                time_range = [
                    np.max(
                        [
                            vp.time[0],
                            (
                                self.selection_time_range[0]
                                - _selection_max_time_margin[0]
                            ).to_datetime64(),
                        ]
                    ),
                    np.min(
                        [
                            vp.time[-1],
                            (
                                self.selection_time_range[1]
                                + _selection_max_time_margin[1]
                            ).to_datetime64(),
                        ]
                    ),
                ]

        if isinstance(rolling_mean, int):
            vp = vp.rolling_mean(rolling_mean)

        if height_range is not None:
            if isinstance(height_range, Iterable) and len(height_range) == 2:
                for i in [0, -1]:
                    height_range = list(height_range)
                    if height_range[i] is None:
                        height_range[i] = [
                            np.nanmin(vp.height),
                            np.nanmax(vp.height),
                        ][i]
                    height_range = tuple(height_range)
            vp = vp.select_height_range(height_range, pad_idx=1)
        else:
            height_range = (
                np.nanmin(vp.height),
                np.nanmax(vp.height),
            )

        if time_range is not None:
            if isinstance(time_range, Iterable) and len(time_range) == 2:
                for i in [0, -1]:
                    time_range = list(time_range)
                    if time_range[i] is None:
                        time_range[i] = vp.time[i]
                    time_range = tuple(time_range)  # type: ignore
            pad_idxs = 0
            if isinstance(rolling_mean, int):
                pad_idxs = rolling_mean
            vp = vp.select_time_range(time_range, pad_idxs=pad_idxs)

        # else:
        time_range = (vp.time[0], vp.time[-1])
        tmin = np.datetime64(time_range[0])
        tmax = np.datetime64(time_range[1])

        hmin = height_range[0]
        hmax = height_range[1]

        time_non_coarsened = vp.time
        lat_non_coarsened = vp.latitude
        lon_non_coarsened = vp.longitude

        if (
            self.mode == "fast"
            and not cmap.categorical
            and not np.issubdtype(vp.values.dtype, np.integer)
        ):
            n = vp.time.shape[0] // self.min_num_profiles
            if n > 1:
                vp = vp.coarsen_mean(n)

        time_grid, height_grid = create_time_height_grids(
            values=vp.values, time=vp.time, height=vp.height
        )

        mesh = self.ax.pcolormesh(
            time_grid,
            height_grid[:, ::-1],
            vp.values[:, ::-1],
            cmap=cmap,
            norm=norm,
            shading="auto",
            linewidth=0,
            rasterized=True,
            **kwargs,
        )
        mesh.set_edgecolor("face")

        if colorbar:
            cb_kwargs = dict(
                label=format_var_label(vp.label, vp.units, label_len=label_length),
                position=colorbar_position,
                alignment=colorbar_alignment,
                width=colorbar_width,
                spacing=colorbar_spacing,
                length_ratio=colorbar_length_ratio,
                label_outside=colorbar_label_outside,
                ticks_outside=colorbar_ticks_outside,
                ticks_both=colorbar_ticks_both,
            )
            if cmap.categorical:
                self.colorbar = add_colorbar(
                    fig=self.fig,
                    ax=self.ax,
                    data=mesh,
                    cmap=cmap,
                    **cb_kwargs,  # type: ignore
                )
            else:
                self.colorbar = add_colorbar(
                    fig=self.fig,
                    ax=self.ax,
                    data=mesh,
                    ticks=colorbar_ticks,
                    tick_labels=colorbar_tick_labels,
                    **cb_kwargs,  # type: ignore
                )

        if selection_time_range is not None:
            if selection_highlight:
                if selection_highlight_inverted:
                    self.ax.axvspan(
                        tmin,  # type: ignore
                        self.selection_time_range[0],  # type: ignore
                        color=selection_highlight_color,
                        alpha=selection_highlight_alpha,
                    )
                    self.ax.axvspan(
                        self.selection_time_range[1],  # type: ignore
                        tmax,  # type: ignore
                        color=selection_highlight_color,
                        alpha=selection_highlight_alpha,
                    )
                else:
                    self.ax.axvspan(
                        self.selection_time_range[0],  # type: ignore
                        self.selection_time_range[1],  # type: ignore
                        color=selection_highlight_color,
                        alpha=selection_highlight_alpha,
                    )

            for t in self.selection_time_range:  # type: ignore
                self.ax.axvline(
                    x=t,  # type: ignore
                    color=selection_color,
                    linestyle=selection_linestyle,
                    linewidth=selection_linewidth,
                    zorder=20,
                )

        _latitude = None
        if isinstance(vp.latitude, (np.ndarray)) and isinstance(
            lat_non_coarsened, (np.ndarray)
        ):
            _latitude = np.concatenate(
                ([lat_non_coarsened[0]], vp.latitude, [lat_non_coarsened[-1]])
            )

        _longitude = None
        if isinstance(vp.longitude, (np.ndarray)) and isinstance(
            lon_non_coarsened, (np.ndarray)
        ):
            _longitude = np.concatenate(
                ([lon_non_coarsened[0]], vp.longitude, [lon_non_coarsened[-1]])
            )

        self._set_axes(
            tmin=tmin,
            tmax=tmax,
            hmin=hmin,  # type: ignore
            hmax=hmax,  # type: ignore
            time=np.concatenate(
                ([time_non_coarsened[0]], vp.time, [time_non_coarsened[-1]])
            ),
            tmin_original=tmin_original,
            tmax_original=tmax_original,
            latitude=_latitude,
            longitude=_longitude,
            ax_style_top=ax_style_top,
            ax_style_bottom=ax_style_bottom,
        )

        if show_temperature and values_temperature is not None:
            self.plot_contour(
                values=values_temperature,
                time=time,
                height=height,
            )

        if mark_profiles_at is not None:
            for i, t in enumerate(to_timestamps(mark_profiles_at)):
                self.ax.axvline(
                    t,  # type: ignore
                    color=_mark_profiles_at_color[i],
                    linestyle=_mark_profiles_at_linestyle[i],
                    linewidth=_mark_profiles_at_linewidth[i],
                    zorder=20,
                )  # type: ignore

        return self

    def ecplot(
        self,
        ds: xr.Dataset,
        var: str,
        *,
        time_var: str = TIME_VAR,
        height_var: str = HEIGHT_VAR,
        lat_var: str = TRACK_LAT_VAR,
        lon_var: str = TRACK_LON_VAR,
        temperature_var: str = TEMP_CELSIUS_VAR,
        along_track_dim: str = ALONG_TRACK_DIM,
        values: NDArray | None = None,
        time: NDArray | None = None,
        height: NDArray | None = None,
        latitude: NDArray | None = None,
        longitude: NDArray | None = None,
        values_temperature: NDArray | None = None,
        site: str | GroundSite | None = None,
        radius_km: float = 100.0,
        mark_closest_profile: bool = False,
        show_info: bool = True,
        show_info_orbit_and_frame: bool = True,
        show_info_file_type: bool = True,
        show_info_baseline: bool = True,
        info_text_orbit_and_frame: str | None = None,
        info_text_file_type: str | None = None,
        info_text_baseline: str | None = None,
        show_radius: bool = True,
        info_text_loc: str | None = None,
        # Common args for wrappers
        value_range: ValueRangeLike | Literal["default"] | None = "default",
        log_scale: bool | None = None,
        norm: Normalize | None = None,
        time_range: TimeRangeLike | None = None,
        height_range: DistanceRangeLike | None = (0, 40e3),
        label: str | None = None,
        units: str | None = None,
        cmap: str | Colormap | None = None,
        colorbar: bool = True,
        colorbar_ticks: ArrayLike | None = None,
        colorbar_tick_labels: ArrayLike | None = None,
        colorbar_position: str | Literal["left", "right", "top", "bottom"] = "right",
        colorbar_alignment: str | Literal["left", "center", "right"] = "center",
        colorbar_width: float = DEFAULT_COLORBAR_WIDTH,
        colorbar_spacing: float = 0.2,
        colorbar_length_ratio: float | str = "100%",
        colorbar_label_outside: bool = True,
        colorbar_ticks_outside: bool = True,
        colorbar_ticks_both: bool = False,
        rolling_mean: int | None = None,
        selection_time_range: TimeRangeLike | None = None,
        selection_color: str | None = Color("ec:earthcare"),
        selection_linestyle: str | None = "dashed",
        selection_linewidth: float | int | None = 2.5,
        selection_highlight: bool = False,
        selection_highlight_inverted: bool = True,
        selection_highlight_color: str | None = Color("white"),
        selection_highlight_alpha: float = 0.5,
        selection_max_time_margin: (
            TimedeltaLike | Sequence[TimedeltaLike] | None
        ) = None,
        ax_style_top: AlongTrackAxisStyle | str | None = None,
        ax_style_bottom: AlongTrackAxisStyle | str | None = None,
        show_temperature: bool = False,
        mode: Literal["exact", "fast"] | None = None,
        min_num_profiles: int = 5000,
        mark_profiles_at: Sequence[TimestampLike] | None = None,
        mark_profiles_at_color: (
            str | Color | Sequence[str | Color | None] | None
        ) = None,
        mark_profiles_at_linestyle: str | Sequence[str] = "solid",
        mark_profiles_at_linewidth: float | Sequence[float] = 2.5,
        label_length: int = 40,
        **kwargs,
    ) -> "CurtainFigure":
        """Plot a vertical curtain (i.e. cross-section) of a variable along the satellite track a EarthCARE dataset.

        This method collections all required data from a EarthCARE `xarray.dataset`, such as time, height, latitude and longitude.
        It supports various forms of customization through the use of arguments listed below.

        Args:
            ds (xr.Dataset): The EarthCARE dataset from with data will be plotted.
            var (str): Name of the variable to plot.
            time_var (str, optional): Name of the time variable. Defaults to TIME_VAR.
            height_var (str, optional): Name of the height variable. Defaults to HEIGHT_VAR.
            lat_var (str, optional): Name of the latitude variable. Defaults to TRACK_LAT_VAR.
            lon_var (str, optional): Name of the longitude variable. Defaults to TRACK_LON_VAR.
            temperature_var (str, optional): Name of the temperature variable; ignored if `show_temperature` is set to False. Defaults to TEMP_CELSIUS_VAR.
            along_track_dim (str, optional): Dimension name representing the along-track direction. Defaults to ALONG_TRACK_DIM.
            values (NDArray | None, optional): Data values to be used instead of values found in the `var` variable of the dataset. Defaults to None.
            time (NDArray | None, optional): Time values to be used instead of values found in the `time_var` variable of the dataset. Defaults to None.
            height (NDArray | None, optional): Height values to be used instead of values found in the `height_var` variable of the dataset. Defaults to None.
            latitude (NDArray | None, optional): Latitude values to be used instead of values found in the `lat_var` variable of the dataset. Defaults to None.
            longitude (NDArray | None, optional): Longitude values to be used instead of values found in the `lon_var` variable of the dataset. Defaults to None.
            values_temperature (NDArray | None, optional): Temperature values to be used instead of values found in the `temperature_var` variable of the dataset. Defaults to None.
            site (str | GroundSite | None, optional): Highlights data within `radius_km` of a ground site (given either as a `GroundSite` object or name string); ignored if not set. Defaults to None.
            radius_km (float, optional): Radius around the ground site to highlight data from; ignored if `site` not set. Defaults to 100.0.
            mark_closest_profile (bool, optional): Mark the closest profile to the ground site in the plot; ignored if `site` not set. Defaults to False.
            show_info (bool, optional): If True, show text on the plot containing EarthCARE frame and baseline info. Defaults to True.
            info_text_loc (str | None, optional): Place info text at a specific location of the plot, e.g. "upper right" or "lower left". Defaults to None.
            value_range (ValueRangeLike | None, optional): Min and max range for the variable values. Defaults to None.
            log_scale (bool | None, optional): Whether to apply a logarithmic color scale. Defaults to None.
            norm (Normalize | None, optional): Matplotlib norm to use for color scaling. Defaults to None.
            time_range (TimeRangeLike | None, optional): Time range to restrict the data for plotting. Defaults to None.
            height_range (DistanceRangeLike | None, optional): Height range to restrict the data for plotting. Defaults to (0, 40e3).
            label (str | None, optional): Label to use for colorbar. Defaults to None.
            units (str | None, optional): Units of the variable to show in the colorbar label. Defaults to None.
            cmap (str | Colormap | None, optional): Colormap to use for plotting. Defaults to None.
            colorbar (bool, optional): Whether to display a colorbar. Defaults to True.
            colorbar_ticks (ArrayLike | None, optional): Custom tick values for the colorbar. Defaults to None.
            colorbar_tick_labels (ArrayLike | None, optional): Custom labels for the colorbar ticks. Defaults to None.
            rolling_mean (int | None, optional): Apply rolling mean along time axis with this window size. Defaults to None.
            selection_time_range (TimeRangeLike | None, optional): Time range to highlight as a selection; ignored if `site` is set. Defaults to None.
            selection_color (_type_, optional): Color for the selection range marker lines. Defaults to Color("ec:earthcare").
            selection_linestyle (str | None, optional): Line style for selection range markers. Defaults to "dashed".
            selection_linewidth (float | int | None, optional): Line width for selection range markers. Defaults to 2.5.
            selection_highlight (bool, optional): Whether to highlight the selection region by shading outside or inside areas. Defaults to False.
            selection_highlight_inverted (bool, optional): If True and `selection_highlight` is also set to True, areas outside the selection are shaded. Defaults to True.
            selection_highlight_color (str | None, optional): If True and `selection_highlight` is also set to True, sets color used for shading selected outside or inside areas. Defaults to Color("white").
            selection_highlight_alpha (float, optional): If True and `selection_highlight` is also set to True, sets transparency used for shading selected outside or inside areas.. Defaults to 0.5.
            selection_max_time_margin (TimedeltaLike | Sequence[TimedeltaLike], optional): Zooms the time axis to a given maximum time from a selected time area. Defaults to None.
            ax_style_top (AlongTrackAxisStyle | str | None, optional): Style for the top axis (e.g., geo, lat, lon, distance, time, utc, lst, none). Defaults to None.
            ax_style_bottom (AlongTrackAxisStyle | str | None, optional): Style for the bottom axis (e.g., geo, lat, lon, distance, time, utc, lst, none). Defaults to None.
            show_temperature (bool, optional): Whether to overlay temperature as contours; requires either `values_temperature` or `temperature_var`. Defaults to False.
            mode (Literal["exact", "fast"] | None, optional): Overwrites the curtain plotting mode. Use "fast" to speed up plotting by coarsening data to at least `min_num_profiles`; "exact" plots full resolution. Defaults to None.
            min_num_profiles (int, optional): Overwrites the minimum number of profiles to keep when using "fast" mode. Defaults to 1000.
            mark_profiles_at (Sequence[TimestampLike] | None, optional): Timestamps at which to mark vertical profiles. Defaults to None.

        Returns:
            CurtainFigure: The figure object containing the curtain plot.

        Example:
            ```python
            import earthcarekit as eck

            filepath = "path/to/mydata/ECA_EXAE_ATL_NOM_1B_20250606T132535Z_20250606T150730Z_05813D.h5"
            with eck.read_product(filepath) as ds:
                cf = eck.CurtainFigure()
                cf = cf.ecplot(ds, "mie_attenuated_backscatter", height_range=(0, 20e3))
            ```
        """

        # Collect all common args for wrapped plot function call
        local_args = locals()
        # Delete all args specific to this wrapper function
        del local_args["self"]
        del local_args["ds"]
        del local_args["var"]
        del local_args["time_var"]
        del local_args["height_var"]
        del local_args["lat_var"]
        del local_args["lon_var"]
        del local_args["temperature_var"]
        del local_args["along_track_dim"]
        del local_args["site"]
        del local_args["radius_km"]
        del local_args["show_info"]
        del local_args["show_info_orbit_and_frame"]
        del local_args["show_info_file_type"]
        del local_args["show_info_baseline"]
        del local_args["info_text_orbit_and_frame"]
        del local_args["info_text_file_type"]
        del local_args["info_text_baseline"]
        del local_args["show_radius"]
        del local_args["info_text_loc"]
        del local_args["mark_closest_profile"]
        # Delete kwargs to then merge it with the residual common args
        del local_args["kwargs"]
        all_args = {**local_args, **kwargs}

        warn_about_variable_limitations(var)

        if all_args["values"] is None:
            all_args["values"] = ds[var].values
        if all_args["time"] is None:
            all_args["time"] = ds[time_var].values
        if all_args["height"] is None:
            all_args["height"] = ds[height_var].values
        if all_args["latitude"] is None:
            all_args["latitude"] = ds[lat_var].values
        if all_args["longitude"] is None:
            all_args["longitude"] = ds[lon_var].values
        if all_args["values_temperature"] is None:
            if show_temperature == False:
                all_args["values_temperature"] = None
            elif ds.get(temperature_var, None) is None:
                warnings.warn(
                    f'No temperature variable called "{temperature_var}" found in given dataset.'
                )
                all_args["values_temperature"] = None
            else:
                all_args["values_temperature"] = ds[temperature_var].values

        # Set default values depending on variable name
        if label is None:
            all_args["label"] = (
                "Values" if not hasattr(ds[var], "long_name") else ds[var].long_name
            )
        if units is None:
            all_args["units"] = "-" if not hasattr(ds[var], "units") else ds[var].units
        if isinstance(value_range, str) and value_range == "default":
            value_range = None
            all_args["value_range"] = None
            if log_scale is None and norm is None:
                all_args["norm"] = get_default_norm(var, file_type=ds)
        if rolling_mean is None:
            all_args["rolling_mean"] = get_default_rolling_mean(var, file_type=ds)
        if cmap is None:
            all_args["cmap"] = get_default_cmap(var, file_type=ds)
        all_args["cmap"] = get_cmap(all_args["cmap"])

        if all_args["cmap"] == get_cmap("synergetic_tc"):
            self.colorbar_tick_scale = 0.8

        # Handle overpass
        _site: GroundSite | None = None
        if isinstance(site, GroundSite):
            _site = site
        elif isinstance(site, str):
            _site = get_ground_site(site)
        else:
            pass

        if isinstance(_site, GroundSite):
            info_overpass = get_overpass_info(
                ds,
                radius_km=radius_km,
                site=_site,
                time_var=time_var,
                lat_var=lat_var,
                lon_var=lon_var,
                along_track_dim=along_track_dim,
            )
            if show_radius:
                overpass_time_range = info_overpass.time_range
                all_args["selection_time_range"] = overpass_time_range
            else:
                mark_closest_profile = True
            if mark_closest_profile:
                _mark_profiles_at = all_args["mark_profiles_at"]
                _mark_profiles_at_color = all_args["mark_profiles_at_color"]
                _mark_profiles_at_linestyle = all_args["mark_profiles_at_linestyle"]
                _mark_profiles_at_linewidth = all_args["mark_profiles_at_linewidth"]
                if isinstance(_mark_profiles_at, (Sequence, np.ndarray)):
                    list(_mark_profiles_at).append(info_overpass.closest_time)
                    all_args["mark_profiles_at"] = _mark_profiles_at
                else:
                    all_args["mark_profiles_at"] = [info_overpass.closest_time]

                if not isinstance(_mark_profiles_at_color, str) and isinstance(
                    _mark_profiles_at_color, (Sequence, np.ndarray)
                ):
                    list(_mark_profiles_at_color).append("ec:earthcare")
                    all_args["mark_profiles_at_color"] = _mark_profiles_at_color

                if not isinstance(_mark_profiles_at_linestyle, str) and isinstance(
                    _mark_profiles_at_linestyle, (Sequence, np.ndarray)
                ):
                    list(_mark_profiles_at_linestyle).append("solid")
                    all_args["mark_profiles_at_linestyle"] = _mark_profiles_at_linestyle

                if isinstance(_mark_profiles_at_linewidth, (Sequence, np.ndarray)):
                    list(_mark_profiles_at_linewidth).append(2.5)
                    all_args["mark_profiles_at_linewidth"] = _mark_profiles_at_linewidth

                all_args["selection_linestyle"] = "none"
                all_args["selection_linewidth"] = 0.1
        self.plot(**all_args)

        self._set_info_text_loc(info_text_loc)
        if show_info:
            self.info_text = add_text_product_info(
                self.ax,
                ds,
                append_to=self.info_text,
                loc=self.info_text_loc,
                show_orbit_and_frame=show_info_orbit_and_frame,
                show_file_type=show_info_file_type,
                show_baseline=show_info_baseline,
                text_orbit_and_frame=info_text_orbit_and_frame,
                text_file_type=info_text_file_type,
                text_baseline=info_text_baseline,
            )

        return self

    def plot_height(
        self,
        height: NDArray,
        time: NDArray,
        linewidth: int | float | None = 1.5,
        linestyle: str | None = "solid",
        color: Color | str | None = None,
        alpha: float | None = 1.0,
        zorder: int | float | None = 2,
        marker: str | None = None,
        markersize: int | float | None = None,
        fill: bool = False,
        legend_label: str | None = None,
    ) -> "CurtainFigure":
        """Adds height line to the plot."""
        color = Color.from_optional(color)

        height = np.asarray(height)
        time = np.asarray(time)

        hnew, tnew = _convert_height_line_to_time_bin_step_function(height, time)

        fb: list = []
        if fill:
            _fb1 = self.ax.fill_between(
                tnew,
                hnew,
                y2=-5e3,
                color=color,
                alpha=alpha,
                zorder=zorder,
            )
            from matplotlib.patches import Patch

            # Proxy for the legend
            _fb2 = Patch(facecolor=color, alpha=alpha, linewidth=0.0)
            fb = [_fb1, _fb2]

        hl = self.ax.plot(
            tnew,
            hnew,
            linestyle=linestyle,
            linewidth=linewidth,
            marker=marker,
            markersize=markersize,
            color=color,
            alpha=alpha,
            zorder=zorder,
        )

        if isinstance(legend_label, str):
            self._legend_handles.append(tuple(hl + fb))
            self._legend_labels.append(legend_label)

        return self

    def ecplot_height(
        self,
        ds: xr.Dataset,
        var: str,
        time_var: str = TIME_VAR,
        linewidth: int | float | None = 1.5,
        linestyle: str | None = "none",
        color: Color | str | None = "black",
        zorder: int | float | None = 2.1,
        marker: str | None = "s",
        markersize: int | float | None = 1,
        show_info: bool = True,
        info_text_loc: str | None = None,
        legend_label: str | None = None,
    ) -> "CurtainFigure":
        """Adds height line to the plot."""
        height = ds[var].values
        time = ds[time_var].values
        self.plot_height(
            height=height,
            time=time,
            linewidth=linewidth,
            linestyle=linestyle,
            color=color,
            zorder=zorder,
            marker=marker,
            markersize=markersize,
            legend_label=legend_label,
        )

        self._set_info_text_loc(info_text_loc)
        if show_info:
            self.info_text = add_text_product_info(
                self.ax, ds, append_to=self.info_text, loc=self.info_text_loc
            )

        return self

    def plot_contour(
        self,
        values: NDArray,
        time: NDArray,
        height: NDArray,
        label_levels: list | NDArray | None = None,
        label_format: str | None = None,
        levels: list | NDArray | None = None,
        linewidths: int | float | list | NDArray | None = 1.5,
        linestyles: str | list | NDArray | None = "solid",
        colors: Color | str | list | NDArray | None = "black",
        zorder: int | float | None = 2,
    ) -> "CurtainFigure":
        """Adds contour lines to the plot."""
        values = np.asarray(values)
        time = np.asarray(time)
        height = np.asarray(height)

        if len(height.shape) == 2:
            height = height[0]

        if isinstance(colors, str):
            colors = Color.from_optional(colors)
        elif isinstance(colors, (Iterable, np.ndarray)):
            colors = [Color.from_optional(c) for c in colors]
        else:
            colors = Color.from_optional(colors)

        x = time
        y = height
        z = values.T

        if len(y.shape) == 2:
            y = y[len(y) // 2]

        if isinstance(colors, list):
            shade_color = Color.from_optional(colors[0])
        else:
            shade_color = Color.from_optional(colors)

        if isinstance(shade_color, Color):
            shade_color = shade_color.get_best_bw_contrast_color()

        linewidths2: int | float | np.ndarray
        if not isinstance(linewidths, (int, float, np.number, np.ndarray)):
            linewidths2 = np.array(linewidths) * 2.5
        else:
            linewidths2 = linewidths * 2.5

        cn2 = self.ax.contour(
            x,
            y,
            z,
            levels=levels,
            linewidths=linewidths2,
            colors=shade_color,
            alpha=0.5,
            linestyles="solid",
            zorder=zorder,
        )

        cn = self.ax.contour(
            x,
            y,
            z,
            levels=levels,
            linewidths=linewidths,
            colors=colors,
            linestyles=linestyles,
            zorder=zorder,
        )

        labels: Iterable[float]
        if label_levels:
            labels = [l for l in label_levels if l in cn.levels]
        else:
            labels = cn.levels

        cl = self.ax.clabel(
            cn,
            labels,  # type: ignore
            inline=True,
            fmt=label_format,
            fontsize="small",
            zorder=zorder,
        )

        for t in cn.labelTexts:
            add_shade_to_text(t, alpha=0.5)
            t.set_rotation(0)

        return self

    def plot_hatch(
        self,
        values: NDArray,
        time: NDArray,
        height: NDArray,
        value_range: tuple[float, float],
        hatch: str = "/////",
        linewidth: float = 1,
        linewidth_border: float = 0,
        color: ColorLike | None = "black",
        color_border: ColorLike | None = None,
        zorder: int | float | None = 2,
        legend_label: str | None = None,
    ) -> "CurtainFigure":
        """Adds hatched/filled areas to the plot."""
        values = np.asarray(values)
        time = np.asarray(time)
        height = np.asarray(height)

        if len(height.shape) == 2:
            height = height[0]

        color = Color.from_optional(color)
        color_border = Color.from_optional(color_border)

        cnf = self.ax.contourf(
            time,
            height,
            values.T,
            levels=[value_range[0], value_range[1]],
            colors=["none"],
            hatches=[hatch],
            zorder=zorder,
        )
        cnf.set_edgecolors(color)  # type: ignore
        cnf.set_hatch_linewidth(linewidth)

        color = Color(cnf.get_edgecolors()[0], is_normalized=True)  # type: ignore
        if color_border is None:
            color_border = color.hex
        cnf.set_color(color_border)  # type: ignore
        cnf.set_linewidth(linewidth_border)

        if isinstance(legend_label, str):
            from matplotlib.patches import Patch

            _facecolor = "none"
            if color.is_close_to_white():
                _facecolor = color.blend(0.7, "black").hex

            hatch_patch = Patch(
                linewidth=linewidth_border,
                facecolor=_facecolor,
                edgecolor=color.hex,
                hatch=hatch,
                label=legend_label,
            )

            self._legend_handles.append(hatch_patch)
            self._legend_labels.append(legend_label)

        return self

    def ecplot_hatch(
        self,
        ds: xr.Dataset,
        var: str,
        value_range: tuple[float, float],
        time_var: str = TIME_VAR,
        height_var: str = HEIGHT_VAR,
        hatch: str = "/////",
        linewidth: float = 1,
        linewidth_border: float = 0,
        color: ColorLike | None = "black",
        color_border: ColorLike | None = None,
        zorder: int | float | None = 2,
        legend_label: str | None = None,
    ) -> "CurtainFigure":
        """Adds hatched/filled areas to the plot."""
        height = ds[height_var].values
        time = ds[time_var].values
        values = ds[var].values

        return self.plot_hatch(
            values=values,
            time=time,
            height=height,
            value_range=value_range,
            hatch=hatch,
            linewidth=linewidth,
            linewidth_border=linewidth_border,
            color=color,
            color_border=color_border,
            zorder=zorder,
            legend_label=legend_label,
        )

    def ecplot_hatch_attenuated(
        self,
        ds: xr.Dataset,
        var: str = "simple_classification",
        value_range: tuple[float, float] = (-1.5, -0.5),
        **kwargs,
    ) -> "CurtainFigure":
        """Adds hatched area where ATLID "simple_classification" shows "attenuated" (-1)."""
        return self.ecplot_hatch(
            ds=ds,
            var=var,
            value_range=value_range,
            **kwargs,
        )

    def ecplot_contour(
        self,
        ds: xr.Dataset,
        var: str,
        time_var: str = TIME_VAR,
        height_var: str = HEIGHT_VAR,
        levels: list | NDArray | None = None,
        label_format: str | None = None,
        label_levels: list | NDArray | None = None,
        linewidths: int | float | list | NDArray | None = 1.5,
        linestyles: str | list | NDArray | None = "solid",
        colors: Color | str | list | NDArray | None = "black",
        zorder: float | int = 3,
    ) -> "CurtainFigure":
        """Adds contour lines to the plot."""
        values = ds[var].values
        time = ds[time_var].values
        height = ds[height_var].values
        tp = ProfileData(values=values, time=time, height=height)
        self.plot_contour(
            values=tp.values,
            time=tp.time,
            height=tp.height,
            levels=levels,
            linewidths=linewidths,
            linestyles=linestyles,
            colors=colors,
            zorder=zorder,
            label_format=label_format,
            label_levels=label_levels,
        )
        return self

    def ecplot_temperature(
        self,
        ds: xr.Dataset,
        var: str = TEMP_CELSIUS_VAR,
        label_format: str | None = "$%.0f^{\circ}$C",
        label_levels: list | NDArray | None = [-80, -40, 0],
        levels=[
            -80,
            -70,
            -60,
            -50,
            -40,
            -30,
            -20,
            -10,
            0,
            10,
            20,
        ],
        linewidths=[
            0.75,  # -80
            0.25,  # -70
            0.50,  # -60
            0.50,  # -50
            0.75,  # -40
            0.50,  # -30
            0.75,  # -20
            0.50,  # -10
            1.00,  # 0
            0.50,  # 10
            0.75,  # 20
        ],
        linestyles=[
            "dashed",  # -80
            "dashed",  # -70
            "dashed",  # -60
            "dashed",  # -50
            "dashed",  # -40
            "dashed",  # -30
            "dashed",  # -20
            "dashed",  # -10
            "solid",  # 0
            "solid",  # 10
            "solid",  # 20
        ],
        colors="black",
        **kwargs,
    ) -> "CurtainFigure":
        """Adds temperature contour lines to the plot."""
        return self.ecplot_contour(
            ds=ds,
            var=var,
            label_format=label_format,
            levels=levels,
            label_levels=label_levels,
            linewidths=linewidths,
            linestyles=linestyles,
            colors=colors,
            **kwargs,
        )

    def ecplot_pressure(
        self,
        ds: xr.Dataset,
        var: str = PRESSURE_VAR,
        time_var: str = TIME_VAR,
        height_var: str = HEIGHT_VAR,
        label_format: str | None = r"%d hPa",
        **kwargs,
    ) -> "CurtainFigure":
        """Adds pressure contour lines to the plot."""
        values = ds[var].values / 100.0
        time = ds[time_var].values
        height = ds[height_var].values
        return self.plot_contour(
            values=values,
            time=time,
            height=height,
            label_format=label_format,
            **kwargs,
        )

    def ecplot_elevation(
        self,
        ds: xr.Dataset,
        var: str = ELEVATION_VAR,
        time_var: str = TIME_VAR,
        land_flag_var: str = LAND_FLAG_VAR,
        color: Color | str | None = "ec:land",
        color_water: Color | str | None = "ec:water",
        legend_label: str | None = None,
        legend_label_water: str | None = None,
    ) -> "CurtainFigure":
        """Adds filled elevation/surface area to the plot."""
        height = ds[var].copy().values
        time = ds[time_var].copy().values

        kwargs = dict(
            linewidth=0,
            linestyle="none",
            marker="none",
            markersize=0,
            fill=True,
            zorder=10,
        )

        is_water = land_flag_var in ds.variables

        if is_water:
            land_flag = ds[land_flag_var].copy().values == 1
            height_water = height.copy()
            height_water[land_flag] = np.nan
            height[~land_flag] = np.nan

        self.plot_height(
            height=height,
            time=time,
            color=color,
            legend_label=legend_label,
            **kwargs,  # type: ignore
        )

        if is_water:
            self.plot_height(
                height=height_water,
                time=time,
                color=color_water,
                legend_label=legend_label_water,
                **kwargs,  # type: ignore
            )

        return self

    def ecplot_tropopause(
        self,
        ds: xr.Dataset,
        var: str = TROPOPAUSE_VAR,
        time_var: str = TIME_VAR,
        color: Color | str | None = "ec:tropopause",
        linewidth: float = 2,
        linestyle: str = "solid",
        legend_label: str | None = None,
    ) -> "CurtainFigure":
        """Adds tropopause line to the plot."""
        height = ds[var].values
        time = ds[time_var].values
        self.plot_height(
            height=height,
            time=time,
            linewidth=linewidth,
            linestyle=linestyle,
            color=color,
            marker="none",
            markersize=0,
            fill=False,
            zorder=12,
            legend_label=legend_label,
        )

        return self

    def to_texture(self) -> "CurtainFigure":
        """Convert the figure to a texture by removing all axis ticks, labels, annotations, and text."""
        # Remove anchored text and other artist text objects
        for artist in reversed(self.ax.artists):
            if isinstance(artist, (Text, AnchoredOffsetbox)):
                artist.remove()

        # Completely remove axis ticks and labels
        self.ax.axis("off")

        if self.ax_top:
            self.ax_top.axis("off")

        if self.ax_right:
            self.ax_right.axis("off")

        # Remove white frame around figure
        self.fig.subplots_adjust(left=0, right=1, bottom=0, top=1)

        # Remove colorbar
        if self.colorbar:
            self.colorbar.remove()
            self.colorbar = None

        # Remove legend
        if self.legend:
            self.legend.remove()
            self.legend = None

        return self

    def invert_xaxis(self) -> "CurtainFigure":
        """Invert the x-axis."""
        self.ax.invert_xaxis()
        if self.ax_top:
            self.ax_top.invert_xaxis()
        return self

    def invert_yaxis(self) -> "CurtainFigure":
        """Invert the y-axis."""
        self.ax.invert_yaxis()
        if self.ax_right:
            self.ax_right.invert_yaxis()
        return self

    def show_legend(
        self,
        loc: str = "upper left",
        markerscale: float = 1.5,
        frameon: bool = True,
        facecolor: ColorLike = "white",
        edgecolor: ColorLike = "black",
        framealpha: float = 0.8,
        edgewidth: float = 1.5,
        fancybox: bool = False,
        handlelength: float = 0.7,
        handletextpad: float = 0.5,
        borderaxespad: float = 0,
        ncols: int = 8,
        textcolor: ColorLike = "black",
        textweight: int | str = "normal",
        textshadealpha: float = 0.0,
        textshadewidth: float = 3.0,
        textshadecolor: ColorLike = "white",
        **kwargs,
    ) -> "CurtainFigure":
        from matplotlib.legend_handler import HandlerTuple

        facecolor = Color(facecolor)
        edgecolor = Color(edgecolor)
        textcolor = Color(textcolor)
        textshadecolor = Color(textshadecolor)

        if len(self._legend_handles) > 0:
            _ax = self.ax_right or self.ax
            self.legend = _ax.legend(
                self._legend_handles,
                self._legend_labels,
                loc=loc,
                markerscale=markerscale,
                frameon=frameon,
                facecolor=facecolor,
                edgecolor=edgecolor,
                framealpha=framealpha,
                fancybox=fancybox,
                handlelength=handlelength,
                handletextpad=handletextpad,
                borderaxespad=borderaxespad,
                ncols=ncols,
                handler_map={tuple: HandlerTuple(ndivide=1)},
                **kwargs,
            )
            self.legend.get_frame().set_linewidth(edgewidth)
            for text in self.legend.get_texts():
                text.set_color(textcolor)
                text.set_fontweight(textweight)

                if textshadealpha > 0:
                    text = add_shade_to_text(
                        text,
                        alpha=textshadealpha,
                        linewidth=textshadewidth,
                        color=textshadecolor,
                    )
        return self

    def set_colorbar_tick_scale(
        self,
        multiplier: float | None = None,
        fontsize: float | str | None = None,
    ) -> "CurtainFigure":
        _cb = self.colorbar
        cb: Colorbar
        if isinstance(_cb, Colorbar):
            cb = _cb
        else:
            return self

        if fontsize is not None:
            cb.ax.tick_params(labelsize=fontsize)
            return self

        if multiplier is not None:
            tls = cb.ax.yaxis.get_ticklabels()
            if len(tls) == 0:
                tls = cb.ax.xaxis.get_ticklabels()
            if len(tls) == 0:
                return self
            _fontsize = tls[0].get_fontsize()
            if isinstance(_fontsize, str):
                from matplotlib import font_manager

                fp = font_manager.FontProperties(size=_fontsize)
                _fontsize = fp.get_size_in_points()
            cb.ax.tick_params(labelsize=_fontsize * multiplier)
        return self

    def show(self) -> None:
        import IPython
        import matplotlib.pyplot as plt
        from IPython.display import display

        if IPython.get_ipython() is not None:
            display(self.fig)
        else:
            plt.show()

    def save(
        self,
        filename: str = "",
        filepath: str | None = None,
        ds: xr.Dataset | None = None,
        ds_filepath: str | None = None,
        dpi: float | Literal["figure"] = "figure",
        orbit_and_frame: str | None = None,
        utc_timestamp: TimestampLike | None = None,
        use_utc_creation_timestamp: bool = False,
        site_name: str | None = None,
        hmax: int | float | None = None,
        radius: int | float | None = None,
        extra: str | None = None,
        transparent_outside: bool = False,
        verbose: bool = True,
        print_prefix: str = "",
        create_dirs: bool = False,
        transparent_background: bool = False,
        resolution: str | None = None,
        **kwargs,
    ) -> None:
        """
        Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

        Args:
            figure (Figure | HasFigure): A figure object (`matplotlib.figure.Figure`) or objects exposing a `.fig` attribute containing a figure (e.g., `CurtainFigure`).
            filename (str, optional): The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.
            filepath (str | None, optional): The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.
            ds (xr.Dataset | None, optional): A EarthCARE dataset from which metadata will be taken. Defaults to None.
            ds_filepath (str | None, optional): A path to a EarthCARE product from which metadata will be taken. Defaults to None.
            pad (float, optional): Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.
            dpi (float | 'figure', optional): The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.
            orbit_and_frame (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            utc_timestamp (TimestampLike | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            use_utc_creation_timestamp (bool, optional): Whether the time of image creation should be included in the file name. Defaults to False.
            site_name (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            hmax (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            radius (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            resolution (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            extra (str | None, optional): A custom string to be included in the file name. Defaults to None.
            transparent_outside (bool, optional): Whether the area outside figures should be transparent. Defaults to False.
            verbose (bool, optional): Whether the progress of image creation should be printed to the console. Defaults to True.
            print_prefix (str, optional): A prefix string to all console messages. Defaults to "".
            create_dirs (bool, optional): Whether images should be saved in a folder structure based on provided metadata. Defaults to False.
            transparent_background (bool, optional): Whether the background inside and outside of figures should be transparent. Defaults to False.
            **kwargs (dict[str, Any]): Keyword arguments passed to wrapped function call of `matplotlib.pyplot.savefig`.
        """
        save_plot(
            fig=self.fig,
            filename=filename,
            filepath=filepath,
            ds=ds,
            ds_filepath=ds_filepath,
            dpi=dpi,
            orbit_and_frame=orbit_and_frame,
            utc_timestamp=utc_timestamp,
            use_utc_creation_timestamp=use_utc_creation_timestamp,
            site_name=site_name,
            hmax=hmax,
            radius=radius,
            extra=extra,
            transparent_outside=transparent_outside,
            verbose=verbose,
            print_prefix=print_prefix,
            create_dirs=create_dirs,
            transparent_background=transparent_background,
            resolution=resolution,
            **kwargs,
        )

ecplot

ecplot(
    ds,
    var,
    *,
    time_var=TIME_VAR,
    height_var=HEIGHT_VAR,
    lat_var=TRACK_LAT_VAR,
    lon_var=TRACK_LON_VAR,
    temperature_var=TEMP_CELSIUS_VAR,
    along_track_dim=ALONG_TRACK_DIM,
    values=None,
    time=None,
    height=None,
    latitude=None,
    longitude=None,
    values_temperature=None,
    site=None,
    radius_km=100.0,
    mark_closest_profile=False,
    show_info=True,
    show_info_orbit_and_frame=True,
    show_info_file_type=True,
    show_info_baseline=True,
    info_text_orbit_and_frame=None,
    info_text_file_type=None,
    info_text_baseline=None,
    show_radius=True,
    info_text_loc=None,
    value_range="default",
    log_scale=None,
    norm=None,
    time_range=None,
    height_range=(0, 40000.0),
    label=None,
    units=None,
    cmap=None,
    colorbar=True,
    colorbar_ticks=None,
    colorbar_tick_labels=None,
    colorbar_position="right",
    colorbar_alignment="center",
    colorbar_width=DEFAULT_COLORBAR_WIDTH,
    colorbar_spacing=0.2,
    colorbar_length_ratio="100%",
    colorbar_label_outside=True,
    colorbar_ticks_outside=True,
    colorbar_ticks_both=False,
    rolling_mean=None,
    selection_time_range=None,
    selection_color=Color("ec:earthcare"),
    selection_linestyle="dashed",
    selection_linewidth=2.5,
    selection_highlight=False,
    selection_highlight_inverted=True,
    selection_highlight_color=Color("white"),
    selection_highlight_alpha=0.5,
    selection_max_time_margin=None,
    ax_style_top=None,
    ax_style_bottom=None,
    show_temperature=False,
    mode=None,
    min_num_profiles=5000,
    mark_profiles_at=None,
    mark_profiles_at_color=None,
    mark_profiles_at_linestyle="solid",
    mark_profiles_at_linewidth=2.5,
    label_length=40,
    **kwargs
)

Plot a vertical curtain (i.e. cross-section) of a variable along the satellite track a EarthCARE dataset.

This method collections all required data from a EarthCARE xarray.dataset, such as time, height, latitude and longitude. It supports various forms of customization through the use of arguments listed below.

Parameters:

Name Type Description Default
ds Dataset

The EarthCARE dataset from with data will be plotted.

required
var str

Name of the variable to plot.

required
time_var str

Name of the time variable. Defaults to TIME_VAR.

TIME_VAR
height_var str

Name of the height variable. Defaults to HEIGHT_VAR.

HEIGHT_VAR
lat_var str

Name of the latitude variable. Defaults to TRACK_LAT_VAR.

TRACK_LAT_VAR
lon_var str

Name of the longitude variable. Defaults to TRACK_LON_VAR.

TRACK_LON_VAR
temperature_var str

Name of the temperature variable; ignored if show_temperature is set to False. Defaults to TEMP_CELSIUS_VAR.

TEMP_CELSIUS_VAR
along_track_dim str

Dimension name representing the along-track direction. Defaults to ALONG_TRACK_DIM.

ALONG_TRACK_DIM
values NDArray | None

Data values to be used instead of values found in the var variable of the dataset. Defaults to None.

None
time NDArray | None

Time values to be used instead of values found in the time_var variable of the dataset. Defaults to None.

None
height NDArray | None

Height values to be used instead of values found in the height_var variable of the dataset. Defaults to None.

None
latitude NDArray | None

Latitude values to be used instead of values found in the lat_var variable of the dataset. Defaults to None.

None
longitude NDArray | None

Longitude values to be used instead of values found in the lon_var variable of the dataset. Defaults to None.

None
values_temperature NDArray | None

Temperature values to be used instead of values found in the temperature_var variable of the dataset. Defaults to None.

None
site str | GroundSite | None

Highlights data within radius_km of a ground site (given either as a GroundSite object or name string); ignored if not set. Defaults to None.

None
radius_km float

Radius around the ground site to highlight data from; ignored if site not set. Defaults to 100.0.

100.0
mark_closest_profile bool

Mark the closest profile to the ground site in the plot; ignored if site not set. Defaults to False.

False
show_info bool

If True, show text on the plot containing EarthCARE frame and baseline info. Defaults to True.

True
info_text_loc str | None

Place info text at a specific location of the plot, e.g. "upper right" or "lower left". Defaults to None.

None
value_range ValueRangeLike | None

Min and max range for the variable values. Defaults to None.

'default'
log_scale bool | None

Whether to apply a logarithmic color scale. Defaults to None.

None
norm Normalize | None

Matplotlib norm to use for color scaling. Defaults to None.

None
time_range TimeRangeLike | None

Time range to restrict the data for plotting. Defaults to None.

None
height_range DistanceRangeLike | None

Height range to restrict the data for plotting. Defaults to (0, 40e3).

(0, 40000.0)
label str | None

Label to use for colorbar. Defaults to None.

None
units str | None

Units of the variable to show in the colorbar label. Defaults to None.

None
cmap str | Colormap | None

Colormap to use for plotting. Defaults to None.

None
colorbar bool

Whether to display a colorbar. Defaults to True.

True
colorbar_ticks ArrayLike | None

Custom tick values for the colorbar. Defaults to None.

None
colorbar_tick_labels ArrayLike | None

Custom labels for the colorbar ticks. Defaults to None.

None
rolling_mean int | None

Apply rolling mean along time axis with this window size. Defaults to None.

None
selection_time_range TimeRangeLike | None

Time range to highlight as a selection; ignored if site is set. Defaults to None.

None
selection_color _type_

Color for the selection range marker lines. Defaults to Color("ec:earthcare").

Color('ec:earthcare')
selection_linestyle str | None

Line style for selection range markers. Defaults to "dashed".

'dashed'
selection_linewidth float | int | None

Line width for selection range markers. Defaults to 2.5.

2.5
selection_highlight bool

Whether to highlight the selection region by shading outside or inside areas. Defaults to False.

False
selection_highlight_inverted bool

If True and selection_highlight is also set to True, areas outside the selection are shaded. Defaults to True.

True
selection_highlight_color str | None

If True and selection_highlight is also set to True, sets color used for shading selected outside or inside areas. Defaults to Color("white").

Color('white')
selection_highlight_alpha float

If True and selection_highlight is also set to True, sets transparency used for shading selected outside or inside areas.. Defaults to 0.5.

0.5
selection_max_time_margin TimedeltaLike | Sequence[TimedeltaLike]

Zooms the time axis to a given maximum time from a selected time area. Defaults to None.

None
ax_style_top AlongTrackAxisStyle | str | None

Style for the top axis (e.g., geo, lat, lon, distance, time, utc, lst, none). Defaults to None.

None
ax_style_bottom AlongTrackAxisStyle | str | None

Style for the bottom axis (e.g., geo, lat, lon, distance, time, utc, lst, none). Defaults to None.

None
show_temperature bool

Whether to overlay temperature as contours; requires either values_temperature or temperature_var. Defaults to False.

False
mode Literal['exact', 'fast'] | None

Overwrites the curtain plotting mode. Use "fast" to speed up plotting by coarsening data to at least min_num_profiles; "exact" plots full resolution. Defaults to None.

None
min_num_profiles int

Overwrites the minimum number of profiles to keep when using "fast" mode. Defaults to 1000.

5000
mark_profiles_at Sequence[TimestampLike] | None

Timestamps at which to mark vertical profiles. Defaults to None.

None

Returns:

Name Type Description
CurtainFigure CurtainFigure

The figure object containing the curtain plot.

Example
import earthcarekit as eck

filepath = "path/to/mydata/ECA_EXAE_ATL_NOM_1B_20250606T132535Z_20250606T150730Z_05813D.h5"
with eck.read_product(filepath) as ds:
    cf = eck.CurtainFigure()
    cf = cf.ecplot(ds, "mie_attenuated_backscatter", height_range=(0, 20e3))
Source code in earthcarekit/plot/figure/curtain.py
def ecplot(
    self,
    ds: xr.Dataset,
    var: str,
    *,
    time_var: str = TIME_VAR,
    height_var: str = HEIGHT_VAR,
    lat_var: str = TRACK_LAT_VAR,
    lon_var: str = TRACK_LON_VAR,
    temperature_var: str = TEMP_CELSIUS_VAR,
    along_track_dim: str = ALONG_TRACK_DIM,
    values: NDArray | None = None,
    time: NDArray | None = None,
    height: NDArray | None = None,
    latitude: NDArray | None = None,
    longitude: NDArray | None = None,
    values_temperature: NDArray | None = None,
    site: str | GroundSite | None = None,
    radius_km: float = 100.0,
    mark_closest_profile: bool = False,
    show_info: bool = True,
    show_info_orbit_and_frame: bool = True,
    show_info_file_type: bool = True,
    show_info_baseline: bool = True,
    info_text_orbit_and_frame: str | None = None,
    info_text_file_type: str | None = None,
    info_text_baseline: str | None = None,
    show_radius: bool = True,
    info_text_loc: str | None = None,
    # Common args for wrappers
    value_range: ValueRangeLike | Literal["default"] | None = "default",
    log_scale: bool | None = None,
    norm: Normalize | None = None,
    time_range: TimeRangeLike | None = None,
    height_range: DistanceRangeLike | None = (0, 40e3),
    label: str | None = None,
    units: str | None = None,
    cmap: str | Colormap | None = None,
    colorbar: bool = True,
    colorbar_ticks: ArrayLike | None = None,
    colorbar_tick_labels: ArrayLike | None = None,
    colorbar_position: str | Literal["left", "right", "top", "bottom"] = "right",
    colorbar_alignment: str | Literal["left", "center", "right"] = "center",
    colorbar_width: float = DEFAULT_COLORBAR_WIDTH,
    colorbar_spacing: float = 0.2,
    colorbar_length_ratio: float | str = "100%",
    colorbar_label_outside: bool = True,
    colorbar_ticks_outside: bool = True,
    colorbar_ticks_both: bool = False,
    rolling_mean: int | None = None,
    selection_time_range: TimeRangeLike | None = None,
    selection_color: str | None = Color("ec:earthcare"),
    selection_linestyle: str | None = "dashed",
    selection_linewidth: float | int | None = 2.5,
    selection_highlight: bool = False,
    selection_highlight_inverted: bool = True,
    selection_highlight_color: str | None = Color("white"),
    selection_highlight_alpha: float = 0.5,
    selection_max_time_margin: (
        TimedeltaLike | Sequence[TimedeltaLike] | None
    ) = None,
    ax_style_top: AlongTrackAxisStyle | str | None = None,
    ax_style_bottom: AlongTrackAxisStyle | str | None = None,
    show_temperature: bool = False,
    mode: Literal["exact", "fast"] | None = None,
    min_num_profiles: int = 5000,
    mark_profiles_at: Sequence[TimestampLike] | None = None,
    mark_profiles_at_color: (
        str | Color | Sequence[str | Color | None] | None
    ) = None,
    mark_profiles_at_linestyle: str | Sequence[str] = "solid",
    mark_profiles_at_linewidth: float | Sequence[float] = 2.5,
    label_length: int = 40,
    **kwargs,
) -> "CurtainFigure":
    """Plot a vertical curtain (i.e. cross-section) of a variable along the satellite track a EarthCARE dataset.

    This method collections all required data from a EarthCARE `xarray.dataset`, such as time, height, latitude and longitude.
    It supports various forms of customization through the use of arguments listed below.

    Args:
        ds (xr.Dataset): The EarthCARE dataset from with data will be plotted.
        var (str): Name of the variable to plot.
        time_var (str, optional): Name of the time variable. Defaults to TIME_VAR.
        height_var (str, optional): Name of the height variable. Defaults to HEIGHT_VAR.
        lat_var (str, optional): Name of the latitude variable. Defaults to TRACK_LAT_VAR.
        lon_var (str, optional): Name of the longitude variable. Defaults to TRACK_LON_VAR.
        temperature_var (str, optional): Name of the temperature variable; ignored if `show_temperature` is set to False. Defaults to TEMP_CELSIUS_VAR.
        along_track_dim (str, optional): Dimension name representing the along-track direction. Defaults to ALONG_TRACK_DIM.
        values (NDArray | None, optional): Data values to be used instead of values found in the `var` variable of the dataset. Defaults to None.
        time (NDArray | None, optional): Time values to be used instead of values found in the `time_var` variable of the dataset. Defaults to None.
        height (NDArray | None, optional): Height values to be used instead of values found in the `height_var` variable of the dataset. Defaults to None.
        latitude (NDArray | None, optional): Latitude values to be used instead of values found in the `lat_var` variable of the dataset. Defaults to None.
        longitude (NDArray | None, optional): Longitude values to be used instead of values found in the `lon_var` variable of the dataset. Defaults to None.
        values_temperature (NDArray | None, optional): Temperature values to be used instead of values found in the `temperature_var` variable of the dataset. Defaults to None.
        site (str | GroundSite | None, optional): Highlights data within `radius_km` of a ground site (given either as a `GroundSite` object or name string); ignored if not set. Defaults to None.
        radius_km (float, optional): Radius around the ground site to highlight data from; ignored if `site` not set. Defaults to 100.0.
        mark_closest_profile (bool, optional): Mark the closest profile to the ground site in the plot; ignored if `site` not set. Defaults to False.
        show_info (bool, optional): If True, show text on the plot containing EarthCARE frame and baseline info. Defaults to True.
        info_text_loc (str | None, optional): Place info text at a specific location of the plot, e.g. "upper right" or "lower left". Defaults to None.
        value_range (ValueRangeLike | None, optional): Min and max range for the variable values. Defaults to None.
        log_scale (bool | None, optional): Whether to apply a logarithmic color scale. Defaults to None.
        norm (Normalize | None, optional): Matplotlib norm to use for color scaling. Defaults to None.
        time_range (TimeRangeLike | None, optional): Time range to restrict the data for plotting. Defaults to None.
        height_range (DistanceRangeLike | None, optional): Height range to restrict the data for plotting. Defaults to (0, 40e3).
        label (str | None, optional): Label to use for colorbar. Defaults to None.
        units (str | None, optional): Units of the variable to show in the colorbar label. Defaults to None.
        cmap (str | Colormap | None, optional): Colormap to use for plotting. Defaults to None.
        colorbar (bool, optional): Whether to display a colorbar. Defaults to True.
        colorbar_ticks (ArrayLike | None, optional): Custom tick values for the colorbar. Defaults to None.
        colorbar_tick_labels (ArrayLike | None, optional): Custom labels for the colorbar ticks. Defaults to None.
        rolling_mean (int | None, optional): Apply rolling mean along time axis with this window size. Defaults to None.
        selection_time_range (TimeRangeLike | None, optional): Time range to highlight as a selection; ignored if `site` is set. Defaults to None.
        selection_color (_type_, optional): Color for the selection range marker lines. Defaults to Color("ec:earthcare").
        selection_linestyle (str | None, optional): Line style for selection range markers. Defaults to "dashed".
        selection_linewidth (float | int | None, optional): Line width for selection range markers. Defaults to 2.5.
        selection_highlight (bool, optional): Whether to highlight the selection region by shading outside or inside areas. Defaults to False.
        selection_highlight_inverted (bool, optional): If True and `selection_highlight` is also set to True, areas outside the selection are shaded. Defaults to True.
        selection_highlight_color (str | None, optional): If True and `selection_highlight` is also set to True, sets color used for shading selected outside or inside areas. Defaults to Color("white").
        selection_highlight_alpha (float, optional): If True and `selection_highlight` is also set to True, sets transparency used for shading selected outside or inside areas.. Defaults to 0.5.
        selection_max_time_margin (TimedeltaLike | Sequence[TimedeltaLike], optional): Zooms the time axis to a given maximum time from a selected time area. Defaults to None.
        ax_style_top (AlongTrackAxisStyle | str | None, optional): Style for the top axis (e.g., geo, lat, lon, distance, time, utc, lst, none). Defaults to None.
        ax_style_bottom (AlongTrackAxisStyle | str | None, optional): Style for the bottom axis (e.g., geo, lat, lon, distance, time, utc, lst, none). Defaults to None.
        show_temperature (bool, optional): Whether to overlay temperature as contours; requires either `values_temperature` or `temperature_var`. Defaults to False.
        mode (Literal["exact", "fast"] | None, optional): Overwrites the curtain plotting mode. Use "fast" to speed up plotting by coarsening data to at least `min_num_profiles`; "exact" plots full resolution. Defaults to None.
        min_num_profiles (int, optional): Overwrites the minimum number of profiles to keep when using "fast" mode. Defaults to 1000.
        mark_profiles_at (Sequence[TimestampLike] | None, optional): Timestamps at which to mark vertical profiles. Defaults to None.

    Returns:
        CurtainFigure: The figure object containing the curtain plot.

    Example:
        ```python
        import earthcarekit as eck

        filepath = "path/to/mydata/ECA_EXAE_ATL_NOM_1B_20250606T132535Z_20250606T150730Z_05813D.h5"
        with eck.read_product(filepath) as ds:
            cf = eck.CurtainFigure()
            cf = cf.ecplot(ds, "mie_attenuated_backscatter", height_range=(0, 20e3))
        ```
    """

    # Collect all common args for wrapped plot function call
    local_args = locals()
    # Delete all args specific to this wrapper function
    del local_args["self"]
    del local_args["ds"]
    del local_args["var"]
    del local_args["time_var"]
    del local_args["height_var"]
    del local_args["lat_var"]
    del local_args["lon_var"]
    del local_args["temperature_var"]
    del local_args["along_track_dim"]
    del local_args["site"]
    del local_args["radius_km"]
    del local_args["show_info"]
    del local_args["show_info_orbit_and_frame"]
    del local_args["show_info_file_type"]
    del local_args["show_info_baseline"]
    del local_args["info_text_orbit_and_frame"]
    del local_args["info_text_file_type"]
    del local_args["info_text_baseline"]
    del local_args["show_radius"]
    del local_args["info_text_loc"]
    del local_args["mark_closest_profile"]
    # Delete kwargs to then merge it with the residual common args
    del local_args["kwargs"]
    all_args = {**local_args, **kwargs}

    warn_about_variable_limitations(var)

    if all_args["values"] is None:
        all_args["values"] = ds[var].values
    if all_args["time"] is None:
        all_args["time"] = ds[time_var].values
    if all_args["height"] is None:
        all_args["height"] = ds[height_var].values
    if all_args["latitude"] is None:
        all_args["latitude"] = ds[lat_var].values
    if all_args["longitude"] is None:
        all_args["longitude"] = ds[lon_var].values
    if all_args["values_temperature"] is None:
        if show_temperature == False:
            all_args["values_temperature"] = None
        elif ds.get(temperature_var, None) is None:
            warnings.warn(
                f'No temperature variable called "{temperature_var}" found in given dataset.'
            )
            all_args["values_temperature"] = None
        else:
            all_args["values_temperature"] = ds[temperature_var].values

    # Set default values depending on variable name
    if label is None:
        all_args["label"] = (
            "Values" if not hasattr(ds[var], "long_name") else ds[var].long_name
        )
    if units is None:
        all_args["units"] = "-" if not hasattr(ds[var], "units") else ds[var].units
    if isinstance(value_range, str) and value_range == "default":
        value_range = None
        all_args["value_range"] = None
        if log_scale is None and norm is None:
            all_args["norm"] = get_default_norm(var, file_type=ds)
    if rolling_mean is None:
        all_args["rolling_mean"] = get_default_rolling_mean(var, file_type=ds)
    if cmap is None:
        all_args["cmap"] = get_default_cmap(var, file_type=ds)
    all_args["cmap"] = get_cmap(all_args["cmap"])

    if all_args["cmap"] == get_cmap("synergetic_tc"):
        self.colorbar_tick_scale = 0.8

    # Handle overpass
    _site: GroundSite | None = None
    if isinstance(site, GroundSite):
        _site = site
    elif isinstance(site, str):
        _site = get_ground_site(site)
    else:
        pass

    if isinstance(_site, GroundSite):
        info_overpass = get_overpass_info(
            ds,
            radius_km=radius_km,
            site=_site,
            time_var=time_var,
            lat_var=lat_var,
            lon_var=lon_var,
            along_track_dim=along_track_dim,
        )
        if show_radius:
            overpass_time_range = info_overpass.time_range
            all_args["selection_time_range"] = overpass_time_range
        else:
            mark_closest_profile = True
        if mark_closest_profile:
            _mark_profiles_at = all_args["mark_profiles_at"]
            _mark_profiles_at_color = all_args["mark_profiles_at_color"]
            _mark_profiles_at_linestyle = all_args["mark_profiles_at_linestyle"]
            _mark_profiles_at_linewidth = all_args["mark_profiles_at_linewidth"]
            if isinstance(_mark_profiles_at, (Sequence, np.ndarray)):
                list(_mark_profiles_at).append(info_overpass.closest_time)
                all_args["mark_profiles_at"] = _mark_profiles_at
            else:
                all_args["mark_profiles_at"] = [info_overpass.closest_time]

            if not isinstance(_mark_profiles_at_color, str) and isinstance(
                _mark_profiles_at_color, (Sequence, np.ndarray)
            ):
                list(_mark_profiles_at_color).append("ec:earthcare")
                all_args["mark_profiles_at_color"] = _mark_profiles_at_color

            if not isinstance(_mark_profiles_at_linestyle, str) and isinstance(
                _mark_profiles_at_linestyle, (Sequence, np.ndarray)
            ):
                list(_mark_profiles_at_linestyle).append("solid")
                all_args["mark_profiles_at_linestyle"] = _mark_profiles_at_linestyle

            if isinstance(_mark_profiles_at_linewidth, (Sequence, np.ndarray)):
                list(_mark_profiles_at_linewidth).append(2.5)
                all_args["mark_profiles_at_linewidth"] = _mark_profiles_at_linewidth

            all_args["selection_linestyle"] = "none"
            all_args["selection_linewidth"] = 0.1
    self.plot(**all_args)

    self._set_info_text_loc(info_text_loc)
    if show_info:
        self.info_text = add_text_product_info(
            self.ax,
            ds,
            append_to=self.info_text,
            loc=self.info_text_loc,
            show_orbit_and_frame=show_info_orbit_and_frame,
            show_file_type=show_info_file_type,
            show_baseline=show_info_baseline,
            text_orbit_and_frame=info_text_orbit_and_frame,
            text_file_type=info_text_file_type,
            text_baseline=info_text_baseline,
        )

    return self

ecplot_contour

ecplot_contour(
    ds,
    var,
    time_var=TIME_VAR,
    height_var=HEIGHT_VAR,
    levels=None,
    label_format=None,
    label_levels=None,
    linewidths=1.5,
    linestyles="solid",
    colors="black",
    zorder=3,
)

Adds contour lines to the plot.

Source code in earthcarekit/plot/figure/curtain.py
def ecplot_contour(
    self,
    ds: xr.Dataset,
    var: str,
    time_var: str = TIME_VAR,
    height_var: str = HEIGHT_VAR,
    levels: list | NDArray | None = None,
    label_format: str | None = None,
    label_levels: list | NDArray | None = None,
    linewidths: int | float | list | NDArray | None = 1.5,
    linestyles: str | list | NDArray | None = "solid",
    colors: Color | str | list | NDArray | None = "black",
    zorder: float | int = 3,
) -> "CurtainFigure":
    """Adds contour lines to the plot."""
    values = ds[var].values
    time = ds[time_var].values
    height = ds[height_var].values
    tp = ProfileData(values=values, time=time, height=height)
    self.plot_contour(
        values=tp.values,
        time=tp.time,
        height=tp.height,
        levels=levels,
        linewidths=linewidths,
        linestyles=linestyles,
        colors=colors,
        zorder=zorder,
        label_format=label_format,
        label_levels=label_levels,
    )
    return self

ecplot_elevation

ecplot_elevation(
    ds,
    var=ELEVATION_VAR,
    time_var=TIME_VAR,
    land_flag_var=LAND_FLAG_VAR,
    color="ec:land",
    color_water="ec:water",
    legend_label=None,
    legend_label_water=None,
)

Adds filled elevation/surface area to the plot.

Source code in earthcarekit/plot/figure/curtain.py
def ecplot_elevation(
    self,
    ds: xr.Dataset,
    var: str = ELEVATION_VAR,
    time_var: str = TIME_VAR,
    land_flag_var: str = LAND_FLAG_VAR,
    color: Color | str | None = "ec:land",
    color_water: Color | str | None = "ec:water",
    legend_label: str | None = None,
    legend_label_water: str | None = None,
) -> "CurtainFigure":
    """Adds filled elevation/surface area to the plot."""
    height = ds[var].copy().values
    time = ds[time_var].copy().values

    kwargs = dict(
        linewidth=0,
        linestyle="none",
        marker="none",
        markersize=0,
        fill=True,
        zorder=10,
    )

    is_water = land_flag_var in ds.variables

    if is_water:
        land_flag = ds[land_flag_var].copy().values == 1
        height_water = height.copy()
        height_water[land_flag] = np.nan
        height[~land_flag] = np.nan

    self.plot_height(
        height=height,
        time=time,
        color=color,
        legend_label=legend_label,
        **kwargs,  # type: ignore
    )

    if is_water:
        self.plot_height(
            height=height_water,
            time=time,
            color=color_water,
            legend_label=legend_label_water,
            **kwargs,  # type: ignore
        )

    return self

ecplot_hatch

ecplot_hatch(
    ds,
    var,
    value_range,
    time_var=TIME_VAR,
    height_var=HEIGHT_VAR,
    hatch="/////",
    linewidth=1,
    linewidth_border=0,
    color="black",
    color_border=None,
    zorder=2,
    legend_label=None,
)

Adds hatched/filled areas to the plot.

Source code in earthcarekit/plot/figure/curtain.py
def ecplot_hatch(
    self,
    ds: xr.Dataset,
    var: str,
    value_range: tuple[float, float],
    time_var: str = TIME_VAR,
    height_var: str = HEIGHT_VAR,
    hatch: str = "/////",
    linewidth: float = 1,
    linewidth_border: float = 0,
    color: ColorLike | None = "black",
    color_border: ColorLike | None = None,
    zorder: int | float | None = 2,
    legend_label: str | None = None,
) -> "CurtainFigure":
    """Adds hatched/filled areas to the plot."""
    height = ds[height_var].values
    time = ds[time_var].values
    values = ds[var].values

    return self.plot_hatch(
        values=values,
        time=time,
        height=height,
        value_range=value_range,
        hatch=hatch,
        linewidth=linewidth,
        linewidth_border=linewidth_border,
        color=color,
        color_border=color_border,
        zorder=zorder,
        legend_label=legend_label,
    )

ecplot_hatch_attenuated

ecplot_hatch_attenuated(
    ds, var="simple_classification", value_range=(-1.5, -0.5), **kwargs
)

Adds hatched area where ATLID "simple_classification" shows "attenuated" (-1).

Source code in earthcarekit/plot/figure/curtain.py
def ecplot_hatch_attenuated(
    self,
    ds: xr.Dataset,
    var: str = "simple_classification",
    value_range: tuple[float, float] = (-1.5, -0.5),
    **kwargs,
) -> "CurtainFigure":
    """Adds hatched area where ATLID "simple_classification" shows "attenuated" (-1)."""
    return self.ecplot_hatch(
        ds=ds,
        var=var,
        value_range=value_range,
        **kwargs,
    )

ecplot_height

ecplot_height(
    ds,
    var,
    time_var=TIME_VAR,
    linewidth=1.5,
    linestyle="none",
    color="black",
    zorder=2.1,
    marker="s",
    markersize=1,
    show_info=True,
    info_text_loc=None,
    legend_label=None,
)

Adds height line to the plot.

Source code in earthcarekit/plot/figure/curtain.py
def ecplot_height(
    self,
    ds: xr.Dataset,
    var: str,
    time_var: str = TIME_VAR,
    linewidth: int | float | None = 1.5,
    linestyle: str | None = "none",
    color: Color | str | None = "black",
    zorder: int | float | None = 2.1,
    marker: str | None = "s",
    markersize: int | float | None = 1,
    show_info: bool = True,
    info_text_loc: str | None = None,
    legend_label: str | None = None,
) -> "CurtainFigure":
    """Adds height line to the plot."""
    height = ds[var].values
    time = ds[time_var].values
    self.plot_height(
        height=height,
        time=time,
        linewidth=linewidth,
        linestyle=linestyle,
        color=color,
        zorder=zorder,
        marker=marker,
        markersize=markersize,
        legend_label=legend_label,
    )

    self._set_info_text_loc(info_text_loc)
    if show_info:
        self.info_text = add_text_product_info(
            self.ax, ds, append_to=self.info_text, loc=self.info_text_loc
        )

    return self

ecplot_pressure

ecplot_pressure(
    ds,
    var=PRESSURE_VAR,
    time_var=TIME_VAR,
    height_var=HEIGHT_VAR,
    label_format="%d hPa",
    **kwargs
)

Adds pressure contour lines to the plot.

Source code in earthcarekit/plot/figure/curtain.py
def ecplot_pressure(
    self,
    ds: xr.Dataset,
    var: str = PRESSURE_VAR,
    time_var: str = TIME_VAR,
    height_var: str = HEIGHT_VAR,
    label_format: str | None = r"%d hPa",
    **kwargs,
) -> "CurtainFigure":
    """Adds pressure contour lines to the plot."""
    values = ds[var].values / 100.0
    time = ds[time_var].values
    height = ds[height_var].values
    return self.plot_contour(
        values=values,
        time=time,
        height=height,
        label_format=label_format,
        **kwargs,
    )

ecplot_temperature

ecplot_temperature(
    ds,
    var=TEMP_CELSIUS_VAR,
    label_format="$%.0f^{\\circ}$C",
    label_levels=[-80, -40, 0],
    levels=[-80, -70, -60, -50, -40, -30, -20, -10, 0, 10, 20],
    linewidths=[0.75, 0.25, 0.5, 0.5, 0.75, 0.5, 0.75, 0.5, 1.0, 0.5, 0.75],
    linestyles=[
        "dashed",
        "dashed",
        "dashed",
        "dashed",
        "dashed",
        "dashed",
        "dashed",
        "dashed",
        "solid",
        "solid",
        "solid",
    ],
    colors="black",
    **kwargs
)

Adds temperature contour lines to the plot.

Source code in earthcarekit/plot/figure/curtain.py
def ecplot_temperature(
    self,
    ds: xr.Dataset,
    var: str = TEMP_CELSIUS_VAR,
    label_format: str | None = "$%.0f^{\circ}$C",
    label_levels: list | NDArray | None = [-80, -40, 0],
    levels=[
        -80,
        -70,
        -60,
        -50,
        -40,
        -30,
        -20,
        -10,
        0,
        10,
        20,
    ],
    linewidths=[
        0.75,  # -80
        0.25,  # -70
        0.50,  # -60
        0.50,  # -50
        0.75,  # -40
        0.50,  # -30
        0.75,  # -20
        0.50,  # -10
        1.00,  # 0
        0.50,  # 10
        0.75,  # 20
    ],
    linestyles=[
        "dashed",  # -80
        "dashed",  # -70
        "dashed",  # -60
        "dashed",  # -50
        "dashed",  # -40
        "dashed",  # -30
        "dashed",  # -20
        "dashed",  # -10
        "solid",  # 0
        "solid",  # 10
        "solid",  # 20
    ],
    colors="black",
    **kwargs,
) -> "CurtainFigure":
    """Adds temperature contour lines to the plot."""
    return self.ecplot_contour(
        ds=ds,
        var=var,
        label_format=label_format,
        levels=levels,
        label_levels=label_levels,
        linewidths=linewidths,
        linestyles=linestyles,
        colors=colors,
        **kwargs,
    )

ecplot_tropopause

ecplot_tropopause(
    ds,
    var=TROPOPAUSE_VAR,
    time_var=TIME_VAR,
    color="ec:tropopause",
    linewidth=2,
    linestyle="solid",
    legend_label=None,
)

Adds tropopause line to the plot.

Source code in earthcarekit/plot/figure/curtain.py
def ecplot_tropopause(
    self,
    ds: xr.Dataset,
    var: str = TROPOPAUSE_VAR,
    time_var: str = TIME_VAR,
    color: Color | str | None = "ec:tropopause",
    linewidth: float = 2,
    linestyle: str = "solid",
    legend_label: str | None = None,
) -> "CurtainFigure":
    """Adds tropopause line to the plot."""
    height = ds[var].values
    time = ds[time_var].values
    self.plot_height(
        height=height,
        time=time,
        linewidth=linewidth,
        linestyle=linestyle,
        color=color,
        marker="none",
        markersize=0,
        fill=False,
        zorder=12,
        legend_label=legend_label,
    )

    return self

invert_xaxis

invert_xaxis()

Invert the x-axis.

Source code in earthcarekit/plot/figure/curtain.py
def invert_xaxis(self) -> "CurtainFigure":
    """Invert the x-axis."""
    self.ax.invert_xaxis()
    if self.ax_top:
        self.ax_top.invert_xaxis()
    return self

invert_yaxis

invert_yaxis()

Invert the y-axis.

Source code in earthcarekit/plot/figure/curtain.py
def invert_yaxis(self) -> "CurtainFigure":
    """Invert the y-axis."""
    self.ax.invert_yaxis()
    if self.ax_right:
        self.ax_right.invert_yaxis()
    return self

plot_contour

plot_contour(
    values,
    time,
    height,
    label_levels=None,
    label_format=None,
    levels=None,
    linewidths=1.5,
    linestyles="solid",
    colors="black",
    zorder=2,
)

Adds contour lines to the plot.

Source code in earthcarekit/plot/figure/curtain.py
def plot_contour(
    self,
    values: NDArray,
    time: NDArray,
    height: NDArray,
    label_levels: list | NDArray | None = None,
    label_format: str | None = None,
    levels: list | NDArray | None = None,
    linewidths: int | float | list | NDArray | None = 1.5,
    linestyles: str | list | NDArray | None = "solid",
    colors: Color | str | list | NDArray | None = "black",
    zorder: int | float | None = 2,
) -> "CurtainFigure":
    """Adds contour lines to the plot."""
    values = np.asarray(values)
    time = np.asarray(time)
    height = np.asarray(height)

    if len(height.shape) == 2:
        height = height[0]

    if isinstance(colors, str):
        colors = Color.from_optional(colors)
    elif isinstance(colors, (Iterable, np.ndarray)):
        colors = [Color.from_optional(c) for c in colors]
    else:
        colors = Color.from_optional(colors)

    x = time
    y = height
    z = values.T

    if len(y.shape) == 2:
        y = y[len(y) // 2]

    if isinstance(colors, list):
        shade_color = Color.from_optional(colors[0])
    else:
        shade_color = Color.from_optional(colors)

    if isinstance(shade_color, Color):
        shade_color = shade_color.get_best_bw_contrast_color()

    linewidths2: int | float | np.ndarray
    if not isinstance(linewidths, (int, float, np.number, np.ndarray)):
        linewidths2 = np.array(linewidths) * 2.5
    else:
        linewidths2 = linewidths * 2.5

    cn2 = self.ax.contour(
        x,
        y,
        z,
        levels=levels,
        linewidths=linewidths2,
        colors=shade_color,
        alpha=0.5,
        linestyles="solid",
        zorder=zorder,
    )

    cn = self.ax.contour(
        x,
        y,
        z,
        levels=levels,
        linewidths=linewidths,
        colors=colors,
        linestyles=linestyles,
        zorder=zorder,
    )

    labels: Iterable[float]
    if label_levels:
        labels = [l for l in label_levels if l in cn.levels]
    else:
        labels = cn.levels

    cl = self.ax.clabel(
        cn,
        labels,  # type: ignore
        inline=True,
        fmt=label_format,
        fontsize="small",
        zorder=zorder,
    )

    for t in cn.labelTexts:
        add_shade_to_text(t, alpha=0.5)
        t.set_rotation(0)

    return self

plot_hatch

plot_hatch(
    values,
    time,
    height,
    value_range,
    hatch="/////",
    linewidth=1,
    linewidth_border=0,
    color="black",
    color_border=None,
    zorder=2,
    legend_label=None,
)

Adds hatched/filled areas to the plot.

Source code in earthcarekit/plot/figure/curtain.py
def plot_hatch(
    self,
    values: NDArray,
    time: NDArray,
    height: NDArray,
    value_range: tuple[float, float],
    hatch: str = "/////",
    linewidth: float = 1,
    linewidth_border: float = 0,
    color: ColorLike | None = "black",
    color_border: ColorLike | None = None,
    zorder: int | float | None = 2,
    legend_label: str | None = None,
) -> "CurtainFigure":
    """Adds hatched/filled areas to the plot."""
    values = np.asarray(values)
    time = np.asarray(time)
    height = np.asarray(height)

    if len(height.shape) == 2:
        height = height[0]

    color = Color.from_optional(color)
    color_border = Color.from_optional(color_border)

    cnf = self.ax.contourf(
        time,
        height,
        values.T,
        levels=[value_range[0], value_range[1]],
        colors=["none"],
        hatches=[hatch],
        zorder=zorder,
    )
    cnf.set_edgecolors(color)  # type: ignore
    cnf.set_hatch_linewidth(linewidth)

    color = Color(cnf.get_edgecolors()[0], is_normalized=True)  # type: ignore
    if color_border is None:
        color_border = color.hex
    cnf.set_color(color_border)  # type: ignore
    cnf.set_linewidth(linewidth_border)

    if isinstance(legend_label, str):
        from matplotlib.patches import Patch

        _facecolor = "none"
        if color.is_close_to_white():
            _facecolor = color.blend(0.7, "black").hex

        hatch_patch = Patch(
            linewidth=linewidth_border,
            facecolor=_facecolor,
            edgecolor=color.hex,
            hatch=hatch,
            label=legend_label,
        )

        self._legend_handles.append(hatch_patch)
        self._legend_labels.append(legend_label)

    return self

plot_height

plot_height(
    height,
    time,
    linewidth=1.5,
    linestyle="solid",
    color=None,
    alpha=1.0,
    zorder=2,
    marker=None,
    markersize=None,
    fill=False,
    legend_label=None,
)

Adds height line to the plot.

Source code in earthcarekit/plot/figure/curtain.py
def plot_height(
    self,
    height: NDArray,
    time: NDArray,
    linewidth: int | float | None = 1.5,
    linestyle: str | None = "solid",
    color: Color | str | None = None,
    alpha: float | None = 1.0,
    zorder: int | float | None = 2,
    marker: str | None = None,
    markersize: int | float | None = None,
    fill: bool = False,
    legend_label: str | None = None,
) -> "CurtainFigure":
    """Adds height line to the plot."""
    color = Color.from_optional(color)

    height = np.asarray(height)
    time = np.asarray(time)

    hnew, tnew = _convert_height_line_to_time_bin_step_function(height, time)

    fb: list = []
    if fill:
        _fb1 = self.ax.fill_between(
            tnew,
            hnew,
            y2=-5e3,
            color=color,
            alpha=alpha,
            zorder=zorder,
        )
        from matplotlib.patches import Patch

        # Proxy for the legend
        _fb2 = Patch(facecolor=color, alpha=alpha, linewidth=0.0)
        fb = [_fb1, _fb2]

    hl = self.ax.plot(
        tnew,
        hnew,
        linestyle=linestyle,
        linewidth=linewidth,
        marker=marker,
        markersize=markersize,
        color=color,
        alpha=alpha,
        zorder=zorder,
    )

    if isinstance(legend_label, str):
        self._legend_handles.append(tuple(hl + fb))
        self._legend_labels.append(legend_label)

    return self

save

save(
    filename="",
    filepath=None,
    ds=None,
    ds_filepath=None,
    dpi="figure",
    orbit_and_frame=None,
    utc_timestamp=None,
    use_utc_creation_timestamp=False,
    site_name=None,
    hmax=None,
    radius=None,
    extra=None,
    transparent_outside=False,
    verbose=True,
    print_prefix="",
    create_dirs=False,
    transparent_background=False,
    resolution=None,
    **kwargs
)

Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

Parameters:

Name Type Description Default
figure Figure | HasFigure

A figure object (matplotlib.figure.Figure) or objects exposing a .fig attribute containing a figure (e.g., CurtainFigure).

required
filename str

The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.

''
filepath str | None

The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.

None
ds Dataset | None

A EarthCARE dataset from which metadata will be taken. Defaults to None.

None
ds_filepath str | None

A path to a EarthCARE product from which metadata will be taken. Defaults to None.

None
pad float

Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.

required
dpi float | figure

The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.

'figure'
orbit_and_frame str | None

Metadata used in the formatting of the file name. Defaults to None.

None
utc_timestamp TimestampLike | None

Metadata used in the formatting of the file name. Defaults to None.

None
use_utc_creation_timestamp bool

Whether the time of image creation should be included in the file name. Defaults to False.

False
site_name str | None

Metadata used in the formatting of the file name. Defaults to None.

None
hmax int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
radius int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
resolution str | None

Metadata used in the formatting of the file name. Defaults to None.

None
extra str | None

A custom string to be included in the file name. Defaults to None.

None
transparent_outside bool

Whether the area outside figures should be transparent. Defaults to False.

False
verbose bool

Whether the progress of image creation should be printed to the console. Defaults to True.

True
print_prefix str

A prefix string to all console messages. Defaults to "".

''
create_dirs bool

Whether images should be saved in a folder structure based on provided metadata. Defaults to False.

False
transparent_background bool

Whether the background inside and outside of figures should be transparent. Defaults to False.

False
**kwargs dict[str, Any]

Keyword arguments passed to wrapped function call of matplotlib.pyplot.savefig.

{}
Source code in earthcarekit/plot/figure/curtain.py
def save(
    self,
    filename: str = "",
    filepath: str | None = None,
    ds: xr.Dataset | None = None,
    ds_filepath: str | None = None,
    dpi: float | Literal["figure"] = "figure",
    orbit_and_frame: str | None = None,
    utc_timestamp: TimestampLike | None = None,
    use_utc_creation_timestamp: bool = False,
    site_name: str | None = None,
    hmax: int | float | None = None,
    radius: int | float | None = None,
    extra: str | None = None,
    transparent_outside: bool = False,
    verbose: bool = True,
    print_prefix: str = "",
    create_dirs: bool = False,
    transparent_background: bool = False,
    resolution: str | None = None,
    **kwargs,
) -> None:
    """
    Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

    Args:
        figure (Figure | HasFigure): A figure object (`matplotlib.figure.Figure`) or objects exposing a `.fig` attribute containing a figure (e.g., `CurtainFigure`).
        filename (str, optional): The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.
        filepath (str | None, optional): The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.
        ds (xr.Dataset | None, optional): A EarthCARE dataset from which metadata will be taken. Defaults to None.
        ds_filepath (str | None, optional): A path to a EarthCARE product from which metadata will be taken. Defaults to None.
        pad (float, optional): Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.
        dpi (float | 'figure', optional): The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.
        orbit_and_frame (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        utc_timestamp (TimestampLike | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        use_utc_creation_timestamp (bool, optional): Whether the time of image creation should be included in the file name. Defaults to False.
        site_name (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        hmax (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        radius (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        resolution (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        extra (str | None, optional): A custom string to be included in the file name. Defaults to None.
        transparent_outside (bool, optional): Whether the area outside figures should be transparent. Defaults to False.
        verbose (bool, optional): Whether the progress of image creation should be printed to the console. Defaults to True.
        print_prefix (str, optional): A prefix string to all console messages. Defaults to "".
        create_dirs (bool, optional): Whether images should be saved in a folder structure based on provided metadata. Defaults to False.
        transparent_background (bool, optional): Whether the background inside and outside of figures should be transparent. Defaults to False.
        **kwargs (dict[str, Any]): Keyword arguments passed to wrapped function call of `matplotlib.pyplot.savefig`.
    """
    save_plot(
        fig=self.fig,
        filename=filename,
        filepath=filepath,
        ds=ds,
        ds_filepath=ds_filepath,
        dpi=dpi,
        orbit_and_frame=orbit_and_frame,
        utc_timestamp=utc_timestamp,
        use_utc_creation_timestamp=use_utc_creation_timestamp,
        site_name=site_name,
        hmax=hmax,
        radius=radius,
        extra=extra,
        transparent_outside=transparent_outside,
        verbose=verbose,
        print_prefix=print_prefix,
        create_dirs=create_dirs,
        transparent_background=transparent_background,
        resolution=resolution,
        **kwargs,
    )

to_texture

to_texture()

Convert the figure to a texture by removing all axis ticks, labels, annotations, and text.

Source code in earthcarekit/plot/figure/curtain.py
def to_texture(self) -> "CurtainFigure":
    """Convert the figure to a texture by removing all axis ticks, labels, annotations, and text."""
    # Remove anchored text and other artist text objects
    for artist in reversed(self.ax.artists):
        if isinstance(artist, (Text, AnchoredOffsetbox)):
            artist.remove()

    # Completely remove axis ticks and labels
    self.ax.axis("off")

    if self.ax_top:
        self.ax_top.axis("off")

    if self.ax_right:
        self.ax_right.axis("off")

    # Remove white frame around figure
    self.fig.subplots_adjust(left=0, right=1, bottom=0, top=1)

    # Remove colorbar
    if self.colorbar:
        self.colorbar.remove()
        self.colorbar = None

    # Remove legend
    if self.legend:
        self.legend.remove()
        self.legend = None

    return self

FileAgency

Bases: FileInfoEnum

Source code in earthcarekit/utils/read/product/file_info/agency.py
class FileAgency(FileInfoEnum):
    ESA = "E"
    JAXA = "J"

    @classmethod
    def from_input(cls, input: str | xr.Dataset) -> "FileAgency":
        """Infers the EarthCARE product agency (i.e. ESA or JAXA) from a given file or dataset."""
        if isinstance(input, str):
            try:
                return cls[input.upper()]
            except AttributeError:
                pass
            except KeyError:
                pass
            try:
                return cls(input.upper())
            except ValueError:
                pass

        return get_file_agency(input)

from_input classmethod

from_input(input)

Infers the EarthCARE product agency (i.e. ESA or JAXA) from a given file or dataset.

Source code in earthcarekit/utils/read/product/file_info/agency.py
@classmethod
def from_input(cls, input: str | xr.Dataset) -> "FileAgency":
    """Infers the EarthCARE product agency (i.e. ESA or JAXA) from a given file or dataset."""
    if isinstance(input, str):
        try:
            return cls[input.upper()]
        except AttributeError:
            pass
        except KeyError:
            pass
        try:
            return cls(input.upper())
        except ValueError:
            pass

    return get_file_agency(input)

FileLatency

Bases: FileInfoEnum

Source code in earthcarekit/utils/read/product/file_info/latency.py
class FileLatency(FileInfoEnum):
    NEAR_REAL_TIME = "N"
    OFFLINE = "O"
    NOT_APPLICABLE = "X"

    @classmethod
    def from_input(cls, input: str | xr.Dataset) -> "FileLatency":
        """Infers the EarthCARE product latency indicator (i.e. N for Near-real time, O for Offline, X for not applicable) from a given name, file or dataset."""
        if isinstance(input, str):
            try:
                return cls[input.upper()]
            except AttributeError:
                pass
            except KeyError:
                pass
            try:
                return cls(input.upper())
            except ValueError:
                pass

        return get_file_latency(input)

from_input classmethod

from_input(input)

Infers the EarthCARE product latency indicator (i.e. N for Near-real time, O for Offline, X for not applicable) from a given name, file or dataset.

Source code in earthcarekit/utils/read/product/file_info/latency.py
@classmethod
def from_input(cls, input: str | xr.Dataset) -> "FileLatency":
    """Infers the EarthCARE product latency indicator (i.e. N for Near-real time, O for Offline, X for not applicable) from a given name, file or dataset."""
    if isinstance(input, str):
        try:
            return cls[input.upper()]
        except AttributeError:
            pass
        except KeyError:
            pass
        try:
            return cls(input.upper())
        except ValueError:
            pass

    return get_file_latency(input)

FileType

Bases: FileInfoEnum

Source code in earthcarekit/utils/read/product/file_info/type.py
class FileType(FileInfoEnum):
    # Level 1
    ATL_NOM_1B = "ATL_NOM_1B"
    ATL_DCC_1B = "ATL_DCC_1B"
    ATL_CSC_1B = "ATL_CSC_1B"
    ATL_FSC_1B = "ATL_FSC_1B"
    MSI_NOM_1B = "MSI_NOM_1B"
    MSI_BBS_1B = "MSI_BBS_1B"
    MSI_SD1_1B = "MSI_SD1_1B"
    MSI_SD2_1B = "MSI_SD2_1B"
    MSI_RGR_1C = "MSI_RGR_1C"
    BBR_NOM_1B = "BBR_NOM_1B"
    BBR_SNG_1B = "BBR_SNG_1B"
    BBR_SOL_1B = "BBR_SOL_1B"
    BBR_LIN_1B = "BBR_LIN_1B"
    CPR_NOM_1B = "CPR_NOM_1B"  # JAXA product
    # Level 2a
    ATL_FM__2A = "ATL_FM__2A"
    ATL_AER_2A = "ATL_AER_2A"
    ATL_ICE_2A = "ATL_ICE_2A"
    ATL_TC__2A = "ATL_TC__2A"
    ATL_EBD_2A = "ATL_EBD_2A"
    ATL_CTH_2A = "ATL_CTH_2A"
    ATL_ALD_2A = "ATL_ALD_2A"
    MSI_CM__2A = "MSI_CM__2A"
    MSI_COP_2A = "MSI_COP_2A"
    MSI_AOT_2A = "MSI_AOT_2A"
    CPR_FMR_2A = "CPR_FMR_2A"
    CPR_CD__2A = "CPR_CD__2A"
    CPR_TC__2A = "CPR_TC__2A"
    CPR_CLD_2A = "CPR_CLD_2A"
    CPR_APC_2A = "CPR_APC_2A"
    ATL_CLA_2A = "ATL_CLA_2A"  # JAXA product
    MSI_CLP_2A = "MSI_CLP_2A"  # JAXA product
    CPR_ECO_2A = "CPR_ECO_2A"  # JAXA product
    CPR_CLP_2A = "CPR_CLP_2A"  # JAXA product
    # Level 2b
    AM__MO__2B = "AM__MO__2B"
    AM__CTH_2B = "AM__CTH_2B"
    AM__ACD_2B = "AM__ACD_2B"
    AC__TC__2B = "AC__TC__2B"
    BM__RAD_2B = "BM__RAD_2B"
    BMA_FLX_2B = "BMA_FLX_2B"
    ACM_CAP_2B = "ACM_CAP_2B"
    ACM_COM_2B = "ACM_COM_2B"
    ACM_RT__2B = "ACM_RT__2B"
    ALL_DF__2B = "ALL_DF__2B"
    ALL_3D__2B = "ALL_3D__2B"
    AC__CLP_2B = "AC__CLP_2B"  # JAXA product
    ACM_CLP_2B = "ACM_CLP_2B"  # JAXA product
    ALL_RAD_2B = "ALL_RAD_2B"  # JAXA product
    # Auxiliary data
    AUX_MET_1D = "AUX_MET_1D"
    AUX_JSG_1D = "AUX_JSG_1D"
    # Orbit data
    MPL_ORBSCT = "MPL_ORBSCT"
    AUX_ORBPRE = "AUX_ORBPRE"
    AUX_ORBRES = "AUX_ORBRES"

    @classmethod
    def from_input(cls, input: str | xr.Dataset) -> "FileType":
        """Infers the EarthCARE product type from a given file or dataset."""
        if isinstance(input, str):
            try:
                return cls[format_file_type_string(input)]
            except AttributeError:
                pass
            except KeyError:
                pass
            try:
                return cls(format_file_type_string(input))
            except ValueError:
                pass
            except KeyError:
                pass

        return get_file_type(input)

    @classmethod
    def list(cls):
        return list(map(lambda c: c.value, cls))

    def to_shorthand(self, with_dash: bool = False):
        if with_dash:
            return _short_hand_map[self.value]
        else:
            return _short_hand_map[self.value].replace("-", "")

    def get_level(self) -> Literal["1B", "1C", "2A", "2B", "1D", "ORB"]:
        if self.value[-2:] in ["1B", "1C", "1D", "2A", "2B"]:
            return self.value[-2:]  # type: ignore
        elif self.value in [
            FileType.MPL_ORBSCT.value,
            FileType.AUX_ORBPRE.value,
            FileType.AUX_ORBRES.value,
        ]:
            return "ORB"
        raise NotImplementedError(f"missing implementation for {self}")

from_input classmethod

from_input(input)

Infers the EarthCARE product type from a given file or dataset.

Source code in earthcarekit/utils/read/product/file_info/type.py
@classmethod
def from_input(cls, input: str | xr.Dataset) -> "FileType":
    """Infers the EarthCARE product type from a given file or dataset."""
    if isinstance(input, str):
        try:
            return cls[format_file_type_string(input)]
        except AttributeError:
            pass
        except KeyError:
            pass
        try:
            return cls(format_file_type_string(input))
        except ValueError:
            pass
        except KeyError:
            pass

    return get_file_type(input)

GroundSite dataclass

Class representing a geographic site (or ground station) with associated metadata.

Attributes:

Name Type Description
latitude float

Latitude of the site in decimal degrees.

longitude float

Longitude of the site in decimal degrees.

name str

Short name or identifier of the site.

long_name str

Full descriptive name of the site.

aliases list[str]

Alternative names or identifiers for the site.

altitude float

Altitude of the site in meters above sea level.

cloudnet_name str | None

Identifier string used in CloudNet file names, or None if not applicable.

Source code in earthcarekit/utils/ground_sites.py
@dataclass(frozen=True)
class GroundSite:
    """Class representing a geographic site (or ground station) with associated metadata.

    Attributes:
        latitude (float): Latitude of the site in decimal degrees.
        longitude (float): Longitude of the site in decimal degrees.
        name (str): Short name or identifier of the site.
        long_name (str): Full descriptive name of the site.
        aliases (list[str]): Alternative names or identifiers for the site.
        altitude (float): Altitude of the site in meters above sea level.
        cloudnet_name (str | None): Identifier string used in CloudNet file names, or None if not applicable.
    """

    latitude: float
    """Latitude of the site in decimal degrees."""
    longitude: float
    """Longitude of the site in decimal degrees."""
    name: str = ""
    """Short name or identifier of the site."""
    long_name: str = ""
    """Full descriptive name of the site."""
    aliases: list[str] = field(default_factory=list)
    """Alternative names or identifiers for the site."""
    altitude: float = 0.0
    """Altitude of the site in meters above sea level."""
    cloudnet_name: str | None = None
    """Identifier string used in CloudNet file names, or None if not applicable."""

    @property
    def coordinates(self) -> tuple[float, float]:
        """Geodetic coordinates of the ground site (lat,lon)."""
        return (self.latitude, self.longitude)

aliases class-attribute instance-attribute

aliases = field(default_factory=list)

Alternative names or identifiers for the site.

altitude class-attribute instance-attribute

altitude = 0.0

Altitude of the site in meters above sea level.

cloudnet_name class-attribute instance-attribute

cloudnet_name = None

Identifier string used in CloudNet file names, or None if not applicable.

coordinates property

coordinates

Geodetic coordinates of the ground site (lat,lon).

latitude instance-attribute

latitude

Latitude of the site in decimal degrees.

long_name class-attribute instance-attribute

long_name = ''

Full descriptive name of the site.

longitude instance-attribute

longitude

Longitude of the site in decimal degrees.

name class-attribute instance-attribute

name = ''

Short name or identifier of the site.

LineFigure

TODO: documentation

Source code in earthcarekit/plot/figure/line.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
class LineFigure:
    """TODO: documentation"""

    def __init__(
        self,
        ax: Axes | None = None,
        figsize: tuple[float, float] = (FIGURE_WIDTH_LINE, FIGURE_HEIGHT_LINE),
        dpi: int | None = None,
        title: str | None = None,
        ax_style_top: AlongTrackAxisStyle | str = "geo",
        ax_style_bottom: AlongTrackAxisStyle | str = "time",
        num_ticks: int = 10,
        show_value_left: bool = True,
        show_value_right: bool = False,
        mode: str | Literal["line", "scatter", "area"] = "line",
        show_grid: bool = True,
        grid_color: str | None = Color("lightgray"),
        grid_which: Literal["major", "minor", "both"] = "major",
        grid_axis: Literal["both", "x", "y"] = "both",
        grid_linestyle: str = "dashed",
        grid_linewidth: float = 1,
        fig_height_scale: float = 1.0,
        fig_width_scale: float = 1.0,
    ):
        figsize = (figsize[0] * fig_width_scale, figsize[1] * fig_height_scale)
        self.fig: Figure
        if isinstance(ax, Axes):
            tmp = ax.get_figure()
            if not isinstance(tmp, (Figure, SubFigure)):
                raise ValueError(f"Invalid Figure")
            self.fig = tmp  # type: ignore
            self.ax = ax
        else:
            self.fig = plt.figure(figsize=figsize, dpi=dpi)
            self.ax = self.fig.add_subplot()  # add_axes((0.0, 0.0, 1.0, 1.0))
        self.title = title
        if self.title:
            self.fig.suptitle(self.title, y=1.4)

        self.ax_top: Axes
        self.ax_right: Axes
        self.selection_time_range: tuple[pd.Timestamp, pd.Timestamp] | None = None
        self.ax_style_top: AlongTrackAxisStyle = AlongTrackAxisStyle.from_input(
            ax_style_top
        )
        self.ax_style_bottom: AlongTrackAxisStyle = AlongTrackAxisStyle.from_input(
            ax_style_bottom
        )

        self.info_text: AnchoredText | None = None
        self.info_text_loc: str = "upper right"
        self.num_ticks = num_ticks
        self.show_value_left: bool = show_value_left
        self.show_value_right: bool = show_value_right
        self.mode: str | Literal["line", "scatter", "area"] = mode

        self.show_grid = show_grid
        self.grid_color = Color.from_optional(grid_color)
        self.grid_which = grid_which
        self.grid_axis = grid_axis
        self.grid_linestyle = grid_linestyle
        self.grid_linewidth = grid_linewidth

        self.ax_right = self.ax.twinx()
        self.ax_right.set_ylim(self.ax.get_ylim())
        self.ax_right.set_yticks([])

        self.ax_top = self.ax.twiny()
        self.ax_top.set_xlim(self.ax.get_xlim())

        self.tmin: np.datetime64 | None = None
        self.tmax: np.datetime64 | None = None

    def _set_info_text_loc(self, info_text_loc: str | None) -> None:
        if isinstance(info_text_loc, str):
            self.info_text_loc = info_text_loc

    def _set_axes(
        self,
        tmin: np.datetime64,
        tmax: np.datetime64,
        vmin: float | None,
        vmax: float | None,
        time: NDArray,
        tmin_original: np.datetime64 | None = None,
        tmax_original: np.datetime64 | None = None,
        longitude: NDArray | None = None,
        latitude: NDArray | None = None,
        ax_style_top: AlongTrackAxisStyle | str | None = None,
        ax_style_bottom: AlongTrackAxisStyle | str | None = None,
    ) -> "LineFigure":
        if ax_style_top is not None:
            self.ax_style_top = AlongTrackAxisStyle.from_input(self.ax_style_top)
        if ax_style_bottom is not None:
            self.ax_style_bottom = AlongTrackAxisStyle.from_input(self.ax_style_bottom)
        if not isinstance(tmin_original, np.datetime64):
            tmin_original = tmin
        if not isinstance(tmax_original, np.datetime64):
            tmax_original = tmax

        self.ax.set_xlim((tmin, tmax))  # type: ignore
        if vmin is not None and not np.isfinite(vmin):
            vmin = None
        if vmax is not None and not np.isfinite(vmax):
            vmax = None
        self.ax.set_ylim((vmin, vmax))  # type: ignore

        if self.show_grid:
            self.ax.grid(
                visible=self.show_grid,
                which=self.grid_which,
                axis=self.grid_axis,
                color=self.grid_color,
                linestyle=self.grid_linestyle,
                linewidth=self.grid_linewidth,
            )

        self.ax_right.set_ylim(self.ax.get_ylim())
        self.ax_top.set_xlim(self.ax.get_xlim())

        format_along_track_axis(
            self.ax,
            self.ax_style_bottom,
            time,
            tmin,
            tmax,
            tmin_original,
            tmax_original,
            longitude,
            latitude,
            num_ticks=self.num_ticks,
        )
        format_along_track_axis(
            self.ax_top,
            self.ax_style_top,
            time,
            tmin,
            tmax,
            tmin_original,
            tmax_original,
            longitude,
            latitude,
            num_ticks=self.num_ticks,
        )
        return self

    def plot(
        self,
        *,
        values: NDArray | None = None,
        time: NDArray | None = None,
        latitude: NDArray | None = None,
        longitude: NDArray | None = None,
        # Common args for wrappers
        mode: str | Literal["line", "scatter", "area"] | None = None,
        value_range: ValueRangeLike | None = None,
        log_scale: bool | None = None,
        norm: Normalize | None = None,
        time_range: TimeRangeLike | None = None,
        label: str | None = None,
        units: str | None = None,
        color: str | None = Color("ec:blue"),
        alpha: float = 1.0,
        linestyle: str | None = "solid",
        linewidth: float | int | None = 2.0,
        marker: str | None = "s",
        markersize: float | int | None = 2.0,
        selection_time_range: TimeRangeLike | None = None,
        selection_color: str | None = Color("ec:earthcare"),
        selection_linestyle: str | None = "dashed",
        selection_linewidth: float | int | None = 2.5,
        selection_highlight: bool = False,
        selection_highlight_inverted: bool = True,
        selection_highlight_color: str | None = Color("white"),
        selection_highlight_alpha: float = 0.5,
        selection_max_time_margin: (
            TimedeltaLike | Sequence[TimedeltaLike] | None
        ) = None,
        ax_style_top: AlongTrackAxisStyle | str | None = None,
        ax_style_bottom: AlongTrackAxisStyle | str | None = None,
        mark_profiles_at: Sequence[TimestampLike] | None = None,
        classes: Sequence[int] | dict[int, str] | None = None,
        classes_kwargs: dict[str, Any] = {},
        is_prob: bool = False,
        prob_labels: list[str] | None = None,
        prob_colors: list[ColorLike] | None = None,
        zorder: int | float | None = None,
        label_length: int = 20,
        **kwargs,
    ) -> "LineFigure":
        _zorder: float = 2.0
        if isinstance(zorder, (int, float)):
            _zorder = float(zorder)
        # Parse colors
        color = Color.from_optional(color)
        selection_color = Color.from_optional(selection_color)
        selection_highlight_color = Color.from_optional(selection_highlight_color)

        if isinstance(mode, str):
            if mode in ["line", "scatter", "area"]:
                self.mode = mode
            else:
                raise ValueError(
                    f'invalid `mode` "{mode}", expected either "line", "scatter" or "area"'
                )

        if isinstance(value_range, Iterable):
            if len(value_range) != 2:
                raise ValueError(
                    f"invalid `value_range`: {value_range}, expecting (vmin, vmax)"
                )
        else:
            value_range = (None, None)

        if isinstance(norm, Normalize):
            if log_scale == True and not isinstance(norm, LogNorm):
                norm = LogNorm(norm.vmin, norm.vmax)
            elif log_scale == False and isinstance(norm, LogNorm):
                norm = Normalize(norm.vmin, norm.vmax)
            if value_range[0] is not None:
                norm.vmin = value_range[0]  # type: ignore # FIXME
            if value_range[1] is not None:
                norm.vmax = value_range[1]  # type: ignore # FIXME
        else:
            if log_scale == True:
                norm = LogNorm(value_range[0], value_range[1])  # type: ignore # FIXME
            else:
                norm = Normalize(value_range[0], value_range[1])  # type: ignore # FIXME
        value_range = (norm.vmin, norm.vmax)

        values = np.asarray(values)
        time = np.asarray(time)
        if latitude is not None:
            latitude = np.asarray(latitude)
        if longitude is not None:
            longitude = np.asarray(longitude)

        # Validate inputs
        if is_prob:
            if len(values.shape) != 2:
                raise ValueError(
                    f"Since {is_prob=} values must be 2D, but has shape={values.shape}"
                )
        elif len(values.shape) != 1:
            raise ValueError(
                f"Since {is_prob=} values must be 1D, but has shape={values.shape}"
            )

        tmin_original = np.datetime64(to_timestamp(time[0]))
        tmax_original = np.datetime64(to_timestamp(time[-1]))
        vmin_original = values[0]
        vmax_original = values[-1]

        if selection_time_range is not None:
            self.selection_time_range = validate_time_range(selection_time_range)
            _selection_max_time_margin: tuple[pd.Timedelta, pd.Timedelta] | None = None
            if isinstance(selection_max_time_margin, (Sequence, np.ndarray)):
                _selection_max_time_margin = (
                    to_timedelta(selection_max_time_margin[0]),
                    to_timedelta(selection_max_time_margin[1]),
                )
            elif selection_max_time_margin is not None:
                _selection_max_time_margin = (
                    to_timedelta(selection_max_time_margin),
                    to_timedelta(selection_max_time_margin),
                )

            if _selection_max_time_margin is not None:
                time_range = [
                    np.max(
                        [
                            time[0],
                            (
                                self.selection_time_range[0]
                                - _selection_max_time_margin[0]
                            ).to_datetime64(),
                        ]
                    ),
                    np.min(
                        [
                            time[-1],
                            (
                                self.selection_time_range[1]
                                + _selection_max_time_margin[1]
                            ).to_datetime64(),
                        ]
                    ),
                ]

        if time_range is not None:
            if isinstance(time_range, Iterable) and len(time_range) == 2:
                for i in [0, -1]:
                    time_range = list(time_range)
                    if time_range[i] is None:
                        time_range[i] = time[i]
                    time_range = tuple(time_range)  # type: ignore
            time_range = (
                np.datetime64(to_timestamp(time_range[0])),
                np.datetime64(to_timestamp(time_range[-1])),
            )
            self.tmin = time_range[0]
            self.tmax = time_range[1]
        else:
            time_range = (time[0], time[-1])
        time_range = (
            np.datetime64(to_timestamp(time_range[0])),
            np.datetime64(to_timestamp(time_range[-1])),
        )

        _value_range: tuple[float, float] = select_value_range(
            data=values,
            value_range=value_range,
            pad_frac=0.0,
            use_min_max=True,
        )

        tmin = self.tmin or np.datetime64(time_range[0])
        tmax = self.tmax or np.datetime64(time_range[1])

        vmin: float = _value_range[0]
        vmax: float = _value_range[1]

        x: NDArray = time
        y: NDArray = values

        if is_prob:
            if label is None:
                label = "Probability"
            plot_stacked_propabilities(
                ax=self.ax,
                probabilities=values,
                time=time,
                labels=prob_labels,
                colors=prob_colors,
                zorder=_zorder,
                ax_label=label,
            )
            vmin = 0
            vmax = 1
        elif classes is not None:
            _yaxis_position = classes_kwargs.get("yaxis_position", "left")
            _is_left = _yaxis_position == "left"
            _label = format_var_label(label, units, label_len=label_length)

            plot_1d_integer_flag(
                ax=self.ax if _is_left else self.ax_right,
                ax2=self.ax_right if _is_left else self.ax,
                data=y,
                x=x,
                classes=classes,
                ax_label=_label,
                zorder=_zorder,
                **classes_kwargs,
            )
        else:
            line: list[Line2D] | PathCollection | PolyCollection
            if "line" in self.mode:
                line = self.ax.plot(
                    x,
                    y,
                    marker="none",
                    linewidth=linewidth,
                    linestyle=linestyle,
                    color=color,
                    alpha=alpha,
                    zorder=_zorder,
                )
            elif "scatter" in self.mode:
                line = self.ax.scatter(
                    x,
                    y,
                    marker=marker,
                    s=markersize,
                    color=color,
                    alpha=alpha,
                    zorder=_zorder,
                )
            elif "area" in self.mode:
                line = self.ax.fill_between(
                    x,
                    [0] * x.shape[0],
                    y,
                    color=color,
                    alpha=alpha,
                    zorder=zorder or 0.0,
                )
            else:
                raise ValueError(f"invalid `mode` {self.mode}")

            format_numeric_ticks(
                ax=self.ax,
                axis="y",
                label=format_var_label(label, units, label_len=label_length),
                max_line_length=label_length,
                show_label=self.show_value_left,
                show_values=self.show_value_left,
            )
            format_numeric_ticks(
                ax=self.ax_right,
                axis="y",
                label=format_var_label(label, units, label_len=label_length),
                max_line_length=label_length,
                show_label=self.show_value_right,
                show_values=self.show_value_right,
            )

        if selection_time_range is not None:
            if selection_highlight:
                if selection_highlight_inverted:
                    self.ax.axvspan(
                        tmin,  # type: ignore
                        self.selection_time_range[0],  # type: ignore
                        color=selection_highlight_color,
                        alpha=selection_highlight_alpha,
                        zorder=3,
                    )
                    self.ax.axvspan(
                        self.selection_time_range[1],  # type: ignore
                        tmax,  # type: ignore
                        color=selection_highlight_color,
                        alpha=selection_highlight_alpha,
                        zorder=3,
                    )
                else:
                    self.ax.axvspan(
                        self.selection_time_range[0],  # type: ignore
                        self.selection_time_range[1],  # type: ignore
                        color=selection_highlight_color,
                        alpha=selection_highlight_alpha,
                        zorder=3,
                    )

            for t in self.selection_time_range:  # type: ignore
                self.ax.axvline(
                    x=t,  # type: ignore
                    color=selection_color,
                    linestyle=selection_linestyle,
                    linewidth=selection_linewidth,
                    zorder=3,
                )

        self._set_axes(
            tmin=tmin,
            tmax=tmax,
            vmin=vmin,
            vmax=vmax,
            time=time,
            tmin_original=tmin_original,
            tmax_original=tmax_original,
            latitude=latitude,
            longitude=longitude,
            ax_style_top=ax_style_top,
            ax_style_bottom=ax_style_bottom,
        )

        if mark_profiles_at is not None:
            for t in to_timestamps(mark_profiles_at):
                self.ax.axvline(
                    t,  # type: ignore
                    color=selection_color,
                    linestyle="solid",
                    linewidth=selection_linewidth,
                    zorder=3,
                )

        return self

    def ecplot(
        self,
        ds: xr.Dataset,
        var: str,
        *,
        time_var: str = TIME_VAR,
        lat_var: str = TRACK_LAT_VAR,
        lon_var: str = TRACK_LON_VAR,
        along_track_dim: str = ALONG_TRACK_DIM,
        values: NDArray | None = None,
        time: NDArray | None = None,
        latitude: NDArray | None = None,
        longitude: NDArray | None = None,
        site: str | GroundSite | None = None,
        radius_km: float = 100.0,
        mark_closest_profile: bool = False,
        show_info: bool = True,
        info_text_loc: str | None = None,
        # Common args for wrappers
        mode: str | Literal["line", "scatter", "area"] | None = None,
        value_range: ValueRangeLike | None = None,
        log_scale: bool | None = None,
        norm: Normalize | None = None,
        time_range: TimeRangeLike | None = None,
        label: str | None = None,
        units: str | None = None,
        color: str | None = Color("ec:blue"),
        alpha: float = 1.0,
        linestyle: str | None = "solid",
        linewidth: float | int | None = 2.0,
        marker: str | None = "s",
        markersize: float | int | None = 2.0,
        selection_time_range: TimeRangeLike | None = None,
        selection_color: str | None = Color("ec:earthcare"),
        selection_linestyle: str | None = "dashed",
        selection_linewidth: float | int | None = 2.5,
        selection_highlight: bool = False,
        selection_highlight_inverted: bool = True,
        selection_highlight_color: str | None = Color("white"),
        selection_highlight_alpha: float = 0.5,
        selection_max_time_margin: (
            TimedeltaLike | Sequence[TimedeltaLike] | None
        ) = None,
        ax_style_top: AlongTrackAxisStyle | str | None = None,
        ax_style_bottom: AlongTrackAxisStyle | str | None = None,
        mark_profiles_at: Sequence[TimestampLike] | None = None,
        classes: Sequence[int] | dict[int, str] | None = None,
        classes_kwargs: dict[str, Any] = {},
        is_prob: bool = False,
        prob_labels: list[str] | None = None,
        prob_colors: list[ColorLike] | None = None,
        zorder: int | float | None = None,
        label_length: int = 20,
        **kwargs,
    ) -> "LineFigure":
        # Collect all common args for wrapped plot function call
        local_args = locals()
        # Delete all args specific to this wrapper function
        del local_args["self"]
        del local_args["ds"]
        del local_args["var"]
        del local_args["time_var"]
        del local_args["lat_var"]
        del local_args["lon_var"]
        del local_args["along_track_dim"]
        del local_args["site"]
        del local_args["radius_km"]
        del local_args["show_info"]
        del local_args["info_text_loc"]
        del local_args["mark_closest_profile"]
        # Delete kwargs to then merge it with the residual common args
        del local_args["kwargs"]
        all_args = {**local_args, **kwargs}

        if all_args["values"] is None:
            all_args["values"] = ds[var].values
        if all_args["time"] is None:
            all_args["time"] = ds[time_var].values
        if all_args["latitude"] is None:
            all_args["latitude"] = ds[lat_var].values
        if all_args["longitude"] is None:
            all_args["longitude"] = ds[lon_var].values

        # Set default values depending on variable name
        if label is None:
            all_args["label"] = (
                "Values" if not hasattr(ds[var], "long_name") else ds[var].long_name
            )
        if units is None:
            all_args["units"] = "-" if not hasattr(ds[var], "units") else ds[var].units
        if classes is not None and len(classes) > 0:
            all_args["value_range"] = (-0.5, len(classes) - 0.5)
        elif value_range is None and log_scale is None and norm is None:
            all_args["norm"] = get_default_norm(var, file_type=ds)

        # Handle overpass
        _site: GroundSite | None = None
        if isinstance(site, GroundSite):
            _site = site
        elif isinstance(site, str):
            _site = get_ground_site(site)
        else:
            pass

        if isinstance(_site, GroundSite):
            info_overpass = get_overpass_info(
                ds,
                radius_km=radius_km,
                site=_site,
                time_var=time_var,
                lat_var=lat_var,
                lon_var=lon_var,
                along_track_dim=along_track_dim,
            )
            overpass_time_range = info_overpass.time_range
            all_args["selection_time_range"] = overpass_time_range
            if mark_closest_profile:
                _mark_profiles_at = all_args["mark_profiles_at"]
                if isinstance(_mark_profiles_at, (Sequence, np.ndarray)):
                    list(_mark_profiles_at).append(info_overpass.closest_time)
                    all_args["mark_profiles_at"] = _mark_profiles_at
                else:
                    all_args["mark_profiles_at"] = [info_overpass.closest_time]

        self.plot(**all_args)

        self._set_info_text_loc(info_text_loc)
        if show_info:
            self.info_text = add_text_product_info(
                self.ax, ds, append_to=self.info_text, loc=self.info_text_loc
            )

        return self

    def to_texture(self) -> "LineFigure":
        # Remove anchored text and other artist text objects
        for artist in reversed(self.ax.artists):
            if isinstance(artist, (Text, AnchoredOffsetbox)):
                artist.remove()

        # Completely remove axis ticks and labels
        self.ax.axis("off")

        if self.ax_top:
            self.ax_top.axis("off")

        if self.ax_right:
            self.ax_right.axis("off")

        # Remove white frame around figure
        self.fig.subplots_adjust(left=0, right=1, bottom=0, top=1)

        return self

    def invert_xaxis(self) -> "LineFigure":
        """Invert the x-axis."""
        self.ax.invert_xaxis()
        if self.ax_top:
            self.ax_top.invert_xaxis()
        return self

    def invert_yaxis(self) -> "LineFigure":
        """Invert the y-axis."""
        self.ax.invert_yaxis()
        if self.ax_right:
            self.ax_right.invert_yaxis()
        return self

    def show(self) -> None:
        import IPython
        import matplotlib.pyplot as plt
        from IPython.display import display

        if IPython.get_ipython() is not None:
            display(self.fig)
        else:
            plt.show()

    def save(
        self,
        filename: str = "",
        filepath: str | None = None,
        ds: xr.Dataset | None = None,
        ds_filepath: str | None = None,
        dpi: float | Literal["figure"] = "figure",
        orbit_and_frame: str | None = None,
        utc_timestamp: TimestampLike | None = None,
        use_utc_creation_timestamp: bool = False,
        site_name: str | None = None,
        hmax: int | float | None = None,
        radius: int | float | None = None,
        extra: str | None = None,
        transparent_outside: bool = False,
        verbose: bool = True,
        print_prefix: str = "",
        create_dirs: bool = False,
        transparent_background: bool = False,
        resolution: str | None = None,
        **kwargs,
    ) -> None:
        """
        Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

        Args:
            figure (Figure | HasFigure): A figure object (`matplotlib.figure.Figure`) or objects exposing a `.fig` attribute containing a figure (e.g., `CurtainFigure`).
            filename (str, optional): The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.
            filepath (str | None, optional): The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.
            ds (xr.Dataset | None, optional): A EarthCARE dataset from which metadata will be taken. Defaults to None.
            ds_filepath (str | None, optional): A path to a EarthCARE product from which metadata will be taken. Defaults to None.
            pad (float, optional): Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.
            dpi (float | 'figure', optional): The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.
            orbit_and_frame (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            utc_timestamp (TimestampLike | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            use_utc_creation_timestamp (bool, optional): Whether the time of image creation should be included in the file name. Defaults to False.
            site_name (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            hmax (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            radius (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            resolution (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            extra (str | None, optional): A custom string to be included in the file name. Defaults to None.
            transparent_outside (bool, optional): Whether the area outside figures should be transparent. Defaults to False.
            verbose (bool, optional): Whether the progress of image creation should be printed to the console. Defaults to True.
            print_prefix (str, optional): A prefix string to all console messages. Defaults to "".
            create_dirs (bool, optional): Whether images should be saved in a folder structure based on provided metadata. Defaults to False.
            transparent_background (bool, optional): Whether the background inside and outside of figures should be transparent. Defaults to False.
            **kwargs (dict[str, Any]): Keyword arguments passed to wrapped function call of `matplotlib.pyplot.savefig`.
        """
        save_plot(
            fig=self.fig,
            filename=filename,
            filepath=filepath,
            ds=ds,
            ds_filepath=ds_filepath,
            dpi=dpi,
            orbit_and_frame=orbit_and_frame,
            utc_timestamp=utc_timestamp,
            use_utc_creation_timestamp=use_utc_creation_timestamp,
            site_name=site_name,
            hmax=hmax,
            radius=radius,
            extra=extra,
            transparent_outside=transparent_outside,
            verbose=verbose,
            print_prefix=print_prefix,
            create_dirs=create_dirs,
            transparent_background=transparent_background,
            resolution=resolution,
            **kwargs,
        )

invert_xaxis

invert_xaxis()

Invert the x-axis.

Source code in earthcarekit/plot/figure/line.py
def invert_xaxis(self) -> "LineFigure":
    """Invert the x-axis."""
    self.ax.invert_xaxis()
    if self.ax_top:
        self.ax_top.invert_xaxis()
    return self

invert_yaxis

invert_yaxis()

Invert the y-axis.

Source code in earthcarekit/plot/figure/line.py
def invert_yaxis(self) -> "LineFigure":
    """Invert the y-axis."""
    self.ax.invert_yaxis()
    if self.ax_right:
        self.ax_right.invert_yaxis()
    return self

save

save(
    filename="",
    filepath=None,
    ds=None,
    ds_filepath=None,
    dpi="figure",
    orbit_and_frame=None,
    utc_timestamp=None,
    use_utc_creation_timestamp=False,
    site_name=None,
    hmax=None,
    radius=None,
    extra=None,
    transparent_outside=False,
    verbose=True,
    print_prefix="",
    create_dirs=False,
    transparent_background=False,
    resolution=None,
    **kwargs
)

Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

Parameters:

Name Type Description Default
figure Figure | HasFigure

A figure object (matplotlib.figure.Figure) or objects exposing a .fig attribute containing a figure (e.g., CurtainFigure).

required
filename str

The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.

''
filepath str | None

The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.

None
ds Dataset | None

A EarthCARE dataset from which metadata will be taken. Defaults to None.

None
ds_filepath str | None

A path to a EarthCARE product from which metadata will be taken. Defaults to None.

None
pad float

Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.

required
dpi float | figure

The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.

'figure'
orbit_and_frame str | None

Metadata used in the formatting of the file name. Defaults to None.

None
utc_timestamp TimestampLike | None

Metadata used in the formatting of the file name. Defaults to None.

None
use_utc_creation_timestamp bool

Whether the time of image creation should be included in the file name. Defaults to False.

False
site_name str | None

Metadata used in the formatting of the file name. Defaults to None.

None
hmax int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
radius int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
resolution str | None

Metadata used in the formatting of the file name. Defaults to None.

None
extra str | None

A custom string to be included in the file name. Defaults to None.

None
transparent_outside bool

Whether the area outside figures should be transparent. Defaults to False.

False
verbose bool

Whether the progress of image creation should be printed to the console. Defaults to True.

True
print_prefix str

A prefix string to all console messages. Defaults to "".

''
create_dirs bool

Whether images should be saved in a folder structure based on provided metadata. Defaults to False.

False
transparent_background bool

Whether the background inside and outside of figures should be transparent. Defaults to False.

False
**kwargs dict[str, Any]

Keyword arguments passed to wrapped function call of matplotlib.pyplot.savefig.

{}
Source code in earthcarekit/plot/figure/line.py
def save(
    self,
    filename: str = "",
    filepath: str | None = None,
    ds: xr.Dataset | None = None,
    ds_filepath: str | None = None,
    dpi: float | Literal["figure"] = "figure",
    orbit_and_frame: str | None = None,
    utc_timestamp: TimestampLike | None = None,
    use_utc_creation_timestamp: bool = False,
    site_name: str | None = None,
    hmax: int | float | None = None,
    radius: int | float | None = None,
    extra: str | None = None,
    transparent_outside: bool = False,
    verbose: bool = True,
    print_prefix: str = "",
    create_dirs: bool = False,
    transparent_background: bool = False,
    resolution: str | None = None,
    **kwargs,
) -> None:
    """
    Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

    Args:
        figure (Figure | HasFigure): A figure object (`matplotlib.figure.Figure`) or objects exposing a `.fig` attribute containing a figure (e.g., `CurtainFigure`).
        filename (str, optional): The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.
        filepath (str | None, optional): The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.
        ds (xr.Dataset | None, optional): A EarthCARE dataset from which metadata will be taken. Defaults to None.
        ds_filepath (str | None, optional): A path to a EarthCARE product from which metadata will be taken. Defaults to None.
        pad (float, optional): Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.
        dpi (float | 'figure', optional): The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.
        orbit_and_frame (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        utc_timestamp (TimestampLike | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        use_utc_creation_timestamp (bool, optional): Whether the time of image creation should be included in the file name. Defaults to False.
        site_name (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        hmax (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        radius (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        resolution (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        extra (str | None, optional): A custom string to be included in the file name. Defaults to None.
        transparent_outside (bool, optional): Whether the area outside figures should be transparent. Defaults to False.
        verbose (bool, optional): Whether the progress of image creation should be printed to the console. Defaults to True.
        print_prefix (str, optional): A prefix string to all console messages. Defaults to "".
        create_dirs (bool, optional): Whether images should be saved in a folder structure based on provided metadata. Defaults to False.
        transparent_background (bool, optional): Whether the background inside and outside of figures should be transparent. Defaults to False.
        **kwargs (dict[str, Any]): Keyword arguments passed to wrapped function call of `matplotlib.pyplot.savefig`.
    """
    save_plot(
        fig=self.fig,
        filename=filename,
        filepath=filepath,
        ds=ds,
        ds_filepath=ds_filepath,
        dpi=dpi,
        orbit_and_frame=orbit_and_frame,
        utc_timestamp=utc_timestamp,
        use_utc_creation_timestamp=use_utc_creation_timestamp,
        site_name=site_name,
        hmax=hmax,
        radius=radius,
        extra=extra,
        transparent_outside=transparent_outside,
        verbose=verbose,
        print_prefix=print_prefix,
        create_dirs=create_dirs,
        transparent_background=transparent_background,
        resolution=resolution,
        **kwargs,
    )

MapFigure

Figure object for displaying EarthCARE satellite track and/or imager swaths on a global map.

This class sets up a georeferenced map canvas using a range of cartographic projections and visual styles. It serves as the basis for plotting 2D swath data (e.g., from MSI) or simple satellite tracks, optionally with info labels, backgrounds, and other styling options.

Parameters:

Name Type Description Default
ax Axes | None

Existing matplotlib axes to plot on; if not provided, new axes will be created. Defaults to None.

None
figsize tuple[float, float]

Figure size in inches. Defaults to (FIGURE_MAP_WIDTH, FIGURE_MAP_HEIGHT).

(FIGURE_MAP_WIDTH, FIGURE_MAP_HEIGHT)
dpi int | None

Resolution of the figure in dots per inch. Defaults to None.

None
title str | None

Title to display on the map. Defaults to None.

None
style str | Literal['none', 'stock_img', 'gray', 'osm', 'satellite', 'mtg', 'msg', 'blue_marble', 'land_ocean', 'land_ocean_lakes_rivers']

Style of the map's background image. Defaults to "gray".

'gray'
projection str | Projection

Map projection to use; options include "platecarree", "perspective", "orthographic", or a custom cartopy.crs.Projection. Defaults to ccrs.Orthographic().

Orthographic()
central_latitude float | None

Latitude at the center of the projection. Defaults to None.

None
central_longitude float | None

Longitude at the center of the projection. Defaults to None.

0.0
grid_color ColorLike | None

Color of grid lines. Defaults to None.

None
border_color ColorLike | None

Color of border box around the map. Defaults to None.

None
coastline_color ColorLike | None

Color of coastlines. Defaults to None.

None
show_grid bool

Whether to show latitude/longitude grid lines. Defaults to True.

True
show_top_labels bool

Whether to show tick labels on the top axis. Defaults to True.

True
show_bottom_labels bool

Whether to show tick labels on the bottom axis. Defaults to True.

True
show_right_labels bool

Whether to show tick labels on the right axis. Defaults to True.

True
show_left_labels bool

Whether to show tick labels on the left axis. Defaults to True.

True
show_text_time bool

Whether to display a datetime info text above the plot. Defaults to True.

True
show_text_frame bool

Whether to display a EarthCARE frame info text above the plot. Defaults to True.

True
show_text_overpass bool

Whether to display ground site overpass info in the plot. Defaults to True.

True
show_night_shade bool

Whether to overlay the nighttime shading based on timestamp. Defaults to True.

True
timestamp TimestampLike | None

Time reference used for nightshade overlay. Defaults to None.

None
extent Iterable | None

Map extent given as [lon_min, lon_max, lat_min, lat_max]; overrides auto zoom. Defaults to None.

None
lod int | None

Level of detail for choosen background map style image; higher values increase complexity. Defaults to None (meaning automatic selection).

None
coastlines_resolution str

Resolution of coastlines to display; options are "10m", "50m", or "110m". Defaults to "110m".

'110m'
azimuth float

Rotation of the cartopy.crs.ObliqueMercator projection, in degrees (if used). Defaults to 0.

0
pad float | list[float]

Padding applied when selecting a map extent. Defaults to 0.05.

0.05
background_alpha float

Transparency level of the background map style. Defaults to 1.0.

1.0
Source code in earthcarekit/plot/figure/map.py
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
class MapFigure:
    """Figure object for displaying EarthCARE satellite track and/or imager swaths on a global map.

    This class sets up a georeferenced map canvas using a range of cartographic projections and visual styles.
    It serves as the basis for plotting 2D swath data (e.g., from MSI) or simple satellite tracks, optionally
    with info labels, backgrounds, and other styling options.

    Args:
        ax (Axes | None, optional): Existing matplotlib axes to plot on; if not provided, new axes will be created. Defaults to None.
        figsize (tuple[float, float], optional): Figure size in inches. Defaults to (FIGURE_MAP_WIDTH, FIGURE_MAP_HEIGHT).
        dpi (int | None, optional): Resolution of the figure in dots per inch. Defaults to None.
        title (str | None, optional): Title to display on the map. Defaults to None.
        style (str | Literal["none", "stock_img", "gray", "osm", "satellite", "mtg", "msg", "blue_marble", "land_ocean", "land_ocean_lakes_rivers"], optional):
            Style of the map's background image. Defaults to "gray".
        projection (str | Projection, optional): Map projection to use; options include "platecarree", "perspective", "orthographic", or a custom `cartopy.crs.Projection`. Defaults to `ccrs.Orthographic()`.
        central_latitude (float | None, optional): Latitude at the center of the projection. Defaults to None.
        central_longitude (float | None, optional): Longitude at the center of the projection. Defaults to None.
        grid_color (ColorLike | None, optional): Color of grid lines. Defaults to None.
        border_color (ColorLike | None, optional): Color of border box around the map. Defaults to None.
        coastline_color (ColorLike | None, optional): Color of coastlines. Defaults to None.
        show_grid (bool, optional): Whether to show latitude/longitude grid lines. Defaults to True.
        show_top_labels (bool, optional): Whether to show tick labels on the top axis. Defaults to True.
        show_bottom_labels (bool, optional): Whether to show tick labels on the bottom axis. Defaults to True.
        show_right_labels (bool, optional): Whether to show tick labels on the right axis. Defaults to True.
        show_left_labels (bool, optional): Whether to show tick labels on the left axis. Defaults to True.
        show_text_time (bool, optional): Whether to display a datetime info text above the plot. Defaults to True.
        show_text_frame (bool, optional): Whether to display a EarthCARE frame info text above the plot. Defaults to True.
        show_text_overpass (bool, optional): Whether to display ground site overpass info in the plot. Defaults to True.
        show_night_shade (bool, optional): Whether to overlay the nighttime shading based on `timestamp`. Defaults to True.
        timestamp (TimestampLike | None, optional): Time reference used for nightshade overlay. Defaults to None.
        extent (Iterable | None, optional): Map extent given as [lon_min, lon_max, lat_min, lat_max]; overrides auto zoom. Defaults to None.
        lod (int | None, optional): Level of detail for choosen background map style image; higher values increase complexity. Defaults to None (meaning automatic selection).
        coastlines_resolution (str, optional): Resolution of coastlines to display; options are "10m", "50m", or "110m". Defaults to "110m".
        azimuth (float, optional): Rotation of the `cartopy.crs.ObliqueMercator` projection, in degrees (if used). Defaults to 0.
        pad (float | list[float], optional): Padding applied when selecting a map extent. Defaults to 0.05.
        background_alpha (float, optional): Transparency level of the background map style. Defaults to 1.0.
    """

    def __init__(
        self,
        ax: Axes | None = None,
        figsize: tuple[float, float] = (FIGURE_MAP_WIDTH, FIGURE_MAP_HEIGHT),
        dpi: int | None = None,
        title: str | None = None,
        style: (
            str
            | Literal[
                "none",
                "stock_img",
                "gray",
                "osm",
                "satellite",
                "mtg",
                "msg",
                "blue_marble",
                "land_ocean",
                "land_ocean_lakes_rivers",
            ]
        ) = "gray",
        projection: (
            Literal["platecarree", "perspective", "orthographic"] | ccrs.Projection
        ) = ccrs.Orthographic(),
        central_latitude: float | ArrayLike | None = None,
        central_longitude: float | ArrayLike | None = 0.0,
        grid_color: ColorLike | None = None,
        border_color: ColorLike | None = None,
        coastline_color: ColorLike | None = None,
        show_grid: bool = True,
        show_grid_labels: bool = True,
        show_geo_labels: bool = True,
        show_top_labels: bool = True,
        show_bottom_labels: bool = True,
        show_right_labels: bool = True,
        show_left_labels: bool = True,
        show_text_time: bool = True,
        show_text_frame: bool = True,
        show_text_overpass: bool = True,
        show_night_shade: bool = True,
        timestamp: TimestampLike | None = None,
        extent: Iterable | None = None,
        lod: int | None = None,
        coastlines_resolution: Literal["10m", "50m", "110m"] = "110m",
        azimuth: float = 0,
        pad: float | list[float] = 0.05,
        background_alpha: float = 1.0,
        colorbar_tick_scale: float | None = None,
        fig_height_scale: float = 1.0,
        fig_width_scale: float = 1.0,
        land_color: ColorLike | None = None,
        ocean_color: ColorLike | None = None,
        lakes_color: ColorLike | None = None,
        rivers_color: ColorLike | None = None,
    ):
        figsize = (figsize[0] * fig_width_scale, figsize[1] * fig_height_scale)
        self.figsize = _validate_figsize(figsize)
        self.fig, self.ax = _ensure_figure_and_main_axis(ax, figsize=figsize, dpi=dpi)

        self.dpi = dpi
        self.title = title
        self.style = style
        self.grid_color = Color.from_optional(grid_color)
        self.border_color = Color.from_optional(border_color)
        self.coastline_color = Color.from_optional(coastline_color)
        self.land_color = Color.from_optional(land_color)
        self.ocean_color = Color.from_optional(ocean_color)
        self.lakes_color = Color.from_optional(lakes_color)
        self.rivers_color = Color.from_optional(rivers_color)
        self.show_grid = show_grid
        self.show_grid_labels = show_grid_labels
        self.show_geo_labels = show_grid_labels and show_geo_labels
        self.show_top_labels = show_grid_labels and show_top_labels
        self.show_bottom_labels = show_grid_labels and show_bottom_labels
        self.show_right_labels = show_grid_labels and show_right_labels
        self.show_left_labels = show_grid_labels and show_left_labels
        if (
            not self.show_top_labels
            and not self.show_bottom_labels
            and not self.show_right_labels
            and not self.show_left_labels
        ):
            self.show_grid_labels = False
        self.show_text_time = show_text_time
        self.show_text_frame = show_text_frame
        self.show_text_overpass = show_text_overpass
        if timestamp is not None:
            timestamp = to_timestamp(timestamp)
        self.timestamp = timestamp
        self.extent: list | None = None
        if isinstance(extent, Iterable):
            self.extent = list(extent)

        if central_latitude is not None and central_longitude is not None:
            central_latitude, central_longitude = get_central_coords(
                central_latitude, central_longitude
            )
        else:
            if central_latitude is not None:
                central_latitude = get_central_latitude(central_latitude)
            if central_longitude is not None:
                central_longitude = get_central_longitude(central_longitude)
        self.projection_type, clat, clon = _validate_projection(projection)
        self.central_latitude: float | None = central_latitude
        self.central_longitude: float | None = central_longitude
        if central_latitude is None:
            self.central_latitude = clat
        if central_longitude is None:
            self.central_longitude = clon

        self._inital_lod: int | None = lod
        self.lod: int = 2 if lod is None else lod
        self.coastlines_resolution = coastlines_resolution
        self.azimuth = azimuth
        self.colorbar: Colorbar | None = None
        self.colorbar_tick_scale: float | None = colorbar_tick_scale
        self.pad = _validate_pad(pad)
        self.background_alpha = background_alpha

        self.grid_lines: Gridliner | None = None

        self.show_night_shade = show_night_shade

        with warnings.catch_warnings():
            warnings.simplefilter("ignore", UserWarning)
            self._init_axes()

    def set_view(
        self,
        latitude: ArrayLike,
        longitude: ArrayLike,
        pad: float | Iterable | None = None,
    ) -> "MapFigure":
        """
        Fits the plot extent to the given latitude and longitude values.

        Args:
            latitude (ArrayLike): Latitude values.
            longitude (ArrayLike): Longitude values.
            pad (float | Iterable | None, optional):
                Padding or margins around the given lat/lon values.
                The padding is applied relative to the min/max difference along the respective lat/lon extent,
                e.g., `lats=[-5,5]` and `pad=0` -> lat extent=[-5,5], `pad=1` -> lat extent=[-15,15], `pad=2` -> lat extent=[-25,25], etc.
                Can be given as single number or as a 4-element list, i.e., [left/west, right/east, bottom/south, top/north].
                Defaults to None.

        Returns:
            Axes: _description_
        """
        if isinstance(pad, (float | int | Iterable)):
            self.pad = _validate_pad(pad)
        self.ax = set_view(
            self.ax,
            self.projection,
            latitude,
            longitude,
            pad_xmin=self.pad[0],
            pad_xmax=self.pad[1],
            pad_ymin=self.pad[2],
            pad_ymax=self.pad[3],
        )
        return self

    def set_extent(
        self, extent: list | None = None, pad: float | Iterable | None = None
    ) -> "MapFigure":
        if isinstance(extent, Iterable):
            self.extent = extent
            self.set_view(
                longitude=np.array(self.extent[0:2]),
                latitude=np.array(self.extent[2:4]),
                pad=pad,
            )
        return self

    def _init_axes(self) -> None:
        if self.projection_type == ccrs.PlateCarree:
            self.projection = self.projection_type(self.central_longitude)
        elif self.projection_type == ccrs.NearsidePerspective:
            self.projection = self.projection_type(
                central_longitude=self.central_longitude,
                central_latitude=self.central_latitude,
            )
        elif self.projection_type == ccrs.Orthographic:
            self.projection = self.projection_type(
                central_longitude=self.central_longitude,
                central_latitude=self.central_latitude,
            )
        elif self.projection_type == ccrs.ObliqueMercator:
            if self.central_longitude is None:
                self.central_longitude = 0
            if self.central_latitude is None:
                self.central_latitude = 0
            self.projection = self.projection_type(
                central_longitude=self.central_longitude,
                central_latitude=self.central_latitude,
                azimuth=self.azimuth,
            )
        elif self.projection_type == ccrs.Stereographic:
            self.projection = self.projection_type(
                central_longitude=self.central_longitude,
                central_latitude=self.central_latitude,
            )
        else:
            self.projection = self.projection_type()
        self.transform = ccrs.Geodetic()  # ccrs.PlateCarree()

        # making sure axis projection is setup correctly
        if not isinstance(self.ax, Axes):
            self.fig, self.ax = plt.subplots(
                subplot_kw={"projection": self.projection}, figsize=self.figsize
            )
        elif not (
            hasattr(self.ax, "projection")
            and type(self.ax.projection) == type(self.projection)
        ):
            tmp = self.ax.get_figure()
            if not isinstance(tmp, (Figure, SubFigure)):
                raise ValueError(f"Invalid Figure")
            self.fig = tmp  # type: ignore
            self.ax = self.ax

            pos = self.ax.get_position()
            self.ax.remove()
            self.ax = self.fig.add_subplot(pos, projection=self.projection)  # type: ignore

        # self.ax.set_facecolor("white")
        # self.ax.set_facecolor("none")

        if self.title:
            self.fig.suptitle(self.title)

        self.ax.axis("equal")

        # Earth image
        grid_color = Color("#000000")
        coastline_color = Color("#000000")

        if not isinstance(self.style, str):
            raise TypeError(
                f"style has wrong type '{type(self.style).__name__}'. Expected 'str'"
            )

        _cfeatures = _get_cfeatures_from_style(self.style)

        if len(_cfeatures) != 0 and not (
            len(_cfeatures) == 1 and _cfeatures[0] == "gray"
        ):
            with warnings.catch_warnings():
                warnings.simplefilter("ignore", UserWarning)

                if "gray" in _cfeatures:
                    land_color = "#F6F6F6"
                    ocean_color = "#C6C6C6"
                    lakes_color = "#D0D0D0"
                    rivers_color = "#DEDEDE"
                else:
                    land_color = "#F4F3E3"
                    ocean_color = "#B8C9D3"
                    lakes_color = "#C4D1D6"
                    rivers_color = "#D6DEDB"

                if isinstance(self.land_color, Color):
                    land_color = self.land_color.hex
                if isinstance(self.ocean_color, Color):
                    ocean_color = self.ocean_color.hex
                if isinstance(self.lakes_color, Color):
                    lakes_color = self.lakes_color.hex
                if isinstance(self.rivers_color, Color):
                    rivers_color = self.rivers_color.hex

                if "land" in _cfeatures:
                    self.ax.add_feature(cfeature.LAND.with_scale(self.coastlines_resolution), facecolor=land_color)  # type: ignore
                if "ocean" in _cfeatures:
                    self.ax.add_feature(cfeature.OCEAN.with_scale(self.coastlines_resolution), facecolor=ocean_color)  # type: ignore
                if "lakes" in _cfeatures:
                    self.ax.add_feature(cfeature.LAKES.with_scale(self.coastlines_resolution), facecolor=lakes_color)  # type: ignore
                if "rivers" in _cfeatures:
                    self.ax.add_feature(cfeature.RIVERS.with_scale(self.coastlines_resolution), edgecolor=rivers_color)  # type: ignore
        elif self.style == "none":
            pass
        elif self.style == "stock_img":
            grid_color = Color("#3f4d53")
            coastline_color = Color("#537585")
            img = self.ax.stock_img()  # type: ignore
        elif self.style == "gray":
            img = add_gray_stock_img(self.ax)
            grid_color = Color("#6d6d6db3")
            coastline_color = Color("#C0C0C0")
        elif self.style == "osm":
            try:
                request = cimgt.OSM()
                img = self.ax.add_image(
                    request,
                    self.lod,
                    interpolation="spline36",
                    regrid_shape=2000,
                )  # type: ignore
                grid_color = Color("#6d6d6db3")
                coastline_color = Color("#C0C0C0")
            except Exception as e:
                msg = f"Failed to load OSM tiles, using stock_img as fallback instead.\nOriginal error: {repr(e)}"
                warnings.warn(msg, UserWarning, stacklevel=2)
                img = self.ax.stock_img()  # type: ignore
        elif self.style == "satellite":
            try:
                request = cimgt.QuadtreeTiles()
                img = self.ax.add_image(
                    request,
                    self.lod,
                    interpolation="spline36",
                    regrid_shape=2000,
                )  # type: ignore
                grid_color = Color("#C0C0C099")
                coastline_color = Color("#C0C0C099")
            except Exception as e:
                msg = f"Failed to load QuadtreeTiles tiles, using stock_img as fallback instead.\nOriginal error: {repr(e)}"
                warnings.warn(msg, UserWarning, stacklevel=2)
                img = self.ax.stock_img()  # type: ignore
        elif self.style == "blue_marble":
            try:
                wms = WebMapService(
                    "https://gibs.earthdata.nasa.gov/wms/epsg4326/best/wms.cgi?",
                    version="1.1.1",
                )
                layer = "BlueMarble_ShadedRelief_Bathymetry"
                img = self.ax.add_wms(wms, layer)  # type: ignore
                grid_color = Color("#C7C7C799")
                coastline_color = Color("#74BBD180")

                width, height = 1024, 512
                white_overlay = np.ones((height, width, 4))
                white_overlay[..., 3] = 0.2

                self.ax.imshow(
                    white_overlay,
                    origin="upper",
                    extent=(-180.0, 180.0, -90.0, 90.0),
                    transform=ccrs.PlateCarree(),
                )
            except Exception as e:
                msg = f"Failed to load BlueMarble_ShadedRelief_Bathymetry tiles, using stock_img as fallback instead.\nOriginal error: {repr(e)}"
                warnings.warn(msg, UserWarning, stacklevel=2)
                img = self.ax.stock_img()  # type: ignore
        else:
            if not isinstance(self.timestamp, pd.Timestamp):
                msg = f"Missing timestamp for {self.style.upper()} data request for 'https://view.eumetsat.int' (timestamp={self.timestamp})"
                warnings.warn(msg)
            else:
                try:
                    if self.style == "mtg":
                        if self.timestamp < to_timestamp("2024-09-23T02:00"):
                            self.style = "msg"
                            msg = (
                                f"Switching to MSG since MTG is only available from 2024-09-23 02:00 UTC onwards"
                                f"(timestamp given: {time_to_iso(self.timestamp, format='%Y-%m-%d %H:%M:%S')})"
                            )
                            warnings.warn(msg)
                    img = add_gray_stock_img(self.ax)
                    grid_color = Color("#3f4d53")
                    coastline_color = Color("white").blend(0.5)  # Color("#3f4d53")

                    date_str = (
                        pd.Timestamp(self.timestamp, tz="UTC")
                        .round("h")
                        .isoformat()
                        .replace("+00:00", "Z")
                    )
                    # Connect to NASA GIBS
                    url = "https://view.eumetsat.int/geoserver/ows"

                    wms = WebMapService(url)
                    if self.style == "mtg":
                        layer = "mtg_fd:rgb_geocolour"  # "mtg_fd:ir105_hrfi" #"mumi:worldcloudmap_ir108" #"MODIS_Terra_SurfaceReflectance_Bands143"
                    elif self.style == "msg":
                        layer = "msg_fes:rgb_naturalenhncd"
                    elif self.style == "nasa":
                        wms = WebMapService(
                            "https://gibs.earthdata.nasa.gov/wms/epsg4326/best/wms.cgi?",
                            version="1.1.1",
                        )
                        layer = "MODIS_Terra_CorrectedReflectance_TrueColor"
                    elif "nasa:" in self.style:
                        self.style = self.style.replace("nasa:", "")
                        layer = self.style
                        wms = WebMapService(
                            "https://gibs.earthdata.nasa.gov/wms/epsg4326/best/wms.cgi?",
                            version="1.1.1",
                        )
                    else:
                        layer = self.style
                        # raise NotImplementedError()
                    wms_kwargs = {
                        "time": date_str,
                    }

                    self.ax.add_wms(wms, layer, wms_kwargs=wms_kwargs)  # type: ignore
                except Exception as e:
                    msg = f"Failed to load '{self.style}' tiles, using stock_img as fallback instead.\nOriginal error: {repr(e)}"
                    warnings.warn(msg, UserWarning, stacklevel=2)
                    img = self.ax.stock_img()  # type: ignore

        # Overlay white transparent layer
        if self.background_alpha < 1.0:
            width, height = 1024, 512
            white_overlay = np.ones((height, width, 4))
            white_overlay[..., 3] = 1 - self.background_alpha

            self.ax.imshow(
                white_overlay,
                origin="upper",
                transform=ccrs.PlateCarree(),
            )
        # else:
        #     raise ValueError(
        #         f'invalid style "{self.style}". Valid styles are: "gray", "osm", "satellite"'
        #     )

        # Grid lines
        _grid_color = self.grid_color
        if _grid_color is None:
            _grid_color = grid_color

        _border_color = self.border_color
        if _border_color is None:
            _border_color = _grid_color

        _coastline_color = self.coastline_color
        if _coastline_color is None:
            _coastline_color = coastline_color

        if self.show_grid:
            self.grid_lines = self.ax.gridlines(draw_labels=True, color=_grid_color, linewidth=0.5, linestyle="dashed")  # type: ignore
            self.grid_lines.geo_labels = self.show_geo_labels
            self.grid_lines.top_labels = self.show_top_labels
            self.grid_lines.bottom_labels = self.show_bottom_labels
            self.grid_lines.right_labels = self.show_right_labels
            self.grid_lines.left_labels = self.show_left_labels
        self.ax.add_feature(cfeature.COASTLINE.with_scale(self.coastlines_resolution), edgecolor=_coastline_color)  # type: ignore
        self.ax.spines["geo"].set_edgecolor(_border_color)

        # Night shade
        if self.timestamp is not None:
            self.timestamp = to_timestamp(self.timestamp)
            if self.show_night_shade:
                night_shade_alpha = 0.15
                night_shade_color = Color("#000000")
                self.ax.add_feature(  # type: ignore
                    Nightshade(
                        self.timestamp,
                        alpha=night_shade_alpha,
                        color=night_shade_color,
                        linewidth=0,
                    )
                )  # type: ignore

    def plot_track(
        self,
        latitude: NDArray,
        longitude: NDArray,
        marker: str | None = None,
        markersize: float | int | None = None,
        linestyle: str | None = None,
        linewidth: float | int = 2,
        color: Color | ColorLike | None = None,
        alpha: float | None = 1.0,
        highlight_first: bool = True,
        highlight_first_color: Color | None = None,
        highlight_last: bool = True,
        highlight_last_color: Color | None = None,
        zorder: float = 4,
        z: NDArray | None = None,
        cmap: Cmap | str = "viridis",
        value_range: ValueRangeLike | None = None,
        log_scale: bool | None = None,
        norm: Normalize | None = None,
        show_border: bool = False,
        border_linewidth: float = 1,
        border_color="black",
        colorbar: bool = True,
        colorbar_position: str | Literal["left", "right", "top", "bottom"] = "bottom",
        colorbar_alignment: str | Literal["left", "center", "right"] = "center",
        colorbar_width: float = DEFAULT_COLORBAR_WIDTH,
        colorbar_spacing: float = 0.3,
        colorbar_length_ratio: float | str = "100%",
        colorbar_label_outside: bool = True,
        colorbar_ticks_outside: bool = True,
        colorbar_ticks_both: bool = False,
        label: str = "",
        units: str = "",
        line_overlap: int = 20,
    ) -> "MapFigure":
        latitude = np.asarray(latitude)
        longitude = np.asarray(longitude)

        if z is not None:
            z = np.asarray(z)
            line_overlap = min(line_overlap, int(len(z) * 0.01))
            cmap, value_range, norm = self._init_cmap(
                cmap, value_range, log_scale, norm
            )

            coords = np.column_stack([longitude, latitude])
            segments = [s for s in np.stack([coords[:-1], coords[1:]], axis=1)]
            coords_borders = np.array(
                [coords[0]]
                + [
                    get_coord_between(s[0][::-1], s[1][::-1])[::-1] for s in segments
                ]  # Reverse lon/lat to lat/lon for get_coord_between and back again
                + [coords[-1]] * (line_overlap + 1)
            )
            segments = [
                s for s in np.stack([coords_borders[:-1], coords_borders[1:]], axis=1)
            ]

            def _stack_points(points, line_overlap):
                n_stacks = line_overlap + 2
                return np.stack(
                    [
                        points[i : len(points) - (n_stacks - 1) + i]
                        for i in range(n_stacks)
                    ],
                    axis=1,
                )

            segments = [
                s for s in _stack_points(coords_borders, line_overlap=line_overlap)
            ]
            z_segments = z

            if show_border:
                _l_border = self.ax.plot(
                    coords[:, 0],
                    coords[:, 1],
                    linestyle="solid",
                    linewidth=linewidth + border_linewidth * 2,
                    transform=self.transform,
                    zorder=zorder,
                    color=border_color,
                    solid_capstyle="butt",
                )

            _lc = LineCollection(
                segments,
                cmap=cmap,
                norm=norm,
                linewidth=linewidth,
                transform=self.transform,
                zorder=zorder,
                antialiased=True,
            )
            _lc.set_array(z_segments)
            self.ax.add_collection(_lc)

            if colorbar and not self.colorbar:
                cb_kwargs = dict(
                    label=format_var_label(label, units),
                    position=colorbar_position,
                    alignment=colorbar_alignment,
                    width=colorbar_width,
                    spacing=colorbar_spacing,
                    length_ratio=colorbar_length_ratio,
                    label_outside=colorbar_label_outside,
                    ticks_outside=colorbar_ticks_outside,
                    ticks_both=colorbar_ticks_both,
                )
                self.colorbar = add_colorbar(
                    fig=self.fig,
                    ax=self.ax,
                    data=_lc,
                    cmap=cmap,
                    **cb_kwargs,  # type: ignore
                )
                self.set_colorbar_tick_scale(multiplier=self.colorbar_tick_scale)

            return self

        color = Color.from_optional(color)
        highlight_first_color = Color.from_optional(highlight_first_color)
        highlight_last_color = Color.from_optional(highlight_last_color)

        _alpha = alpha
        if isinstance(color, Color):
            if _alpha is not None:
                _alpha = _alpha * color.alpha
            else:
                _alpha = color.alpha

        p = self.ax.plot(
            longitude,
            latitude,
            marker=marker,
            markersize=markersize,
            linestyle=linestyle,
            linewidth=linewidth,
            zorder=zorder,
            transform=self.transform,
            color=color,
            alpha=_alpha,
            markeredgewidth=linewidth,
        )
        color = p[0].get_color()  # type: ignore
        if highlight_first_color is None:
            highlight_first_color = color
        if highlight_last_color is None:
            highlight_last_color = color

        _alpha = alpha
        if isinstance(highlight_first_color, Color):
            if _alpha is not None:
                _alpha = _alpha * highlight_first_color.alpha
            else:
                _alpha = highlight_first_color.alpha

        if highlight_first:
            self.ax.plot(
                [longitude[0]],
                [latitude[0]],
                marker="o",
                markersize=markersize,
                linestyle="none",
                zorder=zorder if zorder is not None else 4,
                transform=self.transform,
                color=highlight_first_color,
                alpha=_alpha,
            )

        if highlight_last:
            tmp_i = 0
            for i in range(len(longitude)):
                if (longitude[-1], latitude[-1]) != (
                    longitude[-2 - i],
                    latitude[-2 - i],
                ):
                    tmp_i = -2 - i
                    break
            arrow_style = get_arrow_style(linewidth)
            c1 = (longitude[-1], latitude[-1])
            c2 = (longitude[tmp_i], latitude[tmp_i])
            c3 = tuple(get_coord_between((c1[1], c1[0]), (c2[1], c2[0]), 0.2))
            c3 = (c3[1], c3[0])

            _alpha = alpha
            if isinstance(highlight_last_color, Color):
                if _alpha is not None:
                    _alpha = _alpha * highlight_last_color.alpha
                else:
                    _alpha = highlight_last_color.alpha

            self.ax.annotate(
                "",
                xy=c1,
                xytext=c3,
                transform=self.transform,
                clip_on=True,
                annotation_clip=True,
                arrowprops=dict(
                    arrowstyle=arrow_style,
                    color=highlight_last_color,
                    lw=linewidth,
                    shrinkA=0,
                    shrinkB=0,
                    alpha=_alpha,
                    connectionstyle="arc3,rad=0",
                    mutation_scale=10,
                ),
                zorder=zorder,
            )
        return self

    def plot_text(
        self,
        latitude: int | float,
        longitude: int | float,
        text: str,
        color: Color | ColorLike | None = "black",
        text_side: Literal["left", "right", "center"] = "left",
        zorder: int | float = 8,
        padding: str = "  ",
        rotation: int = 0,
        fontdict: dict[str, Any] | None = None,
        show_shade: bool = True,
        color_shade: Color | ColorLike | None = None,
        alpha_shade: float = 0.8,
    ) -> "MapFigure":
        if isinstance(text_side, str):
            if text_side == "center":
                horizontalalignment = "center"
            elif text_side == "left":
                horizontalalignment = "right"
                text = f"{text}{padding}"
            elif text_side == "right":
                horizontalalignment = "left"
                text = f"{padding}{text}"
            else:
                raise ValueError(
                    f'got invalid text_side "{text_side}". expected "left" or "right".'
                )
        else:
            raise TypeError(
                f"""invalid type '{type(text_side).__name__}' for text_side. expected type 'str': "left" or "right"."""
            )

        t = self.ax.text(
            longitude,
            latitude,
            text,
            color=color,
            verticalalignment="center",
            horizontalalignment=horizontalalignment,
            transform=self.transform,
            zorder=zorder,
            clip_on=True,
            rotation=rotation,
            rotation_mode="anchor",
            fontdict=fontdict,
        )
        if show_shade:
            t = add_shade_to_text(
                t,
                color=color_shade,
                alpha=alpha_shade,
            )
        return self

    def plot_point(
        self,
        latitude: int | float,
        longitude: int | float,
        marker: str | None = "D",
        markersize: int | float = 5,
        color: Color | ColorLike | None = "black",
        alpha: float = 1.0,
        edgecolor: Color | ColorLike | None = "white",
        edgealpha: float = 0.8,
        zorder: int | float = 4,
        text: str | None = None,
        text_color: Color | ColorLike | None = "black",
        text_side: Literal["left", "right", "center"] = "right",
        text_zorder: int | float = 8,
        text_padding: str = "  ",
        text_alpha_shade: float = 0.8,
        text_fontdict: dict[str, Any] | None = None,
    ) -> "MapFigure":
        _color = Color.from_optional(color, alpha=alpha)
        _edgecolor = Color.from_optional(edgecolor, alpha=edgealpha)
        self.ax.plot(
            [longitude],
            [latitude],
            marker=marker,
            markersize=markersize,
            linestyle="none",
            transform=self.transform,
            color=_color,
            zorder=zorder,
            markerfacecolor=_color,
            markeredgecolor=_edgecolor,
        )
        if isinstance(text, str):
            self.plot_text(
                latitude=latitude,
                longitude=longitude,
                text=text,
                color=text_color,
                text_side=text_side,
                zorder=text_zorder,
                padding=text_padding,
                alpha_shade=text_alpha_shade,
                fontdict=text_fontdict,
            )
        return self

    def plot_radius(
        self,
        latitude: int | float,
        longitude: int | float,
        radius_km: int | float,
        color: Color | ColorLike | None = "#000000",
        face_color: Color | ColorLike | None = "#FFFFFF00",
        edge_color: Color | ColorLike | None = None,
        text_color: Color | ColorLike | None = None,
        point_color: Color | ColorLike | None = None,
        edge_alpha: float = 0.8,
        text: str | None = None,
        text_side: Literal["left", "right"] = "right",
        marker: str | None = "D",
        zorder: int | float = 4,
        text_zorder: int | float = 8,
    ) -> "MapFigure":
        _color: Color | None = Color.from_optional(color)
        _face_color = Color.from_optional(face_color) or Color("#FFFFFF00")
        _edge_color = Color.from_optional(edge_color) or _color
        _text_color = Color.from_optional(text_color) or Color("#000000")
        _point_color = Color.from_optional(point_color) or _color
        if isinstance(_edge_color, Color):
            _edge_color = _edge_color.set_alpha(edge_alpha)

        # Draw circle
        # TODO: workaround to avoid annoying warnings, need to change this later!
        with warnings.catch_warnings():
            warnings.filterwarnings(
                "ignore", message="Approximating coordinate system*"
            )
            self.ax.tissot(  # type: ignore
                rad_km=radius_km,
                lons=longitude,
                lats=latitude,
                n_samples=128,
                facecolor=_face_color,
                edgecolor=_edge_color,
                zorder=zorder,
            )  # type: ignore

        # Draw center point
        self.plot_point(
            longitude=longitude,
            latitude=latitude,
            marker=marker,
            markersize=5,
            color=_point_color,
            zorder=zorder,
            text=text,
            text_color=_text_color,
            text_side=text_side,
            text_zorder=text_zorder,
            text_padding="  ",
        )

        return self

    def _plot_overpass(
        self,
        lat_selection: NDArray,
        lon_selection: NDArray,
        lat_total: NDArray,
        lon_total: NDArray,
        site: GroundSite,
        radius_km: int | float,
        site_color: Color | ColorLike | None = "black",
        radius_color: Color | ColorLike | None = None,
        color_selection: Color | ColorLike | None = "ec:earthcare",
        linewidth_selection: float = 3,
        linestyle_selection: str | None = "solid",
        color_total: Color | ColorLike | None = "ec:blue",
        linewidth_total: float = 2.5,
        linestyle_total: str | None = "solid",
        site_text_side: Literal["left", "right"] = "right",
        timestamp: pd.Timestamp | None = None,
        view: Literal["global", "data", "overpass"] = "overpass",
        show_highlights: bool = True,
    ) -> "MapFigure":
        if radius_color is None:
            if self.style in ["satellite", "blue_marble"]:
                radius_color = "white"
            elif self.style in ["gray"]:
                radius_color = "black"

        lat_selection = np.asarray(lat_selection)
        lon_selection = np.asarray(lon_selection)
        lat_total = np.asarray(lat_total)
        lon_total = np.asarray(lon_total)

        site_lat = site.latitude
        site_lon = site.longitude
        site_alt = site.altitude
        site_name = site.name

        self.central_latitude = site_lat
        self.central_longitude = site_lon

        if view == "overpass":
            if isinstance(self._inital_lod, int):
                self.lod = self._inital_lod
            else:
                self.lod = get_osm_lod(
                    (lat_selection[0], lon_selection[0]),
                    (lat_selection[-1], lon_selection[-1]),
                    distance_km=radius_km * 2,
                )
            self.coastlines_resolution = "10m"
        elif view == "data":
            if isinstance(self._inital_lod, int):
                self.lod = self._inital_lod
            else:
                self.lod = get_osm_lod(
                    (lat_total[0], lon_total[0]), (lat_total[-1], lon_total[-1])
                )
            self.coastlines_resolution = "50m"
        else:
            if isinstance(self._inital_lod, int):
                self.lod = self._inital_lod
            else:
                self.lod = 2
            self.coastlines_resolution = "110m"

        if timestamp is not None:
            self.timestamp = to_timestamp(timestamp)

        pos = self.ax.get_position()
        self.fig.delaxes(self.ax)
        self.ax = self.fig.add_axes(pos)  # type: ignore
        self._init_axes()

        # FIXME: workaround to avoid annoying warnings, need to change this later!
        warnings.filterwarnings("ignore", message="Approximating coordinate system*")
        self.plot_radius(
            latitude=site_lat,
            longitude=site_lon,
            radius_km=radius_km,
            text=site_name,
            text_side=site_text_side,
            color=radius_color,
            point_color=site_color,
            text_color=site_color,
        )

        highlight_last = False if view == "overpass" else True
        self.plot_track(
            latitude=lat_total,
            longitude=lon_total,
            linewidth=linewidth_total,
            linestyle=linestyle_total,
            highlight_first=show_highlights and False,
            highlight_last=show_highlights and highlight_last,
            color=color_total,
        )
        highlight_first = True if view == "overpass" else False
        highlight_last = True if view == "overpass" else False
        self.plot_track(
            latitude=lat_selection,
            longitude=lon_selection,
            linewidth=linewidth_selection,
            linestyle=linestyle_selection,
            highlight_first=show_highlights and highlight_first,
            highlight_last=show_highlights and highlight_last,
            color=color_selection,
        )

        self.ax.axis("equal")
        # if view == "overpass":
        #     extent = compute_bbox(np.vstack((lat_selection, lon_selection)).T)
        # else:
        #     extent = compute_bbox(np.vstack((lat_total, lon_total)).T)

        if view == "global":
            self.ax.set_global()  # type: ignore
        elif view == "overpass":
            zoom_radius_meters = radius_km * 1e3
            if isinstance(self.projection, ccrs.PlateCarree):
                self.set_view(
                    latitude=lat_selection,
                    longitude=lon_selection,
                    pad=np.array(self.pad) + 0.25,
                )
            else:
                self.ax.set_xlim(
                    -zoom_radius_meters * (1.25 + self.pad[0]),
                    zoom_radius_meters * (1.25 + self.pad[1]),
                )
                self.ax.set_ylim(
                    -zoom_radius_meters * (1.25 + self.pad[2]),
                    zoom_radius_meters * (1.25 + self.pad[3]),
                )
        elif view == "data":
            _lats = lat_total
            is_polar_track: bool = not ismonotonic(lat_total)
            if is_polar_track:
                _lats = np.nanmin(_lats)
            if isinstance(self.projection, ccrs.PlateCarree) or not is_polar_track:
                self.set_view(latitude=_lats, longitude=lon_total)
            else:
                _dist = haversine(
                    (self.central_latitude, self.central_longitude),
                    (lat_total[0], lon_total[0]),
                    units="m",
                )

                _dist2 = haversine(
                    (self.central_latitude, self.central_longitude),
                    (lat_total[-1], lon_total[-1]),
                    units="m",
                )
                _ratio = np.max([(_dist / np.max([_dist2, 1.0])) * 0.5, 1.0])

                self.ax.set_xlim(-_dist / _ratio, _dist / _ratio)
                if lat_total[0] < lat_total[1]:
                    self.ax.set_ylim(-_dist / _ratio, _dist)
                else:
                    self.ax.set_ylim(-_dist, _dist / _ratio)

            # zoom_radius_meters = (
            #     haversine(
            #         (lat_total[0], lon_total[0]),
            #         (lat_total[-1], lon_total[-1]),
            #         units="m",
            #     )
            #     * 1.3
            # ) / 2
            # if isinstance(self.projection, ccrs.PlateCarree):
            #     self.set_view(lats=lat_total, lons=lon_total)
            # else:
            #     self.ax.set_xlim(-zoom_radius_meters, zoom_radius_meters)
            #     self.ax.set_ylim(-zoom_radius_meters, zoom_radius_meters)

            # _diameter = haversine(
            #     (lat_total[0], lon_total[0]),
            #     (lat_total[-1], lon_total[-1]),
            #     units="m",
            # )
            # _radius = _diameter / 2
            # zoom_radius_meters = _radius * 1e3 * 1.3
            # if isinstance(self.projection, ccrs.PlateCarree):
            #     self.set_view(lats=lat_total, lons=lon_total)
            # else:
            #     self.ax.set_xlim(-zoom_radius_meters, zoom_radius_meters)
            #     self.ax.set_ylim(-zoom_radius_meters, zoom_radius_meters)

        return self

    def ecplot(
        self,
        ds: xr.Dataset,
        var: str | None = None,
        *,
        lat_var: str = TRACK_LAT_VAR,
        lon_var: str = TRACK_LON_VAR,
        swath_lat_var: str = SWATH_LAT_VAR,
        swath_lon_var: str = SWATH_LON_VAR,
        time_var: str = TIME_VAR,
        along_track_dim: str = ALONG_TRACK_DIM,
        across_track_dim: str = ACROSS_TRACK_DIM,
        site: str | GroundSite | None = None,
        radius_km: float = 100.0,
        time_range: TimeRangeLike | None = None,
        view: Literal["global", "data", "overpass"] = "global",
        zoom_tmin: TimestampLike | None = None,
        zoom_tmax: TimestampLike | None = None,
        color: ColorLike | None = "ec:earthcare",
        linewidth: float = 3,
        linestyle: str | None = "solid",
        color2: ColorLike | None = "ec:blue",
        linewidth2: float | None = None,
        linestyle2: str | None = None,
        cmap: str | Cmap | None = None,
        zoom_radius_km: float | None = None,
        extent: list[float] | None = None,
        central_latitude: float | None = None,
        central_longitude: float | None = None,
        value_range: ValueRangeLike | Literal["default"] | None = "default",
        log_scale: bool | None = None,
        norm: Normalize | None = None,
        colorbar: bool = True,
        pad: float | list[float] | None = None,
        show_text_time: bool | None = None,
        show_text_frame: bool | None = None,
        show_text_overpass: bool | None = None,
        colorbar_position: str | Literal["left", "right", "top", "bottom"] = "bottom",
        colorbar_alignment: str | Literal["left", "center", "right"] = "center",
        colorbar_width: float = DEFAULT_COLORBAR_WIDTH,
        colorbar_spacing: float = 0.3,
        colorbar_length_ratio: float | str = "100%",
        colorbar_label_outside: bool = True,
        colorbar_ticks_outside: bool = True,
        colorbar_ticks_both: bool = False,
        selection_max_time_margin: (
            TimedeltaLike | Sequence[TimedeltaLike] | None
        ) = None,
    ) -> "MapFigure":
        """
        Plot the EarthCARE satellite track on a map, optionally showing a 2D swath variable if `var` is provided.

        This method collects all required data from an EarthCARE `xarray.Dataset`.
        If `var` is given, the corresponding swath variable is plotted on the map using a
        color scale. Otherwise, the satellite ground track is plotted as a colored line.
        If `time_range` or `site` is given, the selected track section within the selected time range or in proximity to ground sites are highlighted.

        Args:
            ds (xr.Dataset): The EarthCARE dataset from which data will be plotted.
            var (str | None, optional): Name of a 2D swath variable to plot. If None, only the satellite ground track is shown. Defaults to None.
            lat_var (str, optional): Name of the latitude variable for the along-track data. Defaults to TRACK_LAT_VAR.
            lon_var (str, optional): Name of the longitude variable for the along-track data. Defaults to TRACK_LON_VAR.
            swath_lat_var (str, optional): Name of the latitude variable for the swath. Defaults to SWATH_LAT_VAR.
            swath_lon_var (str, optional): Name of the longitude variable for the swath. Defaults to SWATH_LON_VAR.
            time_var (str, optional): Name of the time variable. Defaults to TIME_VAR.
            along_track_dim (str, optional): Dimension name representing the along-track direction. Defaults to ALONG_TRACK_DIM.
            across_track_dim (str, optional): Dimension name representing the across-track direction. Defaults to ACROSS_TRACK_DIM.
            site (str | GroundSite | None, optional): Highlights data within `radius_km` of a ground site (given either as a `GroundSite` object or name string); ignored if not set. Defaults to None.
            radius_km (float, optional): Radius around the ground site to highlight data from; ignored if `site` not set. Defaults to 100.0.
            time_range (TimeRangeLike | None, optional): Time range to highlight as selection area; ignored if `site` is set. Defaults to None.
            view (Literal["global", "data", "overpass"], optional): Map extent mode: "global" for full world, "data" for tight bounds, or "overpass" to zoom around `site` or time range. Defaults to "global".
            zoom_tmin (TimestampLike | None, optional): Optional lower time bound used for zooming map around track. Defaults to None.
            zoom_tmax (TimestampLike | None, optional): Optional upper time bound used for zooming map around track. Defaults to None.
            color (ColorLike | None, optional): Color used for selected section of the track or entire track if no selection. Defaults to "ec:earthcare".
            linewidth (float, optional): Line width for selected track section. Defaults to 3.
            linestyle (str | None, optional): Line style for selected track section. Defaults to "solid".
            color2 (ColorLike | None, optional): Color used for unselected sections of the track. Defaults to "ec:blue".
            linewidth2 (float, optional): Line width for unselected sections. Defaults to None.
            linestyle2 (str | None, optional): Line style for unselected sections. Defaults to None.
            cmap (str | Cmap | None, optional): Colormap to use when plotting a swath variable. Defaults to None.
            zoom_radius_km (float | None, optional): If set, overrides map extent derived from `view` to use a fixed radius around the site or selection. Defaults to None.
            extent (list[float] | None, optional): Map extent in the form [lon_min, lon_max, lat_min, lat_max]. If given, overrides map extent derived from `view`. Defaults to None.
            central_latitude (float | None, optional): Central latitude used for the map projection. Defaults to None.
            central_longitude (float | None, optional): Central longitude used for the map projection. Defaults to None.
            value_range (ValueRangeLike | None, optional): Min and max range for the variable values; ignored if `var` is None. Defaults to None.
            log_scale (bool | None, optional): Whether to apply a logarithmic color scale to the variable. Defaults to None.
            norm (Normalize | None, optional): Matplotlib norm to use for color scaling. Defaults to None.
            colorbar (bool, optional): Whether to display a colorbar for the variable. Defaults to True.
            pad (float | list[float] | None, optional): Padding around the map extent; ignored if `extent` is given. Defaults to None.
            show_text_time (bool | None, optional): Whether to display the UTC time start and end of the selected track. Defaults to None.
            show_text_frame (bool | None, optional): Whether to display EarthCARE frame information. Defaults to None.
            show_text_overpass (bool | None, optional): Whether to display overpass site name and related info. Defaults to None.

        Returns:
            MapFigure: The figure object containing the map with track or swath.

        Example:
            ```python
            import earthcarekit as eck

            filepath = "path/to/mydata/ECA_EXAE_ATL_NOM_1B_20250606T132535Z_20250606T150730Z_05813D.h5"
            with eck.read_product(filepath) as ds:
                mf = eck.MapFigure()
                mf = mf.ecplot(ds)
            ```
        """
        if pad is not None:
            self.pad = _validate_pad(pad)
        if show_text_time is not None:
            self.show_text_time = show_text_time
        if show_text_frame is not None:
            self.show_text_frame = show_text_frame
        if show_text_overpass is not None:
            self.show_text_overpass = show_text_overpass

        _lat_var: str = lat_var
        _lon_var: str = lon_var

        _linewidth: float = linewidth
        _linewidth2: float
        if isinstance(linewidth2, (float, int)):
            _linewidth2 = float(linewidth2)
        else:
            _linewidth2 = linewidth * 0.7

        if isinstance(var, str):
            ds = ensure_updated_msi_rgb_if_required(
                ds, var, time_range, time_var=time_var
            )
            _linewidth = linewidth * 0.5
            linestyle = "dashed"
            _linewidth2 = linewidth * 0.2
            if all_in(
                (along_track_dim, across_track_dim), [str(d) for d in ds[var].dims]
            ):
                _lat_var = swath_lat_var
                _lon_var = swath_lon_var

        _site: GroundSite | None = None
        if isinstance(site, GroundSite):
            _site = site
        elif isinstance(site, str):
            _site = get_ground_site(site)

        coords_whole_flight = get_coords(ds, lat_var=lat_var, lon_var=lon_var)

        if time_range is not None:
            if zoom_tmin is None and time_range[0] is not None:
                zoom_tmin = to_timestamp(time_range[0])
            if zoom_tmax is None and time_range[1] is not None:
                zoom_tmax = to_timestamp(time_range[1])
        if zoom_tmin or zoom_tmax:
            ds_zoomed_in = filter_time(ds, time_range=[zoom_tmin, zoom_tmax])
            coords_zoomed_in = get_coords(
                ds_zoomed_in, lat_var=_lat_var, lon_var=_lon_var, flatten=True
            )
            coords_zoomed_in_track = get_coords(
                ds_zoomed_in, lat_var=lat_var, lon_var=lon_var
            )
        else:
            coords_zoomed_in = coords_whole_flight
            coords_zoomed_in_track = get_coords(ds, lat_var=lat_var, lon_var=lon_var)

        is_polar_track: bool = False

        if isinstance(_site, GroundSite):
            ds_overpass = filter_radius(
                ds,
                radius_km=radius_km,
                site=_site,
                lat_var=lat_var,
                lon_var=lon_var,
                along_track_dim=along_track_dim,
            )
            info_overpass = get_overpass_info(
                ds_overpass,
                radius_km=radius_km,
                site=_site,
                time_var=time_var,
                lat_var=lat_var,
                lon_var=lon_var,
                along_track_dim=along_track_dim,
            )

            _coords_whole_flight = coords_whole_flight.copy()
            _selection_max_time_margin: tuple[pd.Timedelta, pd.Timedelta] | None = None

            if selection_max_time_margin is not None:
                if isinstance(selection_max_time_margin, str):
                    _selection_max_time_margin = (
                        to_timedelta(selection_max_time_margin),
                        to_timedelta(selection_max_time_margin),
                    )
                elif isinstance(selection_max_time_margin, (Sequence, np.ndarray)):
                    _selection_max_time_margin = (
                        to_timedelta(selection_max_time_margin[0]),
                        to_timedelta(selection_max_time_margin[1]),
                    )
                else:
                    raise ValueError(
                        f"invalid selection_max_time_margin: {selection_max_time_margin}"
                    )

                _ds = filter_time(
                    ds=ds,
                    time_range=(
                        to_timestamp(ds_overpass[time_var].values[0])
                        - _selection_max_time_margin[0],
                        to_timestamp(ds_overpass[time_var].values[1])
                        + _selection_max_time_margin[1],
                    ),
                    time_var=time_var,
                )
                _coords_whole_flight = get_coords(_ds, lat_var=lat_var, lon_var=lon_var)

            coords_overpass = get_coords(ds_overpass, lat_var=lat_var, lon_var=lon_var)
            _ = self._plot_overpass(
                lat_selection=coords_overpass[:, 0],
                lon_selection=coords_overpass[:, 1],
                lat_total=_coords_whole_flight[:, 0],
                lon_total=_coords_whole_flight[:, 1],
                site=_site,
                radius_km=radius_km,
                view=view,
                timestamp=info_overpass.closest_time,
                color_selection=color,
                linewidth_selection=_linewidth,
                linestyle_selection=linestyle,
                color_total=color2,
                linewidth_total=_linewidth2,
                linestyle_total=linestyle2,
                show_highlights=view == "overpass"
                or not isinstance(_selection_max_time_margin, tuple),
                radius_color=None,
            )

            if isinstance(_selection_max_time_margin, tuple):
                self.plot_track(
                    latitude=coords_whole_flight[:, 0],
                    longitude=coords_whole_flight[:, 1],
                    color="white",
                    linestyle="solid",
                    linewidth=2,
                    highlight_first=False,
                    highlight_last=True,
                    zorder=3,
                )

            if view == "overpass":
                if self.show_text_overpass:
                    add_text_overpass_info(self.ax, info_overpass)
            if self.show_text_time:
                add_title_earthcare_time(
                    self.ax, tmin=info_overpass.start_time, tmax=info_overpass.end_time
                )
        else:
            if isinstance(central_latitude, (int, float)):
                self.central_latitude = central_latitude
            else:
                self.central_latitude = np.nanmean(coords_zoomed_in_track[:, 0])
            if isinstance(central_longitude, (int, float)):
                self.central_longitude = central_longitude
            else:
                if not ismonotonic(coords_whole_flight[:, 0]):
                    is_polar_track = True
                    self.central_longitude = coords_whole_flight[-1, 1]
                else:
                    self.central_longitude = circular_nanmean(coords_whole_flight[:, 1])
            logger.debug(
                f"Set central coords to (lat={self.central_latitude}, lon={self.central_longitude})"
            )

            time = ds[time_var].values
            timestamp = time[len(time) // 2]
            self.timestamp = to_timestamp(timestamp)
            if view == "overpass":
                if isinstance(self._inital_lod, int):
                    self.lod = self._inital_lod
                else:
                    self.lod = get_osm_lod(coords_zoomed_in[0], coords_zoomed_in[-1])
                if extent is None:
                    extent = compute_bbox(coords_zoomed_in)
                    self.extent = extent
            pos = self.ax.get_position()
            self.fig.delaxes(self.ax)
            self.ax = self.fig.add_axes(pos)  # type: ignore
            self._init_axes()
            if time_range is not None:
                _highlight_last = view in ["global", "data"]
                _ = self.plot_track(
                    latitude=coords_whole_flight[:, 0],
                    longitude=coords_whole_flight[:, 1],
                    linewidth=_linewidth2,
                    linestyle=linestyle,
                    highlight_first=False,
                    highlight_last=_highlight_last,
                    color=color2,
                )

                _highlight_last = view == "overpass"
                _ = self.plot_track(
                    latitude=coords_zoomed_in_track[:, 0],
                    longitude=coords_zoomed_in_track[:, 1],
                    linewidth=_linewidth,
                    linestyle=linestyle,
                    highlight_first=False,
                    highlight_last=_highlight_last,
                    color=color,
                )
            else:
                _ = self.plot_track(
                    latitude=coords_whole_flight[:, 0],
                    longitude=coords_whole_flight[:, 1],
                    linewidth=_linewidth,
                    linestyle=linestyle,
                    highlight_first=False,
                    highlight_last=True,
                    color=color,
                )
            self.ax.axis("equal")
            if view == "global":
                self.ax.set_global()  # type: ignore
            elif view == "data":
                _lats = coords_whole_flight[:, 0]
                if is_polar_track:
                    _lats = np.nanmin(_lats)
                if isinstance(self.projection, ccrs.PlateCarree) or not is_polar_track:
                    self.set_view(latitude=_lats, longitude=coords_whole_flight[:, 1])
                else:
                    _dist = haversine(
                        (self.central_latitude, self.central_longitude),  # type: ignore
                        coords_whole_flight[0],
                        units="m",
                    )
                    self.ax.set_xlim(-_dist / 2, _dist / 2)
                    if coords_whole_flight[0, 0] < coords_whole_flight[1, 0]:
                        self.ax.set_ylim(-_dist / 2, _dist)
                    else:
                        self.ax.set_ylim(-_dist, _dist / 2)
            else:
                _lats = coords_zoomed_in[:, 0]
                if is_polar_track:
                    _lats = np.nanmin(_lats)
                if isinstance(self.projection, ccrs.PlateCarree) or not is_polar_track:
                    self.set_view(latitude=_lats, longitude=coords_zoomed_in[:, 1])
                else:
                    _dist = haversine(
                        (self.central_latitude, self.central_longitude),  # type: ignore
                        coords_zoomed_in[0],
                        units="m",
                    )
                    self.ax.set_xlim(-_dist / 2, _dist / 2)
                    if coords_zoomed_in[0, 0] < coords_zoomed_in[1, 0]:
                        self.ax.set_ylim(-_dist / 2, _dist)
                    else:
                        self.ax.set_ylim(-_dist, _dist / 2)
                # _lats = coords_zoomed_in[:, 0]
                # if is_polar_track:
                #     _lats = np.nanmin(_lats)
                # self.set_view(lats=_lats, lons=coords_zoomed_in[:, 1])

            if self.show_text_time:
                add_title_earthcare_time(self.ax, ds=ds, tmin=zoom_tmin, tmax=zoom_tmax)

        if isinstance(var, str):
            if cmap is None:
                cmap = get_default_cmap(var, ds)
            if isinstance(value_range, str) and value_range == "default":
                value_range = None
                if log_scale is None and norm is None:
                    norm = get_default_norm(var, file_type=ds)

            _dims_var = list(ds[var].dims)
            values = ds[var].values
            label = getattr(ds[var], "long_name", "")
            units = getattr(ds[var], "units", "")
            if across_track_dim not in _dims_var and along_track_dim in _dims_var:
                lats = ds[lat_var].values
                lons = ds[lon_var].values
                if len(values.shape) > 1:
                    values = np.nanmean(values, axis=1)
                self.plot_track(
                    lats,
                    lons,
                    z=values,
                    linewidth=linewidth,
                    cmap=cmap,
                    label=label,
                    units=units,
                    value_range=value_range,
                    log_scale=log_scale,
                    norm=norm,
                    colorbar=colorbar,
                    colorbar_position=colorbar_position,
                    colorbar_alignment=colorbar_alignment,
                    colorbar_width=colorbar_width,
                    colorbar_spacing=colorbar_spacing,
                    colorbar_length_ratio=colorbar_length_ratio,
                    colorbar_label_outside=colorbar_label_outside,
                    colorbar_ticks_outside=colorbar_ticks_outside,
                    colorbar_ticks_both=colorbar_ticks_both,
                )
            else:
                lats = ds[swath_lat_var].values
                lons = ds[swath_lon_var].values
                _ = self.plot_swath(
                    lats,
                    lons,
                    values,
                    cmap=cmap,
                    label=label,
                    units=units,
                    value_range=value_range,
                    log_scale=log_scale,
                    norm=norm,
                    colorbar=colorbar,
                    colorbar_position=colorbar_position,
                    colorbar_alignment=colorbar_alignment,
                    colorbar_width=colorbar_width,
                    colorbar_spacing=colorbar_spacing,
                    colorbar_length_ratio=colorbar_length_ratio,
                    colorbar_label_outside=colorbar_label_outside,
                    colorbar_ticks_outside=colorbar_ticks_outside,
                    colorbar_ticks_both=colorbar_ticks_both,
                )

        # if view == "data":
        #     self.set_view(lats=lats, lons=lons)

        # if zoom_tmin or zoom_tmax:
        #     extent = compute_bbox(coords_zoomed_in)
        #     self.ax.set_extent(extent, crs=ccrs.PlateCarree())  # type: ignore
        if self.show_text_frame:
            add_title_earthcare_frame(self.ax, ds=ds)

        self.zoom(extent=extent, radius_km=zoom_radius_km)

        return self

    def _init_cmap(
        self,
        cmap: str | Cmap | None = None,
        value_range: ValueRangeLike | None = None,
        log_scale: bool | None = None,
        norm: Normalize | None = None,
    ) -> tuple[Cmap, tuple, Normalize]:
        cmap = get_cmap(cmap)

        if isinstance(value_range, Iterable):
            if len(value_range) != 2:
                raise ValueError(
                    f"invalid `value_range`: {value_range}, expecting (vmin, vmax)"
                )
        else:
            value_range = (None, None)

        if isinstance(cmap, Cmap) and cmap.categorical == True:
            norm = cmap.norm
        elif isinstance(norm, Normalize):
            if log_scale == True and not isinstance(norm, LogNorm):
                norm = LogNorm(norm.vmin, norm.vmax)
            elif log_scale == False and isinstance(norm, LogNorm):
                norm = Normalize(norm.vmin, norm.vmax)
            if value_range[0] is not None:
                norm.vmin = value_range[0]  # type: ignore # FIXME
            if value_range[1] is not None:
                norm.vmax = value_range[1]  # type: ignore # FIXME
        else:
            if log_scale == True:
                norm = LogNorm(value_range[0], value_range[1])  # type: ignore # FIXME
            else:
                norm = Normalize(value_range[0], value_range[1])  # type: ignore # FIXME

        assert isinstance(norm, Normalize)
        value_range = (norm.vmin, norm.vmax)

        return (cmap, value_range, norm)

    def plot_swath(
        self,
        lats: NDArray,
        lons: NDArray,
        values: NDArray,
        label: str = "",
        units: str = "",
        cmap: str | Cmap | None = None,
        value_range: ValueRangeLike | None = None,
        log_scale: bool | None = None,
        norm: Normalize | None = None,
        colorbar: bool = True,
        colorbar_position: str | Literal["left", "right", "top", "bottom"] = "bottom",
        colorbar_alignment: str | Literal["left", "center", "right"] = "center",
        colorbar_width: float = DEFAULT_COLORBAR_WIDTH,
        colorbar_spacing: float = 0.3,
        colorbar_length_ratio: float | str = "100%",
        colorbar_label_outside: bool = True,
        colorbar_ticks_outside: bool = True,
        colorbar_ticks_both: bool = False,
        show_swath_border: bool = True,
    ) -> "MapFigure":
        cmap, value_range, norm = self._init_cmap(cmap, value_range, log_scale, norm)

        if len(values.shape) == 3 and values.shape[2] == 3:
            mesh = self.ax.pcolormesh(
                lons.T,
                lats.T,
                values,
                shading="auto",
                transform=ccrs.PlateCarree(),
                rasterized=True,
            )
        else:
            mesh = self.ax.pcolormesh(
                lons,
                lats,
                values,
                cmap=cmap,
                norm=norm,
                shading="auto",
                transform=ccrs.PlateCarree(),
                rasterized=True,
            )
            if colorbar:
                cb_kwargs = dict(
                    label=format_var_label(label, units),
                    position=colorbar_position,
                    alignment=colorbar_alignment,
                    width=colorbar_width,
                    spacing=colorbar_spacing,
                    length_ratio=colorbar_length_ratio,
                    label_outside=colorbar_label_outside,
                    ticks_outside=colorbar_ticks_outside,
                    ticks_both=colorbar_ticks_both,
                )
                self.colorbar = add_colorbar(
                    fig=self.fig,
                    ax=self.ax,
                    data=mesh,
                    cmap=cmap,
                    **cb_kwargs,  # type: ignore
                )
                self.set_colorbar_tick_scale(multiplier=self.colorbar_tick_scale)
        if show_swath_border:
            edgecolor = Color("white").set_alpha(0.5)
            _ = self.plot_track(
                lats[:, 0],
                lons[:, 0],
                highlight_first=False,
                highlight_last=False,
                color=edgecolor,
                linewidth=1,
            )
            _ = self.plot_track(
                lats[:, -1],
                lons[:, -1],
                highlight_first=False,
                highlight_last=False,
                color=edgecolor,
                linewidth=1,
            )
            _ = self.plot_track(
                lats[0, :],
                lons[0, :],
                highlight_first=False,
                highlight_last=False,
                color=edgecolor,
                linewidth=1,
            )
            _ = self.plot_track(
                lats[-1, :],
                lons[-1, :],
                highlight_first=False,
                highlight_last=False,
                color=edgecolor,
                linewidth=1,
            )

        return self

    def zoom(
        self, extent: ArrayLike | None = None, radius_km: float | None = None
    ) -> "MapFigure":
        radius_meters: float = 0

        if extent is not None:
            extent = np.asarray(extent)
            if extent.shape[0] != 4:
                ValueError(
                    f"'extent' has wrong size ({extent.shape[0]}), expecting size of 4 (min_lon, max_lon, min_lat, max_lat)"
                )
            lon_extent_km = haversine([extent[2], extent[0]], [extent[2], extent[1]])
            lat_extent_km = haversine([extent[2], extent[0]], [extent[3], extent[0]])
            radius_meters = np.max([lon_extent_km, lat_extent_km]) * 1e3

        if isinstance(radius_km, (int, float)):
            radius_meters = radius_km * 1e3

        if isinstance(self.projection, ccrs.PlateCarree) and extent is not None:
            self.ax.set_extent(extent, crs=ccrs.PlateCarree())  # type: ignore
        elif (
            not isinstance(self.projection, ccrs.PlateCarree) and radius_km is not None
        ):
            self.ax.set_xlim(-radius_meters, radius_meters)
            self.ax.set_ylim(-radius_meters, radius_meters)

        return self

    def to_texture(
        self, remove_images: bool = True, remove_features: bool = True
    ) -> "MapFigure":
        """Convert the figure to a texture by removing all axis ticks, labels, annotations, and text."""
        # Remove anchored text and other artist text objects
        for artist in reversed(self.ax.artists):
            if isinstance(artist, (Text, AnchoredOffsetbox)):
                artist.remove()

        # Completely remove axis ticks and labels
        self.ax.axis("off")

        # Remove white frame around figure
        self.fig.subplots_adjust(left=0, right=1, bottom=0, top=1)

        # Remove ticks, tick labels, and gridlines
        self.ax.set_xticks([])
        self.ax.set_yticks([])
        self.ax.xaxis.set_ticklabels([])
        self.ax.yaxis.set_ticklabels([])
        self.ax.grid(False)

        # Remove outline box around map
        self.ax.spines["geo"].set_visible(False)

        # Make the map fill the whole figure
        self.ax.set_position((0.0, 0.0, 1.0, 1.0))

        if self.colorbar:
            self.colorbar.remove()

        if self.grid_lines:
            self.grid_lines.remove()

        if remove_images:
            for img in self.ax.get_images():
                img.remove()

        if remove_features:
            for c in self.ax.get_children():
                if isinstance(c, FeatureArtist):
                    c.remove()

        # for c in self.ax.get_children():
        #     if isinstance(c, _ViewClippedPathPatch):
        #         c.set_alpha(0)

        for c in self.fig.get_children():
            if isinstance(c, Rectangle):
                c.set_alpha(0)

        self.ax.set_facecolor("none")

        return self

    def set_colorbar_tick_scale(
        self,
        multiplier: float | None = None,
        fontsize: float | str | None = None,
    ) -> "MapFigure":
        _cb = self.colorbar
        cb: Colorbar
        if isinstance(_cb, Colorbar):
            cb = _cb
        else:
            return self

        if fontsize is not None:
            cb.ax.tick_params(labelsize=fontsize)
            return self

        if multiplier is not None:
            tls = cb.ax.yaxis.get_ticklabels()
            if len(tls) == 0:
                tls = cb.ax.xaxis.get_ticklabels()
            if len(tls) == 0:
                return self
            _fontsize = tls[0].get_fontsize()
            if isinstance(_fontsize, str):
                from matplotlib import font_manager

                fp = font_manager.FontProperties(size=_fontsize)
                _fontsize = fp.get_size_in_points()
            cb.ax.tick_params(labelsize=_fontsize * multiplier)
        return self

    def show(self) -> None:
        import IPython
        import matplotlib.pyplot as plt
        from IPython.display import display

        if IPython.get_ipython() is not None:
            display(self.fig)
        else:
            plt.show()

    def save(
        self,
        filename: str = "",
        filepath: str | None = None,
        ds: xr.Dataset | None = None,
        ds_filepath: str | None = None,
        dpi: float | Literal["figure"] = "figure",
        orbit_and_frame: str | None = None,
        utc_timestamp: TimestampLike | None = None,
        use_utc_creation_timestamp: bool = False,
        site_name: str | None = None,
        hmax: int | float | None = None,
        radius: int | float | None = None,
        extra: str | None = None,
        transparent_outside: bool = False,
        verbose: bool = True,
        print_prefix: str = "",
        create_dirs: bool = False,
        transparent_background: bool = False,
        resolution: str | None = None,
        **kwargs,
    ) -> None:
        """
        Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

        Args:
            figure (Figure | HasFigure): A figure object (`matplotlib.figure.Figure`) or objects exposing a `.fig` attribute containing a figure (e.g., `CurtainFigure`).
            filename (str, optional): The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.
            filepath (str | None, optional): The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.
            ds (xr.Dataset | None, optional): A EarthCARE dataset from which metadata will be taken. Defaults to None.
            ds_filepath (str | None, optional): A path to a EarthCARE product from which metadata will be taken. Defaults to None.
            pad (float, optional): Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.
            dpi (float | 'figure', optional): The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.
            orbit_and_frame (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            utc_timestamp (TimestampLike | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            use_utc_creation_timestamp (bool, optional): Whether the time of image creation should be included in the file name. Defaults to False.
            site_name (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            hmax (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            radius (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            resolution (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            extra (str | None, optional): A custom string to be included in the file name. Defaults to None.
            transparent_outside (bool, optional): Whether the area outside figures should be transparent. Defaults to False.
            verbose (bool, optional): Whether the progress of image creation should be printed to the console. Defaults to True.
            print_prefix (str, optional): A prefix string to all console messages. Defaults to "".
            create_dirs (bool, optional): Whether images should be saved in a folder structure based on provided metadata. Defaults to False.
            transparent_background (bool, optional): Whether the background inside and outside of figures should be transparent. Defaults to False.
            **kwargs (dict[str, Any]): Keyword arguments passed to wrapped function call of `matplotlib.pyplot.savefig`.
        """
        save_plot(
            fig=self.fig,
            filename=filename,
            filepath=filepath,
            ds=ds,
            ds_filepath=ds_filepath,
            dpi=dpi,
            orbit_and_frame=orbit_and_frame,
            utc_timestamp=utc_timestamp,
            use_utc_creation_timestamp=use_utc_creation_timestamp,
            site_name=site_name,
            hmax=hmax,
            radius=radius,
            extra=extra,
            transparent_outside=transparent_outside,
            verbose=verbose,
            print_prefix=print_prefix,
            create_dirs=create_dirs,
            transparent_background=transparent_background,
            resolution=resolution,
            **kwargs,
        )

ecplot

ecplot(
    ds,
    var=None,
    *,
    lat_var=TRACK_LAT_VAR,
    lon_var=TRACK_LON_VAR,
    swath_lat_var=SWATH_LAT_VAR,
    swath_lon_var=SWATH_LON_VAR,
    time_var=TIME_VAR,
    along_track_dim=ALONG_TRACK_DIM,
    across_track_dim=ACROSS_TRACK_DIM,
    site=None,
    radius_km=100.0,
    time_range=None,
    view="global",
    zoom_tmin=None,
    zoom_tmax=None,
    color="ec:earthcare",
    linewidth=3,
    linestyle="solid",
    color2="ec:blue",
    linewidth2=None,
    linestyle2=None,
    cmap=None,
    zoom_radius_km=None,
    extent=None,
    central_latitude=None,
    central_longitude=None,
    value_range="default",
    log_scale=None,
    norm=None,
    colorbar=True,
    pad=None,
    show_text_time=None,
    show_text_frame=None,
    show_text_overpass=None,
    colorbar_position="bottom",
    colorbar_alignment="center",
    colorbar_width=DEFAULT_COLORBAR_WIDTH,
    colorbar_spacing=0.3,
    colorbar_length_ratio="100%",
    colorbar_label_outside=True,
    colorbar_ticks_outside=True,
    colorbar_ticks_both=False,
    selection_max_time_margin=None
)

Plot the EarthCARE satellite track on a map, optionally showing a 2D swath variable if var is provided.

This method collects all required data from an EarthCARE xarray.Dataset. If var is given, the corresponding swath variable is plotted on the map using a color scale. Otherwise, the satellite ground track is plotted as a colored line. If time_range or site is given, the selected track section within the selected time range or in proximity to ground sites are highlighted.

Parameters:

Name Type Description Default
ds Dataset

The EarthCARE dataset from which data will be plotted.

required
var str | None

Name of a 2D swath variable to plot. If None, only the satellite ground track is shown. Defaults to None.

None
lat_var str

Name of the latitude variable for the along-track data. Defaults to TRACK_LAT_VAR.

TRACK_LAT_VAR
lon_var str

Name of the longitude variable for the along-track data. Defaults to TRACK_LON_VAR.

TRACK_LON_VAR
swath_lat_var str

Name of the latitude variable for the swath. Defaults to SWATH_LAT_VAR.

SWATH_LAT_VAR
swath_lon_var str

Name of the longitude variable for the swath. Defaults to SWATH_LON_VAR.

SWATH_LON_VAR
time_var str

Name of the time variable. Defaults to TIME_VAR.

TIME_VAR
along_track_dim str

Dimension name representing the along-track direction. Defaults to ALONG_TRACK_DIM.

ALONG_TRACK_DIM
across_track_dim str

Dimension name representing the across-track direction. Defaults to ACROSS_TRACK_DIM.

ACROSS_TRACK_DIM
site str | GroundSite | None

Highlights data within radius_km of a ground site (given either as a GroundSite object or name string); ignored if not set. Defaults to None.

None
radius_km float

Radius around the ground site to highlight data from; ignored if site not set. Defaults to 100.0.

100.0
time_range TimeRangeLike | None

Time range to highlight as selection area; ignored if site is set. Defaults to None.

None
view Literal['global', 'data', 'overpass']

Map extent mode: "global" for full world, "data" for tight bounds, or "overpass" to zoom around site or time range. Defaults to "global".

'global'
zoom_tmin TimestampLike | None

Optional lower time bound used for zooming map around track. Defaults to None.

None
zoom_tmax TimestampLike | None

Optional upper time bound used for zooming map around track. Defaults to None.

None
color ColorLike | None

Color used for selected section of the track or entire track if no selection. Defaults to "ec:earthcare".

'ec:earthcare'
linewidth float

Line width for selected track section. Defaults to 3.

3
linestyle str | None

Line style for selected track section. Defaults to "solid".

'solid'
color2 ColorLike | None

Color used for unselected sections of the track. Defaults to "ec:blue".

'ec:blue'
linewidth2 float

Line width for unselected sections. Defaults to None.

None
linestyle2 str | None

Line style for unselected sections. Defaults to None.

None
cmap str | Cmap | None

Colormap to use when plotting a swath variable. Defaults to None.

None
zoom_radius_km float | None

If set, overrides map extent derived from view to use a fixed radius around the site or selection. Defaults to None.

None
extent list[float] | None

Map extent in the form [lon_min, lon_max, lat_min, lat_max]. If given, overrides map extent derived from view. Defaults to None.

None
central_latitude float | None

Central latitude used for the map projection. Defaults to None.

None
central_longitude float | None

Central longitude used for the map projection. Defaults to None.

None
value_range ValueRangeLike | None

Min and max range for the variable values; ignored if var is None. Defaults to None.

'default'
log_scale bool | None

Whether to apply a logarithmic color scale to the variable. Defaults to None.

None
norm Normalize | None

Matplotlib norm to use for color scaling. Defaults to None.

None
colorbar bool

Whether to display a colorbar for the variable. Defaults to True.

True
pad float | list[float] | None

Padding around the map extent; ignored if extent is given. Defaults to None.

None
show_text_time bool | None

Whether to display the UTC time start and end of the selected track. Defaults to None.

None
show_text_frame bool | None

Whether to display EarthCARE frame information. Defaults to None.

None
show_text_overpass bool | None

Whether to display overpass site name and related info. Defaults to None.

None

Returns:

Name Type Description
MapFigure MapFigure

The figure object containing the map with track or swath.

Example
import earthcarekit as eck

filepath = "path/to/mydata/ECA_EXAE_ATL_NOM_1B_20250606T132535Z_20250606T150730Z_05813D.h5"
with eck.read_product(filepath) as ds:
    mf = eck.MapFigure()
    mf = mf.ecplot(ds)
Source code in earthcarekit/plot/figure/map.py
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
def ecplot(
    self,
    ds: xr.Dataset,
    var: str | None = None,
    *,
    lat_var: str = TRACK_LAT_VAR,
    lon_var: str = TRACK_LON_VAR,
    swath_lat_var: str = SWATH_LAT_VAR,
    swath_lon_var: str = SWATH_LON_VAR,
    time_var: str = TIME_VAR,
    along_track_dim: str = ALONG_TRACK_DIM,
    across_track_dim: str = ACROSS_TRACK_DIM,
    site: str | GroundSite | None = None,
    radius_km: float = 100.0,
    time_range: TimeRangeLike | None = None,
    view: Literal["global", "data", "overpass"] = "global",
    zoom_tmin: TimestampLike | None = None,
    zoom_tmax: TimestampLike | None = None,
    color: ColorLike | None = "ec:earthcare",
    linewidth: float = 3,
    linestyle: str | None = "solid",
    color2: ColorLike | None = "ec:blue",
    linewidth2: float | None = None,
    linestyle2: str | None = None,
    cmap: str | Cmap | None = None,
    zoom_radius_km: float | None = None,
    extent: list[float] | None = None,
    central_latitude: float | None = None,
    central_longitude: float | None = None,
    value_range: ValueRangeLike | Literal["default"] | None = "default",
    log_scale: bool | None = None,
    norm: Normalize | None = None,
    colorbar: bool = True,
    pad: float | list[float] | None = None,
    show_text_time: bool | None = None,
    show_text_frame: bool | None = None,
    show_text_overpass: bool | None = None,
    colorbar_position: str | Literal["left", "right", "top", "bottom"] = "bottom",
    colorbar_alignment: str | Literal["left", "center", "right"] = "center",
    colorbar_width: float = DEFAULT_COLORBAR_WIDTH,
    colorbar_spacing: float = 0.3,
    colorbar_length_ratio: float | str = "100%",
    colorbar_label_outside: bool = True,
    colorbar_ticks_outside: bool = True,
    colorbar_ticks_both: bool = False,
    selection_max_time_margin: (
        TimedeltaLike | Sequence[TimedeltaLike] | None
    ) = None,
) -> "MapFigure":
    """
    Plot the EarthCARE satellite track on a map, optionally showing a 2D swath variable if `var` is provided.

    This method collects all required data from an EarthCARE `xarray.Dataset`.
    If `var` is given, the corresponding swath variable is plotted on the map using a
    color scale. Otherwise, the satellite ground track is plotted as a colored line.
    If `time_range` or `site` is given, the selected track section within the selected time range or in proximity to ground sites are highlighted.

    Args:
        ds (xr.Dataset): The EarthCARE dataset from which data will be plotted.
        var (str | None, optional): Name of a 2D swath variable to plot. If None, only the satellite ground track is shown. Defaults to None.
        lat_var (str, optional): Name of the latitude variable for the along-track data. Defaults to TRACK_LAT_VAR.
        lon_var (str, optional): Name of the longitude variable for the along-track data. Defaults to TRACK_LON_VAR.
        swath_lat_var (str, optional): Name of the latitude variable for the swath. Defaults to SWATH_LAT_VAR.
        swath_lon_var (str, optional): Name of the longitude variable for the swath. Defaults to SWATH_LON_VAR.
        time_var (str, optional): Name of the time variable. Defaults to TIME_VAR.
        along_track_dim (str, optional): Dimension name representing the along-track direction. Defaults to ALONG_TRACK_DIM.
        across_track_dim (str, optional): Dimension name representing the across-track direction. Defaults to ACROSS_TRACK_DIM.
        site (str | GroundSite | None, optional): Highlights data within `radius_km` of a ground site (given either as a `GroundSite` object or name string); ignored if not set. Defaults to None.
        radius_km (float, optional): Radius around the ground site to highlight data from; ignored if `site` not set. Defaults to 100.0.
        time_range (TimeRangeLike | None, optional): Time range to highlight as selection area; ignored if `site` is set. Defaults to None.
        view (Literal["global", "data", "overpass"], optional): Map extent mode: "global" for full world, "data" for tight bounds, or "overpass" to zoom around `site` or time range. Defaults to "global".
        zoom_tmin (TimestampLike | None, optional): Optional lower time bound used for zooming map around track. Defaults to None.
        zoom_tmax (TimestampLike | None, optional): Optional upper time bound used for zooming map around track. Defaults to None.
        color (ColorLike | None, optional): Color used for selected section of the track or entire track if no selection. Defaults to "ec:earthcare".
        linewidth (float, optional): Line width for selected track section. Defaults to 3.
        linestyle (str | None, optional): Line style for selected track section. Defaults to "solid".
        color2 (ColorLike | None, optional): Color used for unselected sections of the track. Defaults to "ec:blue".
        linewidth2 (float, optional): Line width for unselected sections. Defaults to None.
        linestyle2 (str | None, optional): Line style for unselected sections. Defaults to None.
        cmap (str | Cmap | None, optional): Colormap to use when plotting a swath variable. Defaults to None.
        zoom_radius_km (float | None, optional): If set, overrides map extent derived from `view` to use a fixed radius around the site or selection. Defaults to None.
        extent (list[float] | None, optional): Map extent in the form [lon_min, lon_max, lat_min, lat_max]. If given, overrides map extent derived from `view`. Defaults to None.
        central_latitude (float | None, optional): Central latitude used for the map projection. Defaults to None.
        central_longitude (float | None, optional): Central longitude used for the map projection. Defaults to None.
        value_range (ValueRangeLike | None, optional): Min and max range for the variable values; ignored if `var` is None. Defaults to None.
        log_scale (bool | None, optional): Whether to apply a logarithmic color scale to the variable. Defaults to None.
        norm (Normalize | None, optional): Matplotlib norm to use for color scaling. Defaults to None.
        colorbar (bool, optional): Whether to display a colorbar for the variable. Defaults to True.
        pad (float | list[float] | None, optional): Padding around the map extent; ignored if `extent` is given. Defaults to None.
        show_text_time (bool | None, optional): Whether to display the UTC time start and end of the selected track. Defaults to None.
        show_text_frame (bool | None, optional): Whether to display EarthCARE frame information. Defaults to None.
        show_text_overpass (bool | None, optional): Whether to display overpass site name and related info. Defaults to None.

    Returns:
        MapFigure: The figure object containing the map with track or swath.

    Example:
        ```python
        import earthcarekit as eck

        filepath = "path/to/mydata/ECA_EXAE_ATL_NOM_1B_20250606T132535Z_20250606T150730Z_05813D.h5"
        with eck.read_product(filepath) as ds:
            mf = eck.MapFigure()
            mf = mf.ecplot(ds)
        ```
    """
    if pad is not None:
        self.pad = _validate_pad(pad)
    if show_text_time is not None:
        self.show_text_time = show_text_time
    if show_text_frame is not None:
        self.show_text_frame = show_text_frame
    if show_text_overpass is not None:
        self.show_text_overpass = show_text_overpass

    _lat_var: str = lat_var
    _lon_var: str = lon_var

    _linewidth: float = linewidth
    _linewidth2: float
    if isinstance(linewidth2, (float, int)):
        _linewidth2 = float(linewidth2)
    else:
        _linewidth2 = linewidth * 0.7

    if isinstance(var, str):
        ds = ensure_updated_msi_rgb_if_required(
            ds, var, time_range, time_var=time_var
        )
        _linewidth = linewidth * 0.5
        linestyle = "dashed"
        _linewidth2 = linewidth * 0.2
        if all_in(
            (along_track_dim, across_track_dim), [str(d) for d in ds[var].dims]
        ):
            _lat_var = swath_lat_var
            _lon_var = swath_lon_var

    _site: GroundSite | None = None
    if isinstance(site, GroundSite):
        _site = site
    elif isinstance(site, str):
        _site = get_ground_site(site)

    coords_whole_flight = get_coords(ds, lat_var=lat_var, lon_var=lon_var)

    if time_range is not None:
        if zoom_tmin is None and time_range[0] is not None:
            zoom_tmin = to_timestamp(time_range[0])
        if zoom_tmax is None and time_range[1] is not None:
            zoom_tmax = to_timestamp(time_range[1])
    if zoom_tmin or zoom_tmax:
        ds_zoomed_in = filter_time(ds, time_range=[zoom_tmin, zoom_tmax])
        coords_zoomed_in = get_coords(
            ds_zoomed_in, lat_var=_lat_var, lon_var=_lon_var, flatten=True
        )
        coords_zoomed_in_track = get_coords(
            ds_zoomed_in, lat_var=lat_var, lon_var=lon_var
        )
    else:
        coords_zoomed_in = coords_whole_flight
        coords_zoomed_in_track = get_coords(ds, lat_var=lat_var, lon_var=lon_var)

    is_polar_track: bool = False

    if isinstance(_site, GroundSite):
        ds_overpass = filter_radius(
            ds,
            radius_km=radius_km,
            site=_site,
            lat_var=lat_var,
            lon_var=lon_var,
            along_track_dim=along_track_dim,
        )
        info_overpass = get_overpass_info(
            ds_overpass,
            radius_km=radius_km,
            site=_site,
            time_var=time_var,
            lat_var=lat_var,
            lon_var=lon_var,
            along_track_dim=along_track_dim,
        )

        _coords_whole_flight = coords_whole_flight.copy()
        _selection_max_time_margin: tuple[pd.Timedelta, pd.Timedelta] | None = None

        if selection_max_time_margin is not None:
            if isinstance(selection_max_time_margin, str):
                _selection_max_time_margin = (
                    to_timedelta(selection_max_time_margin),
                    to_timedelta(selection_max_time_margin),
                )
            elif isinstance(selection_max_time_margin, (Sequence, np.ndarray)):
                _selection_max_time_margin = (
                    to_timedelta(selection_max_time_margin[0]),
                    to_timedelta(selection_max_time_margin[1]),
                )
            else:
                raise ValueError(
                    f"invalid selection_max_time_margin: {selection_max_time_margin}"
                )

            _ds = filter_time(
                ds=ds,
                time_range=(
                    to_timestamp(ds_overpass[time_var].values[0])
                    - _selection_max_time_margin[0],
                    to_timestamp(ds_overpass[time_var].values[1])
                    + _selection_max_time_margin[1],
                ),
                time_var=time_var,
            )
            _coords_whole_flight = get_coords(_ds, lat_var=lat_var, lon_var=lon_var)

        coords_overpass = get_coords(ds_overpass, lat_var=lat_var, lon_var=lon_var)
        _ = self._plot_overpass(
            lat_selection=coords_overpass[:, 0],
            lon_selection=coords_overpass[:, 1],
            lat_total=_coords_whole_flight[:, 0],
            lon_total=_coords_whole_flight[:, 1],
            site=_site,
            radius_km=radius_km,
            view=view,
            timestamp=info_overpass.closest_time,
            color_selection=color,
            linewidth_selection=_linewidth,
            linestyle_selection=linestyle,
            color_total=color2,
            linewidth_total=_linewidth2,
            linestyle_total=linestyle2,
            show_highlights=view == "overpass"
            or not isinstance(_selection_max_time_margin, tuple),
            radius_color=None,
        )

        if isinstance(_selection_max_time_margin, tuple):
            self.plot_track(
                latitude=coords_whole_flight[:, 0],
                longitude=coords_whole_flight[:, 1],
                color="white",
                linestyle="solid",
                linewidth=2,
                highlight_first=False,
                highlight_last=True,
                zorder=3,
            )

        if view == "overpass":
            if self.show_text_overpass:
                add_text_overpass_info(self.ax, info_overpass)
        if self.show_text_time:
            add_title_earthcare_time(
                self.ax, tmin=info_overpass.start_time, tmax=info_overpass.end_time
            )
    else:
        if isinstance(central_latitude, (int, float)):
            self.central_latitude = central_latitude
        else:
            self.central_latitude = np.nanmean(coords_zoomed_in_track[:, 0])
        if isinstance(central_longitude, (int, float)):
            self.central_longitude = central_longitude
        else:
            if not ismonotonic(coords_whole_flight[:, 0]):
                is_polar_track = True
                self.central_longitude = coords_whole_flight[-1, 1]
            else:
                self.central_longitude = circular_nanmean(coords_whole_flight[:, 1])
        logger.debug(
            f"Set central coords to (lat={self.central_latitude}, lon={self.central_longitude})"
        )

        time = ds[time_var].values
        timestamp = time[len(time) // 2]
        self.timestamp = to_timestamp(timestamp)
        if view == "overpass":
            if isinstance(self._inital_lod, int):
                self.lod = self._inital_lod
            else:
                self.lod = get_osm_lod(coords_zoomed_in[0], coords_zoomed_in[-1])
            if extent is None:
                extent = compute_bbox(coords_zoomed_in)
                self.extent = extent
        pos = self.ax.get_position()
        self.fig.delaxes(self.ax)
        self.ax = self.fig.add_axes(pos)  # type: ignore
        self._init_axes()
        if time_range is not None:
            _highlight_last = view in ["global", "data"]
            _ = self.plot_track(
                latitude=coords_whole_flight[:, 0],
                longitude=coords_whole_flight[:, 1],
                linewidth=_linewidth2,
                linestyle=linestyle,
                highlight_first=False,
                highlight_last=_highlight_last,
                color=color2,
            )

            _highlight_last = view == "overpass"
            _ = self.plot_track(
                latitude=coords_zoomed_in_track[:, 0],
                longitude=coords_zoomed_in_track[:, 1],
                linewidth=_linewidth,
                linestyle=linestyle,
                highlight_first=False,
                highlight_last=_highlight_last,
                color=color,
            )
        else:
            _ = self.plot_track(
                latitude=coords_whole_flight[:, 0],
                longitude=coords_whole_flight[:, 1],
                linewidth=_linewidth,
                linestyle=linestyle,
                highlight_first=False,
                highlight_last=True,
                color=color,
            )
        self.ax.axis("equal")
        if view == "global":
            self.ax.set_global()  # type: ignore
        elif view == "data":
            _lats = coords_whole_flight[:, 0]
            if is_polar_track:
                _lats = np.nanmin(_lats)
            if isinstance(self.projection, ccrs.PlateCarree) or not is_polar_track:
                self.set_view(latitude=_lats, longitude=coords_whole_flight[:, 1])
            else:
                _dist = haversine(
                    (self.central_latitude, self.central_longitude),  # type: ignore
                    coords_whole_flight[0],
                    units="m",
                )
                self.ax.set_xlim(-_dist / 2, _dist / 2)
                if coords_whole_flight[0, 0] < coords_whole_flight[1, 0]:
                    self.ax.set_ylim(-_dist / 2, _dist)
                else:
                    self.ax.set_ylim(-_dist, _dist / 2)
        else:
            _lats = coords_zoomed_in[:, 0]
            if is_polar_track:
                _lats = np.nanmin(_lats)
            if isinstance(self.projection, ccrs.PlateCarree) or not is_polar_track:
                self.set_view(latitude=_lats, longitude=coords_zoomed_in[:, 1])
            else:
                _dist = haversine(
                    (self.central_latitude, self.central_longitude),  # type: ignore
                    coords_zoomed_in[0],
                    units="m",
                )
                self.ax.set_xlim(-_dist / 2, _dist / 2)
                if coords_zoomed_in[0, 0] < coords_zoomed_in[1, 0]:
                    self.ax.set_ylim(-_dist / 2, _dist)
                else:
                    self.ax.set_ylim(-_dist, _dist / 2)
            # _lats = coords_zoomed_in[:, 0]
            # if is_polar_track:
            #     _lats = np.nanmin(_lats)
            # self.set_view(lats=_lats, lons=coords_zoomed_in[:, 1])

        if self.show_text_time:
            add_title_earthcare_time(self.ax, ds=ds, tmin=zoom_tmin, tmax=zoom_tmax)

    if isinstance(var, str):
        if cmap is None:
            cmap = get_default_cmap(var, ds)
        if isinstance(value_range, str) and value_range == "default":
            value_range = None
            if log_scale is None and norm is None:
                norm = get_default_norm(var, file_type=ds)

        _dims_var = list(ds[var].dims)
        values = ds[var].values
        label = getattr(ds[var], "long_name", "")
        units = getattr(ds[var], "units", "")
        if across_track_dim not in _dims_var and along_track_dim in _dims_var:
            lats = ds[lat_var].values
            lons = ds[lon_var].values
            if len(values.shape) > 1:
                values = np.nanmean(values, axis=1)
            self.plot_track(
                lats,
                lons,
                z=values,
                linewidth=linewidth,
                cmap=cmap,
                label=label,
                units=units,
                value_range=value_range,
                log_scale=log_scale,
                norm=norm,
                colorbar=colorbar,
                colorbar_position=colorbar_position,
                colorbar_alignment=colorbar_alignment,
                colorbar_width=colorbar_width,
                colorbar_spacing=colorbar_spacing,
                colorbar_length_ratio=colorbar_length_ratio,
                colorbar_label_outside=colorbar_label_outside,
                colorbar_ticks_outside=colorbar_ticks_outside,
                colorbar_ticks_both=colorbar_ticks_both,
            )
        else:
            lats = ds[swath_lat_var].values
            lons = ds[swath_lon_var].values
            _ = self.plot_swath(
                lats,
                lons,
                values,
                cmap=cmap,
                label=label,
                units=units,
                value_range=value_range,
                log_scale=log_scale,
                norm=norm,
                colorbar=colorbar,
                colorbar_position=colorbar_position,
                colorbar_alignment=colorbar_alignment,
                colorbar_width=colorbar_width,
                colorbar_spacing=colorbar_spacing,
                colorbar_length_ratio=colorbar_length_ratio,
                colorbar_label_outside=colorbar_label_outside,
                colorbar_ticks_outside=colorbar_ticks_outside,
                colorbar_ticks_both=colorbar_ticks_both,
            )

    # if view == "data":
    #     self.set_view(lats=lats, lons=lons)

    # if zoom_tmin or zoom_tmax:
    #     extent = compute_bbox(coords_zoomed_in)
    #     self.ax.set_extent(extent, crs=ccrs.PlateCarree())  # type: ignore
    if self.show_text_frame:
        add_title_earthcare_frame(self.ax, ds=ds)

    self.zoom(extent=extent, radius_km=zoom_radius_km)

    return self

save

save(
    filename="",
    filepath=None,
    ds=None,
    ds_filepath=None,
    dpi="figure",
    orbit_and_frame=None,
    utc_timestamp=None,
    use_utc_creation_timestamp=False,
    site_name=None,
    hmax=None,
    radius=None,
    extra=None,
    transparent_outside=False,
    verbose=True,
    print_prefix="",
    create_dirs=False,
    transparent_background=False,
    resolution=None,
    **kwargs
)

Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

Parameters:

Name Type Description Default
figure Figure | HasFigure

A figure object (matplotlib.figure.Figure) or objects exposing a .fig attribute containing a figure (e.g., CurtainFigure).

required
filename str

The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.

''
filepath str | None

The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.

None
ds Dataset | None

A EarthCARE dataset from which metadata will be taken. Defaults to None.

None
ds_filepath str | None

A path to a EarthCARE product from which metadata will be taken. Defaults to None.

None
pad float

Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.

required
dpi float | figure

The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.

'figure'
orbit_and_frame str | None

Metadata used in the formatting of the file name. Defaults to None.

None
utc_timestamp TimestampLike | None

Metadata used in the formatting of the file name. Defaults to None.

None
use_utc_creation_timestamp bool

Whether the time of image creation should be included in the file name. Defaults to False.

False
site_name str | None

Metadata used in the formatting of the file name. Defaults to None.

None
hmax int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
radius int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
resolution str | None

Metadata used in the formatting of the file name. Defaults to None.

None
extra str | None

A custom string to be included in the file name. Defaults to None.

None
transparent_outside bool

Whether the area outside figures should be transparent. Defaults to False.

False
verbose bool

Whether the progress of image creation should be printed to the console. Defaults to True.

True
print_prefix str

A prefix string to all console messages. Defaults to "".

''
create_dirs bool

Whether images should be saved in a folder structure based on provided metadata. Defaults to False.

False
transparent_background bool

Whether the background inside and outside of figures should be transparent. Defaults to False.

False
**kwargs dict[str, Any]

Keyword arguments passed to wrapped function call of matplotlib.pyplot.savefig.

{}
Source code in earthcarekit/plot/figure/map.py
def save(
    self,
    filename: str = "",
    filepath: str | None = None,
    ds: xr.Dataset | None = None,
    ds_filepath: str | None = None,
    dpi: float | Literal["figure"] = "figure",
    orbit_and_frame: str | None = None,
    utc_timestamp: TimestampLike | None = None,
    use_utc_creation_timestamp: bool = False,
    site_name: str | None = None,
    hmax: int | float | None = None,
    radius: int | float | None = None,
    extra: str | None = None,
    transparent_outside: bool = False,
    verbose: bool = True,
    print_prefix: str = "",
    create_dirs: bool = False,
    transparent_background: bool = False,
    resolution: str | None = None,
    **kwargs,
) -> None:
    """
    Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

    Args:
        figure (Figure | HasFigure): A figure object (`matplotlib.figure.Figure`) or objects exposing a `.fig` attribute containing a figure (e.g., `CurtainFigure`).
        filename (str, optional): The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.
        filepath (str | None, optional): The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.
        ds (xr.Dataset | None, optional): A EarthCARE dataset from which metadata will be taken. Defaults to None.
        ds_filepath (str | None, optional): A path to a EarthCARE product from which metadata will be taken. Defaults to None.
        pad (float, optional): Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.
        dpi (float | 'figure', optional): The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.
        orbit_and_frame (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        utc_timestamp (TimestampLike | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        use_utc_creation_timestamp (bool, optional): Whether the time of image creation should be included in the file name. Defaults to False.
        site_name (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        hmax (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        radius (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        resolution (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        extra (str | None, optional): A custom string to be included in the file name. Defaults to None.
        transparent_outside (bool, optional): Whether the area outside figures should be transparent. Defaults to False.
        verbose (bool, optional): Whether the progress of image creation should be printed to the console. Defaults to True.
        print_prefix (str, optional): A prefix string to all console messages. Defaults to "".
        create_dirs (bool, optional): Whether images should be saved in a folder structure based on provided metadata. Defaults to False.
        transparent_background (bool, optional): Whether the background inside and outside of figures should be transparent. Defaults to False.
        **kwargs (dict[str, Any]): Keyword arguments passed to wrapped function call of `matplotlib.pyplot.savefig`.
    """
    save_plot(
        fig=self.fig,
        filename=filename,
        filepath=filepath,
        ds=ds,
        ds_filepath=ds_filepath,
        dpi=dpi,
        orbit_and_frame=orbit_and_frame,
        utc_timestamp=utc_timestamp,
        use_utc_creation_timestamp=use_utc_creation_timestamp,
        site_name=site_name,
        hmax=hmax,
        radius=radius,
        extra=extra,
        transparent_outside=transparent_outside,
        verbose=verbose,
        print_prefix=print_prefix,
        create_dirs=create_dirs,
        transparent_background=transparent_background,
        resolution=resolution,
        **kwargs,
    )

set_view

set_view(latitude, longitude, pad=None)

Fits the plot extent to the given latitude and longitude values.

Parameters:

Name Type Description Default
latitude ArrayLike

Latitude values.

required
longitude ArrayLike

Longitude values.

required
pad float | Iterable | None

Padding or margins around the given lat/lon values. The padding is applied relative to the min/max difference along the respective lat/lon extent, e.g., lats=[-5,5] and pad=0 -> lat extent=[-5,5], pad=1 -> lat extent=[-15,15], pad=2 -> lat extent=[-25,25], etc. Can be given as single number or as a 4-element list, i.e., [left/west, right/east, bottom/south, top/north]. Defaults to None.

None

Returns:

Name Type Description
Axes MapFigure

description

Source code in earthcarekit/plot/figure/map.py
def set_view(
    self,
    latitude: ArrayLike,
    longitude: ArrayLike,
    pad: float | Iterable | None = None,
) -> "MapFigure":
    """
    Fits the plot extent to the given latitude and longitude values.

    Args:
        latitude (ArrayLike): Latitude values.
        longitude (ArrayLike): Longitude values.
        pad (float | Iterable | None, optional):
            Padding or margins around the given lat/lon values.
            The padding is applied relative to the min/max difference along the respective lat/lon extent,
            e.g., `lats=[-5,5]` and `pad=0` -> lat extent=[-5,5], `pad=1` -> lat extent=[-15,15], `pad=2` -> lat extent=[-25,25], etc.
            Can be given as single number or as a 4-element list, i.e., [left/west, right/east, bottom/south, top/north].
            Defaults to None.

    Returns:
        Axes: _description_
    """
    if isinstance(pad, (float | int | Iterable)):
        self.pad = _validate_pad(pad)
    self.ax = set_view(
        self.ax,
        self.projection,
        latitude,
        longitude,
        pad_xmin=self.pad[0],
        pad_xmax=self.pad[1],
        pad_ymin=self.pad[2],
        pad_ymax=self.pad[3],
    )
    return self

to_texture

to_texture(remove_images=True, remove_features=True)

Convert the figure to a texture by removing all axis ticks, labels, annotations, and text.

Source code in earthcarekit/plot/figure/map.py
def to_texture(
    self, remove_images: bool = True, remove_features: bool = True
) -> "MapFigure":
    """Convert the figure to a texture by removing all axis ticks, labels, annotations, and text."""
    # Remove anchored text and other artist text objects
    for artist in reversed(self.ax.artists):
        if isinstance(artist, (Text, AnchoredOffsetbox)):
            artist.remove()

    # Completely remove axis ticks and labels
    self.ax.axis("off")

    # Remove white frame around figure
    self.fig.subplots_adjust(left=0, right=1, bottom=0, top=1)

    # Remove ticks, tick labels, and gridlines
    self.ax.set_xticks([])
    self.ax.set_yticks([])
    self.ax.xaxis.set_ticklabels([])
    self.ax.yaxis.set_ticklabels([])
    self.ax.grid(False)

    # Remove outline box around map
    self.ax.spines["geo"].set_visible(False)

    # Make the map fill the whole figure
    self.ax.set_position((0.0, 0.0, 1.0, 1.0))

    if self.colorbar:
        self.colorbar.remove()

    if self.grid_lines:
        self.grid_lines.remove()

    if remove_images:
        for img in self.ax.get_images():
            img.remove()

    if remove_features:
        for c in self.ax.get_children():
            if isinstance(c, FeatureArtist):
                c.remove()

    # for c in self.ax.get_children():
    #     if isinstance(c, _ViewClippedPathPatch):
    #         c.set_alpha(0)

    for c in self.fig.get_children():
        if isinstance(c, Rectangle):
            c.set_alpha(0)

    self.ax.set_facecolor("none")

    return self

ProductInfo dataclass

Class storing all info gathered from a EarthCARE product's file path.

Attributes:

Name Type Description
mission_id FileMissionID

Mission ID (ECA = EarthCARE).

agency FileAgency

Agency that generated the file (E = ESA, J = JAXA).

latency FileLatency

Latency indicator (X = not applicable, N = near real-time, O = offline).

baseline str

Two-letter product/processor version string (e.g., "BA").

file_type FileType

Full product name (10 characters, e.g., "ATL_EBD_2A").

start_sensing_time Timestamp

Start-time of data collection (i.e., time of first available data in the product).

start_processing_time Timestamp

Start-time of processing (i.e., time at which creation of the product started).

orbit_number int

Number of the orbit.

frame_id str

Single letter identifier between A and H, indication the orbit segment (A,B,H = night frames; D,E,F = day frames; C,G = polar day/night frames).

orbit_and_frame str

Six-character string with leading zeros combining orbit number and frame ID.

name str

Full name of the product without file extension.

filepath str

Local file path or empty string if not available.

hdr_filepath str

Local header file path or empty string if not available.

Source code in earthcarekit/utils/read/product/file_info/product_info.py
@dataclass
class ProductInfo:
    """
    Class storing all info gathered from a EarthCARE product's file path.

    Attributes:
        mission_id (FileMissionID):
            Mission ID (ECA = EarthCARE).
        agency (FileAgency):
            Agency that generated the file (E = ESA, J = JAXA).
        latency (FileLatency):
            Latency indicator (X = not applicable, N = near real-time, O = offline).
        baseline (str):
            Two-letter product/processor version string (e.g., "BA").
        file_type (FileType):
            Full product name (10 characters, e.g., "ATL_EBD_2A").
        start_sensing_time (pd.Timestamp):
            Start-time of data collection (i.e., time of first available data in the product).
        start_processing_time (pd.Timestamp):
            Start-time of processing (i.e., time at which creation of the product started).
        orbit_number (int):
            Number of the orbit.
        frame_id (str):
            Single letter identifier between A and H, indication the orbit segment
            (A,B,H = night frames; D,E,F = day frames; C,G = polar day/night frames).
        orbit_and_frame (str):
            Six-character string with leading zeros combining orbit number and frame ID.
        name (str):
            Full name of the product without file extension.
        filepath (str):
            Local file path or empty string if not available.
        hdr_filepath (str):
            Local header file path or empty string if not available.
    """

    mission_id: FileMissionID
    agency: FileAgency
    latency: FileLatency
    baseline: str
    file_type: FileType
    start_sensing_time: pd.Timestamp
    start_processing_time: pd.Timestamp
    orbit_number: int
    frame_id: str
    orbit_and_frame: str
    name: str
    filepath: str
    hdr_filepath: str

    def to_dict(self) -> dict:
        """Returns product info as a Python `dict`."""
        return asdict(self)

    def to_dataframe(self) -> "ProductDataFrame":
        """Returns product info as a `pandas.Dataframe`."""
        return ProductDataFrame([self])

to_dataframe

to_dataframe()

Returns product info as a pandas.Dataframe.

Source code in earthcarekit/utils/read/product/file_info/product_info.py
def to_dataframe(self) -> "ProductDataFrame":
    """Returns product info as a `pandas.Dataframe`."""
    return ProductDataFrame([self])

to_dict

to_dict()

Returns product info as a Python dict.

Source code in earthcarekit/utils/read/product/file_info/product_info.py
def to_dict(self) -> dict:
    """Returns product info as a Python `dict`."""
    return asdict(self)

ProfileData dataclass

Container for atmospheric profile data.

Stores profile values together with their time/height bins and, optionally, their coordinates and metadata in a consistent structure, making profiles easier to handle, compare and visualise. The object supports NumPy-style indexing based on its values attribute following the convention: profile[time_index, height_index].

Attributes:

Name Type Description
values NDArray

Profile data, either a single vertical profile or a time series of profiles (time x height).

height NDArray

Height bin centers, ascending. Can be fixed or vary with time.

time NDArray

Timestamps corresponding to each profile.

latitude NDArray | None

Ground latitudes for the profiles, optional.

longitude NDArray | None

Ground longitudes for the profiles, optional.

color str | None

Color for plotting, optional.

label str | None

Variable label for plot annotations, optional.

units str | None

Units for plot annotations, optional.

platform str | None

Name or type of measurement platform/instrument, optional.

error NDArray | None

Associated uncertainties for the profile values, optional.

Source code in earthcarekit/utils/profile_data/profile_data.py
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
@dataclass
class ProfileData:
    """Container for atmospheric profile data.

    Stores profile values together with their time/height bins and,
    optionally, their coordinates and metadata in a consistent structure,
    making profiles easier to handle, compare and visualise.
    The object supports NumPy-style indexing based on its `values` attribute
    following the convention: `profile[time_index, height_index]`.

    Attributes:
        values (NDArray): Profile data, either a single vertical profile
            or a time series of profiles (time x height).
        height (NDArray): Height bin centers, ascending. Can be fixed or
            vary with time.
        time (NDArray): Timestamps corresponding to each profile.
        latitude (NDArray | None): Ground latitudes for the profiles, optional.
        longitude (NDArray | None): Ground longitudes for the profiles, optional.
        color (str | None): Color for plotting, optional.
        label (str | None): Variable label for plot annotations, optional.
        units (str | None): Units for plot annotations, optional.
        platform (str | None): Name or type of measurement platform/instrument,
            optional.
        error (NDArray | None): Associated uncertainties for the profile
            values, optional.
    """

    values: NDArray
    height: NDArray
    time: NDArray
    latitude: NDArray | None = None
    longitude: NDArray | None = None
    color: str | None = None
    label: str | None = None
    units: str | None = None
    platform: str | None = None
    error: NDArray | None = None

    def __post_init__(self: "ProfileData") -> None:

        is_increasing: bool = False
        if isinstance(self.height, Iterable):
            self.height = np.asarray(self.height)
            mask_nan_heights = ~np.isnan(np.atleast_2d(self.height)).all(axis=0)
            self.height = _apply_nan_height_mask(self.height, mask_nan_heights)
            h = np.atleast_2d(self.height.copy())
            for i in range(h.shape[0]):
                if not np.all(np.isnan(h[i])):
                    is_increasing = ismonotonic(h[i], mode="increasing")
                    break
            if not is_increasing:
                if len(self.height.shape) == 2:
                    self.height = self.height[:, ::-1]
                else:
                    self.height = self.height[::-1]
        if isinstance(self.values, Iterable):
            self.values = np.atleast_2d(self.values)
            self.values = _apply_nan_height_mask(self.values, mask_nan_heights)
            if not is_increasing:
                self.values = self.values[:, ::-1]
        if isinstance(self.time, Iterable):
            self.time = pd.to_datetime(np.asarray(self.time)).to_numpy()
        if isinstance(self.latitude, Iterable):
            self.latitude = np.asarray(self.latitude)
        if isinstance(self.longitude, Iterable):
            self.longitude = np.asarray(self.longitude)
        if isinstance(self.error, Iterable):
            self.error = np.atleast_2d(self.error)
            self.error = _apply_nan_height_mask(self.error, mask_nan_heights)
            if not is_increasing:
                self.error = self.error[:, ::-1]
            if self.values.shape != self.error.shape:
                raise ValueError(
                    f"`error` must have same shape as `values`: values.shape={self.values.shape} != error.shape={self.error.shape}"
                )
        if isinstance(self.units, str):
            self.units = parse_units(self.units)

        validate_profile_data_dimensions(
            values=self.values,
            height=self.height,
            time=self.time,
            latitude=self.latitude,
            longitude=self.longitude,
        )

    def __getitem__(self: "ProfileData", idx: Any) -> "ProfileData":

        if not isinstance(idx, tuple):
            idx = (idx, slice(None))

        t_idx, h_idx = idx

        new_values = self.values[t_idx, h_idx]

        if self.height.shape == self.values.shape:
            new_height = self.height[t_idx, h_idx]
        else:
            new_height = self.height[h_idx]

        new_error: NDArray | None = None
        if isinstance(self.error, np.ndarray):
            new_error = self.error[t_idx, h_idx]

        if isinstance(self.time, np.ndarray):
            new_time = self.time[t_idx]
        else:
            new_time = None

        if isinstance(self.latitude, np.ndarray):
            new_latitude = self.latitude[t_idx]
        else:
            new_latitude = None

        if isinstance(self.longitude, np.ndarray):
            new_longitude = self.longitude[t_idx]
        else:
            new_longitude = None

        new_color = self.color
        new_label = self.label
        new_units = self.units
        new_platform = self.platform

        return ProfileData(
            values=new_values,
            height=new_height,
            time=new_time,
            latitude=new_latitude,
            longitude=new_longitude,
            color=new_color,
            label=new_label,
            units=new_units,
            platform=new_platform,
            error=new_error,
        )

    @property
    def shape(self):
        return self.values.shape

    def __array__(self, dtype=None, copy=True):
        arr = np.asarray(self.values, dtype=dtype)
        if copy:
            return arr.copy()
        return arr

    def __pos__(self):
        return self.copy()

    def __neg__(self):
        result = self.copy()
        result.values = -result.values
        return result

    def __abs__(self):
        result = self.copy()
        result.values = np.abs(result.values)
        return result

    def __add__(self, other):
        result = self.copy()
        if isinstance(other, ProfileData):
            result.values = result.values + other.values
        else:
            result.values = result.values + other
        return result

    def __radd__(self, other):
        return self.__add__(other)

    def __sub__(self, other):
        result = self.copy()
        if isinstance(other, ProfileData):
            result.values = result.values - other.values
        else:
            result.values = result.values - other
        return result

    def __rsub__(self, other):
        return (self * -1).__add__(other * -1)

    def __mul__(self, other):
        result = self.copy()
        if isinstance(other, ProfileData):
            result.values = result.values * other.values
        else:
            result.values = result.values * other
        return result

    def __rmul__(self, other):
        return self.__mul__(other)

    def __truediv__(self, other):
        result = self.copy()
        if isinstance(other, ProfileData):
            result.values = result.values / other.values
        else:
            result.values = result.values / other
        return result

    def __rtruediv__(self, other):
        result = self.copy()
        if isinstance(other, ProfileData):
            result.values = other.values / result.values
        else:
            result.values = other / result.values
        return result

    def __pow__(self, other):
        result = self.copy()
        if isinstance(other, ProfileData):
            result.values = result.values**other.values
        else:
            result.values = result.values**other
        return result

    def __rpow__(self, other):
        result = self.copy()
        if isinstance(other, ProfileData):
            result.values = other.values**result.values
        else:
            result.values = other**result.values
        return result

    def __eq__(self, other):
        if isinstance(other, (np.ndarray, Number)):
            return self.values == other
        if not isinstance(other, ProfileData):
            raise TypeError("Can only compare two ProfileData instances")
        return self.values == other.values

    def __lt__(self, other):
        if isinstance(other, (np.ndarray, Number)):
            return self.values < other
        if not isinstance(other, ProfileData):
            raise TypeError("Can only compare two ProfileData instances")
        return self.values < other.values

    def __le__(self, other):
        if isinstance(other, (np.ndarray, Number)):
            return self.values <= other
        if not isinstance(other, ProfileData):
            raise TypeError("Can only compare two ProfileData instances")
        return self.values <= other.values

    def __gt__(self, other):
        if isinstance(other, (np.ndarray, Number)):
            return self.values > other
        if not isinstance(other, ProfileData):
            raise TypeError("Can only compare two ProfileData instances")
        return self.values > other.values

    def __ge__(self, other):
        if isinstance(other, (np.ndarray, Number)):
            return self.values >= other
        if not isinstance(other, ProfileData):
            raise TypeError("Can only compare two ProfileData instances")
        return self.values >= other.values

    @classmethod
    def from_dataset(
        self,
        ds: xr.Dataset,
        var: str,
        error_var: str | None = None,
        height_var: str = HEIGHT_VAR,
        time_var: str = TIME_VAR,
        lat_var: str = TRACK_LAT_VAR,
        lon_var: str = TRACK_LON_VAR,
        color: str | None = None,
        label: str | None = None,
        units: str | None = None,
        platform: str | None = None,
    ) -> "ProfileData":
        values = ds[var].values
        height = ds[height_var].values
        time = ds[time_var].values

        latitude: NDArray | None = None
        if lat_var in ds:
            latitude = ds[lat_var].values

        longitude: NDArray | None = None
        if lon_var in ds:
            longitude = ds[lon_var].values

        if not isinstance(label, str):
            label = None if not hasattr(ds[var], "long_name") else ds[var].long_name

        if not isinstance(label, str):
            label = None if not hasattr(ds[var], "name") else ds[var].name  # type: ignore

        if not isinstance(label, str):
            label = None if not hasattr(ds[var], "label") else ds[var].label

        if not isinstance(units, str):
            units = None if not hasattr(ds[var], "units") else ds[var].units

        if not isinstance(units, str):
            units = None if not hasattr(ds[var], "unit") else ds[var].unit

        error: NDArray | None = None
        if isinstance(error_var, str):
            error = ds[error_var].values

        return ProfileData(
            values=values,
            height=height,
            time=time,
            latitude=latitude,
            longitude=longitude,
            color=color,
            label=label,
            units=units,
            platform=platform,
            error=error,
        )

    def print_shapes(self):
        if isinstance(self.values, Iterable):
            print(f"values={self.values.shape}")
        if isinstance(self.height, Iterable):
            print(f"height={self.height.shape}")
        if isinstance(self.time, Iterable):
            print(f"time={self.time.shape}")
        if isinstance(self.latitude, Iterable):
            print(f"latitude={self.latitude.shape}")
        if isinstance(self.longitude, Iterable):
            print(f"longitude={self.longitude.shape}")

    def mean(self, **kwargs) -> "ProfileData":
        """Returns mean profile."""
        if "axis" in kwargs:
            return np.mean(self.values, **kwargs)
        elif len(kwargs) > 0:
            raise TypeError(
                f"{self.mean.__name__}() got an unexpected keyword argument '{list(kwargs.keys())[0]}'"
            )

        new_values = _mean_2d(self.values)
        new_height = _mean_2d(self.height)
        new_error: NDArray | None = None
        if isinstance(self.error, np.ndarray):
            new_error = _mean_2d(self.error)

        if isinstance(self.time, np.ndarray):
            new_time = _mean_1d(self.time)
        else:
            new_time = None

        if isinstance(self.latitude, np.ndarray):
            new_latitude = _mean_1d(self.latitude)
        else:
            new_latitude = None

        if isinstance(self.longitude, np.ndarray):
            new_longitude = _mean_1d(self.longitude)
        else:
            new_longitude = None

        new_color = self.color
        new_label = self.label
        new_units = self.units
        new_platform = self.platform

        return ProfileData(
            values=new_values,
            height=new_height,
            time=new_time,
            latitude=new_latitude,
            longitude=new_longitude,
            color=new_color,
            label=new_label,
            units=new_units,
            platform=new_platform,
            error=new_error,
        )

    def std(self, **kwargs) -> "ProfileData":
        """Returns standard deviation profile."""
        if "axis" in kwargs:
            return np.std(self.values, **kwargs)
        elif len(kwargs) > 0:
            raise TypeError(
                f"{self.std.__name__}() got an unexpected keyword argument '{list(kwargs.keys())[0]}'"
            )

        new_values = _std_2d(self.values)
        new_height = _mean_2d(self.height)
        new_error: NDArray | None = None
        if isinstance(self.error, np.ndarray):
            new_error = _mean_2d(self.error)

        if isinstance(self.time, np.ndarray):
            new_time = _mean_1d(self.time)
        else:
            new_time = None

        if isinstance(self.latitude, np.ndarray):
            new_latitude = _mean_1d(self.latitude)
        else:
            new_latitude = None

        if isinstance(self.longitude, np.ndarray):
            new_longitude = _mean_1d(self.longitude)
        else:
            new_longitude = None

        new_color = self.color
        new_label = self.label
        new_units = self.units
        new_platform = self.platform

        return ProfileData(
            values=new_values,
            height=new_height,
            time=new_time,
            latitude=new_latitude,
            longitude=new_longitude,
            color=new_color,
            label=new_label,
            units=new_units,
            platform=new_platform,
            error=new_error,
        )

    def rolling_mean(self, window_size: int, axis: Literal[0, 1] = 0) -> "ProfileData":
        """Returns mean profile."""
        if len(self.values.shape) == 2:
            new_values = rolling_mean_2d(self.values, w=window_size, axis=axis)
            new_error: NDArray | None = None
            if isinstance(self.error, np.ndarray):
                new_error = self.error
            return ProfileData(
                values=new_values,
                height=self.height,
                time=self.time,
                latitude=self.latitude,
                longitude=self.longitude,
                color=self.color,
                label=self.label,
                units=self.units,
                platform=self.platform,
                error=new_error,
            )

        msg = f"VerticalProfile contains only one profile and thus {self.rolling_mean.__name__}() is not applied."
        warnings.warn(msg)
        return self

    def layer_mean(self, hmin: float, hmax: float) -> NDArray:
        """Returns layer mean values."""
        layer_mask = np.logical_and(hmin <= self.height, self.height <= hmax)
        layer_mean_values = self.values
        if not np.issubdtype(layer_mean_values.dtype, np.floating):
            layer_mean_values = layer_mean_values.astype(float)
        layer_mean_values[~layer_mask] = np.nan
        if len(layer_mean_values.shape) == 2:
            layer_mean_values = _mean_2d(layer_mean_values, axis=1)
        else:
            layer_mean_values = np.array(nan_mean(layer_mean_values))
        return layer_mean_values

    def rebin_height(
        self,
        height_bin_centers: Iterable[float] | NDArray,
        method: Literal["interpolate", "mean"] = "mean",
    ) -> "ProfileData":
        """
        Rebins profiles to new height bins.

        Parameters:
            new_height (np.ndarray):
                Target height bin centers as a 1D array (shape represents vertical dimension)

        Returns:
            rebinned_profiles (VerticalProfiles):
                Profiles rebinned along the vertical dimension according to `height_bin_centers`.
        """
        if self.height.shape == np.array(height_bin_centers).shape and np.all(
            np.array(self.height) == np.array(height_bin_centers)
        ):
            return ProfileData(
                values=self.values,
                height=self.height,
                time=self.time,
                latitude=self.latitude,
                longitude=self.longitude,
                color=self.color,
                label=self.label,
                units=self.units,
                platform=self.platform,
                error=self.error,
            )

        new_values = rebin_height(
            self.values,
            self.height,
            height_bin_centers,
            method=method,
        )
        new_height = np.asarray(height_bin_centers)
        if len(new_values.shape) == 2:
            new_height = np.atleast_2d(new_height)
            if new_height.shape[0] == 1:
                new_height = new_height[0]
        new_error: NDArray | None = None
        if isinstance(self.error, np.ndarray):
            new_error = rebin_height(
                self.error,
                self.height,
                height_bin_centers,
                method=method,
            )
        return ProfileData(
            values=new_values,
            height=new_height,
            time=self.time,
            latitude=self.latitude,
            longitude=self.longitude,
            color=self.color,
            label=self.label,
            units=self.units,
            platform=self.platform,
            error=new_error,
        )

    def rebin_time(
        self,
        time_bin_centers: Sequence[TimestampLike] | ArrayLike,
        method: Literal["interpolate", "mean"] = "mean",
    ) -> "ProfileData":
        """
        Rebins profiles to new time bins.

        Args:
            time_bin_centers (Iterable[TimestampLike] | ArrayLike):
                Target time bin centers as a 1D array (shape represents temporal dimension)

        Returns:
            rebinned_profiles (VerticalProfiles):
                Profiles rebinned along the temporal dimension according to `height_bin_centers`.
        """
        time_bin_centers = to_timestamps(time_bin_centers)
        new_values = rebin_time(self.values, self.time, time_bin_centers, method=method)
        if len(self.height.shape) == 2:
            new_height = rebin_time(
                self.height, self.time, time_bin_centers, method=method
            )
        else:
            new_height = self.height
        new_error: NDArray | None = None
        if isinstance(self.error, np.ndarray):
            new_error = rebin_time(
                self.error, self.time, time_bin_centers, method=method
            )

        if isinstance(self.latitude, np.ndarray) and isinstance(
            self.longitude, np.ndarray
        ):
            new_coords = rebin_time(
                np.vstack([self.latitude, self.longitude]).T,
                self.time,
                time_bin_centers,
                is_geo=True,
                method=method,
            )
            new_latitude = new_coords[:, 0]
            new_longitude = new_coords[:, 0]
        else:
            new_latitude = None
            new_longitude = None
        return ProfileData(
            values=new_values,
            height=new_height,
            time=pd.to_datetime(to_timestamps(time_bin_centers)).to_numpy(),
            latitude=new_latitude,
            longitude=new_longitude,
            color=self.color,
            label=self.label,
            units=self.units,
            platform=self.platform,
            error=new_error,
        )

    def rebin_along_track(
        self,
        latitude_bin_centers: ArrayLike,
        longitude_bin_centers: ArrayLike,
    ) -> "ProfileData":
        """
        Rebins profiles to new time bins.

        Args:
            latitude_bin_centers (ArrayLike):
                Target time bin centers as a 1D array (shape represents temporal dimension)

        Returns:
            rebinned_profiles (VerticalProfiles):
                Profiles rebinned along the temporal dimension according to `height_bin_centers`.
        """
        has_lat = self.latitude is not None
        has_lon = self.longitude is not None

        if not has_lat or not has_lon:
            missing = []
            if not has_lat:
                missing.append("latitude")
            if not has_lon:
                missing.append("longitude")
            raise ValueError(
                f"{ProfileData.__name__} instance is missing {' and '.join(missing)} data"
            )

        latitude_bin_centers = np.asarray(latitude_bin_centers)
        longitude_bin_centers = np.asarray(longitude_bin_centers)

        new_values = rebin_along_track(
            self.values,
            np.asarray(self.latitude),
            np.asarray(self.longitude),
            latitude_bin_centers,
            longitude_bin_centers,
        )
        new_error: NDArray | None = None
        if isinstance(self.error, np.ndarray):
            new_error = rebin_along_track(
                self.error,
                np.asarray(self.latitude),
                np.asarray(self.longitude),
                latitude_bin_centers,
                longitude_bin_centers,
            )
        new_times = rebin_along_track(
            self.time,
            np.asarray(self.latitude),
            np.asarray(self.longitude),
            latitude_bin_centers,
            longitude_bin_centers,
        )
        return ProfileData(
            values=new_values,
            height=self.height,
            time=new_times,
            latitude=np.array(latitude_bin_centers),
            longitude=np.array(longitude_bin_centers),
            color=self.color,
            label=self.label,
            units=self.units,
            platform=self.platform,
            error=new_error,
        )

    def to_dict(self) -> dict:
        """Returns stored profile data as `dict`."""
        return asdict(self)

    def select_height_range(
        self,
        height_range: DistanceRangeLike,
        pad_idx: int = 0,
    ) -> "ProfileData":
        """
        Returns only data within the specified `height_range`.

        Args:
            height_range (DistanceRangeLike): Pair of minimum and maximum height in meters.
            pad_idx (int): Number of indexes that will be appended to the result before and after given height range. Defaults to 0.

        Returns:
            ProfileData: New instance of ProfileData filtered by given height range.
        """
        height_range = validate_height_range(height_range)

        if len(self.height.shape) == 2:
            ref_height = self.height.copy()
        else:
            ref_height = np.repeat(
                np.atleast_2d(self.height.copy()),
                self.values.shape[0],
                axis=0,
            )

        mask = np.logical_and(
            height_range[0] <= ref_height,
            ref_height <= height_range[1],
        )
        mask = pad_true_sequence_2d(mask, pad_idx)

        sel_height = ref_height.copy()
        if not np.issubdtype(sel_height.dtype, np.floating):
            sel_height = sel_height.astype(float)
        sel_height[~mask] = np.nan
        mask_height = ~np.isnan(np.atleast_2d(sel_height)).all(axis=0)
        sel_height = sel_height[:, mask_height]

        sel_values = self.values.copy()
        if not np.issubdtype(sel_values.dtype, np.floating):
            sel_values = sel_values.astype(float)
        sel_values[~mask] = np.nan
        sel_values = sel_values[:, mask_height]

        sel_error: NDArray | None = None
        if isinstance(self.error, np.ndarray):
            sel_error = self.error.copy()
            if not np.issubdtype(sel_error.dtype, np.floating):
                sel_error = sel_error.astype(float)
            sel_error[~mask] = np.nan
            sel_error = sel_error[:, mask_height]

        if len(self.height.shape) == 1:
            sel_height = sel_height[0]

        return ProfileData(
            values=sel_values,
            height=sel_height,
            time=self.time,
            latitude=self.latitude,
            longitude=self.longitude,
            color=self.color,
            label=self.label,
            units=self.units,
            platform=self.platform,
            error=sel_error,
        )

    def select_time_range(
        self,
        time_range: TimeRangeLike | None,
        pad_idxs: int = 0,
    ) -> "ProfileData":
        """
        Returns only data within the specified `time_range`.

        Args:
            time_range (TimeRangeLike | None): Pair of minimum and maximum timestamps or None.
            pad_idx (int): Number of indexes that will be appended to the result before and after given time range. Defaults to 0.

        Returns:
            ProfileData: New instance of ProfileData filtered by given time range.
        """
        if time_range is None:
            return self
        elif not isinstance(self.time, np.ndarray):
            raise ValueError(
                f"{ProfileData.__name__}.{self.select_time_range.__name__}() missing `time` data"
            )

        time_range = validate_time_range(time_range)

        times = to_timestamps(self.time)
        mask = np.logical_and(time_range[0] <= times, times <= time_range[1])
        mask = pad_true_sequence(mask, pad_idxs)

        sel_values = self.values[mask]
        sel_error: NDArray | None = None
        if isinstance(self.error, np.ndarray):
            sel_error = self.error[:, mask]
        sel_time = self.time[mask]

        if len(self.height.shape) == 2:
            sel_height = self.height[mask]
        else:
            sel_height = self.height

        if isinstance(self.latitude, np.ndarray):
            sel_latitude = self.latitude[mask]
        else:
            sel_latitude = None

        if isinstance(self.longitude, np.ndarray):
            sel_longitude = self.longitude[mask]
        else:
            sel_longitude = None

        return ProfileData(
            values=sel_values,
            height=sel_height,
            time=sel_time,
            latitude=sel_latitude,
            longitude=sel_longitude,
            color=self.color,
            label=self.label,
            units=self.units,
            platform=self.platform,
            error=sel_error,
        )

    def coarsen_mean(self, n: int, is_bin: bool = False) -> "ProfileData":
        """Returns downsampled profile data."""
        if len(self.values.shape) == 2:
            new_values: NDArray
            new_values = coarsen_mean(self.values, n=n, is_bin=is_bin)
            new_error: NDArray | None = None
            if isinstance(self.error, np.ndarray):
                new_error = coarsen_mean(self.error, n=n, is_bin=is_bin)
            new_time: NDArray = coarsen_mean(self.time, n=n)

            new_height: NDArray
            if len(self.height.shape) == 2:
                new_height = coarsen_mean(self.height, n=n)
            else:
                new_height = self.height

            new_latitude: NDArray | None
            if isinstance(self.latitude, np.ndarray):
                new_latitude = coarsen_mean(self.latitude, n=n)
            else:
                new_latitude = None

            new_longitude: NDArray | None
            if isinstance(self.longitude, np.ndarray):
                new_longitude = coarsen_mean(self.longitude, n=n)
            else:
                new_longitude = None

            return ProfileData(
                values=new_values,
                height=new_height,
                time=new_time,
                latitude=new_latitude,
                longitude=new_longitude,
                color=self.color,
                label=self.label,
                units=self.units,
                platform=self.platform,
                error=new_error,
            )

        msg = f"VerticalProfile contains only one profile and thus {self.coarsen_mean.__name__}() is not applied."
        warnings.warn(msg)
        return self

    def stats(
        self,
        height_range: DistanceRangeLike | None = None,
    ) -> ProfileStatResults:
        p = self
        _hmin: float = float(np.nanmin(p.height))
        _hmax: float = float(np.nanmax(p.height))
        if height_range is not None:
            height_range = validate_height_range(height_range)
            _hmin = height_range[0]
            _hmax = height_range[1]
            p = p.select_height_range(height_range)

        p = p.mean()
        _mean: float = float(stats.nan_mean(p.values))
        _std: float = float(stats.nan_std(p.values))
        _mean_error: float | None = None
        if isinstance(p.error, np.ndarray):
            _mean_error = float(stats.nan_mean(p.error))
        return ProfileStatResults(
            hmin=_hmin,
            hmax=_hmax,
            mean=_mean,
            std=_std,
            mean_error=_mean_error,
        )

    def compare_to(
        self,
        target: "ProfileData",
        height_range: DistanceRangeLike | None = None,
    ) -> ProfileComparisonResults:
        p = self.copy()
        p = p.mean()
        t = target.copy()
        t = t.mean()

        get_mean_abs_diff = lambda x: float(np.nanmean(np.abs(np.diff(x))))
        if get_mean_abs_diff(p.height) > get_mean_abs_diff(t.height):
            t = t.rebin_height(p.height)
        else:
            p = p.rebin_height(t.height)

        _hmin: float = float(np.nanmin(p.height))
        _hmax: float = float(np.nanmax(p.height))
        if height_range is not None:
            height_range = validate_height_range(height_range)
            _hmin = height_range[0]
            _hmax = height_range[1]
            p = p.select_height_range(height_range)
            t = t.select_height_range(height_range)

        stats_pred = p.stats()
        stats_targ = t.stats()

        if np.nanmean(np.diff(self.height)) > np.nanmean(np.diff(target.height)):
            height_bins = target.height
        else:
            height_bins = self.height

        _diff_of_means: float = float(stats.nan_diff_of_means(p.values, t.values))
        _mae: float = float(stats.nan_mae(p.values, t.values))
        _rmse: float = float(stats.nan_rmse(p.values, t.values))
        _mean_diff: float = float(stats.nan_mean_diff(p.values, t.values))

        return ProfileComparisonResults(
            hmin=_hmin,
            hmax=_hmax,
            diff_of_means=_diff_of_means,
            mae=_mae,
            rmse=_rmse,
            mean_diff=_mean_diff,
            prediction=stats_pred,
            target=stats_targ,
        )

    def to_mega(self) -> "ProfileData":
        import logging

        logger = logging.getLogger()

        if isinstance(self.units, str):
            if self.units in ["m-1 sr-1", "m-1"]:
                return ProfileData(
                    values=self.values * 1e6,
                    height=self.height,
                    time=self.time,
                    latitude=self.latitude,
                    longitude=self.longitude,
                    color=self.color,
                    label=self.label,
                    units=f"M{self.units}",
                    platform=self.platform,
                    error=(
                        None
                        if not isinstance(self.error, np.ndarray)
                        else self.error * 1e6
                    ),
                )
            elif self.units in ["Mm-1 sr-1", "Mm-1"]:
                logger.warning(
                    f"""Profile units already converted to "{self.units}"."""
                )
                return self.copy()
            else:
                logger.warning(
                    f"""Can not convert profile to "Mm-1 sr-1" or "Mm-1" since it's original units are: "{self.units}"."""
                )
                return self.copy()
        logger.warning(
            f"""Can not convert profile to "Mm-1 sr-1" or "Mm-1" since units are not given."""
        )

        return self.copy()

    def copy(self) -> "ProfileData":
        return ProfileData(
            values=self.values,
            height=self.height,
            time=self.time,
            latitude=self.latitude,
            longitude=self.longitude,
            color=self.color,
            label=self.label,
            units=self.units,
            platform=self.platform,
            error=self.error,
        )

coarsen_mean

coarsen_mean(n, is_bin=False)

Returns downsampled profile data.

Source code in earthcarekit/utils/profile_data/profile_data.py
def coarsen_mean(self, n: int, is_bin: bool = False) -> "ProfileData":
    """Returns downsampled profile data."""
    if len(self.values.shape) == 2:
        new_values: NDArray
        new_values = coarsen_mean(self.values, n=n, is_bin=is_bin)
        new_error: NDArray | None = None
        if isinstance(self.error, np.ndarray):
            new_error = coarsen_mean(self.error, n=n, is_bin=is_bin)
        new_time: NDArray = coarsen_mean(self.time, n=n)

        new_height: NDArray
        if len(self.height.shape) == 2:
            new_height = coarsen_mean(self.height, n=n)
        else:
            new_height = self.height

        new_latitude: NDArray | None
        if isinstance(self.latitude, np.ndarray):
            new_latitude = coarsen_mean(self.latitude, n=n)
        else:
            new_latitude = None

        new_longitude: NDArray | None
        if isinstance(self.longitude, np.ndarray):
            new_longitude = coarsen_mean(self.longitude, n=n)
        else:
            new_longitude = None

        return ProfileData(
            values=new_values,
            height=new_height,
            time=new_time,
            latitude=new_latitude,
            longitude=new_longitude,
            color=self.color,
            label=self.label,
            units=self.units,
            platform=self.platform,
            error=new_error,
        )

    msg = f"VerticalProfile contains only one profile and thus {self.coarsen_mean.__name__}() is not applied."
    warnings.warn(msg)
    return self

layer_mean

layer_mean(hmin, hmax)

Returns layer mean values.

Source code in earthcarekit/utils/profile_data/profile_data.py
def layer_mean(self, hmin: float, hmax: float) -> NDArray:
    """Returns layer mean values."""
    layer_mask = np.logical_and(hmin <= self.height, self.height <= hmax)
    layer_mean_values = self.values
    if not np.issubdtype(layer_mean_values.dtype, np.floating):
        layer_mean_values = layer_mean_values.astype(float)
    layer_mean_values[~layer_mask] = np.nan
    if len(layer_mean_values.shape) == 2:
        layer_mean_values = _mean_2d(layer_mean_values, axis=1)
    else:
        layer_mean_values = np.array(nan_mean(layer_mean_values))
    return layer_mean_values

mean

mean(**kwargs)

Returns mean profile.

Source code in earthcarekit/utils/profile_data/profile_data.py
def mean(self, **kwargs) -> "ProfileData":
    """Returns mean profile."""
    if "axis" in kwargs:
        return np.mean(self.values, **kwargs)
    elif len(kwargs) > 0:
        raise TypeError(
            f"{self.mean.__name__}() got an unexpected keyword argument '{list(kwargs.keys())[0]}'"
        )

    new_values = _mean_2d(self.values)
    new_height = _mean_2d(self.height)
    new_error: NDArray | None = None
    if isinstance(self.error, np.ndarray):
        new_error = _mean_2d(self.error)

    if isinstance(self.time, np.ndarray):
        new_time = _mean_1d(self.time)
    else:
        new_time = None

    if isinstance(self.latitude, np.ndarray):
        new_latitude = _mean_1d(self.latitude)
    else:
        new_latitude = None

    if isinstance(self.longitude, np.ndarray):
        new_longitude = _mean_1d(self.longitude)
    else:
        new_longitude = None

    new_color = self.color
    new_label = self.label
    new_units = self.units
    new_platform = self.platform

    return ProfileData(
        values=new_values,
        height=new_height,
        time=new_time,
        latitude=new_latitude,
        longitude=new_longitude,
        color=new_color,
        label=new_label,
        units=new_units,
        platform=new_platform,
        error=new_error,
    )

rebin_along_track

rebin_along_track(latitude_bin_centers, longitude_bin_centers)

Rebins profiles to new time bins.

Parameters:

Name Type Description Default
latitude_bin_centers ArrayLike

Target time bin centers as a 1D array (shape represents temporal dimension)

required

Returns:

Name Type Description
rebinned_profiles VerticalProfiles

Profiles rebinned along the temporal dimension according to height_bin_centers.

Source code in earthcarekit/utils/profile_data/profile_data.py
def rebin_along_track(
    self,
    latitude_bin_centers: ArrayLike,
    longitude_bin_centers: ArrayLike,
) -> "ProfileData":
    """
    Rebins profiles to new time bins.

    Args:
        latitude_bin_centers (ArrayLike):
            Target time bin centers as a 1D array (shape represents temporal dimension)

    Returns:
        rebinned_profiles (VerticalProfiles):
            Profiles rebinned along the temporal dimension according to `height_bin_centers`.
    """
    has_lat = self.latitude is not None
    has_lon = self.longitude is not None

    if not has_lat or not has_lon:
        missing = []
        if not has_lat:
            missing.append("latitude")
        if not has_lon:
            missing.append("longitude")
        raise ValueError(
            f"{ProfileData.__name__} instance is missing {' and '.join(missing)} data"
        )

    latitude_bin_centers = np.asarray(latitude_bin_centers)
    longitude_bin_centers = np.asarray(longitude_bin_centers)

    new_values = rebin_along_track(
        self.values,
        np.asarray(self.latitude),
        np.asarray(self.longitude),
        latitude_bin_centers,
        longitude_bin_centers,
    )
    new_error: NDArray | None = None
    if isinstance(self.error, np.ndarray):
        new_error = rebin_along_track(
            self.error,
            np.asarray(self.latitude),
            np.asarray(self.longitude),
            latitude_bin_centers,
            longitude_bin_centers,
        )
    new_times = rebin_along_track(
        self.time,
        np.asarray(self.latitude),
        np.asarray(self.longitude),
        latitude_bin_centers,
        longitude_bin_centers,
    )
    return ProfileData(
        values=new_values,
        height=self.height,
        time=new_times,
        latitude=np.array(latitude_bin_centers),
        longitude=np.array(longitude_bin_centers),
        color=self.color,
        label=self.label,
        units=self.units,
        platform=self.platform,
        error=new_error,
    )

rebin_height

rebin_height(height_bin_centers, method='mean')

Rebins profiles to new height bins.

Parameters:

Name Type Description Default
new_height ndarray

Target height bin centers as a 1D array (shape represents vertical dimension)

required

Returns:

Name Type Description
rebinned_profiles VerticalProfiles

Profiles rebinned along the vertical dimension according to height_bin_centers.

Source code in earthcarekit/utils/profile_data/profile_data.py
def rebin_height(
    self,
    height_bin_centers: Iterable[float] | NDArray,
    method: Literal["interpolate", "mean"] = "mean",
) -> "ProfileData":
    """
    Rebins profiles to new height bins.

    Parameters:
        new_height (np.ndarray):
            Target height bin centers as a 1D array (shape represents vertical dimension)

    Returns:
        rebinned_profiles (VerticalProfiles):
            Profiles rebinned along the vertical dimension according to `height_bin_centers`.
    """
    if self.height.shape == np.array(height_bin_centers).shape and np.all(
        np.array(self.height) == np.array(height_bin_centers)
    ):
        return ProfileData(
            values=self.values,
            height=self.height,
            time=self.time,
            latitude=self.latitude,
            longitude=self.longitude,
            color=self.color,
            label=self.label,
            units=self.units,
            platform=self.platform,
            error=self.error,
        )

    new_values = rebin_height(
        self.values,
        self.height,
        height_bin_centers,
        method=method,
    )
    new_height = np.asarray(height_bin_centers)
    if len(new_values.shape) == 2:
        new_height = np.atleast_2d(new_height)
        if new_height.shape[0] == 1:
            new_height = new_height[0]
    new_error: NDArray | None = None
    if isinstance(self.error, np.ndarray):
        new_error = rebin_height(
            self.error,
            self.height,
            height_bin_centers,
            method=method,
        )
    return ProfileData(
        values=new_values,
        height=new_height,
        time=self.time,
        latitude=self.latitude,
        longitude=self.longitude,
        color=self.color,
        label=self.label,
        units=self.units,
        platform=self.platform,
        error=new_error,
    )

rebin_time

rebin_time(time_bin_centers, method='mean')

Rebins profiles to new time bins.

Parameters:

Name Type Description Default
time_bin_centers Iterable[TimestampLike] | ArrayLike

Target time bin centers as a 1D array (shape represents temporal dimension)

required

Returns:

Name Type Description
rebinned_profiles VerticalProfiles

Profiles rebinned along the temporal dimension according to height_bin_centers.

Source code in earthcarekit/utils/profile_data/profile_data.py
def rebin_time(
    self,
    time_bin_centers: Sequence[TimestampLike] | ArrayLike,
    method: Literal["interpolate", "mean"] = "mean",
) -> "ProfileData":
    """
    Rebins profiles to new time bins.

    Args:
        time_bin_centers (Iterable[TimestampLike] | ArrayLike):
            Target time bin centers as a 1D array (shape represents temporal dimension)

    Returns:
        rebinned_profiles (VerticalProfiles):
            Profiles rebinned along the temporal dimension according to `height_bin_centers`.
    """
    time_bin_centers = to_timestamps(time_bin_centers)
    new_values = rebin_time(self.values, self.time, time_bin_centers, method=method)
    if len(self.height.shape) == 2:
        new_height = rebin_time(
            self.height, self.time, time_bin_centers, method=method
        )
    else:
        new_height = self.height
    new_error: NDArray | None = None
    if isinstance(self.error, np.ndarray):
        new_error = rebin_time(
            self.error, self.time, time_bin_centers, method=method
        )

    if isinstance(self.latitude, np.ndarray) and isinstance(
        self.longitude, np.ndarray
    ):
        new_coords = rebin_time(
            np.vstack([self.latitude, self.longitude]).T,
            self.time,
            time_bin_centers,
            is_geo=True,
            method=method,
        )
        new_latitude = new_coords[:, 0]
        new_longitude = new_coords[:, 0]
    else:
        new_latitude = None
        new_longitude = None
    return ProfileData(
        values=new_values,
        height=new_height,
        time=pd.to_datetime(to_timestamps(time_bin_centers)).to_numpy(),
        latitude=new_latitude,
        longitude=new_longitude,
        color=self.color,
        label=self.label,
        units=self.units,
        platform=self.platform,
        error=new_error,
    )

rolling_mean

rolling_mean(window_size, axis=0)

Returns mean profile.

Source code in earthcarekit/utils/profile_data/profile_data.py
def rolling_mean(self, window_size: int, axis: Literal[0, 1] = 0) -> "ProfileData":
    """Returns mean profile."""
    if len(self.values.shape) == 2:
        new_values = rolling_mean_2d(self.values, w=window_size, axis=axis)
        new_error: NDArray | None = None
        if isinstance(self.error, np.ndarray):
            new_error = self.error
        return ProfileData(
            values=new_values,
            height=self.height,
            time=self.time,
            latitude=self.latitude,
            longitude=self.longitude,
            color=self.color,
            label=self.label,
            units=self.units,
            platform=self.platform,
            error=new_error,
        )

    msg = f"VerticalProfile contains only one profile and thus {self.rolling_mean.__name__}() is not applied."
    warnings.warn(msg)
    return self

select_height_range

select_height_range(height_range, pad_idx=0)

Returns only data within the specified height_range.

Parameters:

Name Type Description Default
height_range DistanceRangeLike

Pair of minimum and maximum height in meters.

required
pad_idx int

Number of indexes that will be appended to the result before and after given height range. Defaults to 0.

0

Returns:

Name Type Description
ProfileData ProfileData

New instance of ProfileData filtered by given height range.

Source code in earthcarekit/utils/profile_data/profile_data.py
def select_height_range(
    self,
    height_range: DistanceRangeLike,
    pad_idx: int = 0,
) -> "ProfileData":
    """
    Returns only data within the specified `height_range`.

    Args:
        height_range (DistanceRangeLike): Pair of minimum and maximum height in meters.
        pad_idx (int): Number of indexes that will be appended to the result before and after given height range. Defaults to 0.

    Returns:
        ProfileData: New instance of ProfileData filtered by given height range.
    """
    height_range = validate_height_range(height_range)

    if len(self.height.shape) == 2:
        ref_height = self.height.copy()
    else:
        ref_height = np.repeat(
            np.atleast_2d(self.height.copy()),
            self.values.shape[0],
            axis=0,
        )

    mask = np.logical_and(
        height_range[0] <= ref_height,
        ref_height <= height_range[1],
    )
    mask = pad_true_sequence_2d(mask, pad_idx)

    sel_height = ref_height.copy()
    if not np.issubdtype(sel_height.dtype, np.floating):
        sel_height = sel_height.astype(float)
    sel_height[~mask] = np.nan
    mask_height = ~np.isnan(np.atleast_2d(sel_height)).all(axis=0)
    sel_height = sel_height[:, mask_height]

    sel_values = self.values.copy()
    if not np.issubdtype(sel_values.dtype, np.floating):
        sel_values = sel_values.astype(float)
    sel_values[~mask] = np.nan
    sel_values = sel_values[:, mask_height]

    sel_error: NDArray | None = None
    if isinstance(self.error, np.ndarray):
        sel_error = self.error.copy()
        if not np.issubdtype(sel_error.dtype, np.floating):
            sel_error = sel_error.astype(float)
        sel_error[~mask] = np.nan
        sel_error = sel_error[:, mask_height]

    if len(self.height.shape) == 1:
        sel_height = sel_height[0]

    return ProfileData(
        values=sel_values,
        height=sel_height,
        time=self.time,
        latitude=self.latitude,
        longitude=self.longitude,
        color=self.color,
        label=self.label,
        units=self.units,
        platform=self.platform,
        error=sel_error,
    )

select_time_range

select_time_range(time_range, pad_idxs=0)

Returns only data within the specified time_range.

Parameters:

Name Type Description Default
time_range TimeRangeLike | None

Pair of minimum and maximum timestamps or None.

required
pad_idx int

Number of indexes that will be appended to the result before and after given time range. Defaults to 0.

required

Returns:

Name Type Description
ProfileData ProfileData

New instance of ProfileData filtered by given time range.

Source code in earthcarekit/utils/profile_data/profile_data.py
def select_time_range(
    self,
    time_range: TimeRangeLike | None,
    pad_idxs: int = 0,
) -> "ProfileData":
    """
    Returns only data within the specified `time_range`.

    Args:
        time_range (TimeRangeLike | None): Pair of minimum and maximum timestamps or None.
        pad_idx (int): Number of indexes that will be appended to the result before and after given time range. Defaults to 0.

    Returns:
        ProfileData: New instance of ProfileData filtered by given time range.
    """
    if time_range is None:
        return self
    elif not isinstance(self.time, np.ndarray):
        raise ValueError(
            f"{ProfileData.__name__}.{self.select_time_range.__name__}() missing `time` data"
        )

    time_range = validate_time_range(time_range)

    times = to_timestamps(self.time)
    mask = np.logical_and(time_range[0] <= times, times <= time_range[1])
    mask = pad_true_sequence(mask, pad_idxs)

    sel_values = self.values[mask]
    sel_error: NDArray | None = None
    if isinstance(self.error, np.ndarray):
        sel_error = self.error[:, mask]
    sel_time = self.time[mask]

    if len(self.height.shape) == 2:
        sel_height = self.height[mask]
    else:
        sel_height = self.height

    if isinstance(self.latitude, np.ndarray):
        sel_latitude = self.latitude[mask]
    else:
        sel_latitude = None

    if isinstance(self.longitude, np.ndarray):
        sel_longitude = self.longitude[mask]
    else:
        sel_longitude = None

    return ProfileData(
        values=sel_values,
        height=sel_height,
        time=sel_time,
        latitude=sel_latitude,
        longitude=sel_longitude,
        color=self.color,
        label=self.label,
        units=self.units,
        platform=self.platform,
        error=sel_error,
    )

std

std(**kwargs)

Returns standard deviation profile.

Source code in earthcarekit/utils/profile_data/profile_data.py
def std(self, **kwargs) -> "ProfileData":
    """Returns standard deviation profile."""
    if "axis" in kwargs:
        return np.std(self.values, **kwargs)
    elif len(kwargs) > 0:
        raise TypeError(
            f"{self.std.__name__}() got an unexpected keyword argument '{list(kwargs.keys())[0]}'"
        )

    new_values = _std_2d(self.values)
    new_height = _mean_2d(self.height)
    new_error: NDArray | None = None
    if isinstance(self.error, np.ndarray):
        new_error = _mean_2d(self.error)

    if isinstance(self.time, np.ndarray):
        new_time = _mean_1d(self.time)
    else:
        new_time = None

    if isinstance(self.latitude, np.ndarray):
        new_latitude = _mean_1d(self.latitude)
    else:
        new_latitude = None

    if isinstance(self.longitude, np.ndarray):
        new_longitude = _mean_1d(self.longitude)
    else:
        new_longitude = None

    new_color = self.color
    new_label = self.label
    new_units = self.units
    new_platform = self.platform

    return ProfileData(
        values=new_values,
        height=new_height,
        time=new_time,
        latitude=new_latitude,
        longitude=new_longitude,
        color=new_color,
        label=new_label,
        units=new_units,
        platform=new_platform,
        error=new_error,
    )

to_dict

to_dict()

Returns stored profile data as dict.

Source code in earthcarekit/utils/profile_data/profile_data.py
def to_dict(self) -> dict:
    """Returns stored profile data as `dict`."""
    return asdict(self)

ProfileFigure

Source code in earthcarekit/plot/figure/profile.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
class ProfileFigure:
    def __init__(
        self,
        ax: Axes | None = None,
        figsize: tuple[float, float] = (3, 4),
        dpi: int | None = None,
        title: str | None = None,
        height_axis: Literal["x", "y"] = "y",
        show_grid: bool = True,
        flip_height_axis: bool = False,
        show_legend: bool = False,
        show_height_ticks: bool = True,
        show_height_label: bool = True,
        height_range: DistanceRangeLike | None = None,
        value_range: ValueRangeLike | None = (0, None),
        label: str = "",
        units: str = "",
    ):
        self.fig: Figure
        if isinstance(ax, Axes):
            tmp = ax.get_figure()
            if not isinstance(tmp, (Figure, SubFigure)):
                raise ValueError(f"Invalid Figure")
            self.fig = tmp  # type: ignore
            self.ax = ax
        else:
            # self.fig: Figure = plt.figure(figsize=figsize, dpi=dpi)  # type: ignore
            # self.ax = self.fig.add_subplot()
            self.fig = plt.figure(figsize=figsize, dpi=dpi)
            self.ax = self.fig.add_axes((0.0, 0.0, 1.0, 1.0))
        self.title = title
        if isinstance(self.title, str):
            add_title(self.ax, title=self.title)
            # self.fig.suptitle(self.title)

        self.selection_time_range: tuple[pd.Timestamp, pd.Timestamp] | None = None
        self.info_text: AnchoredText | None = None

        self.ax_fill_between = (
            self.ax.fill_betweenx if height_axis == "y" else self.ax.fill_between
        )
        self.ax_set_hlim = self.ax.set_ylim if height_axis == "y" else self.ax.set_xlim
        self.ax_set_vlim = self.ax.set_ylim if height_axis == "x" else self.ax.set_xlim

        self.hmin: Number | None = 0
        self.hmax: Number | None = 40e3
        if isinstance(height_range, (Sequence, np.ndarray)):
            self.hmin = height_range[0]
            self.hmax = height_range[1]

        self.vmin: Number | None = None
        self.vmax: Number | None = None
        if isinstance(value_range, (Sequence, np.ndarray)):
            self.vmin = value_range[0]
            self.vmax = value_range[1]

        self.height_axis: Literal["x", "y"] = height_axis
        self.flip_height_axis = flip_height_axis
        self.value_axis: Literal["x", "y"] = "x" if height_axis == "y" else "y"

        self.show_grid: bool = show_grid

        self.label: str | None = label
        self.units: str | None = units

        self.ax_right: Axes | None = None
        self.ax_top: Axes | None = None

        self.show_legend: bool = show_legend
        self.legend_handles: list = []
        self.legend_labels: list[str] = []
        self.legend: Legend | None = None

        self.show_height_ticks: bool = show_height_ticks
        self.show_height_label: bool = show_height_label

        self._init_axes()

    def _init_axes(self) -> None:
        self.ax.grid(self.show_grid)

        _hmin: float | None = None if self.hmin is None else float(self.hmin)
        _hmax: float | None = None if self.hmax is None else float(self.hmax)
        _vmin: float | None = None if self.vmin is None else float(self.vmin)
        _vmax: float | None = None if self.vmax is None else float(self.vmax)
        self.ax_set_hlim(_hmin, _hmax)
        if _vmin is not None or _vmax is not None:
            if _vmin is not None and np.isnan(_vmin):
                _vmin = None
            if _vmax is not None and np.isnan(_vmax):
                _vmax = None
            self.ax_set_vlim(_vmin, _vmax)

        is_init = not isinstance(self.ax_right, Axes)

        if isinstance(self.ax_right, Axes):
            self.ax_right.remove()
        self.ax_right = self.ax.twinx()
        self.ax_right.set_ylim(self.ax.get_ylim())
        self.ax_right.set_yticklabels([])

        if isinstance(self.ax_top, Axes):
            self.ax_top.remove()
        self.ax_top = self.ax.twiny()
        self.ax_top.set_xlim(self.ax.get_xlim())
        format_numeric_ticks(
            self.ax_top,
            axis=self.value_axis,
            label=format_var_label(self.label, self.units),
            show_label=False,
        )
        self.ax_top.set_xticklabels([])

        if self.flip_height_axis:
            format_height_ticks(
                self.ax_right,
                axis=self.height_axis,
                show_tick_labels=self.show_height_ticks,
                label="Height" if self.show_height_label else None,
            )
            self.ax.set_yticklabels([])
        else:
            format_height_ticks(
                self.ax,
                axis=self.height_axis,
                show_tick_labels=self.show_height_ticks,
                label="Height" if self.show_height_label else None,
            )
        format_numeric_ticks(
            self.ax,
            axis=self.value_axis,
            label=format_var_label(self.label, self.units),
        )

        if self.show_legend and len(self.legend_handles) > 0:
            self.legend = self.ax.legend(
                handles=self.legend_handles,
                labels=self.legend_labels,
                fontsize="small",
                #   bbox_to_anchor=(1, 1),
                #   loc=2,
                bbox_to_anchor=(0, 1.015),
                loc="lower left",
                borderaxespad=0.25,
                edgecolor="white",
            )
        elif isinstance(self.legend, Legend):
            self.legend.remove()

    def plot(
        self,
        profiles: ProfileData | None = None,
        *,
        values: NDArray | None = None,
        time: NDArray | None = None,
        height: NDArray | None = None,
        latitude: NDArray | None = None,
        longitude: NDArray | None = None,
        error: NDArray | None = None,
        # Common args for wrappers
        label: str | None = None,
        units: str | None = None,
        value_range: ValueRangeLike | None = (0, None),
        height_range: DistanceRangeLike | None = None,
        time_range: TimeRangeLike | None = None,
        selection_height_range: DistanceRangeLike | None = None,
        show_mean: bool = True,
        show_std: bool = True,
        show_min: bool = False,
        show_max: bool = False,
        show_sem: bool = False,
        show_error: bool = False,
        color: str | ColorLike | None = None,
        alpha: float = 1.0,
        linestyle: str = "solid",
        linewidth: Number = 1.5,
        ribbon_alpha: float = 0.2,
        show_grid: bool | None = None,
        zorder: Number | None = 1,
        legend_label: str | None = None,
        show_legend: bool | None = None,
        show_steps: bool = DEFAULT_PROFILE_SHOW_STEPS,
    ) -> "ProfileFigure":
        """TODO: documentation

        Args:
            profiles (ProfileData | None, optional): _description_. Defaults to None.
            values (NDArray | None, optional): _description_. Defaults to None.
            time (NDArray | None, optional): _description_. Defaults to None.
            height (NDArray | None, optional): _description_. Defaults to None.
            latitude (NDArray | None, optional): _description_. Defaults to None.
            longitude (NDArray | None, optional): _description_. Defaults to None.
            error (NDArray | None, optional): _description_. Defaults to None.
            label (str | None, optional): _description_. Defaults to None.
            units (str | None, optional): _description_. Defaults to None.
            value_range (ValueRangeLike | None, optional): _description_. Defaults to (0, None).
            height_range (DistanceRangeLike | None, optional): _description_. Defaults to None.
            time_range (TimeRangeLike | None, optional): _description_. Defaults to None.
            selection_height_range (DistanceRangeLike | None, optional): _description_. Defaults to None.
            show_mean (bool, optional): _description_. Defaults to True.
            show_std (bool, optional): _description_. Defaults to True.
            show_min (bool, optional): _description_. Defaults to False.
            show_max (bool, optional): _description_. Defaults to False.
            show_sem (bool, optional): _description_. Defaults to False.
            show_error (bool, optional): _description_. Defaults to False.
            color (str | ColorLike | None, optional): _description_. Defaults to None.
            alpha (float, optional): _description_. Defaults to 1.0.
            linestyle (str, optional): _description_. Defaults to "solid".
            linewidth (Number, optional): _description_. Defaults to 1.5.
            ribbon_alpha (float, optional): _description_. Defaults to 0.2.
            show_grid (bool | None, optional): _description_. Defaults to None.
            zorder (Number | None, optional): _description_. Defaults to 1.
            legend_label (str | None, optional): _description_. Defaults to None.
            show_legend (bool | None, optional): _description_. Defaults to None.
            show_steps (bool, optional): _description_. Defaults to DEFAULT_PROFILE_SHOW_STEPS.

        Raises:
            ValueError: _description_
            ValueError: _description_

        Returns:
            ProfileFigure: _description_
        """
        color = Color.from_optional(color)

        if isinstance(show_legend, bool):
            self.show_legend = show_legend

        if isinstance(show_grid, bool):
            self.show_grid = show_grid
            self.ax.grid(self.show_grid)

        if isinstance(value_range, Iterable):
            if len(value_range) != 2:
                raise ValueError(
                    f"invalid `value_range`: {value_range}, expecting (vmin, vmax)"
                )
            else:
                if value_range[0] is not None:
                    self.vmin = value_range[0]
                if value_range[1] is not None:
                    self.vmax = value_range[1]
        else:
            value_range = (None, None)
        logger.debug(f"{value_range=}")

        if isinstance(profiles, ProfileData):
            values = profiles.values
            time = profiles.time
            height = profiles.height
            latitude = profiles.latitude
            longitude = profiles.longitude
            if not isinstance(label, str):
                label = profiles.label
            if not isinstance(units, str):
                units = profiles.units
            error = profiles.error
        elif values is None or height is None:
            raise ValueError(
                "Missing required arguments. Provide either a `VerticalProfiles` or all of `values` and `height`"
            )

        values = np.asarray(np.atleast_2d(values))
        if time is None:
            time = np.array([pd.Timestamp.now()] * values.shape[0])
        time = np.asarray(np.atleast_1d(time))
        height = np.asarray(height)
        is_single_profile_and_multiple_height_profiles = values.shape[0] == 1 and (
            len(height.shape) > 1 and height.shape[0] > 1
        )
        if is_single_profile_and_multiple_height_profiles:
            values = np.repeat(values, height.shape[0], axis=0)
        if latitude is not None:
            latitude = np.asarray(latitude)
        if longitude is not None:
            longitude = np.asarray(longitude)

        vp = ProfileData(
            values=values,
            time=time,
            height=height,
            latitude=latitude,
            longitude=longitude,
            label=label,
            units=units,
            error=error,
        )
        if is_single_profile_and_multiple_height_profiles:
            vp = vp.mean()

        vp.select_time_range(time_range)

        if isinstance(vp.label, str):
            self.label = vp.label
        if isinstance(vp.units, str):
            self.units = vp.units

        if height_range is not None:
            if isinstance(height_range, Iterable) and len(height_range) == 2:
                for i in [0, -1]:
                    height_range = list(height_range)
                    if height_range[i] is None:
                        height_range[i] = np.atleast_2d(vp.height)[0, i]
                    elif i == 0:
                        self.hmin = height_range[0]
                    elif i == -1:
                        self.hmax = height_range[-1]
                    height_range = tuple(height_range)
        else:
            height_range = (
                np.atleast_2d(vp.height)[0, 0],
                np.atleast_2d(vp.height)[0, -1],
            )

        if len(vp.height.shape) == 2 and vp.height.shape[0] == 1:
            h = vp.height[0]
        elif len(vp.height.shape) == 2:
            h = nan_mean(vp.height, axis=0)
        else:
            h = vp.height

        handle_mean: list[Line2D] | list[None] = [None]
        handle_min: list[Line2D] | list[None] = [None]
        handle_max: list[Line2D] | list[None] = [None]
        handle_std: PolyCollection | None = None
        handle_sem: PolyCollection | None = None

        if show_mean:
            if vp.values.shape[0] == 1:
                vmean = vp.values[0]
                show_std = False
                show_sem = False
                show_min = False
                show_max = False
            else:
                vmean = nan_mean(vp.values, axis=0)
            vnew, hnew = vmean, h
            if show_steps:
                vnew, hnew = _convert_vertical_profile_to_step_function(vmean, h)
            xy = (vnew, hnew) if self.height_axis == "y" else (hnew, vnew)
            handle_mean = self.ax.plot(
                *xy,
                color=color,
                alpha=alpha,
                zorder=zorder,
                linestyle=linestyle,
                linewidth=linewidth,
            )
            color = handle_mean[0].get_color()  # type: ignore

            value_range = select_value_range(vmean, value_range, pad_frac=0.01)
            if not (self.vmin is not None and self.vmin < value_range[0]):
                self.vmin = value_range[0]
            if not (self.vmax is not None and self.vmax > value_range[1]):
                self.vmax = value_range[1]

            if show_error and vp.error is not None:
                verror = vp.error.flatten()
                if show_steps:
                    verror, _ = _convert_vertical_profile_to_step_function(verror, h)
                handle_std = self.ax_fill_between(
                    hnew,
                    vnew - verror,
                    vnew + verror,
                    alpha=ribbon_alpha,
                    color=color,
                    linewidth=0,
                )

        if show_sem:
            vsem = nan_sem(vp.values, axis=0)
            if show_steps:
                vsem, _ = _convert_vertical_profile_to_step_function(vsem, h)
            handle_sem = self.ax_fill_between(
                hnew,
                vnew - vsem,
                vnew + vsem,
                alpha=ribbon_alpha,
                color=color,
                linewidth=0,
            )
        elif show_std:
            vstd = nan_std(vp.values, axis=0)
            if show_steps:
                vstd, _ = _convert_vertical_profile_to_step_function(vstd, h)
            handle_std = self.ax_fill_between(
                hnew,
                vnew - vstd,
                vnew + vstd,
                alpha=ribbon_alpha,
                color=color,
                linewidth=0,
            )

        if show_min:
            vmin = nan_min(vp.values, axis=0)
            vnew, hnew = vmin, h
            if show_steps:
                vnew, hnew = _convert_vertical_profile_to_step_function(vmin, h)
            xy = (vnew, hnew) if self.height_axis == "y" else (hnew, vnew)
            handle_min = self.ax.plot(
                *xy,
                color=color,
                alpha=alpha,
                zorder=zorder,
                linestyle="dashed",
                linewidth=linewidth,
            )
            color = handle_min[0].get_color()  # type: ignore

        if show_max:
            vmax = nan_max(vp.values, axis=0)
            vnew, hnew = vmax, h
            if show_steps:
                vnew, hnew = _convert_vertical_profile_to_step_function(vmax, h)
            xy = (vnew, hnew) if self.height_axis == "y" else (hnew, vnew)
            handle_max = self.ax.plot(
                *xy,
                color=color,
                alpha=alpha,
                zorder=zorder,
                linestyle="dashed",
                linewidth=linewidth,
            )
            color = handle_max[0].get_color()  # type: ignore

        # Legend labels
        if isinstance(legend_label, str):
            handle_std

            _handle: tuple | list = [
                *handle_mean,
                handle_std,
                handle_sem,
                *handle_min,
                *handle_max,
            ]
            _default_h = next(_h for _h in _handle if _h is not None)
            _handle = tuple([_h if _h is not None else _default_h for _h in _handle])
            self.legend_handles.append(_handle)
            self.legend_labels.append(legend_label)

        if selection_height_range:
            _shr: tuple[float, float] = validate_numeric_range(selection_height_range)
            _highlight_height_range(
                ax=self.ax,
                height_range=_shr,
            )

        self._init_axes()

        # format_height_ticks(self.ax, axis=self.height_axis)
        # format_numeric_ticks(self.ax, axis=self.value_axis, label=self.label)

        return self

    def ecplot(
        self,
        ds: xr.Dataset,
        var: str,
        *,
        time_var: str = TIME_VAR,
        height_var: str = HEIGHT_VAR,
        lat_var: str = TRACK_LAT_VAR,
        lon_var: str = TRACK_LON_VAR,
        error_var: str | None = None,
        along_track_dim: str = ALONG_TRACK_DIM,
        values: NDArray | None = None,
        time: NDArray | None = None,
        height: NDArray | None = None,
        latitude: NDArray | None = None,
        longitude: NDArray | None = None,
        error: NDArray | None = None,
        site: str | GroundSite | None = None,
        radius_km: float = 100.0,
        # Common args for wrappers
        value_range: ValueRangeLike | None = None,
        height_range: DistanceRangeLike | None = (0, 40e3),
        time_range: TimeRangeLike | None = None,
        selection_height_range: DistanceRangeLike | None = None,
        label: str | None = None,
        units: str | None = None,
        zorder: Number | None = 1,
        legend_label: str | None = "EarthCARE",
        show_legend: bool | None = None,
        show_steps: bool = DEFAULT_PROFILE_SHOW_STEPS,
        show_error: bool = False,
        **kwargs,
    ) -> "ProfileFigure":
        # Collect all common args for wrapped plot function call
        local_args = locals()
        # Delete all args specific to this wrapper function
        del local_args["self"]
        del local_args["ds"]
        del local_args["var"]
        del local_args["time_var"]
        del local_args["height_var"]
        del local_args["lat_var"]
        del local_args["lon_var"]
        del local_args["error_var"]
        del local_args["along_track_dim"]
        del local_args["site"]
        del local_args["radius_km"]
        # Delete kwargs to then merge it with the residual common args
        del local_args["kwargs"]
        all_args = {**local_args, **kwargs}

        if all_args["values"] is None:
            all_args["values"] = ds[var].values
        if all_args["time"] is None:
            all_args["time"] = ds[time_var].values
        if all_args["height"] is None:
            all_args["height"] = ds[height_var].values
        if all_args["latitude"] is None:
            all_args["latitude"] = ds[lat_var].values
        if all_args["longitude"] is None:
            all_args["longitude"] = ds[lon_var].values
        if all_args["error"] is None and isinstance(error_var, str):
            all_args["error"] = ds[error_var].values
            all_args["show_error"] = True

        # Set default values depending on variable name
        if label is None:
            all_args["label"] = (
                "Values" if not hasattr(ds[var], "long_name") else ds[var].long_name
            )
        if units is None:
            all_args["units"] = "-" if not hasattr(ds[var], "units") else ds[var].units
        if value_range is None:
            all_args["value_range"] = get_default_profile_range(var)

        self.plot(**all_args)

        return self

    def invert_xaxis(self) -> "ProfileFigure":
        """Invert the x-axis."""
        self.ax.invert_xaxis()
        if self.ax_top:
            self.ax_top.invert_xaxis()
        return self

    def invert_yaxis(self) -> "ProfileFigure":
        """Invert the y-axis."""
        self.ax.invert_yaxis()
        if self.ax_right:
            self.ax_right.invert_yaxis()
        return self

    def show(self) -> None:
        import IPython
        import matplotlib.pyplot as plt
        from IPython.display import display

        if IPython.get_ipython() is not None:
            display(self.fig)
        else:
            plt.show()

    def save(
        self,
        filename: str = "",
        filepath: str | None = None,
        ds: xr.Dataset | None = None,
        ds_filepath: str | None = None,
        dpi: float | Literal["figure"] = "figure",
        orbit_and_frame: str | None = None,
        utc_timestamp: TimestampLike | None = None,
        use_utc_creation_timestamp: bool = False,
        site_name: str | None = None,
        hmax: int | float | None = None,
        radius: int | float | None = None,
        extra: str | None = None,
        transparent_outside: bool = False,
        verbose: bool = True,
        print_prefix: str = "",
        create_dirs: bool = False,
        transparent_background: bool = False,
        resolution: str | None = None,
        **kwargs,
    ) -> None:
        """
        Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

        Args:
            figure (Figure | HasFigure): A figure object (`matplotlib.figure.Figure`) or objects exposing a `.fig` attribute containing a figure (e.g., `CurtainFigure`).
            filename (str, optional): The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.
            filepath (str | None, optional): The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.
            ds (xr.Dataset | None, optional): A EarthCARE dataset from which metadata will be taken. Defaults to None.
            ds_filepath (str | None, optional): A path to a EarthCARE product from which metadata will be taken. Defaults to None.
            pad (float, optional): Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.
            dpi (float | 'figure', optional): The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.
            orbit_and_frame (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            utc_timestamp (TimestampLike | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            use_utc_creation_timestamp (bool, optional): Whether the time of image creation should be included in the file name. Defaults to False.
            site_name (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            hmax (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            radius (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            resolution (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            extra (str | None, optional): A custom string to be included in the file name. Defaults to None.
            transparent_outside (bool, optional): Whether the area outside figures should be transparent. Defaults to False.
            verbose (bool, optional): Whether the progress of image creation should be printed to the console. Defaults to True.
            print_prefix (str, optional): A prefix string to all console messages. Defaults to "".
            create_dirs (bool, optional): Whether images should be saved in a folder structure based on provided metadata. Defaults to False.
            transparent_background (bool, optional): Whether the background inside and outside of figures should be transparent. Defaults to False.
            **kwargs (dict[str, Any]): Keyword arguments passed to wrapped function call of `matplotlib.pyplot.savefig`.
        """
        save_plot(
            fig=self.fig,
            filename=filename,
            filepath=filepath,
            ds=ds,
            ds_filepath=ds_filepath,
            dpi=dpi,
            orbit_and_frame=orbit_and_frame,
            utc_timestamp=utc_timestamp,
            use_utc_creation_timestamp=use_utc_creation_timestamp,
            site_name=site_name,
            hmax=hmax,
            radius=radius,
            extra=extra,
            transparent_outside=transparent_outside,
            verbose=verbose,
            print_prefix=print_prefix,
            create_dirs=create_dirs,
            transparent_background=transparent_background,
            resolution=resolution,
            **kwargs,
        )

invert_xaxis

invert_xaxis()

Invert the x-axis.

Source code in earthcarekit/plot/figure/profile.py
def invert_xaxis(self) -> "ProfileFigure":
    """Invert the x-axis."""
    self.ax.invert_xaxis()
    if self.ax_top:
        self.ax_top.invert_xaxis()
    return self

invert_yaxis

invert_yaxis()

Invert the y-axis.

Source code in earthcarekit/plot/figure/profile.py
def invert_yaxis(self) -> "ProfileFigure":
    """Invert the y-axis."""
    self.ax.invert_yaxis()
    if self.ax_right:
        self.ax_right.invert_yaxis()
    return self

plot

plot(
    profiles=None,
    *,
    values=None,
    time=None,
    height=None,
    latitude=None,
    longitude=None,
    error=None,
    label=None,
    units=None,
    value_range=(0, None),
    height_range=None,
    time_range=None,
    selection_height_range=None,
    show_mean=True,
    show_std=True,
    show_min=False,
    show_max=False,
    show_sem=False,
    show_error=False,
    color=None,
    alpha=1.0,
    linestyle="solid",
    linewidth=1.5,
    ribbon_alpha=0.2,
    show_grid=None,
    zorder=1,
    legend_label=None,
    show_legend=None,
    show_steps=DEFAULT_PROFILE_SHOW_STEPS
)

TODO: documentation

Parameters:

Name Type Description Default
profiles ProfileData | None

description. Defaults to None.

None
values NDArray | None

description. Defaults to None.

None
time NDArray | None

description. Defaults to None.

None
height NDArray | None

description. Defaults to None.

None
latitude NDArray | None

description. Defaults to None.

None
longitude NDArray | None

description. Defaults to None.

None
error NDArray | None

description. Defaults to None.

None
label str | None

description. Defaults to None.

None
units str | None

description. Defaults to None.

None
value_range ValueRangeLike | None

description. Defaults to (0, None).

(0, None)
height_range DistanceRangeLike | None

description. Defaults to None.

None
time_range TimeRangeLike | None

description. Defaults to None.

None
selection_height_range DistanceRangeLike | None

description. Defaults to None.

None
show_mean bool

description. Defaults to True.

True
show_std bool

description. Defaults to True.

True
show_min bool

description. Defaults to False.

False
show_max bool

description. Defaults to False.

False
show_sem bool

description. Defaults to False.

False
show_error bool

description. Defaults to False.

False
color str | ColorLike | None

description. Defaults to None.

None
alpha float

description. Defaults to 1.0.

1.0
linestyle str

description. Defaults to "solid".

'solid'
linewidth Number

description. Defaults to 1.5.

1.5
ribbon_alpha float

description. Defaults to 0.2.

0.2
show_grid bool | None

description. Defaults to None.

None
zorder Number | None

description. Defaults to 1.

1
legend_label str | None

description. Defaults to None.

None
show_legend bool | None

description. Defaults to None.

None
show_steps bool

description. Defaults to DEFAULT_PROFILE_SHOW_STEPS.

DEFAULT_PROFILE_SHOW_STEPS

Raises:

Type Description
ValueError

description

ValueError

description

Returns:

Name Type Description
ProfileFigure ProfileFigure

description

Source code in earthcarekit/plot/figure/profile.py
def plot(
    self,
    profiles: ProfileData | None = None,
    *,
    values: NDArray | None = None,
    time: NDArray | None = None,
    height: NDArray | None = None,
    latitude: NDArray | None = None,
    longitude: NDArray | None = None,
    error: NDArray | None = None,
    # Common args for wrappers
    label: str | None = None,
    units: str | None = None,
    value_range: ValueRangeLike | None = (0, None),
    height_range: DistanceRangeLike | None = None,
    time_range: TimeRangeLike | None = None,
    selection_height_range: DistanceRangeLike | None = None,
    show_mean: bool = True,
    show_std: bool = True,
    show_min: bool = False,
    show_max: bool = False,
    show_sem: bool = False,
    show_error: bool = False,
    color: str | ColorLike | None = None,
    alpha: float = 1.0,
    linestyle: str = "solid",
    linewidth: Number = 1.5,
    ribbon_alpha: float = 0.2,
    show_grid: bool | None = None,
    zorder: Number | None = 1,
    legend_label: str | None = None,
    show_legend: bool | None = None,
    show_steps: bool = DEFAULT_PROFILE_SHOW_STEPS,
) -> "ProfileFigure":
    """TODO: documentation

    Args:
        profiles (ProfileData | None, optional): _description_. Defaults to None.
        values (NDArray | None, optional): _description_. Defaults to None.
        time (NDArray | None, optional): _description_. Defaults to None.
        height (NDArray | None, optional): _description_. Defaults to None.
        latitude (NDArray | None, optional): _description_. Defaults to None.
        longitude (NDArray | None, optional): _description_. Defaults to None.
        error (NDArray | None, optional): _description_. Defaults to None.
        label (str | None, optional): _description_. Defaults to None.
        units (str | None, optional): _description_. Defaults to None.
        value_range (ValueRangeLike | None, optional): _description_. Defaults to (0, None).
        height_range (DistanceRangeLike | None, optional): _description_. Defaults to None.
        time_range (TimeRangeLike | None, optional): _description_. Defaults to None.
        selection_height_range (DistanceRangeLike | None, optional): _description_. Defaults to None.
        show_mean (bool, optional): _description_. Defaults to True.
        show_std (bool, optional): _description_. Defaults to True.
        show_min (bool, optional): _description_. Defaults to False.
        show_max (bool, optional): _description_. Defaults to False.
        show_sem (bool, optional): _description_. Defaults to False.
        show_error (bool, optional): _description_. Defaults to False.
        color (str | ColorLike | None, optional): _description_. Defaults to None.
        alpha (float, optional): _description_. Defaults to 1.0.
        linestyle (str, optional): _description_. Defaults to "solid".
        linewidth (Number, optional): _description_. Defaults to 1.5.
        ribbon_alpha (float, optional): _description_. Defaults to 0.2.
        show_grid (bool | None, optional): _description_. Defaults to None.
        zorder (Number | None, optional): _description_. Defaults to 1.
        legend_label (str | None, optional): _description_. Defaults to None.
        show_legend (bool | None, optional): _description_. Defaults to None.
        show_steps (bool, optional): _description_. Defaults to DEFAULT_PROFILE_SHOW_STEPS.

    Raises:
        ValueError: _description_
        ValueError: _description_

    Returns:
        ProfileFigure: _description_
    """
    color = Color.from_optional(color)

    if isinstance(show_legend, bool):
        self.show_legend = show_legend

    if isinstance(show_grid, bool):
        self.show_grid = show_grid
        self.ax.grid(self.show_grid)

    if isinstance(value_range, Iterable):
        if len(value_range) != 2:
            raise ValueError(
                f"invalid `value_range`: {value_range}, expecting (vmin, vmax)"
            )
        else:
            if value_range[0] is not None:
                self.vmin = value_range[0]
            if value_range[1] is not None:
                self.vmax = value_range[1]
    else:
        value_range = (None, None)
    logger.debug(f"{value_range=}")

    if isinstance(profiles, ProfileData):
        values = profiles.values
        time = profiles.time
        height = profiles.height
        latitude = profiles.latitude
        longitude = profiles.longitude
        if not isinstance(label, str):
            label = profiles.label
        if not isinstance(units, str):
            units = profiles.units
        error = profiles.error
    elif values is None or height is None:
        raise ValueError(
            "Missing required arguments. Provide either a `VerticalProfiles` or all of `values` and `height`"
        )

    values = np.asarray(np.atleast_2d(values))
    if time is None:
        time = np.array([pd.Timestamp.now()] * values.shape[0])
    time = np.asarray(np.atleast_1d(time))
    height = np.asarray(height)
    is_single_profile_and_multiple_height_profiles = values.shape[0] == 1 and (
        len(height.shape) > 1 and height.shape[0] > 1
    )
    if is_single_profile_and_multiple_height_profiles:
        values = np.repeat(values, height.shape[0], axis=0)
    if latitude is not None:
        latitude = np.asarray(latitude)
    if longitude is not None:
        longitude = np.asarray(longitude)

    vp = ProfileData(
        values=values,
        time=time,
        height=height,
        latitude=latitude,
        longitude=longitude,
        label=label,
        units=units,
        error=error,
    )
    if is_single_profile_and_multiple_height_profiles:
        vp = vp.mean()

    vp.select_time_range(time_range)

    if isinstance(vp.label, str):
        self.label = vp.label
    if isinstance(vp.units, str):
        self.units = vp.units

    if height_range is not None:
        if isinstance(height_range, Iterable) and len(height_range) == 2:
            for i in [0, -1]:
                height_range = list(height_range)
                if height_range[i] is None:
                    height_range[i] = np.atleast_2d(vp.height)[0, i]
                elif i == 0:
                    self.hmin = height_range[0]
                elif i == -1:
                    self.hmax = height_range[-1]
                height_range = tuple(height_range)
    else:
        height_range = (
            np.atleast_2d(vp.height)[0, 0],
            np.atleast_2d(vp.height)[0, -1],
        )

    if len(vp.height.shape) == 2 and vp.height.shape[0] == 1:
        h = vp.height[0]
    elif len(vp.height.shape) == 2:
        h = nan_mean(vp.height, axis=0)
    else:
        h = vp.height

    handle_mean: list[Line2D] | list[None] = [None]
    handle_min: list[Line2D] | list[None] = [None]
    handle_max: list[Line2D] | list[None] = [None]
    handle_std: PolyCollection | None = None
    handle_sem: PolyCollection | None = None

    if show_mean:
        if vp.values.shape[0] == 1:
            vmean = vp.values[0]
            show_std = False
            show_sem = False
            show_min = False
            show_max = False
        else:
            vmean = nan_mean(vp.values, axis=0)
        vnew, hnew = vmean, h
        if show_steps:
            vnew, hnew = _convert_vertical_profile_to_step_function(vmean, h)
        xy = (vnew, hnew) if self.height_axis == "y" else (hnew, vnew)
        handle_mean = self.ax.plot(
            *xy,
            color=color,
            alpha=alpha,
            zorder=zorder,
            linestyle=linestyle,
            linewidth=linewidth,
        )
        color = handle_mean[0].get_color()  # type: ignore

        value_range = select_value_range(vmean, value_range, pad_frac=0.01)
        if not (self.vmin is not None and self.vmin < value_range[0]):
            self.vmin = value_range[0]
        if not (self.vmax is not None and self.vmax > value_range[1]):
            self.vmax = value_range[1]

        if show_error and vp.error is not None:
            verror = vp.error.flatten()
            if show_steps:
                verror, _ = _convert_vertical_profile_to_step_function(verror, h)
            handle_std = self.ax_fill_between(
                hnew,
                vnew - verror,
                vnew + verror,
                alpha=ribbon_alpha,
                color=color,
                linewidth=0,
            )

    if show_sem:
        vsem = nan_sem(vp.values, axis=0)
        if show_steps:
            vsem, _ = _convert_vertical_profile_to_step_function(vsem, h)
        handle_sem = self.ax_fill_between(
            hnew,
            vnew - vsem,
            vnew + vsem,
            alpha=ribbon_alpha,
            color=color,
            linewidth=0,
        )
    elif show_std:
        vstd = nan_std(vp.values, axis=0)
        if show_steps:
            vstd, _ = _convert_vertical_profile_to_step_function(vstd, h)
        handle_std = self.ax_fill_between(
            hnew,
            vnew - vstd,
            vnew + vstd,
            alpha=ribbon_alpha,
            color=color,
            linewidth=0,
        )

    if show_min:
        vmin = nan_min(vp.values, axis=0)
        vnew, hnew = vmin, h
        if show_steps:
            vnew, hnew = _convert_vertical_profile_to_step_function(vmin, h)
        xy = (vnew, hnew) if self.height_axis == "y" else (hnew, vnew)
        handle_min = self.ax.plot(
            *xy,
            color=color,
            alpha=alpha,
            zorder=zorder,
            linestyle="dashed",
            linewidth=linewidth,
        )
        color = handle_min[0].get_color()  # type: ignore

    if show_max:
        vmax = nan_max(vp.values, axis=0)
        vnew, hnew = vmax, h
        if show_steps:
            vnew, hnew = _convert_vertical_profile_to_step_function(vmax, h)
        xy = (vnew, hnew) if self.height_axis == "y" else (hnew, vnew)
        handle_max = self.ax.plot(
            *xy,
            color=color,
            alpha=alpha,
            zorder=zorder,
            linestyle="dashed",
            linewidth=linewidth,
        )
        color = handle_max[0].get_color()  # type: ignore

    # Legend labels
    if isinstance(legend_label, str):
        handle_std

        _handle: tuple | list = [
            *handle_mean,
            handle_std,
            handle_sem,
            *handle_min,
            *handle_max,
        ]
        _default_h = next(_h for _h in _handle if _h is not None)
        _handle = tuple([_h if _h is not None else _default_h for _h in _handle])
        self.legend_handles.append(_handle)
        self.legend_labels.append(legend_label)

    if selection_height_range:
        _shr: tuple[float, float] = validate_numeric_range(selection_height_range)
        _highlight_height_range(
            ax=self.ax,
            height_range=_shr,
        )

    self._init_axes()

    # format_height_ticks(self.ax, axis=self.height_axis)
    # format_numeric_ticks(self.ax, axis=self.value_axis, label=self.label)

    return self

save

save(
    filename="",
    filepath=None,
    ds=None,
    ds_filepath=None,
    dpi="figure",
    orbit_and_frame=None,
    utc_timestamp=None,
    use_utc_creation_timestamp=False,
    site_name=None,
    hmax=None,
    radius=None,
    extra=None,
    transparent_outside=False,
    verbose=True,
    print_prefix="",
    create_dirs=False,
    transparent_background=False,
    resolution=None,
    **kwargs
)

Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

Parameters:

Name Type Description Default
figure Figure | HasFigure

A figure object (matplotlib.figure.Figure) or objects exposing a .fig attribute containing a figure (e.g., CurtainFigure).

required
filename str

The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.

''
filepath str | None

The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.

None
ds Dataset | None

A EarthCARE dataset from which metadata will be taken. Defaults to None.

None
ds_filepath str | None

A path to a EarthCARE product from which metadata will be taken. Defaults to None.

None
pad float

Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.

required
dpi float | figure

The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.

'figure'
orbit_and_frame str | None

Metadata used in the formatting of the file name. Defaults to None.

None
utc_timestamp TimestampLike | None

Metadata used in the formatting of the file name. Defaults to None.

None
use_utc_creation_timestamp bool

Whether the time of image creation should be included in the file name. Defaults to False.

False
site_name str | None

Metadata used in the formatting of the file name. Defaults to None.

None
hmax int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
radius int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
resolution str | None

Metadata used in the formatting of the file name. Defaults to None.

None
extra str | None

A custom string to be included in the file name. Defaults to None.

None
transparent_outside bool

Whether the area outside figures should be transparent. Defaults to False.

False
verbose bool

Whether the progress of image creation should be printed to the console. Defaults to True.

True
print_prefix str

A prefix string to all console messages. Defaults to "".

''
create_dirs bool

Whether images should be saved in a folder structure based on provided metadata. Defaults to False.

False
transparent_background bool

Whether the background inside and outside of figures should be transparent. Defaults to False.

False
**kwargs dict[str, Any]

Keyword arguments passed to wrapped function call of matplotlib.pyplot.savefig.

{}
Source code in earthcarekit/plot/figure/profile.py
def save(
    self,
    filename: str = "",
    filepath: str | None = None,
    ds: xr.Dataset | None = None,
    ds_filepath: str | None = None,
    dpi: float | Literal["figure"] = "figure",
    orbit_and_frame: str | None = None,
    utc_timestamp: TimestampLike | None = None,
    use_utc_creation_timestamp: bool = False,
    site_name: str | None = None,
    hmax: int | float | None = None,
    radius: int | float | None = None,
    extra: str | None = None,
    transparent_outside: bool = False,
    verbose: bool = True,
    print_prefix: str = "",
    create_dirs: bool = False,
    transparent_background: bool = False,
    resolution: str | None = None,
    **kwargs,
) -> None:
    """
    Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

    Args:
        figure (Figure | HasFigure): A figure object (`matplotlib.figure.Figure`) or objects exposing a `.fig` attribute containing a figure (e.g., `CurtainFigure`).
        filename (str, optional): The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.
        filepath (str | None, optional): The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.
        ds (xr.Dataset | None, optional): A EarthCARE dataset from which metadata will be taken. Defaults to None.
        ds_filepath (str | None, optional): A path to a EarthCARE product from which metadata will be taken. Defaults to None.
        pad (float, optional): Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.
        dpi (float | 'figure', optional): The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.
        orbit_and_frame (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        utc_timestamp (TimestampLike | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        use_utc_creation_timestamp (bool, optional): Whether the time of image creation should be included in the file name. Defaults to False.
        site_name (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        hmax (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        radius (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        resolution (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        extra (str | None, optional): A custom string to be included in the file name. Defaults to None.
        transparent_outside (bool, optional): Whether the area outside figures should be transparent. Defaults to False.
        verbose (bool, optional): Whether the progress of image creation should be printed to the console. Defaults to True.
        print_prefix (str, optional): A prefix string to all console messages. Defaults to "".
        create_dirs (bool, optional): Whether images should be saved in a folder structure based on provided metadata. Defaults to False.
        transparent_background (bool, optional): Whether the background inside and outside of figures should be transparent. Defaults to False.
        **kwargs (dict[str, Any]): Keyword arguments passed to wrapped function call of `matplotlib.pyplot.savefig`.
    """
    save_plot(
        fig=self.fig,
        filename=filename,
        filepath=filepath,
        ds=ds,
        ds_filepath=ds_filepath,
        dpi=dpi,
        orbit_and_frame=orbit_and_frame,
        utc_timestamp=utc_timestamp,
        use_utc_creation_timestamp=use_utc_creation_timestamp,
        site_name=site_name,
        hmax=hmax,
        radius=radius,
        extra=extra,
        transparent_outside=transparent_outside,
        verbose=verbose,
        print_prefix=print_prefix,
        create_dirs=create_dirs,
        transparent_background=transparent_background,
        resolution=resolution,
        **kwargs,
    )

SwathFigure

TODO: documentation

Source code in earthcarekit/plot/figure/swath.py
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
class SwathFigure:
    """TODO: documentation"""

    def __init__(
        self,
        ax: Axes | None = None,
        figsize: tuple[float, float] = (FIGURE_WIDTH_SWATH, FIGURE_HEIGHT_SWATH),
        dpi: int | None = None,
        title: str | None = None,
        ax_style_top: AlongTrackAxisStyle | str = "geo",
        ax_style_bottom: AlongTrackAxisStyle | str = "time",
        num_ticks: int = 10,
        colorbar_tick_scale: float | None = None,
        ax_style_y: Literal[
            "from_track_distance",
            "across_track_distance",
            "pixel",
        ] = "from_track_distance",
        fig_height_scale: float = 1.0,
        fig_width_scale: float = 1.0,
    ):
        figsize = (figsize[0] * fig_width_scale, figsize[1] * fig_height_scale)
        self.fig: Figure
        if isinstance(ax, Axes):
            tmp = ax.get_figure()
            if not isinstance(tmp, (Figure, SubFigure)):
                raise ValueError(f"Invalid Figure")
            self.fig = tmp  # type: ignore
            self.ax = ax
        else:
            self.fig = plt.figure(figsize=figsize, dpi=dpi)
            self.ax = self.fig.add_axes((0.0, 0.0, 1.0, 1.0))

        self.title = title
        if self.title:
            self.fig.suptitle(self.title)

        self.ax_top: Axes | None = None
        self.ax_right: Axes | None = None
        self.colorbar: Colorbar | None = None
        self.colorbar_tick_scale: float | None = colorbar_tick_scale
        self.selection_time_range: tuple[pd.Timestamp, pd.Timestamp] | None = None
        self.ax_style_top: AlongTrackAxisStyle = AlongTrackAxisStyle.from_input(
            ax_style_top
        )
        self.ax_style_bottom: AlongTrackAxisStyle = AlongTrackAxisStyle.from_input(
            ax_style_bottom
        )
        self.ax_style_y: Literal[
            "from_track_distance",
            "across_track_distance",
            "pixel",
        ] = ax_style_y

        self.info_text: AnchoredText | None = None
        self.info_text_loc: str = "upper right"
        self.num_ticks = num_ticks

    def _set_info_text_loc(self, info_text_loc: str | None) -> None:
        if isinstance(info_text_loc, str):
            self.info_text_loc = info_text_loc

    def plot(
        self,
        swath: SwathData | None = None,
        *,
        values: NDArray | None = None,
        time: NDArray | None = None,
        nadir_index: int | None = None,
        latitude: NDArray | None = None,
        longitude: NDArray | None = None,
        # Common args for wrappers
        value_range: ValueRangeLike | None = None,
        log_scale: bool | None = None,
        norm: Normalize | None = None,
        time_range: TimeRangeLike | None = None,
        from_track_range: DistanceRangeLike | None = None,
        label: str | None = None,
        units: str | None = None,
        cmap: str | Colormap | None = None,
        colorbar: bool = True,
        colorbar_ticks: ArrayLike | None = None,
        colorbar_tick_labels: ArrayLike | None = None,
        colorbar_position: str | Literal["left", "right", "top", "bottom"] = "right",
        colorbar_alignment: str | Literal["left", "center", "right"] = "center",
        colorbar_width: float = DEFAULT_COLORBAR_WIDTH,
        colorbar_spacing: float = 0.2,
        colorbar_length_ratio: float | str = "100%",
        colorbar_label_outside: bool = True,
        colorbar_ticks_outside: bool = True,
        colorbar_ticks_both: bool = False,
        selection_time_range: TimeRangeLike | None = None,
        selection_color: str | None = Color("ec:earthcare"),
        selection_linestyle: str | None = "dashed",
        selection_linewidth: float | int | None = 2.5,
        selection_highlight: bool = False,
        selection_highlight_inverted: bool = True,
        selection_highlight_color: str = Color("white"),
        selection_highlight_alpha: float = 0.5,
        ax_style_top: AlongTrackAxisStyle | str | None = None,
        ax_style_bottom: AlongTrackAxisStyle | str | None = None,
        ax_style_y: (
            Literal["from_track_distance", "across_track_distance", "pixel"] | None
        ) = None,
        show_nadir: bool = True,
        nadir_color: ColorLike | None = "red",
        nadir_linewidth: int | float = 1.5,
        label_length: int = 25,
        **kwargs,
    ) -> "SwathFigure":
        if isinstance(value_range, Iterable):
            if len(value_range) != 2:
                raise ValueError(
                    f"invalid `value_range`: {value_range}, expecting (vmin, vmax)"
                )
        else:
            value_range = (None, None)

        cmap = get_cmap(cmap)

        if isinstance(cmap, Cmap) and cmap.categorical == True:
            norm = cmap.norm
        elif isinstance(norm, Normalize):
            if log_scale == True and not isinstance(norm, LogNorm):
                norm = LogNorm(norm.vmin, norm.vmax)
            elif log_scale == False and isinstance(norm, LogNorm):
                norm = Normalize(norm.vmin, norm.vmax)
            if value_range[0] is not None:
                norm.vmin = value_range[0]  # type: ignore
            if value_range[1] is not None:
                norm.vmax = value_range[1]  # type: ignore
        else:
            if log_scale == True:
                norm = LogNorm(value_range[0], value_range[1])  # type: ignore
            else:
                norm = Normalize(value_range[0], value_range[1])  # type: ignore

        assert isinstance(norm, Normalize)
        value_range = (norm.vmin, norm.vmax)

        if isinstance(swath, SwathData):
            values = swath.values
            time = swath.time
            nadir_index = swath.nadir_index
            latitude = swath.latitude
            longitude = swath.longitude
            label = swath.label
            units = swath.units
        elif (
            values is None
            or time is None
            or nadir_index is None
            or latitude is None
            or longitude is None
        ):
            raise ValueError(
                "Missing required arguments. Provide either a `SwathData` or all of `values`, `time`, `nadir_index`, `latitude` and `longitude`"
            )

        values = np.asarray(values)
        time = np.asarray(time)
        latitude = np.asarray(latitude)
        longitude = np.asarray(longitude)

        swath_data = SwathData(
            values=values,
            time=time,
            latitude=latitude,
            longitude=longitude,
            nadir_index=nadir_index,
            label=label,
            units=units,
        )

        tmin_original = swath_data.time[0]
        tmax_original = swath_data.time[-1]

        if from_track_range is not None:
            if isinstance(from_track_range, Iterable) and len(from_track_range) == 2:
                from_track_range = list(from_track_range)
                for i in [0, -1]:
                    if from_track_range[i] is None:
                        from_track_range[i] = swath_data.across_track_distance[i]
            swath_data = swath_data.select_from_track_range(from_track_range)
        else:
            from_track_range = (
                swath_data.across_track_distance[0],
                swath_data.across_track_distance[-1],
            )

        if time_range is not None:
            if isinstance(time_range, Iterable) and len(time_range) == 2:
                time_range = list(time_range)
                for i in [0, -1]:
                    if time_range[i] is None:
                        time_range[i] = to_timestamp(swath_data.time[i])
                    else:
                        time_range[i] = to_timestamp(time_range[i])
            swath_data = swath_data.select_time_range(time_range)
        else:
            time_range = (swath_data.time[0], swath_data.time[-1])

        values = swath_data.values
        time = swath_data.time
        latitude = swath_data.latitude
        longitude = swath_data.longitude
        across_track_distance = swath_data.across_track_distance
        from_track_distance = swath_data.from_track_distance
        label = swath_data.label
        units = swath_data.units
        nadir_index = swath_data.nadir_index

        self.ax_style_y = ax_style_y or self.ax_style_y
        if self.ax_style_y == "from_track_distance":
            ydata = from_track_distance
            ylabel = "Distance from track"
        elif self.ax_style_y == "across_track_distance":
            ydata = across_track_distance
            ylabel = "Distance"
        elif self.ax_style_y == "pixel":
            ydata = np.arange(len(from_track_distance))
            ylabel = "Pixel"
        ynadir = ydata[nadir_index]

        tmin = np.datetime64(time_range[0])
        tmax = np.datetime64(time_range[1])

        if len(values.shape) == 3 and values.shape[2] == 3:
            mesh = self.ax.pcolormesh(
                time,
                ydata,
                values,
                rasterized=True,
                **kwargs,
            )
        else:
            mesh = self.ax.pcolormesh(
                time,
                ydata,
                values.T,
                norm=norm,
                cmap=cmap,
                rasterized=True,
                **kwargs,
            )

            if colorbar:
                cb_kwargs = dict(
                    label=format_var_label(label, units, label_len=label_length),
                    position=colorbar_position,
                    alignment=colorbar_alignment,
                    width=colorbar_width,
                    spacing=colorbar_spacing,
                    length_ratio=colorbar_length_ratio,
                    label_outside=colorbar_label_outside,
                    ticks_outside=colorbar_ticks_outside,
                    ticks_both=colorbar_ticks_both,
                )
                if cmap.categorical:
                    self.colorbar = add_colorbar(
                        fig=self.fig,
                        ax=self.ax,
                        data=mesh,
                        cmap=cmap,
                        **cb_kwargs,  # type: ignore
                    )
                else:
                    self.colorbar = add_colorbar(
                        fig=self.fig,
                        ax=self.ax,
                        data=mesh,
                        ticks=colorbar_ticks,
                        tick_labels=colorbar_tick_labels,
                        **cb_kwargs,  # type: ignore
                    )

        if selection_time_range is not None:
            self.selection_time_range = validate_time_range(selection_time_range)

            if selection_highlight:
                if selection_highlight_inverted:
                    self.ax.axvspan(  # type: ignore
                        tmin,  # type: ignore
                        self.selection_time_range[0],  # type: ignore
                        color=selection_highlight_color,  # type: ignore
                        alpha=selection_highlight_alpha,  # type: ignore
                    )  # type: ignore
                    self.ax.axvspan(  # type: ignore
                        self.selection_time_range[1],  # type: ignore
                        tmax,  # type: ignore
                        color=selection_highlight_color,  # type: ignore
                        alpha=selection_highlight_alpha,  # type: ignore
                    )  # type: ignore
                else:
                    self.ax.axvspan(  # type: ignore
                        self.selection_time_range[0],  # type: ignore
                        self.selection_time_range[1],  # type: ignore
                        color=selection_highlight_color,  # type: ignore
                        alpha=selection_highlight_alpha,  # type: ignore
                    )  # type: ignore

            for t in self.selection_time_range:
                self.ax.axvline(  # type: ignore
                    x=t,  # type: ignore
                    color=selection_color,  # type: ignore
                    linestyle=selection_linestyle,  # type: ignore
                    linewidth=selection_linewidth,  # type: ignore
                )  # type: ignore

        if show_nadir:
            nadir_color = Color.from_optional(nadir_color)
            nadir_color_shade = "white"
            if isinstance(nadir_color, Color):
                nadir_color_shade = nadir_color.get_best_bw_contrast_color()
            nadir_line_shade = self.ax.axhline(
                y=ynadir,
                color=nadir_color_shade,
                linestyle="solid",
                linewidth=nadir_linewidth * 2,
                alpha=0.3,
                zorder=10,
            )
            nadir_line = self.ax.axhline(
                y=ynadir,
                color=Color.from_optional(nadir_color),
                linestyle="dashed",
                linewidth=nadir_linewidth,
                zorder=10,
            )

        self.ax.set_xlim((tmin, tmax))  # type: ignore
        # self.ax.set_ylim((hmin, hmax))

        self.ax_right = self.ax.twinx()
        self.ax_right.set_ylim(self.ax.get_ylim())

        self.ax_top = self.ax.twiny()
        self.ax_top.set_xlim(self.ax.get_xlim())

        if self.ax_style_y == "pixel":
            format_height_ticks(self.ax, label=ylabel, show_units=False)
        else:
            format_height_ticks(self.ax, label=ylabel)
        format_height_ticks(
            self.ax_right, show_tick_labels=False, show_units=False, label=""
        )

        if ax_style_top is not None:
            self.ax_style_top = AlongTrackAxisStyle.from_input(self.ax_style_top)
        if ax_style_bottom is not None:
            self.ax_style_bottom = AlongTrackAxisStyle.from_input(self.ax_style_bottom)

        format_along_track_axis(
            self.ax,
            self.ax_style_bottom,
            time,
            tmin,
            tmax,
            tmin_original,
            tmax_original,
            longitude[:, nadir_index],
            latitude[:, nadir_index],
            num_ticks=self.num_ticks,
        )
        format_along_track_axis(
            self.ax_top,
            self.ax_style_top,
            time,
            tmin,
            tmax,
            tmin_original,
            tmax_original,
            longitude[:, nadir_index],
            latitude[:, nadir_index],
            num_ticks=self.num_ticks,
        )

        return self

    def plot_contour(
        self,
        values: NDArray,
        time: NDArray,
        latitude: NDArray,
        longitude: NDArray,
        nadir_index: int,
        label_levels: list | NDArray | None = None,
        label_format: str | None = None,
        levels: list | NDArray | None = None,
        linewidths: int | float | list | NDArray | None = 1.5,
        linestyles: str | list | NDArray | None = "solid",
        colors: Color | str | list | NDArray | None = "black",
        zorder: int | float | None = 2,
        show_labels: bool = True,
    ) -> "SwathFigure":
        """Adds contour lines to the plot."""
        values = np.asarray(values)
        time = np.asarray(time)
        latitude = np.asarray(latitude)
        longitude = np.asarray(longitude)

        swath_data = SwathData(
            values=values,
            time=time,
            latitude=latitude,
            longitude=longitude,
            nadir_index=nadir_index,
        )

        if isinstance(colors, str):
            colors = Color.from_optional(colors)
        elif isinstance(colors, (Iterable, np.ndarray)):
            colors = [Color.from_optional(c) for c in colors]
        else:
            colors = Color.from_optional(colors)

        values = swath_data.values
        time = swath_data.time
        latitude = swath_data.latitude
        longitude = swath_data.longitude
        across_track_distance = swath_data.across_track_distance
        from_track_distance = swath_data.from_track_distance
        # label = swath_data.label
        # units = swath_data.units
        nadir_index = swath_data.nadir_index

        if self.ax_style_y == "from_track_distance":
            ydata = from_track_distance
        elif self.ax_style_y == "across_track_distance":
            ydata = across_track_distance
        elif self.ax_style_y == "pixel":
            ydata = np.arange(len(from_track_distance))

        x = time
        y = ydata
        z = values.T

        if len(y.shape) == 2:
            y = y[len(y) // 2]

        cn = self.ax.contour(
            x,
            y,
            z,
            levels=levels,
            linewidths=linewidths,
            colors=colors,
            linestyles=linestyles,
            zorder=zorder,
        )

        if show_labels:
            labels: Iterable[float]
            if label_levels:
                labels = [l for l in label_levels if l in cn.levels]
            else:
                labels = cn.levels

            cl = self.ax.clabel(
                cn,
                labels,  # type: ignore
                inline=True,
                fmt=label_format,
                fontsize="small",
                zorder=zorder,
            )

            bold_font = font_manager.FontProperties(weight="bold")
            for text in cl:
                text.set_fontproperties(bold_font)

            for l in cn.labelTexts:
                l.set_rotation(0)

        return self

    def ecplot_coastline(
        self,
        ds: xr.Dataset,
        var: str = "land_flag",
        *,
        time_var: str = TIME_VAR,
        lat_var: str = SWATH_LAT_VAR,
        lon_var: str = SWATH_LON_VAR,
        color: ColorLike = "#F3E490",
        linewidth: float | int = 0.5,
    ):
        return self.plot_contour(
            values=ds[var].values,
            time=ds[time_var].values,
            latitude=ds[lat_var].values,
            longitude=ds[lon_var].values,
            nadir_index=int(ds.nadir_index.values),
            levels=[0, 1],
            colors=Color.from_optional(color),
            show_labels=False,
            linewidths=linewidth,
        )

    def ecplot(
        self,
        ds: xr.Dataset,
        var: str,
        *,
        time_var: str = TIME_VAR,
        lat_var: str = SWATH_LAT_VAR,
        lon_var: str = SWATH_LON_VAR,
        values: NDArray | None = None,
        time: NDArray | None = None,
        nadir_index: int | None = None,
        latitude: NDArray | None = None,
        longitude: NDArray | None = None,
        show_info: bool = True,
        show_info_orbit_and_frame: bool = True,
        show_info_file_type: bool = True,
        show_info_baseline: bool = True,
        info_text_orbit_and_frame: str | None = None,
        info_text_file_type: str | None = None,
        info_text_baseline: str | None = None,
        info_text_loc: str | None = None,
        # Common args for wrappers
        value_range: ValueRangeLike | Literal["default"] | None = "default",
        log_scale: bool | None = None,
        norm: Normalize | None = None,
        time_range: TimeRangeLike | None = None,
        from_track_range: DistanceRangeLike | None = None,
        label: str | None = None,
        units: str | None = None,
        cmap: str | Colormap | None = None,
        colorbar: bool = True,
        colorbar_ticks: ArrayLike | None = None,
        colorbar_tick_labels: ArrayLike | None = None,
        colorbar_position: str | Literal["left", "right", "top", "bottom"] = "right",
        colorbar_alignment: str | Literal["left", "center", "right"] = "center",
        colorbar_width: float = DEFAULT_COLORBAR_WIDTH,
        colorbar_spacing: float = 0.2,
        colorbar_length_ratio: float | str = "100%",
        colorbar_label_outside: bool = True,
        colorbar_ticks_outside: bool = True,
        colorbar_ticks_both: bool = False,
        selection_time_range: TimeRangeLike | None = None,
        selection_color: str | None = Color("ec:earthcare"),
        selection_linestyle: str | None = "dashed",
        selection_linewidth: float | int | None = 2.5,
        selection_highlight: bool = False,
        selection_highlight_inverted: bool = True,
        selection_highlight_color: str = Color("white"),
        selection_highlight_alpha: float = 0.5,
        ax_style_top: AlongTrackAxisStyle | str | None = None,
        ax_style_bottom: AlongTrackAxisStyle | str | None = None,
        ax_style_y: Literal[
            "from_track_distance", "across_track_distance", "pixel"
        ] = "from_track_distance",
        show_nadir: bool = True,
        nadir_color: ColorLike | None = "black",
        nadir_linewidth: int | float = 1.5,
        label_length: int = 25,
        **kwargs,
    ) -> "SwathFigure":
        # Collect all common args for wrapped plot function call
        local_args = locals()
        # Delete all args specific to this wrapper function
        del local_args["self"]
        del local_args["ds"]
        del local_args["var"]
        del local_args["time_var"]
        del local_args["lat_var"]
        del local_args["lon_var"]
        del local_args["show_info"]
        del local_args["show_info_orbit_and_frame"]
        del local_args["show_info_file_type"]
        del local_args["show_info_baseline"]
        del local_args["info_text_orbit_and_frame"]
        del local_args["info_text_file_type"]
        del local_args["info_text_baseline"]
        del local_args["info_text_loc"]
        # Delete kwargs to then merge it with the residual common args
        del local_args["kwargs"]
        all_args = {**local_args, **kwargs}

        if all_args["values"] is None:
            all_args["values"] = ds[var].values
        if all_args["time"] is None:
            all_args["time"] = ds[time_var].values
        if all_args["nadir_index"] is None:
            all_args["nadir_index"] = get_nadir_index(ds)
        if all_args["latitude"] is None:
            all_args["latitude"] = ds[lat_var].values
        if all_args["longitude"] is None:
            all_args["longitude"] = ds[lon_var].values

        # Set default values depending on variable name
        if label is None:
            all_args["label"] = (
                "Values" if not hasattr(ds[var], "long_name") else ds[var].long_name
            )
        if units is None:
            all_args["units"] = "-" if not hasattr(ds[var], "units") else ds[var].units
        if isinstance(value_range, str) and value_range == "default":
            value_range = None
            all_args["value_range"] = None
            if log_scale is None and norm is None:
                all_args["norm"] = get_default_norm(var, file_type=ds)
        if cmap is None:
            all_args["cmap"] = get_default_cmap(var, file_type=ds)

        ds = ensure_updated_msi_rgb_if_required(ds, var, time_range, time_var=time_var)

        self.plot(**all_args)

        self._set_info_text_loc(info_text_loc)
        if show_info:
            self.info_text = add_text_product_info(
                self.ax,
                ds,
                append_to=self.info_text,
                loc=self.info_text_loc,
                show_orbit_and_frame=show_info_orbit_and_frame,
                show_file_type=show_info_file_type,
                show_baseline=show_info_baseline,
                text_orbit_and_frame=info_text_orbit_and_frame,
                text_file_type=info_text_file_type,
                text_baseline=info_text_baseline,
            )

        return self

    def to_texture(self) -> "SwathFigure":
        """Convert the figure to a texture by removing all axis ticks, labels, annotations, and text."""
        # Remove anchored text and other artist text objects
        for artist in reversed(self.ax.artists):
            if isinstance(artist, (Text, AnchoredOffsetbox)):
                artist.remove()

        # Completely remove axis ticks and labels
        self.ax.axis("off")

        if self.ax_top:
            self.ax_top.axis("off")

        if self.ax_right:
            self.ax_right.axis("off")

        # Remove white frame around figure
        self.fig.subplots_adjust(left=0, right=1, bottom=0, top=1)

        # Remove colorbar
        if self.colorbar:
            self.colorbar.remove()

        return self

    def invert_xaxis(self) -> "SwathFigure":
        """Invert the x-axis."""
        self.ax.invert_xaxis()
        if self.ax_top:
            self.ax_top.invert_xaxis()
        return self

    def invert_yaxis(self) -> "SwathFigure":
        """Invert the y-axis."""
        self.ax.invert_yaxis()
        if self.ax_right:
            self.ax_right.invert_yaxis()
        return self

    def set_colorbar_tick_scale(
        self,
        multiplier: float | None = None,
        fontsize: float | str | None = None,
    ) -> "SwathFigure":
        _cb = self.colorbar
        cb: Colorbar
        if isinstance(_cb, Colorbar):
            cb = _cb
        else:
            return self

        if fontsize is not None:
            cb.ax.tick_params(labelsize=fontsize)
            return self

        if multiplier is not None:
            _fontsize = cb.ax.yaxis.get_ticklabels()[0].get_fontsize()
            if isinstance(_fontsize, str):
                fp = font_manager.FontProperties(size=_fontsize)
                _fontsize = fp.get_size_in_points()
            cb.ax.tick_params(labelsize=_fontsize * multiplier)
        return self

    def show(self) -> None:
        import IPython
        import matplotlib.pyplot as plt
        from IPython.display import display

        if IPython.get_ipython() is not None:
            display(self.fig)
        else:
            plt.show()

    def save(
        self,
        filename: str = "",
        filepath: str | None = None,
        ds: xr.Dataset | None = None,
        ds_filepath: str | None = None,
        dpi: float | Literal["figure"] = "figure",
        orbit_and_frame: str | None = None,
        utc_timestamp: TimestampLike | None = None,
        use_utc_creation_timestamp: bool = False,
        site_name: str | None = None,
        hmax: int | float | None = None,
        radius: int | float | None = None,
        extra: str | None = None,
        transparent_outside: bool = False,
        verbose: bool = True,
        print_prefix: str = "",
        create_dirs: bool = False,
        transparent_background: bool = False,
        resolution: str | None = None,
        **kwargs,
    ) -> None:
        """
        Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

        Args:
            figure (Figure | HasFigure): A figure object (`matplotlib.figure.Figure`) or objects exposing a `.fig` attribute containing a figure (e.g., `CurtainFigure`).
            filename (str, optional): The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.
            filepath (str | None, optional): The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.
            ds (xr.Dataset | None, optional): A EarthCARE dataset from which metadata will be taken. Defaults to None.
            ds_filepath (str | None, optional): A path to a EarthCARE product from which metadata will be taken. Defaults to None.
            pad (float, optional): Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.
            dpi (float | 'figure', optional): The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.
            orbit_and_frame (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            utc_timestamp (TimestampLike | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            use_utc_creation_timestamp (bool, optional): Whether the time of image creation should be included in the file name. Defaults to False.
            site_name (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            hmax (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            radius (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            resolution (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
            extra (str | None, optional): A custom string to be included in the file name. Defaults to None.
            transparent_outside (bool, optional): Whether the area outside figures should be transparent. Defaults to False.
            verbose (bool, optional): Whether the progress of image creation should be printed to the console. Defaults to True.
            print_prefix (str, optional): A prefix string to all console messages. Defaults to "".
            create_dirs (bool, optional): Whether images should be saved in a folder structure based on provided metadata. Defaults to False.
            transparent_background (bool, optional): Whether the background inside and outside of figures should be transparent. Defaults to False.
            **kwargs (dict[str, Any]): Keyword arguments passed to wrapped function call of `matplotlib.pyplot.savefig`.
        """
        save_plot(
            fig=self.fig,
            filename=filename,
            filepath=filepath,
            ds=ds,
            ds_filepath=ds_filepath,
            dpi=dpi,
            orbit_and_frame=orbit_and_frame,
            utc_timestamp=utc_timestamp,
            use_utc_creation_timestamp=use_utc_creation_timestamp,
            site_name=site_name,
            hmax=hmax,
            radius=radius,
            extra=extra,
            transparent_outside=transparent_outside,
            verbose=verbose,
            print_prefix=print_prefix,
            create_dirs=create_dirs,
            transparent_background=transparent_background,
            resolution=resolution,
            **kwargs,
        )

invert_xaxis

invert_xaxis()

Invert the x-axis.

Source code in earthcarekit/plot/figure/swath.py
def invert_xaxis(self) -> "SwathFigure":
    """Invert the x-axis."""
    self.ax.invert_xaxis()
    if self.ax_top:
        self.ax_top.invert_xaxis()
    return self

invert_yaxis

invert_yaxis()

Invert the y-axis.

Source code in earthcarekit/plot/figure/swath.py
def invert_yaxis(self) -> "SwathFigure":
    """Invert the y-axis."""
    self.ax.invert_yaxis()
    if self.ax_right:
        self.ax_right.invert_yaxis()
    return self

plot_contour

plot_contour(
    values,
    time,
    latitude,
    longitude,
    nadir_index,
    label_levels=None,
    label_format=None,
    levels=None,
    linewidths=1.5,
    linestyles="solid",
    colors="black",
    zorder=2,
    show_labels=True,
)

Adds contour lines to the plot.

Source code in earthcarekit/plot/figure/swath.py
def plot_contour(
    self,
    values: NDArray,
    time: NDArray,
    latitude: NDArray,
    longitude: NDArray,
    nadir_index: int,
    label_levels: list | NDArray | None = None,
    label_format: str | None = None,
    levels: list | NDArray | None = None,
    linewidths: int | float | list | NDArray | None = 1.5,
    linestyles: str | list | NDArray | None = "solid",
    colors: Color | str | list | NDArray | None = "black",
    zorder: int | float | None = 2,
    show_labels: bool = True,
) -> "SwathFigure":
    """Adds contour lines to the plot."""
    values = np.asarray(values)
    time = np.asarray(time)
    latitude = np.asarray(latitude)
    longitude = np.asarray(longitude)

    swath_data = SwathData(
        values=values,
        time=time,
        latitude=latitude,
        longitude=longitude,
        nadir_index=nadir_index,
    )

    if isinstance(colors, str):
        colors = Color.from_optional(colors)
    elif isinstance(colors, (Iterable, np.ndarray)):
        colors = [Color.from_optional(c) for c in colors]
    else:
        colors = Color.from_optional(colors)

    values = swath_data.values
    time = swath_data.time
    latitude = swath_data.latitude
    longitude = swath_data.longitude
    across_track_distance = swath_data.across_track_distance
    from_track_distance = swath_data.from_track_distance
    # label = swath_data.label
    # units = swath_data.units
    nadir_index = swath_data.nadir_index

    if self.ax_style_y == "from_track_distance":
        ydata = from_track_distance
    elif self.ax_style_y == "across_track_distance":
        ydata = across_track_distance
    elif self.ax_style_y == "pixel":
        ydata = np.arange(len(from_track_distance))

    x = time
    y = ydata
    z = values.T

    if len(y.shape) == 2:
        y = y[len(y) // 2]

    cn = self.ax.contour(
        x,
        y,
        z,
        levels=levels,
        linewidths=linewidths,
        colors=colors,
        linestyles=linestyles,
        zorder=zorder,
    )

    if show_labels:
        labels: Iterable[float]
        if label_levels:
            labels = [l for l in label_levels if l in cn.levels]
        else:
            labels = cn.levels

        cl = self.ax.clabel(
            cn,
            labels,  # type: ignore
            inline=True,
            fmt=label_format,
            fontsize="small",
            zorder=zorder,
        )

        bold_font = font_manager.FontProperties(weight="bold")
        for text in cl:
            text.set_fontproperties(bold_font)

        for l in cn.labelTexts:
            l.set_rotation(0)

    return self

save

save(
    filename="",
    filepath=None,
    ds=None,
    ds_filepath=None,
    dpi="figure",
    orbit_and_frame=None,
    utc_timestamp=None,
    use_utc_creation_timestamp=False,
    site_name=None,
    hmax=None,
    radius=None,
    extra=None,
    transparent_outside=False,
    verbose=True,
    print_prefix="",
    create_dirs=False,
    transparent_background=False,
    resolution=None,
    **kwargs
)

Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

Parameters:

Name Type Description Default
figure Figure | HasFigure

A figure object (matplotlib.figure.Figure) or objects exposing a .fig attribute containing a figure (e.g., CurtainFigure).

required
filename str

The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.

''
filepath str | None

The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.

None
ds Dataset | None

A EarthCARE dataset from which metadata will be taken. Defaults to None.

None
ds_filepath str | None

A path to a EarthCARE product from which metadata will be taken. Defaults to None.

None
pad float

Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.

required
dpi float | figure

The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.

'figure'
orbit_and_frame str | None

Metadata used in the formatting of the file name. Defaults to None.

None
utc_timestamp TimestampLike | None

Metadata used in the formatting of the file name. Defaults to None.

None
use_utc_creation_timestamp bool

Whether the time of image creation should be included in the file name. Defaults to False.

False
site_name str | None

Metadata used in the formatting of the file name. Defaults to None.

None
hmax int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
radius int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
resolution str | None

Metadata used in the formatting of the file name. Defaults to None.

None
extra str | None

A custom string to be included in the file name. Defaults to None.

None
transparent_outside bool

Whether the area outside figures should be transparent. Defaults to False.

False
verbose bool

Whether the progress of image creation should be printed to the console. Defaults to True.

True
print_prefix str

A prefix string to all console messages. Defaults to "".

''
create_dirs bool

Whether images should be saved in a folder structure based on provided metadata. Defaults to False.

False
transparent_background bool

Whether the background inside and outside of figures should be transparent. Defaults to False.

False
**kwargs dict[str, Any]

Keyword arguments passed to wrapped function call of matplotlib.pyplot.savefig.

{}
Source code in earthcarekit/plot/figure/swath.py
def save(
    self,
    filename: str = "",
    filepath: str | None = None,
    ds: xr.Dataset | None = None,
    ds_filepath: str | None = None,
    dpi: float | Literal["figure"] = "figure",
    orbit_and_frame: str | None = None,
    utc_timestamp: TimestampLike | None = None,
    use_utc_creation_timestamp: bool = False,
    site_name: str | None = None,
    hmax: int | float | None = None,
    radius: int | float | None = None,
    extra: str | None = None,
    transparent_outside: bool = False,
    verbose: bool = True,
    print_prefix: str = "",
    create_dirs: bool = False,
    transparent_background: bool = False,
    resolution: str | None = None,
    **kwargs,
) -> None:
    """
    Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

    Args:
        figure (Figure | HasFigure): A figure object (`matplotlib.figure.Figure`) or objects exposing a `.fig` attribute containing a figure (e.g., `CurtainFigure`).
        filename (str, optional): The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.
        filepath (str | None, optional): The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.
        ds (xr.Dataset | None, optional): A EarthCARE dataset from which metadata will be taken. Defaults to None.
        ds_filepath (str | None, optional): A path to a EarthCARE product from which metadata will be taken. Defaults to None.
        pad (float, optional): Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.
        dpi (float | 'figure', optional): The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.
        orbit_and_frame (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        utc_timestamp (TimestampLike | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        use_utc_creation_timestamp (bool, optional): Whether the time of image creation should be included in the file name. Defaults to False.
        site_name (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        hmax (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        radius (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        resolution (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        extra (str | None, optional): A custom string to be included in the file name. Defaults to None.
        transparent_outside (bool, optional): Whether the area outside figures should be transparent. Defaults to False.
        verbose (bool, optional): Whether the progress of image creation should be printed to the console. Defaults to True.
        print_prefix (str, optional): A prefix string to all console messages. Defaults to "".
        create_dirs (bool, optional): Whether images should be saved in a folder structure based on provided metadata. Defaults to False.
        transparent_background (bool, optional): Whether the background inside and outside of figures should be transparent. Defaults to False.
        **kwargs (dict[str, Any]): Keyword arguments passed to wrapped function call of `matplotlib.pyplot.savefig`.
    """
    save_plot(
        fig=self.fig,
        filename=filename,
        filepath=filepath,
        ds=ds,
        ds_filepath=ds_filepath,
        dpi=dpi,
        orbit_and_frame=orbit_and_frame,
        utc_timestamp=utc_timestamp,
        use_utc_creation_timestamp=use_utc_creation_timestamp,
        site_name=site_name,
        hmax=hmax,
        radius=radius,
        extra=extra,
        transparent_outside=transparent_outside,
        verbose=verbose,
        print_prefix=print_prefix,
        create_dirs=create_dirs,
        transparent_background=transparent_background,
        resolution=resolution,
        **kwargs,
    )

to_texture

to_texture()

Convert the figure to a texture by removing all axis ticks, labels, annotations, and text.

Source code in earthcarekit/plot/figure/swath.py
def to_texture(self) -> "SwathFigure":
    """Convert the figure to a texture by removing all axis ticks, labels, annotations, and text."""
    # Remove anchored text and other artist text objects
    for artist in reversed(self.ax.artists):
        if isinstance(artist, (Text, AnchoredOffsetbox)):
            artist.remove()

    # Completely remove axis ticks and labels
    self.ax.axis("off")

    if self.ax_top:
        self.ax_top.axis("off")

    if self.ax_right:
        self.ax_right.axis("off")

    # Remove white frame around figure
    self.fig.subplots_adjust(left=0, right=1, bottom=0, top=1)

    # Remove colorbar
    if self.colorbar:
        self.colorbar.remove()

    return self

add_depol_ratio

add_depol_ratio(
    ds_anom,
    rolling_w=20,
    near_zero_tolerance=2e-07,
    smooth=True,
    skip_height_above_elevation=300,
    cpol_var="mie_attenuated_backscatter",
    xpol_var="crosspolar_attenuated_backscatter",
    elevation_var=ELEVATION_VAR,
    height_var=HEIGHT_VAR,
    height_dim=VERTICAL_DIM,
)

Compute depolarization ratio (DPOL = XPOL/CPOL) from attenuated backscatter signals.

This function derives the depol. ratio from cross-polarized (XPOL) and co-polarized (CPOL) attenuated backscatter signals. Signals below the surface are masked, by default with a vertical margin on 300 meters above elevation to remove potential surface return. Also, signals are smoothed (or "cleaned") with a rolling mean, and near-zero divisions are suppressed and set to NaN instead. In the resulting dataset, the ratio curtain and a ratio profile calculated from mean profiles of the full dataset (e.g., mean(XPOL)/mean(CPOL)).

Parameters:

Name Type Description Default
ds_anom Dataset

ATL_NOM_1B dataset containing cross- and co-polar attenuated backscatter.

required
rolling_w int

Window size for rolling mean smoothing. Defaults to 20.

20
near_zero_tolerance float

Tolerance for masking near-zero CPOL (i.e., denominators). Defaults to 2e-7.

2e-07
smooth bool

Whether to apply rolling mean smoothing. Defaults to True.

True
skip_height_above_elevation int

Vertical margin above surface elevation to mask in meters. Defaults to 300.

300
cpol_var str

Input co-polar variable name. Defaults to "mie_attenuated_backscatter".

'mie_attenuated_backscatter'
xpol_var str

Input cross-polar variable name. Defaults to "crosspolar_attenuated_backscatter".

'crosspolar_attenuated_backscatter'
elevation_var str

Elevation variable name. Defaults to ELEVATION_VAR.

ELEVATION_VAR
height_var str

Height variable name. Defaults to HEIGHT_VAR.

HEIGHT_VAR
height_dim str

Height dimension name. Defaults to VERTICAL_DIM.

VERTICAL_DIM

Returns:

Type Description
Dataset

xr.Dataset: Dataset with added depol. ratio, cleaned signals, and depol. ratio profile from mean profiles.

Source code in earthcarekit/utils/read/product/level1/atl_nom_1b.py
def add_depol_ratio(
    ds_anom: xr.Dataset,
    rolling_w: int = 20,
    near_zero_tolerance: float = 2e-7,
    smooth: bool = True,
    skip_height_above_elevation: int = 300,
    cpol_var: str = "mie_attenuated_backscatter",
    xpol_var: str = "crosspolar_attenuated_backscatter",
    elevation_var: str = ELEVATION_VAR,
    height_var: str = HEIGHT_VAR,
    height_dim: str = VERTICAL_DIM,
) -> xr.Dataset:
    """
    Compute depolarization ratio (`DPOL` = `XPOL`/`CPOL`) from attenuated backscatter signals.

    This function derives the depol. ratio from cross-polarized (`XPOL`) and co-polarized (`CPOL`) attenuated backscatter signals.
    Signals below the surface are masked, by default with a vertical margin on 300 meters above elevation to remove potential surface return.
    Also, signals are smoothed (or "cleaned") with a rolling mean, and near-zero divisions are suppressed and set to NaN instead.
    In the resulting dataset, the ratio curtain and a ratio profile calculated from mean profiles of the full dataset (e.g., mean(`XPOL`)/mean(`CPOL`)).

    Args:
        ds_anom (xr.Dataset): ATL_NOM_1B dataset containing cross- and co-polar attenuated backscatter.
        rolling_w (int, optional): Window size for rolling mean smoothing. Defaults to 20.
        near_zero_tolerance (float, optional): Tolerance for masking near-zero `CPOL` (i.e., denominators). Defaults to 2e-7.
        smooth (bool, optional): Whether to apply rolling mean smoothing. Defaults to True.
        skip_height_above_elevation (int, optional): Vertical margin above surface elevation to mask in meters. Defaults to 300.
        cpol_var (str, optional): Input co-polar variable name. Defaults to "mie_attenuated_backscatter".
        xpol_var (str, optional): Input cross-polar variable name. Defaults to "crosspolar_attenuated_backscatter".
        elevation_var (str, optional): Elevation variable name. Defaults to ELEVATION_VAR.
        height_var (str, optional): Height variable name. Defaults to HEIGHT_VAR.
        height_dim (str, optional): Height dimension name. Defaults to VERTICAL_DIM.

    Returns:
        xr.Dataset: Dataset with added depol. ratio, cleaned signals, and depol. ratio profile from mean profiles.
    """
    return add_scattering_ratio(
        ds_anom=ds_anom,
        formula="x/c",
        rolling_w=rolling_w,
        near_zero_tolerance=near_zero_tolerance,
        smooth=smooth,
        skip_height_above_elevation=skip_height_above_elevation,
        cpol_var=cpol_var,
        xpol_var=xpol_var,
        elevation_var=elevation_var,
        height_var=height_var,
        height_dim=height_dim,
    )

add_isccp_cloud_type

add_isccp_cloud_type(
    ds,
    new_var="isccp_cloud_type",
    cot_var="cloud_optical_thickness",
    cth_var="cloud_top_height",
    along_track_dim=ALONG_TRACK_DIM,
    across_track_dim=ACROSS_TRACK_DIM,
)

Adds a variable to the dataset containing ISCCP cloud types calculated from cloud optical thickness (COT) and cloud top height (CTH).

Parameters:

Name Type Description Default
ds Dataset

A MSI_COP_2A dataset.

required
new_var str

Name of the new ISCCP cloud type variable. Defaults to "isccp_cloud_type".

'isccp_cloud_type'
cot_var str

Name of the COT variable in ds. Defaults to "cloud_optical_thickness".

'cloud_optical_thickness'
cth_var str

Name of the CTH variable in ds. Defaults to "cloud_top_height".

'cloud_top_height'
along_track_dim str

Name of the along-track dimension in ds. Defaults to ALONG_TRACK_DIM.

ALONG_TRACK_DIM
across_track_dim str

Name of the across-track dimension in ds. Defaults to ACROSS_TRACK_DIM.

ACROSS_TRACK_DIM

Returns:

Type Description
Dataset

xr.Dataset: The input dataset with added ISCCP cloud type variable.

References
  • International Satellite Cloud Climatology Project (ISCCP). ISCCP Definition of Cloud Types. Retrieved September 25, 2025. https://isccp.giss.nasa.gov/cloudtypes.html
Source code in earthcarekit/utils/read/product/level2a/msi_cop_2a.py
def add_isccp_cloud_type(
    ds: xr.Dataset,
    new_var: str = "isccp_cloud_type",
    cot_var: str = "cloud_optical_thickness",
    cth_var: str = "cloud_top_height",
    along_track_dim: str = ALONG_TRACK_DIM,
    across_track_dim: str = ACROSS_TRACK_DIM,
) -> xr.Dataset:
    """
    Adds a variable to the dataset containing ISCCP cloud types calculated from cloud optical thickness (COT)
    and cloud top height (CTH).

    Args:
        ds (xr.Dataset): A MSI_COP_2A dataset.
        new_var (str, optional): Name of the new ISCCP cloud type variable. Defaults to "isccp_cloud_type".
        cot_var (str, optional): Name of the COT variable in `ds`. Defaults to "cloud_optical_thickness".
        cth_var (str, optional): Name of the CTH variable in `ds`. Defaults to "cloud_top_height".
        along_track_dim (str, optional): Name of the along-track dimension in `ds`. Defaults to ALONG_TRACK_DIM.
        across_track_dim (str, optional): Name of the across-track dimension in `ds`. Defaults to ACROSS_TRACK_DIM.

    Returns:
        xr.Dataset: The input dataset with added ISCCP cloud type variable.

    References:
        - International Satellite Cloud Climatology Project (ISCCP). ISCCP Definition of Cloud Types.
        Retrieved September 25, 2025. https://isccp.giss.nasa.gov/cloudtypes.html
    """
    cot = ds[cot_var].values
    cth = ds[cth_var].values

    cu = np.where((cth >= 100) & (cth < 3200) & (cot >= 0.01) & (cot < 3.6))
    ac = np.where((cth >= 3200) & (cth < 6500) & (cot >= 0.01) & (cot < 3.6))
    ci = np.where((cth >= 6500) & (cth < 19300) & (cot >= 0.01) & (cot < 3.6))
    sc = np.where((cth >= 100) & (cth < 3200) & (cot >= 3.6) & (cot < 23))
    asc = np.where((cth >= 3200) & (cth < 6500) & (cot >= 3.6) & (cot < 23))
    cs = np.where((cth >= 6500) & (cth < 19300) & (cot >= 3.6) & (cot < 23))
    st = np.where((cth >= 100) & (cth < 3200) & (cot >= 23))
    ns = np.where((cth >= 3200) & (cth < 6500) & (cot >= 23))
    cb = np.where((cth >= 6500) & (cth < 19300) & (cot >= 23))
    clear = np.where((cot < 0.01) & (cot >= 0))

    cloud_type = np.empty(shape=cot.shape, dtype=int)
    cloud_type[:, :] = -127

    cloud_type[cu] = 1
    cloud_type[ac] = 2
    cloud_type[ci] = 3
    cloud_type[sc] = 4
    cloud_type[asc] = 5
    cloud_type[cs] = 6
    cloud_type[st] = 7
    cloud_type[ns] = 8
    cloud_type[cb] = 9
    cloud_type[clear] = 0

    da = xr.DataArray(
        cloud_type,
        dims=(along_track_dim, across_track_dim),
        name=new_var,
        attrs={
            "units": "",
            "long_name": "ISCCP cloud type calculated from M-COP",
            "definition": "0: Clear, 1: Cumulus, 2: Altocumulus, 3: Cirrus, 4: Stratocumulus, 5: Altostratus, 6: Cirrostratus, 7: Stratus, 8: Nimbostratus, 9: Deep convection, -127: Not determined",
            "earthcarekit": "Added by earthcarekit",
        },
    )
    ds[new_var] = da

    return ds

add_potential_temperature

add_potential_temperature(
    ds,
    temperature_var="temperature_kelvin",
    pressure_var="pressure",
    new_var="potential_temperature",
)

Computes potential temperature from temperature [K] and pressure [Pa] and adds it as a variable to the dataset (source: https://en.wikipedia.org/wiki/Potential_temperature, accessed: 2026-02-06).

Parameters:

Name Type Description Default
ds Dataset

Dataset (e.g., AUX_MET_1D) containing temperature [K] and pressure [Pa] data.

required
temperature_var str

Input temperature variable name. Defaults to "temperature_kelvin".

'temperature_kelvin'
pressure_var str

Input pressure variable name. Defaults to "pressure".

'pressure'
new_var str

New variable name for potential temperature. Defaults to "potential_temperature".

'potential_temperature'

Returns:

Type Description
Dataset

xr.Dataset: Dataset with 2 new variables for potential temperature profiles added (kelvin and celsius).

Source code in earthcarekit/utils/read/product/auxiliary/aux_met_1d.py
def add_potential_temperature(
    ds: xr.Dataset,
    temperature_var: str = "temperature_kelvin",
    pressure_var: str = "pressure",
    new_var: str = "potential_temperature",
) -> xr.Dataset:
    """
    Computes potential temperature from temperature [K] and pressure [Pa] and adds it as a variable to the dataset (source: https://en.wikipedia.org/wiki/Potential_temperature, accessed: 2026-02-06).

    Args:
        ds (xr.Dataset): Dataset (e.g., AUX_MET_1D) containing temperature [K] and pressure [Pa] data.
        temperature_var (str, optional): Input temperature variable name. Defaults to "temperature_kelvin".
        pressure_var (str, optional): Input pressure variable name. Defaults to "pressure".
        new_var (str, optional): New variable name for potential temperature. Defaults to "potential_temperature".

    Returns:
        xr.Dataset: Dataset with 2 new variables for potential temperature profiles added (kelvin and celsius).
    """
    t = ds[temperature_var].values  # [K]
    p = ds[pressure_var].values  # [Pa]
    p0 = 100_000.0  # [Pa]
    rcp = 0.286
    potential_t = t * np.pow(p0 / p, rcp)

    attrs = {
        "units": "K",
        "long_name": "Potential temperature",
        "name": "Potential temperature",
    }
    ds[f"{new_var}_kelvin"] = (
        ds[temperature_var].copy().drop_attrs().assign_attrs(attrs)
    )
    ds[f"{new_var}_kelvin"].values = potential_t
    attrs["units"] = r"$^{\circ}$C"
    ds[f"{new_var}_celsius"] = (
        ds[temperature_var].copy().drop_attrs().assign_attrs(attrs)
    )
    ds[f"{new_var}_celsius"].values = potential_t - 273.15

    return ds

add_scattering_ratio

add_scattering_ratio(
    ds_anom,
    formula,
    rolling_w=20,
    near_zero_tolerance=2e-07,
    smooth=True,
    skip_height_above_elevation=300,
    cpol_var="mie_attenuated_backscatter",
    xpol_var="crosspolar_attenuated_backscatter",
    ray_var="rayleigh_attenuated_backscatter",
    elevation_var=ELEVATION_VAR,
    height_var=HEIGHT_VAR,
    height_dim=VERTICAL_DIM,
)

Compute scattering ratio from attenuated backscatter signals given a formula: "x/c", "(c+x)/r", or "(c+x+r)/r".

This function derives the scattering ratio from cross-polarized (XPOL), co-polarized (CPOL) and rayleigh (RAY) attenuated backscatter signals. Signals below the surface are masked, by default with a vertical margin on 300 meters above elevation to remove potential surface return. Also, signals are smoothed (or "cleaned") with a rolling mean, and near-zero divisions are suppressed and set to NaN instead. In the resulting dataset, the ratio curtain and a ratio profile calculated from mean profiles of the full dataset (e.g., mean(XPOL)/mean(CPOL)).

Parameters:

Name Type Description Default
ds_anom Dataset

ATL_NOM_1B dataset containing the attenuated backscatter signals.

required
formula Literal['x/c', '(c+x)/r', '(c+x+r)/r']

Formula used to calculate the scattering ratio.

required
rolling_w int

Window size for rolling mean smoothing. Defaults to 20.

20
near_zero_tolerance float

Tolerance for masking near-zero denominators. Defaults to 2e-7.

2e-07
smooth bool

Whether to apply rolling mean smoothing. Defaults to True.

True
skip_height_above_elevation int

Vertical margin above surface elevation to mask in meters. Defaults to 300.

300
cpol_var str

Input co-polar variable name. Defaults to "mie_attenuated_backscatter".

'mie_attenuated_backscatter'
xpol_var str

Input cross-polar variable name. Defaults to "crosspolar_attenuated_backscatter".

'crosspolar_attenuated_backscatter'
ray_var str

Input rayleigh variable name. Defaults to "rayleigh_attenuated_backscatter".

'rayleigh_attenuated_backscatter'
elevation_var str

Elevation variable name. Defaults to ELEVATION_VAR.

ELEVATION_VAR
height_var str

Height variable name. Defaults to HEIGHT_VAR.

HEIGHT_VAR
height_dim str

Height dimension name. Defaults to VERTICAL_DIM.

VERTICAL_DIM

Returns:

Type Description
Dataset

xr.Dataset: xr.Dataset: Dataset with added ratio curtain and ratio profile from mean profiles.

Source code in earthcarekit/utils/read/product/level1/atl_nom_1b.py
def add_scattering_ratio(
    ds_anom: xr.Dataset,
    formula: Literal["x/c", "(c+x)/r", "(c+x+r)/r"],
    rolling_w: int = 20,
    near_zero_tolerance: float = 2e-7,
    smooth: bool = True,
    skip_height_above_elevation: int = 300,
    cpol_var: str = "mie_attenuated_backscatter",
    xpol_var: str = "crosspolar_attenuated_backscatter",
    ray_var: str = "rayleigh_attenuated_backscatter",
    elevation_var: str = ELEVATION_VAR,
    height_var: str = HEIGHT_VAR,
    height_dim: str = VERTICAL_DIM,
) -> xr.Dataset:
    """
    Compute scattering ratio from attenuated backscatter signals given a formula: "x/c", "(c+x)/r", or "(c+x+r)/r".

    This function derives the scattering ratio from cross-polarized (`XPOL`), co-polarized (`CPOL`) and rayleigh (`RAY`) attenuated backscatter signals.
    Signals below the surface are masked, by default with a vertical margin on 300 meters above elevation to remove potential surface return.
    Also, signals are smoothed (or "cleaned") with a rolling mean, and near-zero divisions are suppressed and set to NaN instead.
    In the resulting dataset, the ratio curtain and a ratio profile calculated from mean profiles of the full dataset (e.g., mean(`XPOL`)/mean(`CPOL`)).

    Args:
        ds_anom (xr.Dataset): ATL_NOM_1B dataset containing the attenuated backscatter signals.
        formula (Literal["x/c", "(c+x)/r", "(c+x+r)/r"]): Formula used to calculate the scattering ratio.
        rolling_w (int, optional): Window size for rolling mean smoothing. Defaults to 20.
        near_zero_tolerance (float, optional): Tolerance for masking near-zero denominators. Defaults to 2e-7.
        smooth (bool, optional): Whether to apply rolling mean smoothing. Defaults to True.
        skip_height_above_elevation (int, optional): Vertical margin above surface elevation to mask in meters. Defaults to 300.
        cpol_var (str, optional): Input co-polar variable name. Defaults to "mie_attenuated_backscatter".
        xpol_var (str, optional): Input cross-polar variable name. Defaults to "crosspolar_attenuated_backscatter".
        ray_var (str, optional): Input rayleigh variable name. Defaults to "rayleigh_attenuated_backscatter".
        elevation_var (str, optional): Elevation variable name. Defaults to ELEVATION_VAR.
        height_var (str, optional): Height variable name. Defaults to HEIGHT_VAR.
        height_dim (str, optional): Height dimension name. Defaults to VERTICAL_DIM.

    Returns:
        xr.Dataset: xr.Dataset: Dataset with added ratio curtain and ratio profile from mean profiles.
    """

    if formula.lower() not in ["x/c", "(c+x)/r", "(c+x+r)/r"]:
        raise ValueError(
            f"invalid formula '{formula}', expected 'x/c', '(c+x)/r' or '(c+x+r)/r'"
        )

    cpol_cleaned_var: str = "cpol_cleaned_for_ratio_calculation"
    xpol_cleaned_var: str = "xpol_cleaned_for_ratio_calculation"
    ray_cleaned_var: str = "ray_cleaned_for_ratio_calculation"

    cpol_da = ds_anom[cpol_var].copy()
    xpol_da = ds_anom[xpol_var].copy()
    ray_da = ds_anom[ray_var].copy()
    # if formula == "x/c":
    #     ray_da = xpol_da
    # else:

    def _calc(c, x, r):
        if formula == "x/c":
            return x / c
        elif formula == "(c+x)/r":
            return (c + x) / r
        elif formula == "(c+x+r)/r":
            return (c + x + r) / r

    def _get_near_zero_mask(c, x, r):
        if formula == "x/c":
            return np.isclose(c, 0, atol=near_zero_tolerance)
        elif formula == "(c+x)/r":
            return np.isclose(r, 0, atol=near_zero_tolerance)
        elif formula == "(c+x+r)/r":
            return np.isclose(r, 0, atol=near_zero_tolerance)

    def _get_long_name():
        if formula == "x/c":
            return "Depol. ratio from cross- and co-polar atten. part. bsc."
        elif formula == "(c+x)/r":
            return "Total part. to ray. atten. bsc. ratio"
        elif formula == "(c+x+r)/r":
            return "Total to ray. atten. bsc. ratio"

    def _get_ratio_var():
        if formula == "x/c":
            return "depol_ratio"
        elif formula == "(c+x)/r":
            return "cpol_xpol_to_ray_ratio"
        elif formula == "(c+x+r)/r":
            return "cpol_xpol_ray_to_ray_ratio"

    ratio_var = _get_ratio_var()
    ratio_from_means_var = f"{ratio_var}_from_means"

    ds_anom[ratio_var] = _calc(cpol_da, xpol_da, ray_da)
    rename_var_info(
        ds_anom,
        ratio_var,
        name=ratio_var,
        long_name=_get_long_name(),
        units="",
    )

    elevation = (
        ds_anom[elevation_var].data.copy()[:, np.newaxis] + skip_height_above_elevation
    )
    mask_surface = ds_anom[height_var].data[0].copy() < elevation

    cpol = ds_anom[cpol_var].data
    xpol = ds_anom[xpol_var].data
    ray = ds_anom[ray_var].data
    # if formula == "x/c":
    #     ray = xpol
    # else:

    cpol[mask_surface] = np.nan
    xpol[mask_surface] = np.nan
    ray[mask_surface] = np.nan

    if smooth:
        cpol = rolling_mean_2d(cpol, rolling_w, axis=0)
        xpol = rolling_mean_2d(xpol, rolling_w, axis=0)
        ray = rolling_mean_2d(ray, rolling_w, axis=0)

    ds_anom[ratio_var].data = _calc(cpol, xpol, ray)
    ds_anom[ratio_var] = ds_anom[ratio_var].assign_attrs(
        {
            "earthcarekit": "Added by earthcarekit: Intended for use in curtain plots only!",
        }
    )

    if smooth:
        near_zero_mask = _get_near_zero_mask(cpol, xpol, ray)
        ds_anom[ratio_var].data[near_zero_mask] = np.nan
        cpol[near_zero_mask] = np.nan
        xpol[near_zero_mask] = np.nan
        ray[near_zero_mask] = np.nan

    ds_anom[xpol_cleaned_var] = ds_anom[xpol_var].copy()
    ds_anom[xpol_cleaned_var].data = xpol
    ds_anom[xpol_cleaned_var] = ds_anom[xpol_cleaned_var].assign_attrs(
        {
            "earthcarekit": f"Added by earthcarekit: Rolling mean applied (w={rolling_w}) and near-zero values removed (tolerance={near_zero_tolerance})"
        }
    )

    ds_anom[cpol_cleaned_var] = ds_anom[cpol_var].copy()
    ds_anom[cpol_cleaned_var].data = cpol
    ds_anom[cpol_cleaned_var] = ds_anom[cpol_cleaned_var].assign_attrs(
        {
            "earthcarekit": f"Added by earthcarekit: Rolling mean applied (w={rolling_w}) and near-zero values removed (tolerance={near_zero_tolerance})"
        }
    )

    # if formula == "x/c":
    ds_anom[ray_cleaned_var] = ds_anom[ray_var].copy()
    ds_anom[ray_cleaned_var].data = ray
    ds_anom[ray_cleaned_var] = ds_anom[ray_cleaned_var].assign_attrs(
        {
            "earthcarekit": f"Added by earthcarekit: Rolling mean applied (w={rolling_w}) and near-zero values removed (tolerance={near_zero_tolerance})"
        }
    )

    ratio_mean = _calc(
        nan_mean(cpol, axis=0),
        nan_mean(xpol, axis=0),
        nan_mean(ray, axis=0),
    )

    ds_anom[ratio_from_means_var] = xr.DataArray(
        data=ratio_mean,
        dims=[height_dim],
        attrs={
            "long_name": _get_long_name(),
            "units": "",
            "earthcarekit": "Added by earthcarekit: Scattering ratio profile calculated from the mean profiles",
        },
    )

    return ds_anom

compare_bsc_ext_lr_depol

compare_bsc_ext_lr_depol(
    input_ec,
    input_ground=None,
    time_var_ground="time",
    height_var_ground="height",
    bsc_var_ground=[],
    ext_var_ground=[],
    lr_var_ground=[],
    depol_var_ground=[],
    input_ec2=None,
    input_ec3=None,
    input_ec4=None,
    input_ground2=None,
    input_ground3=None,
    input_ground4=None,
    time_var_ground2=None,
    height_var_ground2=None,
    time_var_ground3=None,
    height_var_ground3=None,
    time_var_ground4=None,
    height_var_ground4=None,
    bsc_var_ground2=None,
    ext_var_ground2=None,
    lr_var_ground2=None,
    depol_var_ground2=None,
    bsc_var_ground3=None,
    ext_var_ground3=None,
    lr_var_ground3=None,
    depol_var_ground3=None,
    bsc_var_ground4=None,
    ext_var_ground4=None,
    lr_var_ground4=None,
    depol_var_ground4=None,
    site=None,
    radius_km=100.0,
    resolution="_low_resolution",
    resolution2=None,
    resolution3=None,
    resolution4=None,
    height_range=(0, 30000.0),
    selection_height_range=None,
    selection_height_range_bsc=None,
    selection_height_range_ext=None,
    selection_height_range_lr=None,
    selection_height_range_depol=None,
    value_range_bsc=(0, 8e-06),
    value_range_ext=(0, 0.0003),
    value_range_lr=(0, 100),
    value_range_depol=(0, 0.6),
    colors_ec=["ec:red", "ec:orange", "ec:yellow", "ec:purple"],
    colors_ground=[
        "ec:blue",
        "ec:darkblue",
        "ec:lightgreen",
        "ec:darkgreen",
        "ec:purple",
    ],
    linewidth_ec=1.5,
    linewidth_ground=1.5,
    linestyle_ec="solid",
    linestyle_ground="solid",
    label_ec=[],
    label_ground=[],
    alpha=1.0,
    show_steps=DEFAULT_PROFILE_SHOW_STEPS,
    show_error_ec=False,
    show_quality_status=False,
    show_rebinned=False,
    quality_status_width_scale=1.0,
    quality_status_var="quality_status",
    quality_status_value_range=None,
    to_mega=False,
    single_figsize=(5 * CM_AS_INCH, 12 * CM_AS_INCH),
    label_bsc="Bsc. coeff.",
    label_ext="Ext. coeff.",
    label_lr="Lidar ratio",
    label_depol="Depol. ratio",
    units_bsc="m$^{-1}$ sr$^{-1}$",
    units_ext="m$^{-1}$",
    units_lr="sr",
    units_depol="",
    verbose=True,
)

Compares Lidar profiles from up to 3 EarthCARE source dataset an one ground-based dataset by creating plots and statistics dataframe.

Parameters:

Name Type Description Default
input_ec str | Dataset

A opened EarthCARE or file path.

required
input_ground str | Dataset

A opened ground-based NetCDF dataset or file path (e.g., PollyNET data).

None
time_var_ground str

The name of the time variable in the ground-based dataset (e.g., for single profile PollyNET data use "start_time"). Defaults to "height".

'time'
height_var_ground str

The name of the height variable in the ground-based dataset. Defaults to "height".

'height'
bsc_var_ground str | tuple | list[str | tuple]

Backscatter variable name in the ground-based dataset. Multiple variables can be provided as list. Variable errors can be provided as tuples (e.g., [("bsc", "bsc_err"), ("bsc2", "bsc2_err"), ...]). Defaults to empty list.

[]
ext_var_ground str | tuple | list[str | tuple]

Extinction variable name in the ground-based dataset. Multiple variables can be provided as list. Variable errors can be provided as tuples (e.g., [("ext", "ext_err"), ("ext2", "ext2_err"), ...]). Defaults to empty list.

[]
lr_var_ground str | tuple | list[str | tuple]

Lidar ratio variable name in the ground-based dataset. Multiple variables can be provided as list. Variable errors can be provided as tuples (e.g., [("lr", "lr_err"), ("lr2", "lr2_err"), ...]). Defaults to empty list.

[]
depol_var_ground str | tuple | list[str | tuple]

Depol. ratio variable name in the ground-based dataset. Multiple variables can be provided as list. Variable errors can be provided as tuples (e.g., [("depol", "depol_err"), ("depol2", "depol2_err"), ...]). Defaults to empty list.

[]
input_ec2 str | Dataset

An optional seconds EarthCARE dataset to compare. Defaults to None.

None
input_ec3 str | Dataset

An optional third EarthCARE dataset to compare. Defaults to None.

None
site GroundSite | str | None

Ground site or location of the ground-based data as a GroundSite object or by name string (e.g., "mindelo"). Defaults to None.

None
radius_km float

Radius around the ground site. Defaults to 100.0.

100.0
resolution str

Sets the used resolution of the EarthCARE data if applicable (e.g., for A-EBD). Defaults to "_low_resolution".

'_low_resolution'
height_range tuple[float, float] | None

Height range in meters to restrict the data for plotting. Defaults to (0, 30e3).

(0, 30000.0)
selection_height_range tuple[float, float] | None

Height range in meters to select data for statistsics. Defaults to None.

None
selection_height_range_bsc tuple[float, float] | None

Height range in meters to select bsc. data for statistsics. Defaults to None (i.e., selection_height_range).

None
selection_height_range_ext tuple[float, float] | None

Height range in meters to select ext. data for statistsics. Defaults to None (i.e., selection_height_range).

None
selection_height_range_lr tuple[float, float] | None

Height range in meters to select LR data for statistsics. Defaults to None (i.e., selection_height_range).

None
selection_height_range_depol tuple[float, float] | None

Height range in meters to select depol. data for statistsics. Defaults to None (i.e., selection_height_range).

None
value_range_bsc ValueRangeLike | None

Tuple setting minimum and maximum value on x-axis. Defaults to (0, 8e-6).

(0, 8e-06)
value_range_ext ValueRangeLike | None

Tuple setting minimum and maximum value on x-axis. Defaults to (0, 3e-4).

(0, 0.0003)
value_range_lr ValueRangeLike | None

Tuple setting minimum and maximum value on x-axis. Defaults to (0, 100).

(0, 100)
value_range_depol ValueRangeLike | None

Tuple setting minimum and maximum value on x-axis. Defaults to (0, 0.6).

(0, 0.6)
colors_ec list[str]

List of colors for the EarthCARE profiles.

['ec:red', 'ec:orange', 'ec:yellow', 'ec:purple']
colors_ground list[str]

List of colors for the ground-based profiles.

['ec:blue', 'ec:darkblue', 'ec:lightgreen', 'ec:darkgreen', 'ec:purple']
linewidth_ec Number | list[Number]

Value or list of line width for the EarthCARE profiles. Defaults to 1.5.

1.5
linewidth_ground Number | list[Number]

Value or list of line width for the ground-based profiles. Defaults to 1.5.

1.5
linestyle_ec Number | list[Number]

Value or list of line style for the EarthCARE profiles. Defaults to "solid".

'solid'
linestyle_ground Number | list[Number]

Value or list of line style for the ground-based profiles. Defaults to "solid".

'solid'
label_ec list[str]

List of legend labels for the EarthCARE profiles.

[]
label_ground list[str]

List of legend labels for the ground-based profiles.

[]
alpha float

Transparency value for the profile lines (value between 0 and 1). Defaults to 1.0.

1.0
show_steps bool

If True, profiles will be plotted as step functions instead of bin centers.

DEFAULT_PROFILE_SHOW_STEPS
show_error_ec bool

If True, plot error ribbons for EarthCARE profiles.

False
show_rebinned bool

If True, ground-based profiles will be plotted rebinnned to the first EarthCARE profile. Defaults to False.

False
to_mega bool

If Ture, converts bsc. and ext. data results (i.e., plot and statistics) to [Mm-1 sr-1] and [Mm-1]. Defaults to False.

False
single_figsize tuple[float, float]

2-element tuple setting width and height of the subfigures (i.e., for each profile plot).

(5 * CM_AS_INCH, 12 * CM_AS_INCH)
label_bsc str

Label displayed on the backscatter sub-figure. Defaults to "Bsc. coeff.".

'Bsc. coeff.'
label_ext str

Label displayed on the extinction sub-figure. Defaults to "Ext. coeff.".

'Ext. coeff.'
label_lr str

Label displayed on the lidar ratio sub-figure. Defaults to "Lidar ratio".

'Lidar ratio'
label_depol str

Label displayed on the depol sub-figure. Defaults to "Depol. ratio".

'Depol. ratio'
units_bsc str

Units displayed on the backscatter sub-figure. Defaults to "m$^{-1}$ sr$^{-1}$".

'm$^{-1}$ sr$^{-1}$'
units_ext str

Units displayed on the extinction sub-figure. Defaults to "m$^{-1}$".

'm$^{-1}$'
units_lr str

Units displayed on the lidar ratio sub-figure. Defaults to "sr".

'sr'
units_depol str

Units displayed on the depol sub-figure. Defaults to "".

''
verbose bool

Whether logs about processing steps appear in the console. Defaults to True.

True

Returns:

Name Type Description
results _CompareBscExtLRDepolResults

An object containing the plot and statistical results. - results.fig: The matplotlib figure - results.fig_bsc: Backscatter subfigure as ProfileFigure - results.fig_ext: Extinction subfigure as ProfileFigure - results.fig_lr: Lidar ratio subfigure as ProfileFigure - results.fig_depol: Depol. ratio subfigure as ProfileFigure - results.stats: Statistical results as a pandas.DataFrame

Source code in earthcarekit/calval/_compare_bsc_ext_lr_depol.py
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
def compare_bsc_ext_lr_depol(
    input_ec: str | xr.Dataset,
    input_ground: str | xr.Dataset | None = None,
    time_var_ground: str = "time",
    height_var_ground: str = "height",
    bsc_var_ground: str | tuple[str, str] | list[str | tuple[str, str]] = [],
    ext_var_ground: str | tuple[str, str] | list[str | tuple[str, str]] = [],
    lr_var_ground: str | tuple[str, str] | list[str | tuple[str, str]] = [],
    depol_var_ground: str | tuple[str, str] | list[str | tuple[str, str]] = [],
    input_ec2: str | xr.Dataset | None = None,
    input_ec3: str | xr.Dataset | None = None,
    input_ec4: str | xr.Dataset | None = None,
    input_ground2: str | xr.Dataset | None = None,
    input_ground3: str | xr.Dataset | None = None,
    input_ground4: str | xr.Dataset | None = None,
    time_var_ground2: str | None = None,
    height_var_ground2: str | None = None,
    time_var_ground3: str | None = None,
    height_var_ground3: str | None = None,
    time_var_ground4: str | None = None,
    height_var_ground4: str | None = None,
    bsc_var_ground2: str | tuple[str, str] | list[str | tuple[str, str]] | None = None,
    ext_var_ground2: str | tuple[str, str] | list[str | tuple[str, str]] | None = None,
    lr_var_ground2: str | tuple[str, str] | list[str | tuple[str, str]] | None = None,
    depol_var_ground2: (
        str | tuple[str, str] | list[str | tuple[str, str]] | None
    ) = None,
    bsc_var_ground3: str | tuple[str, str] | list[str | tuple[str, str]] | None = None,
    ext_var_ground3: str | tuple[str, str] | list[str | tuple[str, str]] | None = None,
    lr_var_ground3: str | tuple[str, str] | list[str | tuple[str, str]] | None = None,
    depol_var_ground3: (
        str | tuple[str, str] | list[str | tuple[str, str]] | None
    ) = None,
    bsc_var_ground4: str | tuple[str, str] | list[str | tuple[str, str]] | None = None,
    ext_var_ground4: str | tuple[str, str] | list[str | tuple[str, str]] | None = None,
    lr_var_ground4: str | tuple[str, str] | list[str | tuple[str, str]] | None = None,
    depol_var_ground4: (
        str | tuple[str, str] | list[str | tuple[str, str]] | None
    ) = None,
    site: GroundSite | str | None = None,
    radius_km: float = 100.0,
    resolution: str = "_low_resolution",
    resolution2: str | None = None,
    resolution3: str | None = None,
    resolution4: str | None = None,
    height_range: tuple[float, float] | None = (0, 30e3),
    selection_height_range: tuple[float, float] | None = None,
    selection_height_range_bsc: tuple[float, float] | None = None,
    selection_height_range_ext: tuple[float, float] | None = None,
    selection_height_range_lr: tuple[float, float] | None = None,
    selection_height_range_depol: tuple[float, float] | None = None,
    value_range_bsc: ValueRangeLike | None = (0, 8e-6),
    value_range_ext: ValueRangeLike | None = (0, 3e-4),
    value_range_lr: ValueRangeLike | None = (0, 100),
    value_range_depol: ValueRangeLike | None = (0, 0.6),
    colors_ec: list[str] = [
        "ec:red",
        "ec:orange",
        "ec:yellow",
        "ec:purple",
    ],
    colors_ground: list[str] = [
        "ec:blue",
        "ec:darkblue",
        "ec:lightgreen",
        "ec:darkgreen",
        "ec:purple",
    ],
    linewidth_ec: list[float | int] | float | int = 1.5,
    linewidth_ground: list[float | int] | float | int = 1.5,
    linestyle_ec: list[str] | str = "solid",
    linestyle_ground: list[str] | str = "solid",
    label_ec: list[str | None] = [],
    label_ground: list[str | None] = [],
    alpha: float = 1.0,
    show_steps: bool = DEFAULT_PROFILE_SHOW_STEPS,
    show_error_ec: bool = False,
    show_quality_status: bool = False,
    show_rebinned: bool = False,
    quality_status_width_scale: float = 1.0,
    quality_status_var: str = "quality_status",
    quality_status_value_range: tuple[float | None, float | None] | None = None,
    to_mega: bool = False,
    single_figsize: tuple[float | int, float | int] = (5 * CM_AS_INCH, 12 * CM_AS_INCH),
    label_bsc: str = "Bsc. coeff.",
    label_ext: str = "Ext. coeff.",
    label_lr: str = "Lidar ratio",
    label_depol: str = "Depol. ratio",
    units_bsc: str = "m$^{-1}$ sr$^{-1}$",
    units_ext: str = "m$^{-1}$",
    units_lr: str = "sr",
    units_depol: str = "",
    verbose: bool = True,
) -> _CompareBscExtLRDepolResults:
    """Compares Lidar profiles from up to 3 EarthCARE source dataset an one ground-based dataset by creating plots and statistics dataframe.

    Args:
        input_ec (str | xr.Dataset): A opened EarthCARE or file path.
        input_ground (str | xr.Dataset, optional): A opened ground-based NetCDF dataset or file path (e.g., PollyNET data).
        time_var_ground (str, optional): The name of the time variable in the ground-based dataset (e.g., for single profile PollyNET data use `"start_time"`). Defaults to `"height"`.
        height_var_ground (str, optional): The name of the height variable in the ground-based dataset. Defaults to `"height"`.
        bsc_var_ground (str | tuple | list[str | tuple], optional): Backscatter variable name in the ground-based dataset.
            Multiple variables can be provided as list. Variable errors can be provided as tuples (e.g., `[("bsc", "bsc_err"), ("bsc2", "bsc2_err"), ...]`). Defaults to empty list.
        ext_var_ground (str | tuple | list[str | tuple], optional): Extinction variable name in the ground-based dataset.
            Multiple variables can be provided as list. Variable errors can be provided as tuples (e.g., `[("ext", "ext_err"), ("ext2", "ext2_err"), ...]`). Defaults to empty list.
        lr_var_ground (str | tuple | list[str | tuple], optional): Lidar ratio variable name in the ground-based dataset.
            Multiple variables can be provided as list. Variable errors can be provided as tuples (e.g., `[("lr", "lr_err"), ("lr2", "lr2_err"), ...]`). Defaults to empty list.
        depol_var_ground (str | tuple | list[str | tuple], optional): Depol. ratio variable name in the ground-based dataset.
            Multiple variables can be provided as list. Variable errors can be provided as tuples (e.g., `[("depol", "depol_err"), ("depol2", "depol2_err"), ...]`). Defaults to empty list.
        input_ec2 (str | xr.Dataset, optional): An optional seconds EarthCARE dataset to compare. Defaults to None.
        input_ec3 (str | xr.Dataset, optional): An optional third EarthCARE dataset to compare. Defaults to None.
        site (GroundSite | str | None, optional): Ground site or location of the ground-based data as a `GroundSite` object or by name string (e.g., `"mindelo"`). Defaults to None.
        radius_km (float, optional): Radius around the ground site. Defaults to 100.0.
        resolution (str, optional): Sets the used resolution of the EarthCARE data if applicable (e.g., for A-EBD). Defaults to "_low_resolution".
        height_range (tuple[float, float] | None, optional): Height range in meters to restrict the data for plotting. Defaults to (0, 30e3).
        selection_height_range (tuple[float, float] | None, optional): Height range in meters to select data for statistsics. Defaults to None.
        selection_height_range_bsc (tuple[float, float] | None, optional): Height range in meters to select bsc. data for statistsics. Defaults to None (i.e., `selection_height_range`).
        selection_height_range_ext (tuple[float, float] | None, optional): Height range in meters to select ext. data for statistsics. Defaults to None (i.e., `selection_height_range`).
        selection_height_range_lr (tuple[float, float] | None, optional): Height range in meters to select LR data for statistsics. Defaults to None (i.e., `selection_height_range`).
        selection_height_range_depol (tuple[float, float] | None, optional): Height range in meters to select depol. data for statistsics. Defaults to None (i.e., `selection_height_range`).
        value_range_bsc (ValueRangeLike | None, optional): Tuple setting minimum and maximum value on x-axis. Defaults to (0, 8e-6).
        value_range_ext (ValueRangeLike | None, optional): Tuple setting minimum and maximum value on x-axis. Defaults to (0, 3e-4).
        value_range_lr (ValueRangeLike | None, optional): Tuple setting minimum and maximum value on x-axis. Defaults to (0, 100).
        value_range_depol (ValueRangeLike | None, optional): Tuple setting minimum and maximum value on x-axis. Defaults to (0, 0.6).
        colors_ec (list[str], optional): List of colors for the EarthCARE profiles.
        colors_ground (list[str], optional): List of colors for the ground-based profiles.
        linewidth_ec (Number | list[Number], optional): Value or list of line width for the EarthCARE profiles. Defaults to 1.5.
        linewidth_ground (Number | list[Number], optional): Value or list of line width for the ground-based profiles. Defaults to 1.5.
        linestyle_ec (Number | list[Number], optional): Value or list of line style for the EarthCARE profiles. Defaults to "solid".
        linestyle_ground (Number | list[Number], optional): Value or list of line style for the ground-based profiles. Defaults to "solid".
        label_ec (list[str], optional): List of legend labels for the EarthCARE profiles.
        label_ground (list[str], optional): List of legend labels for the ground-based profiles.
        alpha (float, optional): Transparency value for the profile lines (value between 0 and 1). Defaults to 1.0.
        show_steps (bool, optional): If True, profiles will be plotted as step functions instead of bin centers.
        show_error_ec (bool, optional): If True, plot error ribbons for EarthCARE profiles.
        show_rebinned (bool, optional): If True, ground-based profiles will be plotted rebinnned to the first EarthCARE profile. Defaults to False.
        to_mega (bool, optional): If Ture, converts bsc. and ext. data results (i.e., plot and statistics) to [Mm-1 sr-1] and [Mm-1]. Defaults to False.
        single_figsize (tuple[float, float], optional): 2-element tuple setting width and height of the subfigures (i.e., for each profile plot).
        label_bsc (str, optional): Label displayed on the backscatter sub-figure. Defaults to "Bsc. coeff.".
        label_ext (str, optional): Label displayed on the extinction sub-figure. Defaults to "Ext. coeff.".
        label_lr (str, optional): Label displayed on the lidar ratio sub-figure. Defaults to "Lidar ratio".
        label_depol (str, optional): Label displayed on the depol sub-figure. Defaults to "Depol. ratio".
        units_bsc (str, optional): Units displayed on the backscatter sub-figure. Defaults to "m$^{-1}$ sr$^{-1}$".
        units_ext (str, optional): Units displayed on the extinction sub-figure. Defaults to "m$^{-1}$".
        units_lr (str, optional): Units displayed on the lidar ratio sub-figure. Defaults to "sr".
        units_depol (str, optional): Units displayed on the depol sub-figure. Defaults to "".
        verbose (bool, optional): Whether logs about processing steps appear in the console. Defaults to True.

    Returns:
        results (_CompareBscExtLRDepolResults): An object containing the plot and statistical results.
            - `results.fig`: The `matplotlib` figure
            - `results.fig_bsc`: Backscatter subfigure as `ProfileFigure`
            - `results.fig_ext`: Extinction subfigure as `ProfileFigure`
            - `results.fig_lr`: Lidar ratio subfigure as `ProfileFigure`
            - `results.fig_depol`: Depol. ratio subfigure as `ProfileFigure`
            - `results.stats`: Statistical results as a `pandas.DataFrame`
    """
    _logger = logging.getLogger()
    ctx = nullcontext() if verbose else silence_logger(_logger)
    with ctx:
        _vars_main: list[str | tuple[str, str]] = _get_ec_vars(
            input_ec,
            resolution,
            show_error=show_error_ec,
        )
        _closest: bool = _get_ec_is_closest(input_ec)

        label = [
            label_bsc,
            label_ext,
            label_lr,
            label_depol,
        ]

        units = [
            units_bsc,
            units_ext,
            units_lr,
            units_depol,
        ]

        if not isinstance(resolution2, str):
            resolution2 = resolution
        if not isinstance(resolution3, str):
            resolution3 = resolution
        if not isinstance(resolution4, str):
            resolution4 = resolution

        if not isinstance(time_var_ground2, str):
            time_var_ground2 = time_var_ground
        if not isinstance(time_var_ground3, str):
            time_var_ground3 = time_var_ground
        if not isinstance(time_var_ground4, str):
            time_var_ground4 = time_var_ground

        if not isinstance(height_var_ground2, str):
            height_var_ground2 = height_var_ground
        if not isinstance(height_var_ground3, str):
            height_var_ground3 = height_var_ground
        if not isinstance(height_var_ground4, str):
            height_var_ground4 = height_var_ground

        if bsc_var_ground2 is None:
            bsc_var_ground2 = bsc_var_ground
        if bsc_var_ground3 is None:
            bsc_var_ground3 = bsc_var_ground
        if bsc_var_ground4 is None:
            bsc_var_ground4 = bsc_var_ground

        if ext_var_ground2 is None:
            ext_var_ground2 = ext_var_ground
        if ext_var_ground3 is None:
            ext_var_ground3 = ext_var_ground
        if ext_var_ground4 is None:
            ext_var_ground4 = ext_var_ground

        if lr_var_ground2 is None:
            lr_var_ground2 = lr_var_ground
        if lr_var_ground3 is None:
            lr_var_ground3 = lr_var_ground
        if lr_var_ground4 is None:
            lr_var_ground4 = lr_var_ground

        if depol_var_ground2 is None:
            depol_var_ground2 = depol_var_ground
        if depol_var_ground3 is None:
            depol_var_ground3 = depol_var_ground
        if depol_var_ground4 is None:
            depol_var_ground4 = depol_var_ground

        _vars_main2: list[str | tuple[str, str]] | None = None
        _closest2: bool | None = None
        if input_ec2 is not None:
            _vars_main2 = _get_ec_vars(
                input_ec2,
                resolution2,
                show_error=show_error_ec,
            )
            _closest2 = _get_ec_is_closest(input_ec2)

        _vars_main3: list[str | tuple[str, str]] | None = None
        _closest3: bool | None = None
        if input_ec3 is not None:
            _vars_main3 = _get_ec_vars(
                input_ec3,
                resolution3,
                show_error=show_error_ec,
            )
            _closest3 = _get_ec_is_closest(input_ec3)

        _vars_main4: list[str | tuple[str, str]] | None = None
        _closest4: bool | None = None
        if input_ec4 is not None:
            _vars_main4 = _get_ec_vars(
                input_ec4,
                resolution4,
                show_error=show_error_ec,
            )
            _closest4 = _get_ec_is_closest(input_ec4)

        with (
            read_product(input_ec) as ds_ec,
            nullcontext(
                None if input_ec2 is None else read_product(input_ec2)
            ) as ds_ec2,
            nullcontext(
                None if input_ec3 is None else read_product(input_ec3)
            ) as ds_ec3,
            nullcontext(
                None if input_ec4 is None else read_product(input_ec4)
            ) as ds_ec4,
            nullcontext(
                None if input_ground is None else read_any(input_ground)
            ) as ds_target,
            nullcontext(
                None if input_ground2 is None else read_any(input_ground2)
            ) as ds_target2,
            nullcontext(
                None if input_ground3 is None else read_any(input_ground3)
            ) as ds_target3,
            nullcontext(
                None if input_ground4 is None else read_any(input_ground4)
            ) as ds_target4,
        ):
            ncols: int = 4
            width_scale: float | list[float] = 1.0
            if show_quality_status:
                ncols = 5
                width_scale = [1.0, 1.0, 1.0, 1.0, quality_status_width_scale]
            _output = create_column_figure_layout(
                ncols=ncols,
                single_figsize=single_figsize,
                margin=0.6,
                width_scale=width_scale,
            )
            fig = _output.fig
            axs = _output.axs

            vars_target: list[str | tuple[str, str] | list[str | tuple[str, str]]] = [
                bsc_var_ground,
                ext_var_ground,
                lr_var_ground,
                depol_var_ground,
            ]

            vars_target2: list[str | tuple[str, str] | list[str | tuple[str, str]]] = [
                bsc_var_ground2,
                ext_var_ground2,
                lr_var_ground2,
                depol_var_ground2,
            ]

            vars_target3: list[str | tuple[str, str] | list[str | tuple[str, str]]] = [
                bsc_var_ground3,
                ext_var_ground3,
                lr_var_ground3,
                depol_var_ground3,
            ]

            vars_target4: list[str | tuple[str, str] | list[str | tuple[str, str]]] = [
                bsc_var_ground4,
                ext_var_ground4,
                lr_var_ground4,
                depol_var_ground4,
            ]

            value_range: list = [
                value_range_bsc,
                value_range_ext,
                value_range_lr,
                value_range_depol,
            ]

            if selection_height_range_bsc is None:
                selection_height_range_bsc = selection_height_range
            if selection_height_range_ext is None:
                selection_height_range_ext = selection_height_range
            if selection_height_range_lr is None:
                selection_height_range_lr = selection_height_range
            if selection_height_range_depol is None:
                selection_height_range_depol = selection_height_range

            _selection_height_range = [
                selection_height_range_bsc,
                selection_height_range_ext,
                selection_height_range_lr,
                selection_height_range_depol,
            ]

            pfs: list[ProfileFigure] = []
            dfs: list[pd.DataFrame] = []
            for i in range(len(_vars_main)):
                _flip_height_axis: bool = False
                _show_height_ticks: bool = True
                _show_height_label: bool = False

                if i == 0:
                    _show_height_label = True
                    _show_height_ticks = True
                _pf, _df = compare_ec_profiles_with_target(
                    ds_ec=ds_ec,
                    ds_ec2=ds_ec2,
                    ds_ec3=ds_ec3,
                    ds_ec4=ds_ec4,
                    ds_target=ds_target,
                    ds_target2=ds_target2,
                    ds_target3=ds_target3,
                    ds_target4=ds_target4,
                    var_ec=_vars_main[i],
                    var_ec2=None if _vars_main2 is None else _vars_main2[i],
                    var_ec3=None if _vars_main3 is None else _vars_main3[i],
                    var_ec4=None if _vars_main4 is None else _vars_main4[i],
                    var_target=vars_target[i],
                    var_target2=vars_target2[i],
                    var_target3=vars_target3[i],
                    var_target4=vars_target4[i],
                    selection_height_range=_selection_height_range[i],
                    height_range=height_range,
                    site=site,
                    radius_km=radius_km,
                    closest=_closest,
                    closest2=False if _closest2 is None else _closest2,
                    closest3=False if _closest3 is None else _closest3,
                    closest4=False if _closest4 is None else _closest4,
                    time_var_target=time_var_ground,
                    height_var_target=height_var_ground,
                    time_var_target2=time_var_ground2,
                    height_var_target2=height_var_ground2,
                    time_var_target3=time_var_ground3,
                    height_var_target3=height_var_ground3,
                    time_var_target4=time_var_ground4,
                    height_var_target4=height_var_ground4,
                    ax=axs[i],
                    label=label[i],
                    units=units[i],
                    value_range=value_range[i],
                    flip_height_axis=_flip_height_axis,
                    show_height_ticks=_show_height_ticks,
                    show_height_label=_show_height_label,
                    colors_ec=colors_ec,
                    colors_ground=colors_ground,
                    linewidth_ec=linewidth_ec,
                    linewidth_ground=linewidth_ground,
                    linestyle_ec=linestyle_ec,
                    linestyle_ground=linestyle_ground,
                    label_ec=label_ec,
                    label_ground=label_ground,
                    alpha=alpha,
                    show_steps=show_steps,
                    show_rebinned=show_rebinned,
                    to_mega=False if i > 1 else to_mega,
                    single_figsize=single_figsize,
                )
                pfs.append(_pf)
                dfs.append(_df)
            df = pd.concat(dfs, ignore_index=True)

            # Optional: plot quality status
            if show_quality_status:
                _dss: list = []
                _var: str = quality_status_var
                _ps_qs: list[ProfileData] = []
                for _ds in [ds_ec, ds_ec2, ds_ec3, ds_ec4]:
                    if (
                        isinstance(_ds, xr.Dataset)
                        and not any([_ds.equals(x) for x in _dss])
                        and _var in _ds
                    ):
                        p_qs = _extract_earthcare_profile(
                            ds=_ds,
                            var=_var,
                            site=site,
                            radius_km=radius_km,
                            closest=True,
                        )
                        p_qs.platform = (
                            "EC"
                            if p_qs.platform is None
                            else (
                                p_qs.platform.replace("res.", "")
                                .replace("low", "")
                                .replace("medium", "")
                                .replace("high", "")
                                .strip()
                            )
                        )
                        _ps_qs.append(p_qs)
                        _dss.append(_ds)
                vrange = quality_status_value_range
                if vrange is None and _var == "quality_status":
                    vrange = (-0.2, 4.2)
                _plot_profiles(
                    _ps_qs,
                    ax=axs[-1],
                    selection_height_range=selection_height_range,
                    height_range=height_range,
                    value_range=vrange,
                    flip_height_axis=False,
                    show_height_ticks=True,
                    show_height_label=False,
                    colors_ec=colors_ec,
                    linewidth_ec=linewidth_ec,
                    linestyle_ec=linestyle_ec,
                    label_ec=label_ec,
                    alpha=alpha,
                    show_steps=show_steps,
                    figsize=single_figsize,
                )

    return _CompareBscExtLRDepolResults(
        fig=fig,
        fig_bsc=pfs[0],
        fig_ext=pfs[1],
        fig_lr=pfs[2],
        fig_depol=pfs[3],
        stats=df,
    )

create_column_figure_layout

create_column_figure_layout(
    ncols, single_figsize=(3, 8), margin=0.0, height_scale=1.0, width_scale=1.0
)

Creates a figure with multiple subfigures arranged as columns in a single row, each containing one Axes.

Parameters:

Name Type Description Default
ncols int

Number of subfigures (columns) to create.

required
single_figsize tuple[float, float]

Size (width, height) of each individual subfigure. Defaults to (3, 8).

(3, 8)

Returns:

Type Description
FigureLayoutColumns

tuple[Figure, list[Axes]]: The parent figure and a list of Axes objects, one for each subfigure.

Source code in earthcarekit/plot/figure/multi_panel/simple_columns.py
def create_column_figure_layout(
    ncols: int,
    single_figsize: tuple[float, float] = (3, 8),
    margin: float = 0.0,
    height_scale: float = 1.0,
    width_scale: float | list[float] = 1.0,
) -> FigureLayoutColumns:
    """
    Creates a figure with multiple subfigures arranged as columns in a single row, each containing one Axes.

    Args:
        ncols (int): Number of subfigures (columns) to create.
        single_figsize (tuple[float, float], optional): Size (width, height) of each individual subfigure.
            Defaults to (3, 8).

    Returns:
        tuple[Figure, list[Axes]]: The parent figure and a list of Axes objects, one for each subfigure.
    """
    if not isinstance(width_scale, list):
        width_scale = [width_scale] * ncols

    if not isinstance(width_scale, list) or len(width_scale) != ncols:
        raise ValueError(
            f"length of list width_scale ({len(width_scale)}) must match 'ncols' ({ncols}) or be scalar float"
        )

    fig: Figure = plt.figure(
        figsize=(
            np.sum(single_figsize[0] * np.array(width_scale)) + (ncols - 1) * margin,
            single_figsize[1] * height_scale,
        )
    )
    figs: np.ndarray
    if ncols == 1:
        figs = np.array([fig])
    else:
        width_ratios = [single_figsize[0]]
        for i in range(ncols - 1):
            width_ratios.extend([margin, single_figsize[0] * width_scale[i + 1]])

        figs = fig.subfigures(
            1,
            ncols + (ncols - 1),
            wspace=0.0,
            hspace=0.0,
            width_ratios=width_ratios,
        )
    axs: list[Axes] = [
        f.add_subplot([0, 0, 1, 1]) for i, f in enumerate(figs) if i % 2 == 0
    ]

    return FigureLayoutColumns(fig=fig, axs=axs)

create_multi_figure_layout

create_multi_figure_layout(
    rows,
    zoom_rows=None,
    profile_rows=None,
    map_rows=None,
    wspace=1.2,
    hspace=1.2,
    wmain=FIGURE_WIDTH_CURTAIN,
    hrow=FIGURE_HEIGHT_CURTAIN,
    hswath=FIGURE_HEIGHT_SWATH,
    hline=FIGURE_HEIGHT_LINE,
    wprofile=FIGURE_WIDTH_PROFILE,
    wmap=FIGURE_MAP_WIDTH,
    wzoom=FIGURE_WIDTH_CURTAIN / 3.0,
)

Creates a complex figure layout with columns for map, main, zoom, and profile panels (in that order from left to right).

Each panel column can have a custom sequence of figure types (e.g., row heights), and the layout supports both uniform and per-gap horizontal/vertical spacing.

Parameters:

Name Type Description Default
main_rows Sequence[FigureType | int]

List of figure types for the rows of the main column.

required
zoom_rows Sequence[FigureType | int]

List of figure types for the rows in the optional zoom column.

None
profile_rows Sequence[FigureType | int]

List of figure types for the rows in the optional profile column.

None
map_rows Sequence[FigureType | int]

List of figure types for the rows in the optional map column.

None
wspace float | Sequence[float]

Horizontal spacing between columns. Can be a single value or list defining spacing before, between, and after columns.

1.2
hspace float | Sequence[float]

Vertical spacing between rows. Similar behavior as wspace.

1.2
wmain float

Width of the main column. Default is FIGURE_WIDTH_CURTAIN.

FIGURE_WIDTH_CURTAIN
hrow float

Height of a standard row. Default is FIGURE_HEIGHT_CURTAIN.

FIGURE_HEIGHT_CURTAIN
hswath float

Height of a SwathFigure-type row. Default is FIGURE_HEIGHT_SWATH.

FIGURE_HEIGHT_SWATH
wprofile float

Width of the profile column.

FIGURE_WIDTH_PROFILE
wmap float

Width of the map column.

FIGURE_MAP_WIDTH
wzoom float

Width of the zoom column.

FIGURE_WIDTH_CURTAIN / 3.0

Returns:

Name Type Description
tuple FigureLayoutMapMainZoomProfile

A tuple containing: - Figure: The matplotlib figure object. - Sequence[Axes]: Axes for map panels (may be empty). - Sequence[Axes]: Axes for main panels. - Sequence[Axes]: Axes for zoom panels (may be empty). - Sequence[Axes]: Axes for profile panels (may be empty).

Raises:

Type Description
ValueError

If the provided spacing sequences are of invalid length.

TypeError

If spacing arguments are of unsupported types.

Source code in earthcarekit/plot/figure/multi_panel/map_main_zoom_profile_figure.py
def create_multi_figure_layout(
    rows: Sequence[FigureType | int],
    zoom_rows: Sequence[FigureType | int] | None = None,
    profile_rows: Sequence[FigureType | int] | None = None,
    map_rows: Sequence[FigureType | int] | None = None,
    wspace: float | Sequence[float] = 1.2,
    hspace: float | Sequence[float] = 1.2,
    wmain: float = FIGURE_WIDTH_CURTAIN,
    hrow: float = FIGURE_HEIGHT_CURTAIN,
    hswath: float = FIGURE_HEIGHT_SWATH,
    hline: float = FIGURE_HEIGHT_LINE,
    wprofile: float = FIGURE_WIDTH_PROFILE,
    wmap: float = FIGURE_MAP_WIDTH,
    wzoom: float = FIGURE_WIDTH_CURTAIN / 3.0,
) -> FigureLayoutMapMainZoomProfile:
    """
    Creates a complex figure layout with columns for map, main, zoom, and profile panels (in that order from left to right).

    Each panel column can have a custom sequence of figure types (e.g., row heights), and the layout
    supports both uniform and per-gap horizontal/vertical spacing.

    Args:
        main_rows (Sequence[FigureType | int]): List of figure types for the rows of the main column.
        zoom_rows (Sequence[FigureType | int], optional): List of figure types for the rows in the optional zoom column.
        profile_rows (Sequence[FigureType | int], optional): List of figure types for the rows in the optional profile column.
        map_rows (Sequence[FigureType | int], optional): List of figure types for the rows in the optional map column.
        wspace (float | Sequence[float], optional): Horizontal spacing between columns. Can be a single value
            or list defining spacing before, between, and after columns.
        hspace (float | Sequence[float], optional): Vertical spacing between rows. Similar behavior as `wspace`.
        wmain (float, optional): Width of the main column. Default is `FIGURE_WIDTH_CURTAIN`.
        hrow (float, optional): Height of a standard row. Default is `FIGURE_HEIGHT_CURTAIN`.
        hswath (float, optional): Height of a `SwathFigure`-type row. Default is `FIGURE_HEIGHT_SWATH`.
        wprofile (float, optional): Width of the profile column.
        wmap (float, optional): Width of the map column.
        wzoom (float, optional): Width of the zoom column.

    Returns:
        tuple: A tuple containing:
            - Figure: The matplotlib figure object.
            - Sequence[Axes]: Axes for map panels (may be empty).
            - Sequence[Axes]: Axes for main panels.
            - Sequence[Axes]: Axes for zoom panels (may be empty).
            - Sequence[Axes]: Axes for profile panels (may be empty).

    Raises:
        ValueError: If the provided spacing sequences are of invalid length.
        TypeError: If spacing arguments are of unsupported types.
    """
    # Calculate number of columns
    is_map_col: bool = isinstance(map_rows, list) and len(map_rows) > 0
    is_main_col: bool = isinstance(rows, list) and len(rows) > 0
    is_zoom_col: bool = isinstance(zoom_rows, list) and len(zoom_rows) > 0
    is_profile_col: bool = isinstance(profile_rows, list) and len(profile_rows) > 0
    col_present: list[bool] = [is_map_col, is_main_col, is_zoom_col, is_profile_col]

    ncols: int = sum(col_present)

    # Calculate number of rows
    nrows_min: int = 0
    if isinstance(map_rows, list):
        for ft in map_rows:
            nrows_min += 1
            if ft == FigureType.MAP_2_ROW:
                nrows_min += 1

    nrows: int = max(nrows_min, len(rows))

    # Calulate spaces between figures
    def _calulate_spaces(
        space: float | Sequence[float],
        n: int,
        name: str,
        name_col_row: str,
    ) -> list[float]:
        if isinstance(space, Sequence):
            space = list(space)
            if len(space) < n - 1 or len(space) > n + 1:
                raise ValueError(
                    f"{name} was given as a list (size={len(space)}) and thus needs to have a size between number of {name_col_row} ({n}) -1 (i.e. only spaces between {name_col_row}) and +1 (i.e. spaces before, between and after {name_col_row})."
                )
            elif len(space) == n - 1:
                space = [0.0] + space + [0.0]
            elif len(space) == n:
                space = space + [0.0]
        elif isinstance(space, float):
            space = [0.0] + [space] * (n - 1) + [0.0]
        else:
            raise TypeError(
                f"{name} has wrong type '{type(space).__name__}'. expected types: '{float.__name__}' or ''{list.__name__}'[{float.__name__}]'"
            )
        return space

    wspace = _calulate_spaces(wspace, ncols, "wspace", "columns")
    hspace = _calulate_spaces(hspace, nrows, "hspace", "rows")

    # Calculate size ratios of figures
    def _get_ratios(
        ratios_figs: list[float],
        space: list[float],
    ) -> list[float]:
        assert len(space) == len(ratios_figs) + 1

        ratios: list[float] = []
        for i, r in enumerate(ratios_figs):
            ratios.append(space[i])
            ratios.append(r)
        ratios.append(space[-1])

        return ratios

    wratios_figs: list[float] = np.array([wmap, wmain, wzoom, wprofile])[
        col_present
    ].tolist()
    hratios_figs: list[float] = []
    for fig_type in rows:
        if isinstance(fig_type, float):
            hratios_figs.append(fig_type)
        elif fig_type == FigureType.SWATH:
            hratios_figs.append(hswath)
        elif fig_type == FigureType.LINE:
            hratios_figs.append(hline)
        elif fig_type == FigureType.CURTAIN_75:
            hratios_figs.append(hrow * 0.75)
        elif fig_type == FigureType.CURTAIN_67:
            hratios_figs.append(hrow * 0.666666667)
        elif fig_type == FigureType.CURTAIN_50:
            hratios_figs.append(hrow * 0.50)
        elif fig_type == FigureType.CURTAIN_33:
            hratios_figs.append(hrow * 0.333333333)
        elif fig_type == FigureType.CURTAIN_25:
            hratios_figs.append(hrow * 0.25)
        else:
            hratios_figs.append(hrow)
    if len(rows) < nrows_min:
        for i in range(nrows_min - len(rows)):
            hratios_figs.append(hrow)

    wratios = _get_ratios(wratios_figs, wspace)
    hratios = _get_ratios(hratios_figs, hspace)

    # Create the figure
    wfig = sum(wratios)
    hfig = sum(hratios)
    figsize = (wfig, hfig)

    fig = plt.figure(figsize=figsize)

    # Create the grid layout
    gs = gridspec.GridSpec(
        nrows=len(hratios),
        ncols=len(wratios),
        width_ratios=wratios,
        height_ratios=hratios,
        figure=fig,
        wspace=0,
        hspace=0,
        bottom=0.0,
        top=1.0,
        right=1.0,
        left=0.0,
    )

    # Create the plots
    # Create maps
    current_col: int = 1
    current_row: int = 1
    axs_map: list[Axes] = []
    axs_main: list[Axes] = []
    axs_zoom: list[Axes] = []
    axs_profile: list[Axes] = []
    ax: Axes | None
    if isinstance(map_rows, list):
        for fig_type in map_rows:
            if fig_type == FigureType.MAP_2_ROW:
                ax = fig.add_subplot(gs[current_row : current_row + 3, current_col])
                current_row += 2
            elif fig_type == FigureType.NONE:
                ax = None
            else:
                ax = fig.add_subplot(gs[current_row, current_col])
            if isinstance(ax, Axes):
                axs_map.append(ax)
            current_row += 2
        current_col += 2
        current_row = 1

    if isinstance(rows, list):
        for fig_type in rows:
            if fig_type == FigureType.NONE:
                ax = None
            else:
                ax = fig.add_subplot(gs[current_row, current_col])
            if isinstance(ax, Axes):
                axs_main.append(ax)
            current_row += 2
        current_col += 2
        current_row = 1

    if isinstance(zoom_rows, list):
        for fig_type in zoom_rows:
            if fig_type == FigureType.NONE:
                ax = None
            else:
                ax = fig.add_subplot(gs[current_row, current_col])
            if isinstance(ax, Axes):
                axs_zoom.append(ax)
            current_row += 2
        current_col += 2
        current_row = 1

    if isinstance(profile_rows, list):
        for fig_type in profile_rows:
            if fig_type == FigureType.NONE:
                ax = None
            else:
                ax = fig.add_subplot(gs[current_row, current_col])
            if isinstance(ax, Axes):
                axs_profile.append(ax)
            current_row += 2
        current_col += 2
        current_row = 1

    return FigureLayoutMapMainZoomProfile(
        fig=fig,
        axs_map=axs_map,
        axs=axs_main,
        axs_zoom=axs_zoom,
        axs_profile=axs_profile,
    )

ecdownload

ecdownload(
    file_type,
    baseline=None,
    orbit_number=None,
    start_orbit_number=None,
    end_orbit_number=None,
    frame_id=None,
    orbit_and_frame=None,
    start_orbit_and_frame=None,
    end_orbit_and_frame=None,
    timestamps=None,
    start_time=None,
    end_time=None,
    radius_search=None,
    bounding_box=None,
    path_to_config=None,
    path_to_data=None,
    is_log=False,
    is_debug=False,
    is_download=True,
    is_overwrite=False,
    is_unzip=True,
    is_delete=True,
    is_create_subdirs=True,
    is_export_results=False,
    idx_selected_input=None,
    is_organize_data=False,
    is_include_header=None,
    is_reversed_order=False,
    return_results=False,
    verbose=True,
    check_product_availability=False,
)

EarthCARE Download Tool: Search for and download EarthCARE products from a ESA data distribution platform (OADS or MAAP).

The execution of this tool is divided into two parts:

Parameters:

Name Type Description Default
file_type str | list[str]

Name(s) of EarthCARE product(s) to search for (e.g., "ATL_NOM_1B", "ANOM", or "A-NOM"). Note: Input string evaluation is not case sensitive. Also, product version may also be selected by adding a colon and the two-letter processor baseline after the name (e.g., "ANOM:BA").

required
baseline str | None

Two-letter processor baseline used as default for all given file_types (e.g., "BA"). Note: A baseline specified in file_type with colon notation (e.g., "ANOM:BA") overwrites the default baseline. Defaults to None.

None
orbit_number int | list[int] | None

Specific orbit number(s) to search for (e.g., 981 or [1000, 5000, ...]). Defaults to None.

None
start_orbit_number int | None

The lower limit of orbit numbers to search for (e.g., 5000). Defaults to None.

None
end_orbit_number int | None

The upper limit of orbit numbers to search for (e.g., 5003). Defaults to None.

None
frame_id str | list[str] | None

Frame ID letter(s) to search for (i.e., letters A to H). Defaults to None.

None
orbit_and_frame str | list[str] | None

Orbit and frame string(s) to search for (e.g., "01234F" or ["1000A", "5000C", ...]). Defaults to None.

None
start_orbit_and_frame str | None

The lower limit of orbit and frames to search for (e.g., "05000D"). Defaults to None.

None
end_orbit_and_frame str | None

The upper limit of orbit and frames to search for (e.g., "05003C"). Defaults to None.

None
timestamps str | list[str] | None

Search for data containing specific timestamp(s) (e.g. "2024-07-31 13:45" or "20240731T134500Z"). Defaults to None.

None
start_time str | None

The lower time limit for the search. Defaults to None.

None
end_time str | None

The upper time limit for the search. Defaults to None.

None
radius_search tuple[RadiusMetersFloat, LatFloat, LonFloat] | list | None

A tuple containing a radius (meters) and a lat/lon point to perform a geo radius search (e.g., 25000 51.35 12.43, i.e., ). Latitudes must be provided as degrees north and longitudes as degrees east. Defaults to None.

None
bounding_box tuple[LatSFloat, LonWFloat, LatNFloat, LonEFloat] | list | None

A tuple containing the extent for a bounding box geo search (e.g., [14.9, 37.7, 14.99, 37.78], i.e., ). Latitudes must be provided as degrees north and longitudes as degrees east. Defaults to None.

None
path_to_config str | None

If provided, uses given config file instead of the default config. Defaults to None.

None
path_to_data str | None

If provided, downloads data to the given folder instead of the one defined in the config file. Defaults to None.

None
is_log bool

If True, creates a log file in a /log folder inside the current working directory. Defaults to False.

False
is_debug bool

If True, shows debug logs in the console. Defaults to False.

False
is_download bool

If False, skips download part, but still performs search requests via the data dissemination platform API. Defaults to True.

True
is_overwrite bool

If True, downloads and overwrites files that already exist in the data directory instead of skipping them. Defaults to False.

False
is_unzip bool

If False, skips file extraction for downloaded archives. Defaults to True.

True
is_delete bool

If True, deletes downloaded archives after extraction (i.e., does not delete non-extracted archives). Defaults to True.

True
is_create_subdirs bool

If True, places downloaded files in a sub-directory structure according to the template defined in the config file. Defaults to True.

True
is_export_results bool

If True, creates a text file in the current working directory listing all search results. Defaults to False.

False
idx_selected_input int | None

A number matching an index in the list of found files. If provided, only this single file will be downloaded. Defaults to None.

None
is_organize_data bool

If True, does not search or download any data. Defaults to False.

False
is_include_header bool | None

If True, the full archive is downloaded containing both HDF5 data file (.h5) and header data file (.HDR). If False, only the data file will be downloaded, speeding up the download time. Defaults to None.

Caution

This option only applies to MAAP. OADS will always download the full archive.

None
is_reversed_order bool

If True, downloads data products in reversed order (from the latest to the earliest). Defaults to False.

False
return_results bool

If True, returns the search results as a ProductDataFrame. Defaults to False.

False
verbose bool

If False, does not print logs to the console and does not create log file. Defaults to True.

True
check_product_availability bool

If True, sends extra request to the download backend checking the list of available products per data collection. If False, uses internally stored lists of available products, significantly reducing execution time (but might fail in case of backend changes). Defaults to False.

False

Returns:

Name Type Description
results ProductDataFrame | None

If return_results=False, the function has no return (i.e., None). If return_results=True, the function returns the search results.

Source code in earthcarekit/download/main.py
def ecdownload(
    file_type: str | list[str],
    baseline: str | None = None,
    orbit_number: int | list[int] | None = None,
    start_orbit_number: int | None = None,
    end_orbit_number: int | None = None,
    frame_id: str | list[str] | None = None,
    orbit_and_frame: str | list[str] | None = None,
    start_orbit_and_frame: str | None = None,
    end_orbit_and_frame: str | None = None,
    timestamps: str | list[str] | None = None,
    start_time: str | None = None,
    end_time: str | None = None,
    radius_search: tuple[RadiusMetersFloat, LatFloat, LonFloat] | list | None = None,
    bounding_box: (
        tuple[LatSFloat, LonWFloat, LatNFloat, LonEFloat] | list | None
    ) = None,
    path_to_config: str | None = None,
    path_to_data: str | None = None,
    is_log: bool = False,
    is_debug: bool = False,
    is_download: bool = True,
    is_overwrite: bool = False,
    is_unzip: bool = True,
    is_delete: bool = True,
    is_create_subdirs: bool = True,
    is_export_results: bool = False,
    idx_selected_input: int | None = None,
    is_organize_data: bool = False,
    is_include_header: bool | None = None,
    is_reversed_order: bool = False,
    return_results: bool = False,
    verbose: bool = True,
    check_product_availability: bool = False,
) -> ProductDataFrame | None:
    """
    EarthCARE Download Tool: Search for and download EarthCARE products from a ESA data distribution platform (OADS or MAAP).

    The execution of this tool is divided into two parts:

    - First, based on provided arguments search request will be send via the OpenSearch API of the [ESA MAAP catalogue](https://catalog.maap.eo.esa.int/catalogue/).
    - Second, the resulting list of products is then downloaded from the configures download backend (OADS or MAAP). See:
        - MAAP: [portal.maap.eo.esa.int/earthcare](https://portal.maap.eo.esa.int/earthcare/)
        - OADS L1: [ec-pdgs-dissemination1.eo.esa.int](https://ec-pdgs-dissemination1.eo.esa.int/)
        - OADS L2: [ec-pdgs-dissemination2.eo.esa.int](https://ec-pdgs-dissemination2.eo.esa.int/)

    Args:
        file_type (str | list[str]): Name(s) of EarthCARE product(s) to search for (e.g., "ATL_NOM_1B", "ANOM", or "A-NOM").
            Note: Input string evaluation is not case sensitive. Also, product version may also be selected
            by adding a colon and the two-letter processor baseline after the name (e.g., "ANOM:BA").
        baseline (str | None, optional): Two-letter processor baseline used as default for all given `file_type`s (e.g., "BA").
            Note: A baseline specified in `file_type` with colon notation (e.g., "ANOM:BA") overwrites the default `baseline`.
            Defaults to None.
        orbit_number (int | list[int] | None, optional):
            Specific orbit number(s) to search for (e.g., 981 or [1000, 5000, ...]). Defaults to None.
        start_orbit_number (int | None, optional):
            The lower limit of orbit numbers to search for (e.g., 5000). Defaults to None.
        end_orbit_number (int | None, optional):
            The upper limit of orbit numbers to search for (e.g., 5003). Defaults to None.
        frame_id (str | list[str] | None, optional):
            Frame ID letter(s) to search for (i.e., letters A to H). Defaults to None.
        orbit_and_frame (str | list[str] | None, optional):
            Orbit and frame string(s) to search for (e.g., "01234F" or ["1000A", "5000C", ...]). Defaults to None.
        start_orbit_and_frame (str | None, optional):
            The lower limit of orbit and frames to search for (e.g., "05000D"). Defaults to None.
        end_orbit_and_frame (str | None, optional):
            The upper limit of orbit and frames to search for (e.g., "05003C"). Defaults to None.
        timestamps (str | list[str] | None, optional):
            Search for data containing specific timestamp(s) (e.g. "2024-07-31 13:45" or "20240731T134500Z"). Defaults to None.
        start_time (str | None, optional):
            The lower time limit for the search. Defaults to None.
        end_time (str | None, optional):
            The upper time limit for the search. Defaults to None.
        radius_search (tuple[RadiusMetersFloat, LatFloat, LonFloat] | list | None, optional):
            A tuple containing a radius (meters) and a lat/lon point to perform a geo radius search (e.g., 25000 51.35 12.43, i.e.,
            <radius[m]> <lat> <lon>). Latitudes must be provided as degrees north and longitudes as degrees east. Defaults to None.
        bounding_box (tuple[LatSFloat, LonWFloat, LatNFloat, LonEFloat]  |  list  |  None, optional):
            A tuple containing the extent for a bounding box geo search (e.g., [14.9, 37.7, 14.99, 37.78],
            i.e., <latS> <lonW> <latN> <lonE>). Latitudes must be provided as degrees north and longitudes as degrees east.
            Defaults to None.
        path_to_config (str | None, optional):
            If provided, uses given config file instead of the default config. Defaults to None.
        path_to_data (str | None, optional):
            If provided, downloads data to the given folder instead of the one defined in the config file. Defaults to None.
        is_log (bool, optional):
            If True, creates a log file in a `/log` folder inside the current working directory. Defaults to False.
        is_debug (bool, optional):
            If True, shows debug logs in the console. Defaults to False.
        is_download (bool, optional):
            If False, skips download part, but still performs search requests via the data dissemination platform API. Defaults to True.
        is_overwrite (bool, optional):
            If True, downloads and overwrites files that already exist in the data directory instead of skipping them. Defaults to False.
        is_unzip (bool, optional): If False, skips file extraction for downloaded archives. Defaults to True.
        is_delete (bool, optional):
            If True, deletes downloaded archives after extraction (i.e., does not delete non-extracted archives). Defaults to True.
        is_create_subdirs (bool, optional):
            If True, places downloaded files in a sub-directory structure according to the template defined in the config file.
            Defaults to True.
        is_export_results (bool, optional):
            If True, creates a text file in the current working directory listing all search results. Defaults to False.
        idx_selected_input (int | None, optional):
            A number matching an index in the list of found files. If provided, only this single file will be downloaded.
            Defaults to None.
        is_organize_data (bool, optional):
            If True, does not search or download any data. Defaults to False.
        is_include_header (bool | None, optional):
            If True, the full archive is downloaded containing both HDF5 data file (`.h5`) and header data file (`.HDR`).
            If False, only the data file will be downloaded, speeding up the download time.
            Defaults to None.

            !!! caution
                This option only applies to MAAP. OADS will always download the full archive.

        is_reversed_order (bool, optional):
            If True, downloads data products in reversed order (from the latest to the earliest). Defaults to False.
        return_results (bool, optional):
            If True, returns the search results as a `ProductDataFrame`. Defaults to False.
        verbose (bool, optional):
            If False, does not print logs to the console and does not create log file. Defaults to True.
        check_product_availability (bool, optional):
            If True, sends extra request to the download backend checking the list of available products per data collection.
            If False, uses internally stored lists of available products, significantly reducing execution time (but might fail in case of backend changes).
            Defaults to False.

    Returns:
        results (ProductDataFrame | None):
            If `return_results=False`, the function has no return (i.e., None).
            If `return_results=True`, the function returns the search results.
    """
    time_start_script: pd.Timestamp = pd.Timestamp(
        datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    )
    time_end_script: pd.Timestamp
    execution_time: pd.Timedelta

    def _to_list(input: Any, _type: Type) -> list | None:
        if isinstance(input, _type):
            return [input]
        elif isinstance(input, list):
            return input
        else:
            return None

    _file_type: list[str] | None = _to_list(file_type, str)
    assert isinstance(_file_type, list)
    file_type = _file_type

    orbit_number = _to_list(orbit_number, int)
    frame_id = _to_list(frame_id, str)
    orbit_and_frame = _to_list(orbit_and_frame, str)
    timestamps = _to_list(timestamps, str)

    if isinstance(radius_search, tuple):
        radius_search = list(radius_search)

    if isinstance(bounding_box, tuple):
        bounding_box = list(bounding_box)

    idx_selected: int | None = parse_selected_index(idx_selected_input)

    logger: Logger | None = None
    if verbose:
        logger = create_logger(
            logger_name=PROGRAM_NAME,
            log_to_file=is_log,
            debug=is_debug,
        )
    if is_log:
        remove_old_logs(100, pd.Timedelta(days=30))

    log_textbox(
        f"EarthCARE Download Tool\n{__title__} {__version__}",
        logger=logger,
        is_mayor=True,
    )

    if logger and not is_organize_data:
        logger.info(f"# Settings")
        logger.info(f"# - {is_download=}")
        logger.info(f"# - {is_overwrite=}")
        logger.info(f"# - {is_unzip=}")
        logger.info(f"# - {is_delete=}")
        logger.info(f"# - {is_create_subdirs=}")
        logger.info(f"# - {is_log=}")
        logger.info(f"# - {is_debug=}")
        logger.info(f"# - {is_export_results=}")
        logger.info(f"# - {idx_selected_input=}")

    config = parse_path_to_config(path_to_config, logger=logger)
    path_to_data = parse_path_to_data(path_to_data, logger=logger)
    if isinstance(path_to_data, str):
        config.path_to_data = path_to_data

    if logger and not is_organize_data:
        logger.info(f"# - config_filepath=<{config.filepath}>")
        logger.info(f"# - data_dirpath=<{config.path_to_data}>")

    if is_organize_data:
        if logger:
            logger.info(f"# Organizing local data ...")
        performed_moves = organize_data(
            config=config,
            logger=logger,
        )
        time_end_script = pd.Timestamp(
            datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        )
        execution_time = time_end_script - time_start_script
        execution_time_str = str(execution_time).split()[-1]
        if logger:
            console_exclusive_info()
        _moved = len([pm for pm in performed_moves if pm.get("status") == "success"])
        _failed = len([pm for pm in performed_moves if pm.get("status") == "error"])
        _msg = [
            f"EXECUTION SUMMARY",
            "---",
            f"Time taken          {execution_time_str}",
            f"Moved files         {_moved}",
            f"Failed moves        {_failed}",
        ]
        log_textbox("\n".join(_msg), logger=logger, show_time=True)
        return None

    if not isinstance(is_include_header, bool):
        is_include_header = config.maap_include_header_file

    search_inputs: _SearchInputs = parse_search_inputs(
        product_type=file_type,
        baseline=baseline,
        orbit_number=orbit_number,
        start_orbit_number=start_orbit_number,
        end_orbit_number=end_orbit_number,
        frame_id=frame_id,
        orbit_and_frame=orbit_and_frame,
        start_orbit_and_frame=start_orbit_and_frame,
        end_orbit_and_frame=end_orbit_and_frame,
        timestamps=timestamps,
        start_time=start_time,
        end_time=end_time,
        radius_search=radius_search,
        bounding_box=bounding_box,
        logger=logger,
    )
    if config.download_backend.lower() == "maap":
        entrypoint = Entrypoint.MAAP
    else:
        entrypoint = Entrypoint.OADS

    planned_requests: list[EOSearchRequest] = create_search_request_list(
        entrypoint=entrypoint,
        search_inputs=search_inputs,
        input_user_type=None,
        candidate_coll_names_user=[c.value for c in config.collections],
        perform_requests=check_product_availability,
        logger=logger,
    )

    found_products: list[EOProduct] = run_search_requets(
        log_heading_msg=f"STEP 1/2 - Search products",
        search_requests=planned_requests,
        is_debug=is_debug,
        is_found_files_list_to_txt=is_export_results,
        selected_index=idx_selected,
        selected_index_input=idx_selected_input,
        logger=logger,
        download_only_h5=not is_include_header,
    )

    donwload_results: list[_DownloadResult] = run_downloads(
        log_heading_msg=f"STEP 2/2 - Download products",
        products=found_products,
        config=config,
        entrypoint=entrypoint,
        is_download=is_download,
        is_overwrite=is_overwrite,
        is_unzip=is_unzip,
        is_delete=is_delete,
        is_create_subdirs=is_create_subdirs,
        logger=logger,
        is_reversed_order=is_reversed_order,
    )

    if logger:
        num_downloads: int = 0
        num_unzips: int = 0
        num_errors: int = 0
        size_msg: str = "<missing size_msg>"
        avg_speed_mbs: float = 0.0
        if len(donwload_results) > 0:
            num_errors = sum([not r.success for r in donwload_results])
            num_downloads = sum([r.downloaded for r in donwload_results])
            num_unzips = sum([r.unzipped for r in donwload_results])
            total_size_mb = sum([r.size_mb for r in donwload_results])
            size_msg = f"{total_size_mb:.2f} MB"
            if total_size_mb >= 1024:
                size_msg = f"{total_size_mb / 1024:.2f} GB"
            avg_speed_mbs = float(np.mean([r.speed_mbs for r in donwload_results]))

        time_end_script = pd.Timestamp(
            datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        )
        execution_time = time_end_script - time_start_script
        execution_time_str = str(execution_time).split()[-1]

        console_exclusive_info()
        _msg = [
            f"EXECUTION SUMMARY",
            "---",
            f"Time taken          {execution_time_str}",
            f"API search requests {len(planned_requests)}",
            f"Remote files found  {len(found_products)}",
            f"Files downloaded    {num_downloads} ({size_msg} at ~{avg_speed_mbs:.2f} MB/s)",
            f"Files unzipped      {num_unzips}",
            f"Errors occured      {num_errors}",
        ]
        log_textbox("\n".join(_msg), logger=logger, show_time=True)

    if return_results:
        return get_product_infos([p.name for p in found_products], must_exist=False)
    return None

ecquicklook

ecquicklook(
    ds,
    vars=None,
    show_maps=True,
    show_zoom=False,
    show_profile=True,
    site=None,
    radius_km=100.0,
    time_range=None,
    height_range=None,
    ds_tropopause=None,
    ds_elevation=None,
    ds_temperature=None,
    resolution="medium",
    ds2=None,
    ds_xmet=None,
    logger=None,
    log_msg_prefix="",
    selection_max_time_margin=None,
    show_steps=DEFAULT_PROFILE_SHOW_STEPS,
    mode="fast",
    map_style="blue_marble",
    curtain_kwargs={},
    map_kwargs={},
    profile_kwargs={},
)

Generate a preview visualization of an EarthCARE dataset with optional maps, zoomed views, and profiles.

Parameters:

Name Type Description Default
ds Dataset | str

EarthCARE dataset or path.

required
vars (str | list[str] | None, otional)

List of variable to plot. Automatically sets product-specific default list of variables if None.

None
show_maps bool

Whether to include map view. Dafaults to True.

True
show_zoom bool

Whether to show an additional column of zoomed plots. Defaults to False.

False
show_profile bool

Whether to include vertical profile plots. Dfaults to True.

True
site GroundSite | str | None

Ground site object or name identifier.

None
radius_km float

Search radius around site in kilometers. Defaults to 100.

100.0
time_range TimeRangeLike | None

Time range filter.

None
height_range DistanceRangeNoneLike | None

Height range in meters. Defaults to None.

None
ds_tropopause Dataset | str | None

Optional dataset or path containing tropopause data to add it to the plot.

None
ds_elevation Dataset | str | None

Optional dataset or path containing elevation data to add it to the plot.

None
ds_temperature Dataset | str | None

Optional dataset or path containing temperature data to add it to the plot.

None
resolution Literal['low', 'medium', 'high', 'l', 'm', 'h']

Resolution of A-PRO data. Defaults to "low".

'medium'
ds2 Dataset | str | None

Secondary dataset required for certain product quicklook (e.g., A-LAY products need A-NOM or A-EBD to serve as background curtain plots).

None
ds_xmet Dataset | str | None

Optional auxiliary meteorological dataset used to plot tropopause, elevation and temperature from.

None
logger Logger

Logger instance for output messages.

None
log_msg_prefix str

Prefix for log messages.

''
selection_max_time_margin TimedeltaLike | Sequence[TimedeltaLike] | None

Allowed time difference for selection.

None
show_steps bool

Whether to plot profiles as height bin step functions or instead plot only the line through bin centers. Defaults to True.

DEFAULT_PROFILE_SHOW_STEPS
mode Literal['fast', 'exact']

Processing mode.

'fast'
map_style str | Literal['none', 'stock_img', 'gray', 'osm', 'satellite', 'mtg', 'msg', 'blue_marble', 'land_ocean', 'land_ocean_lakes_rivers'] | None

Style of the background in the secondary/zoomed map. Defaults to "blue_marble".

'blue_marble'

Returns:

Name Type Description
_QuicklookResults QuicklookFigure

Object containing figures and metadata.

Source code in earthcarekit/plot/quicklook/_quicklook.py
def ecquicklook(
    ds: xr.Dataset | str,
    vars: str | list[str] | None = None,
    show_maps: bool = True,
    show_zoom: bool = False,
    show_profile: bool = True,
    site: GroundSite | str | None = None,
    radius_km: float = 100.0,
    time_range: TimeRangeLike | None = None,
    height_range: DistanceRangeNoneLike | None = None,
    ds_tropopause: xr.Dataset | str | None = None,
    ds_elevation: xr.Dataset | str | None = None,
    ds_temperature: xr.Dataset | str | None = None,
    resolution: Literal["low", "medium", "high", "l", "m", "h"] = "medium",
    ds2: xr.Dataset | str | None = None,
    ds_xmet: xr.Dataset | str | None = None,
    logger: Logger | None = None,
    log_msg_prefix: str = "",
    selection_max_time_margin: TimedeltaLike | Sequence[TimedeltaLike] | None = None,
    show_steps: bool = DEFAULT_PROFILE_SHOW_STEPS,
    mode: Literal["fast", "exact"] = "fast",
    map_style: (
        str
        | Literal[
            "none",
            "stock_img",
            "gray",
            "osm",
            "satellite",
            "mtg",
            "msg",
            "blue_marble",
            "land_ocean",
            "land_ocean_lakes_rivers",
        ]
        | None
    ) = "blue_marble",
    curtain_kwargs: dict[str, Any] = {},
    map_kwargs: dict[str, Any] = {},
    profile_kwargs: dict[str, Any] = {},
) -> QuicklookFigure:
    """
    Generate a preview visualization of an EarthCARE dataset with optional maps, zoomed views, and profiles.

    Args:
        ds (xr.Dataset | str): EarthCARE dataset or path.
        vars (str | list[str] | None, otional): List of variable to plot. Automatically sets product-specific default list of variables if None.
        show_maps (bool, optional): Whether to include map view. Dafaults to True.
        show_zoom (bool, optional): Whether to show an additional column of zoomed plots. Defaults to False.
        show_profile (bool, optional): Whether to include vertical profile plots. Dfaults to True.
        site (GroundSite | str | None, optional): Ground site object or name identifier.
        radius_km (float, optional): Search radius around site in kilometers. Defaults to 100.
        time_range (TimeRangeLike | None, optional): Time range filter.
        height_range (DistanceRangeNoneLike | None, optional): Height range in meters. Defaults to None.
        ds_tropopause (xr.Dataset | str | None, optional): Optional dataset or path containing tropopause data to add it to the plot.
        ds_elevation (xr.Dataset | str | None, optional): Optional dataset or path containing elevation data to add it to the plot.
        ds_temperature (xr.Dataset | str | None, optional): Optional dataset or path containing temperature data to add it to the plot.
        resolution (Literal["low", "medium", "high", "l", "m", "h"], optional): Resolution of A-PRO data. Defaults to "low".
        ds2 (xr.Dataset | str | None, optional): Secondary dataset required for certain product quicklook (e.g., A-LAY products need A-NOM or A-EBD to serve as background curtain plots).
        ds_xmet (xr.Dataset | str | None, optional): Optional auxiliary meteorological dataset used to plot tropopause, elevation and temperature from.
        logger (Logger, optional): Logger instance for output messages.
        log_msg_prefix (str, optional): Prefix for log messages.
        selection_max_time_margin (TimedeltaLike | Sequence[TimedeltaLike] | None, optional): Allowed time difference for selection.
        show_steps (bool, optional): Whether to plot profiles as height bin step functions or instead plot only the line through bin centers. Defaults to True.
        mode (Literal["fast", "exact"], optional): Processing mode.
        map_style (str | Literal["none", "stock_img", "gray", "osm", "satellite", "mtg", "msg", "blue_marble", "land_ocean", "land_ocean_lakes_rivers"] | None, optional):
            Style of the background in the secondary/zoomed map. Defaults to "blue_marble".

    Returns:
        _QuicklookResults: Object containing figures and metadata.
    """
    if isinstance(vars, str):
        vars = [vars]

    filepath: str | None = None
    if isinstance(ds, str):
        filepath = ds

    ds = read_product(ds, in_memory=True)
    file_type = FileType.from_input(ds)

    if isinstance(ds_xmet, (xr.Dataset, str)):
        ds_xmet = read_product(ds_xmet, in_memory=True)
        if file_type in [
            FileType.ATL_NOM_1B,
            FileType.ATL_FM__2A,
            FileType.ATL_AER_2A,
            FileType.ATL_EBD_2A,
            FileType.ATL_ICE_2A,
            FileType.ATL_TC__2A,
            FileType.ATL_CLA_2A,
            FileType.CPR_NOM_1B,
        ]:
            ds_xmet = rebin_xmet_to_vertical_track(ds_xmet, ds)

    ds_tropopause, ds_elevation, ds_temperature = _get_addon_ds(
        ds,
        filepath,
        ds_tropopause or ds_xmet,
        ds_elevation or ds_xmet,
        ds_temperature or ds_xmet,
    )

    kwargs = dict(
        ds=ds,
        vars=vars,
        show_maps=show_maps,
        show_zoom=show_zoom,
        show_profile=show_profile,
        site=site,
        radius_km=radius_km,
        time_range=time_range,
        height_range=height_range,
        ds_tropopause=ds_tropopause,
        ds_elevation=ds_elevation,
        ds_temperature=ds_temperature,
        logger=logger,
        log_msg_prefix=log_msg_prefix,
        selection_max_time_margin=selection_max_time_margin,
        mode=mode,
        map_style=map_style,
        curtain_kwargs=curtain_kwargs,
        map_kwargs=map_kwargs,
        profile_kwargs=profile_kwargs,
    )

    if file_type == FileType.ATL_NOM_1B:
        kwargs["show_steps"] = show_steps
        return ecquicklook_anom(**kwargs)  # type: ignore
    elif file_type == FileType.ATL_EBD_2A:
        kwargs["show_steps"] = show_steps
        kwargs["resolution"] = resolution
        return ecquicklook_aebd(**kwargs)  # type: ignore
    elif file_type == FileType.ATL_AER_2A:
        kwargs["show_steps"] = show_steps
        kwargs["resolution"] = resolution
        return ecquicklook_aaer(**kwargs)  # type: ignore
    elif file_type == FileType.ATL_TC__2A:
        return ecquicklook_atc(**kwargs)  # type: ignore
    elif file_type == FileType.ATL_CTH_2A:

        if ds2 is not None:
            ds2 = read_product(ds2, in_memory=True)
            file_type2 = FileType.from_input(ds2)
            if file_type2 in [
                FileType.ATL_NOM_1B,
                FileType.ATL_EBD_2A,
                FileType.ATL_AER_2A,
                FileType.ATL_TC__2A,
            ]:
                kwargs["ds_bg"] = ds2
                kwargs["resolution"] = resolution
                return ecquicklook_acth(**kwargs)  # type: ignore
            raise ValueError(
                f"There is no CTH background curtain plotting for {str(file_type2)} products. Use instead: {str(FileType.ATL_NOM_1B)}, {str(FileType.ATL_EBD_2A)}, {str(FileType.ATL_AER_2A)}, {str(FileType.ATL_TC__2A)}"
            )
        raise TypeError(f"""Missing dataset "ds2" to plot a background for the CTH""")
    elif file_type == FileType.CPR_FMR_2A:
        return ecquicklook_cfmr(**kwargs)  # type: ignore
    elif file_type == FileType.CPR_CD__2A:
        return ecquicklook_ccd(**kwargs)  # type: ignore
    elif file_type == FileType.CPR_CLD_2A:
        return ecquicklook_ccld(**kwargs)  # type: ignore
    elif file_type == FileType.CPR_TC__2A:
        return ecquicklook_ctc(**kwargs)  # type: ignore
    elif file_type == FileType.AC__TC__2B:
        return ecquicklook_actc(**kwargs)  # type: ignore
    elif file_type == FileType.ACM_CAP_2B:
        return ecquicklook_acmcap(**kwargs)  # type: ignore
    raise NotImplementedError()

ecquicklook_deep_convection

ecquicklook_deep_convection(
    mrgr,
    cfmr,
    ccd,
    aebd,
    xmet=None,
    height_range=(-250, 20000.0),
    time_range=None,
    info_text_loc=None,
    trim_to_frame=False,
)

Creates a 4 panel quicklook of a storm or deep convective event, displaying:

  • 1st row: RGB image from MSI_RGR_1C
  • 2nd row: Radar reflectivity from CPR_FMR_2A
  • 3rd row: Doppler velocity from CPR_CD__2A
  • 4th row: Total attenuated backscatter from ATL_EBD_2A

Parameters:

Name Type Description Default
ds_mrgr Dataset

The MSI_RGR_1C product filepath or dataset.

required
ds_cfmr Dataset

The CPR_FMR_2A product filepath or dataset.

required
ds_ccd Dataset

The CPR_CD__2A product filepath or dataset.

required
ds_aebd Dataset

The ATL_EBD_2A product filepath or dataset.

required
ds_xmet Dataset | None

The AUX_MET_1D product filepath or dataset. If given, temperature contour lines will be added to the plots. Defaults to None.

required
height_range DistanceRangeLike | None

A height range (i.e., min, max) in meters. Defaults to (-250, 20e3).

(-250, 20000.0)
time_range TimeRangeLike | None

A time range to filter the displayed data. Defaults to None.

None
info_text_loc str | None

The positioning of the orbt, frame and product info text (e.g., "upper right"). Defaults to None.

None
trim_to_frame bool

Wether the read products should be trimmed to the EarthCARE frame bounds.

False

Returns:

Name Type Description
QuicklookFigure QuicklookFigure

The quicklook object.

Examples:

import earthcarekit as eck

df = eck.search_product(
    file_type=["mrgr", "cfmr", "ccd", "aebd", "xmet"],
    orbit_and_frame="07590D",
).filter_latest()

fp_mrgr = df.filter_file_type("mrgr").filepath[-1]
fp_cfmr = df.filter_file_type("cfmr").filepath[-1]
fp_ccd = df.filter_file_type("ccd").filepath[-1]
fp_aebd = df.filter_file_type("aebd").filepath[-1]
fp_xmet = df.filter_file_type("xmet").filepath[-1]

ql = eck.ecquicklook_deep_convection(
    mrgr=fp_mrgr,
    cfmr=fp_cfmr,
    ccd=fp_ccd,
    aebd=fp_aebd,
    xmet=fp_xmet,
    time_range=("2025-09-28T18:27:10", None),
    info_text_loc="upper left",
)

ecquicklook_deep_convection.png

Source code in earthcarekit/plot/quicklook/_quicklook_deep_convection.py
def ecquicklook_deep_convection(
    mrgr: Dataset | str,
    cfmr: Dataset | str,
    ccd: Dataset | str,
    aebd: Dataset | str,
    xmet: Dataset | str | None = None,
    height_range: DistanceRangeLike | None = (-250, 20e3),
    time_range: TimeRangeLike | None = None,
    info_text_loc: str | None = None,
    trim_to_frame: bool = False,
) -> QuicklookFigure:
    """
    Creates a 4 panel quicklook of a storm or deep convective event, displaying:

    - 1st row: RGB image from MSI_RGR_1C
    - 2nd row: Radar reflectivity from CPR_FMR_2A
    - 3rd row: Doppler velocity from CPR_CD__2A
    - 4th row: Total attenuated backscatter from ATL_EBD_2A

    Args:
        ds_mrgr (Dataset): The MSI_RGR_1C product filepath or dataset.
        ds_cfmr (Dataset): The CPR_FMR_2A product filepath or dataset.
        ds_ccd (Dataset): The CPR_CD__2A product filepath or dataset.
        ds_aebd (Dataset): The ATL_EBD_2A product filepath or dataset.
        ds_xmet (Dataset | None, optional): The AUX_MET_1D product filepath or dataset.
            If given, temperature contour lines will be added to the plots. Defaults to None.
        height_range (DistanceRangeLike | None, optional): A height range (i.e., min, max) in meters. Defaults to (-250, 20e3).
        time_range (TimeRangeLike | None, optional): A time range to filter the displayed data. Defaults to None.
        info_text_loc (str | None, optional): The positioning of the orbt, frame and product info text (e.g., "upper right").
            Defaults to None.
        trim_to_frame (bool, optional): Wether the read products should be trimmed to the EarthCARE frame bounds.

    Returns:
        QuicklookFigure: The quicklook object.

    Examples:
        ```python
        import earthcarekit as eck

        df = eck.search_product(
            file_type=["mrgr", "cfmr", "ccd", "aebd", "xmet"],
            orbit_and_frame="07590D",
        ).filter_latest()

        fp_mrgr = df.filter_file_type("mrgr").filepath[-1]
        fp_cfmr = df.filter_file_type("cfmr").filepath[-1]
        fp_ccd = df.filter_file_type("ccd").filepath[-1]
        fp_aebd = df.filter_file_type("aebd").filepath[-1]
        fp_xmet = df.filter_file_type("xmet").filepath[-1]

        ql = eck.ecquicklook_deep_convection(
            mrgr=fp_mrgr,
            cfmr=fp_cfmr,
            ccd=fp_ccd,
            aebd=fp_aebd,
            xmet=fp_xmet,
            time_range=("2025-09-28T18:27:10", None),
            info_text_loc="upper left",
        )
        ```

        ![ecquicklook_deep_convection.png](https://raw.githubusercontent.com/TROPOS-RSD/earthcarekit-docs-assets/refs/heads/main/assets/images/quicklooks/ecquicklook_deep_convection.png)
    """

    def _load_xmet() -> Dataset | None:
        if isinstance(xmet, Dataset):
            return xmet
        elif isinstance(xmet, str):
            return read_product(xmet)
        return None

    with (
        read_product(mrgr, trim_to_frame=trim_to_frame) as ds_mrgr,
        read_product(cfmr, trim_to_frame=trim_to_frame) as ds_cfmr,
        read_product(ccd, trim_to_frame=trim_to_frame) as ds_ccd,
        read_product(aebd, trim_to_frame=trim_to_frame) as ds_aebd,
        nullcontext(_load_xmet()) as ds_xmet,
    ):

        min_time = np.max(
            [
                np.min(ds_mrgr.time.values),
                np.min(ds_cfmr.time.values),
                np.min(ds_ccd.time.values),
                np.min(ds_aebd.time.values),
            ]
        )

        max_time = np.min(
            [
                np.max(ds_mrgr.time.values),
                np.max(ds_cfmr.time.values),
                np.max(ds_ccd.time.values),
                np.max(ds_aebd.time.values),
            ]
        )

        ds_mrgr = filter_time(ds_mrgr, (min_time, max_time))
        ds_cfmr = filter_time(ds_cfmr, (min_time, max_time))
        ds_ccd = filter_time(ds_ccd, (min_time, max_time))
        ds_aebd = filter_time(ds_aebd, (min_time, max_time))

        layout = create_multi_figure_layout(
            rows=[
                FigureType.SWATH,
                FigureType.CURTAIN_75,
                FigureType.CURTAIN_75,
                FigureType.CURTAIN_75,
            ],
            hspace=[0.7, 0.35, 0.35],
        )

        figs: list[ECKFigure] = []

        # 1. Row: MSI RGR RGB
        ax = layout.axs[0]

        f: SwathFigure | CurtainFigure
        f = SwathFigure(ax=ax, ax_style_top="time", ax_style_bottom="geo")
        f = f.ecplot(
            ds=ds_mrgr,
            var="rgb",
            time_range=time_range,
            info_text_loc=info_text_loc,
        )
        f = f.ecplot_coastline(ds_mrgr)
        figs.append(f)

        ds_xmet_vert: Dataset | None = None
        if isinstance(ds_xmet, Dataset):
            ds_xmet_vert = rebin_xmet_to_vertical_track(ds_xmet, ds_aebd)
            ds_xmet_vert = filter_time(ds_xmet_vert, time_range)

        # 2. Row CPR FMR reflectivity (Range -40 - 20 dBz)
        ax = layout.axs[1]
        f = CurtainFigure(
            ax=ax,
            ax_style_top="none",
            ax_style_bottom="distance_notitle",
        )
        f = f.ecplot(
            ds=ds_cfmr,
            var="reflectivity_corrected",
            height_range=height_range,
            time_range=time_range,
            value_range=(-40, 20),
            info_text_loc=info_text_loc,
        )
        f = f.ecplot_elevation(ds_cfmr)
        f = f.ecplot_tropopause(ds_aebd)
        if isinstance(ds_xmet_vert, Dataset):
            f = f.ecplot_temperature(ds_xmet_vert)
        figs.append(f)

        # 3. Row CPR-CD Doppler Velocity best estimate (Range -5 -5 m/s)
        ax = layout.axs[2]
        f = CurtainFigure(
            ax=ax,
            ax_style_top="none",
            ax_style_bottom="distance_notitle",
        )
        f = f.ecplot(
            ds=ds_ccd,
            var="doppler_velocity_best_estimate",
            height_range=height_range,
            time_range=time_range,
            value_range=(-5, 5),
            info_text_loc=info_text_loc,
        )
        f = f.ecplot_elevation(ds_cfmr)
        f = f.ecplot_tropopause(ds_aebd)
        if isinstance(ds_xmet_vert, Dataset):
            f = f.ecplot_temperature(ds_xmet_vert)
        figs.append(f)

        # 4. Row ATL-EBD total attenuated mie backscatter
        ax = layout.axs[3]
        f = CurtainFigure(
            ax=ax,
            ax_style_top="none",
            ax_style_bottom="distance",
        )
        f = f.ecplot(
            ds=ds_aebd,
            var="mie_total_attenuated_backscatter_355nm",
            height_range=height_range,
            time_range=time_range,
            info_text_loc=info_text_loc,
        )
        f = f.ecplot_elevation(ds_cfmr)
        f = f.ecplot_tropopause(ds_aebd)
        if isinstance(ds_xmet_vert, Dataset):
            f = f.ecplot_temperature(ds_xmet_vert, colors="white")
        figs.append(f)

        return QuicklookFigure(
            fig=layout.fig,
            subfigs=[figs],
        )

ecquicklook_psc

ecquicklook_psc(
    anom,
    xmet=None,
    zoom_at=0.5,
    height_range=(0, 40000.0),
    time_range=None,
    info_text_loc=None,
)

Creates a two-column multi-panel quicklook of a PSC event, displaying:

  • 1st column: Two maps showing the EarthCARE track.
  • 2nd column: Three rows showing co- and cross-polar attenuated backscatter and the calculated depolarization ratio.

Parameters:

Name Type Description Default
anom str | Sequence[str] | Dataset

The ATL_NOM_1B product filepath(s) or dataset(s).

required
xmet str | Sequence[str] | Dataset | None

The AUX_MET_1D product filepath(s) or dataset(s). If given, temperature contour lines will be added to the plots. Defaults to None.

None
zoom_at float | None

In case two frames are given, selects only a zoomed-in portion of the frames around this fractional index (0 -> only 1st frame, 0.5 -> half of end of 1st and half of beginning of 2nd frame, 1 -> only 2nd frame). Defaults to 0.5.

0.5
height_range DistanceRangeLike | None

description. Defaults to (0, 40e3).

(0, 40000.0)
time_range TimeRangeLike | None

A time range to filter the displayed data. Defaults to None.

None
info_text_loc str | None

The positioning of the orbt, frame and product info text (e.g., "upper right"). Defaults to None.

None

Raises:

Type Description
ValueError

If none or more than 2 frames are given.

ValueError

If given number X-MET files does not match number of A-NOM files.

Returns:

Name Type Description
QuicklookFigure QuicklookFigure

The quicklook object.

Examples:

import earthcarekit as eck

df = eck.search_product(
    file_type=["anom", "xmet"],
    orbit_and_frame=["3579B", "3579C"],
).filter_latest()

fps_anom = df.filter_file_type("anom").filepath
fps_xmet = df.filter_file_type("xmet").filepath

ql = eck.ecquicklook_psc(
    anom=fps_anom,
    xmet=fps_xmet,
)

ecquicklook_psc.png

Source code in earthcarekit/plot/quicklook/_quicklook_psc.py
def ecquicklook_psc(
    anom: str | Sequence[str] | NDArray[np.str_] | Dataset,
    xmet: str | Sequence[str] | NDArray[np.str_] | Dataset | None = None,
    zoom_at: float | None = 0.5,
    height_range: DistanceRangeLike | None = (0, 40e3),
    time_range: TimeRangeLike | None = None,
    info_text_loc: str | None = None,
) -> QuicklookFigure:
    """
    Creates a two-column multi-panel quicklook of a PSC event, displaying:

    - 1st column: Two maps showing the EarthCARE track.
    - 2nd column: Three rows showing co- and cross-polar attenuated backscatter and the calculated depolarization ratio.

    Args:
        anom (str | Sequence[str] | Dataset):  The ATL_NOM_1B product filepath(s) or dataset(s).
        xmet (str | Sequence[str] | Dataset | None, optional): The AUX_MET_1D product filepath(s) or dataset(s).
            If given, temperature contour lines will be added to the plots. Defaults to None.
        zoom_at (float | None, optional): In case two frames are given, selects only a zoomed-in portion of the
            frames around this fractional index (0 -> only 1st frame, 0.5 -> half of end of 1st and half of beginning
            of 2nd frame, 1 -> only 2nd frame). Defaults to 0.5.
        height_range (DistanceRangeLike | None, optional): _description_. Defaults to (0, 40e3).
        time_range (TimeRangeLike | None, optional): A time range to filter the displayed data. Defaults to None.
        info_text_loc (str | None, optional): The positioning of the orbt, frame and product info text (e.g., "upper right").
            Defaults to None.

    Raises:
        ValueError: If none or more than 2 frames are given.
        ValueError: If given number X-MET files does not match number of A-NOM files.

    Returns:
        QuicklookFigure: The quicklook object.

    Examples:
        ```python
        import earthcarekit as eck

        df = eck.search_product(
            file_type=["anom", "xmet"],
            orbit_and_frame=["3579B", "3579C"],
        ).filter_latest()

        fps_anom = df.filter_file_type("anom").filepath
        fps_xmet = df.filter_file_type("xmet").filepath

        ql = eck.ecquicklook_psc(
            anom=fps_anom,
            xmet=fps_xmet,
        )
        ```

        ![ecquicklook_psc.png](https://raw.githubusercontent.com/TROPOS-RSD/earthcarekit-docs-assets/refs/heads/main/assets/images/quicklooks/ecquicklook_psc.png)
    """

    if not isinstance(anom, str) and isinstance(anom, (Sequence, np.ndarray)):
        if len(anom) == 0 or len(anom) > 2:
            raise ValueError(
                f"supports input of either 1 or 2 consecutive frames, but got {len(anom)} A-NOM frames"
            )
        if not isinstance(xmet, str) and isinstance(xmet, (Sequence, np.ndarray)):
            if len(anom) != len(xmet):
                raise ValueError(
                    f"number of X-MET frames ({len(xmet)}) must match number of A-NOM frames ({len(anom)})"
                )

    def _load_full_anom() -> Dataset:
        if isinstance(anom, Dataset):
            return anom
        elif isinstance(anom, str):
            return read_product(anom)
        return read_products(anom)

    def _load_anom() -> Dataset:
        if isinstance(anom, Dataset):
            return anom
        elif isinstance(anom, str):
            return read_product(anom)
        return read_products(anom, zoom_at=zoom_at)

    def _load_xmet() -> Dataset | None:
        if isinstance(xmet, Dataset):
            return xmet
        elif isinstance(xmet, str):
            return read_product(xmet)
        elif isinstance(xmet, (Sequence, np.ndarray)) and len(xmet) > 0:
            return read_product(xmet[0])
        return None

    def _load_xmet2() -> Dataset | None:
        if (
            not isinstance(xmet, Dataset)
            and not isinstance(xmet, str)
            and isinstance(xmet, (Sequence, np.ndarray))
            and len(xmet) > 1
        ):
            return read_product(xmet[1])
        return None

    with (
        _load_full_anom() as ds_full,
        _load_anom() as ds,
        nullcontext(_load_xmet()) as ds_xmet,
        nullcontext(_load_xmet2()) as ds_xmet2,
    ):
        if isinstance(ds_xmet, Dataset):
            ds_xmet = rebin_xmet_to_vertical_track(ds_xmet, ds_full)
            ds_xmet = trim_to_latitude_frame_bounds(ds_xmet)

            if isinstance(ds_xmet2, Dataset):
                ds_xmet2 = rebin_xmet_to_vertical_track(ds_xmet2, ds_full)
                ds_xmet2 = trim_to_latitude_frame_bounds(ds_xmet2)

                ds_xmet = concat_datasets(ds_xmet, ds_xmet2, "along_track")

        ds = filter_time(ds, time_range)

        figs: list[list[ECKFigure]] = [[], []]
        layout = create_multi_figure_layout(
            rows=[
                FigureType.CURTAIN,
                FigureType.CURTAIN,
                FigureType.CURTAIN,
            ],
            hspace=0.4,
            wspace=1.0,
            map_rows=[
                FigureType.MAP_1_ROW,
                FigureType.MAP_2_ROW,
            ],
        )

        mf1 = MapFigure(
            ax=layout.axs_map[0],
            show_grid_labels=False,
        )
        mf1.ecplot(ds)
        mf1.plot_track(
            latitude=ds_full.latitude.values,
            longitude=ds_full.longitude.values,
            color="white",
            linestyle="dashed",
            linewidth=1,
            zorder=2,
            highlight_first=False,
            highlight_last=False,
            alpha=0.8,
        )
        figs[0].append(mf1)

        mf2 = MapFigure(
            ax=layout.axs_map[1],
            show_top_labels=False,
            show_right_labels=False,
            style="blue_marble",
            coastlines_resolution="50m",
            show_text_time=False,
            show_text_frame=False,
        )
        mf2.ecplot(ds)
        mf2.plot_track(
            latitude=ds_full.latitude.values,
            longitude=ds_full.longitude.values,
            color="white",
            linestyle="dashed",
            linewidth=1,
            zorder=2,
            highlight_first=False,
            highlight_last=False,
            alpha=0.8,
        )

        coords = get_coords(ds)
        zoom_radius_meters = geodesic(coords[0], coords[-1], units="m") * 0.4
        mf2.ax.set_xlim(-zoom_radius_meters, zoom_radius_meters)  # type: ignore
        mf2.ax.set_ylim(-zoom_radius_meters, zoom_radius_meters)  # type: ignore
        figs[0].append(mf2)

        vars = [
            "mie_attenuated_backscatter",
            "crosspolar_attenuated_backscatter",
            "depol_ratio",
        ]

        for i, (var, ax) in enumerate(zip(vars, layout.axs)):
            if i == 0:
                ax_style_top = "utc"
                ax_style_bottom = "lat"
            elif i == 1:
                ax_style_top = "lat_nolabels"
                ax_style_bottom = "lon"
            elif i == 2:
                ax_style_top = "lon_nolabels"
                ax_style_bottom = "distance"

            cf = CurtainFigure(
                ax=ax,
                ax_style_top=ax_style_top,
                ax_style_bottom=ax_style_bottom,
                num_ticks=8,
            )
            cf.ecplot(
                ds=ds,
                var=var,
                label_length=55,
                height_range=height_range,
                info_text_loc=info_text_loc,
            )
            cf.ecplot_temperature(
                ds=ds,
                colors="#fffffff0",
                levels=[-90, -80, -60, -40, -20, 0, 10],
                label_levels=[-90, -80, -60, -40, -20, 0, 10],
                linestyles="solid",
                linewidths=[1, 0.7, 0.5, 1, 0.5, 1, 0.5],
            )
            if isinstance(ds_xmet, Dataset):
                cf.ecplot_tropopause(ds_xmet)
            figs[1].append(cf)

        return QuicklookFigure(
            fig=layout.fig,
            subfigs=figs,
        )

geodesic

geodesic(a, b, units='km', tolerance=1e-12, max_iterations=10)

Calculates the geodesic distances between points on Earth (i.e. WSG 84 ellipsoid) using Vincenty's inverse method.

Supports single or sequences of coordiates.

Parameters:

Name Type Description Default
a ArrayLike

Coordinates [lat, lon] or array of shape (N, 2), in decimal degrees.

required
b ArrayLike

Second coordinates, same format/shape as a.

required
units str

Output units, "km" (default) or "m".

'km'
tolerance float

Convergence threshold in radians. Default is 1e-12.

1e-12
max_iterations int

Maximum iterations before failure. Default is 10.

10

Returns:

Type Description
float64 | NDArray[float64]

float or np.ndarray: The geodesic distance or distances between the point in a and b.

Raises:

Type Description
ValueError

If input shapes are incompatible or units are invalid.

Note

Uses WGS84 (a=6378137.0 m, f=1/298.257223563). May fail for nearly antipodal points.

Examples:

>>> geodesic([51.352757, 12.43392], [38.559, 68.856])
4548.675334434374
>>> geodesic([0,0], [[0,0], [10,0], [20,0]])
array([   0.        , 1105.85483324, 2212.36625417])
>>> geodesic([[0,0], [10,0], [20,0]], [[0,0], [10,0], [20,0]])
array([0., 0., 0.])
References
  • Vincenty, T. (1975). Direct and Inverse Solutions of Geodesics on the Ellipsoid with application of nested equations. Survey Review, 23(176), 88-93. https://doi.org/10.1179/sre.1975.23.176.88
Source code in earthcarekit/utils/geo/distance/_vincenty.py
def vincenty(
    a: ArrayLike,
    b: ArrayLike,
    units: str = "km",
    tolerance: float = 1e-12,
    max_iterations: int = 10,
) -> np.float64 | NDArray[np.float64]:
    """
    Calculates the geodesic distances between points on Earth (i.e. WSG 84 ellipsoid) using Vincenty's inverse method.

    Supports single or sequences of coordiates.

    Args:
        a (ArrayLike): Coordinates [lat, lon] or array of shape (N, 2), in decimal degrees.
        b (ArrayLike): Second coordinates, same format/shape as `a`.
        units (str, optional): Output units, "km" (default) or "m".
        tolerance (float, optional): Convergence threshold in radians. Default is 1e-12.
        max_iterations (int, optional): Maximum iterations before failure. Default is 10.

    Returns:
        float or np.ndarray: The geodesic distance or distances between the point in `a` and `b`.

    Raises:
        ValueError: If input shapes are incompatible or units are invalid.

    Note:
        Uses WGS84 (a=6378137.0 m, f=1/298.257223563). May fail for nearly antipodal points.

    Examples:
        >>> geodesic([51.352757, 12.43392], [38.559, 68.856])
        4548.675334434374
        >>> geodesic([0,0], [[0,0], [10,0], [20,0]])
        array([   0.        , 1105.85483324, 2212.36625417])
        >>> geodesic([[0,0], [10,0], [20,0]], [[0,0], [10,0], [20,0]])
        array([0., 0., 0.])

    References:
        - Vincenty, T. (1975). Direct and Inverse Solutions of Geodesics on the Ellipsoid with application of nested equations.
        Survey Review, 23(176), 88-93. https://doi.org/10.1179/sre.1975.23.176.88
    """
    _a, _b = map(np.asarray, [a, b])
    coord_a, coord_b = map(np.atleast_2d, [_a, _b])
    coord_a, coord_b = map(np.radians, [coord_a, coord_b])

    if (coord_a.shape[1] != 2) or (coord_b.shape[1] != 2):
        raise ValueError(
            f"At least one passed array has a wrong shape (a={_a.shape}, b={_b.shape}). 1d arrays should be of length 2 (i.e. [lat, lon]) and 2d array should have the shape (n, 2)."
        )
    if (coord_a.shape[0] < 1) or (coord_b.shape[0] < 1):
        raise ValueError(
            f"At least one passed array contains no values (a={_a.shape}, b={_b.shape})."
        )
    if coord_a.shape[0] != coord_b.shape[0]:
        if (coord_a.shape[0] != 1) and (coord_b.shape[0] != 1):
            raise ValueError(
                f"The shapes of passed arrays dont match (a={_a.shape}, b={_b.shape}). Either both should contain the same number of coordinates or at least one of them should contain a single coordinate."
            )

    lat_1, lon_1 = coord_a[:, 0], coord_a[:, 1]
    lat_2, lon_2 = coord_b[:, 0], coord_b[:, 1]

    # WGS84 ellipsoid constants
    a = 6378137.0  # semi-major axis (equatorial radius) in meters
    f = 1 / 298.257223563  # flattening
    b = (1 - f) * a  # semi-minor axis (polar radius) in meters

    # Reduced latitudes
    beta_1 = np.arctan((1 - f) * np.tan(lat_1))
    beta_2 = np.arctan((1 - f) * np.tan(lat_2))

    initial_lon_diff = lon_2 - lon_1

    # Initialize variables for iterative solution
    lon_diff = initial_lon_diff
    sin_beta_1, cos_beta_1 = np.sin(beta_1), np.cos(beta_1)
    sin_beta_2, cos_beta_2 = np.sin(beta_2), np.cos(beta_2)
    # Track convergence for each point pair
    converged = np.full_like(lat_1, False, dtype=bool)

    for _ in range(max_iterations):
        sin_lon_diff, cos_lon_diff = np.sin(lon_diff), np.cos(lon_diff)

        sin_sigma = np.sqrt(
            (cos_beta_2 * sin_lon_diff) ** 2
            + (cos_beta_1 * sin_beta_2 - sin_beta_1 * cos_beta_2 * cos_lon_diff) ** 2
        )
        cos_sigma = (sin_beta_1 * sin_beta_2) + (cos_beta_1 * cos_beta_2 * cos_lon_diff)
        sigma = np.arctan2(sin_sigma, cos_sigma)

        with warnings.catch_warnings():
            warnings.filterwarnings("ignore")
            sin_alpha = cos_beta_1 * cos_beta_2 * sin_lon_diff / sin_sigma
        sin_alpha = np.nan_to_num(sin_alpha, nan=0.0)
        cos2_alpha = 1 - sin_alpha**2

        with warnings.catch_warnings():
            warnings.filterwarnings("ignore")
            cos2_sigma_m = np.where(
                cos2_alpha != 0.0,
                cos_sigma - ((2 * sin_beta_1 * sin_beta_2) / cos2_alpha),
                0.0,
            )
        cos2_sigma_m = np.nan_to_num(cos2_sigma_m, nan=0.0)

        C = f / 16 * cos2_alpha * (4 + f * (4 - 3 * cos2_alpha))

        previous_lon_diff = lon_diff
        lon_diff = initial_lon_diff + (1 - C) * f * sin_alpha * (
            sigma
            + C
            * sin_sigma
            * (cos2_sigma_m + C * cos_sigma * (-1 + 2 * cos2_sigma_m**2))
        )
        converged = converged | (np.abs(lon_diff - previous_lon_diff) < tolerance)
        if np.all(converged):
            break

    u2 = cos2_alpha * (a**2 - b**2) / b**2
    A = 1 + u2 / 16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)))
    B = u2 / 1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)))

    delta_sigma = (
        B
        * sin_sigma
        * (
            cos2_sigma_m
            + B
            / 4
            * (
                cos_sigma * (-1 + 2 * cos2_sigma_m**2)
                - B
                / 6
                * cos2_sigma_m
                * (-3 + 4 * sin_sigma**2)
                * (-3 + 4 * cos2_sigma_m**2)
            )
        )
    )

    distance = b * A * (sigma - delta_sigma)

    if units == "km":
        distance = distance / 1000.0
    elif units != "m":
        raise ValueError(
            f"{vincenty.__name__}() Invalid units : {units}. Use 'm' or 'km' instead."
        )

    if len(_a.shape) == 1 and len(_b.shape) == 1:
        return distance[0]

    return distance

get_cmap

get_cmap(cmap)

Return a color map given by cmap.

Parameters:

Name Type Description Default
cmap str | Colormap | list | None
  • If a Colormap, return it.
  • If a str, return matching custom color map or if not matching look it up in cmcrameri.cm.cmaps and matplotlib.colormaps.
  • If a list of colors, create a corresponding descrete color map.
  • If None, return the Colormap defined in image.cmap.
required

Returns: cmap (Cmap): A color map matching the given cmap.

Source code in earthcarekit/plot/color/colormap/colormap.py
def get_cmap(cmap: str | Colormap | list | None) -> Cmap:
    """
    Return a color map given by `cmap`.

    Parameters:
        cmap (str | matplotlib.colors.Colormap | list | None):
            - If a `Colormap`, return it.
            - If a `str`, return matching custom color map or
              if not matching look it up in `cmcrameri.cm.cmaps`
              and `matplotlib.colormaps`.
            - If a `list` of colors, create a corresponding descrete color map.
            - If None, return the Colormap defined in `image.cmap`.
    Returns:
        cmap (Cmap):
            A color map matching the given `cmap`.
    """
    if isinstance(cmap, list):
        cmap = ListedColormap(cmap)
    return Cmap.from_colormap(_get_cmap(cmap))

get_config

get_config(c=None)

Returns the default or a given earthcarekit config object.

Parameters:

Name Type Description Default
c str | ECKConfig | None

A path to a config file (.toml) or None. If None, returns the default config. Defaults to None.

None

Returns:

Name Type Description
ECKConfig ECKConfig

A config object.

Source code in earthcarekit/utils/config.py
def get_config(c: str | ECKConfig | None = None) -> ECKConfig:
    """
    Returns the default or a given earthcarekit config object.

    Args:
        c (str | ECKConfig | None, optional): A path to a config file (.toml) or None. If None, returns the default config. Defaults to None.

    Returns:
        ECKConfig: A config object.
    """
    _config: ECKConfig
    if c is None:
        _config = read_config()
    elif isinstance(c, str):
        _config = read_config(c)
    elif isinstance(c, ECKConfig):
        _config = c
    else:
        raise TypeError(
            f"Invalid config! Either give a path to a eckit config TOML file or pass a instance of the class '{ECKConfig.__name__}'"
        )
    return _config

get_coord_between

get_coord_between(coord1, coord2, f=0.5)

Interpolates between two coordinates by fraction f (0 to 1).

Parameters:

Name Type Description Default
coord1 ArrayLike

The first lat/lon point.

required
coord2 ArrayLike

The second lat/lon point.

required
f float

A fractional value between 0 and 1. Defaults to 0.5, i.e., the mid point between coord1 and coord2.

0.5

Returns:

Name Type Description
NDArray NDArray

A 2-element numpy.ndarray representing the interpolated lat/lon point.

Source code in earthcarekit/utils/geo/interpolate.py
def get_coord_between(
    coord1: ArrayLike,
    coord2: ArrayLike,
    f: float = 0.5,
) -> NDArray:
    """
    Interpolates between two coordinates by fraction f (0 to 1).

    Args:
        coord1 (ArrayLike): The first lat/lon point.
        coord2 (ArrayLike): The second lat/lon point.
        f (float): A fractional value between 0 and 1. Defaults to 0.5, i.e., the mid point between coord1 and coord2.

    Returns:
        NDArray: A 2-element `numpy.ndarray` representing the interpolated lat/lon point.
    """

    coord1 = np.array(coord1)
    coord2 = np.array(coord2)

    if coord1.shape != (2,):
        raise ValueError(f"coord1 must be a 2-element sequence (lat, lon)")

    if coord2.shape != (2,):
        raise ValueError(f"coord2 must be a 2-element sequence (lat, lon)")

    lon, lat = interpgeo(
        lat1=float(coord1[0]),
        lon1=float(coord1[1]),
        lat2=float(coord2[0]),
        lon2=float(coord2[1]),
        f=f,
    )
    return np.array([lat, lon])

get_coords

get_coords(ds, *, lat_var=TRACK_LAT_VAR, lon_var=TRACK_LON_VAR, flatten=False)

Takes a xarray.Dataset and returns the lat/lon coordinates as a numpy array.

Parameters:

Name Type Description Default
lat_var str

Name of the latitude variable. Defaults to TRACK_LAT_VAR.

TRACK_LAT_VAR
lon_var str

Name of the longitude variable. Defaults to TRACK_LON_VAR.

TRACK_LON_VAR
flatten bool

If True, the coordinates will be flattened to a 2D array

  • 1st dimension: time
  • 2nd dimension: lat/lon
False

Returns:

Type Description
NDArray

numpy.array: The extracted lat/lon coordinates.

Source code in earthcarekit/utils/geo/coordinates.py
def get_coords(
    ds: xr.Dataset,
    *,
    lat_var: str = TRACK_LAT_VAR,
    lon_var: str = TRACK_LON_VAR,
    flatten: bool = False,
) -> NDArray:
    """Takes a `xarray.Dataset` and returns the lat/lon coordinates as a numpy array.

    Args:
        lat_var (str, optional): Name of the latitude variable. Defaults to TRACK_LAT_VAR.
        lon_var (str, optional): Name of the longitude variable. Defaults to TRACK_LON_VAR.
        flatten (bool, optional):
            If True, the coordinates will be flattened to a 2D array

            - 1st dimension: time
            - 2nd dimension: lat/lon

    Returns:
        numpy.array: The extracted lat/lon coordinates.
    """
    lat = ds[lat_var].values
    lon = ds[lon_var].values
    coords = np.stack((lat, lon)).transpose()

    if len(coords.shape) > 2 and flatten:
        coords = coords.reshape(-1, 2)
    return coords

get_ground_site

get_ground_site(site)

Retruns ground site data based on name and raises ValueError if no matching ground site is found and TypeError.

Source code in earthcarekit/utils/ground_sites.py
def get_ground_site(site: str | GroundSite) -> GroundSite:
    """Retruns ground site data based on name and raises `ValueError` if no matching ground site is found and `TypeError`."""
    if isinstance(site, GroundSite):
        return site
    if not isinstance(site, str):
        raise TypeError(
            f"{get_ground_site.__name__}() Expected type `{str.__name__}` but got `{type(site).__name__}` (name={site})"
        )
    site = site.lower()
    for gs in GROUND_SITES:
        if site in gs.aliases:
            return gs

    gss = [gs.name for gs in GROUND_SITES]
    error_msg = f"""No matching ground site found: '{site}'. Supported site names are: '{gss[0]}', {"', '".join(gss[1:-1])}', and '{gss[-1]}'."""
    raise ValueError(error_msg)

get_overpass_info

get_overpass_info(
    ds,
    site,
    radius_km=100.0,
    *,
    time_var=TIME_VAR,
    lat_var=TRACK_LAT_VAR,
    lon_var=TRACK_LON_VAR,
    along_track_dim=ALONG_TRACK_DIM
)

Extract details about an overpass, including duration, distance, time, closest index, etc.

Parameters:

Name Type Description Default
ds str | Dataset

Path to or instance of a dataset containing along-track satellite data.

required
site GroundSite | str

Site name or object over which the satellite is passing.

required
radius_km float | int

Radius to look for an overpass in kilometers. Defaults to 100.

100.0
time_var str

Name of the dataset variable containing time data. Defaults to "time".

TIME_VAR
lat_var str

Name of the dataset variable containing latitude data. Defaults to "latitude".

TRACK_LAT_VAR
lon_var str

Name of the dataset variable containing longitude data. Defaults to "longitude".

TRACK_LON_VAR
along_track_dim str

Name of the along-track or temporal dataset dimension. Defaults to "along_track".

ALONG_TRACK_DIM

Raises:

Type Description
TypeError

If ds is not of type str (i.e., filepath) or xr.Dataset.

Returns:

Name Type Description
OverpassInfo OverpassInfo

description

Source code in earthcarekit/utils/overpass.py
def get_overpass_info(
    ds: str | xr.Dataset,
    site: GroundSite | str,
    radius_km: float | int = 100.0,
    *,
    time_var: str = TIME_VAR,
    lat_var: str = TRACK_LAT_VAR,
    lon_var: str = TRACK_LON_VAR,
    along_track_dim: str = ALONG_TRACK_DIM,
) -> OverpassInfo:
    """
    Extract details about an overpass, including duration, distance, time, closest index, etc.

    Args:
        ds (str | xr.Dataset): Path to or instance of a dataset containing along-track satellite data.
        site (GroundSite | str): Site name or object over which the satellite is passing.
        radius_km (float | int, optional): Radius to look for an overpass in kilometers. Defaults to 100.
        time_var (str, optional): Name of the dataset variable containing time data. Defaults to "time".
        lat_var (str, optional): Name of the dataset variable containing latitude data. Defaults to "latitude".
        lon_var (str, optional): Name of the dataset variable containing longitude data. Defaults to "longitude".
        along_track_dim (str, optional): Name of the along-track or temporal dataset dimension. Defaults to "along_track".

    Raises:
        TypeError: If `ds` is not of type `str` (i.e., filepath) or `xr.Dataset`.

    Returns:
        OverpassInfo: _description_
    """
    if isinstance(ds, str):
        with read_product(ds) as _ds:
            result = _get_overpass_info(
                _ds,
                radius_km=radius_km,
                site=site,
                time_var=time_var,
                lat_var=lat_var,
                lon_var=lon_var,
                along_track_dim=along_track_dim,
            )
    elif isinstance(ds, xr.Dataset):
        result = _get_overpass_info(
            ds,
            radius_km=radius_km,
            site=site,
            time_var=time_var,
            lat_var=lat_var,
            lon_var=lon_var,
            along_track_dim=along_track_dim,
        )
    else:
        raise TypeError(
            f"`ds` has invalid type '{type(ds).__name__}', expected 'str' (i.e. filepath) or 'xr.Dataset'"
        )

    return result

get_product_info

get_product_info(filepath, warn=False, must_exist=True)

Gather all info contained in the EarthCARE product's file path.

Source code in earthcarekit/utils/read/product/file_info/product_info.py
def get_product_info(
    filepath: str,
    warn: bool = False,
    must_exist: bool = True,
) -> ProductInfo:
    """Gather all info contained in the EarthCARE product's file path."""
    if _is_url(filepath):
        filepath = _get_path_from_url(filepath)
        must_exist = False

    filepath = os.path.abspath(filepath)

    if must_exist and not os.path.exists(filepath):
        raise FileNotFoundError(f"File does not exist: {filepath}")

    if must_exist:
        pattern = re.compile(
            r".*ECA_[EJ][XNO][A-Z]{2}_..._..._.._\d{8}T\d{6}Z_\d{8}T\d{6}Z_\d{5}[ABCDEFGH]\.h5"
        )
    else:
        pattern = re.compile(
            r".*ECA_[EJ][XNO][A-Z]{2}_..._..._.._\d{8}T\d{6}Z_\d{8}T\d{6}Z_\d{5}[ABCDEFGH].*"
        )
    is_match = bool(pattern.fullmatch(filepath))

    if not is_match:
        pattern_orbit_file = re.compile(
            r".*ECA_[EJ][XNO][A-Z]{2}_..._......_\d{8}T\d{6}Z_\d{8}T\d{6}Z_\d{4}.*"
        )
        is_match = bool(pattern_orbit_file.fullmatch(filepath))

        if not is_match:
            raise ValueError(f"EarthCARE product has invalid file name: {filepath}")

        filename = os.path.basename(filepath).removesuffix(".h5")
        mission_id = FileMissionID.from_input(filename[0:3])
        agency = FileAgency.from_input(filename[4])
        latency = FileLatency.from_input(filename[5])
        baseline = filename[6:8]
        file_type = FileType.from_input(filename[9:19])
        start_sensing_time: pd.Timestamp
        try:
            start_sensing_time = pd.Timestamp(filename[20:35])
        except ValueError as e:
            start_sensing_time = pd.NaT  # type: ignore
        start_processing_time: pd.Timestamp
        try:
            start_processing_time = pd.Timestamp(filename[37:52])
        except ValueError as e:
            start_processing_time = pd.NaT  # type: ignore

        info = ProductInfo(
            mission_id=mission_id,
            agency=agency,
            latency=latency,
            baseline=baseline,
            file_type=file_type,
            start_sensing_time=start_sensing_time,
            start_processing_time=start_processing_time,
            orbit_number=0,
            frame_id="",
            orbit_and_frame="",
            name=filename,
            filepath=filepath,
            hdr_filepath="",
        )

        return info

    product_filepath = filepath.removesuffix(".h5").removesuffix(".HDR") + ".h5"
    if not os.path.exists(product_filepath):
        if warn:
            msg = f"Missing product file: {product_filepath}"
            warnings.warn(msg)
        product_filepath = ""

    hdr_filepath = filepath.removesuffix(".h5").removesuffix(".HDR") + ".HDR"
    if not os.path.exists(hdr_filepath):
        if warn:
            msg = f"Missing product header file: {hdr_filepath}"
            warnings.warn(msg)
        hdr_filepath = ""

    filename = os.path.basename(filepath).removesuffix(".h5").removesuffix(".HDR")
    mission_id = FileMissionID.from_input(filename[0:3])
    agency = FileAgency.from_input(filename[4])
    latency = FileLatency.from_input(filename[5])
    baseline = filename[6:8]
    file_type = FileType.from_input(filename[9:19])
    start_sensing_time = pd.Timestamp(filename[20:35])
    start_processing_time = pd.Timestamp(filename[37:52])
    orbit_number = int(filename[54:59])
    frame_id = filename[59]
    orbit_and_frame = filename[54:60]

    info = ProductInfo(
        mission_id=mission_id,
        agency=agency,
        latency=latency,
        baseline=baseline,
        file_type=file_type,
        start_sensing_time=start_sensing_time,
        start_processing_time=start_processing_time,
        orbit_number=orbit_number,
        frame_id=frame_id,
        orbit_and_frame=orbit_and_frame,
        name=filename,
        filepath=product_filepath,
        hdr_filepath=hdr_filepath,
    )

    return info

get_product_infos

get_product_infos(filepaths, warn=False, must_exist=True)

Extracts product metadata from EarthCARE product file paths (e.g. file_type, orbit_number, frame_id, baseline, ...).

Parameters:

Name Type Description Default
filepaths str | list[str] | NDArray | DataFrame | Dataset

Input sources for EarthCARE product files. Can be one of - str -> A single file path. - list[str] or numpy.ndarray -> A list or array of file paths. - pandas.DataFrame -> Must contain a 'filepath' column. - xarray.Dataset -> Must have encoding with attribute 'source' (str) or 'sources' (list[str]).

required
**kwargs

Additional arguments passed to get_product_info().

required

Returns:

Name Type Description
ProductDataFrame ProductDataFrame

A dataframe containing extracted product information.

Source code in earthcarekit/utils/read/product/file_info/product_info.py
def get_product_infos(
    filepaths: str | list[str] | NDArray | pd.DataFrame | xr.Dataset,
    warn: bool = False,
    must_exist: bool = True,
) -> "ProductDataFrame":
    """
    Extracts product metadata from EarthCARE product file paths (e.g. file_type, orbit_number, frame_id, baseline, ...).

    Args:
        filepaths:
            Input sources for EarthCARE product files. Can be one of
            - `str` -> A single file path.
            - `list[str]` or `numpy.ndarray` -> A list or array of file paths.
            - `pandas.DataFrame` -> Must contain a 'filepath' column.
            - `xarray.Dataset` -> Must have encoding with attribute 'source' (`str`) or 'sources' (`list[str]`).
        **kwargs: Additional arguments passed to `get_product_info()`.

    Returns:
        ProductDataFrame: A dataframe containing extracted product information.
    """
    _filepaths: list[str] | NDArray
    if isinstance(filepaths, (str, np.str_)):
        _filepaths = [str(filepaths)]
    elif isinstance(filepaths, xr.Dataset):
        ds: xr.Dataset = filepaths
        if not hasattr(ds, "encoding"):
            raise ValueError(f"Dataset missing encoding attribute.")
        elif "source" in ds.encoding:
            _filepaths = [ds.encoding["source"]]
        elif "sources" in ds.encoding:
            _filepaths = ds.encoding["sources"]
        else:
            raise ValueError(f"Dataset encoding does not contain source or sources.")
    elif isinstance(filepaths, pd.DataFrame):
        df: pd.DataFrame = filepaths
        if "filepath" in df:
            _filepaths = df["filepath"].to_numpy()
        else:
            raise ValueError(
                f"""Given dataframe does not contain a column of file paths. A valid file path column name is "filepath"."""
            )
    else:
        _filepaths = filepaths

    infos = []
    for filepath in _filepaths:
        try:
            infos.append(
                get_product_info(filepath, warn=warn, must_exist=must_exist).to_dict()
            )
        except ValueError as e:
            continue
    pdf = ProductDataFrame(infos)
    pdf.validate_columns()
    return pdf

haversine

haversine(a, b, units='km', radius_m=MEAN_EARTH_RADIUS_METERS)

Calculates the great-circle (spherical) distance between pairs of latitude/longitude coordinates using the haversine formula.

Parameters:

Name Type Description Default
a ArrayLike

An array-like object of shape (..., 2) containing latitude and longitude coordinates in degrees. The last dimension must be 2: (lat, lon).

required
b ArrayLike

An array-like object of the same shape as a, containing corresponding latitude and longitude coordinates.

required
units Literal['m', 'km']

Unit of the output distance. Must be either "km" for kilometers or "m" for meters. Defaults to "km".

'km'
radius float

Radius of the sphere to use for distance calculation. Defaults to MEAN_EARTH_RADIUS_METERS (based on WSG 84 ellipsoid: ~6371008.77 meters). Note: If units="km", this value is automatically converted to kilometers.

required

Returns:

Type Description

np.ndarray: Array of great-circle distances between a and b, in the specified units. The shape matches the input shape excluding the last dimension.

Raises:

Type Description
ValueError

If the shapes of a and b are incompatible or units is not one of "m" or "km".

Examples:

>>> haversine([51.352757, 12.43392], [38.559, 68.856])
4537.564747442274
>>> haversine([0,0], [[0,0], [10,0], [20,0]])
array([   0.        , 1111.95079735, 2223.90159469])
>>> haversine([[0,0], [10,0], [20,0]], [[0,0], [10,0], [20,0]])
array([0., 0., 0.])
Source code in earthcarekit/utils/geo/distance/_haversine.py
def haversine(
    a: ArrayLike,
    b: ArrayLike,
    units: Literal["m", "km"] = "km",
    radius_m: float = MEAN_EARTH_RADIUS_METERS,
):
    """
    Calculates the great-circle (spherical) distance between pairs of latitude/longitude coordinates
    using the haversine formula.

    Args:
        a (ArrayLike): An array-like object of shape (..., 2) containing latitude and longitude
            coordinates in degrees. The last dimension must be 2: (lat, lon).
        b (ArrayLike): An array-like object of the same shape as `a`, containing corresponding
            latitude and longitude coordinates.
        units (Literal["m", "km"], optional): Unit of the output distance. Must be either
            "km" for kilometers or "m" for meters. Defaults to "km".
        radius (float, optional): Radius of the sphere to use for distance calculation.
            Defaults to MEAN_EARTH_RADIUS_METERS (based on WSG 84 ellipsoid: ~6371008.77 meters).
            Note: If `units="km"`, this value is automatically converted to kilometers.

    Returns:
        np.ndarray: Array of great-circle distances between `a` and `b`, in the specified units.
            The shape matches the input shape excluding the last dimension.

    Raises:
        ValueError: If the shapes of `a` and `b` are incompatible or `units` is not one of "m" or "km".

    Examples:
        >>> haversine([51.352757, 12.43392], [38.559, 68.856])
        4537.564747442274
        >>> haversine([0,0], [[0,0], [10,0], [20,0]])
        array([   0.        , 1111.95079735, 2223.90159469])
        >>> haversine([[0,0], [10,0], [20,0]], [[0,0], [10,0], [20,0]])
        array([0., 0., 0.])
    """

    if units not in ["m", "km"]:
        raise ValueError(
            f"{haversine.__name__}() Invalid units : {units}. Use 'm' or 'km' instead."
        )

    radius: float = radius_m
    if units == "km":
        radius = radius / 1000.0

    a = np.array(a)
    b = np.array(b)

    coord_a = np.atleast_2d(a)
    coord_b = np.atleast_2d(b)

    if (coord_a.shape[1] != 2) or (coord_b.shape[1] != 2):
        raise ValueError(
            f"At least one passed array has a wrong shape (a={a.shape}, b={b.shape}). 1d arrays should be of length 2 (i.e. [lat, lon]) and 2d array should have the shape (n, 2)."
        )
    if (coord_a.shape[0] < 1) or (coord_b.shape[0] < 1):
        raise ValueError(
            f"At least one passed array contains no values (a={a.shape}, b={b.shape})."
        )
    if coord_a.shape[0] != coord_b.shape[0]:
        if (coord_a.shape[0] != 1) and (coord_b.shape[0] != 1):
            raise ValueError(
                f"The shapes of passed arrays dont match (a={a.shape}, b={b.shape}). Either both should contain the same number of coordinates or at least one of them should contain a single coordinate."
            )

    coord_a = np.radians(coord_a)
    coord_b = np.radians(coord_b)

    phi_1, lambda_1 = coord_a[:, 0], coord_a[:, 1]
    phi_2, lambda_2 = coord_b[:, 0], coord_b[:, 1]

    hav = lambda theta: (1 - np.cos(theta)) / 2

    h = hav(phi_2 - phi_1) + np.cos(phi_1) * np.cos(phi_2) * hav(lambda_2 - lambda_1)

    d = 2 * radius * np.arcsin(np.sqrt(h))

    if len(a.shape) == 1 and len(b.shape) == 1:
        return d[0]

    return d

perform_anom_depol_statistics

perform_anom_depol_statistics(
    ds_anom,
    selection_height_range,
    is_rayleigh_corrected=False,
    rayleigh_correction_factor=0.004,
    **kwargs
)

Calculate depolarization statistics and uncertainties within a height range.

This function adds the depol. ratio (DPOL) calculated from co- (CPOL) and cross-polarized (XPOL) attenuated backscatter to the dataset (ATL_NOM_1B) and computes related statistics. Mean values and standard deviations are calculated for CPOL, XPOL, and DPOL within the selected height range. Variability is separated into vertical and temporal components. Errors of DPOL are derived using error propagation for the XPOL/CPOL ratio.

Parameters:

Name Type Description Default
ds_anom Dataset

ATL_NOM_1B dataset with cross- and co-polar signals.

required
selection_height_range DistanceRangeLike

Height range for statistics.

required
is_rayleight_corrected bool

If True, the mean cross-polar profile is corrected by substracting the mean rayleigh profile scaled by a correction factor. Defaults to False.

required
rayleigh_correction_factor float

The scaling factor used when is_rayleight_corrected is True. Defaults to 0.004.

0.004

Returns:

Name Type Description
_ANOMDepolCalculationResults _ANOMDepolCalculationResults

Results container with

  • Mean and standard deviation of depolarization ratio.
  • Mean, vertical, temporal, and combined spreads for co- and cross-polar signals.
  • Propagated uncertainty of δ (total, vertical, temporal).
  • Input dataset with depolarization ratio added.
Example
import earthcarekit as eck

ft = "ANOM"
oaf = "01508B"
site = "dushanbe"
radius_km = 100
sel_hrange = (1e3, 4e3)

# # Optionally, download required data
# eck.ecdownload(file_type=ft, orbit_and_frame=oaf)

df = eck.search_product(file_type=ft, orbit_and_frame=oaf)
fp = df.filepath[-1]

with eck.read_any(fp) as ds:
    ds = eck.filter_radius(ds, radius_km=radius_km, site=site)
    results = eck.perform_anom_depol_statistics(ds, sel_hrange)
    results.print()  # prints statistics

    # # Optionally, save statistics as CSV file
    # results.stats.to_csv("./stats.csv")

    # # Optionally, save profile figure as PNG file
    # fig = results.plot(height_range=(0, 10e3))
    # eck.save_plot(fig, filepath="./depol_profile.png")
Source code in earthcarekit/calval/_perform_anom_depol_statistics.py
def perform_anom_depol_statistics(
    ds_anom: xr.Dataset,
    selection_height_range: DistanceRangeLike,
    is_rayleigh_corrected: bool = False,
    rayleigh_correction_factor: float = 0.004,
    **kwargs,
) -> _ANOMDepolCalculationResults:
    """
    Calculate depolarization statistics and uncertainties within a height range.

    This function adds the depol. ratio (`DPOL`) calculated from co- (`CPOL`) and cross-polarized (`XPOL`)
    attenuated backscatter to the dataset (ATL_NOM_1B) and computes related statistics.
    Mean values and standard deviations are calculated for `CPOL`, `XPOL`, and `DPOL` within the selected
    height range. Variability is separated into vertical and temporal components. Errors of `DPOL` are derived
    using error propagation for the `XPOL`/`CPOL` ratio.

    Args:
        ds_anom (xr.Dataset): ATL_NOM_1B dataset with cross- and co-polar signals.
        selection_height_range (DistanceRangeLike): Height range for statistics.
        is_rayleight_corrected (bool): If True, the mean cross-polar profile is corrected by substracting the
            mean rayleigh profile scaled by a correction factor. Defaults to False.
        rayleigh_correction_factor (float): The scaling factor used when `is_rayleight_corrected` is True.
            Defaults to 0.004.

    Returns:
        _ANOMDepolCalculationResults: Results container with

            - Mean and standard deviation of depolarization ratio.
            - Mean, vertical, temporal, and combined spreads for co- and cross-polar signals.
            - Propagated uncertainty of δ (total, vertical, temporal).
            - Input dataset with depolarization ratio added.

    Example:
        ```python
        import earthcarekit as eck

        ft = "ANOM"
        oaf = "01508B"
        site = "dushanbe"
        radius_km = 100
        sel_hrange = (1e3, 4e3)

        # # Optionally, download required data
        # eck.ecdownload(file_type=ft, orbit_and_frame=oaf)

        df = eck.search_product(file_type=ft, orbit_and_frame=oaf)
        fp = df.filepath[-1]

        with eck.read_any(fp) as ds:
            ds = eck.filter_radius(ds, radius_km=radius_km, site=site)
            results = eck.perform_anom_depol_statistics(ds, sel_hrange)
            results.print()  # prints statistics

            # # Optionally, save statistics as CSV file
            # results.stats.to_csv("./stats.csv")

            # # Optionally, save profile figure as PNG file
            # fig = results.plot(height_range=(0, 10e3))
            # eck.save_plot(fig, filepath="./depol_profile.png")
        ```
    """

    selection_height_range = validate_numeric_range(selection_height_range)

    ds_anom = add_depol_ratio(ds_anom, **kwargs)

    cpol_p: ProfileData = ProfileData.from_dataset(
        ds_anom, var="cpol_cleaned_for_ratio_calculation"
    )
    xpol_p: ProfileData = ProfileData.from_dataset(
        ds_anom, var="xpol_cleaned_for_ratio_calculation"
    )
    ray_p: ProfileData = ProfileData.from_dataset(
        ds_anom, var="ray_cleaned_for_ratio_calculation"
    )
    cpol_mean_p: ProfileData = cpol_p.mean()
    xpol_mean_p: ProfileData = xpol_p.mean()
    ray_mean_p: ProfileData = ray_p.mean()

    if is_rayleigh_corrected:
        xpol_mean_p = xpol_mean_p - (ray_mean_p * rayleigh_correction_factor)

    dpol_mean_p: ProfileData = xpol_mean_p / cpol_mean_p
    cpol_std_p: ProfileData = cpol_p.std()
    xpol_std_p: ProfileData = xpol_p.std()

    cpol_stats = cpol_p.stats(selection_height_range)
    if is_rayleigh_corrected:
        xpol_p_corr = xpol_p - (ray_p * rayleigh_correction_factor)
        xpol_stats = xpol_p_corr.stats(selection_height_range)
    else:
        xpol_stats = xpol_p.stats(selection_height_range)
    dpol_stats = dpol_mean_p.stats(selection_height_range)

    dpol_mean: float = dpol_stats.mean
    dpol_std: float = dpol_stats.std
    cpol_mean: float = cpol_stats.mean
    xpol_mean: float = xpol_stats.mean
    cpol_std_t: float = cpol_std_p.stats(selection_height_range).std
    xpol_std_t: float = xpol_std_p.stats(selection_height_range).std
    cpol_std_z: float = cpol_stats.std
    xpol_std_z: float = xpol_stats.std
    cpol_std: float = cpol_std_t + cpol_std_z
    xpol_std: float = xpol_std_t + xpol_std_z

    calc_error = lambda xsd, csd: np.sqrt(
        (xsd / cpol_mean) ** 2 + (((xpol_mean / (cpol_mean**2)) * csd) ** 2)
    )
    error = calc_error(xpol_std, cpol_std)
    error_z = calc_error(xpol_std_z, cpol_std_z)
    error_t = calc_error(xpol_std_t, cpol_std_t)

    return _ANOMDepolCalculationResults(
        ds=ds_anom.copy(),
        selection_height_range=selection_height_range,
        dpol_mean=dpol_mean,
        dpol_std=dpol_std,
        cpol_mean=cpol_mean,
        cpol_std_t=cpol_std_t,
        cpol_std_z=cpol_std_z,
        cpol_std=cpol_std,
        xpol_mean=xpol_mean,
        xpol_std_t=xpol_std_t,
        xpol_std_z=xpol_std_z,
        xpol_std=xpol_std,
        error=error,
        error_t=error_t,
        error_z=error_z,
    )

plot_line_between_figures

plot_line_between_figures(
    ax1,
    ax2,
    point1,
    point2=None,
    color="ec:red",
    linestyle="dashed",
    linewidth=2,
    alpha=0.3,
    capstyle="butt",
    zorder=-20,
    **kwargs
)

Draws a line connecting a point in one subfigure (ax1) to a point in another (ax2).

Source code in earthcarekit/plot/figure/_plot_line_between_figures.py
def plot_line_between_figures(
    ax1: Axes,
    ax2: Axes,
    point1: _NumberTimeOrTuple,
    point2: _NumberTimeOrTuple | None = None,
    color: ColorLike | None = "ec:red",
    linestyle: str = "dashed",
    linewidth: int | float = 2,
    alpha: int | float = 0.3,
    capstyle: str = "butt",
    zorder: int | float = -20,
    **kwargs,
) -> None:
    """Draws a line connecting a point in one subfigure (ax1) to a point in another (ax2)."""
    p1: tuple[float, float] = _get_point(ax1, 1, point1)
    if point2 is None:
        point2 = point1
    p2: tuple[float, float] = _get_point(ax2, 2, point2)

    con = ConnectionPatch(
        xyA=p1,
        coordsA=ax1.transData,
        xyB=p2,
        coordsB=ax2.transData,
        axesA=ax1,
        axesB=ax2,
        color=Color.from_optional(color),
        linestyle=linestyle,
        linewidth=linewidth,
        alpha=alpha,
        capstyle=capstyle,
        zorder=zorder,
        **kwargs,
    )
    ax1.figure.add_artist(con)

read_any

read_any(input, **kwargs)

Reads various input types and returns an xarray.Dataset.

This function can read
  • EarthCARE product files (.h5)
  • NetCDF files (.nc)
  • Manually processed PollyXT output files (.txt)

Parameters:

Name Type Description Default
input str | Dataset

File path or existing Dataset.

required
**kwargs

Additional keyword arguments for specific readers.

{}

Returns:

Type Description
Dataset

xr.Dataset: Opened dataset.

Raises:

Type Description
ValueError

If the file type is not supported.

TypeError

If the input type is invalid.

Source code in earthcarekit/utils/read/_read_any.py
def read_any(input: str | xr.Dataset, **kwargs) -> xr.Dataset:
    """Reads various input types and returns an `xarray.Dataset`.

    This function can read:
        - EarthCARE product files (`.h5`)
        - NetCDF files (`.nc`)
        - Manually processed PollyXT output files (`.txt`)

    Args:
        input (str | xr.Dataset): File path or existing Dataset.
        **kwargs: Additional keyword arguments for specific readers.

    Returns:
        xr.Dataset: Opened dataset.

    Raises:
        ValueError: If the file type is not supported.
        TypeError: If the input type is invalid.
    """
    if isinstance(input, xr.Dataset):
        return input
    elif isinstance(input, str):
        filepath = input

        if is_earthcare_product(filepath=filepath):
            return read_product(filepath, **kwargs)

        filename = os.path.basename(filepath)
        _, ext = os.path.splitext(filename)
        if ext.lower() == ".txt":
            return read_polly(filepath)
        elif ext.lower() == ".nc":
            return read_nc(filepath, **kwargs)

        raise ValueError(f"Reading of file not supported: <{input}>")
    raise TypeError(f"Invalid type '{type(input).__name__}' for input.")

read_header_data

read_header_data(source: str) -> Dataset
read_header_data(source: Dataset) -> Dataset
read_header_data(source)

Opens the product header groups of a EarthCARE file as a xarray.Dataset.

Source code in earthcarekit/utils/read/product/header_group.py
def read_header_data(source: str | xr.Dataset) -> xr.Dataset:
    """Opens the product header groups of a EarthCARE file as a `xarray.Dataset`."""
    if isinstance(source, str):
        filepath = source
    elif isinstance(source, xr.Dataset):
        filepath = source.encoding.get("source", None)
        if filepath is None:
            raise ValueError(f"Dataset missing source attribute")
    else:
        raise TypeError("Expected 'str' or 'xarray.Dataset'")

    groups = xr.open_groups(filepath)
    header_groups = {n: g for n, g in groups.items() if "HeaderData" in n}

    # Rename duplicate vars

    all_vars = {}
    header_datasets = []
    for i, (group_name, ds) in enumerate(header_groups.items()):
        ds_new = ds.copy()
        for var in ds.data_vars:
            if var in all_vars:
                new_name = f"{group_name.split('/')[-1]}_{var}"
                ds_new = ds_new.rename({var: new_name})
            else:
                all_vars[var] = True
        header_datasets.append(ds_new)

    ds = xr.merge(header_datasets)

    # Convert timestamps to numpy datetime
    for var in [
        "Creation_Date",
        "Validity_Start",
        "Validity_Stop",
        "ANXTime",
        "frameStartTime",
        "frameStopTime",
        "processingStartTime",
        "processingStopTime",
        "sensingStartTime",
        "sensingStopTime",
        "stateVectorTime",
    ]:
        if var in ds:
            raw = ds[var].values
            formatted = np.char.replace(raw, "UTC=", "")
            ds[var].values = formatted.astype("datetime64[ns]")

    # Ensure that strings are correctly decoded
    for var in ["frameID"]:
        if var in ds:
            ds = convert_scalar_var_to_str(ds, var)

    # Remove dimensions of size == 1
    ds = ds.squeeze()

    return ds

read_nc

read_nc(input, modify=True, in_memory=False, **kwargs)

Returns an xarray.Dataset from a Dataset or NetCDF file path, optionally loaded into memory.

Parameters:

Name Type Description Default
input Dataset or str

Path to a NetCDF file. If a already opened xarray.Dataset object is passed, it is returned as is.

required
modify bool

If True, default modifications to the opened dataset will be applied (e.g., converting heights in Polly data from height a.g.l. to height above mean sea level).

True
in_memory bool

If True, ensures the dataset is fully loaded into memory. Defaults to False.

False
**kwargs

Key-word arguments passed to xarray.open_dataset().

{}

Returns:

Type Description
Dataset

xarray.Dataset: The resulting dataset.

Raises:

Type Description
TypeError

If input is not a Dataset or string.

Source code in earthcarekit/utils/read/_read_nc.py
def read_nc(
    input: str | xr.Dataset,
    modify: bool = True,
    in_memory: bool = False,
    **kwargs,
) -> xr.Dataset:
    """Returns an `xarray.Dataset` from a Dataset or NetCDF file path, optionally loaded into memory.

    Args:
        input (xarray.Dataset or str): Path to a NetCDF file. If a already opened `xarray.Dataset` object is passed, it is returned as is.
        modify (bool): If True, default modifications to the opened dataset will be applied
            (e.g., converting heights in Polly data from height a.g.l. to height above mean sea level).
        in_memory (bool, optional): If True, ensures the dataset is fully loaded into memory. Defaults to False.
        **kwargs: Key-word arguments passed to `xarray.open_dataset()`.

    Returns:
        xarray.Dataset: The resulting dataset.

    Raises:
        TypeError: If input is not a Dataset or string.
    """
    ds: xr.Dataset
    if isinstance(input, xr.Dataset):
        ds = input
    elif isinstance(input, str):
        if in_memory:
            with _read_nc(input, modify=modify, **kwargs) as ds:
                ds = ds.load()
        else:
            ds = _read_nc(input, modify=modify, **kwargs)
    else:
        raise TypeError(
            f"Invalid input type! Expecting a opened NetCDF dataset (xarray.Dataset) or a path to a NetCDF file."
        )
    return ds

read_polly

read_polly(input)

Reads manually processed PollyXT output text files as xarray.Dataset or returns an already open one.

Source code in earthcarekit/utils/read/_read_polly.py
def read_polly(input: str | xr.Dataset) -> xr.Dataset:
    """Reads manually processed PollyXT output text files as `xarray.Dataset` or returns an already open one."""

    if isinstance(input, xr.Dataset):
        return input

    with open(input, "r", encoding="utf-8", errors="ignore") as f:
        df = pd.read_csv(f, sep="\t")

    new_columns = [_parse_column_name(c) for c in df.columns]
    new_column_names = [c.name for c in new_columns]
    new_column_names = _make_column_names_unique(new_column_names)
    df.columns = pd.Index(new_column_names)

    ds = xr.Dataset.from_dataframe(df)
    ds = ds.assign_coords(index=ds.height.values)
    ds = ds.rename({"index": "vertical"})
    if "time" not in ds:
        ds = ds.assign({"time": np.datetime64("1970-01-01T00:00:00.000", "ms")})

    vars_order = ["time"] + [v for v in ds.data_vars if v != "time"]
    ds = ds[vars_order]

    for c in new_columns:
        if c.units == "km":
            ds[c.name].values = ds[c.name].values * 1e3
            c.units = c.units.replace("k", "")
        elif c.units in ["Mm-1 sr-1", "Mm-1", "Msr-1"]:
            ds[c.name].values = ds[c.name].values / 1e6
            c.units = c.units.replace("M", "")

        ds[c.name] = ds[c.name].assign_attrs(
            dict(
                long_name=c.long_name,
                units=c.units,
            )
        )
    return ds

read_product

read_product(
    input,
    trim_to_frame=True,
    modify=DEFAULT_READ_EC_PRODUCT_MODIFY,
    header=DEFAULT_READ_EC_PRODUCT_HEADER,
    meta=DEFAULT_READ_EC_PRODUCT_META,
    ensure_nans=DEFAULT_READ_EC_PRODUCT_ENSURE_NANS,
    in_memory=False,
    **kwargs
)

Returns an xarray.Dataset from a Dataset or EarthCARE file path, optionally loaded into memory.

Parameters:

Name Type Description Default
input str or Dataset

Path to a EarthCARE file. If a xarray.Dataset is given it will be returned as is.

required
trim_to_frame bool

Whether to trim the dataset to latitude frame bounds. Defaults to True.

True
modify bool

If True, default modifications to the opened dataset will be applied (e.g., renaming dimension corresponding to height to "vertical"). Defaults to True.

DEFAULT_READ_EC_PRODUCT_MODIFY
header bool

If True, all header data will be included in the dataframe. Defaults to False.

DEFAULT_READ_EC_PRODUCT_HEADER
meta bool

If True, select meta data from header (like orbit number and frame ID) will be included in the dataframe. Defaults to True.

DEFAULT_READ_EC_PRODUCT_META
ensure_nans bool

If True, ensures that _FillValues are set to NaNs even if encoding of _FillValues or dtype is missing. Be aware, if True increases reading time. Defaults to False.

DEFAULT_READ_EC_PRODUCT_ENSURE_NANS
in_memory bool

If True, ensures the dataset is fully loaded into memory. Defaults to False.

False

Returns:

Type Description
Dataset

xarray.Dataset: The resulting dataset.

Raises:

Type Description
TypeError

If input is not a Dataset or string.

Source code in earthcarekit/utils/read/product/_generic.py
def read_product(
    input: str | Dataset,
    trim_to_frame: bool = True,
    modify: bool = DEFAULT_READ_EC_PRODUCT_MODIFY,
    header: bool = DEFAULT_READ_EC_PRODUCT_HEADER,
    meta: bool = DEFAULT_READ_EC_PRODUCT_META,
    ensure_nans: bool = DEFAULT_READ_EC_PRODUCT_ENSURE_NANS,
    in_memory: bool = False,
    **kwargs,
) -> Dataset:
    """Returns an `xarray.Dataset` from a Dataset or EarthCARE file path, optionally loaded into memory.

    Args:
        input (str or xarray.Dataset): Path to a EarthCARE file. If a `xarray.Dataset` is given it will be returned as is.
        trim_to_frame (bool, optional): Whether to trim the dataset to latitude frame bounds. Defaults to True.
        modify (bool, optional): If True, default modifications to the opened dataset will be applied
            (e.g., renaming dimension corresponding to height to "vertical"). Defaults to True.
        header (bool, optional): If True, all header data will be included in the dataframe. Defaults to False.
        meta (bool, optional): If True, select meta data from header (like orbit number and frame ID) will be included in the dataframe. Defaults to True.
        ensure_nans (bool, optional): If True, ensures that _FillValues are set to NaNs even  if encoding of _FillValues or dtype is missing.
            Be aware, if True increases reading time. Defaults to False.
        in_memory (bool, optional): If True, ensures the dataset is fully loaded into memory. Defaults to False.

    Returns:
        xarray.Dataset: The resulting dataset.

    Raises:
        TypeError: If input is not a Dataset or string.
    """
    ds: Dataset
    if isinstance(input, Dataset):
        ds = input
    elif isinstance(input, str):
        kwargs = dict(
            trim_to_frame=trim_to_frame,
            modify=modify,
            header=header,
            meta=meta,
            ensure_nans=ensure_nans,
            **kwargs,
        )
        if in_memory:
            with _read_product(filepath=input, **kwargs) as ds:
                ds = ds.load()
        else:
            ds = _read_product(filepath=input, **kwargs)
    else:
        raise TypeError(
            f"Invalid input type! Expecting a opened EarthCARE dataset (xarray.Dataset) or a path to a EarthCARE product."
        )
    return ds

read_products

read_products(
    filepaths,
    zoom_at=None,
    along_track_dim=ALONG_TRACK_DIM,
    func=None,
    func_inputs=None,
    max_num_files=8,
    coarsen=True,
)

Read and concatenate a sequence of EarthCARE frames into a single xarray Dataset.

By default, the dataset is coarsened according to the number of input frames (e.g., combining 3 products averages every 3 profiles, so the along-track dimension remains comparable to a single product). Optionally applies a processing function to each frame and zooms in on a specific region (defined by zoom_at) without coarsening. Coarsening can also be turned of but might case memory issues.

Parameters:

Name Type Description Default
filepaths Sequence[str] or DataFrame

EarthCARE product file paths as a list or a DataFrame with metadata including filepath, orbit_number, and frame_id.

required
zoom_at float

If set, selects only a zoomed-in portion of the frames around this fractional index. Defaults to None.

None
along_track_dim str

Name of the dimension to concatenate along. Defaults to ALONG_TRACK_DIM.

ALONG_TRACK_DIM
func Callable

Function to apply to each frame after loading. Defaults to None.

None
func_inputs Sequence[dict]

Optional per-frame arguments to pass to func. Defaults to None.

None
max_num_files int

Max. number of files that are allowed to be loaded at once. A ValueError is raised if above. Defaults to 8 (e.g., full orbit).

8
coarsen bool

If Ture, read data sets are coarened depending on the number given of files. Only aplicable when not zooming. Defaults to Ture.

True

Returns:

Name Type Description
Dataset Dataset

Concatenated dataset with all frames along along_track_dim.

Source code in earthcarekit/utils/read/product/_concat.py
def read_products(
    filepaths: Sequence[str] | NDArray[np.str_] | pd.DataFrame,
    zoom_at: float | None = None,
    along_track_dim: str = ALONG_TRACK_DIM,
    func: Callable | None = None,
    func_inputs: Sequence[dict] | None = None,
    max_num_files: int = 8,
    coarsen: bool = True,
) -> Dataset:
    """Read and concatenate a sequence of EarthCARE frames into a single xarray Dataset.

    By default, the dataset is coarsened according to the number of input frames (e.g.,
    combining 3 products averages every 3 profiles, so the along-track dimension remains
    comparable to a single product). Optionally applies a processing function to each
    frame and zooms in on a specific region (defined by `zoom_at`) without coarsening.
    Coarsening can also be turned of but might case memory issues.

    Args:
        filepaths (Sequence[str] or pandas.DataFrame):
            EarthCARE product file paths as a list or a DataFrame with metadata
            including `filepath`, `orbit_number`, and `frame_id`.
        zoom_at (float, optional):
            If set, selects only a zoomed-in portion of the frames around this
            fractional index. Defaults to None.
        along_track_dim (str, optional):
            Name of the dimension to concatenate along. Defaults to ALONG_TRACK_DIM.
        func (Callable, optional):
            Function to apply to each frame after loading. Defaults to None.
        func_inputs (Sequence[dict], optional):
            Optional per-frame arguments to pass to `func`. Defaults to None.
        max_num_files (int, optional):
            Max. number of files that are allowed to be loaded at once.
            A `ValueError` is raised if above. Defaults to 8 (e.g., full orbit).
        coarsen (bool, optional):
            If Ture, read data sets are coarened depending on the number given of files.
            Only aplicable when not zooming. Defaults to Ture.

    Returns:
        Dataset: Concatenated dataset with all frames along `along_track_dim`.
    """
    if isinstance(filepaths, str):
        filepaths = [filepaths]
    elif isinstance(filepaths, pd.DataFrame):
        df = filepaths.sort_values(by="orbit_and_frame")
        filepaths = df["filepath"].tolist()
    else:
        df = ProductDataFrame.from_files(list(filepaths)).sort_values(
            by="orbit_and_frame"
        )
        df.validate_columns()
        filepaths = df["filepath"].tolist()

    if len(filepaths) == 0:
        raise ValueError(f"Given sequence of product files paths is empty")
    elif len(filepaths) == 1:
        warnings.warn(f"Can not concatenate frames since only one file path was given")
        return read_product(filepaths[0])
    elif len(filepaths) > max_num_files:
        raise ValueError(
            f"Too many files provided: {len(filepaths)} (currently maximum allowed is {max_num_files}). "
            "Please reduce the number of files or increase the allowed amount by setting the argument max_num_files."
        )
    elif len(filepaths) > 8:
        warnings.warn(
            f"You provided {len(filepaths)} files, which is more than one full orbit (8 files). "
            "Processing might take longer than usual."
        )

    # # Construct filename suffix from orbit/frame numbers
    # orbit_start = str(df["orbit_number"].iloc[0]).zfill(5)
    # orbit_end = str(df["orbit_number"].iloc[-1]).zfill(5)
    # frame_start = df["frame_id"].iloc[0]
    # frame_end = df["frame_id"].iloc[-1]

    # if orbit_start == orbit_end:
    #     oaf_string = (
    #         f"{orbit_start}{frame_start}"
    #         if frame_start == frame_end
    #         else f"{orbit_start}{frame_start}-{frame_end}"
    #     )
    # else:
    #     oaf_string = f"{orbit_start}{frame_start}-{orbit_end}{frame_end}"

    def apply_func(ds: Dataset, i: int) -> Dataset:
        """Apply a processing function to a dataset if specified."""
        if func is None:
            return ds
        if func_inputs is None:
            return func(ds)
        if i < len(func_inputs):
            return func(ds, **func_inputs[i])
        raise IndexError("Too few function inputs provided")

    num_files = len(filepaths)
    ds: xr.Dataset | None = None

    if zoom_at is not None:
        # Zoomed read: select portions of two adjacent frames
        frame_indices = np.unique([int(np.floor(zoom_at)), int(np.ceil(zoom_at))])
        offset = zoom_at - frame_indices[0]
        filepaths = [filepaths[i] for i in frame_indices]

        for i, filepath in enumerate(filepaths):
            with read_product(filepath) as frame_ds:
                frame_ds = apply_func(frame_ds, frame_indices[i])

                # Preserve original dtypes
                original_dtypes = {v: frame_ds[v].dtype for v in frame_ds.variables}

                # Select relevant portion of the frame
                n = len(frame_ds[along_track_dim])
                sel_slice = (
                    slice(int(np.floor(n * offset)), n)
                    if i == 0
                    else slice(0, int(np.ceil(n * offset)))
                )
                frame_ds = frame_ds.sel({along_track_dim: sel_slice})

                # Restore dtypes
                for v, dtype in original_dtypes.items():
                    frame_ds[v] = frame_ds[v].astype(dtype)

                ds = (
                    frame_ds.copy()
                    if ds is None
                    else concat_datasets(
                        ds.copy(), frame_ds.copy(), dim=along_track_dim
                    )
                )

    else:
        # Full read and coarsen each frame
        for i, filepath in enumerate(filepaths):
            with read_product(filepath) as frame_ds:
                frame_ds = apply_func(frame_ds, i)

                if coarsen:
                    original_dtypes = {v: frame_ds[v].dtype for v in frame_ds.variables}

                    coarsen_dims = {along_track_dim: num_files}

                    # Circular mean for longitude
                    lon_coarse = (
                        frame_ds["longitude"]
                        .coarsen(coarsen_dims, boundary="trim")
                        .reduce(circular_mean_np)
                    )
                    _tmp_attrs = lon_coarse.attrs
                    lon_coarse.attrs = {}

                    # Regular mean for the rest
                    rest = (
                        frame_ds.drop_vars("longitude")
                        .coarsen(coarsen_dims, boundary="trim")
                        .mean()  # type: ignore
                    )

                    # Merge results
                    frame_ds = xr.merge([lon_coarse, rest])
                    frame_ds["longitude"].attrs = _tmp_attrs

                    for v, dtype in original_dtypes.items():
                        frame_ds[v] = frame_ds[v].astype(dtype)

                ds = (
                    frame_ds
                    if ds is None
                    else concat_datasets(ds, frame_ds, dim=along_track_dim)
                )

    # Set output file sources
    if isinstance(ds, Dataset):
        ds.encoding["sources"] = list(filepaths)
        return ds
    else:
        raise RuntimeError(f"Bad implementation")

read_science_data

read_science_data(filepath, agency=None, ensure_nans=False, **kwargs)

Opens the science data of a EarthCARE file as a xarray.Dataset.

Source code in earthcarekit/utils/read/product/science_group.py
def read_science_data(
    filepath: str,
    agency: Union["FileAgency", None] = None,
    ensure_nans: bool = False,
    **kwargs,
) -> xr.Dataset:
    """Opens the science data of a EarthCARE file as a `xarray.Dataset`."""
    from .file_info.agency import (
        FileAgency,  # Imported inside function to avoid circular import error
    )

    if agency is None:
        agency = FileAgency.from_input(filepath)

    if agency == FileAgency.ESA:
        ds = xr.open_dataset(filepath, group="ScienceData", engine=_engine, **kwargs)
    elif agency == FileAgency.JAXA:
        df_cpr_geo = xr.open_dataset(
            filepath,
            group="ScienceData/Geo",
            engine=_engine,
            phony_dims="sort",
            **kwargs,
        )
        df_cpr_data = xr.open_dataset(
            filepath,
            group="ScienceData/Data",
            engine=_engine,
            phony_dims="sort",
            **kwargs,
        )
        ds = xr.merge([df_cpr_data, df_cpr_geo])
        ds.encoding["source"] = df_cpr_data.encoding["source"]
    else:
        raise NotImplementedError()

    if ensure_nans:
        ds = _convert_all_fill_values_to_nan(ds)

    return ds

rebin_msi_to_jsg

rebin_msi_to_jsg(
    ds_msi,
    ds_xjsg,
    vars=None,
    k=4,
    eps=1e-12,
    lat_var=SWATH_LAT_VAR,
    lon_var=SWATH_LON_VAR,
    time_var=TIME_VAR,
    along_track_dim=ALONG_TRACK_DIM,
    across_track_dim=ACROSS_TRACK_DIM,
    lat_var_xjsg=SWATH_LAT_VAR,
    lon_var_xjsg=SWATH_LON_VAR,
    time_var_xjsg=TIME_VAR,
    along_track_dim_xjsg=ALONG_TRACK_DIM,
    across_track_dim_xjsg=ACROSS_TRACK_DIM,
)

Rebins variables from an MSI product dataset onto the geo-spacial lat/lon grid given by the related AUX_JSG_1D dataset.

This function interpolates selected variables from ds_msi onto the JSG grid from ds_xjsg using quick kd-tree nearest-neighbor search with scipy.spatial.cKDTree followed by averaging the k-nearest points using inverse distance weighting. The resulting dataframe matches the along- and across-track resolution of ds_xjsg.

Parameters:

Name Type Description Default
ds_msi Dataset | str

The source MSI dataset (e.g., MSI_RGR_1C, MSI_COP_2A, ...).

required
ds_xjsg Dataset | str

The target XJSG dataset.

required
vars list[str] | None

List of variable names from ds_msi to rebin. If None, all data variables are considered. Defaults to None.

None
k int

Number of nearest geo-spacial neighbors to include in the kd-tree search. Defaults to 4.

4
eps float

Numerical threshold to avoid division by zero in distance calculations during the kd-tree search. Defaults to 1e-12.

1e-12

Returns:

Type Description
Dataset

xr.Dataset: The MSI dataset with variables rebinned to the JSG grid.

Source code in earthcarekit/utils/read/product/_rebin_msi_to_jsg.py
def rebin_msi_to_jsg(
    ds_msi: xr.Dataset | str,
    ds_xjsg: xr.Dataset | str,
    vars: list[str] | None = None,
    k: int = 4,
    eps: float = 1e-12,
    lat_var: str = SWATH_LAT_VAR,
    lon_var: str = SWATH_LON_VAR,
    time_var: str = TIME_VAR,
    along_track_dim: str = ALONG_TRACK_DIM,
    across_track_dim: str = ACROSS_TRACK_DIM,
    lat_var_xjsg: str = SWATH_LAT_VAR,
    lon_var_xjsg: str = SWATH_LON_VAR,
    time_var_xjsg: str = TIME_VAR,
    along_track_dim_xjsg: str = ALONG_TRACK_DIM,
    across_track_dim_xjsg: str = ACROSS_TRACK_DIM,
) -> xr.Dataset:
    """
    Rebins variables from an MSI product dataset onto the geo-spacial lat/lon grid given by the related AUX_JSG_1D dataset.

    This function interpolates selected variables from `ds_msi` onto the JSG grid from `ds_xjsg`
    using quick kd-tree nearest-neighbor search with `scipy.spatial.cKDTree` followed
    by averaging the `k`-nearest points using inverse distance weighting. The resulting dataframe
    matches the along- and across-track resolution of `ds_xjsg`.

    Args:
        ds_msi (xr.Dataset | str): The source MSI dataset (e.g., MSI_RGR_1C, MSI_COP_2A, ...).
        ds_xjsg (xr.Dataset | str): The target XJSG dataset.
        vars (list[str] | None, optional): List of variable names from `ds_msi` to rebin.
            If None, all data variables are considered. Defaults to None.
        k (int, optional): Number of nearest geo-spacial neighbors to include in the kd-tree search.
            Defaults to 4.
        eps (float, optional): Numerical threshold to avoid division by zero in distance calculations during the kd-tree search.
            Defaults to 1e-12.

    Returns:
        xr.Dataset: The MSI dataset with variables rebinned to the JSG grid.
    """

    def _read_msi() -> xr.Dataset:
        if isinstance(ds_msi, str):
            return read_product(ds_msi)
        return ds_msi

    def _read_xjsg() -> xr.Dataset:
        if isinstance(ds_xjsg, str):
            return read_product(ds_xjsg)
        return ds_xjsg

    with (
        _read_msi() as ds_msi,
        _read_xjsg() as ds_xjsg,
    ):
        if vars is None:
            vars = [str(v) for v in ds_msi.variables]
        else:
            for var in vars:
                if var not in ds_msi.variables:
                    present_vars = [str(v) for v in ds_msi.variables]
                    raise KeyError(
                        f"""X-MET dataset does not contain variable "{var}". Present variables are: {", ".join(present_vars)}"""
                    )

        ds_xjsg = ds_xjsg.copy().swap_dims(
            {
                along_track_dim_xjsg: along_track_dim,
                across_track_dim_xjsg: across_track_dim,
            }
        )

        new_ds_msi = ds_msi.copy().swap_dims(
            {
                along_track_dim: f"{along_track_dim}_original",
                across_track_dim: f"{across_track_dim}_original",
            }
        )
        new_ds_msi[time_var] = ds_xjsg[time_var_xjsg].copy()

        lat_msi = ds_msi[lat_var].values.flatten()
        lon_msi = ds_msi[lon_var].values.flatten()
        coords_msi = sequence_geo_to_ecef(lat_msi, lon_msi)

        lat_jsg = ds_xjsg[lat_var_xjsg].values.flatten()
        lon_jsg = ds_xjsg[lon_var_xjsg].values.flatten()
        coords_jsg = sequence_geo_to_ecef(lat_jsg, lon_jsg)

        tree = cKDTree(coords_msi)
        dists, idxs = tree.query(coords_jsg, k=k)

        dims: str | tuple[str, str]
        for var in vars:
            if ds_msi[var].dims == (along_track_dim, across_track_dim):
                dims = (along_track_dim, across_track_dim)

                values = ds_msi[var].values
                values_flat = values.flatten()

                mask_nan = np.isnan(values_flat[idxs])

                _dists = dists
                _dists[mask_nan] = np.inf

                # Inverse distance weighting
                if k > 1:
                    weights = 1.0 / (_dists + eps)
                    weights /= np.sum(weights, axis=1, keepdims=True)
                else:
                    weights = np.ones(idxs.shape)

                if k > 1:
                    _v = values_flat[idxs]

                    if np.issubdtype(_v.dtype, np.floating):
                        m = np.all(np.isnan(_v), axis=1)
                        _v[np.isnan(_v)] = 0.0
                        _v[m] = np.nan

                    result = np.sum(_v * weights, axis=1)

                    new_values = result
                else:
                    new_values = values_flat[idxs]

                new_values = new_values.reshape(ds_xjsg.latitude_swath.shape)

                new_var = f"{var}"
                new_ds_msi[new_var] = (dims, new_values)
                new_ds_msi[new_var].attrs = ds_msi[var].attrs
            elif var not in _SKIP_VARS and var in ds_msi and var in ds_xjsg:
                new_ds_msi[var] = ds_xjsg[var].copy()
                new_ds_msi[var].attrs = ds_xjsg[var].attrs
            else:
                continue

        return new_ds_msi

rebin_xmet_to_vertical_track

rebin_xmet_to_vertical_track(
    ds_xmet,
    ds_vert,
    vars=None,
    k=4,
    eps=1e-12,
    lat_var=TRACK_LAT_VAR,
    lon_var=TRACK_LON_VAR,
    time_var=TIME_VAR,
    height_var=HEIGHT_VAR,
    along_track_dim=ALONG_TRACK_DIM,
    height_dim=VERTICAL_DIM,
    xmet_lat_var="latitude",
    xmet_lon_var="longitude",
    xmet_height_var="geometrical_height",
    xmet_height_dim="height",
    xmet_horizontal_grid_dim="horizontal_grid",
)

Rebins variables from an AUX_MET_1D (XMET) dataset onto the vertical curtain track of given by another dataset (e.g. ATL_EBD_2A).

This function interpolates selected variables from ds_xmet onto a EarthCARE vertical track given in ds_vert, using quick horizontal kd-tree nearest-neighbor search with scipy.spatial.cKDTree followed by averaging the k-nearest vertical XMET profiles using inverse distance weighting. The resulting profiles are then interpolated in the vertical to match the height resolution of ds_vert.

Parameters:

Name Type Description Default
ds_xmet Dataset | str

The source XMET dataset from which vertical curtain along track will be interpolated.

required
ds_vert Dataset | str

The target dataset containing the vertical curtain track.

required
vars list[str] | None

List of variable names from ds_xmet to rebin. If None, all data variables are considered.

None
k int

Number of nearest horizontal neighbors to include in the kd-tree search. Defaults to 4.

4
eps float

Numerical threshold to avoid division by zero in distance calculations during the kd-tree search. Defaults to 1e-12.

1e-12
lat_var str

Name of the latitude variable in ds_vert. Defaults to TRACK_LAT_VAR.

TRACK_LAT_VAR
lon_var str

Name of the longitude variable in ds_vert. Defaults to TRACK_LON_VAR.

TRACK_LON_VAR
time_var str

Name of the time variable in ds_vert. Defaults to TIME_VAR.

TIME_VAR
height_var str

Name of the height variable in ds_vert. Defaults to HEIGHT_VAR.

HEIGHT_VAR
along_track_dim str

Name of the along-track dimension in ds_vert. Defaults to ALONG_TRACK_DIM.

ALONG_TRACK_DIM
height_dim str

Name of the vertical or height dimension in ds_vert. Defaults to VERTICAL_DIM.

VERTICAL_DIM
xmet_lat_var str

Name of the latitude variable in ds_xmet. Defaults to "latitude".

'latitude'
xmet_lon_var str

Name of the longitude variable in ds_xmet. Defaults to "longitude".

'longitude'
xmet_height_var str

Name of the height variable in ds_xmet. Defaults to "geometrical_height".

'geometrical_height'
xmet_height_dim str

Name of the vertical dimension in ds_xmet. Defaults to "height".

'height'
xmet_horizontal_grid_dim str

Name of the horizontal grid dimension in ds_xmet. Defaults to "horizontal_grid".

'horizontal_grid'

Returns:

Type Description
Dataset

xr.Dataset: A new dataset containing the selected XMET variables interpolated to the grid of the vertical curtain given in ds_vert. This new dataset has the same along-track and vertical dimensions as ds_vert.

Raises:

Type Description
KeyError

If any specified variable or coordinate name is not found in ds_xmet.

Source code in earthcarekit/utils/read/product/_rebin_xmet_to_vertical_track.py
def rebin_xmet_to_vertical_track(
    ds_xmet: xr.Dataset | str,
    ds_vert: xr.Dataset | str,
    vars: list[str] | None = None,
    k: int = 4,
    eps: float = 1e-12,
    lat_var: str = TRACK_LAT_VAR,
    lon_var: str = TRACK_LON_VAR,
    time_var: str = TIME_VAR,
    height_var: str = HEIGHT_VAR,
    along_track_dim: str = ALONG_TRACK_DIM,
    height_dim: str = VERTICAL_DIM,
    xmet_lat_var: str = "latitude",
    xmet_lon_var: str = "longitude",
    xmet_height_var: str = "geometrical_height",
    xmet_height_dim: str = "height",
    xmet_horizontal_grid_dim: str = "horizontal_grid",
) -> xr.Dataset:
    """
    Rebins variables from an AUX_MET_1D (XMET) dataset onto the vertical curtain track of given by another dataset (e.g. ATL_EBD_2A).

    This function interpolates selected variables from `ds_xmet` onto a EarthCARE
    vertical track given in `ds_vert`, using quick horizontal kd-tree nearest-neighbor search with `scipy.spatial.cKDTree` followed
    by averaging the `k`-nearest vertical XMET profiles using inverse distance weighting. The resulting
    profiles are then interpolated in the vertical to match the height resolution of `ds_vert`.

    Args:
        ds_xmet (xr.Dataset | str): The source XMET dataset from which vertical curtain along track will be interpolated.
        ds_vert (xr.Dataset | str): The target dataset containing the vertical curtain track.
        vars (list[str] | None, optional): List of variable names from `ds_xmet` to rebin.
            If None, all data variables are considered.
        k (int, optional): Number of nearest horizontal neighbors to include in the kd-tree search.
            Defaults to 4.
        eps (float, optional): Numerical threshold to avoid division by zero in distance calculations during the kd-tree search.
            Defaults to 1e-12.
        lat_var (str, optional): Name of the latitude variable in `ds_vert`.
            Defaults to TRACK_LAT_VAR.
        lon_var (str, optional): Name of the longitude variable in `ds_vert`.
            Defaults to TRACK_LON_VAR.
        time_var (str, optional): Name of the time variable in `ds_vert`.
            Defaults to TIME_VAR.
        height_var (str, optional): Name of the height variable in `ds_vert`.
            Defaults to HEIGHT_VAR.
        along_track_dim (str, optional): Name of the along-track dimension in `ds_vert`.
            Defaults to ALONG_TRACK_DIM.
        height_dim (str, optional): Name of the vertical or height dimension in `ds_vert`.
            Defaults to VERTICAL_DIM.
        xmet_lat_var (str, optional): Name of the latitude variable in `ds_xmet`.
            Defaults to "latitude".
        xmet_lon_var (str, optional): Name of the longitude variable in `ds_xmet`.
            Defaults to "longitude".
        xmet_height_var (str, optional): Name of the height variable in `ds_xmet`.
            Defaults to "geometrical_height".
        xmet_height_dim (str, optional): Name of the vertical dimension in `ds_xmet`.
            Defaults to "height".
        xmet_horizontal_grid_dim (str, optional): Name of the horizontal grid dimension in `ds_xmet`.
            Defaults to "horizontal_grid".

    Returns:
        xr.Dataset: A new dataset containing the selected XMET variables interpolated to the grid of the
            vertical curtain given in `ds_vert`. This new dataset has the same along-track and vertical
            dimensions as `ds_vert`.

    Raises:
        KeyError: If any specified variable or coordinate name is not found in `ds_xmet`.
    """

    def _read_xmet() -> xr.Dataset:
        if isinstance(ds_xmet, str):
            return read_product_xmet(ds_xmet)
        return ds_xmet

    def _read_vert() -> xr.Dataset:
        if isinstance(ds_vert, str):
            return read_product(ds_vert)
        return ds_vert

    with (
        _read_xmet() as ds_xmet,
        _read_vert() as ds_vert,
    ):

        if vars is None:
            vars = [str(v) for v in ds_xmet.variables]
        else:
            for var in vars:
                if var not in ds_xmet.variables:
                    present_vars = [str(v) for v in ds_xmet.variables]
                    raise KeyError(
                        f"""X-MET dataset does not contain variable "{var}". Present variables are: {", ".join(present_vars)}"""
                    )

        new_ds_xmet = ds_xmet.copy().swap_dims({xmet_height_dim: "tmp_xmet_height"})
        new_ds_xmet[time_var] = ds_vert[time_var].copy()
        new_ds_xmet[height_var] = ds_vert[height_var].copy()

        hgrid_lat = ds_xmet[xmet_lat_var].values.flatten()
        hgrid_lon = ds_xmet[xmet_lon_var].values.flatten()
        hgrid_alt = ds_xmet[xmet_height_var].values
        hgrid_coords = sequence_geo_to_ecef(hgrid_lat, hgrid_lon)

        track_lat = ds_vert[lat_var].values
        track_lon = ds_vert[lon_var].values
        track_alt = ds_vert[height_var].values
        track_coords = sequence_geo_to_ecef(track_lat, track_lon)

        tree = cKDTree(hgrid_coords)
        dists, idxs = tree.query(track_coords, k=k)

        # Inverse distance weighting
        if k > 1:
            weights = 1.0 / (dists + eps)
            weights /= np.sum(weights, axis=1, keepdims=True)
            height = np.einsum("ij,ijh->ih", weights, hgrid_alt[idxs])
        else:
            weights = np.ones(idxs.shape)
            height = hgrid_alt[idxs]

        # Handle longitudes separately to account for sign changes at the dateline
        if xmet_lon_var in vars:
            vars.remove(xmet_lon_var)
        if k > 1:
            new_coords = np.sum(
                hgrid_coords[idxs] * weights.reshape((*weights.shape, 1)), axis=1
            )
        else:
            new_coords = hgrid_coords[idxs]
        new_lons = sequence_ecef_to_geo(
            x=new_coords[:, 0],
            y=new_coords[:, 1],
            z=new_coords[:, 2],
        )[:, 1]
        new_ds_xmet[xmet_lon_var] = xr.DataArray(
            data=new_lons,
            dims=along_track_dim,
            attrs=new_ds_xmet[xmet_lon_var].attrs,
        )

        # Handle all remaining variables
        dims: str | tuple[str, str]
        for var in vars:
            values = ds_xmet[var].values
            if len(values.shape) == 0:
                continue

            if len(values.shape) == 1:
                dims = along_track_dim

                if k > 1:
                    result = np.sum(values[idxs] * weights, axis=1)
                    new_values = result
                else:
                    new_values = values[idxs]
            else:
                dims = (along_track_dim, height_dim)

                if k > 1:
                    result = np.einsum("ij,ijh->ih", weights, values[idxs])
                else:
                    result = values[idxs]

                new_values = np.empty(track_alt.shape)
                new_values[:] = np.nan

                for i in np.arange(track_alt.shape[0]):
                    _new_values = np.interp(
                        track_alt[i],
                        height[i],
                        result[i],
                    )

                    new_values[i] = _new_values

            new_var = f"{var}"
            new_ds_xmet[new_var] = (dims, new_values)
            new_ds_xmet[new_var].attrs = ds_xmet[var].attrs

        # Remove original horizontal grid dims and associated variables
        new_ds_xmet = remove_dims(
            new_ds_xmet, [xmet_horizontal_grid_dim, xmet_height_dim]
        )

        return new_ds_xmet

save_plot

save_plot(
    fig,
    filename="",
    filepath=None,
    ds=None,
    ds_filepath=None,
    pad=0.1,
    dpi="figure",
    orbit_and_frame=None,
    utc_timestamp=None,
    use_utc_creation_timestamp=False,
    site_name=None,
    hmax=None,
    radius=None,
    resolution=None,
    extra=None,
    transparent_outside=False,
    verbose=True,
    print_prefix="",
    create_dirs=False,
    transparent_background=False,
    **kwargs
)

Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

Parameters:

Name Type Description Default
figure Figure | HasFigure

A figure object (matplotlib.figure.Figure) or objects exposing a .fig attribute containing a figure (e.g., CurtainFigure).

required
filename str

The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.

''
filepath str | None

The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.

None
ds Dataset | None

A EarthCARE dataset from which metadata will be taken. Defaults to None.

None
ds_filepath str | None

A path to a EarthCARE product from which metadata will be taken. Defaults to None.

None
pad float

Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.

0.1
dpi float | figure

The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.

'figure'
orbit_and_frame str | None

Metadata used in the formatting of the file name. Defaults to None.

None
utc_timestamp TimestampLike | None

Metadata used in the formatting of the file name. Defaults to None.

None
use_utc_creation_timestamp bool

Whether the time of image creation should be included in the file name. Defaults to False.

False
site_name str | None

Metadata used in the formatting of the file name. Defaults to None.

None
hmax int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
radius int | float | None

Metadata used in the formatting of the file name. Defaults to None.

None
resolution str | None

Metadata used in the formatting of the file name. Defaults to None.

None
extra str | None

A custom string to be included in the file name. Defaults to None.

None
transparent_outside bool

Whether the area outside figures should be transparent. Defaults to False.

False
verbose bool

Whether the progress of image creation should be printed to the console. Defaults to True.

True
print_prefix str

A prefix string to all console messages. Defaults to "".

''
create_dirs bool

Whether images should be saved in a folder structure based on provided metadata. Defaults to False.

False
transparent_background bool

Whether the background inside and outside of figures should be transparent. Defaults to False.

False
**kwargs dict[str, Any]

Keyword arguments passed to wrapped function call of matplotlib.pyplot.savefig.

{}
Source code in earthcarekit/plot/save/simple_save.py
def save_plot(
    fig: Figure | HasFigure,
    filename: str = "",
    filepath: str | None = None,
    ds: xr.Dataset | None = None,
    ds_filepath: str | None = None,
    pad: float = 0.1,
    dpi: float | Literal["figure"] = "figure",
    orbit_and_frame: str | None = None,
    utc_timestamp: TimestampLike | None = None,
    use_utc_creation_timestamp: bool = False,
    site_name: str | None = None,
    hmax: int | float | None = None,
    radius: int | float | None = None,
    resolution: str | None = None,
    extra: str | None = None,
    transparent_outside: bool = False,
    verbose: bool = True,
    print_prefix: str = "",
    create_dirs: bool = False,
    transparent_background: bool = False,
    **kwargs,
) -> None:
    """
    Save a figure as an image or vector graphic to a file and optionally format the file name in a structured way using EarthCARE metadata.

    Args:
        figure (Figure | HasFigure): A figure object (`matplotlib.figure.Figure`) or objects exposing a `.fig` attribute containing a figure (e.g., `CurtainFigure`).
        filename (str, optional): The base name of the file. Can be extended based on other metadata provided. Defaults to empty string.
        filepath (str | None, optional): The path where the image is saved. Can be extended based on other metadata provided. Defaults to None.
        ds (xr.Dataset | None, optional): A EarthCARE dataset from which metadata will be taken. Defaults to None.
        ds_filepath (str | None, optional): A path to a EarthCARE product from which metadata will be taken. Defaults to None.
        pad (float, optional): Extra padding (i.e., empty space) around the image in inches. Defaults to 0.1.
        dpi (float | 'figure', optional): The resolution in dots per inch. If 'figure', use the figure's dpi value. Defaults to None.
        orbit_and_frame (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        utc_timestamp (TimestampLike | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        use_utc_creation_timestamp (bool, optional): Whether the time of image creation should be included in the file name. Defaults to False.
        site_name (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        hmax (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        radius (int | float | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        resolution (str | None, optional): Metadata used in the formatting of the file name. Defaults to None.
        extra (str | None, optional): A custom string to be included in the file name. Defaults to None.
        transparent_outside (bool, optional): Whether the area outside figures should be transparent. Defaults to False.
        verbose (bool, optional): Whether the progress of image creation should be printed to the console. Defaults to True.
        print_prefix (str, optional): A prefix string to all console messages. Defaults to "".
        create_dirs (bool, optional): Whether images should be saved in a folder structure based on provided metadata. Defaults to False.
        transparent_background (bool, optional): Whether the background inside and outside of figures should be transparent. Defaults to False.
        **kwargs (dict[str, Any]): Keyword arguments passed to wrapped function call of `matplotlib.pyplot.savefig`.
    """
    if not isinstance(fig, Figure):
        fig = fig.fig

    _stime: str = pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S")

    try:
        if transparent_background:
            transparent_outside = True

        new_filepath = create_filepath(
            filename,
            filepath,
            ds,
            ds_filepath,
            orbit_and_frame,
            utc_timestamp,
            use_utc_creation_timestamp,
            site_name,
            hmax,
            radius,
            extra,
            create_dirs,
            resolution,
        )

        if transparent_outside:
            fig.patch.set_alpha(0)
        if transparent_background:
            for ax in fig.get_axes():
                ax.patch.set_alpha(0)

        if verbose:
            print(f"{print_prefix}Saving plot ...", end="\r")
        save_figure_with_auto_margins(
            fig,
            new_filepath,
            pad=pad,
            dpi=None if dpi == "figure" else dpi,
            **kwargs,
        )

        # Restore original settings
        if transparent_outside:
            fig.patch.set_alpha(1)
        if transparent_background:
            for ax in fig.get_axes():
                ax.patch.set_alpha(1)

        _etime: str = pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S")
        _dtime: str = str(pd.Timestamp(_etime) - pd.Timestamp(_stime)).split()[-1]
        if verbose:
            print(f"{print_prefix}Plot saved (time taken {_dtime}): <{new_filepath}>")

        # raise ValueError(f"hi")
    except ValueError as e:
        if verbose:
            print(f"{print_prefix}Did not create plot since an error occured: {e}")

search_files_by_regex

search_files_by_regex(root_dirpath, regex_pattern)

Recursively searches for files in a directory that match a given regex pattern.

Parameters:

Name Type Description Default
root_dirpath str

The root directory to start the search from.

required
regex_pattern str

A regular expression pattern to match file names against.

required
Return

list[str]: A list of absolute file paths that point to files with matching names.

Raises:

Type Description
FileNotFoundError

If the root directory does not exist.

error

If the given pattern is not a valid regular expression.

Source code in earthcarekit/utils/read/search.py
def search_files_by_regex(root_dirpath: str, regex_pattern: str) -> list[str]:
    """
    Recursively searches for files in a directory that match a given regex pattern.

    Args:
        root_dirpath (str): The root directory to start the search from.
        regex_pattern (str): A regular expression pattern to match file names against.

    Return:
        list[str]: A list of absolute file paths that point to files with matching names.

    Raises:
        FileNotFoundError: If the root directory does not exist.
        re.error: If the given pattern is not a valid regular expression.
    """
    if not os.path.exists(root_dirpath):
        raise FileNotFoundError(
            f"{search_files_by_regex.__name__}() Root directory does not exist: {root_dirpath}"
        )

    filepaths = []
    for dirpath, _, filenames in os.walk(root_dirpath):
        for filename in filenames:
            filepath = os.path.join(dirpath, filename)
            if re.search(regex_pattern, filename):
                filepaths.append(filepath)
    return filepaths

search_product

search_product(
    root_dirpath=None,
    config=None,
    file_type=None,
    agency=None,
    latency=None,
    timestamp=None,
    baseline=None,
    orbit_and_frame=None,
    orbit_number=None,
    frame_id=None,
    filename=None,
    start_time=None,
    end_time=None,
    mode="exhaustive",
)

Searches for EarthCARE product files matching given metadata filters.

Parameters:

Name Type Description Default
root_dirpath str

Root directory to search. Defaults to directory given in a configuration file.

None
config str | ECKConfig | None

Path to a config.toml file or a ECKConfig instance. Defaults to the default configuration file path.

None
file_type str | Sequence[str]

Product file type(s) to match.

None
agency str | Sequence[str]

Producing agency or agencies (e.g. "ESA" or "JAXA").

None
latency str | Sequence[str]

Data latency level(s).

None
timestamp TimestampLike | Sequence

Timestamp(s) included in the product's time coverage.

None
baseline str | Sequence[str]

Baseline version(s).

None
orbit_and_frame str | Sequence[str]

Orbit and frame identifiers.

None
orbit_number int, str, | Sequence

Orbit number(s).

None
frame_id str | Sequence[str]

Frame identifier(s).

None
filename str | Sequence[str]

Specific filename(s) or regular expression patterns to match.

None
start_time TimestampLike

First timestamp included in the product's time coverage.

None
end_time TimestampLike

Last timestamp included in the product's time coverage.

None
mode Literal['exhaustive', 'fast']

Search strategy controlling completeness vs performance; the "exhaustive" mode recursivly scans all files under the root_directory, while the "fast" mode searches files only at expected paths and may miss files outside the standard data folder structure defined during the configuration of earthcarekit.

'exhaustive'

Returns:

Name Type Description
ProductDataFrame ProductDataFrame

Filtered list of matching product files as a pandas.DataFrame-based object.

Raises:

Type Description
FileNotFoundError

If root directory does not exist.

Source code in earthcarekit/utils/read/product/_search.py
def search_product(
    root_dirpath: str | None = None,
    config: str | ECKConfig | None = None,
    file_type: str | Sequence[str] | None = None,
    agency: str | Sequence[str] | None = None,
    latency: str | Sequence[str] | None = None,
    timestamp: TimestampLike | Sequence[TimestampLike] | None = None,
    baseline: str | Sequence[str] | None = None,
    orbit_and_frame: str | Sequence[str] | None = None,
    orbit_number: int | str | Sequence[int | str] | None = None,
    frame_id: str | Sequence[str] | None = None,
    filename: str | Sequence[str] | None = None,
    start_time: TimestampLike | None = None,
    end_time: TimestampLike | None = None,
    mode: Literal["exhaustive", "fast"] = "exhaustive",
) -> ProductDataFrame:
    """
    Searches for EarthCARE product files matching given metadata filters.

    Args:
        root_dirpath (str, optional): Root directory to search. Defaults to directory given in a configuration file.
        config (str | ECKConfig | None , optional): Path to a `config.toml` file or a ECKConfig instance. Defaults to the default configuration file path.
        file_type (str | Sequence[str], optional): Product file type(s) to match.
        agency (str | Sequence[str], optional): Producing agency or agencies (e.g. "ESA" or "JAXA").
        latency (str | Sequence[str], optional): Data latency level(s).
        timestamp (TimestampLike | Sequence, optional): Timestamp(s) included in the product's time coverage.
        baseline (str | Sequence[str], optional): Baseline version(s).
        orbit_and_frame (str | Sequence[str], optional): Orbit and frame identifiers.
        orbit_number (int, str, | Sequence, optional): Orbit number(s).
        frame_id (str | Sequence[str], optional): Frame identifier(s).
        filename (str | Sequence[str], optional): Specific filename(s) or regular expression patterns to match.
        start_time (TimestampLike, optional): First timestamp included in the product's time coverage.
        end_time (TimestampLike, optional): Last timestamp included in the product's time coverage.
        mode (Literal["exhaustive", "fast"]): Search strategy controlling completeness vs performance; the "exhaustive" mode
            recursivly scans all files under the `root_directory`, while the "fast" mode searches files only at expected paths
            and may miss files outside the standard data folder structure defined during the configuration of earthcarekit.

    Returns:
        ProductDataFrame: Filtered list of matching product files as a `pandas.DataFrame`-based object.

    Raises:
        FileNotFoundError: If root directory does not exist.
    """
    if not isinstance(config, ECKConfig):
        config = read_config(config)

    if not isinstance(root_dirpath, str):
        root_dirpath = config.path_to_data

    if not os.path.exists(root_dirpath):
        raise FileNotFoundError(f"Given root directory does not exist: {root_dirpath}")

    mission_id = "ECA"

    if isinstance(file_type, str):
        file_type = [file_type]
    if isinstance(file_type, Sequence):
        _baseline: list[str] = []
        _file_type: list[str] = []
        for i, ft in enumerate(file_type):
            if isinstance(ft, str):
                _parts = ft.split(":")
                if len(_parts) == 2:
                    _file_type.append(_parts[0])
                    _baseline.append(_parts[1])
                    continue
            _file_type.append(ft)
            if isinstance(baseline, str):
                _baseline.append(baseline)
            elif isinstance(baseline, Sequence):
                try:
                    _baseline.append(baseline[i])
                except IndexError as e:
                    raise IndexError(e, f"given baseline list is too small")
            else:
                _baseline.append("latest")
        file_type = _file_type
        baseline = _baseline
    file_type = _to_file_info_list(file_type, FileType)
    baseline = _format_input(
        baseline,
        file_types=file_type,
        default_input="..",
        format_func=validate_baseline,
    )
    baseline_and_file_type_list = [f"{bl}_{ft}" for bl, ft in zip(baseline, file_type)]
    baseline_and_file_type = _list_to_regex(
        baseline_and_file_type_list, ".._..._..._.."
    )

    agency = _to_file_info_list(agency, FileAgency)
    agency = _list_to_regex(agency, ".")

    latency = _to_file_info_list(latency, FileLatency)
    latency = _list_to_regex(latency, ".")

    timestamp = _format_input(timestamp, format_func=to_timestamp)
    _start_time = [] if start_time is None else [to_timestamp(start_time)]
    _end_time = [] if end_time is None else [to_timestamp(end_time)]
    timestamp = timestamp + _start_time + _end_time

    orbit_and_frame = _format_input(orbit_and_frame, format_func=format_orbit_and_frame)
    orbit_and_frame = _list_to_regex(orbit_and_frame, "." * 6)

    orbit_number = _format_input(orbit_number, format_func=format_orbit_number)
    orbit_number = _list_to_regex(orbit_number, "." * 5)

    frame_id = _format_input(frame_id, format_func=format_frame_id)
    frame_id = _list_to_regex(frame_id, ".")

    oaf_list = []
    oaf = ""
    if orbit_number != "." * 5:
        oaf_list.append(orbit_number)
    if frame_id != ".":
        oaf_list.append(frame_id)
    if orbit_number != "." * 5 or frame_id != ".":
        oaf = f"{orbit_number}{frame_id}"

    if oaf == "":
        oaf = orbit_and_frame
    elif oaf != "" and orbit_and_frame != "." * 6:
        oaf = f"(({oaf})|{orbit_and_frame})"

    pattern = f".*{mission_id}_{agency}{latency}{baseline_and_file_type}_........T......Z_........T......Z_{oaf}.h5"

    files: list[str]
    if pattern == ".*ECA_...._..._..._.._........T......Z_........T......Z_.......h5":
        files = []
    elif mode == "fast" and len(file_type) > 0:
        files = []
        for ft in file_type:
            lvl = FileType.from_input(ft).get_level()
            _lvl_subdir = ""
            if lvl == "1B":
                _lvl_subdir = config.subdir_name_level1b
            elif lvl == "1C":
                _lvl_subdir = config.subdir_name_level1c
            elif lvl == "1D":
                _lvl_subdir = config.subdir_name_auxiliary_files
            elif lvl == "2A":
                _lvl_subdir = config.subdir_name_level2a
            elif lvl == "2B":
                _lvl_subdir = config.subdir_name_level2b
            else:
                raise ValueError(
                    f"file type '{ft}' not supported for search mode '{mode}'"
                )
            _root_dirpath = os.path.join(root_dirpath, _lvl_subdir, ft)

            if start_time is not None:
                _date_subdir = _get_date_subdir(start_time, end_time)
                if isinstance(_date_subdir, str):
                    _root_dirpath = os.path.join(
                        root_dirpath, _lvl_subdir, ft, _date_subdir
                    )

            if os.path.exists(_root_dirpath):
                print(f"Searching data at <{_root_dirpath}>")
                _files = search_files_by_regex(_root_dirpath, pattern)
            else:
                _files = []

            files.extend(_files)
    else:
        files = search_files_by_regex(root_dirpath, pattern)

    if isinstance(filename, str) or isinstance(filename, Sequence):
        if isinstance(filename, str):
            filename = [filename]
        _get_pattern = lambda fn: f".*{os.path.basename(fn).replace('.h5', '')}.*.h5"
        filename = [_get_pattern(fn) for fn in filename]
    elif filename is None:
        filename = []
    else:
        raise TypeError(
            f"Given filename has invalid type ({type(filename)}: {filename})"
        )

    for fn in filename:
        new_files = search_files_by_regex(root_dirpath, fn)
        files.extend(new_files)

    # Remove duplicates
    files = list(set(files))

    old_files = files.copy()
    if len(timestamp) > 0:
        files = []
        for t in timestamp:
            new_files = [
                f for f in old_files if _check_product_contains_timestamp(f, t)
            ]
            if len(new_files) > 0:
                files.extend(new_files)

    pdf = get_product_infos(files)

    if start_time is not None or end_time is not None:
        _pdf = get_product_infos(old_files)
        _pdf = filter_time_range(_pdf, start_time=start_time, end_time=end_time)

        if not pdf.empty and not _pdf.empty:
            pdf = ProductDataFrame(pd.concat([pdf, _pdf], ignore_index=True))
        elif not _pdf.empty:
            pdf = _pdf

    pdf = pdf.sort_values(by=["orbit_and_frame", "file_type", "start_processing_time"])
    pdf = pdf.drop_duplicates()
    pdf = pdf.reset_index(drop=True)

    pdf.validate_columns()
    return pdf

set_config

set_config(c, verbose=True)

Creates or updates the default earthcarekit configuration file.

Parameters:

Name Type Description Default
c str | ECKConfig

Filepath to a configuration file (.toml) or configuration object.

required
verbose bool

If True, prints a message to the console. Defaults to True.

True
Source code in earthcarekit/utils/config.py
def set_config(c: str | ECKConfig, verbose: bool = True) -> None:
    """
    Creates or updates the default earthcarekit configuration file.

    Args:
        c (str | ECKConfig): Filepath to a configuration file (.toml) or configuration object.
        verbose (bool): If True, prints a message to the console. Defaults to True.
    """
    _set_config(c=c, verbose=verbose)

set_config_maap_token

set_config_maap_token(token)

Updates the ESA MAAP access token in the default earthcarekit configuration file.

Parameters:

Name Type Description Default
token str

A temporary ESA MAAP access token (to generate it visit: https://portal.maap.eo.esa.int/ini/services/auth/token/).

required
Source code in earthcarekit/utils/config.py
def set_config_maap_token(token: str) -> None:
    """
    Updates the ESA MAAP access token in the default earthcarekit configuration file.

    Args:
        token (str): A temporary ESA MAAP access token (to generate it visit: https://portal.maap.eo.esa.int/ini/services/auth/token/).
    """
    _config: ECKConfig = read_config()
    _config.maap_token = token
    _set_config(
        _config,
        alt_msg=f"Set MAAP access token",
    )

set_config_to_maap

set_config_to_maap()

Sets the download backend to the ESA MAAP system in the default earthcarekit configuration file.

Source code in earthcarekit/utils/config.py
def set_config_to_maap() -> None:
    """Sets the download backend to the ESA MAAP system in the default earthcarekit configuration file."""
    _config: ECKConfig = read_config()
    _config.download_backend = "maap"
    _set_config(
        _config,
        alt_msg=f"Set download backend to {_config.download_backend.upper()}",
    )

set_config_to_oads

set_config_to_oads()

Sets the download backend to OADS in the default earthcarekit configuration file.

Source code in earthcarekit/utils/config.py
def set_config_to_oads() -> None:
    """Sets the download backend to OADS in the default earthcarekit configuration file."""
    _config: ECKConfig = read_config()
    _config.download_backend = "oads"
    _set_config(
        _config,
        alt_msg=f"Set download backend to {_config.download_backend.upper()}",
    )

shift_cmap

shift_cmap(cmap, start=0.0, midpoint=0.5, stop=1.0, name='shifted_cmap')

Create a colormap with its center point shifted to a specified value.

This function is useful for data with asymmetric ranges (e.g., negative min and positive max) where you want the center of the colormap to align with a specific value like zero.

Parameters:

Name Type Description Default
cmap str | Colormap | None

Colormap to be modified

required
start float

Lower bound of the colormap range (value between 0 and midpoint). Defaults to 0.0.

0.0
midpoint float

New center point of the colormap (value between 0 and 1). Defaults to 0.5. For data ranging from vmin to vmax where you want the center at value v, set midpoint = 1 - vmax/(vmax + abs(vmin))

0.5
stop float

Upper bound of the colormap range (value between midpoint and 1). Defaults to 1.0.

1.0
name str

Name of the new colormap. Defaults to "shifted_cmap".

'shifted_cmap'

Returns:

Name Type Description
Cmap Cmap

New colormap with shifted center

Source code in earthcarekit/plot/color/colormap/shift/_shift_cmap.py
def shift_cmap(
    cmap: str | Colormap | None,
    start: float = 0.0,
    midpoint: float = 0.5,
    stop: float = 1.0,
    name: str = "shifted_cmap",
) -> Cmap:
    """Create a colormap with its center point shifted to a specified value.

    This function is useful for data with asymmetric ranges (e.g., negative min and
    positive max) where you want the center of the colormap to align with a specific
    value like zero.

    Args:
        cmap (str | Colormap | None): Colormap to be modified
        start (float): Lower bound of the colormap range (value between 0 and `midpoint`). Defaults to 0.0.
        midpoint (float): New center point of the colormap (value between 0 and 1). Defaults to 0.5.
            For data ranging from vmin to vmax where you want the center at value v,
            set midpoint = 1 - vmax/(vmax + abs(vmin))
        stop (float): Upper bound of the colormap range (value between `midpoint` and 1). Defaults to 1.0.
        name (str): Name of the new colormap. Defaults to "shifted_cmap".

    Returns:
        Cmap: New colormap with shifted center
    """
    from ..colormap import get_cmap

    cmap = get_cmap(cmap)
    cmap = shift_mpl_colormap(
        cmap,
        start=start,
        midpoint=midpoint,
        stop=stop,
        name=name,
    )
    cmap = get_cmap(cmap)

    return cmap

trim_to_latitude_frame_bounds

trim_to_latitude_frame_bounds(
    ds,
    along_track_dim=ALONG_TRACK_DIM,
    lat_var=TRACK_LAT_VAR,
    frame_id=None,
    add_trim_index_offset_var=True,
    trim_index_offset_var="trim_index_offset",
)

Trims the dataset to the region within the latitude frame bounds.

Parameters:

Name Type Description Default
ds Dataset

Input dataset to be trimmed.

required
along_track_dim str

Dimension along which to trim. Defaults to ALONG_TRACK_DIM.

ALONG_TRACK_DIM
lat_var str

Name of the latitude variable. Defaults to TRACK_LAT_VAR.

TRACK_LAT_VAR
frame_id str | None

EarthCARE frame ID (single character between "A" and "H"). If given, speeds up trimming. Defaults to None.

None
add_trim_index_offset_var bool

Whether the index offset between the original and trimmed dataset is stored in the trimmed dataset (variable: "trim_index_offset"). Defaults to True.

True

Returns:

Type Description
Dataset

xarray.Dataset: Trimmed dataset.

Source code in earthcarekit/utils/read/product/_trim_to_frame.py
def trim_to_latitude_frame_bounds(
    ds: Dataset,
    along_track_dim: str = ALONG_TRACK_DIM,
    lat_var: str = TRACK_LAT_VAR,
    frame_id: str | None = None,
    add_trim_index_offset_var: bool = True,
    trim_index_offset_var: str = "trim_index_offset",
) -> Dataset:
    """
    Trims the dataset to the region within the latitude frame bounds.

    Args:
        ds (xarray.Dataset):
            Input dataset to be trimmed.
        along_track_dim (str, optional):
            Dimension along which to trim. Defaults to ALONG_TRACK_DIM.
        lat_var (str, optional):
            Name of the latitude variable. Defaults to TRACK_LAT_VAR.
        frame_id (str | None, optional):
            EarthCARE frame ID (single character between "A" and "H").
            If given, speeds up trimming. Defaults to None.
        add_trim_index_offset_var (bool, optional):
            Whether the index offset between the original and trimmed dataset is stored
            in the trimmed dataset (variable: "trim_index_offset"). Defaults to True.

    Returns:
        xarray.Dataset: Trimmed dataset.
    """
    slice_tuple = get_frame_along_track(
        ds,
        lat_var=lat_var,
        frame_id=frame_id,
    )
    ds = ds.isel({along_track_dim: slice(*slice_tuple)})
    if add_trim_index_offset_var and slice_tuple[0] > 0:
        ds = insert_var(
            ds=ds,
            var=trim_index_offset_var,
            data=int(slice_tuple[0]),
            index=0,
            after_var="processing_start_time",
        )
        ds[trim_index_offset_var] = ds[trim_index_offset_var].assign_attrs(
            {
                "earthcarekit": "Added by earthcarekit: Used to calculate the index in the original, untrimmed dataset, i.e. by addition."
            }
        )
    return ds