1- from enum import Enum
1+ from enum import Enum
22import streamlit as st
33import pandas as pd
44import numpy as np
1717from shapely .geometry .base import BaseGeometry
1818from shapely .ops import unary_union
1919from pathlib import Path
20+ from collections import OrderedDict
2021from functools import partial
2122import requests
2223import hashlib
3839 logger .addHandler (handler )
3940
4041_CLOUD_RUN_SERVICE = os .getenv ('K_SERVICE' ) or os .getenv ('GOOGLE_CLOUD_PROJECT' )
41- logger .setLevel (logging .ERROR if _CLOUD_RUN_SERVICE else logging .DEBUG )
42+ logger .setLevel (logging .INFO if _CLOUD_RUN_SERVICE else logging .DEBUG )
4243
4344def log_progress (message : str ) -> None :
44- level = logging .ERROR if _CLOUD_RUN_SERVICE else logging .INFO
45- logger .log (level , message )
45+ logger .log (logging .INFO , message )
4646
4747DEFAULT_CENTER_LON = - 122.09261814845487
4848DEFAULT_CENTER_LAT = 47.60464601773639
@@ -129,7 +129,16 @@ def list_geojson_files(directory: Path) -> List[Path]:
129129 unique [file_path .name ] = file_path
130130 return sorted (unique .values (), key = lambda f : f .name .lower ())
131131
132- CACHE_ROOT = Path (".cache" )
132+ def _determine_cache_root () -> Path :
133+ override = os .getenv ("CACHE_ROOT" )
134+ if override :
135+ return Path (override )
136+ if _CLOUD_RUN_SERVICE :
137+ return Path ("/tmp/chipnik_cache" )
138+ return Path (".cache" )
139+
140+ CACHE_ROOT = _determine_cache_root ()
141+ CACHE_ROOT .mkdir (parents = True , exist_ok = True )
133142INDEX_CACHE_VERSION = "1"
134143
135144# HLS products have 30 m ground sampling distance; used to convert pixels to area.
@@ -198,6 +207,9 @@ def get_index_cache_path(index_type: IndexType, red_url: str, blue_url: str, nir
198207
199208STAC_PAGE_SIZE = 200
200209STAC_MAX_ITEMS = 2000
210+ _STAC_MEMORY_CACHE_MAX_ENTRIES = 32
211+ _STAC_RESULTS_CACHE : "OrderedDict[Tuple[Any, ...], Tuple[Dict[str, Any], ...]]" = OrderedDict ()
212+
201213
202214STAC_CACHE_TTL_SECONDS = 3600 * 24 * 7
203215
@@ -625,101 +637,180 @@ def build_yearly_crop_rankings(ndvi_df: pd.DataFrame, crop_df: pd.DataFrame) ->
625637 return summary
626638
627639
628- @st .cache_data (ttl = 3600 * 24 * 7 )
629- def search_hls_data (bbox : List [float ], start : str , end : str , max_cc : int ,
630- dataset_type : str , _token : str ) -> pd .DataFrame :
631- """Search HLS scenes via the STAC API"""
632640
633- logger .info ("search_hls_data: bbox=%s start=%s end=%s max_cc=%s dataset=%s token_provided=%s" , bbox , start , end , max_cc , dataset_type , bool (_token ))
634- bbox = normalize_bbox (bbox )
635641
636- cache_path = get_stac_cache_path (bbox , start , end , max_cc , dataset_type , _token )
637- cached_df = load_stac_cache (cache_path )
638- if cached_df is not None :
639- logger .debug ("search_hls_data: returning %s cached items from %s" , len (cached_df ), cache_path .name )
640- return cached_df
642+ def _stac_token_fingerprint (token : str ) -> str :
643+ if not token :
644+ return "anon"
645+ return hashlib .sha256 (token .encode ("utf-8" )).hexdigest ()
641646
642- try :
643- # Connect to LP DAAC STAC
644- catalog = Client .open (
645- "https://cmr.earthdata.nasa.gov/stac/LPCLOUD" ,
646- headers = {"Authorization" : f"Bearer { _token } " } if _token else {}
647+
648+ def _make_stac_memory_cache_key (
649+ bbox : Tuple [float , float , float , float ],
650+ start : str ,
651+ end : str ,
652+ max_cc : int ,
653+ dataset_type : str ,
654+ token : str ,
655+ ) -> Tuple [Any , ...]:
656+ rounded_bbox = tuple (round (float (coord ), 6 ) for coord in bbox )
657+ return (rounded_bbox , start , end , int (max_cc ), dataset_type , _stac_token_fingerprint (token ))
658+
659+
660+ def _get_stac_records_from_memory (cache_key : Tuple [Any , ...]) -> Optional [List [Dict [str , Any ]]]:
661+ cached = _STAC_RESULTS_CACHE .get (cache_key )
662+ if cached is None :
663+ return None
664+ _STAC_RESULTS_CACHE .move_to_end (cache_key )
665+ return [dict (record ) for record in cached ]
666+
667+
668+ def _set_stac_records_in_memory (cache_key : Tuple [Any , ...], records : List [Dict [str , Any ]]) -> None :
669+ _STAC_RESULTS_CACHE [cache_key ] = tuple (dict (record ) for record in records )
670+ _STAC_RESULTS_CACHE .move_to_end (cache_key )
671+ if len (_STAC_RESULTS_CACHE ) > _STAC_MEMORY_CACHE_MAX_ENTRIES :
672+ _STAC_RESULTS_CACHE .popitem (last = False )
673+
674+
675+ def _fetch_stac_records (
676+ bbox : Tuple [float , float , float , float ],
677+ start : str ,
678+ end : str ,
679+ max_cc : int ,
680+ dataset_type : str ,
681+ token : str ,
682+ ) -> List [Dict [str , Any ]]:
683+ cache_key = _make_stac_memory_cache_key (bbox , start , end , max_cc , dataset_type , token )
684+ cached_records = _get_stac_records_from_memory (cache_key )
685+ if cached_records is not None :
686+ logger .debug (
687+ "STAC in-memory cache hit: bbox=%s start=%s end=%s dataset=%s" ,
688+ bbox ,
689+ start ,
690+ end ,
691+ dataset_type ,
647692 )
693+ return cached_records
648694
649- # Determine which collections to query
650- if dataset_type == "HLSS30.v2.0" :
651- collections = [ "HLSS30.v2.0" ]
652- elif dataset_type == "HLSL30.v2.0" :
653- collections = [ "HLSL30.v2.0" ]
654- else :
655- collections = [ "HLSS30.v2.0" , "HLSL30.v2.0" ]
695+ logger . debug (
696+ "STAC in-memory cache miss: bbox=%s start=%s end=%s dataset=%s" ,
697+ bbox ,
698+ start ,
699+ end ,
700+ dataset_type ,
701+ )
656702
657- logger .debug ("Collections to query: %s" , collections )
658- all_items = []
659- for collection in collections :
660- search = catalog .search (
661- collections = [collection ],
662- bbox = bbox ,
663- datetime = f"{ start } /{ end } " ,
664- max_items = STAC_MAX_ITEMS ,
665- limit = STAC_PAGE_SIZE
666- )
667- collected = []
668- for item in search .items ():
669- collected .append (item )
670- if len (collected ) >= STAC_MAX_ITEMS :
671- logger .warning ("Reached STAC max items (%s) for %s; results truncated" , STAC_MAX_ITEMS , collection )
672- break
673- logger .debug ("Collection %s returned %s items before filtering" , collection , len (collected ))
674- all_items .extend (collected )
675-
676- # Convert results into a DataFrame
677- records = []
678- for item in all_items :
679- props = item .properties
680- logger .debug ("Evaluating item %s (cloud %.2f)" , item .id , props .get ('eo:cloud_cover' , float ('nan' )))
703+ catalog = Client .open (
704+ "https://cmr.earthdata.nasa.gov/stac/LPCLOUD" ,
705+ headers = {"Authorization" : f"Bearer { token } " } if token else {},
706+ )
707+
708+ if dataset_type == "HLSS30.v2.0" :
709+ collections = ["HLSS30.v2.0" ]
710+ elif dataset_type == "HLSL30.v2.0" :
711+ collections = ["HLSL30.v2.0" ]
712+ else :
713+ collections = ["HLSS30.v2.0" , "HLSL30.v2.0" ]
714+
715+ logger .debug ("Collections to query: %s" , collections )
681716
682- # Cloud cover filter
683- cloud_cover = props .get ('eo:cloud_cover' , 100 )
717+ records : List [Dict [str , Any ]] = []
718+ normalised_bbox = list (bbox )
719+
720+ for collection in collections :
721+ search = catalog .search (
722+ collections = [collection ],
723+ bbox = normalised_bbox ,
724+ datetime = f"{ start } /{ end } " ,
725+ max_items = STAC_MAX_ITEMS ,
726+ limit = STAC_PAGE_SIZE ,
727+ )
728+ collected : List [Any ] = []
729+ for item in search .items ():
730+ collected .append (item )
731+ if len (collected ) >= STAC_MAX_ITEMS :
732+ logger .warning (
733+ "Reached STAC max items (%s) for %s; results truncated" ,
734+ STAC_MAX_ITEMS ,
735+ collection ,
736+ )
737+ break
738+ logger .debug ("Collection %s returned %s items before filtering" , collection , len (collected ))
739+
740+ for item in collected :
741+ props = item .properties
742+ cloud_cover = props .get ("eo:cloud_cover" , 100 )
684743 if cloud_cover > max_cc :
685- logger .debug ("Skipping item %s: cloud cover %.2f exceeds threshold %s" , item .id , cloud_cover , max_cc )
744+ logger .debug (
745+ "Skipping item %s: cloud cover %.2f exceeds threshold %s" ,
746+ item .id ,
747+ cloud_cover ,
748+ max_cc ,
749+ )
686750 continue
687751
688- nir_asset = item .assets .get (' B8A' ) or item .assets .get (' B05' )
689- red_asset = item .assets .get (' B04' )
690- blue_asset = item .assets .get (' B02' )
752+ nir_asset = item .assets .get (" B8A" ) or item .assets .get (" B05" )
753+ red_asset = item .assets .get (" B04" )
754+ blue_asset = item .assets .get (" B02" )
691755
692- nir_url = getattr (nir_asset , ' href' , None )
693- red_url = getattr (red_asset , ' href' , None )
694- blue_url = getattr (blue_asset , ' href' , None )
756+ nir_url = getattr (nir_asset , " href" , None )
757+ red_url = getattr (red_asset , " href" , None )
758+ blue_url = getattr (blue_asset , " href" , None )
695759 if not nir_url or not red_url or not blue_url :
696- logger .debug ("Skipping item %s: missing required bands (red=%s, nir=%s, blue=%s)" , item .id , bool (red_url ), bool (nir_url ), bool (blue_url ))
760+ logger .debug (
761+ "Skipping item %s: missing required bands (red=%s, nir=%s, blue=%s)" ,
762+ item .id ,
763+ bool (red_url ),
764+ bool (nir_url ),
765+ bool (blue_url ),
766+ )
697767 continue
698768
699- records .append ({
700- "id" : item .id ,
701- "datetime" : pd .to_datetime (props ['datetime' ]),
702- "cloud_cover" : float (cloud_cover ),
703- "collection" : item .collection_id ,
704- "nir_url" : nir_url ,
705- "red_url" : red_url ,
706- "blue_url" : blue_url
707- })
769+ records .append (
770+ {
771+ "id" : item .id ,
772+ "datetime" : pd .to_datetime (props ["datetime" ]),
773+ "cloud_cover" : float (cloud_cover ),
774+ "collection" : item .collection_id ,
775+ "nir_url" : nir_url ,
776+ "red_url" : red_url ,
777+ "blue_url" : blue_url ,
778+ }
779+ )
708780 logger .debug ("Kept item %s (collection=%s)" , item .id , item .collection_id )
709781
710- df = pd .DataFrame (records )
711- logger .info ("search_hls_data: %s items after filtering" , len (df ))
712- if not df .empty :
713- df = df .sort_values ("datetime" )
714- store_stac_cache (cache_path , records )
715- logger .debug ("search_hls_data: cached %s items at %s" , len (df ), cache_path .name )
716- return df
782+ _set_stac_records_in_memory (cache_key , records )
783+ return records
784+
785+ @st .cache_data (ttl = 3600 * 24 * 7 )
786+ def search_hls_data (bbox : List [float ], start : str , end : str , max_cc : int ,
787+ dataset_type : str , _token : str ) -> pd .DataFrame :
788+ """Search HLS scenes via the STAC API"""
789+
790+ logger .info ("search_hls_data: bbox=%s start=%s end=%s max_cc=%s dataset=%s token_provided=%s" , bbox , start , end , max_cc , dataset_type , bool (_token ))
791+ bbox = normalize_bbox (bbox )
792+
793+ cache_path = get_stac_cache_path (bbox , start , end , max_cc , dataset_type , _token )
794+ cached_df = load_stac_cache (cache_path )
795+ if cached_df is not None :
796+ logger .debug ("search_hls_data: returning %s cached items from %s" , len (cached_df ), cache_path .name )
797+ return cached_df
717798
799+ try :
800+ records = _fetch_stac_records (tuple (bbox ), start , end , max_cc , dataset_type , _token )
718801 except Exception as e :
719802 logger .exception ("search_hls_data failed" )
720803 st .error (f"Search error: { e } " )
721804 return pd .DataFrame ()
722805
806+ df = pd .DataFrame (records )
807+ logger .info ("search_hls_data: %s items after filtering" , len (df ))
808+ if not df .empty :
809+ df = df .sort_values ("datetime" )
810+ store_stac_cache (cache_path , records )
811+ logger .debug ("search_hls_data: cached %s items at %s" , len (df ), cache_path .name )
812+ return df
813+
723814
724815def summarise_ndvi_stats (ndvi : Optional [np .ma .MaskedArray ], mean_ndvi : float ) -> Dict [str , float ]:
725816 """Derive crop-cover metrics from an NDVI raster."""
@@ -1129,7 +1220,19 @@ def fetch_weather_history(lat: float, lon: float, start_date: datetime, end_date
11291220
11301221 if row_tuples :
11311222 worker = partial (compute_index_for_row , index_types = index_types , bbox = bbox , token = earthdata_token )
1132- max_workers = min (8 , len (row_tuples ))
1223+ default_worker_cap = 1 if _CLOUD_RUN_SERVICE else 8
1224+ worker_cap_raw = os .getenv ("WORKER_CAP" , "" ).strip ()
1225+ if worker_cap_raw :
1226+ try :
1227+ worker_cap = max (1 , int (worker_cap_raw ))
1228+ except ValueError :
1229+ logger .warning ("Invalid WORKER_CAP=%s; falling back to default" , worker_cap_raw )
1230+ worker_cap = default_worker_cap
1231+ else :
1232+ worker_cap = default_worker_cap
1233+ max_workers = min (worker_cap , len (row_tuples ))
1234+ max_workers = max (1 , max_workers )
1235+ logger .debug ("NDVI worker pool size=%s" , max_workers )
11331236 try :
11341237 with concurrent .futures .ThreadPoolExecutor (max_workers = max_workers ) as executor :
11351238 future_to_index = {executor .submit (worker , row ): idx for idx , row in enumerate (row_tuples )}
@@ -1152,8 +1255,8 @@ def fetch_weather_history(lat: float, lon: float, start_date: datetime, end_date
11521255 message += f" ({ scene_id } )"
11531256 message += f": { date_label } "
11541257 status_text .text (message )
1155- log_progress (message )
11561258 progress_bar .progress (completed / len (row_tuples ))
1259+ log_progress (message )
11571260 except Exception :
11581261 logger .exception ("Parallel NDVI processing failed; falling back to sequential execution" )
11591262 results = []
@@ -1171,11 +1274,11 @@ def fetch_weather_history(lat: float, lon: float, start_date: datetime, end_date
11711274 message += f" ({ scene_id } )"
11721275 message += f": { date_label } "
11731276 status_text .text (message )
1174- log_progress (message )
11751277 result = worker (row )
11761278 if result :
11771279 results .append (result )
11781280 progress_bar .progress ((idx + 1 ) / len (row_tuples ))
1281+ log_progress (message )
11791282
11801283 status_text .empty ()
11811284 progress_bar .empty ()
0 commit comments