Skip to content

API Documentation

earthcarekit

A Python package to simplify working with EarthCARE satellite data

Copyright (c) 2025 Leonard König

Licensed under the MIT License (see LICENSE file or https://opensource.org/license/mit).

Cmap

Bases: ListedColormap

Source code in earthcarekit/plot/color/colormap/cmap.py
class Cmap(ListedColormap):
    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,
    ):
        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":
        if isinstance(cmap, cls):
            return cmap
        elif isinstance(cmap, ListedColormap):
            colors = cmap.colors
            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)]

        new_cmap = cls(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":
        if isinstance(values_to_labels, int):
            values_to_labels = {i: str(i) for i in range(values_to_labels)}
        keys = list(values_to_labels.keys())
        labels = list(values_to_labels.values())
        sorted_values = sorted(keys)

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

        ticks = [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 set_alpha(self, value: float) -> "Cmap":
        """Returns the same colormap 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)"
            )

        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":
        """Returns the same colormap beldned with a second color."""
        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, ...]]:
        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 colormap_to_opaque(self)

    @property
    def alphamap(self) -> "Cmap":
        return colormap_to_alphamap(self)

    @property
    def blended(self) -> "Cmap":
        return colormap_to_blended(self)

    def __new__(cls, *args, **kwargs):
        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')

Returns the same colormap beldned with a second color.

Source code in earthcarekit/plot/color/colormap/cmap.py
def blend(self, value: float, blend_color: Color | ColorLike = "white") -> "Cmap":
    """Returns the same colormap beldned with a second color."""
    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

set_alpha

set_alpha(value)

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

Source code in earthcarekit/plot/color/colormap/cmap.py
def set_alpha(self, value: float) -> "Cmap":
    """Returns the same colormap 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)"
        )

    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

Color dataclass

Bases: str

