From 0b0cea459c7d7ff5ffc339dd4d91eb44bb3f732f Mon Sep 17 00:00:00 2001 From: JacobSampson Date: Sat, 6 Jun 2026 23:22:27 -0500 Subject: [PATCH] Remove GEE dependency and replace with STAC module Now that APR-179 confirmed the STAC path is live in the app: - Delete src/onkia/satellite_lst.py (GEE-based satellite module) - Delete tests/test_satellite_lst.py (tests for the old GEE module) - Move shared dataclasses, constants, and utility functions (LSTObservation, LSTHistoryPoint, NDCIObservation, LAKE_ACRES, celsius_to_fahrenheit, etc.) directly into satellite_lst_stac.py, removing the cross-module import - Update test_satellite_lst_stac.py to import those types from satellite_lst_stac - Remove earthengine-api>=0.1.370 from pyproject.toml and requirements.txt All 218 relevant tests pass. Fixes APR-180. Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 1 - requirements.txt | 1 - src/onkia/satellite_lst.py | 623 ------------------------------- src/onkia/satellite_lst_stac.py | 132 ++++++- tests/test_satellite_lst.py | 404 -------------------- tests/test_satellite_lst_stac.py | 8 +- 6 files changed, 121 insertions(+), 1048 deletions(-) delete mode 100644 src/onkia/satellite_lst.py delete mode 100644 tests/test_satellite_lst.py diff --git a/pyproject.toml b/pyproject.toml index a66293c..c511ce5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ folium = ">=0.14.0" xarray = ">=2023.0" pyarrow = ">=12.0" scipy = ">=1.10" -earthengine-api = ">=0.1.370" pystac-client = ">=0.8" planetary-computer = ">=1.0" rioxarray = ">=0.18" diff --git a/requirements.txt b/requirements.txt index 716e273..cc1a233 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,6 @@ great-expectations>=0.18.0 xarray>=2023.0 pyarrow>=12.0 scipy>=1.10 -earthengine-api>=0.1.370 pystac-client>=0.8 planetary-computer>=1.0 rioxarray>=0.18 diff --git a/src/onkia/satellite_lst.py b/src/onkia/satellite_lst.py deleted file mode 100644 index 1cff68d..0000000 --- a/src/onkia/satellite_lst.py +++ /dev/null @@ -1,623 +0,0 @@ -"""Satellite-derived lake surface temperature via Google Earth Engine. - -Fetches Landsat 8/9 thermal band (ST_B10) data for Wright County lakes. -Provides: - - Most recent cloud-free LST observation per lake - - Historical LST trend (last 3-6 months) - - Sentinel-2 NDCI (chlorophyll-a proxy) - - Spatial temperature heatmap URL for large lakes (>1000 acres) - -GEE authentication is required for live data. Configure via: - EE_SERVICE_ACCOUNT_EMAIL — service account email - EE_SERVICE_ACCOUNT_KEY_PATH — path to private key JSON file - EE_SERVICE_ACCOUNT_KEY_JSON — private key JSON contents (alternative to key path) - -If credentials are absent, all public functions return results with -``fallback_used=True`` and ``None`` temperature values. -""" -from __future__ import annotations - -import json -import logging -import os -import tempfile -from dataclasses import dataclass, field -from datetime import date, datetime, timedelta, timezone -from typing import List, Optional - -_log = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Lake metadata -# --------------------------------------------------------------------------- - -#: Approximate surface area in acres for Wright County lakes. -LAKE_ACRES: dict[str, float] = { - "Clearwater": 3158.0, - "Buffalo Lake": 1552.0, - "Lake Sylvia": 904.0, - "Twin Lake": 872.0, - "Lake Pulaski": 813.0, - "Maple Lake": 777.0, - "Howard Lake": 711.0, - "Pelican Lake": 3800.0, - "Lake Charlotte": 253.0, - "Lake Ida": 226.0, - "Bass Lake": 218.0, - "Lake Francis": 460.0, - "Lake Andrew": 200.0, - "Lake Montrose": 150.0, - "South Center Lake": 100.0, -} - -#: Lakes below this threshold get a single mean LST value only. -LST_USEFUL_THRESHOLD_ACRES: float = 700.0 - -#: Lakes at or above this threshold get a spatial heatmap overlay. -HEATMAP_THRESHOLD_ACRES: float = 1000.0 - -# Landsat scale factor: ST_B10 DN → Kelvin -_LANDSAT_SCALE = 0.00341802 -_LANDSAT_OFFSET = 149.0 # Kelvin offset - -# --------------------------------------------------------------------------- -# Data classes -# --------------------------------------------------------------------------- - - -@dataclass -class LSTObservation: - """Surface temperature result for a single lake snapshot.""" - - lake_name: str - temp_celsius: Optional[float] = None - temp_fahrenheit: Optional[float] = None - observation_date: Optional[date] = None - #: Number of cloud-free Landsat scenes composited. - scene_count: int = 0 - #: Pixel count used in the mean reduction. - pixel_count: Optional[int] = None - satellite: str = "Landsat 8/9" - fallback_used: bool = False - error_msg: Optional[str] = None - - -@dataclass -class LSTHistoryPoint: - """One LST observation in a historical time series.""" - - observation_date: date - temp_celsius: float - temp_fahrenheit: float - - -@dataclass -class NDCIObservation: - """Sentinel-2 Normalised Difference Chlorophyll Index result.""" - - lake_name: str - ndci_value: Optional[float] = None - #: Qualitative category derived from NDCI. - chlorophyll_category: Optional[str] = None - observation_date: Optional[date] = None - fallback_used: bool = False - error_msg: Optional[str] = None - - -# --------------------------------------------------------------------------- -# Unit helpers -# --------------------------------------------------------------------------- - - -def celsius_to_fahrenheit(c: float) -> float: - """Convert Celsius to Fahrenheit.""" - return c * 9.0 / 5.0 + 32.0 - - -def kelvin_to_celsius(k: float) -> float: - """Convert Kelvin to Celsius.""" - return k - 273.15 - - -def landsat_dn_to_celsius(dn: float) -> float: - """Apply Landsat Collection-2 ST scale factor and convert to Celsius.""" - kelvin = dn * _LANDSAT_SCALE + _LANDSAT_OFFSET - return kelvin_to_celsius(kelvin) - - -def ndci_to_category(ndci: float) -> str: - """Map NDCI value to a qualitative chlorophyll category. - - Thresholds from Mishra & Mishra (2012): - ndci < 0.0 → low - 0.0–0.1 → moderate - > 0.1 → high - """ - if ndci < 0.0: - return "low" - if ndci <= 0.1: - return "moderate" - return "high" - - -def lake_radius_m(lake_name: str) -> float: - """Return a search-radius (metres) appropriate for the lake's area. - - Larger lakes need a bigger buffer to capture enough pixels. - """ - acres = LAKE_ACRES.get(lake_name, 300.0) - if acres >= 2000: - return 3000.0 - if acres >= 1000: - return 2000.0 - if acres >= 500: - return 1200.0 - return 700.0 - - -# --------------------------------------------------------------------------- -# GEE initialisation -# --------------------------------------------------------------------------- - -_gee_initialized: bool = False -_gee_available: bool = False -_temp_key_path: Optional[str] = None # path of any temp key file written - - -def _init_gee() -> bool: - """Initialise the Earth Engine API. Returns True if ready. - - Reads credentials from environment variables: - EE_SERVICE_ACCOUNT_EMAIL — service account email - EE_SERVICE_ACCOUNT_KEY_PATH — path to JSON private key file - EE_SERVICE_ACCOUNT_KEY_JSON — JSON key contents (alternative to key path) - - Falls back to application default credentials when env vars are absent. - """ - global _gee_initialized, _gee_available, _temp_key_path - if _gee_initialized: - return _gee_available - _gee_initialized = True - - try: - import ee # type: ignore[import] - except ImportError: - _log.warning("earthengine-api not installed; satellite LST unavailable") - return False - - service_account = os.getenv("EE_SERVICE_ACCOUNT_EMAIL", "").strip() - key_path = os.getenv("EE_SERVICE_ACCOUNT_KEY_PATH", "").strip() - key_json = os.getenv("EE_SERVICE_ACCOUNT_KEY_JSON", "").strip() - - try: - if service_account and (key_path or key_json): - if key_json and not key_path: - # Write the JSON contents to a temporary file - tmp = tempfile.NamedTemporaryFile( - mode="w", suffix=".json", delete=False - ) - tmp.write(key_json) - tmp.close() - _temp_key_path = tmp.name - key_path = _temp_key_path - credentials = ee.ServiceAccountCredentials(service_account, key_path) - ee.Initialize(credentials) - else: - ee.Initialize() - _gee_available = True - _log.info("Google Earth Engine initialised successfully") - except Exception as exc: - _log.warning("GEE initialisation failed: %s", exc) - _gee_available = False - - return _gee_available - - -def _reset_gee_state() -> None: - """Reset GEE init state — used in tests only.""" - global _gee_initialized, _gee_available - _gee_initialized = False - _gee_available = False - - -# --------------------------------------------------------------------------- -# Internal GEE helpers -# --------------------------------------------------------------------------- - - -def _build_lake_region(lat: float, lon: float, radius_m: float): # type: ignore[return] - """Return an ee.Geometry circle for the lake.""" - import ee # type: ignore[import] - return ee.Geometry.Point([lon, lat]).buffer(radius_m) - - -def _apply_lst_mask(image): # type: ignore[return] - """Apply Landsat C2 cloud/shadow mask and scale ST_B10 to Celsius.""" - import ee # type: ignore[import] - qa = image.select("QA_PIXEL") - cloud_bit = 1 << 3 - shadow_bit = 1 << 4 - clear_mask = qa.bitwiseAnd(cloud_bit).eq(0).And( - qa.bitwiseAnd(shadow_bit).eq(0) - ) - lst_celsius = ( - image.select("ST_B10") - .multiply(_LANDSAT_SCALE) - .add(_LANDSAT_OFFSET) - .subtract(273.15) - .rename("LST_C") - ) - return ( - lst_celsius - .updateMask(clear_mask) - .copyProperties(image, ["system:time_start"]) - ) - - -def _landsat_collection(region, start_iso: str, end_iso: str): # type: ignore[return] - """Return a merged Landsat 8+9 C2 L2 collection, filtered and mapped.""" - import ee # type: ignore[import] - col8 = ee.ImageCollection("LANDSAT/LC08/C02/T1_L2") - col9 = ee.ImageCollection("LANDSAT/LC09/C02/T1_L2") - return ( - col8.merge(col9) - .filterBounds(region) - .filterDate(start_iso, end_iso) - .map(_apply_lst_mask) - ) - - -def _mean_lst_over_region(composite, region) -> Optional[float]: - """Reduce composite to a mean LST_C over region; returns None on error.""" - import ee # type: ignore[import] - try: - result = composite.reduceRegion( - reducer=ee.Reducer.mean(), - geometry=region, - scale=100, - maxPixels=int(1e6), - ) - val = result.get("LST_C").getInfo() - return float(val) if val is not None else None - except Exception as exc: - _log.debug("reduceRegion failed: %s", exc) - return None - - -def _pixel_count_over_region(composite, region) -> Optional[int]: - """Return the count of valid pixels in composite over region.""" - import ee # type: ignore[import] - try: - result = composite.reduceRegion( - reducer=ee.Reducer.count(), - geometry=region, - scale=100, - maxPixels=int(1e6), - ) - val = result.get("LST_C").getInfo() - return int(val) if val is not None else None - except Exception: - return None - - -def _most_recent_scene_date(collection) -> Optional[date]: - """Return the acquisition date of the most recent image in collection.""" - import ee # type: ignore[import] - try: - size = collection.size().getInfo() - if size == 0: - return None - latest = collection.sort("system:time_start", False).first() - ts_ms = latest.get("system:time_start").getInfo() - return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).date() - except Exception: - return None - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -def get_latest_lst( - lake_name: str, - lat: float, - lon: float, - days_back: int = 30, -) -> LSTObservation: - """Fetch the most recent cloud-free Landsat LST for a lake. - - Uses a 30-day composite (median) when no single scene is cloud-free enough. - - Args: - lake_name: Display name for labelling results. - lat: Lake centroid latitude (WGS-84). - lon: Lake centroid longitude (WGS-84). - days_back: Look-back window in days (default 30). - - Returns: - LSTObservation with temp in Celsius and Fahrenheit, or - ``fallback_used=True`` if GEE is unavailable. - """ - if not _init_gee(): - return LSTObservation( - lake_name=lake_name, - fallback_used=True, - error_msg="GEE not initialised", - ) - - try: - import ee # type: ignore[import] - - radius_m = lake_radius_m(lake_name) - region = _build_lake_region(lat, lon, radius_m) - - end_date = datetime.now(timezone.utc).date() - start_date = end_date - timedelta(days=days_back) - - collection = _landsat_collection( - region, start_date.isoformat(), end_date.isoformat() - ) - scene_count = collection.size().getInfo() - composite = collection.median() - lst_c = _mean_lst_over_region(composite, region) - pixel_count = _pixel_count_over_region(composite, region) - obs_date = _most_recent_scene_date(collection) - - if lst_c is None: - return LSTObservation( - lake_name=lake_name, - observation_date=obs_date, - scene_count=scene_count, - fallback_used=True, - error_msg="No valid pixels in composite", - ) - - return LSTObservation( - lake_name=lake_name, - temp_celsius=round(lst_c, 1), - temp_fahrenheit=round(celsius_to_fahrenheit(lst_c), 1), - observation_date=obs_date, - scene_count=scene_count, - pixel_count=pixel_count, - fallback_used=False, - ) - except Exception as exc: - _log.warning("get_latest_lst failed for %s: %s", lake_name, exc) - return LSTObservation( - lake_name=lake_name, - fallback_used=True, - error_msg=str(exc), - ) - - -def get_lst_history( - lake_name: str, - lat: float, - lon: float, - days_back: int = 180, - interval_days: int = 16, -) -> List[LSTHistoryPoint]: - """Fetch a historical LST time series for a lake. - - Samples the Landsat archive in ``interval_days`` windows to build a trend. - - Args: - lake_name: Display name for labelling. - lat: Lake centroid latitude. - lon: Lake centroid longitude. - days_back: Total look-back window (default 180 days / ~6 months). - interval_days: Size of each compositing window (default 16, ~Landsat repeat). - - Returns: - List of LSTHistoryPoint sorted oldest-first. - Empty list if GEE is unavailable or no data found. - """ - if not _init_gee(): - return [] - - try: - import ee # type: ignore[import] - - radius_m = lake_radius_m(lake_name) - region = _build_lake_region(lat, lon, radius_m) - - end_date = datetime.now(timezone.utc).date() - start_date = end_date - timedelta(days=days_back) - - points: List[LSTHistoryPoint] = [] - cursor = start_date - while cursor < end_date: - window_end = min(cursor + timedelta(days=interval_days), end_date) - collection = _landsat_collection( - region, cursor.isoformat(), window_end.isoformat() - ) - size = collection.size().getInfo() - if size > 0: - composite = collection.median() - lst_c = _mean_lst_over_region(composite, region) - window_mid = cursor + (window_end - cursor) / 2 - if lst_c is not None: - points.append( - LSTHistoryPoint( - observation_date=window_mid, - temp_celsius=round(lst_c, 1), - temp_fahrenheit=round(celsius_to_fahrenheit(lst_c), 1), - ) - ) - cursor = window_end - - return sorted(points, key=lambda p: p.observation_date) - except Exception as exc: - _log.warning("get_lst_history failed for %s: %s", lake_name, exc) - return [] - - -def get_ndci( - lake_name: str, - lat: float, - lon: float, - days_back: int = 30, -) -> NDCIObservation: - """Fetch the most recent Sentinel-2 NDCI (chlorophyll-a proxy). - - NDCI = (B05 - B04) / (B05 + B04) - where B05 = red-edge (705 nm) and B04 = red (665 nm). - - Args: - lake_name: Display name for labelling. - lat: Lake centroid latitude. - lon: Lake centroid longitude. - days_back: Look-back window in days (default 30). - - Returns: - NDCIObservation with ndci_value and chlorophyll_category, or - ``fallback_used=True`` if GEE is unavailable. - """ - if not _init_gee(): - return NDCIObservation( - lake_name=lake_name, - fallback_used=True, - error_msg="GEE not initialised", - ) - - try: - import ee # type: ignore[import] - - radius_m = lake_radius_m(lake_name) - region = _build_lake_region(lat, lon, radius_m) - - end_date = datetime.now(timezone.utc).date() - start_date = end_date - timedelta(days=days_back) - - def _apply_ndci(image): - red_edge = image.select("B5") # 705 nm - red = image.select("B4") # 665 nm - # Mask clouds/water using Scene Classification Layer - scl = image.select("SCL") - valid_mask = scl.neq(9).And(scl.neq(8)).And(scl.neq(3)) - ndci = red_edge.subtract(red).divide(red_edge.add(red)).rename("NDCI") - return ndci.updateMask(valid_mask).copyProperties( - image, ["system:time_start"] - ) - - collection = ( - ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED") - .filterBounds(region) - .filterDate(start_date.isoformat(), end_date.isoformat()) - .filter(ee.Filter.lt("CLOUDY_PIXEL_PERCENTAGE", 30)) - .map(_apply_ndci) - ) - - size = collection.size().getInfo() - if size == 0: - return NDCIObservation( - lake_name=lake_name, - fallback_used=True, - error_msg="No Sentinel-2 scenes in window", - ) - - composite = collection.median() - try: - result = composite.reduceRegion( - reducer=ee.Reducer.mean(), - geometry=region, - scale=20, - maxPixels=int(1e6), - ) - ndci_val = result.get("NDCI").getInfo() - except Exception: - ndci_val = None - - obs_date = _most_recent_scene_date(collection) - - if ndci_val is None: - return NDCIObservation( - lake_name=lake_name, - observation_date=obs_date, - fallback_used=True, - error_msg="No valid NDCI pixels", - ) - - ndci_f = round(float(ndci_val), 3) - return NDCIObservation( - lake_name=lake_name, - ndci_value=ndci_f, - chlorophyll_category=ndci_to_category(ndci_f), - observation_date=obs_date, - fallback_used=False, - ) - except Exception as exc: - _log.warning("get_ndci failed for %s: %s", lake_name, exc) - return NDCIObservation( - lake_name=lake_name, - fallback_used=True, - error_msg=str(exc), - ) - - -def get_lst_heatmap_url( - lat: float, - lon: float, - radius_m: float, - days_back: int = 30, - min_temp_c: float = 5.0, - max_temp_c: float = 30.0, - image_size: int = 400, -) -> Optional[str]: - """Return a signed GEE thumbnail URL for the LST spatial heatmap. - - Uses a thermal colour palette (blue → red). Suitable for display via - ``st.image()`` in Streamlit or an tag. URL is valid for ~1 hour. - - Args: - lat: Lake centroid latitude. - lon: Lake centroid longitude. - radius_m: Buffer radius in metres. - days_back: Composite window in days (default 30). - min_temp_c: Minimum temperature for colour scale. - max_temp_c: Maximum temperature for colour scale. - image_size: Output image pixel dimension (square). - - Returns: - HTTPS URL string, or None if GEE is unavailable. - """ - if not _init_gee(): - return None - - try: - import ee # type: ignore[import] - - region = _build_lake_region(lat, lon, radius_m) - end_date = datetime.now(timezone.utc).date() - start_date = end_date - timedelta(days=days_back) - - collection = _landsat_collection( - region, start_date.isoformat(), end_date.isoformat() - ) - if collection.size().getInfo() == 0: - return None - - composite = collection.median() - url: str = composite.getThumbURL( - { - "min": min_temp_c, - "max": max_temp_c, - "palette": [ - "000080", # navy (cold) - "0000ff", # blue - "00ffff", # cyan - "00ff00", # green - "ffff00", # yellow - "ff8000", # orange - "ff0000", # red (warm) - ], - "region": region, - "dimensions": image_size, - "format": "png", - } - ) - return url - except Exception as exc: - _log.warning("get_lst_heatmap_url failed: %s", exc) - return None diff --git a/src/onkia/satellite_lst_stac.py b/src/onkia/satellite_lst_stac.py index 746c189..e7ef6c2 100644 --- a/src/onkia/satellite_lst_stac.py +++ b/src/onkia/satellite_lst_stac.py @@ -1,6 +1,6 @@ """Satellite-derived lake surface temperature via STAC APIs. -Drop-in replacement for satellite_lst.py (which depends on Google Earth Engine). +Replaces the former GEE-based satellite_lst module. Uses the Microsoft Planetary Computer STAC API (pystac-client + planetary_computer) to fetch Landsat 8/9 Collection 2 Level-2 thermal data (ST_B10 band). No credentials required — the Planetary Computer provides free SAS-signed @@ -9,7 +9,7 @@ For Sentinel-2 NDCI, also uses the Planetary Computer (free). Falls back to the Copernicus Data Space Ecosystem STAC API (requires CDSE_ACCESS_TOKEN). -Public API mirrors satellite_lst.py: +Public API: - get_latest_lst() — most recent cloud-free LST per lake - get_lst_history() — historical LST trend - get_ndci() — Sentinel-2 NDCI (chlorophyll-a proxy) @@ -21,6 +21,7 @@ import logging import math import os +from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from typing import List, Optional @@ -44,18 +45,121 @@ except ImportError: _HAS_PLANETARY_COMPUTER = False -from onkia.satellite_lst import ( - LAKE_ACRES, - LSTHistoryPoint, - LSTObservation, - LST_USEFUL_THRESHOLD_ACRES, - NDCIObservation, - HEATMAP_THRESHOLD_ACRES, - celsius_to_fahrenheit, - kelvin_to_celsius, - lake_radius_m, - ndci_to_category, -) +# --------------------------------------------------------------------------- +# Lake metadata +# --------------------------------------------------------------------------- + +#: Approximate surface area in acres for Wright County lakes. +LAKE_ACRES: dict[str, float] = { + "Clearwater": 3158.0, + "Buffalo Lake": 1552.0, + "Lake Sylvia": 904.0, + "Twin Lake": 872.0, + "Lake Pulaski": 813.0, + "Maple Lake": 777.0, + "Howard Lake": 711.0, + "Pelican Lake": 3800.0, + "Lake Charlotte": 253.0, + "Lake Ida": 226.0, + "Bass Lake": 218.0, + "Lake Francis": 460.0, + "Lake Andrew": 200.0, + "Lake Montrose": 150.0, + "South Center Lake": 100.0, +} + +#: Lakes below this threshold get a single mean LST value only. +LST_USEFUL_THRESHOLD_ACRES: float = 700.0 + +#: Lakes at or above this threshold get a spatial heatmap overlay. +HEATMAP_THRESHOLD_ACRES: float = 1000.0 + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class LSTObservation: + """Surface temperature result for a single lake snapshot.""" + + lake_name: str + temp_celsius: Optional[float] = None + temp_fahrenheit: Optional[float] = None + observation_date: Optional[date] = None + #: Number of cloud-free Landsat scenes composited. + scene_count: int = 0 + #: Pixel count used in the mean reduction. + pixel_count: Optional[int] = None + satellite: str = "Landsat 8/9" + fallback_used: bool = False + error_msg: Optional[str] = None + + +@dataclass +class LSTHistoryPoint: + """One LST observation in a historical time series.""" + + observation_date: date + temp_celsius: float + temp_fahrenheit: float + + +@dataclass +class NDCIObservation: + """Sentinel-2 Normalised Difference Chlorophyll Index result.""" + + lake_name: str + ndci_value: Optional[float] = None + #: Qualitative category derived from NDCI. + chlorophyll_category: Optional[str] = None + observation_date: Optional[date] = None + fallback_used: bool = False + error_msg: Optional[str] = None + +# --------------------------------------------------------------------------- +# Unit helpers +# --------------------------------------------------------------------------- + + +def celsius_to_fahrenheit(c: float) -> float: + """Convert Celsius to Fahrenheit.""" + return c * 9.0 / 5.0 + 32.0 + + +def kelvin_to_celsius(k: float) -> float: + """Convert Kelvin to Celsius.""" + return k - 273.15 + + +def ndci_to_category(ndci: float) -> str: + """Map NDCI value to a qualitative chlorophyll category. + + Thresholds from Mishra & Mishra (2012): + ndci < 0.0 → low + 0.0–0.1 → moderate + > 0.1 → high + """ + if ndci < 0.0: + return "low" + if ndci <= 0.1: + return "moderate" + return "high" + + +def lake_radius_m(lake_name: str) -> float: + """Return a search-radius (metres) appropriate for the lake's area. + + Larger lakes need a bigger buffer to capture enough pixels. + """ + acres = LAKE_ACRES.get(lake_name, 300.0) + if acres >= 2000: + return 3000.0 + if acres >= 1000: + return 2000.0 + if acres >= 500: + return 1200.0 + return 700.0 _log = logging.getLogger(__name__) diff --git a/tests/test_satellite_lst.py b/tests/test_satellite_lst.py deleted file mode 100644 index 28828ff..0000000 --- a/tests/test_satellite_lst.py +++ /dev/null @@ -1,404 +0,0 @@ -"""Tests for onkia.satellite_lst — GEE satellite lake surface temperature module.""" -from __future__ import annotations - -import sys -from datetime import date, timedelta -from unittest.mock import MagicMock, patch - -import pytest - -from onkia.satellite_lst import ( - HEATMAP_THRESHOLD_ACRES, - LAKE_ACRES, - LST_USEFUL_THRESHOLD_ACRES, - LSTHistoryPoint, - LSTObservation, - NDCIObservation, - _reset_gee_state, - celsius_to_fahrenheit, - get_latest_lst, - get_lst_heatmap_url, - get_lst_history, - get_ndci, - kelvin_to_celsius, - lake_radius_m, - landsat_dn_to_celsius, - ndci_to_category, -) - - -# --------------------------------------------------------------------------- -# Unit helpers -# --------------------------------------------------------------------------- - -class TestCelsiusToFahrenheit: - def test_freezing(self): - assert celsius_to_fahrenheit(0.0) == 32.0 - - def test_boiling(self): - assert celsius_to_fahrenheit(100.0) == 212.0 - - def test_body_temp(self): - assert abs(celsius_to_fahrenheit(37.0) - 98.6) < 0.01 - - def test_negative(self): - assert celsius_to_fahrenheit(-40.0) == -40.0 - - -class TestKelvinToCelsius: - def test_absolute_zero(self): - assert kelvin_to_celsius(0.0) == -273.15 - - def test_water_freezing(self): - assert kelvin_to_celsius(273.15) == pytest.approx(0.0, abs=0.01) - - def test_water_boiling(self): - assert kelvin_to_celsius(373.15) == pytest.approx(100.0, abs=0.01) - - -class TestLandsatDnToCelsius: - def test_scale_and_offset(self): - # A DN of 0 → Kelvin = 0 * 0.00341802 + 149.0 → Celsius = 149 - 273.15 - result = landsat_dn_to_celsius(0.0) - assert abs(result - (149.0 - 273.15)) < 0.001 - - def test_typical_summer_value(self): - # DN ≈ 37000 → Kelvin ≈ 126.5 + 149 = 275.5K → ~2°C - # A more realistic test: pick a DN that gives ~20°C - # 20°C + 273.15 = 293.15K; (293.15 - 149.0) / 0.00341802 ≈ 42194 - dn = (293.15 - 149.0) / 0.00341802 - result = landsat_dn_to_celsius(dn) - assert abs(result - 20.0) < 0.01 - - def test_output_type_float(self): - assert isinstance(landsat_dn_to_celsius(30000.0), float) - - -class TestNdciToCategory: - def test_low_negative(self): - assert ndci_to_category(-0.1) == "low" - - def test_low_zero(self): - assert ndci_to_category(0.0) == "moderate" - - def test_moderate_midpoint(self): - assert ndci_to_category(0.05) == "moderate" - - def test_moderate_upper_bound(self): - assert ndci_to_category(0.1) == "moderate" - - def test_high(self): - assert ndci_to_category(0.2) == "high" - - -class TestLakeRadiusM: - def test_clearwater_large(self): - # 3158 acres → ≥ 2000 → 3000m - assert lake_radius_m("Clearwater") == 3000.0 - - def test_buffalo_lake_medium_large(self): - # 1552 acres → ≥ 1000 → 2000m - assert lake_radius_m("Buffalo Lake") == 2000.0 - - def test_lake_sylvia_medium(self): - # 904 acres → ≥ 500 → 1200m - assert lake_radius_m("Lake Sylvia") == 1200.0 - - def test_bass_lake_small(self): - # 218 acres → < 500 → 700m - assert lake_radius_m("Bass Lake") == 700.0 - - def test_unknown_lake_default(self): - # Unknown → falls back to 300 acres → 700m - assert lake_radius_m("Unknown Lake XYZ") == 700.0 - - -class TestLakeAcresConstants: - def test_clearwater_present(self): - assert "Clearwater" in LAKE_ACRES - assert LAKE_ACRES["Clearwater"] > 3000 - - def test_threshold_ordering(self): - assert LST_USEFUL_THRESHOLD_ACRES < HEATMAP_THRESHOLD_ACRES - - def test_large_lakes_above_useful_threshold(self): - large = ["Clearwater", "Buffalo Lake", "Lake Sylvia", "Maple Lake"] - for name in large: - assert LAKE_ACRES[name] >= LST_USEFUL_THRESHOLD_ACRES, name - - def test_small_lakes_below_useful_threshold(self): - small = ["Lake Charlotte", "Lake Ida", "Bass Lake"] - for name in small: - assert LAKE_ACRES[name] < LST_USEFUL_THRESHOLD_ACRES, name - - def test_heatmap_lakes(self): - # Only large lakes should qualify for heatmap - heatmap_lakes = [n for n, a in LAKE_ACRES.items() if a >= HEATMAP_THRESHOLD_ACRES] - for name in heatmap_lakes: - assert LAKE_ACRES[name] >= 1000, name - - -# --------------------------------------------------------------------------- -# GEE unavailable / fallback paths -# --------------------------------------------------------------------------- - -class TestGetLatestLstFallback: - def setup_method(self): - _reset_gee_state() - - def test_returns_fallback_when_gee_not_installed(self): - with patch("onkia.satellite_lst._init_gee", return_value=False): - result = get_latest_lst("Clearwater", 45.3052, -94.1184) - assert isinstance(result, LSTObservation) - assert result.fallback_used is True - assert result.temp_fahrenheit is None - assert result.lake_name == "Clearwater" - - def test_returns_fallback_on_gee_exception(self): - mock_ee = MagicMock() - with patch.dict("sys.modules", {"ee": mock_ee}): - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._landsat_collection", side_effect=RuntimeError("GEE error")): - result = get_latest_lst("Clearwater", 45.3052, -94.1184) - assert result.fallback_used is True - assert "GEE error" in (result.error_msg or "") - - def test_fallback_when_no_pixels(self): - mock_collection = MagicMock() - mock_collection.size.return_value.getInfo.return_value = 1 - mock_composite = MagicMock() - mock_collection.median.return_value = mock_composite - - with patch.dict("sys.modules", {"ee": MagicMock()}): - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._landsat_collection", return_value=mock_collection): - with patch("onkia.satellite_lst._mean_lst_over_region", return_value=None): - with patch("onkia.satellite_lst._pixel_count_over_region", return_value=0): - with patch("onkia.satellite_lst._most_recent_scene_date", return_value=None): - result = get_latest_lst("Clearwater", 45.3052, -94.1184) - assert result.fallback_used is True - assert result.error_msg is not None - - -class TestGetLatestLstSuccess: - def setup_method(self): - _reset_gee_state() - - def _mock_collection(self, scene_count: int = 3, lst_c: float = 20.0, obs_date: date = date(2026, 5, 1)): - mock_col = MagicMock() - mock_col.size.return_value.getInfo.return_value = scene_count - mock_col.median.return_value = MagicMock() - return mock_col, lst_c, obs_date - - def test_returns_correct_temperatures(self): - mock_col, lst_c, obs_date = self._mock_collection(lst_c=20.0) - expected_f = celsius_to_fahrenheit(20.0) - - with patch.dict("sys.modules", {"ee": MagicMock()}): - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._landsat_collection", return_value=mock_col): - with patch("onkia.satellite_lst._mean_lst_over_region", return_value=lst_c): - with patch("onkia.satellite_lst._pixel_count_over_region", return_value=500): - with patch("onkia.satellite_lst._most_recent_scene_date", return_value=obs_date): - result = get_latest_lst("Clearwater", 45.3052, -94.1184) - - assert result.fallback_used is False - assert result.temp_celsius == 20.0 - assert abs(result.temp_fahrenheit - expected_f) < 0.1 - assert result.observation_date == obs_date - assert result.scene_count == 3 - assert result.pixel_count == 500 - - def test_celsius_to_fahrenheit_roundtrip(self): - test_c = 15.7 - mock_col = MagicMock() - mock_col.size.return_value.getInfo.return_value = 2 - mock_col.median.return_value = MagicMock() - - with patch.dict("sys.modules", {"ee": MagicMock()}): - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._landsat_collection", return_value=mock_col): - with patch("onkia.satellite_lst._mean_lst_over_region", return_value=test_c): - with patch("onkia.satellite_lst._pixel_count_over_region", return_value=100): - with patch("onkia.satellite_lst._most_recent_scene_date", return_value=date(2026, 6, 1)): - result = get_latest_lst("Maple Lake", 45.2243, -94.0062) - - assert result.temp_celsius == round(test_c, 1) - assert result.temp_fahrenheit == round(celsius_to_fahrenheit(test_c), 1) - - -# --------------------------------------------------------------------------- -# get_lst_history -# --------------------------------------------------------------------------- - -class TestGetLstHistory: - def setup_method(self): - _reset_gee_state() - - def test_returns_empty_when_gee_unavailable(self): - with patch("onkia.satellite_lst._init_gee", return_value=False): - result = get_lst_history("Clearwater", 45.3052, -94.1184) - assert result == [] - - def test_returns_empty_on_exception(self): - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._landsat_collection", side_effect=Exception("timeout")): - result = get_lst_history("Clearwater", 45.3052, -94.1184) - assert result == [] - - def test_returns_sorted_history(self): - dates_returned = [date(2026, 3, 1), date(2026, 4, 1), date(2026, 5, 1)] - temps = [5.0, 12.0, 18.0] - call_count = 0 - - def mock_collection(region, start, end): - nonlocal call_count - mock_col = MagicMock() - if call_count < len(temps): - mock_col.size.return_value.getInfo.return_value = 1 - else: - mock_col.size.return_value.getInfo.return_value = 0 - mock_col.median.return_value = MagicMock() - call_count += 1 - return mock_col - - lst_iter = iter(temps) - - def mock_mean_lst(composite, region): - try: - return next(lst_iter) - except StopIteration: - return None - - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._landsat_collection", side_effect=mock_collection): - with patch("onkia.satellite_lst._mean_lst_over_region", side_effect=mock_mean_lst): - result = get_lst_history("Clearwater", 45.3052, -94.1184, days_back=90, interval_days=30) - - assert all(isinstance(p, LSTHistoryPoint) for p in result) - dates_in_result = [p.observation_date for p in result] - assert dates_in_result == sorted(dates_in_result) - - def test_history_points_have_both_units(self): - mock_col = MagicMock() - mock_col.size.return_value.getInfo.return_value = 1 - mock_col.median.return_value = MagicMock() - - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._landsat_collection", return_value=mock_col): - with patch("onkia.satellite_lst._mean_lst_over_region", return_value=10.0): - result = get_lst_history("Clearwater", 45.3052, -94.1184, days_back=16, interval_days=16) - - if result: - point = result[0] - assert point.temp_celsius == 10.0 - assert abs(point.temp_fahrenheit - celsius_to_fahrenheit(10.0)) < 0.1 - - -# --------------------------------------------------------------------------- -# get_ndci -# --------------------------------------------------------------------------- - -class TestGetNdci: - def setup_method(self): - _reset_gee_state() - - def test_returns_fallback_when_gee_unavailable(self): - with patch("onkia.satellite_lst._init_gee", return_value=False): - result = get_ndci("Clearwater", 45.3052, -94.1184) - assert isinstance(result, NDCIObservation) - assert result.fallback_used is True - assert result.ndci_value is None - - def test_returns_fallback_on_empty_collection(self): - mock_col = MagicMock() - mock_col.size.return_value.getInfo.return_value = 0 - - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._build_lake_region", return_value=MagicMock()): - with patch("onkia.satellite_lst._most_recent_scene_date", return_value=None): - with patch.dict("sys.modules", {"ee": _make_mock_ee(mock_col)}): - result = get_ndci("Clearwater", 45.3052, -94.1184) - assert isinstance(result, NDCIObservation) - assert result.fallback_used is True - - def test_returns_fallback_on_exception(self): - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._build_lake_region", side_effect=RuntimeError("auth")): - result = get_ndci("Clearwater", 45.3052, -94.1184) - assert result.fallback_used is True - - def test_ndci_category_propagated(self): - mock_col = MagicMock() - mock_col.size.return_value.getInfo.return_value = 2 - # Set up the NDCI reduce result chain - mock_col.median.return_value.reduceRegion.return_value.get.return_value.getInfo.return_value = 0.15 - - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._build_lake_region", return_value=MagicMock()): - with patch("onkia.satellite_lst._most_recent_scene_date", return_value=date(2026, 6, 1)): - with patch.dict("sys.modules", {"ee": _make_mock_ee(mock_col)}): - result = get_ndci("Clearwater", 45.3052, -94.1184) - assert isinstance(result, NDCIObservation) - # If we got a real value back, verify category logic - if not result.fallback_used and result.ndci_value is not None: - assert result.chlorophyll_category in ("low", "moderate", "high") - - -# --------------------------------------------------------------------------- -# get_lst_heatmap_url -# --------------------------------------------------------------------------- - -class TestGetLstHeatmapUrl: - def setup_method(self): - _reset_gee_state() - - def test_returns_none_when_gee_unavailable(self): - with patch("onkia.satellite_lst._init_gee", return_value=False): - result = get_lst_heatmap_url(45.3052, -94.1184, 3000.0) - assert result is None - - def test_returns_none_on_exception(self): - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._build_lake_region", side_effect=RuntimeError("err")): - result = get_lst_heatmap_url(45.3052, -94.1184, 3000.0) - assert result is None - - def test_returns_none_on_empty_collection(self): - mock_col = MagicMock() - mock_col.size.return_value.getInfo.return_value = 0 - - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._build_lake_region", return_value=MagicMock()): - with patch("onkia.satellite_lst._landsat_collection", return_value=mock_col): - result = get_lst_heatmap_url(45.3052, -94.1184, 3000.0) - assert result is None - - def test_returns_url_string_on_success(self): - expected_url = "https://earthengine.googleapis.com/thumb/abc123" - mock_col = MagicMock() - mock_col.size.return_value.getInfo.return_value = 2 - mock_composite = MagicMock() - mock_composite.getThumbURL.return_value = expected_url - mock_col.median.return_value = mock_composite - - with patch.dict("sys.modules", {"ee": MagicMock()}): - with patch("onkia.satellite_lst._init_gee", return_value=True): - with patch("onkia.satellite_lst._build_lake_region", return_value=MagicMock()): - with patch("onkia.satellite_lst._landsat_collection", return_value=mock_col): - result = get_lst_heatmap_url(45.3052, -94.1184, 3000.0) - assert result == expected_url - - -# --------------------------------------------------------------------------- -# Helpers for mocking ee module -# --------------------------------------------------------------------------- - -def _make_mock_ee(mock_collection: MagicMock) -> MagicMock: - """Build a minimal mock of the `ee` module for testing get_ndci.""" - mock_ee = MagicMock() - mock_ee.ImageCollection.return_value.filterBounds.return_value.filterDate.return_value.\ - filter.return_value.map.return_value = mock_collection - mock_ee.Filter.lt.return_value = MagicMock() - mock_ee.Reducer.mean.return_value = MagicMock() - return mock_ee diff --git a/tests/test_satellite_lst_stac.py b/tests/test_satellite_lst_stac.py index a9bdd22..4a7be75 100644 --- a/tests/test_satellite_lst_stac.py +++ b/tests/test_satellite_lst_stac.py @@ -7,21 +7,19 @@ import numpy as np import pytest -from onkia.satellite_lst import ( +from onkia.satellite_lst_stac import ( LSTHistoryPoint, LSTObservation, NDCIObservation, - celsius_to_fahrenheit, - ndci_to_category, -) -from onkia.satellite_lst_stac import ( _cloud_mask_landsat, _point_to_bbox, _s2_band_keys, + celsius_to_fahrenheit, get_latest_lst, get_lst_heatmap, get_lst_history, get_ndci, + ndci_to_category, )