Source code for qa4sm_autoreports.extent

# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright (c) 2026 TU Wien & AWST
# SPDX-FileCopyrightText: For a full list of authors, see the AUTHORS file.

from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import cartopy.crs as ccrs
import cartopy.feature as cfeature


[docs] @dataclass(frozen=True) class GeographicExtent: """ An immutable geographic bounding box defined by its corner coordinates. Coordinate conventions ---------------------- - Latitude : -90.0 (South Pole) to +90.0 (North Pole) - Longitude : -180.0 (antimeridian west) to +180.0 (antimeridian east) Note ---- Extents that wrap around the antimeridian (e.g. parts of Alaska / Pacific) are *not* handled by this class. All longitudes are assumed to satisfy min_lon <= max_lon. """ min_lat: float min_lon: float max_lat: float max_lon: float # ------------------------------------------------------------------ # Construction / validation # ------------------------------------------------------------------ def __post_init__(self) -> None: if not (-90.0 <= self.min_lat <= 90.0): raise ValueError(f"min_lat {self.min_lat!r} is outside [-90, 90].") if not (-90.0 <= self.max_lat <= 90.0): raise ValueError(f"max_lat {self.max_lat!r} is outside [-90, 90].") if not (-180.0 <= self.min_lon <= 180.0): raise ValueError( f"min_lon {self.min_lon!r} is outside [-180, 180].") if not (-180.0 <= self.max_lon <= 180.0): raise ValueError( f"max_lon {self.max_lon!r} is outside [-180, 180].") if self.min_lat > self.max_lat: raise ValueError( f"min_lat ({self.min_lat}) must be <= max_lat ({self.max_lat})." ) if self.min_lon > self.max_lon: raise ValueError( f"min_lon ({self.min_lon}) must be <= max_lon ({self.max_lon})." )
[docs] @classmethod def from_corners( cls, lat1: float, lon1: float, lat2: float, lon2: float, ) -> "GeographicExtent": """ Convenience constructor that accepts two arbitrary corner points and sorts the coordinates automatically. Parameters ---------- lat1, lon1 : float First corner (any order). lat2, lon2 : float Opposite corner (any order). """ return cls( min_lat=min(lat1, lat2), min_lon=min(lon1, lon2), max_lat=max(lat1, lat2), max_lon=max(lon1, lon2), )
# ------------------------------------------------------------------ # Derived properties # ------------------------------------------------------------------ @property def width(self) -> float: """East–west span in degrees.""" return self.max_lon - self.min_lon @property def height(self) -> float: """North–south span in degrees.""" return self.max_lat - self.min_lat @property def center(self) -> tuple[float, float]: """(latitude, longitude) of the geometric centre.""" return ( (self.min_lat + self.max_lat) / 2.0, (self.min_lon + self.max_lon) / 2.0, ) @property def corners(self) -> tuple[tuple[float, float], ...]: """ All four corners as (lat, lon) tuples in order: SW, NW, NE, SE. """ return ( (self.min_lat, self.min_lon), # SW (self.max_lat, self.min_lon), # NW (self.max_lat, self.max_lon), # NE (self.min_lat, self.max_lon), # SE ) # ------------------------------------------------------------------ # Comparison # ------------------------------------------------------------------
[docs] def equals(self, other: "GeographicExtent", tolerance: float = 0.0) -> bool: """ Return True when this extent and *other* cover the same region. Parameters ---------- other : GeographicExtent The extent to compare against. tolerance : float Allowed absolute difference in degrees for each boundary (default: exact match). Examples -------- >>> a = GeographicExtent(0, 0, 10, 10) >>> b = GeographicExtent(0.0001, 0, 10, 10) >>> a.equals(b) False >>> a.equals(b, tolerance=0.001) True """ if not isinstance(other, GeographicExtent): return NotImplemented return (abs(self.min_lat - other.min_lat) <= tolerance and abs(self.min_lon - other.min_lon) <= tolerance and abs(self.max_lat - other.max_lat) <= tolerance and abs(self.max_lon - other.max_lon) <= tolerance)
# `==` uses the dataclass-generated __eq__ (exact field equality). # Use `.equals(other, tolerance=…)` for fuzzy comparison.
[docs] def overlaps(self, other: "GeographicExtent") -> bool: """ Return True if this extent and *other* have any area in common (touching edges count as overlapping). """ return (self.min_lat <= other.max_lat and self.max_lat >= other.min_lat and self.min_lon <= other.max_lon and self.max_lon >= other.min_lon)
[docs] def contains(self, other: "GeographicExtent") -> bool: """Return True if *other* is fully enclosed by this extent.""" return (self.min_lat <= other.min_lat and self.max_lat >= other.max_lat and self.min_lon <= other.min_lon and self.max_lon >= other.max_lon)
[docs] def contains_point(self, lat: float, lon: float) -> bool: """Return True if the point (lat, lon) lies within this extent.""" return self.min_lat <= lat <= self.max_lat and self.min_lon <= lon <= self.max_lon
# ------------------------------------------------------------------ # Intersection # ------------------------------------------------------------------
[docs] def intersection( self, other: "GeographicExtent") -> Optional["GeographicExtent"]: """ Compute the intersection of this extent and *other*. Returns ------- GeographicExtent The overlapping region, or ``None`` if the extents do not overlap. Examples -------- >>> a = GeographicExtent(0, 0, 10, 10) >>> b = GeographicExtent(5, 5, 15, 15) >>> a.intersection(b) GeographicExtent(min_lat=5, min_lon=5, max_lat=10, max_lon=10) """ new_min_lat = max(self.min_lat, other.min_lat) new_min_lon = max(self.min_lon, other.min_lon) new_max_lat = min(self.max_lat, other.max_lat) new_max_lon = min(self.max_lon, other.max_lon) if new_min_lat > new_max_lat or new_min_lon > new_max_lon: return None # No overlap return GeographicExtent(new_min_lat, new_min_lon, new_max_lat, new_max_lon)
[docs] @staticmethod def multi_intersection( *extents: "GeographicExtent") -> Optional["GeographicExtent"]: """ Compute the common intersection of two or more extents. Parameters ---------- *extents : GeographicExtent Two or more extents to intersect. Returns ------- GeographicExtent The region common to all supplied extents, or ``None`` if there is no common region (or fewer than two extents are supplied). Examples -------- >>> a = GeographicExtent(0, 0, 20, 20) >>> b = GeographicExtent(5, 5, 25, 25) >>> c = GeographicExtent(8, 3, 15, 18) >>> GeographicExtent.multi_intersection(a, b, c) GeographicExtent(min_lat=8, min_lon=5, max_lat=15, max_lon=18) """ if len(extents) < 2: return None result: Optional[GeographicExtent] = extents[0] for ext in extents[1:]: if result is None: break result = result.intersection(ext) return result
# ------------------------------------------------------------------ # Union (bounding box of all extents) # ------------------------------------------------------------------
[docs] @staticmethod def union(*extents: "GeographicExtent") -> "GeographicExtent": """ Return the smallest extent that contains all supplied extents. Parameters ---------- *extents : GeographicExtent Two or more extents. """ if not extents: raise ValueError("At least one extent is required.") return GeographicExtent( min_lat=min(e.min_lat for e in extents), min_lon=min(e.min_lon for e in extents), max_lat=max(e.max_lat for e in extents), max_lon=max(e.max_lon for e in extents), )
# ------------------------------------------------------------------ # Dunder helpers # ------------------------------------------------------------------ def __repr__(self) -> str: return (f"GeographicExtent(" f"min_lat={self.min_lat}, min_lon={self.min_lon}, " f"max_lat={self.max_lat}, max_lon={self.max_lon})") def __str__(self) -> str: return (f"[{self.min_lat}°, {self.min_lon}°] → " f"[{self.max_lat}°, {self.max_lon}°]") def __and__(self, other: "GeographicExtent") -> Optional["GeographicExtent"]: """Syntactic sugar: ``extent_a & extent_b`` → intersection.""" return self.intersection(other) def __or__(self, other: "GeographicExtent") -> "GeographicExtent": """Syntactic sugar: ``extent_a | extent_b`` → union.""" return GeographicExtent.union(self, other)
[docs] def plot_map(self, global_map=False) -> plt.Figure: """ Alternative version with higher detail and different styling options. """ fig = plt.figure(figsize=(15, 10), dpi=300) ax = plt.axes(projection=ccrs.PlateCarree()) # Calculate bounds lat_range = self.max_lat - self.min_lat lon_range = self.max_lon - self.min_lon padding = max(lat_range, lon_range) * 0.4 if global_map: ax.set_global() else: minlon = self.min_lon - padding if minlon < -180: minlon = -180 maxlon = self.max_lon + padding if maxlon > 180: maxlon = 180 minlat = self.min_lat - padding if minlat < -90: minlat = -90 maxlat = self.max_lat + padding if maxlat > 90: maxlat = 90 ax.set_extent([minlon, maxlon, minlat, maxlat], crs=ccrs.PlateCarree()) # High quality features ax.add_feature(cfeature.COASTLINE, linewidth=1.2, color='black') ax.add_feature(cfeature.BORDERS, linewidth=0.8, color='darkgray') land = cfeature.NaturalEarthFeature( category='physical', name='land', scale='110m', facecolor='#f0f0f0', edgecolor='none') ocean = cfeature.NaturalEarthFeature( category='physical', name='ocean', scale='110m', facecolor='#e6f3ff', edgecolor='none') ax.add_feature(land) ax.add_feature(ocean) ax.add_feature(cfeature.LAKES, facecolor='#e6f3ff', linewidth=0.5) ax.add_feature(cfeature.RIVERS, color='#4da6ff', linewidth=0.4) ax.add_feature(cfeature.COASTLINE, color='black', linewidth=2) # Add the rectangle with gradient effect rectangle = patches.Rectangle((self.min_lon, self.min_lat), self.max_lon - self.min_lon, self.max_lat - self.min_lat, linewidth=0, edgecolor='red', facecolor='red', alpha=0.3, transform=ccrs.PlateCarree(), zorder=10, label='Covered Area') ax.add_patch(rectangle) # Enhanced gridlines gl = ax.gridlines( draw_labels=True, linewidth=0.8, color='gray', alpha=0.6, linestyle=':') gl.top_labels = False gl.right_labels = False ax.legend() return fig