Source code in earthcarekit/plot/color/color.py
@dataclass(frozen=True)
class Color(str):
    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,
    ):
        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,
    ):
        object.__setattr__(self, "input", color_input)
        object.__setattr__(self, "name", name)
        object.__setattr__(self, "is_normalized", is_normalized)

    def __hash__(self):
        return hash(str(self))

    @classmethod
    def _rgb_str_to_hex(cls, rgb_string: str, is_normalized: bool = False) -> str:
        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:
        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:
        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:
        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:
        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 | Iterable, is_normalized: bool = False) -> str:
        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, Iterable):
            c_tup = tuple(float(v) for v in color)
            if len(c_tup) == 3:
                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, ...]:
        """Returns the RGB tuple with values in the 0-255 range."""
        hex_str = self.lstrip("#")
        return tuple(int(hex_str[i : i + 2], 16) for i in (0, 2, 4))

    @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, ...]:
        """Returns the RGBA tuple with values in the 0-255 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,
    ) -> Union["Color", None]:
        """Parses optional color input and returns a `Color` instance or `None`."""
        if color_input is None:
            return None
        else:
            return cls(color_input)

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-255 range.

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)

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,
) -> Union["Color", None]:
    """Parses optional color input and returns a `Color` instance or `None`."""
    if color_input is None:
        return None
    else:
        return cls(color_input)

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.

Parameters:

Name Type Description Default
ax Axes | None

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

None
figsize tuple[float, float]

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

(FIGURE_WIDTH_CURTAIN, FIGURE_HEIGHT_CURTAIN)
dpi int | None

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

None
title str | None

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

None
ax_style_top AlongTrackAxisStyle | str

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

'geo'
ax_style_bottom AlongTrackAxisStyle | str

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

'time'
num_ticks int

Number of tick marks to place along the x-axis. Defaults to 10.

10
show_height_left bool

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

True
show_height_right bool

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

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.

'fast'
min_num_profiles int

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

1000
Example
import earthcarekit as eck

fig = eck.CurtainFigure(ax_style_top="geo", ax_style_bottom="frame")
fig = fig.ecplot(ds, variable="beta_att_1064")
Source code in earthcarekit/plot/figure/curtain.py
 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
 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
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.

    Args:
        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): Number of tick marks to 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.

    Example:
        ```python
        import earthcarekit as eck

        fig = eck.CurtainFigure(ax_style_top="geo", ax_style_bottom="frame")
        fig = fig.ecplot(ds, variable="beta_att_1064")
        ```
    """

    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,
    ):
        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 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.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

    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":
        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
        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,
        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,
        **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, 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 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]
            if value_range[1] is not None:
                norm.vmax = value_range[1]
        else:
            if log_scale == True:
                norm = LogNorm(value_range[0], value_range[1])
            else:
                norm = Normalize(value_range[0], value_range[1])
        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.atleast_2d(vp.height)[0, i]
                    height_range = tuple(height_range)
            vp = vp.select_height_range(height_range)
        else:
            height_range = (
                np.atleast_2d(vp.height)[0, 0],
                np.atleast_2d(vp.height)[0, -1],
            )

        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,
            vp.values,
            cmap=cmap,
            norm=norm,
            shading="auto",
            linewidth=0,
            **kwargs,
        )
        mesh.set_edgecolor("face")

        if colorbar:
            if cmap.categorical:
                self.colorbar = add_vertical_colorbar(
                    fig=self.fig,
                    ax=self.ax,
                    data=mesh,
                    label=format_var_label(vp.label, vp.units),
                    cmap=cmap,
                )
            else:
                self.colorbar = add_vertical_colorbar(
                    fig=self.fig,
                    ax=self.ax,
                    data=mesh,
                    label=format_var_label(vp.label, vp.units),
                    ticks=colorbar_ticks,
                    tick_labels=colorbar_tick_labels,
                )

        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,
            hmax=hmax,
            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_radius: bool = True,
        info_text_loc: str | 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,
        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,
        **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_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 value_range is None and log_scale is None and norm is None:
            all_args["norm"] = get_default_norm(var)
        if rolling_mean is None:
            all_args["rolling_mean"] = get_default_rolling_mean(var)
        if cmap is None:
            all_args["cmap"] = get_default_cmap(var)

        # 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,
                site_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_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
            )

        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,
    ) -> "CurtainFigure":
        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)

        if fill:
            self.ax.fill_between(
                tnew,
                hnew,
                color=color,
                alpha=alpha,
                zorder=zorder,
            )

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

        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,
    ) -> "CurtainFigure":
        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,
        )

        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 = r"$%.0f^{\circ}$C",
        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":
        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]

        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=1,
            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_temperature(
        self,
        ds: xr.Dataset,
        temperature_var: str = TEMP_CELSIUS_VAR,
        time_var: str = TIME_VAR,
        height_var: str = HEIGHT_VAR,
        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",
    ) -> "CurtainFigure":
        values_temperature = ds[temperature_var].values
        time = ds[time_var].values
        height = ds[height_var].values
        self.plot_contour(
            values=values_temperature,
            time=time,
            height=height,
            levels=levels,
            linewidths=linewidths,
            linestyles=linestyles,
            colors=colors,
            zorder=11,
        )
        return self

    def ecplot_elevation(
        self,
        ds: xr.Dataset,
        elevation_var: str = ELEVATION_VAR,
        time_var: str = TIME_VAR,
        color: Color | str | None = "ec:elevation",
    ) -> "CurtainFigure":
        height = ds[elevation_var].values
        time = ds[time_var].values
        self.plot_height(
            height=height,
            time=time,
            linewidth=0,
            linestyle="none",
            color=color,
            marker="none",
            markersize=0,
            fill=True,
            zorder=10,
        )
        return self

    def ecplot_tropopause(
        self,
        ds: xr.Dataset,
        tropopause_var: str = TROPOPAUSE_VAR,
        time_var: str = TIME_VAR,
        color: Color | str | None = "ec:tropopause",
        linewidth: float = 2,
        linestyle: str = "solid",
    ) -> "CurtainFigure":
        height = ds[tropopause_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,
        )

        return self

    def to_texture(self) -> "CurtainFigure":
        # 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 show(self):
        self.fig.tight_layout()
        self.fig.show()

    def save(self, filename: str = "", filepath: str | None = None, **kwargs):
        save_plot(fig=self.fig, filename=filename, filepath=filepath, **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_radius=True,
    info_text_loc=None,
    value_range=None,
    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,
    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,
    **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.

None
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_radius: bool = True,
    info_text_loc: str | 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,
    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,
    **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_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 value_range is None and log_scale is None and norm is None:
        all_args["norm"] = get_default_norm(var)
    if rolling_mean is None:
        all_args["rolling_mean"] = get_default_rolling_mean(var)
    if cmap is None:
        all_args["cmap"] = get_default_cmap(var)

    # 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,
            site_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_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
        )

    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("-", "")

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
 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
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,
    ):
        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())

    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,
        vmax: 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,
    ) -> "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
        self.ax.set_ylim((vmin, vmax))

        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] = {},
        **kwargs,
    ) -> "LineFigure":
        # 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]
            if value_range[1] is not None:
                norm.vmax = value_range[1]
        else:
            if log_scale == True:
                norm = LogNorm(value_range[0], value_range[1])
            else:
                norm = Normalize(value_range[0], value_range[1])
        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 len(values.shape) != 1:
            raise ValueError(
                f"Values must be 1D, but has {len(values.shape)} dimensions (shape={values.shape})"
            )

        tmin_original = time[0]
        tmax_original = 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
        else:
            time_range = (time[0], time[-1])

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

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

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

        x: NDArray = time
        y: NDArray = values

        if 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=30)

            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,
                **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,
                )
            elif "scatter" in self.mode:
                line = self.ax.scatter(
                    x,
                    y,
                    marker=marker,
                    s=markersize,
                    color=color,
                    alpha=alpha,
                )  # , **plot_kwargs)
            elif "area" in self.mode:
                line = self.ax.fill_between(
                    x,
                    [0] * x.shape[0],
                    y,
                    zorder=0,
                    color=color,
                    alpha=alpha,
                )
            else:
                raise ValueError(f"invalid `mode` {self.mode}")

            format_numeric_ticks(
                ax=self.ax,
                axis="y",
                label=format_var_label(label, units),
                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),
                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,
                    )
                    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,
                )

        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, color=selection_color, linestyle="solid", linewidth=selection_linewidth)  # type: ignore

        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] = {},
        **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)

        # 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,
                site_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 show(self):
        self.fig.tight_layout()
        self.fig.show()

    def save(self, filename: str = "", filepath: str | None = None, **kwargs):
        save_plot(fig=self.fig, filename=filename, filepath=filepath, **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

Base map style to use; options include "none", "stock_img", "gray", "osm", "satellite", "mtg", "msg". 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.

None
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

Level of detail for coastlines and grid elements; higher values reduce complexity. Defaults to 2.

2
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
 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
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, optional): Base map style to use; options include "none", "stock_img", "gray", "osm", "satellite", "mtg", "msg". 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, optional): Level of detail for coastlines and grid elements; higher values reduce complexity. Defaults to 2.
        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"]
        ) = "gray",
        projection: (
            Literal["platecarree", "perspective", "orthographic"] | ccrs.Projection
        ) = ccrs.Orthographic(),
        central_latitude: float | None = None,
        central_longitude: float | None = None,
        grid_color: ColorLike | None = None,
        border_color: ColorLike | None = None,
        coastline_color: ColorLike | None = None,
        show_grid: 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 = 2,
        coastlines_resolution: Literal["10m", "50m", "110m"] = "110m",
        azimuth: float = 0,
        pad: float | list[float] = 0.05,
        background_alpha: float = 1.0,
    ):
        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.show_grid = show_grid
        self.show_top_labels = show_top_labels
        self.show_bottom_labels = show_bottom_labels
        self.show_right_labels = show_right_labels
        self.show_left_labels = show_left_labels
        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)
        self.central_latitude = central_latitude
        self.central_longitude = central_longitude
        self.lod = lod
        self.coastlines_resolution = coastlines_resolution
        self.azimuth = azimuth
        self.colorbar: Colorbar | None = None
        self.pad = _validate_pad(pad)
        self.background_alpha = background_alpha

        self.projection_type, clat, clon = _validate_projection(projection)
        if self.central_latitude is None:
            self.central_latitude = clat
        if self.central_longitude is None:
            self.central_longitude = clon

        self.grid_lines: Gridliner | None = None

        self.show_night_shade = show_night_shade

        self._init_axes()

    def set_view(
        self, lats: ArrayLike, lons: ArrayLike, pad: float | Iterable | None = None
    ) -> Axes:
        if isinstance(pad, (float | int | Iterable)):
            self.pad = _validate_pad(pad)
        self.ax = set_view(
            self.ax,
            self.projection,
            lats,
            lons,
            pad_xmin=self.pad[0],
            pad_xmax=self.pad[1],
            pad_ymin=self.pad[2],
            pad_ymax=self.pad[3],
        )
        return self.ax

    def set_extent(
        self, extent: list | None = None, pad: float | Iterable | None = None
    ) -> "MapFigure":
        if isinstance(extent, Iterable):
            self.extent = extent
            self.set_view(
                lons=np.array(self.extent[0:2]),
                lats=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'"
            )
        if self.style == "none":
            pass
        elif self.style == "stock_img":
            img = self.ax.stock_img()  # type: ignore
            grid_color = Color("#3f4d53")
            coastline_color = Color("#537585")
        elif self.style == "gray":
            img = add_gray_stock_img(self.ax)
            grid_color = Color("#6d6d6db3")
            coastline_color = Color("#C0C0C0")
        elif self.style == "osm":
            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")
        elif self.style == "satellite":
            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")
        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:
                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"
                else:
                    layer = self.style
                    # raise NotImplementedError()
                wms_kwargs = {
                    "time": date_str,
                }

                self.ax.add_wms(wms, layer, wms_kwargs=wms_kwargs)  # 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.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.coastlines(  # type: ignore
        #     color=coastlines_color, resolution=self.coastlines_resolution
        # )  # type: ignore
        self.ax.add_feature(cfeature.COASTLINE, edgecolor=_coastline_color)  # type: ignore
        # self.ax.add_feature(  # type: ignore
        #     cfeature.BORDERS,
        #     linewidth=0.5,
        #     linestyle="solid",
        #     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: int | None = 4,
    ) -> "MapFigure":
        latitude = np.asarray(latitude)
        longitude = np.asarray(longitude)

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

        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

        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)
            self.ax.annotate(
                "",
                xy=(longitude[-1], latitude[-1]),
                xytext=(longitude[tmp_i], latitude[tmp_i]),
                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"] = "left",
        zorder: int | float = 8,
        padding: str = "  ",
    ) -> "MapFigure":
        if isinstance(text_side, str):
            if 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,
        )
        t = shade_around_text(t, alpha=0.5, linewidth=3, color="white")
        return self

    def plot_point(
        self,
        latitude: int | float,
        longitude: int | float,
        marker: str | None = "s",
        markersize: int | float = 5,
        color: Color | ColorLike | None = "black",
        zorder: int | float = 4,
        text: str | None = None,
        text_color: Color | ColorLike | None = "black",
        text_side: Literal["left", "right"] = "right",
        text_zorder: int | float = 8,
        text_padding: str = "  ",
    ) -> "MapFigure":
        self.ax.plot(
            [longitude],
            [latitude],
            marker=marker,
            markersize=markersize,
            linestyle="none",
            transform=self.transform,
            color=color,
            zorder=zorder,
        )
        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,
            )
        return self

    def plot_radius(
        self,
        latitude: int | float,
        longitude: int | float,
        radius_km: int | float,
        color: Color | ColorLike | None = "black",
        face_color: Color | ColorLike | None = "#FFFFFF00",
        edge_color: Color | ColorLike | None = None,
        text_color: Color | ColorLike | None = None,
        text: str | None = None,
        text_side: Literal["left", "right"] = "right",
        marker: str | None = "s",
        zorder: int | float = 4,
        text_zorder: int | float = 8,
    ) -> "MapFigure":
        color = Color.from_optional(color)
        face_color = Color.from_optional(face_color)
        edge_color = Color.from_optional(edge_color) or color
        text_color = Color.from_optional(text_color) or color

        # 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=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,
        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":
        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":
            self.lod = get_osm_lod(
                (lat_selection[0], lon_selection[0]),
                (lat_selection[-1], lon_selection[-1]),
            )
            self.coastlines_resolution = "10m"
        elif view == "data":
            self.lod = get_osm_lod(
                (lat_total[0], lon_total[0]), (lat_total[-1], lon_total[-1])
            )
            self.coastlines_resolution = "50m"
        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()

        # TODO: 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,
        )

        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 * 1.3
            if isinstance(self.projection, ccrs.PlateCarree):
                self.set_view(lats=lat_selection, lons=lon_selection)
            else:
                self.ax.set_xlim(-zoom_radius_meters, zoom_radius_meters)
                self.ax.set_ylim(-zoom_radius_meters, zoom_radius_meters)
        elif view == "data":
            self.set_view(lats=lat_total, lons=lon_total)

        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 | None = None,
        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,
        cb_orientation: str | Literal["vertical", "horizontal"] = "horizontal",
        cb_position: str | Literal["left", "right", "top", "bottom"] = "bottom",
        cb_alignment: str | Literal["left", "center", "right"] = "center",
        cb_buffer: float = 1.02,
        cb_width_ratio: float | str = "2%",
        cb_height_ratio: float | str = "100%",
        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.
            cb_orientation (Literal["vertical", "horizontal"], optional): Orientation of the colorbar. Defaults to "horizontal".
            cb_position (Literal["left", "right", "top", "bottom"], optional): Position of the colorbar in the plot. Defaults to "bottom".
            cb_alignment (Literal["left", "center", "right"], optional): Horizontal alignment of the colorbar relative to the plot. Defaults to "center".
            cb_buffer (float, optional): Distance between plot and colorbar in axes units. Defaults to 1.02.
            cb_width_ratio (float | str, optional): Width of the colorbar relative to the plot area. Defaults to "2.5%".
            cb_height_ratio (float | str, optional): Height of the colorbar relative to the plot area. Defaults to "100%".

        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):
            _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)

        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,
                site_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",
            )

            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]):
                    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 != "global":
                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:
                if view == "global":
                    _highlight_last = True
                else:
                    _highlight_last = False
                _ = 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,
                )
                if view == "global":
                    _highlight_last = False
                else:
                    _highlight_last = True
                _ = 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
            else:
                self.set_view(lats=coords_zoomed_in[:, 0], 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 norm is None:
                norm = get_default_norm(var, ds)
            lats = ds[swath_lat_var].values
            lons = ds[swath_lon_var].values
            values = ds[var].values
            label = getattr(ds[var], "long_name", "")
            units = getattr(ds[var], "units", "")
            _ = self.plot_swath(
                lats,
                lons,
                values,
                cmap=cmap,
                label=label,
                units=units,
                value_range=value_range,
                log_scale=log_scale,
                norm=norm,
                colorbar=colorbar,
                cb_orientation=cb_orientation,
                cb_position=cb_position,
                cb_alignment=cb_alignment,
                cb_buffer=cb_buffer,
                cb_width_ratio=cb_width_ratio,
                cb_height_ratio=cb_height_ratio,
            )

        # 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 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,
        cb_orientation: str | Literal["vertical", "horizontal"] = "horizontal",
        cb_position: str | Literal["left", "right", "top", "bottom"] = "bottom",
        cb_alignment: str | Literal["left", "center", "right"] = "center",
        cb_buffer: float = 1.02,
        cb_width_ratio: float | str = "2.5%",
        cb_height_ratio: float | str = "100%",
        show_swath_border: bool = True,
    ) -> "MapFigure":
        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]
            if value_range[1] is not None:
                norm.vmax = value_range[1]
        else:
            if log_scale == True:
                norm = LogNorm(value_range[0], value_range[1])
            else:
                norm = Normalize(value_range[0], value_range[1])

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

        if len(values.shape) == 3 and values.shape[2] == 3:
            mesh = self.ax.pcolormesh(
                lons.T,
                lats.T,
                values,
                shading="auto",
                transform=ccrs.PlateCarree(),
            )
        else:
            mesh = self.ax.pcolormesh(
                lons,
                lats,
                values,
                cmap=cmap,
                norm=norm,
                shading="auto",
                transform=ccrs.PlateCarree(),
            )
            if colorbar:
                self.colorbar = add_colorbar(
                    self.fig,
                    self.ax,
                    mesh,
                    orientation=cb_orientation,
                    position=cb_position,
                    alignment=cb_alignment,
                    buffer=cb_buffer,
                    width_ratio=cb_width_ratio,
                    height_ratio=cb_height_ratio,
                    label=format_var_label(label, units),
                    cmap=cmap,
                )
        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":
        # 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 show(self):
        self.fig.tight_layout()
        self.fig.show()

    def save(self, filename: str = "", filepath: str | None = None, **kwargs):
        save_plot(fig=self.fig, filename=filename, filepath=filepath, **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=None,
    log_scale=None,
    norm=None,
    colorbar=True,
    pad=None,
    show_text_time=None,
    show_text_frame=None,
    show_text_overpass=None,
    cb_orientation="horizontal",
    cb_position="bottom",
    cb_alignment="center",
    cb_buffer=1.02,
    cb_width_ratio="2%",
    cb_height_ratio="100%",
    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.

None
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
cb_orientation Literal['vertical', 'horizontal']

Orientation of the colorbar. Defaults to "horizontal".

'horizontal'
cb_position Literal['left', 'right', 'top', 'bottom']

Position of the colorbar in the plot. Defaults to "bottom".

'bottom'
cb_alignment Literal['left', 'center', 'right']

Horizontal alignment of the colorbar relative to the plot. Defaults to "center".

'center'
cb_buffer float

Distance between plot and colorbar in axes units. Defaults to 1.02.

1.02
cb_width_ratio float | str

Width of the colorbar relative to the plot area. Defaults to "2.5%".

'2%'
cb_height_ratio float | str

Height of the colorbar relative to the plot area. Defaults to "100%".

'100%'

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
 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
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 | None = None,
    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,
    cb_orientation: str | Literal["vertical", "horizontal"] = "horizontal",
    cb_position: str | Literal["left", "right", "top", "bottom"] = "bottom",
    cb_alignment: str | Literal["left", "center", "right"] = "center",
    cb_buffer: float = 1.02,
    cb_width_ratio: float | str = "2%",
    cb_height_ratio: float | str = "100%",
    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.
        cb_orientation (Literal["vertical", "horizontal"], optional): Orientation of the colorbar. Defaults to "horizontal".
        cb_position (Literal["left", "right", "top", "bottom"], optional): Position of the colorbar in the plot. Defaults to "bottom".
        cb_alignment (Literal["left", "center", "right"], optional): Horizontal alignment of the colorbar relative to the plot. Defaults to "center".
        cb_buffer (float, optional): Distance between plot and colorbar in axes units. Defaults to 1.02.
        cb_width_ratio (float | str, optional): Width of the colorbar relative to the plot area. Defaults to "2.5%".
        cb_height_ratio (float | str, optional): Height of the colorbar relative to the plot area. Defaults to "100%".

    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):
        _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)

    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,
            site_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",
        )

        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]):
                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 != "global":
            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:
            if view == "global":
                _highlight_last = True
            else:
                _highlight_last = False
            _ = 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,
            )
            if view == "global":
                _highlight_last = False
            else:
                _highlight_last = True
            _ = 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
        else:
            self.set_view(lats=coords_zoomed_in[:, 0], 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 norm is None:
            norm = get_default_norm(var, ds)
        lats = ds[swath_lat_var].values
        lons = ds[swath_lon_var].values
        values = ds[var].values
        label = getattr(ds[var], "long_name", "")
        units = getattr(ds[var], "units", "")
        _ = self.plot_swath(
            lats,
            lons,
            values,
            cmap=cmap,
            label=label,
            units=units,
            value_range=value_range,
            log_scale=log_scale,
            norm=norm,
            colorbar=colorbar,
            cb_orientation=cb_orientation,
            cb_position=cb_position,
            cb_alignment=cb_alignment,
            cb_buffer=cb_buffer,
            cb_width_ratio=cb_width_ratio,
            cb_height_ratio=cb_height_ratio,
        )

    # 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

ProductInfo dataclass

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

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."""

    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 data stored in `ProductInfo` as a `dict`."""
        return asdict(self)

    def to_dataframe(self) -> "ProductDataFrame":
        return ProductDataFrame([self])

to_dict

to_dict()

Returns data stored in ProductInfo as a dict.

Source code in earthcarekit/utils/read/product/file_info/product_info.py
def to_dict(self) -> dict:
    """Returns data stored in `ProductInfo` as a `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.

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
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
@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.

    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):

        if isinstance(self.values, Iterable):
            self.values = np.atleast_2d(self.values)
        if isinstance(self.height, Iterable):
            self.height = np.asarray(self.height)
        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)
            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,
        )

    @property
    def shape(self):
        return self.values.shape

    @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) -> "ProfileData":
        """Returns mean profile."""
        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 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
        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(nanmean(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,
    ) -> "ProfileData":
        """Retruns only data within the specified `height_range`."""
        height_range = validate_height_range(height_range)

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

        mask = np.logical_and(
            height_range[0] <= ref_height, ref_height <= height_range[1]
        )

        sel_values = self.values[:, mask]
        sel_error: NDArray | None = None
        if isinstance(self.error, np.ndarray):
            sel_error = self.error[:, mask]

        if len(self.height.shape) == 2:
            sel_height = self.height[:, mask]
        else:
            sel_height = self.height[mask]

        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":
        """Retruns only data within the specified `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
        p = p.mean()
        t = target
        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()
        _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
    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(nanmean(layer_mean_values))
    return layer_mean_values

mean

mean()

Returns mean profile.

Source code in earthcarekit/utils/profile_data/profile_data.py
def mean(self) -> "ProfileData":
    """Returns mean profile."""
    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)

Retruns only data within the specified height_range.

Source code in earthcarekit/utils/profile_data/profile_data.py
def select_height_range(
    self,
    height_range: DistanceRangeLike,
) -> "ProfileData":
    """Retruns only data within the specified `height_range`."""
    height_range = validate_height_range(height_range)

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

    mask = np.logical_and(
        height_range[0] <= ref_height, ref_height <= height_range[1]
    )

    sel_values = self.values[:, mask]
    sel_error: NDArray | None = None
    if isinstance(self.error, np.ndarray):
        sel_error = self.error[:, mask]

    if len(self.height.shape) == 2:
        sel_height = self.height[:, mask]
    else:
        sel_height = self.height[mask]

    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)

Retruns only data within the specified 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":
    """Retruns only data within the specified `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,
    )

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
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
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: int | float | None = 0
        self.hmax: int | float | None = 40e3
        if isinstance(height_range, (Sequence, np.ndarray)):
            self.hmin = height_range[0]
            self.hmax = height_range[1]

        self.vmin: int | float | None = None
        self.vmax: int | float | 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)

        self.ax_set_hlim(self.hmin, self.hmax)
        if self.vmin or self.vmax:
            if self.vmin and np.isnan(self.vmin):
                self.vmin = None
            if self.vmax and np.isnan(self.vmax):
                self.vmax = None
            self.ax_set_vlim(self.vmin, self.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: int | float = 1.5,
        ribbon_alpha: float = 0.2,
        show_grid: bool | None = None,
        zorder: int | float | 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 (int | float, 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 (int | float | 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)
        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,
        )

        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: int | float | None = 1,
        legend_label: str | None = "EarthCARE",
        show_legend: bool | None = None,
        show_steps: bool = DEFAULT_PROFILE_SHOW_STEPS,
        **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

        # 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 show(self):
        self.fig.tight_layout()
        self.fig.show()

    def save(self, filename: str = "", filepath: str | None = None, **kwargs):
        save_plot(fig=self.fig, filename=filename, filepath=filepath, **kwargs)

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 int | float

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 int | float | 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: int | float = 1.5,
    ribbon_alpha: float = 0.2,
    show_grid: bool | None = None,
    zorder: int | float | 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 (int | float, 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 (int | float | 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)
    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,
    )

    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

SwathFigure

TODO: documentation

Source code in earthcarekit/plot/figure/swath.py
class SwathFigure:
    """TODO: documentation"""

    def __init__(
        self,
        ax: Axes | None = None,
        figsize: tuple[float, float] = (12, 4),
        dpi: int | None = None,
        title: str | None = None,
    ):
        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()
            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.selection_time_range: tuple[pd.Timestamp, pd.Timestamp] | None = None

    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,
        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 = "geo",
        ax_style_bottom: AlongTrackAxisStyle | str = "time",
        ax_style_y: Literal[
            "from_track_distance", "across_track_distance", "pixel"
        ] = "from_track_distance",
        show_nadir: bool = True,
        **kwargs,
    ):
        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]
            if value_range[1] is not None:
                norm.vmax = value_range[1]
        else:
            if log_scale == True:
                norm = LogNorm(value_range[0], value_range[1])
            else:
                norm = Normalize(value_range[0], value_range[1])

        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)

        # Validate inputs
        logger.warning(f"Input validation not implemented")

        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

        is_from_track = ax_style_y != "across_track_distance"
        if ax_style_y == "from_track_distance":
            ydata = from_track_distance
            ylabel = "Distance from track"
        elif ax_style_y == "across_track_distance":
            ydata = across_track_distance
            ylabel = "Distance"
        elif 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, **kwargs)
        else:
            mesh = self.ax.pcolormesh(
                time,
                ydata,
                values.T,
                norm=norm,
                cmap=cmap,
                **kwargs,
            )

            if colorbar:
                if cmap.categorical:
                    add_vertical_colorbar(
                        fig=self.fig,
                        ax=self.ax,
                        data=mesh,
                        label=f"{label} [{units}]",
                        ticks=cmap.ticks,
                        tick_labels=cmap.labels,
                    )
                else:
                    add_vertical_colorbar(
                        fig=self.fig,
                        ax=self.ax,
                        data=mesh,
                        label=f"{label} [{units}]",
                        ticks=colorbar_ticks,
                        tick_labels=colorbar_tick_labels,
                    )

        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:
            self.ax.axhline(
                y=ynadir,
                color=Color("red"),
                linestyle="dashed",
                linewidth=2,
            )

        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 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=""
        )

        ax_style_bottom = AlongTrackAxisStyle.from_input(ax_style_bottom)
        ax_style_top = AlongTrackAxisStyle.from_input(ax_style_top)

        format_along_track_axis(
            self.ax,
            ax_style_bottom,
            time,
            tmin,
            tmax,
            tmin_original,
            tmax_original,
            longitude[:, nadir_index],
            latitude[:, nadir_index],
        )
        format_along_track_axis(
            self.ax_top,
            ax_style_top,
            time,
            tmin,
            tmax,
            tmin_original,
            tmax_original,
            longitude[:, nadir_index],
            latitude[:, nadir_index],
        )

    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,
        # 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,
        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 = "geo",
        ax_style_bottom: AlongTrackAxisStyle | str = "time",
        ax_style_y: Literal[
            "from_track_distance", "across_track_distance", "pixel"
        ] = "from_track_distance",
        show_nadir: bool = True,
        **kwargs,
    ):
        # 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"]
        # 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 value_range is None and log_scale is None and norm is None:
            all_args["norm"] = get_default_norm(var)
        if cmap is None:
            all_args["cmap"] = get_default_cmap(var)

        return self.plot(**all_args)

    def show(self):
        self.fig.tight_layout()
        self.fig.show()

    def save(self, filename: str = "", filepath: str | None = None, **kwargs):
        save_plot(fig=self.fig, filename=filename, filepath=filepath, **kwargs)

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,
    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",
    ],
    alpha=1.0,
    show_steps=DEFAULT_PROFILE_SHOW_STEPS,
    show_error_ec=False,
    to_mega=False,
    single_figsize=(5 * CM_AS_INCH, 12 * CM_AS_INCH),
)

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']
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
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)

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

