Skip to content

Commit f651726

Browse files
author
RM
committed
GCloud optimizations
1 parent 6f42d3a commit f651726

2 files changed

Lines changed: 197 additions & 91 deletions

File tree

chipnik_monitor/.gcloudignore

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
# Ignore Python virtual env
2-
venv/
3-
4-
# Ignore cache
5-
__pycache__/
6-
*.pyc
7-
8-
# Ignore IDE configs
9-
.vscode/
10-
.idea/
1+
# Ignore Python virtual env
2+
venv/
3+
4+
# Ignore cache
5+
__pycache__/
6+
*.pyc
7+
8+
# Ignore IDE configs
9+
.vscode/
10+
.idea/
11+
12+
on-premise/
13+
media/

chipnik_monitor/chipnik_monitor.py

Lines changed: 184 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from enum import Enum
1+
from enum import Enum
22
import streamlit as st
33
import pandas as pd
44
import numpy as np
@@ -17,6 +17,7 @@
1717
from shapely.geometry.base import BaseGeometry
1818
from shapely.ops import unary_union
1919
from pathlib import Path
20+
from collections import OrderedDict
2021
from functools import partial
2122
import requests
2223
import hashlib
@@ -38,11 +39,10 @@
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

4344
def 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

4747
DEFAULT_CENTER_LON = -122.09261814845487
4848
DEFAULT_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)
133142
INDEX_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

199208
STAC_PAGE_SIZE = 200
200209
STAC_MAX_ITEMS = 2000
210+
_STAC_MEMORY_CACHE_MAX_ENTRIES = 32
211+
_STAC_RESULTS_CACHE: "OrderedDict[Tuple[Any, ...], Tuple[Dict[str, Any], ...]]" = OrderedDict()
212+
201213

202214
STAC_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

724815
def 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

Comments
 (0)