Source code in earthcarekit/calval/_compare_bsc_ext_lr_depol.py
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,
    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",
    ],
    alpha: float = 1.0,
    show_steps: bool = DEFAULT_PROFILE_SHOW_STEPS,
    show_error_ec: bool = False,
    to_mega: bool = False,
    single_figsize: tuple[float | int, float | int] = (5 * CM_AS_INCH, 12 * CM_AS_INCH),
) -> _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.
        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.
        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).
    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`
    """
    _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)

    if not isinstance(resolution2, str):
        resolution2 = resolution

    if not isinstance(resolution3, str):
        resolution3 = resolution

    if not isinstance(resolution4, str):
        resolution4 = resolution

    _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,
    ):
        fig, axs = create_column_subfigures(
            ncols=4,
            single_figsize=single_figsize,
            margin=0.6,
        )

        vars_target: list[str | tuple[str, str] | list[str | tuple[str, str]]] = [
            bsc_var_ground,
            ext_var_ground,
            lr_var_ground,
            depol_var_ground,
        ]

        label = [
            "Bsc. coeff.",
            "Ext. coeff.",
            "Lidar ratio",
            "Depol. ratio",
        ]
        units = [
            "m$^{-1}$ sr$^{-1}$",
            "m$^{-1}$",
            "sr",
            "",
        ]
        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,
                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],
                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,
                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,
                alpha=alpha,
                show_steps=show_steps,
                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)

    return _CompareBscExtLRDepolResults(
        fig=fig,
        fig_bsc=pfs[0],
        fig_ext=pfs[1],
        fig_lr=pfs[2],
        fig_depol=pfs[3],
        stats=df,
    )

create_column_subfigures

create_column_subfigures(ncols, single_figsize=(3, 8), margin=0.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
tuple[Figure, list[Axes]]

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_subfigures(
    ncols: int,
    single_figsize: tuple[float, float] = (3, 8),
    margin: float = 0.0,
) -> tuple[Figure, list[Axes]]:
    """
    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.
    """
    fig: Figure = plt.figure(
        figsize=(single_figsize[0] * ncols + (ncols - 1) * margin, single_figsize[1])
    )
    figs: np.ndarray
    if ncols == 1:
        figs = np.array([fig])
    else:
        width_ratios = [single_figsize[0]]
        for c in range(ncols - 1):
            width_ratios.extend([margin, single_figsize[0]])

        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 fig, axs

create_fig_layout_map_main_zoom_profile

create_fig_layout_map_main_zoom_profile(
    main_rows,
    zoom_rows=None,
    profile_rows=None,
    map_rows=None,
    wspace=3.0 * CM_AS_INCH,
    hspace=3.0 * CM_AS_INCH,
    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.

3.0 * CM_AS_INCH
hspace float | Sequence[float]

Vertical spacing between rows. Similar behavior as wspace.

3.0 * CM_AS_INCH
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 tuple[Figure, Sequence[Axes], Sequence[Axes], Sequence[Axes], Sequence[Axes]]

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_fig_layout_map_main_zoom_profile(
    main_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] = 3.0 * CM_AS_INCH,
    hspace: float | Sequence[float] = 3.0 * CM_AS_INCH,
    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,
) -> tuple[Figure, Sequence[Axes], Sequence[Axes], Sequence[Axes], Sequence[Axes]]:
    """
    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(main_rows, list) and len(main_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 = len(main_rows)
    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(main_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 main_rows:
        if fig_type == FigureType.SWATH:
            hratios_figs.append(hswath)
        elif fig_type == FigureType.LINE:
            hratios_figs.append(hline)
        else:
            hratios_figs.append(hrow)
    if len(main_rows) < nrows_min:
        for i in range(nrows_min - len(main_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(main_rows, list):
        for fig_type in main_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 fig, axs_map, axs_main, axs_zoom, axs_profile

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=(0, 30000.0),
    ds_tropopause=None,
    ds_elevation=None,
    ds_temperature=None,
    resolution="low",
    ds2=None,
    ds_xmet=None,
    logger=None,
    log_msg_prefix="",
    selection_max_time_margin=None,
    show_steps=DEFAULT_PROFILE_SHOW_STEPS,
    mode="fast",
)

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 DistanceRangeLike | None

Height range in meters. Defaults to (0, 30_000).

(0, 30000.0)
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".

'low'
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'

Returns:

Name Type Description
_QuicklookResults _QuicklookResults

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: DistanceRangeLike | None = (0, 30e3),
    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"] = "low",
    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",
) -> _QuicklookResults:
    """
    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 (DistanceRangeLike | None, optional): Height range in meters. Defaults to (0, 30_000).
        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.

    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,
    )

    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""")

    raise NotImplementedError()

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_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
    raise ValueError(f"No matching ground site found: '{site}'")

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:
        raise ValueError(f"EarthCARE product has invalid file name: {filepath}")

    hdr_filepath = filepath.rstrip(".h5") + ".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).rstrip(".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(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=filepath,
        hdr_filepath=hdr_filepath,
    )

    return info

get_product_infos

get_product_infos(filepaths, **kwargs)

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().

{}

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,
    **kwargs,
) -> "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, **kwargs).to_dict())
        except ValueError as e:
            continue
    return ProductDataFrame(infos)

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

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) -> xr.Dataset
read_header_data(source: Dataset) -> xr.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)

    ds = _convert_all_fill_values_to_nan(ds)

    # 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,
    in_memory=False,
)

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
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,
    in_memory: bool = False,
) -> 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): 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): If True, all header data will be included in the dataframe. Defaults to False.
        meta (bool): If True, select meta data from header (like orbit number and frame ID) will be included in the dataframe. Defaults to True.
        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):
        if in_memory:
            with _read_product(
                filepath=input,
                trim_to_frame=trim_to_frame,
                modify=modify,
                header=header,
                meta=meta,
            ) as ds:
                ds = ds.load()
        else:
            ds = _read_product(
                filepath=input,
                trim_to_frame=trim_to_frame,
                modify=modify,
                header=header,
                meta=meta,
            )
    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.

Optionally applies a processing function to each frame and zooms in on a specific region (defined by zoom_at). Handles coarse averaging when not zooming.

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] | 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.

    Optionally applies a processing function to each frame and zooms in on a specific region
    (defined by `zoom_at`). Handles coarse averaging when not zooming.

    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): 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): 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="filepath")
        filepaths = df["filepath"].tolist()
    else:
        df = ProductDataFrame.from_files(list(filepaths)).sort_values(by="filepath")
        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)
                    )

                    # 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])

                    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)

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
) -> 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")
    elif agency == FileAgency.JAXA:
        df_cpr_geo = xr.open_dataset(
            filepath, group="ScienceData/Geo", engine="h5netcdf", phony_dims="sort"
        )
        df_cpr_data = xr.open_dataset(
            filepath, group="ScienceData/Data", engine="h5netcdf", phony_dims="sort"
        )
        ds = xr.merge([df_cpr_data, df_cpr_geo])
        ds.encoding["source"] = df_cpr_data.encoding["source"]
    else:
        raise NotImplementedError()

    ds = _convert_all_fill_values_to_nan(ds)

    return ds

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

The source XMET dataset from which vertical curtain along track will be interpolated.

required
ds_vert Dataset

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/auxiliary/aux_met_1d.py
def rebin_xmet_to_vertical_track(
    ds_xmet: xr.Dataset,
    ds_vert: xr.Dataset,
    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): The source XMET dataset from which vertical curtain along track will be interpolated.
        ds_vert (xr.Dataset): 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`.
    """
    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]

    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 = interp(track_alt[i])

                # Fill nans
                # _new_values[np.isnan(_new_values) & (track_alt[i] < height[i, 0])] = result[i, 0]
                # _new_values[np.isnan(_new_values) & (track_alt[i] > height[i, -1])] = result[i, -1]

                new_values[i] = _new_values

        new_var = f"{var}"
        new_ds_xmet[new_var] = (dims, new_values)
        new_ds_xmet[new_var].attrs = new_ds_xmet[var].attrs

    new_ds_xmet = remove_dims(new_ds_xmet, [xmet_horizontal_grid_dim, xmet_height_dim])

    return new_ds_xmet

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,
)

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 | Iterable[str]

Product file type(s) to match.

None
agency str | Iterable[str]

Producing agency or agencies (e.g. "ESA" or "JAXA").

None
latency str | Iterable[str]

Data latency level(s).

None
timestamp TimestampLike | Iterable

Timestamp(s) included in the product's time coverage.

None
baseline str | Iterable[str]

Baseline version(s).

None
orbit_and_frame str | Iterable[str]

Orbit and frame identifiers.

None
orbit_number int, str, | Iterable

Orbit number(s).

None
frame_id str | Iterable[str]

Frame identifier(s).

None
filename str | Iterable[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

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 | Iterable[str] | None = None,
    agency: str | Iterable[str] | None = None,
    latency: str | Iterable[str] | None = None,
    timestamp: TimestampLike | Iterable[TimestampLike] | None = None,
    baseline: str | Iterable[str] | None = None,
    orbit_and_frame: str | Iterable[str] | None = None,
    orbit_number: int | str | Iterable[int | str] | None = None,
    frame_id: str | Iterable[str] | None = None,
    filename: str | Iterable[str] | None = None,
    start_time: TimestampLike | None = None,
    end_time: TimestampLike | None = None,
) -> 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 | Iterable[str], optional): Product file type(s) to match.
        agency (str | Iterable[str], optional): Producing agency or agencies (e.g. "ESA" or "JAXA").
        latency (str | Iterable[str], optional): Data latency level(s).
        timestamp (TimestampLike | Iterable, optional): Timestamp(s) included in the product's time coverage.
        baseline (str | Iterable[str], optional): Baseline version(s).
        orbit_and_frame (str | Iterable[str], optional): Orbit and frame identifiers.
        orbit_number (int, str, | Iterable, optional): Orbit number(s).
        frame_id (str | Iterable[str], optional): Frame identifier(s).
        filename (str | Iterable[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.

    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(root_dirpath, str):
        if isinstance(config, ECKConfig):
            root_dirpath = config.path_to_data
        else:
            root_dirpath = read_config(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"

    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"

    # pattern = search_pattern(
    #     file_type=file_type,
    #     agency=agency,
    #     latency=latency,
    #     timestamp=timestamp,
    #     baseline=baseline,
    #     orbit_and_frame=orbit_and_frame,
    #     orbit_number=orbit_number,
    #     frame_id=frame_id,
    # )

    if pattern == ".*ECA_...._..._..._.._........T......Z_........T......Z_.......h5":
        files = []
    else:
        files = search_files_by_regex(root_dirpath, pattern)

    if isinstance(filename, str) or isinstance(filename, Iterable):
        if isinstance(filename, str):
            filename = np.atleast_1d(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)

    d = []
    df = get_product_infos(files)
    for f in files:
        try:
            d.append(get_product_info(f).to_dict())
        except ValueError as e:
            continue

    df = ProductDataFrame(d)

    if start_time is not None or end_time is not None:
        _d = []
        _df = get_product_infos(old_files)
        for _f in old_files:
            try:
                _d.append(get_product_info(_f).to_dict())
            except ValueError as _e:
                continue

        _df = ProductDataFrame(_d)

        _df = filter_time_range(_df, start_time=start_time, end_time=end_time)

        if not df.empty and not _df.empty:
            df = pd.concat([df, _df], ignore_index=True)
        elif not _df.empty:
            df = _df

    df = df.sort_values(by=["orbit_and_frame", "file_type", "start_processing_time"])
    df = df.drop_duplicates()
    df = df.reset_index(drop=True)

    return df

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