Spaces:
Running
Running
| from __future__ import annotations | |
| import calendar,gzip,io,os,random,re,sys,tarfile,tempfile,threading,time,requests,json,rasterio,rioxarray,math,uuid,hashlib, shutil, logging | |
| from collections import deque,OrderedDict,defaultdict | |
| from datetime import datetime,timedelta,timezone | |
| from functools import lru_cache | |
| from pathlib import Path | |
| from typing import Optional,Callable,Any | |
| import numpy as np | |
| import xarray as xr | |
| from fastapi import FastAPI,HTTPException,Query,Response,Body | |
| from fastapi.staticfiles import StaticFiles | |
| from rasterio.crs import CRS | |
| from rasterio.enums import Resampling | |
| from rasterio.transform import Affine,from_bounds | |
| from rasterio.warp import reproject | |
| from rio_cogeo.cogeo import cog_translate | |
| from rio_cogeo.profiles import cog_profiles | |
| from rio_tiler.errors import TileOutsideBounds | |
| from rio_tiler.io import COGReader | |
| from rio_tiler.utils import render | |
| from starlette.responses import PlainTextResponse,Response | |
| from huggingface_hub import HfApi,snapshot_download,hf_hub_download | |
| from huggingface_hub._commit_api import CommitOperationAdd,CommitOperationDelete | |
| from zoneinfo import ZoneInfo | |
| import sqlite3 | |
| from rio_tiler.colormap import cmap | |
| from fastapi.responses import Response as FastResponse | |
| from pydantic import BaseModel | |
| from starlette.middleware.gzip import GZipMiddleware | |
| import huggingface_hub.utils._http as _hf_http | |
| from requests.adapters import HTTPAdapter | |
| import concurrent.futures | |
| import requests.adapters as _req_adapters | |
| os.environ["TZ"] = "America/Chicago" | |
| time.tzset() | |
| logging.Formatter.converter = time.localtime | |
| os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "600" | |
| os.environ["HF_HUB_ETAG_TIMEOUT"] = "600" | |
| _orig_adapter_send = _req_adapters.HTTPAdapter.send | |
| def _timeout_600_send(self, request, *args, **kwargs): | |
| if "timeout" not in kwargs or kwargs["timeout"] is None or (isinstance(kwargs.get("timeout"), (int, float)) and kwargs["timeout"] <= 30): | |
| kwargs["timeout"] = 600 | |
| return _orig_adapter_send(self, request, *args, **kwargs) | |
| _req_adapters.HTTPAdapter.send = _timeout_600_send | |
| LOCAL_TZ=ZoneInfo(os.environ.get("LOCAL_TZ","America/Chicago")) | |
| LOWRES_Z=int(os.environ.get("LOWRES_Z","6")) | |
| LOWRES_MAX_REQUEST_Z=int(os.environ.get("LOWRES_MAX_REQUEST_Z","14")) | |
| LOWRES_BOX_ZMIN=int(os.environ.get("LOWRES_BOX_ZMIN","4")) | |
| LOWRES_BOX_ZMAX=int(os.environ.get("LOWRES_BOX_ZMAX","6")) | |
| HF_MAX_OPS_PER_COMMIT=int(os.environ.get("HF_MAX_OPS_PER_COMMIT","300")) | |
| HF_PUSH_SLEEP_SEC=float(os.environ.get("HF_PUSH_SLEEP_SEC","0.5")) | |
| ROLL_WINDOW_DAYS=int(os.environ.get("ROLL_WINDOW_DAYS","6")) | |
| SCHEDULE_HOUR_LOCAL=int(os.environ.get("SCHEDULE_HOUR_LOCAL","10")) #hour | |
| SCHEDULE_MIN_LOCAL=int(os.environ.get("SCHEDULE_MIN_LOCAL","00")) #minute | |
| WARM_ZMIN=int(os.environ.get("WARM_ZMIN","6")) | |
| WARM_ZMAX=int(os.environ.get("WARM_ZMAX","11")) | |
| WARM_RADIUS=int(os.environ.get("WARM_RADIUS","1")) | |
| WARM_CAP_PER_ZOOM=int(os.environ.get("WARM_CAP_PER_ZOOM","7000")) | |
| WARM_MAX_TILES_TOTAL=int(os.environ.get("WARM_MAX_TILES_TOTAL","50000")) | |
| WARM_DOM=os.environ.get("WARM_DOM","zz").lower() | |
| WARM_MILES=float(os.environ.get("WARM_MILES","8")) | |
| BOOT_ROLL_DEFAULT=os.environ.get("RUN_BOOT_ROLL","0") #set to 1 to run the download sequence on boot, 0 to bypass | |
| WARM_HOURS_LIST=[24,72] | |
| CN_MBTILES_LOCAL=Path("cache")/"tracks"/"cn_tracks.mbtiles" | |
| CN_MBTILES_REPO_PATH="tracks/cn_tracks.mbtiles" | |
| CN_BOUNDS_REPO_PATH="tracks/cn_tracks.bounds.txt" | |
| _VIEWGRID_LOCK=threading.Lock() | |
| _VIEWGRID_CACHE:dict[tuple,tuple[float,Any]]={} | |
| _VIEWGRID_TTL_S=45.0 | |
| _MELTCOGS_LOCK=threading.Lock() | |
| _MELTCOGS_CACHE:dict[tuple,tuple[float,Any]]={} | |
| _MELTCOGS_TTL_S=300.0 | |
| KEEP_TILE_DAYS=int(os.environ.get("KEEP_TILE_DAYS","6")) | |
| KEEP_FORECAST_COG_DAYS=int(os.environ.get("KEEP_FORECAST_COG_DAYS","1")) | |
| _COGREADER_CACHE_MAX=int(os.environ.get("COGREADER_CACHE_MAX","12")) | |
| _COGREADER_CACHE:"OrderedDict[str,COGReader]"=OrderedDict() | |
| _COGREADER_CACHE_LK=threading.Lock() | |
| ROLL_SINGLETON_KEY="roll_job" | |
| _ACTIVE_CLIENT: dict[str, dict[str, Any]] = {} | |
| _ACTIVE_CLIENT_LK = threading.Lock() | |
| _ACTIVE_CLIENT_TTL_S = 180.0 | |
| CACHE_CONTROL_BY_DATE=os.environ.get("CACHE_CONTROL_BY_DATE","public, max-age=31536000, immutable") | |
| CACHE_CONTROL_LATEST=os.environ.get("CACHE_CONTROL_LATEST","public, max-age=300, stale-while-revalidate=600") | |
| PNG_MAGIC=b"\x89PNG\r\n\x1a\n" | |
| ZIP_MAGIC=b"PK\x03\x04" | |
| RAW_TILE_EXT=".npz" | |
| _COG_LRU_MAX=int(os.environ.get("COGREADER_LRU_MAX","36")) | |
| _COG_LRU:"OrderedDict[str,COGReader]"=OrderedDict() | |
| _COG_LRU_LK=threading.Lock() | |
| _RATE_LIMIT_WINDOW=10.0 | |
| _RATE_LIMIT_MAX_REQUESTS=50 | |
| _PUSH_RESUME_TIMER = None | |
| _PUSH_TIMER_LK = threading.Lock() | |
| def _pause_push_for_user(): | |
| """Pause background HF push for 30s to prioritize user requests.""" | |
| global _PUSH_RESUME_TIMER | |
| _PUSH_PAUSED.set() | |
| with _PUSH_TIMER_LK: | |
| if _PUSH_RESUME_TIMER: | |
| _PUSH_RESUME_TIMER.cancel() | |
| _PUSH_RESUME_TIMER = threading.Timer(30.0, lambda: _PUSH_PAUSED.clear()) | |
| _PUSH_RESUME_TIMER.daemon = True | |
| _PUSH_RESUME_TIMER.start() | |
| def _pick_cache_root()->Path: | |
| env=(os.environ.get("SNODAS_CACHE")or"").strip() | |
| candidates=([env]if env else[])+["/data/snodas-cache","/home/user/snodas-cache","/tmp/snodas-cache","./cache"] | |
| for c in candidates: | |
| p=Path(c).expanduser() | |
| try: | |
| p.mkdir(parents=True,exist_ok=True) | |
| t=p/".wtest";t.write_text("ok");t.unlink(missing_ok=True) | |
| return p.resolve() | |
| except:pass | |
| return Path(tempfile.mkdtemp(prefix="snodas-cache-")).resolve() | |
| CACHE=_pick_cache_root() | |
| _HF_NEGATIVE_CACHE: dict[str, float] = {} | |
| _HF_NEGATIVE_CACHE_LK = threading.Lock() | |
| _HF_NEGATIVE_TTL = 300.0 # 5 minutes | |
| _HF_NEGATIVE_MAX = 10000 # cap size | |
| def _hf_negative_check(rel: str) -> bool: | |
| with _HF_NEGATIVE_CACHE_LK: | |
| ts = _HF_NEGATIVE_CACHE.get(rel) | |
| if ts is None: | |
| return False | |
| if (time.time() - ts) > _HF_NEGATIVE_TTL: | |
| del _HF_NEGATIVE_CACHE[rel] | |
| return False | |
| return True | |
| def _hf_negative_mark(rel: str) -> None: | |
| with _HF_NEGATIVE_CACHE_LK: | |
| _HF_NEGATIVE_CACHE[rel] = time.time() | |
| # Prevent unbounded growth | |
| if len(_HF_NEGATIVE_CACHE) > _HF_NEGATIVE_MAX: | |
| # Remove oldest 20% | |
| sorted_keys = sorted(_HF_NEGATIVE_CACHE, key=_HF_NEGATIVE_CACHE.get) | |
| for k in sorted_keys[:_HF_NEGATIVE_MAX // 5]: | |
| del _HF_NEGATIVE_CACHE[k] | |
| _NOAA_FILE_SIGS: dict[str, dict] = {} | |
| _NOAA_FILE_SIGS_LK = threading.Lock() | |
| _NOAA_SIGS_PATH = CACHE / "_noaa_file_sigs.json" | |
| def _load_noaa_sigs() -> None: | |
| global _NOAA_FILE_SIGS | |
| try: | |
| if _NOAA_SIGS_PATH.exists(): | |
| with _NOAA_FILE_SIGS_LK: | |
| _NOAA_FILE_SIGS = json.loads(_NOAA_SIGS_PATH.read_text("utf-8")) | |
| except: | |
| pass | |
| def _save_noaa_sigs() -> None: | |
| try: | |
| with _NOAA_FILE_SIGS_LK: | |
| _NOAA_SIGS_PATH.write_text(json.dumps(_NOAA_FILE_SIGS, sort_keys=True), "utf-8") | |
| except: | |
| pass | |
| def _get_noaa_file_sig(url: str) -> dict | None: | |
| """HEAD request to get Last-Modified and Content-Length from NOAA.""" | |
| try: | |
| r = requests.head(url, timeout=15, allow_redirects=True, headers={"User-Agent": "snodas/1.3"}) | |
| if r.status_code != 200: | |
| return None | |
| return { | |
| "last_modified": r.headers.get("Last-Modified", ""), | |
| "content_length": r.headers.get("Content-Length", ""), | |
| "etag": r.headers.get("ETag", ""), | |
| } | |
| except: | |
| return None | |
| def _record_baked_sig(valid:str,dom:str,url:str,source_type:str|None=None)->None: | |
| """Record NOAA file sig. Key is now date|dom (not valid|dom) for consistency.""" | |
| sig=_get_noaa_file_sig(url) | |
| if not sig:return | |
| date_ymd=valid[:8]if len(valid)>=8 else valid | |
| key=f"{date_ymd}|{dom}" | |
| sig["url"]=url | |
| sig["baked_at"]=time.time() | |
| sig["valid"]=valid | |
| if source_type is None: | |
| if"ttnats"in url.lower(): | |
| source_type="ttna_11z"if url.lower().endswith("11dp000.nc")or"11dp000"in url.lower()else"ttna_05z" | |
| else: | |
| source_type="forecast" | |
| sig["source_type"]=source_type | |
| with _NOAA_FILE_SIGS_LK: | |
| _NOAA_FILE_SIGS[key]=sig | |
| _save_noaa_sigs() | |
| _log("INFO",f"[staleness] recorded sig for {key}: type={source_type} LM={sig['last_modified']} CL={sig['content_length']}") | |
| def _record_baked_sig_v2(date_ymd:str,dom:str,url:str,source_type:str)->None: | |
| _record_baked_sig(date_ymd,dom,url,source_type=source_type) | |
| # 1. NEW: Find 11Z TTNA melt files | |
| def _find_melt_11044_ttna_11z(valid_date_ymd:str,dom_prefer:str="zz")->list[str]: | |
| """Find TTNA 11Z melt files for a given date (YYYYMMDD).""" | |
| dom_prefer=(dom_prefer or"zz").lower() | |
| html=_collab_dirlist();names=_hrefs(html);want_ts=f"ts{valid_date_ymd}11";out=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if"ssmv11044"not in low:continue | |
| if"__t0024ttnats"not in low:continue | |
| if want_ts not in low:continue | |
| if not low.endswith(".nc"):continue | |
| out.append(_as_url(base)) | |
| return sorted(set(out),key=lambda u:(0 if _dom_from_url(u)==dom_prefer else _dom_pri(_dom_from_url(u))+10)) | |
| # 2. NEW: Find 05Z TTNA melt files | |
| def _find_melt_11044_ttna_05z(valid_date_ymd:str,dom_prefer:str="zz")->list[str]: | |
| """Find TTNA 05Z melt files for a given date (YYYYMMDD).""" | |
| dom_prefer=(dom_prefer or"zz").lower() | |
| html=_collab_dirlist();names=_hrefs(html);want_ts=f"ts{valid_date_ymd}05";out=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if"ssmv11044"not in low:continue | |
| if"__t0024ttnats"not in low:continue | |
| if want_ts not in low:continue | |
| if not low.endswith(".nc"):continue | |
| out.append(_as_url(base)) | |
| return sorted(set(out),key=lambda u:(0 if _dom_from_url(u)==dom_prefer else _dom_pri(_dom_from_url(u))+10)) | |
| def _pick_best_run_init_for_valid_11z(valid:str,dom_prefer:str="zz")->str|None: | |
| """Find the latest TT run init that has a TS...11 file.""" | |
| valid=_norm_ts(valid);dom_prefer=(dom_prefer or"zz").lower() | |
| html=_collab_dirlist();names=_hrefs(html);want_ts=f"ts{valid}";rows=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if"ssmv11044"not in low or"__t0024tt"not in low or want_ts not in low or not low.endswith(".nc"):continue | |
| if"ttnats"in low:continue # Skip TTNA files, only want forecast TT...06 | |
| tt=_tt_from_name(base) | |
| if not tt:continue | |
| rows.append((tt,_score_dom(_as_url(base),dom_prefer))) | |
| if not rows:return None | |
| rows.sort(key=lambda t:(t[0],-t[1]));return rows[-1][0] | |
| def _find_melt_11044_t0024_11z(run_init:str,valid:str,dom_prefer:str="zz")->list[str]: | |
| """Find forecast melt files matching a specific TT run init and 11Z valid time.""" | |
| run_init=_norm_ts(run_init);valid=_norm_ts(valid);dom_prefer=(dom_prefer or"zz").lower() | |
| html=_collab_dirlist();names=_hrefs(html);want_tt=f"tt{run_init}";want_ts=f"ts{valid}";out=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if"ssmv11044"not in low or"__t0024tt"not in low or want_tt not in low or want_ts not in low or not low.endswith(".nc"):continue | |
| if"ttnats"in low:continue # Skip TTNA | |
| out.append(_as_url(base)) | |
| return sorted(set(out),key=lambda u:(0 if _dom_from_url(u)==dom_prefer else _dom_pri(_dom_from_url(u))+10)) | |
| def _find_best_melt_for_date(date_ymd:str,dom_prefer:str="zz")->dict: | |
| """Returns {ok, melt_urls, valid, run_init, source_type} preferring 11Z > 05Z.""" | |
| dom_prefer=(dom_prefer or"zz").lower() | |
| # 1. TTNA 11Z | |
| urls_11z=_find_melt_11044_ttna_11z(date_ymd,dom_prefer=dom_prefer) | |
| if urls_11z: | |
| return{"ok":True,"melt_urls":urls_11z,"valid":f"{date_ymd}11","run_init":"TTNA","source_type":"ttna_11z"} | |
| # 2. Forecast 11Z (TT...06TS...11) | |
| valid_11=f"{date_ymd}11" | |
| run_init_11=_pick_best_run_init_for_valid_11z(valid_11,dom_prefer=dom_prefer) | |
| if run_init_11: | |
| melt_urls_11=_find_melt_11044_t0024_11z(run_init_11,valid_11,dom_prefer=dom_prefer) | |
| if melt_urls_11: | |
| return{"ok":True,"melt_urls":melt_urls_11,"valid":valid_11,"run_init":run_init_11,"source_type":"forecast_11z"} | |
| # 3. TTNA 05Z | |
| urls_05z=_find_melt_11044_ttna_05z(date_ymd,dom_prefer=dom_prefer) | |
| if urls_05z: | |
| return{"ok":True,"melt_urls":urls_05z,"valid":f"{date_ymd}05","run_init":"TTNA","source_type":"ttna_05z"} | |
| # 4. Forecast 05Z (TT...06TS...05) | |
| valid_05=_norm_ts(f"{date_ymd}05") | |
| run_init=_pick_best_run_init_for_valid_t0024(valid_05,dom_prefer=dom_prefer) | |
| if not run_init: | |
| return{"ok":False,"error":"no melt files found (11z, 05z, or forecast)","date":date_ymd} | |
| melt_urls=_find_melt_11044_t0024(run_init,valid_05,dom_prefer=dom_prefer) | |
| if not melt_urls: | |
| return{"ok":False,"error":"forecast run_init found but no melt files","run_init":run_init,"date":date_ymd} | |
| return{"ok":True,"melt_urls":melt_urls,"valid":valid_05,"run_init":run_init,"source_type":"forecast_05z"} | |
| # 4. NEW: Find snowpack files matching the valid time we chose | |
| # def _find_snowpack_for_best_melt(best_melt:dict,dom_prefer:str="zz")->list[str]: | |
| # """Find snowpack files that match the valid time from _find_best_melt_for_date.""" | |
| # dom_prefer=(dom_prefer or"zz").lower() | |
| # valid=best_melt.get("valid","") | |
| # run_init=best_melt.get("run_init","") | |
| # source_type=best_melt.get("source_type","") | |
| # if source_type in("ttna_11z","ttna_05z"): | |
| # # For TTNA, search by valid time | |
| # html=_collab_dirlist();names=_hrefs(html);want_ts=f"ts{valid}";out=[] | |
| # for n in names: | |
| # base=n.split("?",1)[0].strip();low=base.lower() | |
| # if"ssmv11034"not in low and"ssmv11036"not in low:continue | |
| # if"hp001"not in low:continue | |
| # if want_ts not in low:continue | |
| # if not(low.endswith(".grib2")or low.endswith(".nc")):continue | |
| # out.append(_as_url(base)) | |
| # if out:return sorted(set(out),key=lambda u:(0 if _dom_from_url(u)==dom_prefer else _dom_pri(_dom_from_url(u))+10)) | |
| # # Fallback: try 05Z snowpack if we're on 11Z melt | |
| # if source_type=="ttna_11z": | |
| # want_ts_05=f"ts{valid[:8]}05" | |
| # for n in names: | |
| # base=n.split("?",1)[0].strip();low=base.lower() | |
| # if"ssmv11034"not in low and"ssmv11036"not in low:continue | |
| # if"hp001"not in low:continue | |
| # if want_ts_05 not in low:continue | |
| # if not(low.endswith(".grib2")or low.endswith(".nc")):continue | |
| # out.append(_as_url(base)) | |
| # if out:return sorted(set(out),key=lambda u:(0 if _dom_from_url(u)==dom_prefer else _dom_pri(_dom_from_url(u))+10)) | |
| # return[] | |
| # # Forecast source — use existing logic | |
| # if run_init and run_init!="TTNA": | |
| # return _find_fcst_11034_grib2_tt_ts(run_init,valid,dom_prefer=dom_prefer) | |
| # return[] | |
| def _find_snowpack_for_best_melt(best_melt:dict,dom_prefer:str="zz")->list[str]: | |
| dom_prefer=(dom_prefer or"zz").lower() | |
| valid=best_melt.get("valid","") | |
| run_init=best_melt.get("run_init","") | |
| source_type=best_melt.get("source_type","") | |
| html=_collab_dirlist();names=_hrefs(html);want_ts=f"ts{valid}" | |
| if source_type in("ttna_11z","ttna_05z"): | |
| # Prefer TTNA snowpack matching the valid time | |
| ttna_out=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if("ssmv11034"not in low and"ssmv11036"not in low):continue | |
| if"hp001"not in low:continue | |
| if want_ts not in low:continue | |
| if"ttnats"not in low:continue # Only TTNA snowpack | |
| if not(low.endswith(".grib2")or low.endswith(".nc")):continue | |
| ttna_out.append(_as_url(base)) | |
| if ttna_out: | |
| return sorted(set(ttna_out),key=lambda u:(0 if _dom_from_url(u)==dom_prefer else _dom_pri(_dom_from_url(u))+10)) | |
| # Fallback: TTNA snowpack at 05Z if we're on 11Z melt | |
| if source_type=="ttna_11z": | |
| want_ts_05=f"ts{valid[:8]}05" | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if("ssmv11034"not in low and"ssmv11036"not in low):continue | |
| if"hp001"not in low:continue | |
| if want_ts_05 not in low:continue | |
| if"ttnats"not in low:continue | |
| if not(low.endswith(".grib2")or low.endswith(".nc")):continue | |
| ttna_out.append(_as_url(base)) | |
| if ttna_out: | |
| return sorted(set(ttna_out),key=lambda u:(0 if _dom_from_url(u)==dom_prefer else _dom_pri(_dom_from_url(u))+10)) | |
| if source_type in("forecast_11z","forecast_05z","forecast"): | |
| # Use snowpack from the same run init only | |
| if run_init and run_init!="TTNA": | |
| return _find_fcst_11034_grib2_tt_ts(run_init,valid,dom_prefer=dom_prefer) | |
| # Last resort: any snowpack matching valid time but limit to preferred domain | |
| out=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if("ssmv11034"not in low and"ssmv11036"not in low):continue | |
| if"hp001"not in low:continue | |
| if want_ts not in low:continue | |
| if not(low.endswith(".grib2")or low.endswith(".nc")):continue | |
| if _dom_from_url(_as_url(base))==dom_prefer: | |
| out.append(_as_url(base)) | |
| if out:return sorted(set(out),key=lambda u:_dom_pri(_dom_from_url(u))) | |
| return[] | |
| def _check_and_rebuild_stale_dates()->None: | |
| dom=(WARM_DOM or"zz").lower() | |
| window=_rolling_dates_local_window(lookback=2,lookahead=3) | |
| with _NOAA_FILE_SIGS_LK: | |
| known_sigs={k:dict(v)for k,v in _NOAA_FILE_SIGS.items()} | |
| stale_dates=[] | |
| for ymd in window: | |
| key=f"{ymd}|{dom}" | |
| old_sig=known_sigs.get(key) | |
| urls_11z=_find_melt_11044_ttna_11z(ymd,dom_prefer=dom) | |
| if urls_11z: | |
| url_11z=urls_11z[0] | |
| if old_sig and old_sig.get("source_type")=="ttna_11z": | |
| new_sig=_get_noaa_file_sig(url_11z) | |
| if new_sig: | |
| old_lm=old_sig.get("last_modified","");new_lm=new_sig.get("last_modified","") | |
| old_cl=old_sig.get("content_length","");new_cl=new_sig.get("content_length","") | |
| if(old_lm and new_lm and old_lm!=new_lm)or(old_cl and new_cl and old_cl!=new_cl): | |
| _log("INFO",f"[staleness] 11Z updated for {ymd}: LM {old_lm} -> {new_lm}") | |
| stale_dates.append((ymd,url_11z)) | |
| continue | |
| _log("INFO",f"[staleness] 11Z now available for {ymd} — was {old_sig.get('source_type','unknown') if old_sig else 'not baked'}, rebuilding") | |
| stale_dates.append((ymd,url_11z)) | |
| continue | |
| if not old_sig: | |
| best=_find_best_melt_for_date(ymd,dom_prefer=dom) | |
| if best.get("ok")and best.get("melt_urls"): | |
| _record_baked_sig_v2(ymd,dom,best["melt_urls"][0],best.get("source_type","unknown")) | |
| continue | |
| if old_sig and old_sig.get("source_type")in("forecast","forecast_05z","ttna_05z"): | |
| # Check if forecast 11Z is now available | |
| valid_11=f"{ymd}11" | |
| ri_11=_pick_best_run_init_for_valid_11z(valid_11,dom_prefer=dom) | |
| if ri_11: | |
| urls_11=_find_melt_11044_t0024_11z(ri_11,valid_11,dom_prefer=dom) | |
| if urls_11: | |
| _log("INFO",f"[staleness] forecast 11Z now available for {ymd}") | |
| stale_dates.append((ymd,urls_11[0])) | |
| continue | |
| old_url=old_sig.get("url","") | |
| best=_find_best_melt_for_date(ymd,dom_prefer=dom) | |
| if best.get("ok")and best.get("melt_urls"): | |
| new_url=best["melt_urls"][0] | |
| if new_url!=old_url: | |
| _log("INFO",f"[staleness] newer forecast run available for {ymd}: {old_url.split('/')[-1]} -> {new_url.split('/')[-1]}") | |
| stale_dates.append((ymd,new_url)) | |
| continue | |
| new_sig=_get_noaa_file_sig(new_url) | |
| if new_sig: | |
| old_lm=old_sig.get("last_modified","");new_lm=new_sig.get("last_modified","") | |
| old_cl=old_sig.get("content_length","");new_cl=new_sig.get("content_length","") | |
| if(old_lm and new_lm and old_lm!=new_lm)or(old_cl and new_cl and old_cl!=new_cl): | |
| _log("INFO",f"[staleness] forecast file modified for {ymd}: LM {old_lm} -> {new_lm}") | |
| stale_dates.append((ymd,new_url)) | |
| continue | |
| if not stale_dates:return | |
| # Priority: day 2 first, then day 1, then day 3, then others | |
| today=datetime.now(LOCAL_TZ).date() | |
| def _rebuild_priority(item): | |
| ymd=item[0] | |
| try: | |
| d=datetime.strptime(ymd,"%Y%m%d").date() | |
| offset=(d-today).days | |
| if offset==2:return 0 # day 2 first | |
| if offset==1:return 1 # day 1 second | |
| if offset==3:return 2 # day 3 third | |
| return 3+abs(offset) # everything else after | |
| except:return 99 | |
| stale_dates.sort(key=_rebuild_priority) | |
| _log("INFO",f"[staleness] rebuild order: {[d[0] for d in stale_dates]}") | |
| for ymd,url in stale_dates: | |
| _log("INFO",f"[staleness] hot-swapping {ymd}") | |
| try:_hot_swap_rebuild_date(ymd,dom) | |
| except Exception as e:_log("WARN",f"[staleness] rebuild failed for {ymd}: {e!r}") | |
| def _staleness_monitor_loop()->None: | |
| _load_noaa_sigs() | |
| _log("INFO","[staleness] monitor started") | |
| time.sleep(300) | |
| while True: | |
| try:_check_and_rebuild_stale_dates() | |
| except Exception as e:_log("WARN",f"[staleness] monitor error: {e!r}") | |
| time.sleep(20*60) | |
| def _nuke_and_rebuild_date(date:str,dom:str)->None: | |
| dom=(dom or"zz").lower();valid_05=f"{date}05";valid_11=f"{date}11" | |
| # 1. Delete local COGs for both 05Z and 11Z | |
| for valid in[valid_05,valid_11]: | |
| for pattern in[f"fcst_melt24h_union_end_{valid}_{dom}_cog.tif",f"fcst_melt72h_end_{valid}_{dom}_cog.tif",f"fcst_snowpack_union_end_{valid}_{dom}_cog.tif",f"fcst_melt_t0024_*_{valid}_{dom}_cog.tif",f"fcst_melt_t0024_*_{valid}_*_cog.tif"]: | |
| for p in _forecast_dir().glob(pattern): | |
| try:COG_CACHE.evict(p) | |
| except:pass | |
| try:p.unlink(missing_ok=True) | |
| except:pass | |
| # 2. Delete local tile cache | |
| for kind in["tilecache","rawtilecache"]: | |
| for h in WARM_HOURS_LIST: | |
| d=CACHE/kind/dom/str(h)/date | |
| if d.exists():shutil.rmtree(d,ignore_errors=True) | |
| # 3. Delete tar markers | |
| marker_dir=CACHE/"_tar_markers" | |
| if marker_dir.exists(): | |
| for mk in marker_dir.glob(f"*_{date}.tar.gz.done"): | |
| try:mk.unlink(missing_ok=True) | |
| except:pass | |
| # 4. Delete tar archives from HF | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if tok and repo and api is not None: | |
| for kind in["tilecache","rawtilecache"]: | |
| for h in WARM_HOURS_LIST: | |
| archive_path=f"archives/{kind}_{dom}_h{h}_{date}.tar.gz" | |
| try: | |
| api.create_commit(repo_id=repo,repo_type="dataset",operations=[CommitOperationDelete(path_in_repo=archive_path)],commit_message=f"nuke: delete {archive_path}") | |
| _log("INFO",f"[nuke] deleted HF archive: {archive_path}") | |
| except Exception as e: | |
| err=repr(e) | |
| if"404"not in err and"EntryNotFound"not in err and"does not exist"not in err: | |
| _log("WARN",f"[nuke] HF delete failed: {archive_path}: {e!r}") | |
| # 5. Also delete 72h COGs and tiles for next 2 dates | |
| for offset in[1,2]: | |
| try: | |
| future_date=(datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d") | |
| for fv in[f"{future_date}05",f"{future_date}11"]: | |
| for p in _forecast_dir().glob(f"fcst_melt72h_end_{fv}_*_cog.tif"): | |
| try:COG_CACHE.evict(p) | |
| except:pass | |
| try:p.unlink(missing_ok=True);_log("INFO",f"[nuke] deleted 72h COG: {p.name}") | |
| except:pass | |
| for kind in["tilecache","rawtilecache"]: | |
| d=CACHE/kind/dom/"72"/future_date | |
| if d.exists():shutil.rmtree(d,ignore_errors=True);_log("INFO",f"[nuke] deleted {kind}/72h tiles for {future_date}") | |
| if tok and repo and api is not None: | |
| for kind in["tilecache","rawtilecache"]: | |
| ap=f"archives/{kind}_{dom}_h72_{future_date}.tar.gz" | |
| try: | |
| api.create_commit(repo_id=repo,repo_type="dataset",operations=[CommitOperationDelete(path_in_repo=ap)],commit_message=f"nuke: delete stale 72h {ap}") | |
| _log("INFO",f"[nuke] deleted HF 72h archive: {ap}") | |
| except Exception as e: | |
| err=repr(e) | |
| if"404"not in err and"EntryNotFound"not in err and"does not exist"not in err: | |
| _log("WARN",f"[nuke] HF 72h delete failed: {ap}: {e!r}") | |
| except:pass | |
| # 6. Clear negative cache | |
| with _HF_NEGATIVE_CACHE_LK: | |
| to_del=[k for k in _HF_NEGATIVE_CACHE if date in k] | |
| for k in to_del:del _HF_NEGATIVE_CACHE[k] | |
| # 7. Rebuild — _pick_forecast_for_valid now auto-selects 11Z > 05Z > forecast | |
| _log("INFO",f"[nuke] rebuilding COGs for {date} (will auto-select best available)") | |
| best=_find_best_melt_for_date(date,dom_prefer=dom) | |
| _log("INFO",f"[nuke] {date}: selected source_type={best.get('source_type','?')} valid={best.get('valid','?')}") | |
| # Use the valid time from best melt selection | |
| actual_valid=best.get("valid",f"{date}05") | |
| r=_ensure_forecast_for_valid(actual_valid,dom_prefer=dom) | |
| if not r.get("ok"):_log("WARN",f"[nuke] COG rebuild failed for {date}: {r}");return | |
| # 8. Re-bake tiles — sequential for CPU efficiency | |
| _log("INFO",f"[nuke] re-baking tiles for {date}") | |
| for label,fn,extra in[("corridor_png",_bake_corridor_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL)),("corridor_raw",_bake_corridor_raw_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL))]: | |
| try: | |
| r=fn(date_yyyymmdd=date,dom=dom,hours_list=WARM_HOURS_LIST,zmin=WARM_ZMIN,zmax=WARM_ZMAX,**extra) | |
| _log("INFO",f"[nuke] {date}: {label} ok={r.get('ok_count',0)} skip={r.get('skip_count',0)} fail={r.get('fail_count',0)}") | |
| except Exception as e:_log("WARN",f"[nuke] {date}: {label} failed: {e!r}") | |
| for label,fn,extra in[("lowres_png",_bake_lowres_box_tiles_for_date,dict(conc=1)),("lowres_raw",_bake_lowres_box_raw_tiles_for_date,dict(conc=1))]: | |
| try:fn(date_yyyymmdd=date,dom=dom,hours_list=WARM_HOURS_LIST,zmin=LOWRES_BOX_ZMIN,zmax=LOWRES_BOX_ZMAX,**extra) | |
| except:pass | |
| # 9. Rebake 72h for next 2 dates — sequential, with tile invalidation | |
| for offset in[1,2]: | |
| try: | |
| future_date=(datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d") | |
| future_best=_find_best_melt_for_date(future_date,dom_prefer=dom) | |
| future_valid=future_best.get("valid",f"{future_date}05")if future_best.get("ok")else f"{future_date}05" | |
| try:_forecast_melt72h_end_cog(future_valid,dom_prefer=dom) | |
| except:pass | |
| _log("INFO",f"[nuke] rebaking 72h tiles for {future_date}") | |
| # Delete stale 72h tiles (nuke already deleted them in step 5, | |
| # but belt-and-suspenders in case the path changed) | |
| for kind in["tilecache","rawtilecache"]: | |
| d=CACHE/kind/dom/"72"/future_date | |
| if d.exists():shutil.rmtree(d,ignore_errors=True) | |
| for fn,extra in[(_bake_corridor_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL)),(_bake_corridor_raw_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL))]: | |
| try:fn(date_yyyymmdd=future_date,dom=dom,hours_list=[72],zmin=WARM_ZMIN,zmax=WARM_ZMAX,**extra) | |
| except:pass | |
| for fn,extra in[(_bake_lowres_box_tiles_for_date,dict(conc=1)),(_bake_lowres_box_raw_tiles_for_date,dict(conc=1))]: | |
| try:fn(date_yyyymmdd=future_date,dom=dom,hours_list=[72],zmin=LOWRES_BOX_ZMIN,zmax=LOWRES_BOX_ZMAX,**extra) | |
| except:pass | |
| except Exception as e:_log("WARN",f"[nuke] 72h rebake for +{offset} failed: {e!r}") | |
| # 10. Push corrected tiles | |
| push_dates=[date] | |
| for offset in[1,2]: | |
| try:push_dates.append((datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d")) | |
| except:pass | |
| _log("INFO",f"[nuke] pushing corrected tiles for {push_dates}") | |
| _hf_push_baked_as_tar(push_dates,dom,WARM_HOURS_LIST) | |
| # 11. Record signature | |
| if best.get("ok")and best.get("melt_urls"): | |
| _record_baked_sig(date,dom,best["melt_urls"][0],source_type=best.get("source_type")) | |
| _log("INFO",f"[nuke] {date}: rebuild complete (source={best.get('source_type','?')})") | |
| def _client_mark_active(client_id: str, active_date: str, gen: int | None = None) -> None: | |
| with _ACTIVE_CLIENT_LK: | |
| _ACTIVE_CLIENT[client_id] = { | |
| "date": active_date, | |
| "gen": int(gen) if gen is not None else 0, | |
| "ts": time.time(), | |
| } | |
| def _client_is_active_date(client_id: str, date_yyyymmdd: str) -> bool: | |
| now = time.time() | |
| with _ACTIVE_CLIENT_LK: | |
| st = _ACTIVE_CLIENT.get(client_id) | |
| if not st: | |
| return True | |
| if (now - float(st.get("ts", 0.0))) > _ACTIVE_CLIENT_TTL_S: | |
| return True | |
| return str(st.get("date", "")) == str(date_yyyymmdd) | |
| def _client_is_active_gen(client_id: str, gen: int | None) -> bool: | |
| if not client_id or gen is None: | |
| return True # no generation tracking → allow | |
| now = time.time() | |
| with _ACTIVE_CLIENT_LK: | |
| st = _ACTIVE_CLIENT.get(client_id) | |
| if not st: | |
| return True | |
| if (now - float(st.get("ts", 0.0))) > _ACTIVE_CLIENT_TTL_S: | |
| return True | |
| server_gen = int(st.get("gen", 0)) | |
| return int(gen) >= server_gen | |
| def _make_cancel_check(client_id: str | None, gen: int | None): | |
| if not client_id or gen is None: | |
| return None | |
| def _is_cancelled() -> bool: | |
| return not _client_is_active_gen(client_id, gen) | |
| return _is_cancelled | |
| class CogCache: | |
| def __init__(self): | |
| self._datasets: dict[str, COGReader] = {} | |
| self._lock = threading.Lock() | |
| def get(self, cog: Path) -> COGReader: | |
| key = cog.as_posix() | |
| with self._lock: | |
| r = self._datasets.get(key) | |
| if r is None: | |
| r = COGReader(key) | |
| self._datasets[key] = r | |
| return r | |
| def evict(self, path: Path) -> None: | |
| with self._lock: | |
| ds = self._datasets.pop(str(path), None) | |
| if ds: | |
| try: ds.close() | |
| except: pass | |
| def close_all(self): | |
| with self._lock: | |
| for r in self._datasets.values(): | |
| try: r.close() | |
| except: pass | |
| self._datasets.clear() | |
| def __enter__(self): return self | |
| def __exit__(self, *args): self.close_all() | |
| COG_CACHE=CogCache() | |
| def _cogreader_get(path:Path)->COGReader: | |
| key=path.as_posix() | |
| with _COGREADER_CACHE_LK: | |
| r=_COGREADER_CACHE.get(key) | |
| if r is not None:_COGREADER_CACHE.move_to_end(key,last=True);return r | |
| r=COGReader(key);_COGREADER_CACHE[key]=r;_COGREADER_CACHE.move_to_end(key,last=True) | |
| while len(_COGREADER_CACHE)>_COGREADER_CACHE_MAX: | |
| _,old_r=_COGREADER_CACHE.popitem(last=False) | |
| try:old_r.close() | |
| except:pass | |
| return r | |
| class _RateLimitBuckets: | |
| def __init__(self): | |
| self._buckets:dict[str,list[float]]=defaultdict(list) | |
| self._locks:dict[str,threading.Lock]=defaultdict(threading.Lock) | |
| self._cleanup_lock=threading.Lock();self._last_cleanup=0.0 | |
| def check(self,client_id:str)->bool: | |
| if not client_id:return True | |
| now=time.time();cutoff=now-_RATE_LIMIT_WINDOW | |
| with self._locks[client_id]: | |
| bucket=self._buckets[client_id];bucket[:]=[t for t in bucket if t>cutoff] | |
| if len(bucket)>=_RATE_LIMIT_MAX_REQUESTS:return False | |
| bucket.append(now) | |
| if now-self._last_cleanup>60.0:self._cleanup_old_clients(cutoff) | |
| return True | |
| def _cleanup_old_clients(self,cutoff:float)->None: | |
| if not self._cleanup_lock.acquire(blocking=False):return | |
| try: | |
| self._last_cleanup=time.time() | |
| empty=[cid for cid,bucket in self._buckets.items()if not bucket or all(t<=cutoff for t in bucket)] | |
| for cid in empty:self._buckets.pop(cid,None);self._locks.pop(cid,None) | |
| finally:self._cleanup_lock.release() | |
| _rate_limiter=_RateLimitBuckets() | |
| def _check_rate_limit(client_id:str)->bool:return _rate_limiter.check(client_id) | |
| def _is_valid_png_file(p:Path)->bool: | |
| try: | |
| if not p.exists():return False | |
| if p.stat().st_size<64:return False | |
| with p.open("rb")as f:head=f.read(8) | |
| return head==PNG_MAGIC | |
| except:return False | |
| def _ymd_from_cache_relpath(rel:str)->str|None: | |
| try: | |
| p=Path(rel).as_posix();parts=p.split("/") | |
| if len(parts)>=4 and parts[0]in("tilecache","rawtilecache"): | |
| ymd=parts[3];return ymd if re.fullmatch(r"[0-9]{8}",ymd)else None | |
| if parts and parts[0]=="forecast": | |
| m=re.search(r"([0-9]{8})",p);return m.group(1)if m and re.fullmatch(r"[0-9]{8}",m.group(1))else None | |
| base=parts[-1]if parts else p | |
| m=re.match(r"bydate_[a-z0-9]+_h\d+_([0-9]{8})_z\d+_\d+_\d+\.(png|npz)$",base) | |
| return m.group(1)if m else None | |
| except:return None | |
| def _validate_npz_bytes(data:bytes)->bool: | |
| if not data or len(data)<16:return False | |
| if data[0:4]!=b'PK\x03\x04':return False | |
| return True | |
| def _mem_cache_get(cache:dict,key:Any,ttl_s:int)->Any|None: | |
| try: | |
| v=cache.get(key) | |
| if not v:return None | |
| ts,payload=v | |
| if(time.time()-ts)>ttl_s: | |
| try:del cache[key] | |
| except:pass | |
| return None | |
| return payload | |
| except:return None | |
| def _mem_cache_put(cache:dict,key:Any,payload:Any)->None: | |
| try:cache[key]=(time.time(),payload) | |
| except:pass | |
| def _safe_unlink(p:Path)->None: | |
| try:p.unlink(missing_ok=True) | |
| except:pass | |
| def _tile_cache_dir()->Path: | |
| d=CACHE/"tilecache";d.mkdir(parents=True,exist_ok=True);return d | |
| def _tile_cache_path(dom:str,hours:int,date_yyyymmdd:str,z:int,x:int,y:int)->Path: | |
| dom=(dom or"zz").lower() | |
| return CACHE/"tilecache"/dom/str(int(hours))/date_yyyymmdd/str(int(z))/str(int(x))/f"{int(y)}.png" | |
| def _raw_tile_cache_path(dom:str,hours:int,date_yyyymmdd:str,z:int,x:int,y:int)->Path: | |
| dom=(dom or"zz").lower() | |
| return CACHE/"rawtilecache"/dom/str(int(hours))/date_yyyymmdd/str(int(z))/str(int(x))/f"{int(y)}.npz" | |
| def _raw_tile_cache_dir()->Path: | |
| d=CACHE/"rawtilecache";d.mkdir(parents=True,exist_ok=True);return d | |
| def _is_valid_png_bytes(b:bytes)->bool:return bool(b)and len(b)>=64 and b[:8]==PNG_MAGIC | |
| def _is_valid_npz_bytes(b:bytes)->bool:return bool(b)and len(b)>=16 and b[:4]==ZIP_MAGIC | |
| def _atomic_write_bytes(path:Path,data:bytes)->None: | |
| path.parent.mkdir(parents=True,exist_ok=True) | |
| tmp=path.with_name(path.name+f".tmp.{uuid.uuid4().hex}") | |
| with tmp.open("wb")as f:f.write(data);f.flush();os.fsync(f.fileno()) | |
| os.replace(tmp,path) | |
| def _is_valid_npz_file(p:Path)->bool: | |
| try: | |
| if not p.exists():return False | |
| if p.stat().st_size<60:return False | |
| with p.open("rb")as f:head=f.read(2) | |
| return head==b"PK" | |
| except:return False | |
| # def _raw_tile_cache_get(dom: str, hours: int, ymd: str, z: int, x: int, y: int, | |
| # cancel_check=None) -> bytes | None: | |
| # p = _raw_tile_cache_path(dom, hours, ymd, z, x, y) | |
| # try: | |
| # if _is_valid_npz_file(p): | |
| # return p.read_bytes() | |
| # elif p.exists(): | |
| # _safe_unlink(p) | |
| # except: | |
| # pass | |
| # if not _hf_pull_on_miss_enabled(): | |
| # return None | |
| # if cancel_check and cancel_check(): | |
| # return None | |
| # rel = p.relative_to(CACHE).as_posix() | |
| # if _hf_negative_check(rel): | |
| # return None | |
| # try: | |
| # if _hf_try_pull_file(rel, cancel_check=cancel_check): | |
| # if cancel_check and cancel_check(): | |
| # return None | |
| # if _is_valid_npz_file(p): | |
| # return p.read_bytes() | |
| # elif p.exists(): | |
| # _safe_unlink(p) | |
| # _hf_negative_mark(rel) | |
| # except Exception as e: | |
| # if "404" not in str(e).lower(): | |
| # _log("WARN", f"[hf_pull_raw] unexpected error for {rel}: {e!r}") | |
| # else: | |
| # _hf_negative_mark(rel) | |
| # return None | |
| def _raw_tile_cache_get(dom: str, hours: int, ymd: str, z: int, x: int, y: int, | |
| cancel_check=None) -> bytes | None: | |
| """Check local raw tile cache. No HF pull — same reasoning as _tile_cache_get.""" | |
| p = _raw_tile_cache_path(dom, hours, ymd, z, x, y) | |
| try: | |
| if _is_valid_npz_file(p): | |
| return p.read_bytes() | |
| elif p.exists(): | |
| _safe_unlink(p) | |
| except: | |
| pass | |
| # Per-tile HF pull removed: see _tile_cache_get comment. | |
| return None | |
| def _tile_cache_put(dom,hours,ymd,z,x,y,png:bytes)->Path: | |
| p=_tile_cache_path(dom,hours,ymd,z,x,y) | |
| if not _is_valid_png_bytes(png): | |
| if p.exists():_safe_unlink(p) | |
| return p | |
| _atomic_write_bytes(p,png);return p | |
| def _raw_tile_cache_put(dom,hours,ymd,z,x,y,npz_bytes:bytes)->Path: | |
| p=_raw_tile_cache_path(dom,hours,ymd,z,x,y) | |
| if not _is_valid_npz_bytes(npz_bytes): | |
| if p.exists():_safe_unlink(p) | |
| return p | |
| _atomic_write_bytes(p,npz_bytes);return p | |
| _UPLOAD_STATE_DIR=CACHE/"_upload_state" | |
| _UPLOAD_STATE_DIR.mkdir(parents=True,exist_ok=True) | |
| def _upload_manifest_path(date_yyyymmdd:str)->Path:return _UPLOAD_STATE_DIR/f"uploaded_{date_yyyymmdd}.json" | |
| def _load_upload_manifest(date_yyyymmdd:str)->dict: | |
| p=_upload_manifest_path(date_yyyymmdd) | |
| if not p.exists():return{"tilecache":{},"rawtilecache":{}} | |
| try:return json.loads(p.read_text()) | |
| except:return{"tilecache":{},"rawtilecache":{}} | |
| def _save_upload_manifest(date_yyyymmdd:str,manifest:dict)->None: | |
| try:_upload_manifest_path(date_yyyymmdd).write_text(json.dumps(manifest,sort_keys=True)) | |
| except:pass | |
| def _load_upload_manifest_global()->dict: | |
| try: | |
| p=CACHE/"_hf_upload_manifest.json" | |
| if p.exists():return json.loads(p.read_text("utf-8")) | |
| except:pass | |
| return{} | |
| def _save_upload_manifest_global(m:dict)->None: | |
| try: | |
| p=CACHE/"_hf_upload_manifest.json";tmp=p.with_suffix(".tmp") | |
| tmp.write_text(json.dumps(m,separators=(",",":"),sort_keys=True),"utf-8");tmp.replace(p) | |
| except:pass | |
| def _folder_signature(local_folder:Path,stable_age_sec:float=8.0)->tuple[int,int,float]: | |
| now=time.time();file_count=0;total_bytes=0;newest_mtime=0.0 | |
| for p in local_folder.rglob("*"): | |
| try: | |
| if not p.is_file():continue | |
| st=p.stat() | |
| if st.st_size<=0:continue | |
| if(now-st.st_mtime)<stable_age_sec:continue | |
| file_count+=1;total_bytes+=int(st.st_size);newest_mtime=max(newest_mtime,float(st.st_mtime)) | |
| except:continue | |
| return file_count,total_bytes,newest_mtime | |
| def _hf_upload_folder_if_changed(*,local_folder:Path,repo_path:str,date_yyyymmdd:str,kind:str,dom:str,hours:int)->dict: | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if not tok or not repo or api is None:return{"ok":False,"error":"hf_not_configured"} | |
| if not local_folder.exists():return{"ok":True,"skipped":True,"reason":"folder_not_exists"} | |
| file_count,total_bytes,newest_mtime=_folder_signature(local_folder,stable_age_sec=8.0) | |
| if file_count<=0:return{"ok":True,"skipped":True,"reason":"empty_or_unstable_folder"} | |
| max_files=int(os.environ.get("HF_UPLOAD_MAX_FILES","8000")) | |
| if file_count>max_files: | |
| _log("WARN",f"[hf_upload_folder] skip too many files kind={kind} {repo_path} files={file_count} max={max_files}") | |
| return{"ok":True,"skipped":True,"reason":"too_many_files","files":file_count,"max":max_files} | |
| manifest=_load_upload_manifest_global();key=f"{kind}|{dom}|{int(hours)}|{repo_path}";prev=manifest.get(key) | |
| if prev: | |
| if prev.get("file_count")==file_count and prev.get("total_bytes")==total_bytes and prev.get("newest_mtime",0.0)>=newest_mtime: | |
| return{"ok":True,"skipped":True,"reason":"no_change"} | |
| try: | |
| api.upload_folder(folder_path=str(local_folder),repo_id=repo,repo_type="dataset",path_in_repo=repo_path,token=tok, | |
| commit_message=f"{kind}: upload {date_yyyymmdd} dom={dom} h={hours} ({file_count} files)",allow_patterns=["**/*"]) | |
| manifest[key]={"file_count":file_count,"total_bytes":total_bytes,"newest_mtime":newest_mtime,"ts":time.time()} | |
| _save_upload_manifest_global(manifest) | |
| return{"ok":True,"skipped":False,"files":file_count,"bytes":total_bytes} | |
| except Exception as e: | |
| _log("WARN",f"[hf_upload_folder] failed kind={kind} date={date_yyyymmdd} err={e!r}") | |
| return{"ok":False,"error":repr(e)} | |
| def _iter_zoom_dirs(date_folder: Path): | |
| if not date_folder.exists(): | |
| return [] | |
| out = [] | |
| for p in sorted(date_folder.iterdir()): | |
| if p.is_dir() and p.name.isdigit(): | |
| out.append(p) | |
| return out | |
| def _hf_tiles_uploader_loop() -> None: | |
| _log("INFO", "[hf_uploader] disabled — roll job handles HF push directly") | |
| return | |
| def _pack_raw_tile_npz(*,melt_mm:np.ndarray,valid_mask_u8:np.ndarray)->bytes: | |
| try: | |
| mm=melt_mm.astype("float32",copy=False);m=valid_mask_u8.astype("uint8",copy=False);valid=(m>0) | |
| inches=(mm/25.4).astype("float32",copy=False);q=np.zeros(inches.shape,dtype="uint16") | |
| qv=np.rint(np.clip(inches*100.0,0.0,65535.0)).astype("uint16");q[valid]=qv[valid] | |
| bio=io.BytesIO();np.savez_compressed(bio,q=q,m=m,s=np.uint8(100));return bio.getvalue() | |
| except: | |
| bio=io.BytesIO();np.savez_compressed(bio,q=np.zeros((256,256),dtype="uint16"),m=np.zeros((256,256),dtype="uint8"),s=np.uint8(100)) | |
| return bio.getvalue() | |
| _LOCK_ROOT=Path(os.environ.get("SNODAS_LOCK_DIR","/tmp/snodas-locks")).expanduser() | |
| _LOCK_ROOT.mkdir(parents=True,exist_ok=True) | |
| _LOCK_STALE_SECONDS=int(os.environ.get("LOCK_STALE_SECONDS",str(48*3600))) | |
| def _acquire_singleton_lock(name:str)->bool: | |
| p=_LOCK_ROOT/f"{name}.lock";fd=None | |
| try: | |
| fd=os.open(p.as_posix(),os.O_CREAT|os.O_EXCL|os.O_RDWR) | |
| os.write(fd,f"pid={os.getpid()} utc={datetime.utcnow().isoformat()}\n".encode("utf-8"));return True | |
| except FileExistsError: | |
| try: | |
| age=time.time()-p.stat().st_mtime | |
| if age>float(_LOCK_STALE_SECONDS): | |
| try:p.unlink(missing_ok=True) | |
| except:pass | |
| fd=os.open(p.as_posix(),os.O_CREAT|os.O_EXCL|os.O_RDWR) | |
| os.write(fd,f"pid={os.getpid()} utc={datetime.utcnow().isoformat()} stale_recovered=1\n".encode("utf-8"));return True | |
| except:pass | |
| return False | |
| except:return False | |
| finally: | |
| if fd is not None: | |
| try:os.close(fd) | |
| except:pass | |
| def _hf_cfg_norm(): | |
| cfg=_hf_cfg() | |
| if isinstance(cfg,dict): | |
| ok=bool(cfg.get("ok"));repo=cfg.get("repo")or cfg.get("dataset_repo")or cfg.get("repo_id");token=cfg.get("token")or cfg.get("hf_token") | |
| return ok,repo,token,cfg | |
| if isinstance(cfg,tuple): | |
| if len(cfg)==0:return False,None,None,{"raw":cfg} | |
| if len(cfg)==1:return bool(cfg[0]),None,None,{"raw":cfg} | |
| if len(cfg)==2:return bool(cfg[0]),cfg[1],None,{"raw":cfg} | |
| return bool(cfg[0]),cfg[1],(cfg[2]if len(cfg)>=3 else None),{"raw":cfg} | |
| return False,None,None,{"raw":cfg} | |
| _ROLL_LOCK_NAME="roll_job" | |
| _ROLL_LOCK_STALE_SECONDS=int(os.environ.get("ROLL_LOCK_STALE_SECONDS",str(16*3600))) | |
| def _run_lock_path(name:str)->Path:return _LOCK_ROOT/f"{name}.runlock" | |
| def _try_acquire_run_lock(name:str,*,stale_seconds:int)->bool: | |
| p=_run_lock_path(name);fd=None | |
| try: | |
| fd=os.open(p.as_posix(),os.O_CREAT|os.O_EXCL|os.O_RDWR) | |
| os.write(fd,f"pid={os.getpid()} utc={datetime.utcnow().isoformat()}\n".encode("utf-8"));return True | |
| except FileExistsError: | |
| try: | |
| age=time.time()-p.stat().st_mtime | |
| if age>float(stale_seconds): | |
| try:p.unlink(missing_ok=True) | |
| except:pass | |
| fd=os.open(p.as_posix(),os.O_CREAT|os.O_EXCL|os.O_RDWR) | |
| os.write(fd,f"pid={os.getpid()} utc={datetime.utcnow().isoformat()} stale_recovered=1\n".encode("utf-8"));return True | |
| except:pass | |
| return False | |
| except:return False | |
| finally: | |
| if fd is not None: | |
| try:os.close(fd) | |
| except:pass | |
| def _release_run_lock(name:str)->None: | |
| try:_run_lock_path(name).unlink(missing_ok=True) | |
| except:pass | |
| CFGRIB_DIR=CACHE/"cfgrib" | |
| CFGRIB_DIR.mkdir(parents=True,exist_ok=True) | |
| os.environ.setdefault("CFGRIB_INDEXPATH",str(CFGRIB_DIR)) | |
| COLLAB_BASE="https://www.nohrsc.noaa.gov/pub/products/collaborators" | |
| NSIDC_BASE="https://noaadata.apps.nsidc.org/NOAA/G02158" | |
| _DIRLIST={"ts":0.0,"txt":""} | |
| _COLLAB_LAST={"ts":0.0,"status":None,"error":None,"len":0} | |
| _BUILD_LOCKS:dict[str,threading.Lock]={} | |
| _BUILD_LOCKS_LOCK=threading.Lock() | |
| _LOG=deque(maxlen=2000) | |
| TILE_CACHE_DIR=(CACHE/"tile_png_cache"/"forecast_by_date") | |
| TILE_CACHE_DIR.mkdir(parents=True,exist_ok=True) | |
| def _q(v:float,step:float)->float:return round(v/step)*step | |
| def _lock(key:str)->threading.Lock: | |
| with _BUILD_LOCKS_LOCK: | |
| l=_BUILD_LOCKS.get(key) | |
| if l is None:l=_BUILD_LOCKS[key]=threading.Lock() | |
| return l | |
| # def _tile_cache_get(dom: str, hours: int, ymd: str, z: int, x: int, y: int, | |
| # cancel_check=None) -> bytes | None: | |
| # p = _tile_cache_path(dom, hours, ymd, z, x, y) | |
| # try: | |
| # if _is_valid_png_file(p): | |
| # return p.read_bytes() | |
| # elif p.exists(): | |
| # _safe_unlink(p) | |
| # except: | |
| # pass | |
| # if not _hf_pull_on_miss_enabled(): | |
| # return None | |
| # if cancel_check and cancel_check(): | |
| # return None | |
| # rel = p.relative_to(CACHE).as_posix() | |
| # if _hf_negative_check(rel): | |
| # return None | |
| # try: | |
| # if _hf_try_pull_file(rel, cancel_check=cancel_check): | |
| # if cancel_check and cancel_check(): | |
| # return None | |
| # if _is_valid_png_file(p): | |
| # return p.read_bytes() | |
| # elif p.exists(): | |
| # _safe_unlink(p) | |
| # _hf_negative_mark(rel) | |
| # except Exception as e: | |
| # if "404" not in str(e).lower(): | |
| # _log("WARN", f"[hf_pull] unexpected error for {rel}: {e!r}") | |
| # else: | |
| # _hf_negative_mark(rel) | |
| # return None | |
| def _tile_cache_get(dom: str, hours: int, ymd: str, z: int, x: int, y: int, | |
| cancel_check=None) -> bytes | None: | |
| """Check local tile cache. No HF pull — tiles only exist as tar archives | |
| in HF, not as individual files, so per-tile pulls always 404.""" | |
| p = _tile_cache_path(dom, hours, ymd, z, x, y) | |
| try: | |
| if _is_valid_png_file(p): | |
| return p.read_bytes() | |
| elif p.exists(): | |
| _safe_unlink(p) | |
| except: | |
| pass | |
| # Per-tile HF pull removed: individual tiles don't exist in the HF repo. | |
| # Tiles get populated via boot-time tar extraction or local baking. | |
| # Cache misses fall through to on-demand generation (fast w/ preloaded COGs). | |
| return None | |
| def _hf_date_exists_remote(dom: str, hours: int, ymd: str) -> bool: | |
| """Check if HF repo has any tiles for this date/hours combo. Fast — only lists one dir.""" | |
| tok, repo = _hf_cfg() | |
| if not tok or not repo: return False | |
| api = _hf_api() | |
| if api is None: return False | |
| prefix = f"tilecache/{dom}/{hours}/{ymd}" | |
| try: | |
| items = api.list_repo_tree(repo_id=repo, repo_type="dataset", path_in_repo=prefix, recursive=False) | |
| for _ in items: | |
| return True | |
| except Exception: | |
| pass | |
| return False | |
| def _date_is_baked(dom: str, ymd: str, min_tiles: int = 50) -> bool: | |
| if _date_has_local_tiles_both(dom, ymd, min_tiles=min_tiles): | |
| return True | |
| # Temporarily check HF remote for testing | |
| for h in WARM_HOURS_LIST: | |
| if not _hf_date_exists_remote(dom, h, ymd): | |
| return False | |
| return True | |
| def _incremental_hf_push(tile_paths:list[Path],repo_prefix:str,batch_name:str)->dict: | |
| if not tile_paths:return{"ok":True,"pushed":0,"skipped":0} | |
| if repo_prefix in("tilecache","rawtilecache"):return{"ok":True,"pushed":0,"skipped":len(tile_paths),"disabled":True} | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if not tok or not repo or api is None:return{"ok":False,"error":"hf_not_configured","pushed":0,"skipped":0} | |
| ops:list[CommitOperationAdd]=[];skipped=0 | |
| for p in tile_paths: | |
| try: | |
| if(not p.exists())or(p.stat().st_size<=0):skipped+=1;continue | |
| rel=p.relative_to(CACHE).as_posix() | |
| if not(rel.startswith("forecast/")or rel.startswith("tracks/")):skipped+=1;continue | |
| ops.append(CommitOperationAdd(path_in_repo=rel,path_or_fileobj=p.as_posix())) | |
| except:skipped+=1 | |
| if not ops:return{"ok":True,"pushed":0,"skipped":len(tile_paths)} | |
| dedup:dict[str,CommitOperationAdd]={} | |
| for op in ops:dedup[op.path_in_repo]=op | |
| ops=list(dedup.values());attempts=3;last_err:str|None=None | |
| for i in range(attempts): | |
| try: | |
| api.create_commit(repo_id=repo,repo_type="dataset",token=tok,operations=ops,commit_message=f"{repo_prefix}: {batch_name} ({len(ops)} files)") | |
| return{"ok":True,"pushed":len(ops),"skipped":(len(tile_paths)-len(ops))+skipped} | |
| except Exception as e: | |
| msg=repr(e);last_err=msg | |
| is_timeout=("ReadTimeout"in msg)or("Timeout"in msg)or("timed out"in msg.lower()) | |
| _log("WARN",f"[incremental_push] failed attempt={i+1}/{attempts}: {msg}") | |
| if not is_timeout:return{"ok":False,"error":msg,"pushed":0,"skipped":0} | |
| time.sleep(2.0*(2**i)+random.random()) | |
| return{"ok":False,"error":last_err or"unknown_error","pushed":0,"skipped":0} | |
| def _ensure_cn_mbtiles()->Path: | |
| CN_MBTILES_LOCAL.parent.mkdir(parents=True,exist_ok=True) | |
| if CN_MBTILES_LOCAL.exists()and CN_MBTILES_LOCAL.stat().st_size>1024*1024:return CN_MBTILES_LOCAL | |
| tok,repo=_hf_cfg() | |
| if not tok or not repo:raise RuntimeError("HF not configured; cannot download cn_tracks.mbtiles") | |
| p=hf_hub_download(repo_id=repo,repo_type="dataset",filename=CN_MBTILES_REPO_PATH,token=tok,local_dir=str(CN_MBTILES_LOCAL.parent))#,local_dir_use_symlinks=False) | |
| downloaded=Path(p) | |
| if downloaded!=CN_MBTILES_LOCAL: | |
| try:downloaded.replace(CN_MBTILES_LOCAL) | |
| except:pass | |
| return CN_MBTILES_LOCAL | |
| def _mbtiles_overzoom(z:int,x:int,y:int,zmax:int)->tuple[int,int,int]: | |
| z=int(z);x=int(x);y=int(y);zmax=int(zmax) | |
| if z<=zmax:return z,x,y | |
| dz=z-zmax;return zmax,(x>>dz),(y>>dz) | |
| def _cogreader_lru_invalidate(cog:Path)->None: | |
| key=cog.as_posix() | |
| with _COG_LRU_LK:r=_COG_LRU.pop(key,None) | |
| if r is not None: | |
| try:r.close() | |
| except:pass | |
| def _atomic_replace(src:Path,dst:Path)->None: | |
| dst.parent.mkdir(parents=True,exist_ok=True) | |
| try:src.replace(dst) | |
| except: | |
| if dst.exists(): | |
| try:dst.unlink(missing_ok=True) | |
| except:pass | |
| src.replace(dst) | |
| def _atomic_cog_translate(src_tif:Path,out_cog:Path)->None: | |
| out_cog.parent.mkdir(parents=True,exist_ok=True) | |
| tmp_cog=out_cog.with_suffix(out_cog.suffix+".tmp") | |
| try:tmp_cog.unlink(missing_ok=True) | |
| except:pass | |
| prof=cog_profiles.get("deflate") | |
| if prof is None:raise HTTPException(status_code=500,detail="rio_cogeo deflate profile missing") | |
| cog_translate(src_tif.as_posix(),tmp_cog.as_posix(),prof,quiet=True,overview_resampling="nearest") | |
| # Verify the COG is readable before replacing | |
| try: | |
| with rasterio.open(tmp_cog.as_posix())as verify: | |
| verify.read(1,window=rasterio.windows.Window(0,0,min(256,verify.width),min(256,verify.height))) | |
| except Exception as e: | |
| _log("WARN",f"[cog_translate] verification failed for {out_cog.name}: {e!r}") | |
| try:tmp_cog.unlink(missing_ok=True) | |
| except:pass | |
| raise HTTPException(status_code=500,detail=f"COG verification failed: {e!r}") | |
| _atomic_replace(tmp_cog,out_cog) | |
| try:src_tif.unlink(missing_ok=True) | |
| except:pass | |
| def _mbtiles_xyz_to_tms(z:int,y:int)->int:return(1<<z)-1-y | |
| # def _cn_mbtiles_conn()->sqlite3.Connection: | |
| # mb=_ensure_cn_mbtiles();return sqlite3.connect(f"file:{mb}?mode=ro",uri=True,check_same_thread=False) | |
| _CN_CONN: sqlite3.Connection | None = None | |
| _CN_CONN_LK = threading.Lock() | |
| def _cn_mbtiles_conn() -> sqlite3.Connection: | |
| global _CN_CONN | |
| if _CN_CONN is not None: | |
| return _CN_CONN | |
| with _CN_CONN_LK: | |
| if _CN_CONN is not None: | |
| return _CN_CONN | |
| mb = _ensure_cn_mbtiles() | |
| _CN_CONN = sqlite3.connect(f"file:{mb}?mode=ro", uri=True, check_same_thread=False) | |
| return _CN_CONN | |
| def _mbtiles_get_tile(conn:sqlite3.Connection,z:int,x:int,y:int)->bytes|None: | |
| ty=_mbtiles_xyz_to_tms(z,y) | |
| try: | |
| cur=conn.execute("SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?",(z,x,ty)) | |
| row=cur.fetchone();return row[0]if row else None | |
| except:return None | |
| def _cn_mbtiles_maxzoom() -> int: | |
| try: | |
| conn=_cn_mbtiles_conn() | |
| cur=conn.execute("SELECT value FROM metadata WHERE name='maxzoom'") | |
| row=cur.fetchone() | |
| if row:return int(row[0]) | |
| cur2=conn.execute("SELECT MAX(zoom_level) FROM tiles") | |
| row2=cur2.fetchone();return int(row2[0])if row2 and row2[0]else 10 | |
| except:return 10 | |
| def _cn_overzoom_parent(z:int,x:int,y:int,maxz:int)->tuple[int,int,int]: | |
| if z<=maxz:return z,x,y | |
| dz=z-maxz;return maxz,x>>dz,y>>dz | |
| def _tiles_for_boxes_zoom_range(boxes:list[tuple[float,float,float,float]],zmin:int,zmax:int,cap_per_zoom:int=5000)->list[tuple[int,int,int]]: | |
| out=[] | |
| for z in range(zmin,zmax+1): | |
| n=1<<z;zset=set() | |
| for w,s,e,n_ in boxes: | |
| x0=int((w+180)/360*n);x1=int((e+180)/360*n) | |
| y0=int((1-np.log(np.tan(np.radians(n_))+1/np.cos(np.radians(n_)))/np.pi)/2*n) | |
| y1=int((1-np.log(np.tan(np.radians(s))+1/np.cos(np.radians(s)))/np.pi)/2*n) | |
| x0,x1=max(0,min(x0,n-1)),max(0,min(x1,n-1));y0,y1=max(0,min(y0,n-1)),max(0,min(y1,n-1)) | |
| if x0>x1:x0,x1=x1,x0 | |
| if y0>y1:y0,y1=y1,y0 | |
| for xx in range(x0,x1+1): | |
| for yy in range(y0,y1+1):zset.add((z,xx,yy)) | |
| for t in list(zset)[:cap_per_zoom]:out.append(t) | |
| return out | |
| def _generate_forecast_by_date_png_impl(*,z:int,x:int,y:int,date_yyyymmdd:str,hours:int,dom:str,max_in:float|None)->tuple[bytes,dict]: | |
| dom=(dom or"zz").lower();valid=_norm_ts(f"{date_yyyymmdd}05") | |
| if int(hours)==24: | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom) | |
| if not sel.get("ok"):detail=dict(sel);detail["collab_last"]=dict(_COLLAB_LAST);detail["suggestion"]="forecast data may not be available yet for this date";raise HTTPException(status_code=503,detail=detail) | |
| try:melt24_union=_forecast_melt24h_union_end_cog(sel["valid"],dom_prefer=dom) | |
| except Exception as e:raise HTTPException(status_code=503,detail={"error":"melt24_union_cog_unavailable","date":date_yyyymmdd,"hours":24,"upstream_error":repr(e)}) | |
| try:melt_mm, melt_mask, oob = _tile_arrays_from_cog(melt24_union, z, x, y, cog_cache=None) | |
| except Exception as e: | |
| _log("WARN",f"[tile_read] melt24 failed date={date_yyyymmdd} dom={dom} z={z} x={x} y={y} err={e!r}") | |
| return _transparent_png_256(),{"X-Allowed":"1","X-OOB":"1","X-Melt-Info":f"melt24_union:{melt24_union.name}","X-SnowMask-Info":"snowpack_unavailable","X-Forecast-Valid":sel["valid"],"X-Forecast-RunInit-TT":sel["run_init"],"X-Forecast-Hours":"24","X-Read-Error":"melt24"} | |
| if oob or melt_mm is None or melt_mask is None:return _transparent_png_256(),{"X-Allowed":"1","X-OOB":"1","X-Melt-Info":f"melt24_union:{melt24_union.name}","X-SnowMask-Info":"snowpack_unavailable","X-Forecast-Valid":sel["valid"],"X-Forecast-RunInit-TT":sel["run_init"],"X-Forecast-Hours":"24"} | |
| melt_valid=(melt_mask>0)&np.isfinite(melt_mm)&(melt_mm>-9990.0);melt_mask_u8=(melt_valid.astype("uint8")*255) | |
| snow=snow_valid=None;snow_info="snowpack_unavailable" | |
| if sel.get("snowpack_urls"): | |
| try: | |
| snow_union=_forecast_snowpack_union_end_cog(sel.get("snowpack_ts")or sel["valid"],dom_prefer=dom) | |
| if snow_union: | |
| try: | |
| snow_arr,snow_mask,oob2=_tile_arrays_from_cog(snow_union,z,x,y,cog_cache=COG_CACHE) | |
| if(not oob2)and(snow_arr is not None)and(snow_mask is not None):snow_valid=(snow_mask>0)&np.isfinite(snow_arr)&(snow_arr>-9990.0);snow=snow_arr;snow_info=f"snowpack_union:{snow_union.name}" | |
| except Exception as e2:_log("WARN",f"[tile_read] snowpack24 failed date={date_yyyymmdd} dom={dom} z={z} x={x} y={y} err={e2!r}");snow_info=f"snowpack_unavailable:{e2!r}" | |
| except Exception as e:snow_info=f"snowpack_unavailable:{e!r}" | |
| png=_melt_to_png(melt_mm,melt_mask_u8,max_in,snow,snow_valid,snow_allow_min_mm=0.0,snow_underlay_min_mm=0.0001,dilate_px=2,bin_edges_in=BIN_EDGES_IN) | |
| return png,{"X-Allowed":"1","X-OOB":"0","X-Melt-Info":f"melt24_union:{melt24_union.name}","X-SnowMask-Info":snow_info,"X-Forecast-Valid":sel["valid"],"X-Forecast-RunInit-TT":sel["run_init"],"X-Forecast-Hours":"24"} | |
| try: | |
| melt72_cog=_forecast_melt72h_end_cog(valid,dom_prefer=dom) | |
| except HTTPException as e: | |
| raise HTTPException(status_code=503,detail={"error":"melt72_cog_unavailable","date":date_yyyymmdd,"hours":72,"upstream_error":str(e.detail)if hasattr(e,"detail")else str(e)}) | |
| except Exception as e: | |
| raise HTTPException(status_code=503,detail={"error":"melt72_cog_unavailable","date":date_yyyymmdd,"hours":72,"upstream_error":repr(e)}) | |
| try: | |
| melt_mm,melt_mask,oob=_tile_arrays_from_cog(melt72_cog,z,x,y,cog_cache=None) | |
| except Exception as e: | |
| _log("WARN",f"[tile_read] melt72 failed date={date_yyyymmdd} dom={dom} z={z} x={x} y={y} err={e!r}") | |
| return _transparent_png_256(),{"X-Allowed":"1","X-OOB":"1","X-Forecast-Hours":"72","X-Forecast-Valid":valid,"X-Forecast-72h-COG":melt72_cog.name,"X-Read-Error":"melt72"} | |
| if oob or melt_mm is None or melt_mask is None: | |
| return _transparent_png_256(),{"X-Allowed":"1","X-OOB":"1","X-Forecast-Hours":"72","X-Forecast-Valid":valid,"X-Forecast-72h-COG":melt72_cog.name} | |
| # Snowpack overlay is optional — try run_init but don't block on it | |
| run_init=_pick_best_run_init_for_valid_t0024(valid,dom_prefer=dom) | |
| snow=snow_valid=None;snow_info="snowpack_unavailable" | |
| if run_init: | |
| snow_urls=_find_fcst_11034_grib2_tt_ts(run_init,valid,dom_prefer=dom) | |
| if snow_urls: | |
| try: | |
| snow_cogs=_build_forecast_snowpack_cogs(valid,snow_urls) | |
| snow,snow_valid,snow_info=_union_tile(snow_cogs,z,x,y,label="snowpack") | |
| except Exception as e:snow_info=f"snowpack_unavailable:{e!r}" | |
| png=_melt_to_png(melt_mm,melt_mask,max_in,snow,snow_valid,snow_allow_min_mm=0.0,snow_underlay_min_mm=0.0001,dilate_px=2,bin_edges_in=BIN_EDGES_72H_IN) | |
| return png,{"X-Allowed":"1","X-OOB":"0","X-Forecast-Hours":"72","X-Forecast-Valid":valid,"X-Forecast-RunInit-TT":run_init or"none","X-Forecast-72h-COG":melt72_cog.name,"X-SnowPack-INFO":snow_info} | |
| def _generate_forecast_by_date_png(*,z:int,x:int,y:int,date_yyyymmdd:str,hours:int,dom:str,max_in:float|None)->tuple[bytes,dict]: | |
| return _generate_forecast_by_date_png_impl(z=z,x=x,y=y,date_yyyymmdd=date_yyyymmdd,hours=hours,dom=dom,max_in=max_in) | |
| def _union_tile_cached(cogs:list[Path],z:int,x:int,y:int,*,label:str,cog_cache:CogCache,valid_pred:Optional[Callable[[np.ndarray,np.ndarray],np.ndarray]]=None)->tuple[Optional[np.ndarray],Optional[np.ndarray],str]: | |
| if not cogs:return None,None,f"{label}_no_cogs" | |
| out_arr:Optional[np.ndarray]=None;out_valid:Optional[np.ndarray]=None;used:list[str]=[] | |
| for cog in sorted(cogs,key=lambda p:_dom_pri(_cog_dom(p))): | |
| try: | |
| r=cog_cache.get(cog);data,mask=r.tile(x,y,z,tilesize=256,resampling_method="nearest") | |
| arr=data[0].astype("float32");m=mask[0];valid=(m>0)&np.isfinite(arr)&(arr>-9990.0) | |
| if valid_pred is not None: | |
| try:valid=valid&valid_pred(arr,m) | |
| except:pass | |
| if out_arr is None:out_arr=arr.copy();out_valid=valid.copy() | |
| else: | |
| fill=valid&(~out_valid) | |
| if fill.any():out_arr[fill]=arr[fill];out_valid[fill]=True | |
| used.append(cog.name) | |
| except TileOutsideBounds:continue | |
| except:continue | |
| if out_arr is None or out_valid is None:return None,None,f"{label}_unavailable_all_domains" | |
| info=f"{label}_union:"+",".join(used[:4])+("..."if len(used)>4 else"") | |
| return out_arr,out_valid,info | |
| def _log(level:str,msg:str)->None: | |
| _LOG.append({"ts":int(datetime.utcnow().timestamp()*1000),"level":level,"msg":msg}) | |
| print(f"{datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} | {level} | {msg}",flush=True) | |
| _MODE=threading.local() | |
| def _mode_get():return getattr(_MODE,"m","tiles") | |
| class _Mode: | |
| def __init__(self,m):self.m=m | |
| def __enter__(self):_MODE.m=self.m | |
| def __exit__(self,*a):_MODE.m="tiles" | |
| _SESSION=requests.Session() | |
| POOL=int(os.environ.get("HTTP_POOL_SIZE","64")) | |
| _ADAPTER=requests.adapters.HTTPAdapter(pool_connections=POOL,pool_maxsize=POOL,max_retries=0,pool_block=True) | |
| _SESSION.mount("http://",_ADAPTER);_SESSION.mount("https://",_ADAPTER) | |
| HF_PULL_ON_MISS_DEFAULT=(os.environ.get("HF_PULL_ON_MISS","1").strip()!="0") | |
| _HF_PULL_ON_MISS_LOCAL=threading.local() | |
| def _hf_pull_on_miss_enabled()->bool: | |
| v=getattr(_HF_PULL_ON_MISS_LOCAL,"enabled",None) | |
| return bool(HF_PULL_ON_MISS_DEFAULT)if v is None else bool(v) | |
| class _HfPullOnMiss: | |
| def __init__(self,enabled:bool):self.enabled=bool(enabled);self.prev=None | |
| def __enter__(self): | |
| self.prev=getattr(_HF_PULL_ON_MISS_LOCAL,"enabled",None);_HF_PULL_ON_MISS_LOCAL.enabled=self.enabled;return self | |
| def __exit__(self,*a): | |
| if self.prev is None and hasattr(_HF_PULL_ON_MISS_LOCAL,"enabled"): | |
| try:delattr(_HF_PULL_ON_MISS_LOCAL,"enabled") | |
| except:_HF_PULL_ON_MISS_LOCAL.enabled=None | |
| else:_HF_PULL_ON_MISS_LOCAL.enabled=self.prev | |
| _CPU_SEM_LOCK=threading.Lock() | |
| def _cpu_sem_limits()->tuple[int,int]: | |
| t=int(os.environ.get("CPU_TILES_MAX_INFLIGHT","12"));v=int(os.environ.get("CPU_VALUE_MAX_INFLIGHT","16"));return t,v | |
| def _cpu_set_limits(*,tiles:int|None=None,value:int|None=None)->None: | |
| global CPU_SEM_TILES,CPU_SEM_VALUE | |
| tiles0,value0=_cpu_sem_limits();t=int(tiles if tiles is not None else tiles0);v=int(value if value is not None else value0) | |
| t=max(1,t);v=max(1,v) | |
| with _CPU_SEM_LOCK:CPU_SEM_TILES=threading.BoundedSemaphore(t);CPU_SEM_VALUE=threading.BoundedSemaphore(v) | |
| class _CpuLimits: | |
| def __init__(self,*,tiles:int|None=None,value:int|None=None):pass | |
| def __enter__(self):return self | |
| def __exit__(self,*a):pass | |
| def _retry_get(url:str,*,timeout=(10,400),tries:int=6,backoff:float=1.6,headers=None,stream:bool=False,allow_redirects:bool=True)->requests.Response: | |
| hdr={"User-Agent":"snodas/1.3"} | |
| if headers:hdr.update(headers) | |
| last_exc=None | |
| for i in range(int(tries)): | |
| sem=HTTP_SEM_VALUE if _mode_get()=="value"else HTTP_SEM_TILES | |
| with sem: | |
| try:r=_SESSION.get(url,timeout=timeout,allow_redirects=allow_redirects,headers=hdr,stream=stream) | |
| except(requests.exceptions.Timeout,requests.exceptions.ConnectionError)as e:last_exc=e;r=None | |
| if r is None: | |
| if i<tries-1:time.sleep((backoff**i)+random.uniform(0,0.6));continue | |
| raise last_exc | |
| if r.status_code in(429,502,503,504): | |
| ra=r.headers.get("Retry-After") | |
| try:wait=float(ra)if ra else None | |
| except:wait=None | |
| if i<tries-1: | |
| if stream: | |
| try:r.close() | |
| except:pass | |
| time.sleep((wait if wait is not None else(backoff**i))+random.uniform(0,0.8));continue | |
| return r | |
| return r | |
| raise last_exc | |
| def _forecast_melt24h_union_cog(valid:str,dom_prefer:str="zz")->Path: | |
| dom_prefer=(dom_prefer or"zz").lower();valid=_norm_ts(valid) | |
| out=_forecast_dir()/f"{valid}_{dom_prefer}_melt24_union_cog.tif" | |
| if out.exists()and out.stat().st_size>0:return out | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom_prefer) | |
| if not sel.get("ok"):raise HTTPException(status_code=503,detail={"error":"pick_failed","valid":valid,"dom":dom_prefer,"detail":sel}) | |
| melt_cogs=_build_forecast_melt_cogs(sel["run_init"],sel["valid"],"t0024",sel["melt_urls"]) | |
| if not melt_cogs:raise HTTPException(status_code=503,detail={"error":"no_melt_cogs","valid":valid,"dom":dom_prefer}) | |
| cogs=sorted(melt_cogs,key=lambda p:_dom_pri(_cog_dom(p))) | |
| with rasterio.open(cogs[0])as base: | |
| acc=base.read(1).astype("float32");base_transform=base.transform;base_crs=base.crs;base_w,base_h=base.width,base.height | |
| acc_valid=np.isfinite(acc)&(acc>-9990.0) | |
| for p in cogs[1:]: | |
| with rasterio.open(p)as src: | |
| arr=src.read(1).astype("float32") | |
| if(src.transform!=base_transform)or(src.crs!=base_crs)or(src.width!=base_w)or(src.height!=base_h): | |
| tmp=np.full((base_h,base_w),np.nan,dtype="float32") | |
| reproject(arr,tmp,src_transform=src.transform,src_crs=src.crs,dst_transform=base_transform,dst_crs=base_crs,resampling=Resampling.nearest) | |
| arr=tmp | |
| v=np.isfinite(arr)&(arr>-9990.0);fill=v&(~acc_valid) | |
| if fill.any():acc[fill]=arr[fill];acc_valid[fill]=True | |
| tmp_tif=out.with_suffix(".tmp.tif");prof=base.profile.copy();prof.update({"compress":"DEFLATE","tiled":True}) | |
| with rasterio.open(tmp_tif,"w",**prof)as ds:ds.write(acc,1) | |
| _atomic_cog_translate(tmp_tif,out) | |
| try:_hf_enqueue_files([out]) | |
| except:pass | |
| tmp_tif.unlink(missing_ok=True) | |
| _log("INFO",f"[build_forecast] melt24_union OK valid={valid} dom_prefer={dom_prefer} -> {out.name}") | |
| return out | |
| def _forecast_snowpack_union_cog(valid:str,dom_prefer:str="zz")->Path|None: | |
| dom_prefer=(dom_prefer or"zz").lower();valid=_norm_ts(valid) | |
| out=_forecast_dir()/f"{valid}_{dom_prefer}_snow_union_cog.tif" | |
| if out.exists()and out.stat().st_size>0:return out | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom_prefer) | |
| if not sel.get("ok")or not sel.get("snowpack_urls"):return None | |
| try:snow_cogs=_build_forecast_snowpack_cogs(sel.get("snowpack_ts")or sel["valid"],sel["snowpack_urls"]) | |
| except:return None | |
| if not snow_cogs:return None | |
| cogs=sorted(snow_cogs,key=lambda p:_dom_pri(_cog_dom(p))) | |
| with rasterio.open(cogs[0])as base: | |
| acc=base.read(1).astype("float32");base_transform=base.transform;base_crs=base.crs;base_w,base_h=base.width,base.height | |
| acc_valid=np.isfinite(acc)&(acc>-9990.0) | |
| for p in cogs[1:]: | |
| with rasterio.open(p)as src: | |
| arr=src.read(1).astype("float32") | |
| if(src.transform!=base_transform)or(src.crs!=base_crs)or(src.width!=base_w)or(src.height!=base_h): | |
| tmp=np.full((base_h,base_w),np.nan,dtype="float32") | |
| reproject(arr,tmp,src_transform=src.transform,src_crs=src.crs,dst_transform=base_transform,dst_crs=base_crs,resampling=Resampling.nearest) | |
| arr=tmp | |
| v=np.isfinite(arr)&(arr>-9990.0);fill=v&(~acc_valid) | |
| if fill.any():acc[fill]=arr[fill];acc_valid[fill]=True | |
| tmp_tif=out.with_suffix(".tmp.tif");prof=base.profile.copy();prof.update({"compress":"DEFLATE","tiled":True}) | |
| with rasterio.open(tmp_tif,"w",**prof)as ds:ds.write(acc,1) | |
| _atomic_cog_translate(tmp_tif,out) | |
| try:_hf_enqueue_files([out]) | |
| except:pass | |
| tmp_tif.unlink(missing_ok=True) | |
| _log("INFO",f"[build_forecast] snow_union OK valid={valid} dom_prefer={dom_prefer} -> {out.name}") | |
| return out | |
| def _forecast_melt24h_union_end_cog(valid:str,dom_prefer:str="zz")->Path: | |
| valid=_norm_ts(valid);dom_prefer=(dom_prefer or"zz").lower() | |
| out=_forecast_dir()/f"fcst_melt24h_union_end_{valid}_{dom_prefer}_cog.tif" | |
| if out.exists()and out.stat().st_size>0:return out | |
| _hf_try_pull_file(out.relative_to(CACHE).as_posix()) | |
| if out.exists()and out.stat().st_size>0:return out | |
| with _lock(f"fcst_melt24h_union_{valid}_{dom_prefer}"): | |
| if out.exists()and out.stat().st_size>0:return out | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom_prefer) | |
| if not sel.get("ok"):raise HTTPException(status_code=503,detail={"error":"forecast_selection_unavailable_for_24h_union","valid":valid,"dom":dom_prefer,"sel":sel}) | |
| _log("INFO",f"[debug_union] valid={valid} run_init={sel['run_init']} melt_urls={sel['melt_urls']}") | |
| melt_cogs=_build_forecast_melt_cogs(sel["run_init"],sel["valid"],"t0024",sel["melt_urls"]) | |
| _log("INFO",f"[debug_union] melt_cogs built: {[c.name for c in melt_cogs]}") | |
| if not melt_cogs:raise HTTPException(status_code=503,detail={"error":"no_melt_cogs_for_24h_union","valid":valid,"dom":dom_prefer}) | |
| cogs=sorted(melt_cogs,key=lambda p:_dom_pri(_cog_dom(p))) | |
| _log("INFO",f"[debug_union] sorted cogs for union: {[c.name for c in cogs]}") | |
| with rasterio.open(cogs[0])as base: | |
| acc=base.read(1).astype("float32");acc_valid=np.isfinite(acc)&(acc>-9990.0) | |
| _log("INFO",f"[debug_union] base cog={cogs[0].name} valid_pixels={acc_valid.sum()} mean_where_valid={float(np.nanmean(acc[acc_valid])):.2f}") | |
| for c in cogs[1:]: | |
| with rasterio.open(c)as src: | |
| arr=src.read(1).astype("float32") | |
| if(src.transform!=base.transform)or(src.crs!=base.crs)or(src.width!=base.width)or(src.height!=base.height): | |
| tmp=np.full_like(acc,np.nan,dtype="float32") | |
| reproject(arr,tmp,src_transform=src.transform,src_crs=src.crs,dst_transform=base.transform,dst_crs=base.crs,resampling=Resampling.nearest) | |
| arr=tmp | |
| v=np.isfinite(arr)&(arr>-9990.0);fill=v&(~acc_valid) | |
| _log("INFO",f"[debug_union] overlay cog={c.name} valid_pixels={v.sum()} fill_pixels={fill.sum()} mean_where_valid={float(np.nanmean(arr[v])):.2f}") | |
| if fill.any():acc[fill]=arr[fill];acc_valid[fill]=True | |
| _log("INFO",f"[debug_union] final: total_valid={acc_valid.sum()} mean={float(np.nanmean(acc[acc_valid])):.2f} max={float(np.nanmax(acc[acc_valid])):.2f}") | |
| tmp_tif=out.with_suffix(".tmp.tif");prof=base.profile.copy();prof.update({"compress":"DEFLATE","tiled":True}) | |
| with rasterio.open(tmp_tif,"w",**prof)as ds:ds.write(acc,1) | |
| _atomic_cog_translate(tmp_tif,out) | |
| try:_hf_enqueue_files([out]) | |
| except:pass | |
| tmp_tif.unlink(missing_ok=True) | |
| _log("INFO",f"[build_forecast] melt24h_union OK valid={valid} dom_prefer={dom_prefer} -> {out.name}") | |
| return out | |
| def _forecast_snowpack_union_end_cog(valid:str,dom_prefer:str="zz")->Path|None: | |
| valid=_norm_ts(valid);dom_prefer=(dom_prefer or"zz").lower() | |
| out=_forecast_dir()/f"fcst_snowpack_union_end_{valid}_{dom_prefer}_cog.tif" | |
| if out.exists()and out.stat().st_size>0:return out | |
| _hf_try_pull_file(out.relative_to(CACHE).as_posix()) | |
| if out.exists()and out.stat().st_size>0:return out | |
| with _lock(f"fcst_snowpack_union_{valid}_{dom_prefer}"): | |
| if out.exists()and out.stat().st_size>0:return out | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom_prefer) | |
| if not sel.get("ok")or not sel.get("snowpack_urls"):return None | |
| try:snow_cogs=_build_forecast_snowpack_cogs(sel.get("snowpack_ts")or sel["valid"],sel["snowpack_urls"]) | |
| except:return None | |
| if not snow_cogs:return None | |
| cogs=sorted(snow_cogs,key=lambda p:_dom_pri(_cog_dom(p))) | |
| with rasterio.open(cogs[0])as base: | |
| acc=base.read(1).astype("float32");acc_valid=np.isfinite(acc)&(acc>-9990.0) | |
| for c in cogs[1:]: | |
| with rasterio.open(c)as src: | |
| arr=src.read(1).astype("float32") | |
| if(src.transform!=base.transform)or(src.crs!=base.crs)or(src.width!=base.width)or(src.height!=base.height): | |
| tmp=np.full_like(acc,np.nan,dtype="float32") | |
| reproject(arr,tmp,src_transform=src.transform,src_crs=src.crs,dst_transform=base.transform,dst_crs=base.crs,resampling=Resampling.nearest) | |
| arr=tmp | |
| v=np.isfinite(arr)&(arr>-9990.0);fill=v&(~acc_valid) | |
| if fill.any():acc[fill]=arr[fill];acc_valid[fill]=True | |
| tmp_tif=out.with_suffix(".tmp.tif");prof=base.profile.copy();prof.update({"compress":"DEFLATE","tiled":True}) | |
| with rasterio.open(tmp_tif,"w",**prof)as ds:ds.write(acc,1) | |
| _atomic_cog_translate(tmp_tif,out) | |
| try:_hf_enqueue_files([out]) | |
| except:pass | |
| tmp_tif.unlink(missing_ok=True) | |
| _log("INFO",f"[build_forecast] snowpack_union OK valid={valid} dom_prefer={dom_prefer} -> {out.name}") | |
| return out | |
| app=FastAPI(title="SNODAS Snowmelt Tiles",version="1.3.0") | |
| app.add_middleware(GZipMiddleware, minimum_size=500) | |
| app.mount("/web",StaticFiles(directory="web",html=True),name="web") | |
| def favicon():return Response(status_code=204) | |
| _TS10=re.compile(r"^\d{10}$") | |
| _TS8U2=re.compile(r"^(\d{8})[_-](\d{2})$") | |
| _HREF_RE=re.compile(r'href\s*=\s*(?P<q>[\'"])(?P<u>.*?)(?P=q)',re.I) | |
| _TS_IN_NAME=re.compile(r"(?:TS|NATS)(\d{10})",re.I) | |
| _TT_IN_NAME=re.compile(r"TT(\d{10})",re.I) | |
| def _norm_ts(ts:str)->str: | |
| ts=(ts or"").strip() | |
| if _TS10.fullmatch(ts):return ts | |
| m=_TS8U2.fullmatch(ts) | |
| if m:return f"{m.group(1)}{m.group(2)}" | |
| raise HTTPException(status_code=404,detail="unknown forecast key") | |
| US_BORDER_LAT=49.0 | |
| def _tile2lon(x:int,z:int)->float:return(float(x)/(1<<int(z)))*360.0-180.0 | |
| def _tile2lat(y:int,z:int)->float: | |
| n=np.pi-(2.0*np.pi*float(y)/(1<<int(z)));return(180.0/np.pi)*np.arctan(0.5*(np.exp(n)-np.exp(-n))) | |
| def _tile_bounds(z:int,x:int,y:int)->tuple[float,float,float,float]: | |
| z=int(z);x=int(x);y=int(y);return _tile2lon(x,z),_tile2lat(y+1,z),_tile2lon(x+1,z),_tile2lat(y,z) | |
| def _box_intersects(a,b)->bool: | |
| aw,as_,ae,an=a;bw,bs,be,bn=b;return not(ae<bw or be<aw or an<bs or bn<as_) | |
| def _tile_allowed(z:int,x:int,y:int)->bool: | |
| z=int(z);x=int(x);y=int(y);n=1<<z | |
| if y<0 or y>=n or x<0 or x>=n:return True | |
| tb=_tile_bounds(z,x,y);box_a=(-170.0,49.0,-40.0,90.0);box_b=(-97.0,37.0,-63.0,49.0) | |
| return _box_intersects(tb,box_a)or _box_intersects(tb,box_b) | |
| HTTP_SEM_TILES=threading.BoundedSemaphore(int(os.environ.get("HTTP_TILES_MAX_INFLIGHT","24"))) | |
| HTTP_SEM_VALUE=threading.BoundedSemaphore(int(os.environ.get("HTTP_VALUE_MAX_INFLIGHT","12"))) | |
| _cpu_set_limits() | |
| def _transparent_png_256()->bytes: | |
| rgb=np.zeros((3,256,256),dtype="uint8");a=np.zeros((256,256),dtype="uint8");return render(rgb,mask=a,img_format="PNG") | |
| def _zero_raw_npz_256()->bytes: | |
| return _pack_raw_tile_npz(melt_mm=np.zeros((256,256),dtype="float32"),valid_mask_u8=np.zeros((256,256),dtype="uint8")) | |
| _REMOTE_KEEP_DAYS=7 | |
| def _hf_cfg()->tuple[Optional[str],Optional[str]]: | |
| tok=(os.environ.get("HF_TOKEN")or"").strip();repo=(os.environ.get("HF_DATASET_REPO")or"").strip() | |
| return(tok,repo)if tok and repo else(None,None) | |
| _HF_API_SINGLETON:Optional[HfApi]=None | |
| def _hf_api()->Optional[HfApi]: | |
| global _HF_API_SINGLETON | |
| tok,repo=_hf_cfg() | |
| if not tok or not repo:return None | |
| if _HF_API_SINGLETON is None:_HF_API_SINGLETON=HfApi() | |
| return _HF_API_SINGLETON | |
| def _date_ymd_utc(d:datetime.date|None=None)->str: | |
| if d is None:d=datetime.utcnow().date() | |
| return d.strftime("%Y%m%d") | |
| # def _keep_cutoff_ymd(keep_days:int=_REMOTE_KEEP_DAYS)->str: | |
| # return(datetime.utcnow().date()-timedelta(days=int(keep_days)-1)).strftime("%Y%m%d") | |
| def _file_ymd_from_name(name:str)->Optional[str]: | |
| m=re.search(r"(?:^|_)(\d{8})(?:_|\.|$)",name);return m.group(1)if m else None | |
| def _is_cache_file_we_manage(rel:str)->bool: | |
| n=rel.replace("\\","/") | |
| if n.startswith("forecast/")and n.endswith("_cog.tif"):return True | |
| if n.startswith("tilecache/")and n.endswith(".png"):return True | |
| if n.startswith("rawtilecache/")and n.endswith(".npz"):return True | |
| base=n.rsplit("/",1)[-1] | |
| if re.fullmatch(r"bydate_[a-z0-9]+_h\d+_\d{8}_z\d+_\d+_\d+\.png",base):return True | |
| if re.fullmatch(r"bydate_[a-z0-9]+_h\d+_\d{8}_z\d+_\d+_\d+\.npz",base):return True | |
| return False | |
| def _local_cache_paths_to_consider()->list[Path]: | |
| out:list[Path]=[] | |
| for p in CACHE.glob("melt24h_*_cog.tif"):out.append(p) | |
| for p in CACHE.glob("melt72h_end_*_cog.tif"):out.append(p) | |
| fc=_forecast_dir() | |
| if fc.exists(): | |
| for p in fc.glob("*_cog.tif"):out.append(p) | |
| return out | |
| def _is_lfs_pointer_bytes(b:bytes)->bool: | |
| if not b:return False | |
| if b.startswith(b"version https://git-lfs.github.com/spec/v1"):return True | |
| if b"git-lfs.github.com/spec/v1"in b[:200]:return True | |
| return False | |
| def _hf_push_baked_as_tar(baked_ymds:list[str],dom:str,hours_list:list[int])->dict: | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if not tok or not repo or api is None:return{"ok":False,"error":"hf_not_configured"} | |
| dom=(dom or"zz").lower();pushed=0;errors=0;archives_pushed=0 | |
| for ymd in baked_ymds: | |
| for h in hours_list: | |
| for kind in["tilecache","rawtilecache"]: | |
| base=CACHE/kind/dom/str(int(h))/ymd | |
| if not base.exists():continue | |
| while _PUSH_PAUSED.is_set():time.sleep(2.0) | |
| ext=".png"if kind=="tilecache"else".npz";min_sz=64 if kind=="tilecache"else 60 | |
| files=[] | |
| for p in base.rglob(f"*{ext}"): | |
| try: | |
| if p.stat().st_size<min_sz:continue | |
| files.append((p,p.relative_to(CACHE).as_posix())) | |
| except:pass | |
| if not files:continue | |
| _log("INFO",f"[hf_push_tar] {kind}/{dom}/h{h}/{ymd}: archiving {len(files)} files") | |
| tar_buf=io.BytesIO() | |
| try: | |
| with tarfile.open(fileobj=tar_buf,mode="w:gz",compresslevel=1)as tar: | |
| for lp,rp in files: | |
| try:tar.add(str(lp),arcname=rp) | |
| except:pass | |
| except Exception as e:_log("WARN",f"[hf_push_tar] tar failed {kind}/{ymd}/h{h}: {e!r}");errors+=len(files);continue | |
| tar_bytes=tar_buf.getvalue();tar_mb=len(tar_bytes)/(1024*1024) | |
| _log("INFO",f"[hf_push_tar] {kind}/{dom}/h{h}/{ymd}: {tar_mb:.1f}MB ({len(files)} files)") | |
| op=CommitOperationAdd(path_in_repo=f"archives/{kind}_{dom}_h{h}_{ymd}.tar.gz",path_or_fileobj=tar_bytes) | |
| ok=False | |
| for attempt in range(3): | |
| while _PUSH_PAUSED.is_set():time.sleep(2.0) | |
| t0=time.time() | |
| try: | |
| api.create_commit(repo_id=repo,repo_type="dataset",operations=[op],commit_message=f"{kind}: {ymd} h{h} ({len(files)} files as tar.gz)") | |
| pushed+=len(files);archives_pushed+=1;ok=True | |
| _log("INFO",f"[hf_push_tar] {kind}/{dom}/h{h}/{ymd}: pushed {tar_mb:.1f}MB in {time.time()-t0:.1f}s");break | |
| except Exception as e: | |
| elapsed=time.time()-t0;err_str=repr(e) | |
| if"ReadTimeout"in err_str and elapsed>=9.0: | |
| _log("INFO",f"[hf_push_tar] {kind}/{dom}/h{h}/{ymd}: timeout after {elapsed:.1f}s — likely succeeded");pushed+=len(files);archives_pushed+=1;ok=True;break | |
| _log("WARN",f"[hf_push_tar] {kind}/{dom}/h{h}/{ymd} attempt {attempt+1}: {type(e).__name__} after {elapsed:.1f}s") | |
| if attempt<2:time.sleep(10.0*(attempt+1)) | |
| if not ok:errors+=len(files) | |
| del tar_bytes,tar_buf | |
| _log("INFO",f"[hf_push_tar] done: pushed={pushed} errors={errors} archives={archives_pushed}") | |
| return{"ok":errors==0,"pushed":pushed,"errors":errors,"archives":archives_pushed} | |
| def _hf_pull_tar_archives()->int: | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if not tok or not repo or api is None:return 0 | |
| window=set(_rolling_dates_local_window(lookback=2,lookahead=3)) | |
| _log("INFO",f"[hf_pull_tar] window={sorted(window)}") | |
| try: | |
| items=list(api.list_repo_tree(repo_id=repo,repo_type="dataset",path_in_repo="archives/",recursive=False)) | |
| archive_paths=[getattr(it,"path","")for it in items if getattr(it,"path","").endswith(".tar.gz")] | |
| except Exception as e:_log("WARN",f"[hf_pull_tar] list failed: {e!r}");return 0 | |
| if not archive_paths:_log("INFO","[hf_pull_tar] no archives found");return 0 | |
| _log("INFO",f"[hf_pull_tar] found {len(archive_paths)} archives") | |
| pulled=0;staging=CACHE/"_tar_staging";staging.mkdir(parents=True,exist_ok=True) | |
| marker_dir=CACHE/"_tar_markers";marker_dir.mkdir(parents=True,exist_ok=True) | |
| for ap in archive_paths: | |
| name=ap.rsplit("/",1)[-1];m=re.search(r"_(\d{8})\.tar\.gz$",name) | |
| if not m:continue | |
| ymd=m.group(1) | |
| if ymd not in window:continue | |
| marker=marker_dir/f"{name}.done" | |
| if marker.exists():continue | |
| parts=name.replace(".tar.gz","").split("_") | |
| if len(parts)>=4: | |
| edir=CACHE/parts[0]/parts[1]/parts[2].replace("h","")/parts[3] | |
| if edir.exists(): | |
| ext="*.png"if parts[0]=="tilecache"else"*.npz" | |
| try: | |
| cnt=sum(1 for _ in edir.rglob(ext)) | |
| if cnt>100:_log("INFO",f"[hf_pull_tar] skip {name}: {cnt} local files");marker.write_text(f"skipped:{cnt}");continue | |
| except:pass | |
| _log("INFO",f"[hf_pull_tar] downloading {ap}");t0=time.time() | |
| try: | |
| local=hf_hub_download(repo_id=repo,repo_type="dataset",filename=ap,token=tok,local_dir=str(staging)) | |
| lp=Path(local) | |
| if not lp.exists():continue | |
| with lp.open("rb")as f:magic=f.read(2) | |
| if magic!=b"\x1f\x8b":_log("WARN",f"[hf_pull_tar] {name}: not gzip");lp.unlink(missing_ok=True);continue | |
| with tarfile.open(str(lp),mode="r:gz")as tar: | |
| safe=[m for m in tar.getmembers()if not m.name.startswith("/")and".."not in m.name] | |
| tar.extractall(path=str(CACHE),members=safe);pulled+=len(safe) | |
| _log("INFO",f"[hf_pull_tar] unpacked {name}: {len(safe)} files in {time.time()-t0:.1f}s") | |
| marker.write_text(f"unpacked:{len(safe)}:ts:{time.time()}") | |
| try:lp.unlink(missing_ok=True) | |
| except:pass | |
| except Exception as e:_log("WARN",f"[hf_pull_tar] failed {ap}: {e!r}") | |
| try:shutil.rmtree(staging,ignore_errors=True) | |
| except:pass | |
| _log("INFO",f"[hf_pull_tar] complete: {pulled} files");return pulled | |
| # def _hf_pull_cache()->None: | |
| # tok,repo=_hf_cfg() | |
| # if not tok or not repo:_log("INFO","[hf] pull cache disabled");return | |
| # window=_rolling_dates_local_window(lookback=1,lookahead=3);dom=(WARM_DOM or"zz").lower() | |
| # _log("INFO",f"[hf] pull COGs for window={window}");pulled=0 | |
| # for ymd in window: | |
| # for name in[f"forecast/fcst_melt24h_union_end_{ymd}05_{dom}_cog.tif",f"forecast/fcst_melt72h_end_{ymd}05_{dom}_cog.tif",f"forecast/fcst_snowpack_union_end_{ymd}05_{dom}_cog.tif"]: | |
| # dest=CACHE/name | |
| # if dest.exists()and dest.stat().st_size>1000:continue | |
| # try:hf_hub_download(repo_id=repo,repo_type="dataset",token=tok,filename=name,local_dir=str(CACHE));pulled+=1 | |
| # except:pass | |
| # _log("INFO",f"[hf] pull COGs done: {pulled}") | |
| # _log("INFO","[hf] pull tar archives") | |
| # try:pt=_hf_pull_tar_archives();_log("INFO",f"[hf] pull tar archives done: {pt} files") | |
| # except Exception as e:_log("WARN",f"[hf] pull tar archives failed: {e!r}") | |
| def _hf_pull_cache()->None: | |
| tok,repo=_hf_cfg() | |
| if not tok or not repo:_log("INFO","[hf] pull cache disabled");return | |
| window=_rolling_dates_local_window(lookback=1,lookahead=3);dom=(WARM_DOM or"zz").lower() | |
| _log("INFO",f"[hf] pull COGs for window={window}");pulled=0 | |
| for ymd in window: | |
| # Try both 11z and 05z COGs — staleness monitor may have upgraded to 11z | |
| for valid_suffix in ["11", "05"]: | |
| for prefix in ["fcst_melt24h_union_end", "fcst_melt72h_end", "fcst_snowpack_union_end"]: | |
| name = f"forecast/{prefix}_{ymd}{valid_suffix}_{dom}_cog.tif" | |
| dest = CACHE / name | |
| if dest.exists() and dest.stat().st_size > 1000: | |
| continue | |
| try: | |
| hf_hub_download(repo_id=repo, repo_type="dataset", token=tok, filename=name, local_dir=str(CACHE)) | |
| pulled += 1 | |
| except: | |
| pass | |
| _log("INFO",f"[hf] pull COGs done: {pulled}") | |
| _log("INFO","[hf] pull tar archives") | |
| try:pt=_hf_pull_tar_archives();_log("INFO",f"[hf] pull tar archives done: {pt} files") | |
| except Exception as e:_log("WARN",f"[hf] pull tar archives failed: {e!r}") | |
| def _hf_commit(ops:list,message:str)->None: | |
| api=_hf_api();tok,repo=_hf_cfg() | |
| if api is None or not repo or not ops:return | |
| api.create_commit(repo_id=repo,repo_type="dataset",operations=ops,commit_message=message) | |
| def _hf_commit_with_backoff(ops:list,message:str)->None: | |
| api=_hf_api();tok,repo=_hf_cfg() | |
| if api is None or not repo or not ops:return | |
| for i in range(6): | |
| try: | |
| api.create_commit(repo_id=repo,repo_type="dataset",operations=ops,commit_message=message) | |
| if HF_PUSH_SLEEP_SEC>0:time.sleep(HF_PUSH_SLEEP_SEC) | |
| return | |
| except Exception as e: | |
| s=repr(e).lower();is_rate=("429"in s)or("rate"in s and"limit"in s)or("too many requests"in s) | |
| if(i<5)and is_rate:time.sleep(2.0*(2**i)+random.uniform(0,0.25));continue | |
| raise | |
| _TILE_Z_RE=re.compile(r"_z(\d+)_") | |
| def _hf_push_tilecache_for_window(window:list[str],dom:str,hours_list:list[int],zmin:int|None=None,zmax:int|None=None)->dict: | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if not tok or not repo or api is None:return{"ok":False,"error":"hf_not_configured"} | |
| dom=(dom or"zz").lower();root=_tile_cache_dir() | |
| if not root.exists():return{"ok":True,"pushed":0,"skipped":0} | |
| want_dates=set(window);want_hours=set(int(h)for h in hours_list) | |
| ops:list[CommitOperationAdd]=[];pushed=0;skipped=0 | |
| for p in root.rglob("bydate_*.png"): | |
| name=p.name;m=re.match(r"^bydate_([a-z0-9]+)_h(\d+)_(\d{8})_z\d+_\d+_\d+\.png$",name) | |
| if not m:continue | |
| f_dom,f_hours,f_ymd=m.group(1),int(m.group(2)),m.group(3) | |
| if f_dom!=dom or f_hours not in want_hours or f_ymd not in want_dates:continue | |
| mz=_TILE_Z_RE.search(name);z=int(mz.group(1))if mz else None | |
| if z is not None and zmin is not None and z<int(zmin):continue | |
| if z is not None and zmax is not None and z>int(zmax):continue | |
| if not _is_valid_png_file(p):skipped+=1;continue | |
| ops.append(CommitOperationAdd(path_in_repo=f"tilecache/{name}",path_or_fileobj=p.as_posix())) | |
| if len(ops)>=int(HF_MAX_OPS_PER_COMMIT): | |
| api.create_commit(repo_id=repo,repo_type="dataset",token=tok,operations=ops,commit_message=f"tilecache: update dom={dom} files={len(ops)}") | |
| pushed+=len(ops);ops=[] | |
| if HF_PUSH_SLEEP_SEC>0:time.sleep(float(HF_PUSH_SLEEP_SEC)) | |
| if ops: | |
| api.create_commit(repo_id=repo,repo_type="dataset",token=tok,operations=ops,commit_message=f"tilecache: update dom={dom} files={len(ops)}") | |
| pushed+=len(ops) | |
| return{"ok":True,"pushed":pushed,"skipped":skipped,"dom":dom} | |
| def _hf_push_files(paths:list[Path])->None: | |
| tok,repo=_hf_cfg() | |
| if not tok or not repo:return | |
| ops=[] | |
| for p in paths: | |
| try: | |
| if not p.exists()or p.stat().st_size<=0:continue | |
| rel=p.relative_to(CACHE).as_posix() | |
| if not _is_cache_file_we_manage(rel):continue | |
| ops.append(CommitOperationAdd(path_in_repo=rel,path_or_fileobj=p.as_posix())) | |
| except:continue | |
| try: | |
| _hf_commit(ops,message=f"cache: add/update {len(ops)} files") | |
| if ops:_log("INFO",f"[hf_cache] push OK files={len(ops)}") | |
| except Exception as e:_log("INFO",f"[hf_cache] push FAIL err={e!r}") | |
| def _hf_prune_remote_and_local(keep_days:int=_REMOTE_KEEP_DAYS,*,keep_ymds:set[str]|None=None)->None: | |
| if keep_ymds is None:cutoff=_keep_cutoff_ymd(keep_days) | |
| else:cutoff=min(keep_ymds)if keep_ymds else datetime.now(LOCAL_TZ).date().strftime("%Y%m%d") | |
| _log("INFO",f"[hf_prune] keep_ymds={sorted(keep_ymds)if keep_ymds else'None'} cutoff={cutoff}") | |
| local_dirs=0 | |
| for kind in["tilecache","rawtilecache"]: | |
| root=CACHE/kind | |
| if not root.exists():continue | |
| for dom_dir in root.iterdir(): | |
| if not dom_dir.is_dir():continue | |
| for hours_dir in dom_dir.iterdir(): | |
| if not hours_dir.is_dir():continue | |
| for ymd_dir in hours_dir.iterdir(): | |
| if not ymd_dir.is_dir():continue | |
| ymd=ymd_dir.name | |
| if not re.fullmatch(r"[0-9]{8}",ymd):continue | |
| if(keep_ymds and ymd not in keep_ymds)or(not keep_ymds and ymd<cutoff): | |
| shutil.rmtree(ymd_dir,ignore_errors=True);local_dirs+=1 | |
| local_files=0 | |
| try: | |
| fc=_forecast_dir() | |
| if fc.exists(): | |
| for p in fc.glob("*_cog.tif"): | |
| m=re.search(r"([0-9]{8})",p.name) | |
| if not m:continue | |
| ymd=m.group(1) | |
| if(keep_ymds and ymd not in keep_ymds)or(not keep_ymds and ymd<cutoff):_safe_unlink(p);local_files+=1 | |
| except:pass | |
| local_markers=0;marker_dir=CACHE/"_tar_markers" | |
| if marker_dir.exists(): | |
| for mk in marker_dir.glob("*.done"): | |
| m=re.search(r"_(\d{8})\.tar\.gz\.done$",mk.name) | |
| if not m:continue | |
| ymd=m.group(1) | |
| if(keep_ymds and ymd not in keep_ymds)or(not keep_ymds and ymd<cutoff): | |
| try:mk.unlink(missing_ok=True);local_markers+=1 | |
| except:pass | |
| _log("INFO",f"[hf_prune] local done: dirs={local_dirs} files={local_files} markers={local_markers}") | |
| api=_hf_api();tok,repo=_hf_cfg() | |
| if api is None or not repo:return | |
| dom=(WARM_DOM or"zz").lower();folder_ops=[] | |
| for kind in["tilecache","rawtilecache"]: | |
| for h in WARM_HOURS_LIST: | |
| prefix=f"{kind}/{dom}/{h}/" | |
| try: | |
| items=list(api.list_repo_tree(repo_id=repo,repo_type="dataset",path_in_repo=prefix,recursive=False)) | |
| for item in items: | |
| name=getattr(item,"path","").rsplit("/",1)[-1]if hasattr(item,"path")else"" | |
| if not re.fullmatch(r"[0-9]{8}",name):continue | |
| if(keep_ymds and name not in keep_ymds)or(not keep_ymds and name<cutoff): | |
| folder_ops.append(CommitOperationDelete(path_in_repo=f"{prefix}{name}/"));_log("INFO",f"[hf_prune] will delete dir: {prefix}{name}/") | |
| except:pass | |
| try: | |
| items=list(api.list_repo_tree(repo_id=repo,repo_type="dataset",path_in_repo="archives/",recursive=False)) | |
| for item in items: | |
| fname=getattr(item,"path","").rsplit("/",1)[-1]if hasattr(item,"path")else"" | |
| if not fname.endswith(".tar.gz"):continue | |
| m=re.search(r"_(\d{8})\.tar\.gz$",fname) | |
| if not m:continue | |
| ymd=m.group(1) | |
| if(keep_ymds and ymd not in keep_ymds)or(not keep_ymds and ymd<cutoff): | |
| folder_ops.append(CommitOperationDelete(path_in_repo=f"archives/{fname}"));_log("INFO",f"[hf_prune] will delete archive: archives/{fname}") | |
| except:pass | |
| try: | |
| items=list(api.list_repo_tree(repo_id=repo,repo_type="dataset",path_in_repo="forecast/",recursive=False)) | |
| for item in items: | |
| fname=getattr(item,"path","").rsplit("/",1)[-1]if hasattr(item,"path")else"" | |
| m=re.search(r"([0-9]{8})",fname) | |
| if not m:continue | |
| ymd=m.group(1) | |
| if(keep_ymds and ymd not in keep_ymds)or(not keep_ymds and ymd<cutoff): | |
| folder_ops.append(CommitOperationDelete(path_in_repo=f"forecast/{fname}")) | |
| except:pass | |
| if not folder_ops:_log("INFO","[hf_prune] remote: nothing to delete");return | |
| _log("INFO",f"[hf_prune] remote: deleting {len(folder_ops)} items");deleted=0 | |
| for op in folder_ops: | |
| for attempt in range(3): | |
| try: | |
| api.create_commit(repo_id=repo,repo_type="dataset",operations=[op],commit_message=f"prune: delete {op.path_in_repo}") | |
| _log("INFO",f"[hf_prune] deleted: {op.path_in_repo}");deleted+=1;time.sleep(1.0);break | |
| except Exception as e: | |
| err_str=repr(e) | |
| if"404"in err_str or"EntryNotFound"in err_str or"does not exist"in err_str:_log("INFO",f"[hf_prune] {op.path_in_repo}: already deleted");deleted+=1;break | |
| if"ReadTimeout"in err_str and attempt==0:_log("INFO",f"[hf_prune] {op.path_in_repo}: timeout (likely succeeded)");deleted+=1;time.sleep(2.0);break | |
| _log("WARN",f"[hf_prune] {op.path_in_repo} attempt {attempt+1} failed: {e!r}");time.sleep(5.0*(attempt+1)) | |
| _log("INFO",f"[hf_prune] remote done: deleted {deleted}/{len(folder_ops)}") | |
| def _ts_to_dt(ts10:str)->datetime:return datetime.strptime(ts10,"%Y%m%d%H").replace(tzinfo=timezone.utc) | |
| def _yyyymmdd(d)->str:return d.strftime("%Y%m%d") | |
| def _track_points_path()->Path: | |
| for p in[Path("web")/"cn_track_points.npz",Path("cn_track_points.npz"),Path("cache")/"cn_track_points.npz"]: | |
| if p.exists():return p | |
| return Path("web")/"cn_track_points.npz" | |
| def _load_track_points(max_points:int=35000)->list[tuple[float,float]]: | |
| p=_track_points_path() | |
| if not p.exists():_log("INFO",f"[prewarm] cn_track_points.npz not found at {p}");return[] | |
| try: | |
| data=np.load(str(p));arr=data["lonlat"] | |
| if arr.ndim!=2 or arr.shape[1]!=2:_log("INFO",f"[prewarm] cn_track_points.npz has unexpected shape {arr.shape}");return[] | |
| arr=arr.astype(np.float64,copy=False) | |
| except Exception as e:_log("INFO",f"[prewarm] failed to read cn_track_points.npz err={e!r}");return[] | |
| if arr.shape[0]==0:return[] | |
| if arr.shape[0]>int(max_points):step=max(1,int(arr.shape[0])//int(max_points));arr=arr[::step] | |
| return[(float(lon),float(lat))for lon,lat in arr] | |
| def _lonlat_to_tile(lon:float,lat:float,z:int)->tuple[int,int]: | |
| lat=max(-85.05112878,min(85.05112878,float(lat)));lon=float(lon);n=2**int(z) | |
| x=int((lon+180.0)/360.0*n);lat_rad=np.deg2rad(lat) | |
| y=int((1.0-np.log(np.tan(lat_rad)+1.0/np.cos(lat_rad))/np.pi)/2.0*n) | |
| return max(0,min(n-1,x)),max(0,min(n-1,y)) | |
| def _collab_dirlist(ttl_sec:float=240.0)->str: | |
| now=datetime.utcnow().timestamp() | |
| if _DIRLIST["txt"]and(now-float(_DIRLIST["ts"]))<float(ttl_sec):return _DIRLIST["txt"] | |
| url=COLLAB_BASE+"/" | |
| try: | |
| r=_retry_get(url,timeout=(10,300),tries=3,headers={"User-Agent":"snodas-forecast/1.3"}) | |
| _COLLAB_LAST.update({"ts":now,"status":r.status_code,"error":None,"len":len(r.text or"")}) | |
| if r.status_code==200 and(r.text or"").strip(): | |
| _DIRLIST["ts"]=now;_DIRLIST["txt"]=r.text or"";return _DIRLIST["txt"] | |
| _COLLAB_LAST["error"]=f"HTTP {r.status_code} (empty={not bool((r.text or'').strip())})";_log("INFO",f"[collab] dirlist bad: {_COLLAB_LAST['error']}");return"" | |
| except Exception as e:_COLLAB_LAST.update({"ts":now,"status":None,"error":repr(e),"len":0});_log("INFO",f"[collab] dirlist EXC: {repr(e)}");return"" | |
| def _hrefs(html:str)->list[str]:return[m.group("u").strip()for m in _HREF_RE.finditer(html or"")if(m.group("u")or"").strip()] | |
| def _ts_from_name(name:str)->Optional[str]:m=_TS_IN_NAME.search(Path(name).name);return m.group(1)if m else None | |
| def _tt_from_name(name:str)->Optional[str]:m=_TT_IN_NAME.search(Path(name).name);return m.group(1)if m else None | |
| def _dom_from_url(url:str)->str:n=Path(url).name.lower();return(n.split("_",1)[0]if"_"in n else n[:2]).strip()or"xx" | |
| def _dom_pri(dom:str)->int: | |
| d=(dom or"").lower() | |
| if d=="zz":return 0 | |
| if d=="dp":return 1 | |
| if d=="qc":return 2 | |
| if d=="us":return 3 | |
| return 9 | |
| def _as_url(n:str)->str:return n if n.startswith("http")else f"{COLLAB_BASE}/{n}" | |
| def _score_dom(url:str,dom_prefer:str)->int:d=_dom_from_url(url);return 0 if d==dom_prefer else(_dom_pri(d)+10) | |
| def _valid_ts_for_days(days:int,now_utc:Optional[datetime]=None)->str: | |
| now_utc=now_utc or datetime.utcnow().replace(tzinfo=timezone.utc) | |
| return f"{(now_utc.date()+timedelta(days=int(days))).strftime('%Y%m%d')}05" | |
| def _is_data_name(low:str)->bool:low=low.lower();return low.endswith(".nc")or low.endswith(".grz")or low.endswith(".grib2") | |
| def _list_available_valid_ts_t0024(dom_prefer:str="zz")->list[str]: | |
| dom_prefer=(dom_prefer or"zz").lower();html=_collab_dirlist();names=_hrefs(html);ts_set:set[str]=set() | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if"ssmv11044"not in low or"__t0024tt"not in low or not low.endswith(".nc"):continue | |
| ts=_ts_from_name(base) | |
| if ts and _TS10.fullmatch(ts):ts_set.add(ts) | |
| return sorted(ts_set) | |
| def _lon0360_to_180(x:np.ndarray)->np.ndarray:return((x+180.0)%360.0)-180.0 | |
| def _best_state_nc_near_valid(code:str,valid:str,dom_prefer:str="zz",max_after_h:float=18.0,max_before_h:float=240.0)->dict: | |
| code=(code or"").lower();dom_prefer=(dom_prefer or"zz").lower();valid=_norm_ts(valid) | |
| html=_collab_dirlist();names=_hrefs(html);cands=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if code not in low or"__t0001ttnats"not in low or"hp001"not in low or not low.endswith(".nc"):continue | |
| ts=_ts_from_name(base) | |
| if not ts or not _TS10.fullmatch(ts):continue | |
| url=_as_url(base) | |
| try:dh=(_ts_to_dt(ts)-_ts_to_dt(valid)).total_seconds()/3600.0 | |
| except:continue | |
| cands.append((dh,ts,_dom_from_url(url),url)) | |
| if not cands:return{"picked_ts":None,"url":None,"delta_h":None} | |
| preferred=[r for r in cands if(r[2]or"").lower()==dom_prefer];pool=preferred if preferred else cands | |
| before=[r for r in pool if r[0]<=0];after=[r for r in pool if r[0]>0];pick=None | |
| if before:before.sort(key=lambda t:abs(t[0]));pick=before[0]if abs(before[0][0])<=float(max_before_h)else None | |
| if pick is None and after:after.sort(key=lambda t:t[0]);pick=after[0]if after[0][0]<=float(max_after_h)else None | |
| if pick is None:pool.sort(key=lambda t:abs(t[0]));pick=pool[0] | |
| dh,ts,dom,url=pick;return{"picked_ts":ts,"url":url,"delta_h":dh,"dom":dom} | |
| def _find_fcst_11034_grib2_tt_ts(run_init:str,valid:str,dom_prefer:str="zz")->list[str]: | |
| run_init=_norm_ts(run_init);valid=_norm_ts(valid);dom_prefer=(dom_prefer or"zz").lower() | |
| html=_collab_dirlist();names=_hrefs(html);want_tt=f"tt{run_init}";want_ts=f"ts{valid}";out=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if"ssmv11034"not in low or"__t0001tt"not in low or"hp001"not in low:continue | |
| if want_tt not in low or want_ts not in low or not low.endswith(".grib2"):continue | |
| out.append(_as_url(base)) | |
| return sorted(set(out),key=lambda u:(0 if _dom_from_url(u)==dom_prefer else _dom_pri(_dom_from_url(u))+10)) | |
| def _pick_best_run_init_for_valid_t0024(valid:str,dom_prefer:str="zz")->Optional[str]: | |
| valid=_norm_ts(valid);dom_prefer=(dom_prefer or"zz").lower();html=_collab_dirlist();names=_hrefs(html) | |
| want_ts=f"ts{valid}";rows=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if"ssmv11044"not in low or"__t0024tt"not in low or want_ts not in low or not low.endswith(".nc"):continue | |
| tt=_tt_from_name(base) | |
| if not tt:continue | |
| rows.append((tt,_score_dom(_as_url(base),dom_prefer))) | |
| if not rows:return None | |
| rows.sort(key=lambda t:(t[0],-t[1]));return rows[-1][0] | |
| def _find_melt_11044_t0024(run_init:str,valid:str,dom_prefer:str="zz")->list[str]: | |
| run_init=_norm_ts(run_init);valid=_norm_ts(valid);dom_prefer=(dom_prefer or"zz").lower() | |
| html=_collab_dirlist();names=_hrefs(html);want_tt=f"tt{run_init}";want_ts=f"ts{valid}";out=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if"ssmv11044"not in low or"__t0024tt"not in low or want_tt not in low or want_ts not in low or not low.endswith(".nc"):continue | |
| out.append(_as_url(base)) | |
| return sorted(set(out),key=lambda u:(0 if _dom_from_url(u)==dom_prefer else _dom_pri(_dom_from_url(u))+10)) | |
| def _pick_forecast_for_valid(valid:str,dom_prefer:str="zz")->dict: | |
| dom_prefer=(dom_prefer or"zz").lower() | |
| # Extract date from valid (could be YYYYMMDD05 or YYYYMMDD11 or just YYYYMMDD) | |
| if len(valid)>=8: | |
| date_ymd=valid[:8] | |
| else: | |
| date_ymd=valid | |
| best=_find_best_melt_for_date(date_ymd,dom_prefer=dom_prefer) | |
| if not best.get("ok"): | |
| _log("DEBUG",f"[pick_forecast] no melt found for {date_ymd} dom={dom_prefer}: {best.get('error','unknown')}") | |
| return{"ok":False,"valid":valid,"error":best.get("error","no melt files found")} | |
| snowpack_urls=_find_snowpack_for_best_melt(best,dom_prefer=dom_prefer) | |
| actual_valid=best["valid"] | |
| return{ | |
| "ok":True, | |
| "run_init":best["run_init"], | |
| "valid":actual_valid, | |
| "lead":"t0024", | |
| "melt_urls":best["melt_urls"], | |
| "snowpack_ts":actual_valid, | |
| "snowpack_urls":snowpack_urls or[], | |
| "snowpack_missing":not bool(snowpack_urls), | |
| "source_type":best["source_type"], | |
| } | |
| def _pick_forecast_for_days(days:int,dom_prefer:str="zz")->dict: | |
| if days not in(2,3):raise HTTPException(status_code=400,detail="days must be 2 or 3") | |
| return{"ok":True,"run_init":run_init,"valid":valid,"lead":"t0024","melt_urls":melt_urls,"snowpack_ts":valid,"snowpack_urls":snowpack_urls or[],"snowpack_missing":not bool(snowpack_urls)} | |
| now=datetime.utcnow().replace(tzinfo=timezone.utc);valid=_valid_ts_for_days(days,now_utc=now) | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=(dom_prefer or"zz").lower()) | |
| if sel.get("ok"):sel["days"]=days;sel["now_utc"]=now.isoformat() | |
| return sel | |
| def _find_melt_11044_ttna(valid:str,dom_prefer:str="zz")->list[str]: | |
| """Find TTNA (analysis) melt files for a given valid time.""" | |
| valid=_norm_ts(valid);dom_prefer=(dom_prefer or"zz").lower() | |
| html=_collab_dirlist();names=_hrefs(html);want_ts=f"ts{valid}";out=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if"ssmv11044"not in low:continue | |
| if"__t0024ttnats"not in low:continue | |
| if want_ts not in low:continue | |
| if not low.endswith(".nc"):continue | |
| out.append(_as_url(base)) | |
| return sorted(set(out),key=lambda u:(0 if _dom_from_url(u)==dom_prefer else _dom_pri(_dom_from_url(u))+10)) | |
| def _find_snowpack_for_valid(valid:str,dom_prefer:str="zz")->list[str]: | |
| """Find snowpack files for a given valid time, checking both TTNA and forecast patterns.""" | |
| valid=_norm_ts(valid);dom_prefer=(dom_prefer or"zz").lower() | |
| html=_collab_dirlist();names=_hrefs(html);want_ts=f"ts{valid}";out=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if"ssmv11034"not in low and"ssmv11036"not in low:continue | |
| if"hp001"not in low:continue | |
| if want_ts not in low:continue | |
| if not(low.endswith(".grib2")or low.endswith(".nc")):continue | |
| out.append(_as_url(base)) | |
| if out:return sorted(set(out),key=lambda u:(0 if _dom_from_url(u)==dom_prefer else _dom_pri(_dom_from_url(u))+10)) | |
| # Fallback: try finding via the forecast TT approach | |
| run_init=_pick_best_run_init_for_valid_t0024(valid,dom_prefer=dom_prefer) | |
| if run_init:return _find_fcst_11034_grib2_tt_ts(run_init,valid,dom_prefer=dom_prefer) | |
| return[] | |
| def _nsidc_month_folder(d)->str:return f"{d.month:02d}_{calendar.month_abbr[d.month].title()}" | |
| def _nsidc_url(date,masked:bool)->str:return f"{NSIDC_BASE}/{date.year}/{_nsidc_month_folder(date)}/{_yyyymmdd(date)}/SNODAS_{'masked'if masked else'unmasked'}_{_yyyymmdd(date)}.tar" | |
| def _nsidc_exists(date)->tuple[bool,str]: | |
| hdr={"User-Agent":"snodas/1.3"} | |
| for masked in(False,True): | |
| url=_nsidc_url(date,masked) | |
| try: | |
| r=requests.head(url,timeout=20,allow_redirects=True,headers=hdr) | |
| if r.status_code==200:return True,url | |
| if r.status_code in(403,405): | |
| rg=_retry_get(url,stream=True,timeout=(10,60),tries=2,headers=hdr);rg.close() | |
| if rg.status_code==200:return True,url | |
| except:pass | |
| return False,_nsidc_url(date,masked=False) | |
| def _nsidc_exists_cached(dstr:str)->tuple[bool,str]:return _nsidc_exists(datetime.strptime(dstr,"%Y%m%d").date()) | |
| def _http_get(url:str,timeout:int=180)->bytes: | |
| r=_retry_get(url,timeout=(15,timeout),tries=3,headers={"User-Agent":"snodas/1.3"});r.raise_for_status();return r.content | |
| def _resolve_latest_nsidc_day(days_back:int=120)->Optional[str]: | |
| utc_today=datetime.utcnow().date() | |
| for i in range(days_back+1): | |
| d=utc_today-timedelta(days=i);ok,_=_nsidc_exists(d) | |
| if ok:return _yyyymmdd(d) | |
| return None | |
| def _download_day_tar(date)->Path: | |
| dstr=_yyyymmdd(date);out=CACHE/f"SNODAS_{dstr}.tar" | |
| if out.exists()and out.stat().st_size>0:return out | |
| ok,url=_nsidc_exists_cached(dstr) | |
| if not ok:raise HTTPException(status_code=404,detail=f"NSIDC not available for {dstr}") | |
| with _lock(f"nsidc_tar_{dstr}"): | |
| if out.exists()and out.stat().st_size>0:return out | |
| out.write_bytes(_http_get(url,timeout=360)) | |
| return out | |
| _KV_RE=re.compile(r"^\s*(?P<k>[^:=#;]+?)\s*(?:[:=]\s*|\s+)(?P<v>.+?)\s*$") | |
| def _parse_hdr(path:Path)->tuple[dict,str]: | |
| txt=path.read_text(encoding="utf-8",errors="ignore");meta:dict[str,str]={} | |
| for raw in txt.splitlines(): | |
| line=raw.strip() | |
| if not line or line.startswith(("#",";")):continue | |
| m=_KV_RE.match(line) | |
| if not m:continue | |
| k=m.group("k").strip().lower();v=m.group("v").strip().split("#",1)[0].split(";",1)[0].strip() | |
| meta[k]=v;meta[re.sub(r"[^a-z0-9]+","",k)]=v | |
| return meta,txt | |
| def _dtype_from_hdr(nbits:int,pixeltype:str,byteorder:str)->np.dtype: | |
| pixeltype=(pixeltype or"").upper();byteorder=(byteorder or"I").upper() | |
| endian="<"if byteorder in("I","LSBFIRST","LITTLEENDIAN")else">" | |
| if pixeltype=="FLOAT":return np.dtype(endian+("f4"if nbits<=32 else"f8")) | |
| if pixeltype in("UNSIGNEDINT","UNSIGNED","UINTEGER"):return np.dtype(endian+("u1"if nbits<=8 else"u2"if nbits<=16 else"u4")) | |
| if pixeltype in("SIGNEDINT","SIGNED","INTEGER"):return np.dtype(endian+("i1"if nbits<=8 else"i2"if nbits<=16 else"i4")) | |
| return np.dtype(endian+"f4") | |
| def _bil_to_geotiff(hdr:Path,bil:Path,out_tif:Path)->Path: | |
| meta,raw=_parse_hdr(hdr);raw_l=raw.lower() | |
| def gi(*keys,default=None): | |
| for k in keys: | |
| kk=re.sub(r"[^a-z0-9]+","",k.lower()) | |
| for cand in(k.lower(),kk): | |
| v=meta.get(cand) | |
| if v is None:continue | |
| try:return int(float(str(v).strip())) | |
| except:pass | |
| return default | |
| def gf(*keys,default=None): | |
| for k in keys: | |
| kk=re.sub(r"[^a-z0-9]+","",k.lower()) | |
| for cand in(k.lower(),kk): | |
| v=meta.get(cand) | |
| if v is None:continue | |
| try:return float(str(v).strip()) | |
| except:pass | |
| return default | |
| def re_float(pat:str)->Optional[float]: | |
| m=re.search(pat,raw,re.I);return float(m.group(1))if m else None | |
| ncols=gi("ncols","samples","nsamples","columns") | |
| nrows=gi("nrows","lines","nlines","rows") | |
| if ncols is None:m=re.search(r"Number of columns:\s*([0-9]+)",raw,re.I);ncols=int(m.group(1))if m else None | |
| if nrows is None:m=re.search(r"Number of rows:\s*([0-9]+)",raw,re.I);nrows=int(m.group(1))if m else None | |
| if ncols is None or nrows is None:raise HTTPException(status_code=502,detail="BIL header missing ncols/nrows") | |
| bands=gi("bands","nbands","number of bands",default=1)or 1 | |
| nbits=gi("nbits","bitspersample","bits per sample",default=32)or 32 | |
| dbpp=gi("data bytes per pixel",default=None) | |
| if dbpp:nbits=int(dbpp)*8 | |
| pixeltype=meta.get("pixeltype")or meta.get("datatype")or"FLOAT" | |
| byteorder=meta.get("byteorder")or"I" | |
| slope=gf("data slope","slope",default=1.0)or 1.0 | |
| intercept=gf("data intercept","intercept",default=0.0)or 0.0 | |
| nodata_raw=gf("no data value","nodata","nodata_value",default=None) | |
| ulx=gf("ulxmap","upper left x","ulx",default=None) | |
| uly=gf("ulymap","upper left y","uly",default=None) | |
| xll=gf("xllcorner","xllcenter","lower left x","xll",default=None) | |
| yll=gf("yllcorner","yllcenter","lower left y","yll",default=None) | |
| xres=gf("x-axis resolution","x axis resolution","xdim","x dim","cellsize",default=None) | |
| yres=gf("y-axis resolution","y axis resolution","ydim","y dim","cellsize",default=None) | |
| if xres is None:xres=re_float(r"X[- ]axis resolution:\s*([0-9.eE+-]+)") | |
| if yres is None:yres=re_float(r"Y[- ]axis resolution:\s*([0-9.eE+-]+)") | |
| xmin=gf("minimum x-axis coordinate","min x","minx",default=None) | |
| xmax=gf("maximum x-axis coordinate","max x","maxx",default=None) | |
| ymin=gf("minimum y-axis coordinate","min y","miny",default=None) | |
| ymax=gf("maximum y-axis coordinate","max y","maxy","maximumy",default=None) | |
| if xmin is None:xmin=re_float(r"Minimum x[- ]axis coordinate:\s*([0-9.eE+-]+)") | |
| if xmax is None:xmax=re_float(r"Maximum x[- ]axis coordinate:\s*([0-9.eE+-]+)") | |
| if ymin is None:ymin=re_float(r"Minimum y[- ]axis coordinate:\s*([0-9.eE+-]+)") | |
| if ymax is None:ymax=re_float(r"Maximum y[- ]axis coordinate:\s*([0-9.eE+-]+)") | |
| if xres is None and xmin is not None and xmax is not None:xres=(xmax-xmin)/float(ncols) | |
| if yres is None and ymin is not None and ymax is not None:yres=(ymax-ymin)/float(nrows) | |
| if ulx is None and xll is not None:ulx=float(xll) | |
| if uly is None and yll is not None and yres is not None:uly=float(yll)+abs(float(yres))*float(nrows) | |
| if ulx is None and xmin is not None:ulx=float(xmin) | |
| if uly is None and ymax is not None:uly=float(ymax) | |
| if ulx is None or uly is None or xres is None or yres is None:raise HTTPException(status_code=502,detail="BIL header missing geo params") | |
| units_line=(meta.get("data units")or meta.get("dataunits")or"").strip();denom=None | |
| m=re.search(r"Data units:\s*Meters\s*/\s*([0-9.]+)",raw,re.I) | |
| if m: | |
| try:denom=float(m.group(1)) | |
| except:denom=None | |
| if denom is None and units_line and"/"in units_line: | |
| try:denom=float(units_line.split("/",1)[1].strip()) | |
| except:denom=None | |
| is_nohrsc_gisrs="nohrsc gis/rs raster file"in raw_l | |
| file_bytes=bil.stat().st_size;bytes_per_sample=max(1,int(nbits)//8) | |
| expected_samples=int(nrows)*int(ncols)*max(1,int(bands));expected_bytes=expected_samples*bytes_per_sample | |
| if is_nohrsc_gisrs:dtp=np.dtype(">i2");expected_bytes=expected_samples*2 | |
| else:dtp=_dtype_from_hdr(int(nbits),str(pixeltype),str(byteorder)) | |
| if file_bytes!=expected_bytes: | |
| inferred=None | |
| for bps in(1,2,4): | |
| if file_bytes==expected_samples*bps:inferred=bps;break | |
| if inferred is None:raise HTTPException(status_code=502,detail=f"BIL byte mismatch file={file_bytes} expected={expected_bytes}") | |
| if is_nohrsc_gisrs and inferred!=2:raise HTTPException(status_code=502,detail="NOHRSC GIS/RS expected 2 bytes/sample") | |
| if not is_nohrsc_gisrs: | |
| if inferred==2:dtp=np.dtype("<i2") | |
| elif inferred==4:dtp=np.dtype("<f4") | |
| arr=np.fromfile(bil,dtype=dtp) | |
| if arr.size!=expected_samples:raise HTTPException(status_code=502,detail=f"BIL size mismatch expected={expected_samples} got={arr.size}") | |
| arr=arr.reshape((int(nrows),int(ncols)))if int(bands)==1 else arr.reshape((int(bands),int(nrows),int(ncols))) | |
| arr=arr.astype("float32") | |
| if slope!=1.0 or intercept!=0.0:arr=arr*float(slope)+float(intercept) | |
| if is_nohrsc_gisrs: | |
| if denom is None or denom<=0:raise HTTPException(status_code=502,detail="NOHRSC GIS/RS missing units denom") | |
| arr=arr*(1000.0/float(denom));nodata=(float(nodata_raw)*(1000.0/float(denom)))if nodata_raw is not None else None | |
| else: | |
| den=1.0 | |
| if units_line and"/"in units_line: | |
| try:den=float(units_line.split("/",1)[1].strip())or 1.0 | |
| except:den=1.0 | |
| if den!=1.0:arr=arr/float(den) | |
| if"meter"in(units_line or"").lower():arr=arr*1000.0 | |
| nodata=float(nodata_raw)if nodata_raw is not None else None | |
| arr[arr<=-9990.0]=nodata if nodata is not None else np.nan | |
| crs=CRS.from_epsg(4326);transform=Affine(float(xres),0.0,float(ulx),0.0,-abs(float(yres)),float(uly)) | |
| profile={"driver":"GTiff","height":int(nrows),"width":int(ncols),"count":1 if arr.ndim==2 else int(arr.shape[0]),"dtype":"float32","crs":crs,"transform":transform,"compress":"DEFLATE","tiled":True} | |
| if nodata is not None:profile["nodata"]=float(nodata) | |
| with rasterio.open(out_tif,"w",**profile)as dst: | |
| if arr.ndim==2:dst.write(arr,1) | |
| else: | |
| for b in range(arr.shape[0]):dst.write(arr[b],b+1) | |
| return out_tif | |
| def _sanitize_mm(da:xr.DataArray)->xr.DataArray: | |
| da=da.astype("float32");da=da.where(np.isfinite(da)).where(da>-9990.0) | |
| da=xr.where(da<0.0,0.0,da);da=xr.where(da>100000.0,np.nan,da);return da | |
| def _open_grib2(path:Path)->xr.DataArray: | |
| idx=(CFGRIB_DIR/(path.name+".idx")).as_posix();ds=xr.open_dataset(path.as_posix(),engine="cfgrib",backend_kwargs={"indexpath":idx}) | |
| try: | |
| da=_pick_grib_var(ds) | |
| if"step"in da.dims:da=da.isel(step=0) | |
| attrs=dict(da.attrs or{}) | |
| for k in("_FillValue","missingValue","missing_value"): | |
| if k in attrs: | |
| try:da=da.where(da!=float(attrs[k])) | |
| except:pass | |
| da=da.where(np.isfinite(da)).where(da<1.0e20).astype("float32") | |
| units=str(attrs.get("units","")).strip().lower() | |
| if units in("m","meter","metre","m of water equivalent","meters"):da=da*1000.0 | |
| dims_l={d.lower():d for d in da.dims};rename={} | |
| if"longitude"in dims_l:rename[dims_l["longitude"]]="x" | |
| if"latitude"in dims_l:rename[dims_l["latitude"]]="y" | |
| if rename:da=da.rename(rename) | |
| if"x"in da.coords: | |
| try: | |
| x=np.asarray(da["x"].values) | |
| if x.ndim==1 and x.size>2: | |
| if float(np.nanmin(x))>=0.0 and float(np.nanmax(x))>180.0:da=da.assign_coords(x=_lon0360_to_180(x));da=da.sortby("x") | |
| except:pass | |
| if"x"in da.dims and"y"in da.dims: | |
| try:da=da.rio.set_spatial_dims(x_dim="x",y_dim="y",inplace=False) | |
| except:pass | |
| try:da=da.rio.write_crs("EPSG:4326",inplace=False) | |
| except:pass | |
| try:da=_ensure_xy_transform(da) | |
| except:pass | |
| return _sanitize_mm(da) | |
| finally: | |
| try:ds.close() | |
| except:pass | |
| def _open_bil(hdr:Path,dat:Path)->xr.DataArray: | |
| tmp_tif=dat.with_suffix(".tif") | |
| if not tmp_tif.exists()or tmp_tif.stat().st_size==0:_bil_to_geotiff(hdr,dat,tmp_tif) | |
| da=xr.open_dataarray(tmp_tif.as_posix(),engine="rasterio").astype("float32") | |
| try: | |
| if da.rio.crs is None:da=da.rio.write_crs("EPSG:4326",inplace=False) | |
| da=da.rio.set_spatial_dims(x_dim="x",y_dim="y",inplace=False) | |
| except:pass | |
| return _sanitize_mm(da) | |
| def _pick_netcdf_raster_var(ds:xr.Dataset)->xr.DataArray: | |
| bad=("bounds","bnds","crs","grid_mapping","lat_bounds","lon_bounds","time_bounds") | |
| bad2=("mask","qc","quality","flag","prob","probability","index","idx") | |
| good=("melt","snowmelt","smelt","we","water","equiv","mm","kg","swe") | |
| best=None;best_score=-10**9 | |
| for name,da in ds.data_vars.items(): | |
| n=name.lower() | |
| if any(t in n for t in bad)or any(t in n for t in bad2)or da.ndim<2:continue | |
| score=300 if any(t in n for t in good)else 0 | |
| score+=200 if da.ndim==2 else 50 | |
| dims=[d.lower()for d in da.dims] | |
| if("y"in dims and"x"in dims)or("south_north"in dims and"west_east"in dims):score+=200 | |
| if("lat"in dims and"lon"in dims)or("latitude"in dims and"longitude"in dims):score+=150 | |
| try:score+=int((da.shape[-1]*da.shape[-2])//50000) | |
| except:pass | |
| if score>best_score:best_score=score;best=da | |
| if best is None:raise HTTPException(status_code=502,detail=f"No suitable raster variable in NetCDF. vars={list(ds.data_vars)[:50]}") | |
| return best | |
| def _open_netcdf(path:Path)->xr.DataArray: | |
| last=None;ds=None | |
| for eng in("netcdf4","h5netcdf","scipy"): | |
| try:ds=xr.open_dataset(path.as_posix(),engine=eng);break | |
| except Exception as e:last=e;ds=None | |
| if ds is None:raise HTTPException(status_code=502,detail=f"NetCDF backend unavailable/failed. last_err={last!r}") | |
| try: | |
| da=_pick_netcdf_raster_var(ds);dims_l={d.lower():d for d in da.dims};rename={} | |
| if"south_north"in dims_l and"west_east"in dims_l:rename[dims_l["south_north"]]="y";rename[dims_l["west_east"]]="x" | |
| elif"lat"in dims_l and"lon"in dims_l:rename[dims_l["lat"]]="y";rename[dims_l["lon"]]="x" | |
| elif"latitude"in dims_l and"longitude"in dims_l:rename[dims_l["latitude"]]="y";rename[dims_l["longitude"]]="x" | |
| elif"rlat"in dims_l and"rlon"in dims_l:rename[dims_l["rlat"]]="y";rename[dims_l["rlon"]]="x" | |
| if rename:da=da.rename(rename) | |
| spatial={"x","y"} | |
| for d in list(da.dims): | |
| if d not in spatial:da=da.isel({d:int(da.sizes.get(d,1)or 1)-1}) | |
| da=da.astype("float32");units=str(da.attrs.get("units","")).strip().lower() | |
| if units in("m","meter","metre","m of water equivalent","meters"):da=da*1000.0 | |
| try: | |
| lon=da.coords.get("longitude")or da.coords.get("lon");lat=da.coords.get("latitude")or da.coords.get("lat") | |
| if lon is not None and lat is not None: | |
| if lon.ndim==2 and lat.ndim==2: | |
| h,w=da.shape[-2],da.shape[-1] | |
| tfm=from_bounds(float(np.nanmin(lon.values)),float(np.nanmin(lat.values)),float(np.nanmax(lon.values)),float(np.nanmax(lat.values)),w,h) | |
| da=da.rio.write_crs("EPSG:4326",inplace=False).rio.write_transform(tfm,inplace=False) | |
| except:pass | |
| # Set spatial dims FIRST | |
| try: | |
| if"x"in da.dims and"y"in da.dims:da=da.rio.set_spatial_dims(x_dim="x",y_dim="y",inplace=False) | |
| except:pass | |
| try:da=_ensure_xy_transform(da) | |
| except:pass | |
| # Force CRS — drop any conflicting spatial_ref first | |
| try: | |
| if da.rio.crs is None: | |
| if"spatial_ref"in da.coords:da=da.drop_vars("spatial_ref") | |
| da=da.rio.write_crs("EPSG:4326",inplace=False) | |
| except:pass | |
| try: | |
| if da.rio.crs is None: | |
| da=da.rio.write_crs("EPSG:4326",inplace=False) | |
| except Exception as e: | |
| _log("WARN",f"[open_netcdf] CRS write failed: {e!r}") | |
| return _sanitize_mm(da) | |
| finally: | |
| try:ds.close() | |
| except:pass | |
| def _open_grid(desc:dict)->xr.DataArray: | |
| t=desc.get("type") | |
| if t=="grib2":return _open_grib2(desc["path"]) | |
| if t=="bil":return _open_bil(desc["hdr"],desc["dat"]) | |
| if t=="netcdf":return _open_netcdf(desc["path"]) | |
| raise HTTPException(status_code=500,detail="unknown grid type") | |
| def _is_probably_html(blob:bytes)->bool:head=blob[:512].lstrip();return head.startswith(b"<!DOCTYPE")or head.startswith(b"<html")or head.startswith(b"<HTML") | |
| def _hf_try_pull_file(rel: str, timeout_sec: int = 20, cancel_check=None) -> bool: | |
| if cancel_check and cancel_check(): | |
| return False | |
| tok, repo = _hf_cfg() | |
| if not tok or not repo: | |
| return False | |
| rel = (rel or "").lstrip("/") | |
| if not rel: | |
| return False | |
| dest = (CACHE / rel).resolve() | |
| dest.parent.mkdir(parents=True, exist_ok=True) | |
| result = {"tmp": None, "error": None} | |
| def do_download(): | |
| try: | |
| result["tmp"] = hf_hub_download( | |
| repo_id=repo, repo_type="dataset", filename=rel, | |
| token=tok, local_dir=str(CACHE) | |
| ) | |
| except Exception as e: | |
| result["error"] = e | |
| t = threading.Thread(target=do_download, daemon=True) | |
| t.start() | |
| poll_interval = 0.5 | |
| elapsed = 0.0 | |
| while t.is_alive() and elapsed < float(timeout_sec): | |
| if cancel_check and cancel_check(): | |
| _log("DEBUG", f"[hf_pull] cancelled (gen changed) for {rel}") | |
| return False | |
| t.join(timeout=poll_interval) | |
| elapsed += poll_interval | |
| if t.is_alive(): | |
| _log("DEBUG", f"[hf_pull] timeout after {timeout_sec}s for {rel}") | |
| return False | |
| if cancel_check and cancel_check(): | |
| return False | |
| if result["error"] is not None: | |
| e = result["error"] | |
| err_str = str(e).lower() | |
| if "404" in err_str or "not found" in err_str: | |
| return False | |
| if "timeout" not in err_str and "connection" not in err_str: | |
| _log("WARN", f"[hf_pull] error for {rel}: {e!r}") | |
| return False | |
| try: | |
| tmp = result["tmp"] | |
| if tmp is None: | |
| return False | |
| p = Path(tmp) | |
| if not p.exists(): | |
| return False | |
| b = p.read_bytes() | |
| if _is_lfs_pointer_bytes(b) or len(b) < 200: | |
| try: | |
| p.unlink(missing_ok=True) | |
| dest.unlink(missing_ok=True) | |
| except: | |
| pass | |
| return False | |
| if p.resolve() != dest: | |
| try: | |
| dest.write_bytes(b) | |
| except: | |
| pass | |
| return True | |
| except Exception as e: | |
| _log("WARN", f"[hf_pull] validation failed for {rel}: {e!r}") | |
| return False | |
| def _is_netcdf_bytes(blob:bytes)->bool: | |
| if len(blob)>=4 and blob[:3]==b"CDF"and blob[3]in(1,2):return True | |
| return len(blob)>=8 and blob[:8]==b"\x89HDF\r\n\x1a\n" | |
| _HF_OPS_LOCK=threading.Lock() | |
| _HF_PENDING:dict[str,CommitOperationAdd]={} | |
| def _hf_enqueue_files(paths:list[Path])->int: | |
| tok,repo=_hf_cfg() | |
| if not tok or not repo:return 0 | |
| added=0 | |
| with _HF_OPS_LOCK: | |
| for p in paths: | |
| try: | |
| if(not p.exists())or p.stat().st_size<=0:continue | |
| rel=p.relative_to(CACHE).as_posix() | |
| if not _is_cache_file_we_manage(rel):continue | |
| _HF_PENDING[rel]=CommitOperationAdd(path_in_repo=rel,path_or_fileobj=p.as_posix());added+=1 | |
| except:continue | |
| return added | |
| def _hf_flush_pending(message:str)->None: | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if not tok or not repo or api is None:return | |
| with _HF_OPS_LOCK:ops=list(_HF_PENDING.values());_HF_PENDING.clear() | |
| if not ops:return | |
| for attempt in range(6): | |
| try: | |
| api.create_commit(repo_id=repo,repo_type="dataset",operations=ops,commit_message=message) | |
| _log("INFO",f"[hf_cache] flush OK files={len(ops)}");return | |
| except Exception as e: | |
| wait=(1.8**attempt)+random.uniform(0,1.0);_log("INFO",f"[hf_cache] flush retry attempt={attempt+1} wait={wait:.1f} err={e!r}");time.sleep(wait) | |
| _log("INFO",f"[hf_cache] flush FAILED files={len(ops)}") | |
| def _download_url_to_grid(url:str,workdir:Path)->tuple[dict,str]: | |
| hdrs={"User-Agent":"snodas/1.3"};name=Path(url).name.lower();last_err=None | |
| for attempt in range(6): | |
| r=_retry_get(url,timeout=(10,360),tries=1,headers=hdrs);sc=int(r.status_code) | |
| if sc in(429,502,503,504): | |
| last_err=f"HTTP {sc}" | |
| if attempt<5: | |
| ra=r.headers.get("Retry-After") | |
| try:wait=float(ra)if ra else None | |
| except:wait=None | |
| time.sleep((wait if wait is not None else(1.6**attempt))+random.uniform(0,0.8));continue | |
| raise HTTPException(status_code=502,detail=f"{url} -> {last_err} after retries") | |
| if sc not in(200,206):raise HTTPException(status_code=404,detail=f"{url} -> HTTP {sc}") | |
| blob=r.content;ctype=(r.headers.get("Content-Type")or"").lower() | |
| if _is_probably_html(blob)or"text/html"in ctype: | |
| raise HTTPException(status_code=502,detail=f"Upstream returned HTML. url={url}") | |
| if len(blob)>=2 and blob[:2]==b"\x1f\x8b": | |
| try:blob=gzip.decompress(blob) | |
| except Exception as e:raise HTTPException(status_code=502,detail=f"gzip decompress failed: {e!r}") | |
| if blob.startswith(b"GRIB"):p=workdir/"data.grib2";p.write_bytes(blob);return{"type":"grib2","path":p},name | |
| if _is_netcdf_bytes(blob):p=workdir/"data.nc";p.write_bytes(blob);return{"type":"netcdf","path":p},name | |
| looks_tar=(len(blob)%512==0)or(b"ustar"in blob[:4096]) | |
| if looks_tar: | |
| try: | |
| with tarfile.open(fileobj=io.BytesIO(blob),mode="r:*")as tf: | |
| grib_member=hdr_member=dat_member=nc_member=None | |
| for m in tf.getmembers(): | |
| n=m.name.lower() | |
| if n.endswith(".grib2")and grib_member is None:grib_member=m | |
| elif n.endswith(".nc")and nc_member is None:nc_member=m | |
| elif n.endswith(".hdr")and hdr_member is None:hdr_member=m | |
| elif(n.endswith(".dat")or n.endswith(".bil"))and dat_member is None:dat_member=m | |
| if grib_member is not None: | |
| p=workdir/"data.grib2";f=tf.extractfile(grib_member) | |
| if f is None:raise HTTPException(status_code=502,detail="TAR member unreadable") | |
| p.write_bytes(f.read(grib_member.size));return{"type":"grib2","path":p},name | |
| if nc_member is not None: | |
| p=workdir/"data.nc";f=tf.extractfile(nc_member) | |
| if f is None:raise HTTPException(status_code=502,detail="TAR member unreadable") | |
| p.write_bytes(f.read(nc_member.size));return{"type":"netcdf","path":p},name | |
| if hdr_member is not None and dat_member is not None: | |
| hdr=workdir/"data.hdr";dat=workdir/"data.bil" | |
| fh=tf.extractfile(hdr_member);fd=tf.extractfile(dat_member) | |
| if fh is None or fd is None:raise HTTPException(status_code=502,detail="TAR member unreadable") | |
| hdr.write_bytes(fh.read(hdr_member.size));dat.write_bytes(fd.read(dat_member.size)) | |
| return{"type":"bil","hdr":hdr,"dat":dat},name | |
| except HTTPException:raise | |
| except Exception as e:raise HTTPException(status_code=502,detail=f"TAR parse failed: {e!r}") | |
| raise HTTPException(status_code=502,detail=f"payload unrecognized. url={url}") | |
| raise HTTPException(status_code=504,detail=f"upstream retries exhausted. url={url} last_err={last_err}") | |
| def _da_to_cog(da:xr.DataArray,out:Path)->Path: | |
| out.parent.mkdir(parents=True,exist_ok=True) | |
| try:_=da.rio.transform();ok_transform=True | |
| except:ok_transform=False | |
| if not ok_transform and"x"in da.coords and"y"in da.coords: | |
| try:da=_ensure_xy_transform(da) | |
| except:pass | |
| nodata=np.float32(-9999.0) | |
| try:crs=da.rio.crs | |
| except:crs=None | |
| if crs is None: | |
| try: | |
| if"spatial_ref"in da.coords:da=da.drop_vars("spatial_ref") | |
| da=da.rio.write_crs("EPSG:4326",inplace=False) | |
| except:pass | |
| try:crs=da.rio.crs | |
| except:crs=None | |
| if crs is None:da=da.rio.write_crs("EPSG:4326",inplace=False) | |
| da=da.astype("float32").where(np.isfinite(da)).where(da>-9990.0);da=da.where(~da.isnull(),nodata) | |
| with tempfile.NamedTemporaryFile(suffix=".tif",dir=str(out.parent),delete=False)as tf:tmp=Path(tf.name) | |
| try: | |
| da.rio.to_raster(tmp.as_posix(),dtype="float32",compress="DEFLATE",nodata=float(nodata),tiled=True) | |
| # Force CRS into the written file if rio.to_raster didn't set it | |
| with rasterio.open(tmp.as_posix(),"r")as check: | |
| if check.crs is None: | |
| _log("INFO",f"[da_to_cog] patching missing CRS for {out.name}") | |
| data=check.read();prof=check.profile.copy() | |
| prof["crs"]=CRS.from_epsg(4326) | |
| tmp2=tmp.with_suffix(".fix.tif") | |
| with rasterio.open(tmp2.as_posix(),"w",**prof)as ds2: | |
| ds2.write(data) | |
| tmp2.replace(tmp) | |
| _atomic_cog_translate(tmp,out) | |
| finally:tmp.unlink(missing_ok=True) | |
| return out | |
| def _extract_var(tar_path:Path,code:str)->tuple[Path,Path]: | |
| outdir=CACHE/f"{tar_path.stem}_x";outdir.mkdir(exist_ok=True) | |
| hdr_path:Optional[Path]=None;dat_path:Optional[Path]=None | |
| with tarfile.open(tar_path)as tf: | |
| for m in tf.getmembers(): | |
| name=Path(m.name).name;low=name.lower() | |
| if code not in low:continue | |
| if low.endswith(".hdr"): | |
| outp=outdir/name | |
| if not outp.exists(): | |
| f=tf.extractfile(m) | |
| if f is not None:outp.write_bytes(f.read()) | |
| hdr_path=outp | |
| elif low.endswith(".dat")or low.endswith(".bil"): | |
| outp=outdir/name | |
| if not outp.exists(): | |
| f=tf.extractfile(m) | |
| if f is not None:outp.write_bytes(f.read()) | |
| dat_path=outp | |
| if not hdr_path or not dat_path:raise HTTPException(status_code=500,detail=f"Missing .hdr or .dat for var {code}") | |
| return hdr_path,dat_path | |
| def build_24h_cog(date)->Path: | |
| dstr=_yyyymmdd(date);out=CACHE/f"melt24h_{dstr}_cog.tif" | |
| if out.exists()and out.stat().st_size>0:return out | |
| _hf_try_pull_file(out.relative_to(CACHE).as_posix()) | |
| if out.exists()and out.stat().st_size>0:return out | |
| with _lock(f"melt24h_{dstr}"): | |
| if out.exists()and out.stat().st_size>0:return out | |
| _hf_try_pull_file(out.relative_to(CACHE).as_posix()) | |
| if out.exists()and out.stat().st_size>0:return out | |
| tar=_download_day_tar(date);hdr,dat=_extract_var(tar,"1044");da=_open_bil(hdr,dat);p=_da_to_cog(da,out) | |
| try:_hf_enqueue_files([p]) | |
| except:pass | |
| return p | |
| def build_72h_cog(end_date)->Path: | |
| dstr=_yyyymmdd(end_date);out=CACHE/f"melt72h_end_{dstr}_cog.tif" | |
| if out.exists()and out.stat().st_size>0:return out | |
| _hf_try_pull_file(out.relative_to(CACHE).as_posix()) | |
| if out.exists()and out.stat().st_size>0:return out | |
| with _lock(f"melt72h_{dstr}"): | |
| if out.exists()and out.stat().st_size>0:return out | |
| _hf_try_pull_file(out.relative_to(CACHE).as_posix()) | |
| if out.exists()and out.stat().st_size>0:return out | |
| cogs=[build_24h_cog(end_date-timedelta(days=2)),build_24h_cog(end_date-timedelta(days=1)),build_24h_cog(end_date)] | |
| with rasterio.open(cogs[0])as base: | |
| acc=base.read(1).astype("float32") | |
| for c in cogs[1:]: | |
| with rasterio.open(c)as src: | |
| arr=src.read(1).astype("float32") | |
| if(src.transform!=base.transform)or(src.crs!=base.crs)or(src.width!=base.width)or(src.height!=base.height): | |
| tmp=np.full_like(acc,np.nan,dtype="float32") | |
| reproject(arr,tmp,src_transform=src.transform,src_crs=src.crs,dst_transform=base.transform,dst_crs=base.crs,resampling=Resampling.nearest) | |
| arr=tmp | |
| m=np.isfinite(arr);acc[m]=np.nan_to_num(acc[m],nan=0.0)+arr[m] | |
| tmp_tif=out.with_suffix(".tmp.tif");prof=base.profile.copy();prof.update({"compress":"DEFLATE","tiled":True}) | |
| with rasterio.open(tmp_tif,"w",**prof)as ds:ds.write(acc,1) | |
| _atomic_cog_translate(tmp_tif,out);tmp_tif.unlink(missing_ok=True) | |
| try:_hf_enqueue_files([out]) | |
| except:pass | |
| return out | |
| def _forecast_dir()->Path:d=CACHE/"forecast";d.mkdir(parents=True,exist_ok=True);return d | |
| def _forecast_melt_cog(run_init:str,valid:str,lead:str,dom:str)->Path: | |
| ri=run_init if run_init!="TTNA"else"TTNA" | |
| return _forecast_dir()/f"fcst_melt_{lead}_{ri}_{valid}_{dom}_cog.tif" | |
| def _forecast_snow_cog(ts:str,dom:str,code:str)->Path:return _forecast_dir()/f"fcst_snow_{code}_{ts}_{dom}_cog.tif" | |
| def _forecast_melt72h_end_cog(valid:str,dom_prefer:str="zz")->Path: | |
| valid=_norm_ts(valid);dom_prefer=(dom_prefer or"zz").lower() | |
| out=_forecast_dir()/f"fcst_melt72h_end_{valid}_{dom_prefer}_cog.tif" | |
| if out.exists()and out.stat().st_size>0:return out | |
| _hf_try_pull_file(out.relative_to(CACHE).as_posix()) | |
| if out.exists()and out.stat().st_size>0:return out | |
| with _lock(f"fcst_melt72h_{valid}_{dom_prefer}"): | |
| if out.exists()and out.stat().st_size>0:return out | |
| v0=valid;v1=_shift_ts(v0,-24);v2=_shift_ts(v0,-48) | |
| sels=[_pick_forecast_for_valid(v,dom_prefer=dom_prefer)for v in(v2,v1,v0)] | |
| if not all(s.get("ok")for s in sels):raise HTTPException(status_code=503,detail={"error":"forecast_selection_unavailable_for_72h","need":[v2,v1,v0],"sels":sels}) | |
| day_cogs:list[Path]=[] | |
| for s,v in zip(sels,(v2,v1,v0)): | |
| melt_cogs=_build_forecast_melt_cogs(s["run_init"],v,"t0024",s["melt_urls"]) | |
| pick=None | |
| for p in melt_cogs: | |
| if _cog_dom(p)==dom_prefer:pick=p;break | |
| if pick is None:pick=sorted(melt_cogs,key=lambda p:_dom_pri(_cog_dom(p)))[0] | |
| day_cogs.append(pick) | |
| with rasterio.open(day_cogs[0])as base: | |
| acc=base.read(1).astype("float32") | |
| for c in day_cogs[1:]: | |
| with rasterio.open(c)as src: | |
| arr=src.read(1).astype("float32") | |
| if(src.transform!=base.transform)or(src.crs!=base.crs)or(src.width!=base.width)or(src.height!=base.height): | |
| tmp=np.full_like(acc,np.nan,dtype="float32") | |
| reproject(arr,tmp,src_transform=src.transform,src_crs=src.crs,dst_transform=base.transform,dst_crs=base.crs,resampling=Resampling.nearest) | |
| arr=tmp | |
| m=np.isfinite(arr)&(arr>-9990.0);acc[m]=np.nan_to_num(acc[m],nan=0.0)+arr[m] | |
| tmp_tif=out.with_suffix(".tmp.tif");prof=base.profile.copy();prof.update({"compress":"DEFLATE","tiled":True}) | |
| with rasterio.open(tmp_tif,"w",**prof)as ds:ds.write(acc,1) | |
| _atomic_cog_translate(tmp_tif,out) | |
| try:_hf_enqueue_files([out]) | |
| except:pass | |
| tmp_tif.unlink(missing_ok=True);_log("INFO",f"[build_forecast] melt72h OK valid={valid} dom_prefer={dom_prefer} -> {out.name}") | |
| return out | |
| def _pick_grib_var(ds:xr.Dataset)->xr.DataArray: | |
| best=None;best_score=-10**9 | |
| for name,da in ds.data_vars.items(): | |
| a=dict(da.attrs or{});units=str(a.get("units","")).lower() | |
| txt=" ".join([name.lower(),str(a.get("GRIB_name","")).lower(),str(a.get("standard_name","")).lower(),str(a.get("long_name","")).lower()]) | |
| score=0 | |
| if"snow"in txt or"swe"in txt or"water equivalent"in txt:score+=500 | |
| if"depth"in txt:score+=250 | |
| if units in("m","meter","metre","mm"):score+=120 | |
| if units in("k","kelvin","degc","c","degree_celsius"):score-=400 | |
| if da.ndim>=2:score+=50 | |
| if score>best_score:best_score=score;best=da | |
| if best is None:return ds[next(iter(ds.data_vars))] | |
| return best | |
| def _ensure_xy_transform(da:xr.DataArray)->xr.DataArray: | |
| if not("x"in da.coords and"y"in da.coords):return da | |
| try: | |
| x=np.asarray(da["x"].values);y=np.asarray(da["y"].values) | |
| if x.ndim!=1 or y.ndim!=1 or x.size<2 or y.size<2:return da | |
| dx=float(np.nanmedian(np.diff(x)));dy=float(np.nanmedian(np.diff(y))) | |
| if not np.isfinite(dx)or not np.isfinite(dy)or dx==0.0 or dy==0.0:return da | |
| left=float(x[0])-dx/2.0;top=float(y[0])+(-dy)/2.0 if dy<0 else float(y[0])-dy/2.0 | |
| return da.rio.write_transform(Affine(dx,0.0,left,0.0,dy,top),inplace=False) | |
| except:return da | |
| def _build_forecast_melt_cogs(run_init:str,valid:str,lead:str,melt_urls:list[str])->list[Path]: | |
| run_init_safe=run_init if run_init!="TTNA"else"TTNA" | |
| run_init_norm=_norm_ts(run_init)if run_init!="TTNA"else"TTNA" | |
| valid=_norm_ts(valid);lead=(lead or"").lower() | |
| if not melt_urls:raise HTTPException(status_code=502,detail=f"No melt URLs provided for run_init={run_init} valid={valid} lead={lead}") | |
| dom2urls:dict[str,list[str]]={} | |
| for u in melt_urls:dom2urls.setdefault(_dom_from_url(u),[]).append(u) | |
| built:list[Path]=[];errors:list[str]=[] | |
| with _lock(f"melt_{lead}_{run_init_safe}_{valid}"): | |
| for dom in sorted(dom2urls.keys(),key=_dom_pri): | |
| out=_forecast_melt_cog(run_init_safe,valid,lead,dom) | |
| if out.exists()and out.stat().st_size>0:built.append(out);continue | |
| last_err=None | |
| for url in dom2urls[dom]: | |
| low=Path(url).name.lower() | |
| if lead and lead not in low:continue | |
| with tempfile.TemporaryDirectory()as td: | |
| try: | |
| desc,_=_download_url_to_grid(url,Path(td));da=_open_grid(desc);_da_to_cog(da,out) | |
| try:_hf_enqueue_files([out]) | |
| except:pass | |
| if out.exists()and out.stat().st_size>0: | |
| _log("INFO",f"[build_forecast] melt OK run_init={run_init_safe} valid={valid} lead={lead} dom={dom} -> {out.name}") | |
| built.append(out);break | |
| last_err="empty COG after build" | |
| except HTTPException as e:last_err=f"{e.status_code} {e.detail}" | |
| except Exception as e:last_err=repr(e) | |
| if out not in built:errors.append(f"dom={dom} last_err={last_err}");_log("WARN",f"[build_forecast] melt FAIL run_init={run_init_safe} valid={valid} lead={lead} dom={dom}") | |
| if not built:raise HTTPException(status_code=502,detail={"error":"No usable MELT COGs","run_init":run_init,"valid":valid,"lead":lead,"errors":errors[:5]}) | |
| return built | |
| def _build_forecast_snowpack_cogs(ts:str,urls:list[str])->list[Path]: | |
| ts=_norm_ts(ts)if ts and _TS10.fullmatch(ts)else ts | |
| dom2urls:dict[str,list[str]]={} | |
| for u in urls:dom2urls.setdefault(_dom_from_url(u),[]).append(u) | |
| built:list[Path]=[] | |
| with _lock(f"snow_{ts}"): | |
| for dom in sorted(dom2urls.keys(),key=_dom_pri): | |
| last_err=None | |
| for url in dom2urls[dom]: | |
| low=Path(url).name.lower() | |
| code="ssmv11036"if"ssmv11036"in low else"ssmv11034"if"ssmv11034"in low else"ssmv11005"if"ssmv11005"in low else"ssmv_unknown" | |
| out=_forecast_snow_cog(ts or"na",dom,code) | |
| if out.exists()and out.stat().st_size>0:built.append(out);break | |
| with tempfile.TemporaryDirectory()as td: | |
| try: | |
| desc,_=_download_url_to_grid(url,Path(td));da=_open_grid(desc);_da_to_cog(da,out) | |
| try:_hf_enqueue_files([out]) | |
| except:pass | |
| if out.exists()and out.stat().st_size>0: | |
| _log("INFO",f"[build_forecast] snow OK ts={ts} dom={dom} code={code} -> {out.name}") | |
| built.append(out);break | |
| last_err="empty COG" | |
| except HTTPException as e:last_err=f"{e.status_code} {e.detail}" | |
| except Exception as e:last_err=repr(e) | |
| if not any(f"_{dom}_"in p.name for p in built):_log("INFO",f"[build_forecast] snow FAIL ts={ts} dom={dom} last_err={last_err}") | |
| if not built:raise HTTPException(status_code=502,detail=f"No usable snow field for {ts}") | |
| return built | |
| def _cog_dom(p:Path)->str:parts=p.name.split("_");return parts[-2]if len(parts)>=2 else"xx" | |
| def _union_tile(cogs:list[Path],z:int,x:int,y:int,*,label:str,valid_pred:Optional[Callable[[np.ndarray,np.ndarray],np.ndarray]]=None)->tuple[Optional[np.ndarray],Optional[np.ndarray],str]: | |
| if not cogs:return None,None,f"{label}_no_cogs" | |
| out_arr:Optional[np.ndarray]=None;out_valid:Optional[np.ndarray]=None;used:list[str]=[] | |
| for cog in sorted(cogs,key=lambda p:_dom_pri(_cog_dom(p))): | |
| try: | |
| with COGReader(cog.as_posix())as r:data,mask=r.tile(x,y,z,tilesize=256,resampling_method="nearest") | |
| arr=data[0].astype("float32");m=mask[0];valid=(m>0)&np.isfinite(arr)&(arr>-9990.0) | |
| if valid_pred is not None: | |
| try:valid=valid&valid_pred(arr,m) | |
| except:pass | |
| if out_arr is None:out_arr=arr.copy();out_valid=valid.copy() | |
| else: | |
| fill=valid&(~out_valid) | |
| if fill.any():out_arr[fill]=arr[fill];out_valid[fill]=True | |
| used.append(cog.name) | |
| except TileOutsideBounds:continue | |
| except:continue | |
| if out_arr is None or out_valid is None:return None,None,f"{label}_unavailable_all_domains" | |
| return out_arr,out_valid,f"{label}_union:"+",".join(used[:4])+("..."if len(used)>4 else"") | |
| def _read_mask_tile(ts:str,urls:list[str],z:int,x:int,y:int)->tuple[Optional[np.ndarray],Optional[np.ndarray],str]: | |
| ts=_norm_ts(ts)if ts else ts | |
| if not urls:return None,None,"mask_no_urls" | |
| try:cogs=_build_forecast_snowpack_cogs(ts or"0000000000",urls) | |
| except HTTPException as e:return None,None,f"mask_build_fail:{e.status_code}:{e.detail}" | |
| except Exception as e:return None,None,f"mask_build_fail:{e!r}" | |
| return _union_tile(cogs,z,x,y,label="mask") | |
| def _cog_point_data_mask(r:COGReader,lon:float,lat:float)->tuple[np.ndarray,np.ndarray]: | |
| out=r.point(lon,lat) | |
| if isinstance(out,(tuple,list))and len(out)==2:return np.asarray(out[0]),np.asarray(out[1]) | |
| data=getattr(out,"data",None);mask=getattr(out,"mask",None) | |
| if data is None or mask is None:raise ValueError(f"Unexpected COGReader.point() return type: {type(out)!r}") | |
| return np.asarray(data),np.asarray(mask) | |
| INCH_TO_MM=25.4 | |
| BIN_EDGES_IN=np.array([0.01,0.02,0.04,0.08,0.12,0.16,0.20,0.39,0.59,0.79,0.98,1.4,2.0,3.0,4.0,5.0,9999.0],dtype="float32") | |
| BIN_EDGES_72H_IN=np.array([0.01,0.04,0.08,0.16,0.24,0.31,0.39,0.79,1.4,2.0,3.0,3.9,5.0,6.5,8.0,9.8,9999.0],dtype="float32") | |
| MELT_COLORS=np.array([[0xDC,0xDC,0xDC],[0x99,0x00,0x00],[0xCC,0x33,0x33],[0xFF,0x66,0x66],[0xCC,0xCC,0x66],[0xFF,0x8C,0x00],[0xFF,0xFF,0x66],[0x99,0xFF,0x99],[0x00,0xFF,0x00],[0x66,0xCC,0x66],[0x66,0x66,0xFF],[0x33,0x33,0xCC],[0x00,0x00,0x99],[0x99,0x00,0x99],[0x4B,0x00,0x82],[0x2F,0x00,0x4F],[0x00,0x00,0x00]],dtype="uint8") | |
| def _corridor_tiles_set_for_z(z:int)->set[tuple[int,int]]: | |
| pts=_load_track_points() | |
| if not pts:return set() | |
| tiles=_tiles_within_miles(pts,int(z),WARM_MILES,cap=WARM_CAP_PER_ZOOM) | |
| return set((x,y)for(_,x,y)in tiles) | |
| def _is_corridor_tile(z:int,x:int,y:int)->bool:return(int(x),int(y))in _corridor_tiles_set_for_z(int(z)) | |
| def _effective_request_tile(z:int,x:int,y:int)->tuple[int,int,int,str]: | |
| z=int(z);x=int(x);y=int(y) | |
| if not _tile_allowed(z,x,y):return z,x,y,"none" | |
| if _is_corridor_tile(z,x,y):return z,x,y,"hi" | |
| z_lr=min(int(LOWRES_Z),z) | |
| if z<=z_lr:return z,x,y,"lo" | |
| shift=z-z_lr;return z_lr,(x>>shift),(y>>shift),"lo" | |
| def _miles_to_meters(mi:float)->float:return float(mi)*1609.344 | |
| def _meters_per_pixel_webmercator(z:int,lat:float)->float: | |
| lat=max(-85.05112878,min(85.05112878,float(lat))) | |
| return 156543.03392*max(0.0,float(np.cos(np.deg2rad(lat))))/(2**int(z)) | |
| def _tile_radius_for_miles(z:int,lat:float,miles:float)->int: | |
| mpp=_meters_per_pixel_webmercator(int(z),float(lat)) | |
| if not np.isfinite(mpp)or mpp<=0:return 0 | |
| return max(0,int(np.ceil(_miles_to_meters(float(miles))/(mpp*256.0)))) | |
| def _tiles_within_miles(points:list[tuple[float,float]],z:int,miles:float,cap:int=8000)->list[tuple[int,int,int]]: | |
| if not points:return[] | |
| z=int(z);n=2**z;seen:set[tuple[int,int]]=set();out:list[tuple[int,int,int]]=[];miles=float(miles) | |
| for lon,lat in points: | |
| tx,ty=_lonlat_to_tile(lon,lat,z);r=_tile_radius_for_miles(z,lat,miles) | |
| for dx in range(-r,r+1): | |
| x=tx+dx | |
| if x<0 or x>=n:continue | |
| for dy in range(-r,r+1): | |
| y=ty+dy | |
| if y<0 or y>=n:continue | |
| k=(x,y) | |
| if k in seen:continue | |
| seen.add(k);out.append((z,x,y)) | |
| if len(out)>=int(cap):return out | |
| return out | |
| def _generate_forecast_by_date_raw(*,z:int,x:int,y:int,date_yyyymmdd:str,hours:int,dom:str)->tuple[bytes,dict]: | |
| sem=CPU_SEM_VALUE if _mode_get()=="value"else CPU_SEM_TILES | |
| with sem:return _generate_forecast_by_date_raw_impl(z=z,x=x,y=y,date_yyyymmdd=date_yyyymmdd,hours=hours,dom=dom) | |
| def _generate_forecast_by_date_raw_impl(*,z:int,x:int,y:int,date_yyyymmdd:str,hours:int,dom:str,cog_cache:CogCache|None=None)->tuple[bytes,dict]: | |
| dom=(dom or"zz").lower();valid=_norm_ts(f"{date_yyyymmdd}05") | |
| if int(hours)==24: | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom) | |
| if not sel.get("ok"):detail=dict(sel);detail["collab_last"]=dict(_COLLAB_LAST);detail["suggestion"]="forecast data may not be available yet for this date";raise HTTPException(status_code=503,detail=detail) | |
| melt24_union=_forecast_melt24h_union_end_cog(sel["valid"],dom_prefer=dom) | |
| melt_mm,melt_mask,oob=_tile_arrays_from_cog(melt24_union,z,x,y,cog_cache=cog_cache) | |
| if oob or melt_mm is None or melt_mask is None: | |
| raw=_pack_raw_tile_npz(melt_mm=np.zeros((256,256),dtype="float32"),valid_mask_u8=np.zeros((256,256),dtype="uint8")) | |
| return raw,{"X-Allowed":"1","X-OOB":"1","X-Melt-Info":f"melt24_union:{melt24_union.name}","X-SnowMask-Info":"snowpack_unavailable","X-Forecast-Valid":sel["valid"],"X-Forecast-RunInit-TT":sel["run_init"],"X-Forecast-Hours":"24","X-Raw-Scale":"10"} | |
| melt_valid=(melt_mask>0)&np.isfinite(melt_mm)&(melt_mm>-9990.0);melt_mask_u8=(melt_valid.astype("uint8")*255) | |
| snow_info="snowpack_unavailable" | |
| if sel.get("snowpack_urls"): | |
| try: | |
| snow_union=_forecast_snowpack_union_end_cog(sel.get("snowpack_ts")or sel["valid"],dom_prefer=dom) | |
| if snow_union:snow_info=f"snowpack_union:{snow_union.name}" | |
| except Exception as e:snow_info=f"snowpack_unavailable:{e!r}" | |
| raw=_pack_raw_tile_npz(melt_mm=melt_mm,valid_mask_u8=melt_mask_u8) | |
| return raw,{"X-Allowed":"1","X-OOB":"0","X-Melt-Info":f"melt24_union:{melt24_union.name}","X-SnowMask-Info":snow_info,"X-Forecast-Valid":sel["valid"],"X-Forecast-RunInit-TT":sel["run_init"],"X-Forecast-Hours":"24","X-Raw-Scale":"10"} | |
| run_init=_pick_best_run_init_for_valid_t0024(valid,dom_prefer=dom) | |
| if not run_init:raise HTTPException(status_code=503,detail={"error":"no_run_init_for_valid","valid":valid,"dom":dom,"suggestion":"forecast data may not be available yet for this date"}) | |
| melt72_cog=_forecast_melt72h_end_cog(valid,dom_prefer=dom) | |
| melt_mm,melt_mask,oob=_tile_arrays_from_cog(melt72_cog,z,x,y,cog_cache=cog_cache) | |
| if oob or melt_mm is None or melt_mask is None: | |
| raw=_pack_raw_tile_npz(melt_mm=np.zeros((256,256),dtype="float32"),valid_mask_u8=np.zeros((256,256),dtype="uint8")) | |
| return raw,{"X-Allowed":"1","X-OOB":"1","X-Forecast-Hours":"72","X-Forecast-Valid":valid,"X-Forecast-RunInit-TT":run_init,"X-Forecast-72h-COG":melt72_cog.name,"X-Raw-Scale":"10"} | |
| raw=_pack_raw_tile_npz(melt_mm=melt_mm,valid_mask_u8=melt_mask.astype("uint8",copy=False)) | |
| return raw,{"X-Allowed":"1","X-OOB":"0","X-Forecast-Hours":"72","X-Forecast-Valid":valid,"X-Forecast-RunInit-TT":run_init,"X-Forecast-72h-COG":melt72_cog.name,"X-Raw-Scale":"10"} | |
| _BAKING_MODE=threading.local() | |
| def _is_baking_mode()->bool:return getattr(_BAKING_MODE,"active",False) | |
| class _BakingMode: | |
| def __init__(self):self.prev=None | |
| def __enter__(self):self.prev=getattr(_BAKING_MODE,"active",False);_BAKING_MODE.active=True;return self | |
| def __exit__(self,*args):_BAKING_MODE.active=self.prev if self.prev is not None else False | |
| def _bake_lowres_box_tiles_for_date(*,date_yyyymmdd:str,dom:str,hours_list:list[int],zmin:int,zmax:int,conc:int=2,push_every:int=300)->dict: | |
| dom=(dom or"zz").lower();_log("DEBUG",f"[bake_lowres] Starting for {date_yyyymmdd}") | |
| boxes=[(-170.0,49.0,-40.0,90.0),(-97.0,37.0,-63.0,49.0)] | |
| tiles=_tiles_for_boxes_zoom_range(boxes,zmin=int(zmin),zmax=int(zmax)) | |
| if not tiles:return{"ok":False,"error":"no_lowres_tiles"} | |
| q=deque() | |
| for hours in hours_list: | |
| for(z,x,y)in tiles:q.append((int(hours),int(z),int(x),int(y))) | |
| total_jobs=len(q);ok=0;skip=0;fail=0;lk=threading.Lock();pending_paths_count=0 | |
| def worker(): | |
| nonlocal ok,skip,fail,pending_paths_count | |
| with _HfPullOnMiss(False): | |
| while True: | |
| with lk: | |
| if not q:return | |
| hours,z,x,y=q.popleft();processed=total_jobs-len(q) | |
| try: | |
| p=_tile_cache_path(dom,hours,date_yyyymmdd,z,x,y) | |
| if _is_valid_png_file(p): | |
| with lk:skip+=1 | |
| continue | |
| png,_=_generate_forecast_by_date_png_impl(z=z,x=x,y=y,date_yyyymmdd=date_yyyymmdd,hours=hours,dom=dom,max_in=None) | |
| _tile_cache_put(dom,hours,date_yyyymmdd,z,x,y,png) | |
| with lk:ok+=1;pending_paths_count+=1 | |
| if processed%200==0:_log("DEBUG",f"[bake_lowres] {date_yyyymmdd}: {processed}/{total_jobs}") | |
| except Exception as e: | |
| with lk: | |
| if fail<5:_log("WARN",f"[bake_lowres] tile failed z={z} x={x} y={y} err={e!r}") | |
| fail+=1 | |
| threads=[threading.Thread(target=worker,daemon=True)for _ in range(int(conc))] | |
| for t in threads:t.start() | |
| for t in threads:t.join() | |
| _log("INFO",f"[bake_lowres] {date_yyyymmdd} complete: ok={ok}, skip={skip}, fail={fail}, local_written={pending_paths_count}") | |
| return{"ok":True,"date":date_yyyymmdd,"ok_count":ok,"skip_count":skip,"fail_count":fail,"pushed_count":0,"remote_push":"deferred"} | |
| def _bake_lowres_box_raw_tiles_for_date(*,date_yyyymmdd:str,dom:str,hours_list:list[int],zmin:int,zmax:int,conc:int=2,push_every:int=300)->dict: | |
| dom=(dom or"zz").lower();_log("DEBUG",f"[bake_lowres_raw] Starting for {date_yyyymmdd}") | |
| boxes=[(-170.0,49.0,-40.0,90.0),(-97.0,37.0,-63.0,49.0)] | |
| tiles=_tiles_for_boxes_zoom_range(boxes,zmin=int(zmin),zmax=int(zmax)) | |
| if not tiles:return{"ok":False,"error":"no_lowres_tiles"} | |
| q=deque() | |
| for hours in hours_list: | |
| for(z,x,y)in tiles:q.append((int(hours),int(z),int(x),int(y))) | |
| total_jobs=len(q);ok=0;skip=0;fail=0;lk=threading.Lock();pending_paths_count=0 | |
| def worker(): | |
| nonlocal ok,skip,fail,pending_paths_count | |
| with _HfPullOnMiss(False): | |
| while True: | |
| with lk: | |
| if not q:return | |
| hours,z,x,y=q.popleft();processed=total_jobs-len(q) | |
| try: | |
| p=_raw_tile_cache_path(dom,hours,date_yyyymmdd,z,x,y) | |
| if _is_valid_npz_file(p): | |
| with lk:skip+=1 | |
| continue | |
| raw,_=_generate_forecast_by_date_raw_impl(z=z,x=x,y=y,date_yyyymmdd=date_yyyymmdd,hours=hours,dom=dom) | |
| _raw_tile_cache_put(dom,hours,date_yyyymmdd,z,x,y,raw) | |
| with lk:ok+=1;pending_paths_count+=1 | |
| if processed%200==0:_log("DEBUG",f"[bake_lowres_raw] {date_yyyymmdd}: {processed}/{total_jobs}") | |
| except Exception as e: | |
| with lk: | |
| if fail<5:_log("WARN",f"[bake_lowres_raw] tile failed z={z} x={x} y={y} err={e!r}") | |
| fail+=1 | |
| threads=[threading.Thread(target=worker,daemon=True)for _ in range(int(conc))] | |
| for t in threads:t.start() | |
| for t in threads:t.join() | |
| _log("INFO",f"[bake_lowres_raw] {date_yyyymmdd} complete: ok={ok}, skip={skip}, fail={fail}, local_written={pending_paths_count}") | |
| return{"ok":True,"date":date_yyyymmdd,"ok_count":ok,"skip_count":skip,"fail_count":fail,"pushed_count":0,"remote_push":"deferred"} | |
| def _hf_push_rawtilecache_for_window(window:list[str],dom:str,hours_list:list[int],zmin:int|None=None,zmax:int|None=None)->dict: | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if not tok or not repo or api is None:return{"ok":False,"error":"hf_not_configured"} | |
| dom=(dom or"zz").lower();root=_raw_tile_cache_dir() | |
| if not root.exists():return{"ok":True,"pushed":0,"skipped":0} | |
| want_dates=set(window);want_hours=set(int(h)for h in hours_list) | |
| ops:list[CommitOperationAdd]=[];pushed=0;skipped=0 | |
| for p in root.rglob(f"bydate_*{RAW_TILE_EXT}"): | |
| name=p.name;m=re.match(rf"^bydate_([a-z0-9]+)_h(\d+)_(\d{{8}})_z\d+_\d+_\d+\{re.escape(RAW_TILE_EXT)}$",name) | |
| if not m:continue | |
| f_dom,f_hours,f_ymd=m.group(1),int(m.group(2)),m.group(3) | |
| if f_dom!=dom or f_hours not in want_hours or f_ymd not in want_dates:continue | |
| mz=_TILE_Z_RE.search(name);z=int(mz.group(1))if mz else None | |
| if z is not None and zmin is not None and z<int(zmin):continue | |
| if z is not None and zmax is not None and z>int(zmax):continue | |
| if not _is_valid_npz_file(p):skipped+=1;continue | |
| ops.append(CommitOperationAdd(path_in_repo=f"rawtilecache/{name}",path_or_fileobj=p.as_posix())) | |
| if len(ops)>=int(HF_MAX_OPS_PER_COMMIT): | |
| api.create_commit(repo_id=repo,repo_type="dataset",token=tok,operations=ops,commit_message=f"rawtilecache: update dom={dom} files={len(ops)}") | |
| pushed+=len(ops);ops=[] | |
| if HF_PUSH_SLEEP_SEC>0:time.sleep(float(HF_PUSH_SLEEP_SEC)) | |
| if ops: | |
| api.create_commit(repo_id=repo,repo_type="dataset",token=tok,operations=ops,commit_message=f"rawtilecache: update dom={dom} files={len(ops)}") | |
| pushed+=len(ops) | |
| return{"ok":True,"pushed":pushed,"skipped":skipped,"dom":dom} | |
| def _png(rgb:np.ndarray,a:np.ndarray)->bytes:return render(rgb,mask=a.astype("uint8"),img_format="PNG") | |
| def _dilate_bool_1px(m:np.ndarray)->np.ndarray: | |
| m=m.astype(bool,copy=False) | |
| if m.size==0:return m | |
| p=np.pad(m,1,mode="constant",constant_values=False);out=p[1:-1,1:-1].copy() | |
| out|=p[:-2,1:-1];out|=p[2:,1:-1];out|=p[1:-1,:-2];out|=p[1:-1,2:] | |
| out|=p[:-2,:-2];out|=p[:-2,2:];out|=p[2:,:-2];out|=p[2:,2:] | |
| return out | |
| def _melt_to_png(melt_mm:np.ndarray,melt_mask:np.ndarray,max_in:Optional[float],snow_mm:Optional[np.ndarray],snow_valid:Optional[np.ndarray],*,snow_allow_min_mm:float=0.0,snow_underlay_min_mm:float=0.01,dilate_px:int=1,bin_edges_in:Optional[np.ndarray]=None)->bytes: | |
| mm=melt_mm.astype("float32");melt_valid=(melt_mask>0)&np.isfinite(mm)&(mm>-9990.0) | |
| mm=np.where(melt_valid,np.maximum(mm,0.0),np.nan).astype("float32") | |
| snow_underlay=np.zeros_like(melt_valid,dtype=bool);snow_allow=None | |
| if snow_mm is not None and snow_valid is not None: | |
| snow=snow_mm.astype("float32");snow_ok=snow_valid&np.isfinite(snow)&(snow>-9990.0) | |
| snow_underlay=snow_ok&(snow>=float(snow_underlay_min_mm));snow_allow=snow_ok&(snow>=float(snow_allow_min_mm)) | |
| if dilate_px and dilate_px>0: | |
| for _ in range(int(dilate_px)):snow_allow=_dilate_bool_1px(snow_allow) | |
| melt_valid=melt_valid&snow_allow | |
| inches=mm/INCH_TO_MM | |
| if max_in is not None:inches=np.minimum(inches,float(max_in)) | |
| melt_vis=np.isfinite(inches)&(inches>0.0)&melt_valid;snow_only=snow_underlay&(~melt_vis) | |
| h,w=mm.shape;rgb=np.zeros((3,h,w),dtype="uint8");alpha=np.zeros((h,w),dtype="uint8") | |
| if snow_only.any():rgb[:,snow_only]=np.array(MELT_COLORS[0],dtype="uint8").reshape(3,1);alpha[snow_only]=255 | |
| if melt_vis.any(): | |
| filled=np.where(melt_vis,inches,0.0).astype("float32") | |
| edges=bin_edges_in if bin_edges_in is not None else BIN_EDGES_IN | |
| idx=np.clip(np.digitize(filled,edges,right=True),0,len(MELT_COLORS)-1) | |
| rgb[:,melt_vis]=np.moveaxis(MELT_COLORS[idx],-1,0)[:,melt_vis];alpha[melt_vis]=255 | |
| return _png(rgb,alpha) | |
| CACHE_CONTROL_TILES=os.environ.get("TILES_CACHE_CONTROL","public, max-age=604800, immutable") | |
| def _resp_png(data:bytes,*,cache_control:str="public, max-age=31536000, immutable",**extra_headers)->Response: | |
| etag=hashlib.md5(data).hexdigest()[:16] | |
| headers={"Cache-Control":cache_control,"ETag":f'"{etag}"',"Content-Type":"image/png","X-Content-Type-Options":"nosniff"} | |
| for k,v in extra_headers.items(): | |
| if v is not None:headers[k]=str(v) | |
| return Response(content=data,media_type="image/png",headers=headers) | |
| _COG_READ_LOCKS: dict[str, threading.Lock] = {} | |
| _COG_READ_LOCKS_LK = threading.Lock() | |
| def _cog_read_lock(cog: Path) -> threading.Lock: | |
| key = cog.as_posix() | |
| with _COG_READ_LOCKS_LK: | |
| lk = _COG_READ_LOCKS.get(key) | |
| if lk is None: | |
| lk = threading.Lock(); _COG_READ_LOCKS[key] = lk | |
| return lk | |
| def _tile_arrays_from_cog(cog: Path, z: int, x: int, y: int, *, cog_cache: CogCache = None) -> tuple[Optional[np.ndarray], Optional[np.ndarray], bool]: | |
| use_global = (cog_cache is None) | |
| if use_global: cog_cache = COG_CACHE | |
| if (not cog.exists()) or (cog.stat().st_size <= 0): | |
| raise HTTPException(status_code=500, detail=f"COG missing: {cog.name}") | |
| def _read_once(): | |
| r = cog_cache.get(cog) | |
| data, mask = r.tile(x, y, z, tilesize=256, resampling_method="nearest") | |
| return data[0].astype("float32"), mask[0].astype("uint8"), False | |
| if use_global: | |
| lk = _cog_read_lock(cog) | |
| def _read_locked(): | |
| with lk: return _read_once() | |
| read_fn = _read_locked | |
| else: | |
| read_fn = _read_once | |
| try: return read_fn() | |
| except TileOutsideBounds: return None, None, True | |
| except Exception: | |
| try: cog_cache.evict(cog) | |
| except: pass | |
| try: return read_fn() | |
| except TileOutsideBounds: return None, None, True | |
| except Exception as e2: | |
| msg = str(e2); _log("WARN", f"[cog_read] failed cog={cog.name} z={z} x={x} y={y} err={e2!r}") | |
| if ("TIFFReadEncodedTile" in msg) or ("ZIPDecode" in msg) or ("Decoding error" in msg): | |
| try: cog_cache.evict(cog) | |
| except: pass | |
| if use_global: | |
| try: _safe_unlink(cog); _log("WARN", f"[cog_read] deleted corrupted COG: {cog.name}") | |
| except: pass | |
| return None, None, True | |
| def _tile_png_from_cog(cog:Path,z:int,x:int,y:int,max_in:Optional[float])->bytes: | |
| arr,mask,oob=_tile_arrays_from_cog(cog,z,x,y,cog_cache=COG_CACHE) | |
| if oob or arr is None or mask is None:return _transparent_png_256() | |
| return _melt_to_png(arr,mask,max_in,None,None) | |
| def _serve_forecast_tile(run_init:str,valid:str,lead:str,melt_urls:list[str],snow_ts:str,snow_urls:list[str],z:int,x:int,y:int,max_in:float|None)->Response: | |
| run_init=_norm_ts(run_init);valid=_norm_ts(valid);lead=(lead or"").lower() | |
| melt_cogs=_build_forecast_melt_cogs(run_init,valid,lead,melt_urls) | |
| snow=snow_valid=None;snow_info="mask_unavailable" | |
| if snow_urls:snow,snow_valid,snow_info=_read_mask_tile(snow_ts or valid,snow_urls,z,x,y) | |
| melt_mm,melt_valid,melt_info=_union_tile(melt_cogs,z,x,y,label="melt") | |
| if melt_mm is None or melt_valid is None:return _resp_png(_transparent_png_256(),**{"X-Melt-Info":melt_info,"X-NoData":"1"}) | |
| melt_mask_u8=(melt_valid.astype("uint8")*255) | |
| png=_melt_to_png(melt_mm,melt_mask_u8,max_in,snow,snow_valid,snow_allow_min_mm=0.0,snow_underlay_min_mm=0.0001,dilate_px=2) | |
| return _resp_png(png,**{"X-Forecast-RunInit-TT":run_init,"X-Forecast-Valid-TS":valid,"X-Forecast-Lead":lead,"X-Melt-Info":melt_info,"X-SnowMask-Info":snow_info,"X-SnowMask-TS":(snow_ts or valid or"")}) | |
| def _corridor_tiles_for_zoom_range(zmin:int,zmax:int,*,miles:float,cap_per_zoom:int)->list[tuple[int,int,int]]: | |
| pts=_load_track_points() | |
| if not pts:return[] | |
| out:list[tuple[int,int,int]]=[];seen:set[tuple[int,int,int]]=set() | |
| for z in range(int(zmin),int(zmax)+1): | |
| tiles=_tiles_within_miles(pts,z,float(miles),cap=int(cap_per_zoom)) | |
| for(zz,x,y)in tiles: | |
| if not _tile_allowed(zz,x,y):continue | |
| t=(zz,x,y) | |
| if t in seen:continue | |
| seen.add(t);out.append(t) | |
| return out | |
| def _point_mm_from_cog(cog:Path,lon:float,lat:float)->tuple[Optional[float],str]: | |
| if not cog.exists()or cog.stat().st_size<=0:return None,f"missing:{cog.name}" | |
| try: | |
| with COGReader(cog.as_posix())as r:data,mask=_cog_point_data_mask(r,lon,lat) | |
| v=float(np.ravel(data)[0]);m=int(np.ravel(mask)[0]) | |
| if m<=0 or(not np.isfinite(v))or(v<=-9990.0):return None,f"nodata:{cog.name}" | |
| return v,f"ok:{cog.name}" | |
| except TileOutsideBounds:return None,f"oob:{cog.name}" | |
| except Exception as e:return None,f"error:{cog.name}:{e!r}" | |
| def _union_point_mm(cogs:list[Path],lon:float,lat:float)->tuple[Optional[float],str]: | |
| for cog in sorted(cogs,key=lambda p:_dom_pri(_cog_dom(p))): | |
| v,info=_point_mm_from_cog(cog,lon,lat) | |
| if v is not None:return v,info | |
| return None,"all_domains_failed" | |
| def _bake_corridor_tiles_for_date(*,date_yyyymmdd:str,dom:str,hours_list:list[int],zmin:int,zmax:int,miles:float,cap_per_zoom:int,max_tiles_total:int,conc:int=2,push_every:int=500)->dict: | |
| dom=(dom or"zz").lower();_log("DEBUG",f"[bake_corridor] Starting for {date_yyyymmdd}") | |
| tiles=_corridor_tiles_for_zoom_range(zmin,zmax,miles=float(miles),cap_per_zoom=int(cap_per_zoom)) | |
| if not tiles:return{"ok":False,"error":"no_corridor_tiles"} | |
| tiles=tiles[:int(max_tiles_total)];_log("DEBUG",f"[bake_corridor] {len(tiles)} tiles to process") | |
| q=deque() | |
| for hours in hours_list: | |
| for(z,x,y)in tiles:q.append((int(hours),int(z),int(x),int(y))) | |
| total_jobs=len(q);ok=0;skip=0;fail=0;lk=threading.Lock();pending_push:list[Path]=[];total_pushed=0;push_lk=threading.Lock() | |
| def maybe_push(): | |
| nonlocal pending_push,total_pushed | |
| with push_lk: | |
| if len(pending_push)>=push_every: | |
| batch=pending_push[:push_every];pending_push=pending_push[push_every:] | |
| result=_incremental_hf_push(batch,"tilecache",f"{date_yyyymmdd}_corridor_png_{total_pushed}") | |
| if result.get("ok"):total_pushed+=result.get("pushed",0);_log("DEBUG",f"[bake_corridor] Pushed {result.get('pushed',0)} tiles to HF (total: {total_pushed})") | |
| else:_log("WARN",f"[bake_corridor] Push failed: {result}") | |
| def worker(): | |
| nonlocal ok,skip,fail,pending_push | |
| with _HfPullOnMiss(False): | |
| while True: | |
| with lk: | |
| if not q:return | |
| hours,z,x,y=q.popleft();processed=total_jobs-len(q) | |
| try: | |
| p=_tile_cache_path(dom,hours,date_yyyymmdd,z,x,y) | |
| if _is_valid_png_file(p): | |
| with lk:skip+=1 | |
| continue | |
| png,_=_generate_forecast_by_date_png_impl(z=z,x=x,y=y,date_yyyymmdd=date_yyyymmdd,hours=hours,dom=dom,max_in=None) | |
| _tile_cache_put(dom,hours,date_yyyymmdd,z,x,y,png) | |
| with lk:ok+=1;pending_push.append(p) | |
| maybe_push() | |
| if processed%500==0:_log("DEBUG",f"[bake_corridor] {date_yyyymmdd}: {processed}/{total_jobs} (ok={ok}, skip={skip}, fail={fail})") | |
| except Exception as e: | |
| with lk: | |
| if fail<5:_log("WARN",f"[bake_corridor] tile failed z={z} x={x} y={y} err={e!r}") | |
| fail+=1 | |
| threads=[threading.Thread(target=worker,daemon=True)for _ in range(int(conc))] | |
| for t in threads:t.start() | |
| for t in threads:t.join() | |
| with push_lk: | |
| if pending_push: | |
| result=_incremental_hf_push(pending_push,"tilecache",f"{date_yyyymmdd}_corridor_png_final") | |
| if result.get("ok"):total_pushed+=result.get("pushed",0) | |
| _log("DEBUG",f"[bake_corridor] Final push: {result}");pending_push=[] | |
| _log("INFO",f"[bake_corridor] {date_yyyymmdd} complete: ok={ok}, skipped={skip}, fail={fail}, pushed={total_pushed}") | |
| return{"ok":True,"date":date_yyyymmdd,"dom":dom,"hours_list":hours_list,"tiles":len(tiles),"jobs":total_jobs,"ok_count":ok,"skip_count":skip,"fail_count":fail,"pushed_count":total_pushed,"zmin":zmin,"zmax":zmax} | |
| def _rolling_dates_local_window(today_local=None, *, lookback: int = 1, lookahead: int = 3) -> list[str]: | |
| if today_local is None: | |
| today_local = datetime.now(LOCAL_TZ).date() | |
| return sorted([(today_local + timedelta(days=d)).strftime("%Y%m%d") for d in range(-int(lookback), int(lookahead) + 1)]) | |
| def _keep_cutoff_ymd(keep_days: int = _REMOTE_KEEP_DAYS) -> str: | |
| k = int(keep_days) | |
| today_local = datetime.now(LOCAL_TZ).date() | |
| if k <= 0: | |
| cutoff_date = today_local | |
| else: | |
| cutoff_date = today_local - timedelta(days=(k - 1)) | |
| return cutoff_date.strftime("%Y%m%d") | |
| def _prune_tile_cache(keep_ymds: set[str], keep_days_fallback: int = 0) -> int: | |
| deleted = 0 | |
| root = _tile_cache_dir() | |
| if not root.exists(): | |
| return 0 | |
| for dom_dir in root.iterdir(): | |
| if not dom_dir.is_dir(): | |
| continue | |
| for hours_dir in dom_dir.iterdir(): | |
| if not hours_dir.is_dir(): | |
| continue | |
| for ymd_dir in hours_dir.iterdir(): | |
| if not ymd_dir.is_dir(): | |
| continue | |
| ymd = ymd_dir.name | |
| if not re.fullmatch(r"[0-9]{8}", ymd): | |
| continue | |
| if ymd in keep_ymds: | |
| continue | |
| try: | |
| shutil.rmtree(ymd_dir, ignore_errors=True) | |
| deleted += 1 | |
| _log("INFO", f"[prune_tile] deleted local tilecache dir: {ymd_dir.relative_to(CACHE)}") | |
| except: | |
| pass | |
| return deleted | |
| def _prune_raw_tile_cache(keep_ymds: set[str], keep_days_fallback: int = 0) -> int: | |
| deleted = 0 | |
| root = _raw_tile_cache_dir() | |
| if not root.exists(): | |
| return 0 | |
| for dom_dir in root.iterdir(): | |
| if not dom_dir.is_dir(): | |
| continue | |
| for hours_dir in dom_dir.iterdir(): | |
| if not hours_dir.is_dir(): | |
| continue | |
| for ymd_dir in hours_dir.iterdir(): | |
| if not ymd_dir.is_dir(): | |
| continue | |
| ymd = ymd_dir.name | |
| if not re.fullmatch(r"[0-9]{8}", ymd): | |
| continue | |
| # ONLY keep dates explicitly in the keep set | |
| if ymd in keep_ymds: | |
| continue | |
| try: | |
| shutil.rmtree(ymd_dir, ignore_errors=True) | |
| deleted += 1 | |
| _log("INFO", f"[prune_raw] deleted local rawtilecache dir: {ymd_dir.relative_to(CACHE)}") | |
| except: | |
| pass | |
| return deleted | |
| def _prune_forecast_cogs(keep_days: int = 0, *, keep_ymds: set[str] | None = None) -> int: | |
| deleted = 0 | |
| fc = _forecast_dir() | |
| if not fc.exists(): | |
| return 0 | |
| for p in fc.glob("*_cog.tif"): | |
| ymd = _file_ymd_from_name(p.name) | |
| if not ymd: | |
| continue | |
| if keep_ymds is not None: | |
| if ymd in keep_ymds: | |
| continue | |
| else: | |
| cutoff = _keep_cutoff_ymd(keep_days) | |
| if ymd >= cutoff: | |
| continue | |
| try: | |
| p.unlink(missing_ok=True) | |
| deleted += 1 | |
| _log("INFO", f"[prune_cog] deleted forecast COG: {p.name}") | |
| except: | |
| pass | |
| return deleted | |
| def _ensure_forecast_cogs_for_date(date_yyyymmdd:str,dom:str)->dict: | |
| dom=(dom or"zz").lower();valid=_norm_ts(f"{date_yyyymmdd}05") | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom) | |
| if not sel.get("ok"):return{"ok":False,"date":date_yyyymmdd,"error":"pick_failed","detail":sel} | |
| try:_build_forecast_melt_cogs(sel["run_init"],sel["valid"],"t0024",sel["melt_urls"]) | |
| except Exception as e:return{"ok":False,"date":date_yyyymmdd,"valid":sel["valid"],"error":f"melt_build_failed:{e!r}"} | |
| if sel.get("snowpack_urls"): | |
| try:_build_forecast_snowpack_cogs(sel.get("snowpack_ts")or sel["valid"],sel["snowpack_urls"]) | |
| except:pass | |
| melt24_union=snow_union=melt72=None | |
| try:melt24_union=_forecast_melt24h_union_end_cog(sel["valid"],dom_prefer=dom) | |
| except Exception as e:_log("WARN",f"[ensure_forecast] 24h UNION melt failed for {sel['valid']}: {e!r}") | |
| try:snow_union=_forecast_snowpack_union_end_cog(sel.get("snowpack_ts")or sel["valid"],dom_prefer=dom) | |
| except Exception as e:_log("WARN",f"[ensure_forecast] UNION snowpack failed for {sel['valid']}: {e!r}") | |
| try:melt72=_forecast_melt72h_end_cog(sel["valid"],dom_prefer=dom) | |
| except Exception as e:_log("WARN",f"[ensure_forecast] 72h COG failed for {sel['valid']}: {e!r}") | |
| return{"ok":True,"date":date_yyyymmdd,"valid":sel["valid"],"tt":sel["run_init"],"dom":dom,"melt24_union_cog":(melt24_union.name if melt24_union else None),"snow_union_cog":(snow_union.name if snow_union else None),"melt72_cog":(melt72.name if melt72 else None)} | |
| def _ensure_forecast_for_valid(valid: str, dom_prefer: str = "zz") -> dict: | |
| dom_prefer = (dom_prefer or "zz").lower() | |
| valid = _norm_ts(valid) | |
| date_yyyymmdd = valid[:8] | |
| sel = _pick_forecast_for_valid(valid, dom_prefer=dom_prefer) | |
| if not sel.get("ok"): | |
| return { | |
| "ok": False, | |
| "date": date_yyyymmdd, | |
| "valid": valid, | |
| "error": "pick_failed", | |
| "detail": sel, | |
| } | |
| try: | |
| _build_forecast_melt_cogs( | |
| sel["run_init"], | |
| sel["valid"], | |
| "t0024", | |
| sel["melt_urls"], | |
| ) | |
| except Exception as e: | |
| return { | |
| "ok": False, | |
| "date": date_yyyymmdd, | |
| "valid": valid, | |
| "error": f"melt_build_failed:{e!r}", | |
| } | |
| if sel.get("snowpack_urls"): | |
| try: | |
| _build_forecast_snowpack_cogs( | |
| sel.get("snowpack_ts") or sel["valid"], | |
| sel["snowpack_urls"], | |
| ) | |
| except Exception: | |
| pass | |
| melt24_union = None | |
| snow_union = None | |
| melt72 = None | |
| try: | |
| melt24_union = _forecast_melt24h_union_end_cog( | |
| sel["valid"], | |
| dom_prefer=dom_prefer, | |
| ) | |
| except Exception as e: | |
| _log("WARN", f"[ensure_forecast] 24h UNION melt failed for {valid}: {e!r}") | |
| try: | |
| snow_union = _forecast_snowpack_union_end_cog( | |
| sel.get("snowpack_ts") or sel["valid"], | |
| dom_prefer=dom_prefer, | |
| ) | |
| except Exception as e: | |
| _log("WARN", f"[ensure_forecast] UNION snowpack failed for {valid}: {e!r}") | |
| try: | |
| melt72 = _forecast_melt72h_end_cog( | |
| sel["valid"], | |
| dom_prefer=dom_prefer, | |
| ) | |
| except Exception as e: | |
| _log("WARN", f"[ensure_forecast] 72h COG failed for {valid}: {e!r}") | |
| return { | |
| "ok": True, | |
| "date": date_yyyymmdd, | |
| "valid": sel["valid"], | |
| "tt": sel["run_init"], | |
| "dom": dom_prefer, | |
| "melt24_union_cog": (melt24_union.name if melt24_union else None), | |
| "snow_union_cog": (snow_union.name if snow_union else None), | |
| "melt72_cog": (melt72.name if melt72 else None), | |
| } | |
| def _next_run_local(now:datetime|None=None)->datetime: | |
| now=now or datetime.now(LOCAL_TZ);target=now.replace(hour=SCHEDULE_HOUR_LOCAL,minute=SCHEDULE_MIN_LOCAL,second=0,microsecond=0) | |
| return target+timedelta(days=1)if target<=now else target | |
| def _bake_corridor_tiles_for_date_optimized(*,date_yyyymmdd:str,dom:str,hours_list:list[int],zmin:int,zmax:int,miles:float,cap_per_zoom:int,max_tiles_total:int,push_every:int=300)->dict: | |
| dom=(dom or"zz").lower();_log("INFO",f"[bake_corridor_opt] Starting for {date_yyyymmdd}") | |
| tiles=_corridor_tiles_for_zoom_range(zmin,zmax,miles=float(miles),cap_per_zoom=int(cap_per_zoom)) | |
| if not tiles:return{"ok":False,"error":"no_tiles"} | |
| tiles=tiles[:int(max_tiles_total)];jobs=[(int(h),int(z),int(x),int(y))for h in hours_list for(z,x,y)in tiles] | |
| total_jobs=len(jobs);_log("INFO",f"[bake_corridor_opt] {total_jobs} tiles to process");ok=0;skip=0;fail=0;local_written=0 | |
| with CogCache()as cog_cache: | |
| _log("INFO",f"[bake_corridor_opt] Pre-loading COGs for {date_yyyymmdd}");preloaded=_preload_forecast_for_date(date_yyyymmdd,dom,cog_cache) | |
| _log("INFO",f"[bake_corridor_opt] Preload complete: h24={preloaded.get('h24',{}).get('ok',False)}, h72={preloaded.get('h72',{}).get('ok',False)}") | |
| for i,(hours,z,x,y)in enumerate(jobs): | |
| try: | |
| p=_tile_cache_path(dom,hours,date_yyyymmdd,z,x,y) | |
| if _is_valid_png_file(p):skip+=1;continue | |
| png,is_valid=_generate_tile_from_preloaded(z,x,y,hours,preloaded,cog_cache=cog_cache) | |
| _tile_cache_put(dom,hours,date_yyyymmdd,z,x,y,png);ok+=1;local_written+=1 | |
| except Exception as e: | |
| if fail<5:_log("WARN",f"[bake_corridor_opt] tile failed z={z} x={x} y={y}: {e!r}") | |
| fail+=1 | |
| if(i+1)%500==0:_log("DEBUG",f"[bake_corridor_opt] {date_yyyymmdd}: {i+1}/{total_jobs} (ok={ok}, skip={skip}, fail={fail})") | |
| _log("INFO",f"[bake_corridor_opt] {date_yyyymmdd} complete: ok={ok}, skip={skip}, fail={fail}, local_written={local_written}") | |
| return{"ok":True,"date":date_yyyymmdd,"jobs":total_jobs,"ok_count":ok,"skip_count":skip,"fail_count":fail,"pushed_count":0,"remote_push":"deferred"} | |
| def _bake_corridor_raw_tiles_for_date_optimized(*,date_yyyymmdd:str,dom:str,hours_list:list[int],zmin:int,zmax:int,miles:float,cap_per_zoom:int,max_tiles_total:int,push_every:int=300)->dict: | |
| dom=(dom or"zz").lower();_log("INFO",f"[bake_corridor_raw_opt] Starting for {date_yyyymmdd}") | |
| tiles=_corridor_tiles_for_zoom_range(zmin,zmax,miles=float(miles),cap_per_zoom=int(cap_per_zoom)) | |
| if not tiles:return{"ok":False,"error":"no_tiles"} | |
| tiles=tiles[:int(max_tiles_total)];jobs=[(int(h),int(z),int(x),int(y))for h in hours_list for(z,x,y)in tiles] | |
| total_jobs=len(jobs);_log("INFO",f"[bake_corridor_raw_opt] {total_jobs} tiles to process");ok=0;skip=0;fail=0;pending_push:list[Path]=[];total_pushed=0 | |
| with CogCache()as cog_cache: | |
| _log("INFO",f"[bake_corridor_raw_opt] Pre-loading COGs for {date_yyyymmdd}") | |
| preloaded=_preload_forecast_for_date(date_yyyymmdd,dom,cog_cache) | |
| _log("INFO",f"[bake_corridor_raw_opt] Preload complete: h24={preloaded.get('h24',{}).get('ok',False)}, h72={preloaded.get('h72',{}).get('ok',False)}") | |
| for i,(hours,z,x,y)in enumerate(jobs): | |
| try: | |
| p=_raw_tile_cache_path(dom,hours,date_yyyymmdd,z,x,y) | |
| if _is_valid_npz_file(p):skip+=1;continue | |
| raw,_=_generate_forecast_by_date_raw_preloaded(z=z,x=x,y=y,date_yyyymmdd=date_yyyymmdd,hours=hours,dom=dom,preloaded=preloaded,cog_cache=cog_cache) | |
| _raw_tile_cache_put(dom,hours,date_yyyymmdd,z,x,y,raw);ok+=1;pending_push.append(p) | |
| except Exception as e: | |
| if fail<5:_log("WARN",f"[bake_corridor_raw_opt] tile failed z={z} x={x} y={y}: {e!r}") | |
| fail+=1 | |
| if len(pending_push)>=push_every: | |
| result=_incremental_hf_push(pending_push,"rawtilecache",f"{date_yyyymmdd}_corridor_raw_{total_pushed}") | |
| if result.get("ok"):total_pushed+=result.get("pushed",0) | |
| pending_push=[] | |
| if(i+1)%500==0:_log("DEBUG",f"[bake_corridor_raw_opt] {date_yyyymmdd}: {i+1}/{total_jobs} (ok={ok}, skip={skip}, fail={fail}, pushed={total_pushed})") | |
| if pending_push: | |
| result=_incremental_hf_push(pending_push,"rawtilecache",f"{date_yyyymmdd}_corridor_raw_final") | |
| if result.get("ok"):total_pushed+=result.get("pushed",0) | |
| _log("INFO",f"[bake_corridor_raw_opt] {date_yyyymmdd} complete: ok={ok}, skip={skip}, fail={fail}, pushed={total_pushed}") | |
| return{"ok":True,"date":date_yyyymmdd,"ok_count":ok,"skip_count":skip,"fail_count":fail,"pushed_count":total_pushed} | |
| _PUSH_PAUSED = threading.Event() | |
| _PUSH_PAUSED.clear() | |
| def _hf_push_baked_dates(baked_ymds: list[str], dom: str, hours_list: list[int]) -> dict: | |
| tok, repo = _hf_cfg(); api = _hf_api() | |
| if not tok or not repo or api is None: | |
| return {"ok": False, "error": "hf_not_configured"} | |
| dom = (dom or "zz").lower(); pushed = 0; errors = 0 | |
| for ymd in baked_ymds: | |
| for h in hours_list: | |
| for kind in ["tilecache", "rawtilecache"]: | |
| base = CACHE / kind / dom / str(int(h)) / ymd | |
| if not base.exists(): continue | |
| while _PUSH_PAUSED.is_set(): | |
| time.sleep(2.0) | |
| ext = ".png" if kind == "tilecache" else ".npz" | |
| ops = []; total_bytes = 0 | |
| for p in base.rglob(f"*{ext}"): | |
| sz = p.stat().st_size | |
| if sz < 60: continue | |
| try: | |
| rel = p.relative_to(CACHE).as_posix() | |
| ops.append(CommitOperationAdd(path_in_repo=rel, path_or_fileobj=str(p))) | |
| total_bytes += sz | |
| except: pass | |
| if not ops: continue | |
| _log("INFO", f"[hf_push] {kind}/{ymd}/h{h}: {len(ops)} files, {total_bytes/1024/1024:.1f} MB total") | |
| for i in range(0, len(ops),750): | |
| while _PUSH_PAUSED.is_set(): | |
| time.sleep(2.0) | |
| batch = ops[i:i+750] | |
| batch_bytes = sum(Path(op.path_or_fileobj).stat().st_size for op in batch if hasattr(op, 'path_or_fileobj')) | |
| batch_num = i // 750+ 1 | |
| ok = False | |
| for attempt in range(3): | |
| t0 = time.time() | |
| try: | |
| api.create_commit(repo_id=repo, repo_type="dataset", operations=batch, | |
| commit_message=f"{kind}: {ymd} h{h} batch {batch_num} ({len(batch)} files)") | |
| elapsed = time.time() - t0 | |
| pushed += len(batch) | |
| ok = True | |
| _log("INFO", f"[hf_push] {kind}/{ymd}/h{h} batch {batch_num}: OK {len(batch)} files, {batch_bytes/1024:.0f} KB in {elapsed:.1f}s") | |
| break | |
| except Exception as e: | |
| elapsed = time.time() - t0 | |
| err_str = repr(e) | |
| if "ReadTimeout" in err_str and elapsed >= 9.0: | |
| _log("INFO", f"[hf_push] {kind}/{ymd}/h{h} batch {batch_num}: timeout after {elapsed:.1f}s — likely succeeded (attempt {attempt+1})") | |
| pushed += len(batch); ok = True; time.sleep(2.0); break | |
| _log("WARN", f"[hf_push] {kind}/{ymd}/h{h} batch {batch_num} attempt {attempt+1}: {type(e).__name__} after {elapsed:.1f}s — {len(batch)} files, {batch_bytes/1024:.0f} KB") | |
| time.sleep(10.0 * (attempt + 1)) | |
| if not ok: | |
| errors += len(batch) | |
| else: | |
| time.sleep(2.0) | |
| _log("INFO", f"[hf_push] {kind}/{ymd}/h{h}: done (pushed={pushed} errors={errors})") | |
| _log("INFO", f"[hf_push_baked] done: pushed={pushed} errors={errors}") | |
| return {"ok": True, "pushed": pushed, "errors": errors} | |
| def _run_daily_roll_job_impl()->dict: | |
| if not _try_acquire_run_lock(_ROLL_LOCK_NAME,stale_seconds=_ROLL_LOCK_STALE_SECONDS): | |
| _log("INFO","[roll] skipped: already running");return{"ok":True,"skipped":True,"reason":"already_running"} | |
| try: | |
| import gc;started=datetime.now(LOCAL_TZ).isoformat();today_local=datetime.now(LOCAL_TZ).date() | |
| window=_rolling_dates_local_window(today_local=today_local,lookback=2,lookahead=3);keep_set=set(window) | |
| _log("INFO",f"[roll] local_today={today_local} window={window}") | |
| need=[ymd for ymd in window if not _date_is_baked(WARM_DOM,ymd)] | |
| already=[y for y in window if y not in need] | |
| _log("INFO",f"[roll] need={need} already_baked={already}") | |
| if not need: | |
| _log("INFO","[roll] all dates baked — prune only") | |
| try:_hf_prune_remote_and_local(keep_ymds=keep_set) | |
| except Exception as e:_log("WARN",f"[roll] prune failed: {e!r}") | |
| return{"ok":True,"skipped":True,"reason":"nothing_needed","window":window,"finished":datetime.now(LOCAL_TZ).isoformat()} | |
| _log("INFO","[roll] Step 2: Pulling forecast COGs + tar archives") | |
| try:_hf_pull_cache() | |
| except Exception as e:_log("WARN",f"[roll] HF pull failed: {e!r}") | |
| need=[ymd for ymd in need if not _date_is_baked(WARM_DOM,ymd)] | |
| _log("INFO",f"[roll] after pull, still need={need}") | |
| if not need: | |
| try:_hf_prune_remote_and_local(keep_ymds=keep_set) | |
| except:pass | |
| return{"ok":True,"skipped":True,"reason":"hf_pull_satisfied","window":window,"finished":datetime.now(LOCAL_TZ).isoformat()} | |
| _log("INFO","[roll] Step 3: Pruning old tiles") | |
| try: | |
| dt=_prune_tile_cache(keep_set);dr=_prune_raw_tile_cache(keep_set);dc=_prune_forecast_cogs(keep_ymds=keep_set) | |
| _log("INFO",f"[roll] pruned tiles={dt} raw={dr} cogs={dc}") | |
| except Exception as e:_log("WARN",f"[roll] prune failed: {e!r}") | |
| try:_tile_png_cached.cache_clear() | |
| except:pass | |
| today_str=today_local.strftime("%Y%m%d");tmrw_str=(today_local+timedelta(days=1)).strftime("%Y%m%d") | |
| need=sorted(need,key=lambda y:(0 if y==today_str else 1 if y==tmrw_str else 2,y)) | |
| _log("INFO",f"[roll] Step 4: Baking {len(need)} date(s) (priority order: {need})") | |
| results={"ok":True,"started":started,"window":window,"need":need,"dates":{}};baked_ymds=[];failed_ymds=[] | |
| for i,ymd in enumerate(need): | |
| _log("INFO",f"[roll] === {ymd} ({i+1}/{len(need)}) ===");dr={"date":ymd} | |
| cog_ok=False | |
| for cog_attempt in range(5): | |
| try: | |
| r=_ensure_forecast_for_valid(_norm_ts(f"{ymd}05"),dom_prefer=WARM_DOM) | |
| if r.get("ok"):cog_ok=True;dr["cog"]={"ok":True};break | |
| else:_log("WARN",f"[roll] {ymd}: COG attempt {cog_attempt+1}/5 failed") | |
| except Exception as e:_log("WARN",f"[roll] {ymd}: COG attempt {cog_attempt+1}/5 exception: {e!r}") | |
| if cog_attempt<4:sleep_sec=10*(2**cog_attempt);_log("INFO",f"[roll] {ymd}: retrying COG in {sleep_sec}s");time.sleep(sleep_sec) | |
| if not cog_ok:_log("WARN",f"[roll] {ymd}: COG failed after 5 attempts");dr["cog"]={"ok":False};results["dates"][ymd]=dr;failed_ymds.append(ymd);continue | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=2)as pool: | |
| futs={} | |
| # Bake sequentially: PNG first, then raw | |
| for label,fn,extra in[("corridor_png",_bake_corridor_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL)),("corridor_raw",_bake_corridor_raw_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL))]: | |
| try: | |
| r=fn(date_yyyymmdd=ymd,dom=WARM_DOM,hours_list=WARM_HOURS_LIST,zmin=WARM_ZMIN,zmax=WARM_ZMAX,**extra) | |
| dr[label]={"ok":r.get("ok_count",0),"skip":r.get("skip_count",0),"fail":r.get("fail_count",0)} | |
| _log("INFO",f"[roll] {ymd}: {label} ok={r.get('ok_count',0)} skip={r.get('skip_count',0)} fail={r.get('fail_count',0)}") | |
| except Exception as e:_log("WARN",f"[roll] {ymd}: {label} exception: {e!r}");dr[label]={"error":repr(e)} | |
| for label,fn,extra in[("lowres_png",_bake_lowres_box_tiles_for_date,dict(conc=1)),("lowres_raw",_bake_lowres_box_raw_tiles_for_date,dict(conc=1))]: | |
| try: | |
| r=fn(date_yyyymmdd=ymd,dom=WARM_DOM,hours_list=WARM_HOURS_LIST,zmin=LOWRES_BOX_ZMIN,zmax=LOWRES_BOX_ZMAX,**extra) | |
| dr[label]={"ok":r.get("ok_count",0),"skip":r.get("skip_count",0),"fail":r.get("fail_count",0)} | |
| _log("INFO",f"[roll] {ymd}: {label} ok={r.get('ok_count',0)} skip={r.get('skip_count',0)} fail={r.get('fail_count',0)}") | |
| except Exception as e:_log("WARN",f"[roll] {ymd}: {label} exception: {e!r}");dr[label]={"error":repr(e)} | |
| try: | |
| best=_find_best_melt_for_date(ymd,dom_prefer=WARM_DOM) | |
| if best.get("ok")and best.get("melt_urls"):_record_baked_sig(ymd,WARM_DOM,best["melt_urls"][0],source_type=best.get("source_type")) | |
| except:pass | |
| results["dates"][ymd]=dr;baked_ymds.append(ymd);_log("INFO",f"[roll] === {ymd} bake complete ===");gc.collect() | |
| if baked_ymds: | |
| _log("INFO",f"[roll] Step 5: Pushing {len(baked_ymds)} dates to HF via tar archives") | |
| push_result=_hf_push_baked_as_tar(baked_ymds,WARM_DOM,WARM_HOURS_LIST);results["push"]=push_result | |
| _log("INFO",f"[roll] Push complete: {push_result}") | |
| else:_log("INFO","[roll] Step 5: No dates to push") | |
| _log("INFO","[roll] Step 6: Pruning old data") | |
| try:_hf_prune_remote_and_local(keep_ymds=keep_set) | |
| except Exception as e:_log("WARN",f"[roll] prune failed: {e!r}") | |
| results["finished"]=datetime.now(LOCAL_TZ).isoformat() | |
| _log("INFO",f"[roll] ========== COMPLETE at {results['finished']} ==========") | |
| if failed_ymds: | |
| _log("INFO",f"[roll] Step 7: Starting retry loop for failed dates: {failed_ymds}") | |
| def _retry_failed(): | |
| remaining=list(failed_ymds);max_retries=48 | |
| for retry_round in range(max_retries): | |
| if not remaining:break | |
| _log("INFO",f"[roll_retry] round {retry_round+1}: waiting 30 min before retrying {remaining}");time.sleep(30*60) | |
| still_failed=[] | |
| for ymd in remaining: | |
| if _date_is_baked(WARM_DOM,ymd):_log("INFO",f"[roll_retry] {ymd}: already baked");continue | |
| cog_ok=False | |
| for cog_attempt in range(5): | |
| try: | |
| r=_ensure_forecast_for_valid(_norm_ts(f"{ymd}05"),dom_prefer=WARM_DOM) | |
| if r.get("ok"):cog_ok=True;break | |
| except:pass | |
| if cog_attempt<4:time.sleep(10*(2**cog_attempt)) | |
| if not cog_ok:_log("WARN",f"[roll_retry] {ymd}: COG still unavailable");still_failed.append(ymd);continue | |
| _log("INFO",f"[roll_retry] {ymd}: COG OK, baking") | |
| try: | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=2)as pool: | |
| futs={} | |
| for label,fn,extra in[("corridor_png",_bake_corridor_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL)),("corridor_raw",_bake_corridor_raw_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL))]: | |
| futs[label]=pool.submit(fn,date_yyyymmdd=ymd,dom=WARM_DOM,hours_list=WARM_HOURS_LIST,zmin=WARM_ZMIN,zmax=WARM_ZMAX,**extra) | |
| for label,fut in futs.items(): | |
| try:r=fut.result();_log("INFO",f"[roll_retry] {ymd}: {label} ok={r.get('ok_count',0)} skip={r.get('skip_count',0)} fail={r.get('fail_count',0)}") | |
| except Exception as e:_log("WARN",f"[roll_retry] {ymd}: {label} exception: {e!r}") | |
| for label,fn,extra in[("lowres_png",_bake_lowres_box_tiles_for_date,dict(conc=1)),("lowres_raw",_bake_lowres_box_raw_tiles_for_date,dict(conc=1))]: | |
| try:r=fn(date_yyyymmdd=ymd,dom=WARM_DOM,hours_list=WARM_HOURS_LIST,zmin=LOWRES_BOX_ZMIN,zmax=LOWRES_BOX_ZMAX,**extra);_log("INFO",f"[roll_retry] {ymd}: {label} ok={r.get('ok_count',0)} skip={r.get('skip_count',0)} fail={r.get('fail_count',0)}") | |
| except Exception as e:_log("WARN",f"[roll_retry] {ymd}: {label} exception: {e!r}") | |
| _hf_push_baked_as_tar([ymd],WARM_DOM,WARM_HOURS_LIST);_log("INFO",f"[roll_retry] {ymd}: bake + push complete") | |
| try: | |
| best=_find_best_melt_for_date(ymd,dom_prefer=WARM_DOM) | |
| if best.get("ok")and best.get("melt_urls"):_record_baked_sig(ymd,WARM_DOM,best["melt_urls"][0],source_type=best.get("source_type")) | |
| except:pass | |
| except Exception as e:_log("WARN",f"[roll_retry] {ymd}: bake failed: {e!r}");still_failed.append(ymd) | |
| gc.collect() | |
| remaining=still_failed | |
| if remaining:_log("WARN",f"[roll_retry] giving up on: {remaining} after {max_retries} rounds") | |
| else:_log("INFO","[roll_retry] all failed dates recovered") | |
| threading.Thread(target=_retry_failed,daemon=True,name="roll_retry").start() | |
| return results | |
| finally:_release_run_lock(_ROLL_LOCK_NAME) | |
| def _daily_scheduler_loop()->None: | |
| time.sleep(0.25);_log("INFO","[scheduler] start") | |
| while True: | |
| try: | |
| now=datetime.now(LOCAL_TZ);nxt=_next_run_local(now);sleep_s=max(1.0,(nxt-now).total_seconds()) | |
| _log("INFO",f"[scheduler] next_run_local={nxt.isoformat()} sleep_s={int(sleep_s)}");time.sleep(sleep_s) | |
| r=_run_daily_roll_job_impl() | |
| if isinstance(r,dict)and r.get("skipped"):_log("INFO",f"[scheduler] roll skipped reason={r.get('reason')}") | |
| except Exception as e:_log("INFO",f"[scheduler] crashed err={e!r}");time.sleep(30.0) | |
| def _preload_forecast_for_date(date_yyyymmdd:str,dom:str,cog_cache:CogCache)->dict: | |
| dom=(dom or"zz").lower();valid=_norm_ts(f"{date_yyyymmdd}05") | |
| result={"date":date_yyyymmdd,"valid":valid,"dom":dom,"h24":{"ok":False},"h72":{"ok":False}} | |
| try: | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom) | |
| if sel.get("ok"): | |
| melt24_union=snow_union=None | |
| try:melt24_union=_forecast_melt24h_union_end_cog(sel["valid"],dom_prefer=dom) | |
| except Exception as e:_log("WARN",f"[preload] melt24 union failed for {date_yyyymmdd}: {e!r}");melt24_union=None | |
| if sel.get("snowpack_urls"): | |
| try:snow_union=_forecast_snowpack_union_end_cog(sel.get("snowpack_ts")or sel["valid"],dom_prefer=dom) | |
| except:snow_union=None | |
| if melt24_union is not None: | |
| try:cog_cache.get(melt24_union) | |
| except:pass | |
| if snow_union is not None: | |
| try:cog_cache.get(snow_union) | |
| except:pass | |
| if melt24_union is not None and melt24_union.exists()and melt24_union.stat().st_size>0: | |
| result["h24"]={"ok":True,"run_init":sel["run_init"],"valid":sel["valid"],"melt24_union_cog":melt24_union,"snow_union_cog":snow_union} | |
| except Exception as e:_log("WARN",f"[preload] h24 failed for {date_yyyymmdd}: {e!r}") | |
| try: | |
| run_init=_pick_best_run_init_for_valid_t0024(valid,dom_prefer=dom) | |
| if run_init: | |
| melt72_cog=_forecast_melt72h_end_cog(valid,dom_prefer=dom) | |
| if melt72_cog.exists()and melt72_cog.stat().st_size>0: | |
| try:cog_cache.get(melt72_cog) | |
| except:pass | |
| result["h72"]={"ok":True,"run_init":run_init,"valid":valid,"melt72_cog":melt72_cog,"snow_union_cog":result["h24"].get("snow_union_cog")if result["h24"].get("ok")else None} | |
| except Exception as e:_log("WARN",f"[preload] h72 failed for {date_yyyymmdd}: {e!r}") | |
| return result | |
| def _generate_tile_from_preloaded(z:int,x:int,y:int,hours:int,preloaded:dict,max_in:float|None=None,*,cog_cache:CogCache|None=None)->tuple[bytes,bool]: | |
| if hours==24: | |
| h24=preloaded.get("h24",{}) | |
| if not h24.get("ok"):return _transparent_png_256(),False | |
| melt_cog=h24.get("melt24_union_cog") | |
| if not melt_cog:return _transparent_png_256(),False | |
| melt_mm,melt_mask,oob=_tile_arrays_from_cog(melt_cog,z,x,y,cog_cache=cog_cache) | |
| if oob or melt_mm is None or melt_mask is None:return _transparent_png_256(),False | |
| melt_valid=(melt_mask>0)&np.isfinite(melt_mm)&(melt_mm>-9990.0);melt_mask_u8=(melt_valid.astype("uint8")*255) | |
| snow=snow_valid=None;snow_cog=h24.get("snow_union_cog") | |
| if snow_cog: | |
| snow_arr,snow_mask,oob2=_tile_arrays_from_cog(snow_cog,z,x,y,cog_cache=cog_cache) | |
| if(not oob2)and(snow_arr is not None)and(snow_mask is not None):snow_valid=(snow_mask>0)&np.isfinite(snow_arr)&(snow_arr>-9990.0);snow=snow_arr | |
| png=_melt_to_png(melt_mm,melt_mask_u8,max_in,snow,snow_valid,snow_allow_min_mm=0.0,snow_underlay_min_mm=0.0001,dilate_px=2,bin_edges_in=BIN_EDGES_IN) | |
| return png,True | |
| if hours==72: | |
| h72=preloaded.get("h72",{}) | |
| if not h72.get("ok"):return _transparent_png_256(),False | |
| melt72_cog=h72.get("melt72_cog") | |
| if not melt72_cog:return _transparent_png_256(),False | |
| melt_mm,melt_mask,oob=_tile_arrays_from_cog(melt72_cog,z,x,y,cog_cache=cog_cache) | |
| if oob or melt_mm is None or melt_mask is None:return _transparent_png_256(),False | |
| melt_valid=(melt_mask>0)&np.isfinite(melt_mm)&(melt_mm>-9990.0);melt_mask_u8=(melt_valid.astype("uint8")*255) | |
| snow=snow_valid=None;snow_cog=h72.get("snow_union_cog") | |
| if snow_cog: | |
| snow_arr,snow_mask,oob2=_tile_arrays_from_cog(snow_cog,z,x,y,cog_cache=cog_cache) | |
| if(not oob2)and(snow_arr is not None)and(snow_mask is not None):snow_valid=(snow_mask>0)&np.isfinite(snow_arr)&(snow_arr>-9990.0);snow=snow_arr | |
| png=_melt_to_png(melt_mm,melt_mask_u8,max_in,snow,snow_valid,snow_allow_min_mm=0.0,snow_underlay_min_mm=0.0001,dilate_px=2,bin_edges_in=BIN_EDGES_72H_IN) | |
| return png,True | |
| return _transparent_png_256(),False | |
| def _generate_forecast_by_date_raw_preloaded(*,z:int,x:int,y:int,date_yyyymmdd:str,hours:int,dom:str,preloaded:dict,cog_cache:CogCache)->tuple[bytes,dict]: | |
| dom=(dom or"zz").lower();valid=_norm_ts(f"{date_yyyymmdd}05") | |
| if int(hours)==24: | |
| h24=preloaded.get("h24",{}) | |
| if not h24.get("ok"):raise HTTPException(status_code=503,detail={"error":"forecast_unavailable","valid":valid,"dom":dom}) | |
| sel=h24.get("sel")or{};melt_info="melt_unavailable" | |
| if h24.get("melt24_union_cog"): | |
| melt_mm,melt_mask,oob=_tile_arrays_from_cog(h24["melt24_union_cog"],z,x,y,cog_cache=cog_cache) | |
| if oob or melt_mm is None or melt_mask is None:melt_mm=None;melt_valid=None | |
| else:melt_valid=(melt_mask>0)&np.isfinite(melt_mm)&(melt_mm>-9990.0) | |
| melt_info=f"melt24_union:{h24['melt24_union_cog'].name}" | |
| else:melt_mm,melt_valid,melt_info=_union_tile_cached(h24.get("melt_cogs",[]),z,x,y,label="melt",cog_cache=cog_cache) | |
| snow_info="snowpack_unavailable" | |
| try: | |
| if h24.get("snow_union_cog"):snow_info=f"snowpack_union:{h24['snow_union_cog'].name}" | |
| elif h24.get("snow_cogs"):_,_,snow_info=_union_tile_cached(h24["snow_cogs"],z,x,y,label="snowpack",cog_cache=cog_cache) | |
| except Exception as e:snow_info=f"snowpack_unavailable:{e!r}" | |
| if melt_mm is None or melt_valid is None: | |
| raw=_pack_raw_tile_npz(melt_mm=np.zeros((256,256),dtype="float32"),valid_mask_u8=np.zeros((256,256),dtype="uint8")) | |
| return raw,{"X-Allowed":"1","X-OOB":"1","X-Melt-Info":melt_info,"X-SnowMask-Info":snow_info,"X-Forecast-Valid":sel.get("valid",valid),"X-Forecast-RunInit-TT":sel.get("run_init",""),"X-Forecast-Hours":"24","X-Raw-Scale":"10"} | |
| raw=_pack_raw_tile_npz(melt_mm=melt_mm,valid_mask_u8=(melt_valid.astype("uint8")*255)) | |
| return raw,{"X-Allowed":"1","X-OOB":"0","X-Melt-Info":melt_info,"X-SnowMask-Info":snow_info,"X-Forecast-Valid":sel.get("valid",valid),"X-Forecast-RunInit-TT":sel.get("run_init",""),"X-Forecast-Hours":"24","X-Raw-Scale":"10"} | |
| h72=preloaded.get("h72",{}) | |
| if not h72.get("ok"):raise HTTPException(status_code=503,detail={"error":"no_run_init_for_valid","valid":valid,"dom":dom,"suggestion":"forecast data may not be available yet for this date"}) | |
| melt_mm,melt_mask_u8,oob=_tile_arrays_from_cog(h72["melt72_cog"],z,x,y,cog_cache=cog_cache) | |
| if oob or melt_mm is None or melt_mask_u8 is None: | |
| raw=_pack_raw_tile_npz(melt_mm=np.zeros((256,256),dtype="float32"),valid_mask_u8=np.zeros((256,256),dtype="uint8")) | |
| return raw,{"X-Allowed":"1","X-OOB":"1","X-Forecast-Hours":"72","X-Forecast-Valid":valid,"X-Forecast-RunInit-TT":h72.get("run_init",""),"X-Forecast-72h-COG":h72["melt72_cog"].name,"X-Raw-Scale":"10"} | |
| raw=_pack_raw_tile_npz(melt_mm=melt_mm,valid_mask_u8=melt_mask_u8.astype("uint8",copy=False)) | |
| return raw,{"X-Allowed":"1","X-OOB":"0","X-Forecast-Hours":"72","X-Forecast-Valid":valid,"X-Forecast-RunInit-TT":h72.get("run_init",""),"X-Forecast-72h-COG":h72["melt72_cog"].name,"X-Raw-Scale":"10"} | |
| def _shift_ts(ts10:str,hours:int)->str:return(_ts_to_dt(_norm_ts(ts10))+timedelta(hours=hours)).strftime("%Y%m%d%H") | |
| def _union_point(cogs:list[Path],lon:float,lat:float)->tuple[Optional[float],str]: | |
| if not cogs:return None,"no_cogs" | |
| used=[] | |
| for cog in sorted(cogs,key=lambda p:_dom_pri(_cog_dom(p))): | |
| try: | |
| with COGReader(cog.as_posix())as r:data,mask=_cog_point_data_mask(r,lon,lat) | |
| v=float(np.ravel(data)[0]);m=int(np.ravel(mask)[0]);used.append(cog.name) | |
| if m>0 and np.isfinite(v)and v>-9990.0:return v,"ok:"+cog.name | |
| except:continue | |
| return None,"nodata:"+",".join(used[:3]) | |
| def _rolling_window_dates(lookback_days: int = 1, lookahead_days: int = 3) -> list[str]: | |
| today = datetime.now(LOCAL_TZ).date() | |
| return sorted([(today + timedelta(days=d)).strftime("%Y%m%d") for d in range(-int(lookback_days), int(lookahead_days) + 1)]) | |
| def _check_date_has_local_tiles(dom: str, hours: int, ymd: str) -> bool: | |
| dom = (dom or "zz").lower() | |
| for kind, ext in [("tilecache", "*.png"), ("rawtilecache", "*.npz")]: | |
| d = CACHE / kind / dom / str(hours) / ymd | |
| if d.exists(): | |
| try: | |
| next(d.rglob(ext)) | |
| return True | |
| except StopIteration: | |
| pass | |
| return False | |
| def _date_has_local_tiles_both(dom: str, ymd: str, min_tiles: int = 50) -> bool: | |
| dom = (dom or "zz").lower() | |
| for h in WARM_HOURS_LIST: | |
| d = CACHE / "tilecache" / dom / str(h) / ymd | |
| if not d.exists(): | |
| return False | |
| try: | |
| count = sum(1 for _ in d.rglob("*.png")) | |
| if count < min_tiles: | |
| return False | |
| except: | |
| return False | |
| return True | |
| _AVAILABLE_DATES_CACHE:dict={"ts":0.0,"data":None,"key":None} | |
| _AVAILABLE_DATES_CACHE_TTL=120.0 | |
| def forecast_sample_melt(date_yyyymmdd:str=Query(...,regex=r"^\d{8}$"),hours:int=Query(24,ge=24,le=72),lon:float=Query(...,ge=-180.0,le=180.0),lat:float=Query(...,ge=-90.0,le=90.0),dom:str=Query("zz")): | |
| dom=(dom or"zz").lower();valid=_norm_ts(f"{date_yyyymmdd}05");mm=None;info="" | |
| if int(hours)==72: | |
| cog=_forecast_melt72h_end_cog(valid,dom_prefer=dom) | |
| try: | |
| r=_cogreader_get(cog);data,mask=_cog_point_data_mask(r,lon,lat);v=float(np.ravel(data)[0]);m=int(np.ravel(mask)[0]) | |
| if m>0 and np.isfinite(v)and v>-9990.0:mm=v;info="ok:"+cog.name | |
| else:info="nodata:"+cog.name | |
| except Exception as e:info=f"err:{e!r}" | |
| else: | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom) | |
| if not sel.get("ok"):detail=dict(sel);detail["collab_last"]=dict(_COLLAB_LAST);detail["suggestion"]="forecast data may not be available yet for this date";raise HTTPException(status_code=503,detail=detail) | |
| try:melt24_union=_forecast_melt24h_union_end_cog(sel["valid"],dom_prefer=dom) | |
| except Exception as e:raise HTTPException(status_code=503,detail={"error":"melt24_union_cog_unavailable","date_yyyymmdd":date_yyyymmdd,"valid":sel.get("valid",valid),"dom":dom,"upstream_error":repr(e)}) | |
| try: | |
| r=_cogreader_get(melt24_union);data,mask=_cog_point_data_mask(r,lon,lat);v=float(np.ravel(data)[0]);m=int(np.ravel(mask)[0]) | |
| if m>0 and np.isfinite(v)and v>-9990.0:mm=v;info="ok:"+melt24_union.name | |
| else:info="nodata:"+melt24_union.name | |
| except Exception as e:info=f"err:{e!r}" | |
| if mm is None:return{"ok":True,"value_in":None,"info":info,"hours":int(hours),"date_yyyymmdd":date_yyyymmdd} | |
| inches=max(0.0,float(mm)/INCH_TO_MM);return{"ok":True,"value_in":round(inches,1),"info":info,"hours":int(hours),"date_yyyymmdd":date_yyyymmdd} | |
| def _startup()->None: | |
| _transparent_png_256();_zero_raw_npz_256() | |
| if _acquire_singleton_lock("hf_pull"): | |
| def _background_hf_pull_and_cn(): | |
| try:_log("INFO","[startup] background hf_pull_cache starting");_hf_pull_cache();_log("INFO","[startup] background hf_pull_cache finished") | |
| except Exception as e:_log("WARN",f"[startup] background hf_pull_cache failed: {e!r}") | |
| try:_log("INFO","[startup] background ensure_cn_mbtiles starting");_ensure_cn_mbtiles();_log("INFO","[startup] background ensure_cn_mbtiles finished") | |
| except Exception as e:_log("WARN",f"[tracks] failed to ensure cn mbtiles (background): {e!r}") | |
| t_hf=threading.Thread(target=_background_hf_pull_and_cn,daemon=True,name="hf_pull_bg");t_hf.start();_log("INFO","[startup] hf pull scheduled in background (singleton)") | |
| else:_log("INFO","[startup] hf pull skipped (another worker holds lock)") | |
| if _acquire_singleton_lock("daily_scheduler"):t=threading.Thread(target=_daily_scheduler_loop,daemon=True);t.start();_log("INFO","[startup] daily scheduler enabled (singleton)") | |
| else:_log("INFO","[startup] daily scheduler disabled (another worker holds lock)") | |
| if(str(BOOT_ROLL_DEFAULT).strip()!="0")and _acquire_singleton_lock("boot_roll"):t2=threading.Thread(target=_run_daily_roll_job_impl,daemon=True);t2.start();_log("INFO","[startup] boot roll job kicked off") | |
| else:_log("INFO","[startup] boot roll job skipped") | |
| if _acquire_singleton_lock("hf_uploader"):t3=threading.Thread(target=_hf_tiles_uploader_loop,daemon=True);t3.start();_log("INFO","[startup] hf uploader enabled (singleton)") | |
| else:_log("INFO","[startup] hf uploader disabled (another worker holds lock)") | |
| if _acquire_singleton_lock("staleness_monitor"): | |
| t_stale = threading.Thread(target=_staleness_monitor_loop, daemon=True, name="staleness_monitor") | |
| t_stale.start() | |
| _log("INFO", "[startup] staleness monitor enabled (singleton)") | |
| else: | |
| _log("INFO", "[startup] staleness monitor disabled (another worker holds lock)") | |
| def root():return"SNODAS Snowmelt Tiles. See /web for demo and /docs for API." | |
| def health():return"ok" | |
| def config_js():return f"window.__SNODAS_CONFIG__ = {{ MAPBOX_TOKEN: '{os.environ.get('MAPBOX_TOKEN','')}' }};" | |
| def tiles_24h_latest(z:int,x:int,y:int,max:float|None=Query(None)): | |
| d=_resolve_latest_nsidc_day() | |
| if d is None:raise HTTPException(status_code=503,detail="No recent NSIDC day available") | |
| if max is None: | |
| try: | |
| hit=_tile_cache_get("nsidc",24,d,z,x,y) | |
| if hit is not None:return _resp_png(hit,cache_control=CACHE_CONTROL_LATEST,**{"X-Route":"24h-latest","X-Cache":"tilecache-hit","X-Latest-Day":d}) | |
| except:pass | |
| cog=build_24h_cog(datetime.strptime(d,"%Y%m%d").date());png=_tile_png_from_cog(cog,z,x,y,max) | |
| if max is None: | |
| try:_tile_cache_put("nsidc",24,d,z,x,y,png) | |
| except:pass | |
| return _resp_png(png,cache_control=CACHE_CONTROL_LATEST,**{"X-Route":"24h-latest","X-Cache":("tilecache-store"if max is None else"bypass-max"),"X-Latest-Day":d}) | |
| def tiles_24h(date_yyyymmdd:str,z:int,x:int,y:int,max:float|None=Query(None)): | |
| try:d=datetime.strptime(date_yyyymmdd,"%Y%m%d").date() | |
| except:raise HTTPException(status_code=400,detail="Date must be YYYYMMDD") | |
| return _resp_png(_tile_png_from_cog(build_24h_cog(d),z,x,y,max),cache_control=CACHE_CONTROL_BY_DATE,**{"X-Route":"24h-date"}) | |
| def tiles_72h(end_yyyymmdd:str,z:int,x:int,y:int,max:float|None=Query(None)): | |
| try:d=datetime.strptime(end_yyyymmdd,"%Y%m%d").date() | |
| except:raise HTTPException(status_code=400,detail="Date must be YYYYMMDD") | |
| return _resp_png(_tile_png_from_cog(build_72h_cog(d),z,x,y,max),cache_control=CACHE_CONTROL_BY_DATE,**{"X-Route":"72h"}) | |
| def forecast_ending(days:int=Query(2,ge=2,le=3),hours:int=Query(24,ge=24,le=72)): | |
| now=datetime.utcnow().replace(tzinfo=timezone.utc);end_dt=_ts_to_dt(_valid_ts_for_days(days,now_utc=now)) | |
| return{"days":int(days),"hours":int(hours),"valid":end_dt.strftime("%Y%m%d%H"),"end_iso":end_dt.isoformat(),"label":f"{int(hours)} Hour snowmelt ending on {end_dt.strftime('%Y-%m-%d %HZ')}","now_utc":now.isoformat(),"anchor_date_05z":now.date().isoformat()} | |
| def tiles_forecast_latest(z:int,x:int,y:int,max:float|None=Query(None),days:int=Query(2,ge=2,le=3),hours:int=Query(24,ge=24,le=72)): | |
| z_eff,x_eff,y_eff,tier=_effective_request_tile(z,x,y) | |
| if tier=="none":return _resp_png(_transparent_png_256(),cache_control=CACHE_CONTROL_LATEST,**{"X-Route":"forecast-latest","X-Allowed":"0"}) | |
| sel=_pick_forecast_for_days(days,dom_prefer="zz") | |
| if not sel.get("ok"):detail=dict(sel);detail["collab_last"]=dict(_COLLAB_LAST);detail["suggestion"]="latest forecast may not be available yet";raise HTTPException(status_code=503,detail=detail) | |
| valid_ymd=(sel["valid"]or"")[:8] | |
| if max is None and valid_ymd: | |
| try: | |
| hit=_tile_cache_get("zz",int(hours),valid_ymd,z_eff,x_eff,y_eff) | |
| if hit is not None:return _resp_png(hit,cache_control=CACHE_CONTROL_LATEST,**{"X-Route":"forecast-latest","X-Allowed":"1","X-Cache":"hit","X-Forecast-Valid":sel["valid"],"X-Forecast-Hours":str(int(hours)),"X-Forecast-RunInit-TT":sel["run_init"],"X-Tier":tier}) | |
| except:pass | |
| try: | |
| png,headers=_generate_forecast_by_date_png(z=z_eff,x=x_eff,y=y_eff,date_yyyymmdd=valid_ymd,hours=hours,dom="zz",max_in=max) | |
| cache_status="generated-no-cache" | |
| if max is None and valid_ymd: | |
| try:_tile_cache_put("zz",int(hours),valid_ymd,z_eff,x_eff,y_eff,png);_hf_enqueue_files([_tile_cache_path("zz",int(hours),valid_ymd,z_eff,x_eff,y_eff)]);cache_status="generated-and-cached" | |
| except:cache_status="generated-only" | |
| headers["X-Cache"]=cache_status;headers["X-Tier"]=tier;headers["X-Route"]="forecast-latest" | |
| return _resp_png(png,cache_control=CACHE_CONTROL_LATEST,**headers) | |
| except HTTPException as e:_log("WARN",f"[tiles] forecast/latest failed: {e.detail}");raise | |
| def _is_gzip(data:bytes)->bool:return len(data)>=2 and data[0]==0x1F and data[1]==0x8B | |
| def cn_tiles(z:int,x:int,y:int): | |
| conn=_cn_mbtiles_conn();zmax=_cn_mbtiles_maxzoom();z2,x2,y2=_mbtiles_overzoom(z,x,y,zmax) | |
| try:data=_mbtiles_get_tile(conn,z2,x2,y2) | |
| except Exception as e:_log("WARN",f"[cn_tiles] read fail z={z} x={x} y={y} -> z2={z2} x2={x2} y2={y2} err={e!r}");data=None | |
| if not data:return Response(status_code=404) | |
| headers={"Content-Type":"application/x-protobuf","Cache-Control":"public, max-age=86400"} | |
| if _is_gzip(data):headers["Content-Encoding"]="gzip" | |
| return FastResponse(content=data,headers=headers) | |
| def value_forecast_by_date(date_yyyymmdd:str=Query(...,regex=r"^\d{8}$"),hours:int=Query(24,ge=24,le=72),lon:float=Query(...),lat:float=Query(...),dom:str=Query("zz")): | |
| with _Mode("value"): | |
| dom=(dom or"zz").lower();valid=_norm_ts(f"{date_yyyymmdd}05") | |
| if int(hours)==24: | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom) | |
| if not sel.get("ok"):detail=dict(sel);detail["collab_last"]=dict(_COLLAB_LAST);raise HTTPException(status_code=503,detail=detail) | |
| melt_cogs=_build_forecast_melt_cogs(sel["run_init"],sel["valid"],"t0024",sel["melt_urls"]);mm,info=_union_point_mm(melt_cogs,float(lon),float(lat)) | |
| inches=None if mm is None else(float(mm)/INCH_TO_MM);return{"ok":True,"hours":24,"valid":sel["valid"],"run_init":sel["run_init"],"mm":mm,"inches":inches,"info":info} | |
| run_init=_pick_best_run_init_for_valid_t0024(valid,dom_prefer=dom) | |
| if not run_init:raise HTTPException(status_code=503,detail={"error":"no_run_init_for_valid","valid":valid,"dom":dom}) | |
| melt72_cog=_forecast_melt72h_end_cog(valid,dom_prefer=dom);mm,info=_point_mm_from_cog(melt72_cog,float(lon),float(lat)) | |
| inches=None if mm is None else(float(mm)/INCH_TO_MM);inches_1dp=None if inches is None else round(inches,1) | |
| return{"ok":True,"hours":72,"valid":valid,"run_init":run_init,"mm":None if mm is None else float(mm),"inches":inches,"inches_1dp":inches_1dp,"info":info} | |
| def value_forecast_viewport_grid(payload:dict): | |
| with _Mode("value"): | |
| try: | |
| date_yyyymmdd=payload.get("date_yyyymmdd");hours=int(payload.get("hours",24));dom=(payload.get("dom")or"zz").lower();bounds=payload.get("bounds");resolution=int(payload.get("resolution",20)) | |
| if not date_yyyymmdd or not bounds or len(bounds)!=4:raise HTTPException(status_code=400,detail="Missing date_yyyymmdd or bounds") | |
| west,south,east,north=[float(b)for b in bounds];resolution=max(5,min(30,resolution));south=max(-85.0,min(85.0,south));north=max(-85.0,min(85.0,north)) | |
| if east<west:west,east=east,west | |
| if north<south:south,north=north,south | |
| valid=_norm_ts(f"{date_yyyymmdd}05");qstep=0.10;qw,qs,qe,qn=_q(west,qstep),_q(south,qstep),_q(east,qstep),_q(north,qstep) | |
| cache_key=("v1",date_yyyymmdd,int(hours),dom,qw,qs,qe,qn,int(resolution)) | |
| with _VIEWGRID_LOCK:hit=_mem_cache_get(_VIEWGRID_CACHE,cache_key,_VIEWGRID_TTL_S) | |
| if hit is not None:return hit | |
| lons=np.linspace(west,east,resolution);lats=np.linspace(south,north,resolution) | |
| if int(hours)==24: | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom) | |
| if not sel.get("ok"):detail=dict(sel);detail["collab_last"]=dict(_COLLAB_LAST);raise HTTPException(status_code=503,detail=detail) | |
| melt_key=("24",dom,sel["run_init"],sel["valid"]) | |
| with _MELTCOGS_LOCK:melt_cogs=_mem_cache_get(_MELTCOGS_CACHE,melt_key,_MELTCOGS_TTL_S) | |
| if melt_cogs is None:melt_cogs=_build_forecast_melt_cogs(sel["run_init"],sel["valid"],"t0024",sel["melt_urls"]); | |
| grid=[] | |
| for lat in lats: | |
| row=[] | |
| for lon in lons:mm,_=_union_point_mm(melt_cogs,float(lon),float(lat));row.append(None if mm is None else round(float(mm)/INCH_TO_MM,1)) | |
| grid.append(row) | |
| resp={"ok":True,"hours":24,"valid":sel["valid"],"run_init":sel["run_init"],"bounds":[west,south,east,north],"resolution":resolution,"lons":lons.tolist(),"lats":lats.tolist(),"grid":grid} | |
| with _VIEWGRID_LOCK:_mem_cache_put(_VIEWGRID_CACHE,cache_key,resp) | |
| return resp | |
| run_init=_pick_best_run_init_for_valid_t0024(valid,dom_prefer=dom) | |
| if not run_init:raise HTTPException(status_code=503,detail={"error":"no_run_init_for_valid"}) | |
| melt72_cog=_forecast_melt72h_end_cog(valid,dom_prefer=dom);grid=[] | |
| for lat in lats: | |
| row=[] | |
| for lon in lons:mm,_=_point_mm_from_cog(melt72_cog,float(lon),float(lat));row.append(None if mm is None else round(float(mm)/INCH_TO_MM,1)) | |
| grid.append(row) | |
| resp={"ok":True,"hours":72,"valid":valid,"run_init":run_init,"bounds":[west,south,east,north],"resolution":resolution,"lons":lons.tolist(),"lats":lats.tolist(),"grid":grid} | |
| with _VIEWGRID_LOCK:_mem_cache_put(_VIEWGRID_CACHE,cache_key,resp) | |
| return resp | |
| except HTTPException:raise | |
| except Exception as e:_log("ERROR",f"[viewport_grid] error: {e!r}");raise HTTPException(status_code=500,detail=f"viewport_grid_error: {e!r}") | |
| def tiles_forecast_by_date( | |
| z: int, x: int, y: int, | |
| date_yyyymmdd: str = Query(..., regex=r"^\d{8}$"), | |
| hours: int = Query(24), | |
| dom: str = Query("zz"), | |
| max_in: float | None = Query(None), | |
| client_id: str | None = Query(None), | |
| gen: int | None = Query(None), | |
| ) -> Response: | |
| _pause_push_for_user() | |
| dom = (dom or "zz").lower() | |
| hours_i = int(hours) | |
| if not _tile_allowed(z, x, y): | |
| return _resp_png( | |
| _transparent_png_256(), | |
| cache_control="public, max-age=31536000, immutable", | |
| **{"X-Route": "forecast-by-date", "X-Allowed": "0"}, | |
| ) | |
| if client_id and gen is not None and not _client_is_active_gen(client_id, gen): | |
| return _resp_png( | |
| _transparent_png_256(), | |
| cache_control="no-store", | |
| **{"X-Route": "forecast-by-date", "X-Stale": "gen", "X-Date": date_yyyymmdd, "X-Hours": str(hours_i)}, | |
| ) | |
| if client_id and not _client_is_active_date(client_id, date_yyyymmdd): | |
| return _resp_png( | |
| _transparent_png_256(), | |
| cache_control="no-store", | |
| **{"X-Route": "forecast-by-date", "X-Stale": "1", "X-Date": date_yyyymmdd, "X-Hours": str(hours_i)}, | |
| ) | |
| cancel_check = _make_cancel_check(client_id, gen) | |
| try: | |
| cached = _tile_cache_get(dom, hours_i, date_yyyymmdd, z, x, y, cancel_check=cancel_check) | |
| if cached is not None: | |
| return _resp_png( | |
| cached, | |
| cache_control="public, max-age=31536000, immutable", | |
| **{"X-Route": "forecast-by-date", "X-Cache": "hit"}, | |
| ) | |
| except: | |
| pass | |
| if cancel_check and cancel_check(): | |
| return _resp_png( | |
| _transparent_png_256(), | |
| cache_control="no-store", | |
| **{"X-Route": "forecast-by-date", "X-Stale": "gen-pre-gen", "X-Date": date_yyyymmdd}, | |
| ) | |
| #_log("INFO", f"[tile] generating on-demand: {date_yyyymmdd} h{hours_i} z{z} x{x} y{y}") | |
| png, headers = _generate_forecast_by_date_png( | |
| z=z, x=x, y=y, date_yyyymmdd=date_yyyymmdd, | |
| hours=hours_i, dom=dom, max_in=max_in, | |
| ) | |
| if not _is_valid_png_bytes(png): | |
| _log("WARN", f"[tile] generated invalid PNG: {date_yyyymmdd} h{hours_i} z{z} x{x} y{y}") | |
| return _resp_png( | |
| _transparent_png_256(), | |
| cache_control="no-store", | |
| **{"X-Route": "forecast-by-date", "X-Bad-PNG": "1", "X-Date": date_yyyymmdd, "X-Hours": str(hours_i)}, | |
| ) | |
| if cancel_check and cancel_check(): | |
| return _resp_png( | |
| _transparent_png_256(), | |
| cache_control="no-store", | |
| **{"X-Route": "forecast-by-date", "X-Stale": "gen-post-gen", "X-Date": date_yyyymmdd}, | |
| ) | |
| try: | |
| _tile_cache_put(dom, hours_i, date_yyyymmdd, z, x, y, png) | |
| except: | |
| pass | |
| extra = dict(headers) if isinstance(headers, dict) else {} | |
| extra.setdefault("X-Route", "forecast-by-date") | |
| extra.setdefault("X-Cache", "miss") | |
| return _resp_png(png, cache_control="public, max-age=31536000, immutable", **extra) | |
| def tiles_forecast_by_date_raw( | |
| z: int, x: int, y: int, | |
| date_yyyymmdd: str = Query(..., regex=r"^\d{8}$"), | |
| hours: int = Query(24), | |
| dom: str = Query("zz"), | |
| client_id: str | None = Query(None), | |
| gen: int | None = Query(None), | |
| ) -> Response: | |
| _pause_push_for_user() | |
| if client_id and not _check_rate_limit(client_id): | |
| return Response( | |
| content=_zero_raw_npz_256(), | |
| media_type="application/octet-stream", | |
| headers={ | |
| "Cache-Control": "no-store", | |
| "X-Route": "forecast-raw-by-date", | |
| "X-Rate-Limited": "1", | |
| "Retry-After": "5", | |
| }, | |
| ) | |
| dom = (dom or "zz").lower() | |
| hours_i = int(hours) | |
| if client_id and gen is not None and not _client_is_active_gen(client_id, gen): | |
| return Response( | |
| content=_zero_raw_npz_256(), | |
| media_type="application/octet-stream", | |
| headers={ | |
| "Cache-Control": "no-store", | |
| "X-Route": "forecast-raw-by-date", | |
| "X-Stale": "gen", | |
| "X-Date": date_yyyymmdd, | |
| "X-Hours": str(hours_i), | |
| }, | |
| ) | |
| if client_id and not _client_is_active_date(client_id, date_yyyymmdd): | |
| return Response( | |
| content=_zero_raw_npz_256(), | |
| media_type="application/octet-stream", | |
| headers={ | |
| "Cache-Control": "no-store", | |
| "X-Route": "forecast-raw-by-date", | |
| "X-Stale": "1", | |
| "X-Date": date_yyyymmdd, | |
| "X-Hours": str(hours_i), | |
| }, | |
| ) | |
| cancel_check = _make_cancel_check(client_id, gen) | |
| try: | |
| cached = _raw_tile_cache_get(dom, hours_i, date_yyyymmdd, z, x, y, cancel_check=cancel_check) | |
| if cached is not None: | |
| if not _validate_npz_bytes(cached): | |
| _log("WARN", f"[tile_raw] Cached NPZ failed header check: {date_yyyymmdd} z{z} x{x} y{y}") | |
| try: | |
| _safe_unlink(_raw_tile_cache_path(dom, hours_i, date_yyyymmdd, z, x, y)) | |
| except: | |
| pass | |
| else: | |
| return Response( | |
| content=cached, | |
| media_type="application/octet-stream", | |
| headers={ | |
| "Cache-Control": "public, max-age=31536000, immutable", | |
| "X-Route": "forecast-raw-by-date", | |
| "X-Cache": "hit", | |
| }, | |
| ) | |
| except Exception as e: | |
| _log("WARN", f"[tile_raw] cache-read error: {e!r}") | |
| if cancel_check and cancel_check(): | |
| return Response( | |
| content=_zero_raw_npz_256(), | |
| media_type="application/octet-stream", | |
| headers={ | |
| "Cache-Control": "no-store", | |
| "X-Route": "forecast-raw-by-date", | |
| "X-Stale": "gen-pre-gen", | |
| "X-Date": date_yyyymmdd, | |
| }, | |
| ) | |
| #_log("INFO", f"[tile_raw] generating on-demand: {date_yyyymmdd} h{hours_i} z{z} x{x} y{y}") | |
| raw, headers = _generate_forecast_by_date_raw( | |
| z=z, x=x, y=y, date_yyyymmdd=date_yyyymmdd, | |
| hours=hours_i, dom=dom, | |
| ) | |
| if not _validate_npz_bytes(raw): | |
| _log("WARN", f"[tile_raw] generated invalid NPZ header: {date_yyyymmdd} h{hours_i} z{z} x{x} y{y}") | |
| return Response( | |
| content=_zero_raw_npz_256(), | |
| media_type="application/octet-stream", | |
| headers={ | |
| "Cache-Control": "no-store", | |
| "X-Route": "forecast-raw-by-date", | |
| "X-Bad-NPZ": "1", | |
| }, | |
| ) | |
| if cancel_check and cancel_check(): | |
| return Response( | |
| content=_zero_raw_npz_256(), | |
| media_type="application/octet-stream", | |
| headers={ | |
| "Cache-Control": "no-store", | |
| "X-Route": "forecast-raw-by-date", | |
| "X-Stale": "gen-post-gen", | |
| "X-Date": date_yyyymmdd, | |
| }, | |
| ) | |
| try: | |
| _raw_tile_cache_put(dom, hours_i, date_yyyymmdd, z, x, y, raw) | |
| except Exception as e: | |
| _log("WARN", f"[tile_raw] failed to write raw cache for {date_yyyymmdd} z{z} x{x} y{y}: {e!r}") | |
| extra = dict(headers) if isinstance(headers, dict) else {} | |
| h = { | |
| "Cache-Control": "public, max-age=31536000, immutable", | |
| "X-Route": "forecast-raw-by-date", | |
| "X-Cache": "miss", | |
| } | |
| for k, v in extra.items(): | |
| try: | |
| h[str(k)] = str(v) | |
| except: | |
| pass | |
| return Response(content=raw, media_type="application/octet-stream", headers=h) | |
| def __debug_record_all_sigs(): | |
| dom=(WARM_DOM or"zz").lower() | |
| window=_rolling_dates_local_window(lookback=2,lookahead=3) | |
| recorded=[] | |
| for ymd in window: | |
| best=_find_best_melt_for_date(ymd,dom_prefer=dom) | |
| if best.get("ok")and best.get("melt_urls"): | |
| _record_baked_sig(ymd,dom,best["melt_urls"][0],source_type=best.get("source_type")) | |
| recorded.append({"date":ymd,"source_type":best.get("source_type"),"url":best["melt_urls"][0]}) | |
| else: | |
| recorded.append({"date":ymd,"error":best.get("error","no melt found")}) | |
| return{"recorded":recorded} | |
| def tiles_forecast(ts_key:str,z:int,x:int,y:int,max:float|None=Query(None),lead:str=Query("t0024"),valid:str|None=Query(None),dom:str=Query("zz")): | |
| if not _tile_allowed(z,x,y):return _resp_png(_transparent_png_256(),**{"X-Route":"forecast-direct","X-Allowed":"0"}) | |
| ts_key=_norm_ts(ts_key);lead=(lead or"").lower() | |
| if lead!="t0024":raise HTTPException(status_code=400,detail="This service now serves direct 24h melt files (t0024). Use lead=t0024.") | |
| if not valid:raise HTTPException(status_code=400,detail="valid is required (TSYYYYMMDDHH) for direct 24h melt") | |
| valid=_norm_ts(valid);melt_urls=_find_melt_11044_t0024(ts_key,valid,dom_prefer=dom) | |
| if not melt_urls:raise HTTPException(status_code=404,detail=f"No melt file for TT={ts_key} TS={valid} lead=t0024") | |
| sd=_best_state_nc_near_valid("ssmv11036",valid,dom_prefer=dom) | |
| if not sd.get("url"):raise HTTPException(status_code=404,detail="No snowdepth 11036 state .nc found") | |
| resp=_serve_forecast_tile(ts_key,valid,"t0024",melt_urls,sd.get("picked_ts")or"",[sd["url"]],z,x,y,max);resp.headers["X-Allowed"]="1";return resp | |
| def forecast_latest_ts(dom:str="zz"): | |
| now=datetime.utcnow().replace(tzinfo=timezone.utc);sel2=_pick_forecast_for_days(2,dom_prefer=dom);sel3=_pick_forecast_for_days(3,dom_prefer=dom) | |
| return{"now_utc":now.isoformat(),"d2":sel2,"d3":sel3,"collab_last":dict(_COLLAB_LAST)} | |
| def forecast_selection(dom:str="zz"):return forecast_latest_ts(dom=dom) | |
| def value_forecast_by_date_batch(payload:dict): | |
| with _Mode("value"): | |
| try: | |
| date_yyyymmdd=payload.get("date_yyyymmdd");hours=int(payload.get("hours",24));dom=(payload.get("dom")or"zz").lower();pts=payload.get("pts",[]) | |
| if not date_yyyymmdd or not pts:raise HTTPException(status_code=400,detail="Missing date_yyyymmdd or pts") | |
| valid=_norm_ts(f"{date_yyyymmdd}05") | |
| if int(hours)==24: | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom) | |
| if not sel.get("ok"):detail=dict(sel);detail["collab_last"]=dict(_COLLAB_LAST);raise HTTPException(status_code=503,detail=detail) | |
| melt_cogs=_build_forecast_melt_cogs(sel["run_init"],sel["valid"],"t0024",sel["melt_urls"]);values=[] | |
| for pt in pts: | |
| if not isinstance(pt,(list,tuple))or len(pt)<2:values.append(None);continue | |
| lon,lat=float(pt[0]),float(pt[1]);mm,info=_union_point_mm(melt_cogs,lon,lat) | |
| if mm is None:values.append(None) | |
| else:inches=float(mm)/INCH_TO_MM;values.append({"mm":float(mm),"inches":inches,"inches_1dp":round(inches,1)}) | |
| return{"ok":True,"hours":24,"valid":sel["valid"],"run_init":sel["run_init"],"count":len(pts),"values":values} | |
| run_init=_pick_best_run_init_for_valid_t0024(valid,dom_prefer=dom) | |
| if not run_init:raise HTTPException(status_code=503,detail={"error":"no_run_init_for_valid","valid":valid,"dom":dom}) | |
| melt72_cog=_forecast_melt72h_end_cog(valid,dom_prefer=dom);values=[] | |
| for pt in pts: | |
| if not isinstance(pt,(list,tuple))or len(pt)<2:values.append(None);continue | |
| lon,lat=float(pt[0]),float(pt[1]);mm,info=_point_mm_from_cog(melt72_cog,lon,lat) | |
| if mm is None:values.append(None) | |
| else:inches=float(mm)/INCH_TO_MM;values.append({"mm":float(mm),"inches":inches,"inches_1dp":round(inches,1)}) | |
| return{"ok":True,"hours":72,"valid":valid,"run_init":run_init,"count":len(pts),"values":values} | |
| except HTTPException:raise | |
| except Exception as e:_log("ERROR",f"[batch] unexpected error: {e!r}");raise HTTPException(status_code=500,detail=f"batch_error: {e!r}") | |
| def forecast_avail(lead:str="t0024"): | |
| html=_collab_dirlist();names=_hrefs(html);lead=(lead or"").lower();tts:list[str]=[] | |
| for n in names: | |
| base=n.split("?",1)[0].strip();low=base.lower() | |
| if"ssmv11044"not in low:continue | |
| if lead and lead not in low:continue | |
| if not _is_data_name(low):continue | |
| tt=_tt_from_name(base) | |
| if tt:tts.append(tt) | |
| tts=sorted(set(tts));return{"lead":lead,"count":len(tts),"min_tt":tts[0]if tts else None,"max_tt":tts[-1]if tts else None,"sample_tail":tts[-10:]if len(tts)>10 else tts} | |
| _AVAIL_CACHE={"ts":0.0,"key":None,"resp":None} | |
| def _available_dates_from_hf_repo(dom:str,hours:int,lookback_days:int)->list[str]: | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if not tok or not repo or api is None:return[] | |
| try:files=api.list_repo_files(repo_id=repo,repo_type="dataset",token=tok) | |
| except Exception as e:_log("WARN",f"[available_days] hf list_repo_files failed: {e!r}");return[] | |
| now=datetime.utcnow().replace(tzinfo=timezone.utc);min_ymd=(now.date()-timedelta(days=int(lookback_days))).strftime("%Y%m%d") | |
| prefix=f"tilecache/{dom}/{int(hours)}/";out=set() | |
| for f in files: | |
| if not f.startswith(prefix):continue | |
| parts=f.split("/") | |
| if len(parts)<4:continue | |
| ymd=parts[3] | |
| if len(ymd)==8 and ymd.isdigit()and ymd>=min_ymd:out.add(ymd) | |
| return sorted(out) | |
| _HF_FILESET_CACHE={"ts":0.0,"files":None} | |
| def _hf_repo_files_cached(ttl_s:float=60.0)->list[str]: | |
| now=time.time() | |
| if _HF_FILESET_CACHE["files"]is not None and(now-_HF_FILESET_CACHE["ts"])<ttl_s:return _HF_FILESET_CACHE["files"] | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if not tok or not repo or api is None:return[] | |
| try:files=api.list_repo_files(repo_id=repo,repo_type="dataset",token=tok) | |
| except Exception as e:_log("WARN",f"[hf_inventory] list_repo_files failed: {e!r}");return[] | |
| _HF_FILESET_CACHE["ts"]=now;_HF_FILESET_CACHE["files"]=files;return files | |
| def _hf_has_any_for_day(dom:str,hours:int,ymd:str)->bool: | |
| dom=(dom or"zz").lower();hours=int(hours);pref_png=f"tilecache/{dom}/{hours}/{ymd}/";pref_raw=f"rawtilecache/{dom}/{hours}/{ymd}/" | |
| files=_hf_repo_files_cached(ttl_s=120.0)or[] | |
| for f in files: | |
| if not f:continue | |
| f=f.strip() | |
| if f.startswith(pref_png)or f.startswith(pref_raw):return True | |
| parts=f.split("/",1) | |
| if len(parts)==2: | |
| tail=parts[1] | |
| if tail.startswith(pref_png)or tail.startswith(pref_raw):return True | |
| try: | |
| tok,repo=_hf_cfg() | |
| if not repo:return False | |
| api=HfApi() | |
| for c in[f"tilecache/{dom}/{hours}/{ymd}/0/0/0.png",f"tilecache/{dom}/{hours}/{ymd}/1/0/0.png",f"tilecache/{dom}/{hours}/{ymd}/2/0/0.png"]: | |
| try:hf_hub_download(repo_id=repo,filename=c,repo_type="dataset",token=tok,repo_type_kwargs=None);return True | |
| except:continue | |
| except Exception as e:_log("WARN",f"[hf_probe] error probing HF for day {ymd}: {e!r}") | |
| return False | |
| def forecast_available_days(dom: str = Query("zz"), lookback_days: int = Query(1, ge=0, le=7), lookahead_days: int = Query(3, ge=1, le=5)): | |
| cache_key = (dom, int(lookback_days), int(lookahead_days)) | |
| if _AVAILABLE_DATES_CACHE.get("data") is not None and _AVAILABLE_DATES_CACHE.get("key") == cache_key and (time.time() - float(_AVAILABLE_DATES_CACHE.get("ts", 0.0))) < _AVAILABLE_DATES_CACHE_TTL: | |
| return _AVAILABLE_DATES_CACHE["data"] | |
| window_dates = _rolling_window_dates(lookback_days=int(lookback_days), lookahead_days=int(lookahead_days)) | |
| local_status = {} | |
| for ymd in window_dates: | |
| has_24 = _check_date_has_local_tiles(dom, 24, ymd) | |
| has_72 = _check_date_has_local_tiles(dom, 72, ymd) | |
| local_status[ymd] = {"has_24h": has_24, "has_72h": has_72, "has_any": (has_24 or has_72)} | |
| now_local = datetime.now(LOCAL_TZ) | |
| resp = {"ok": True, "now_local": now_local.isoformat(), "today": now_local.strftime("%Y%m%d"), "lookback_days": int(lookback_days), "lookahead_days": int(lookahead_days), "available_dates": window_dates, "local_tile_status": local_status, "source": "rolling_window", "dom": dom} | |
| _AVAILABLE_DATES_CACHE["ts"] = time.time(); _AVAILABLE_DATES_CACHE["key"] = cache_key; _AVAILABLE_DATES_CACHE["data"] = resp | |
| return resp | |
| def __debug_inspect_snow(date:str=Query(...,regex=r"^\d{8}$"),dom:str=Query("zz")): | |
| def _safe(v): | |
| try: | |
| f=float(v) | |
| if not math.isfinite(f):return None | |
| return f | |
| except:return None | |
| dom=(dom or"zz").lower() | |
| sel=_pick_forecast_for_valid(f"{date}05",dom_prefer=dom) | |
| if not sel.get("ok"):return{"ok":False,"error":"pick_failed","detail":str(sel)} | |
| snow_info={"snowpack_urls":sel.get("snowpack_urls",[]),"snowpack_ts":sel.get("snowpack_ts",""),"run_init":sel.get("run_init",""),"source_type":sel.get("source_type","")} | |
| # Check snowpack COG if it exists | |
| valid=sel.get("valid",f"{date}05") | |
| snow_cog_name=f"fcst_snowpack_union_end_{valid}_{dom}_cog.tif" | |
| snow_cog=_forecast_dir()/snow_cog_name | |
| cog_info={"exists":snow_cog.exists(),"name":snow_cog_name} | |
| if snow_cog.exists(): | |
| try: | |
| with rasterio.open(snow_cog.as_posix())as src: | |
| cog_info["crs"]=str(src.crs) | |
| cog_info["transform"]=str(src.transform) | |
| cog_info["width"]=src.width | |
| cog_info["height"]=src.height | |
| cog_info["bounds"]=list(src.bounds) | |
| except Exception as e:cog_info["error"]=repr(e) | |
| # Also check the melt COG | |
| melt_cog_name=f"fcst_melt24h_union_end_{valid}_{dom}_cog.tif" | |
| melt_cog=_forecast_dir()/melt_cog_name | |
| melt_info={"exists":melt_cog.exists(),"name":melt_cog_name} | |
| if melt_cog.exists(): | |
| try: | |
| with rasterio.open(melt_cog.as_posix())as src: | |
| melt_info["crs"]=str(src.crs) | |
| melt_info["transform"]=str(src.transform) | |
| melt_info["width"]=src.width | |
| melt_info["height"]=src.height | |
| melt_info["bounds"]=list(src.bounds) | |
| except Exception as e:melt_info["error"]=repr(e) | |
| return{"ok":True,"date":date,"selection":snow_info,"snow_cog":cog_info,"melt_cog":melt_info} | |
| def __debug_prewarm_cn(days:int=Query(2,ge=2,le=3),hours:int=Query(24,ge=24,le=72),dom:str=Query("zz"),zmin:int=Query(6,ge=0,le=14),zmax:int=Query(12,ge=0,le=14),miles:float=Query(8.0,ge=0.0,le=50.0),cap_per_zoom:int=Query(6000,ge=100,le=20000),max_tiles_total:int=Query(20000,ge=100,le=100000)): | |
| dom=(dom or"zz").lower();now=datetime.utcnow().replace(tzinfo=timezone.utc);valid=_valid_ts_for_days(int(days),now_utc=now) | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom) | |
| if not sel.get("ok"):return{"ok":False,"error":"forecast_selection_failed","detail":sel} | |
| try:return _bake_corridor_tiles_for_date_optimized(date_yyyymmdd=sel["valid"][:8],dom=dom,hours_list=[int(hours)],zmin=int(zmin),zmax=int(zmax),miles=float(miles),cap_per_zoom=int(cap_per_zoom),max_tiles_total=int(max_tiles_total)) | |
| except Exception as e:return{"ok":False,"error":f"{e!r}"} | |
| def __debug_roll_now():return _run_daily_roll_job_impl() | |
| def __logs(since_ms:int|None=None,limit:int=200): | |
| try:limit=max(1,min(int(limit),1000)) | |
| except:limit=200 | |
| rows=list(_LOG) | |
| if since_ms is not None: | |
| try:rows=[r for r in rows if r["ts"]>int(since_ms)] | |
| except:pass | |
| return{"count":len(rows[-limit:]),"items":rows[-limit:]} | |
| def _tile_png_cached(sig: str, z: int, x: int, y: int, max_in: float | None) -> bytes: | |
| name = sig.split(":", 1)[0] | |
| p = _forecast_dir().parent / "forecast" / name if name.endswith(".tif") else None | |
| if p is None or not p.exists(): | |
| raise HTTPException(status_code=500, detail="cached tile missing COG") | |
| return _tile_png_from_cog(p, z, x, y, max_in) | |
| class ActiveDatePayload(BaseModel): | |
| client_id: str | None = None | |
| date_yyyymmdd: str | None = None | |
| gen: int | None = None | |
| def ui_active_date(payload: ActiveDatePayload = Body(...)): | |
| client_id = (payload.client_id or "").strip() | |
| date_yyyymmdd = (payload.date_yyyymmdd or "").strip() | |
| gen = payload.gen | |
| if not client_id: | |
| raise HTTPException(status_code=422, detail="client_id required") | |
| if not re.match(r"^\d{8}$", date_yyyymmdd): | |
| raise HTTPException(status_code=422, detail="date_yyyymmdd must be YYYYMMDD") | |
| _pause_push_for_user() | |
| _client_mark_active(client_id, date_yyyymmdd, gen=gen) | |
| return {"ok": True, "client_id": client_id, "active_date": date_yyyymmdd, "gen": gen} | |
| def __debug_purge_bad_npz(dry_run:bool=Query(True),check_hf_repo:bool=Query(False),min_valid_size:int=Query(100)): | |
| results={"ok":True,"dry_run":dry_run,"local_cache":{"path":None,"exists":False,"found":0,"files":[]},"hf_repo":{"enabled":check_hf_repo,"found":0,"files":[]}} | |
| raw_cache=CACHE/"rawtilecache";results["local_cache"]["path"]=str(raw_cache) | |
| if raw_cache.exists(): | |
| results["local_cache"]["exists"]=True;bad_files=[] | |
| for p in raw_cache.rglob("*.npz"): | |
| try: | |
| size=p.stat().st_size | |
| if size<min_valid_size:bad_files.append({"path":str(p.relative_to(CACHE)),"size":size}) | |
| if not dry_run:p.unlink(missing_ok=True) | |
| except Exception as e:bad_files.append({"path":str(p),"error":str(e)}) | |
| results["local_cache"]["found"]=len(bad_files);results["local_cache"]["files"]=bad_files[:50] | |
| if check_hf_repo: | |
| tok,repo=_hf_cfg() | |
| if tok and repo: | |
| try: | |
| api=HfApi();files=api.list_repo_files(repo_id=repo,repo_type="dataset",token=tok) | |
| npz_files=[f for f in files if f.startswith("rawtilecache/")and f.endswith(".npz")] | |
| results["hf_repo"]["total_npz_files"]=len(npz_files);results["hf_repo"]["sample_files"]=npz_files[:20] | |
| results["hf_repo"]["message"]="To check file sizes in HF repo, you need to use the HuggingFace web UI or download files individually." | |
| except Exception as e:results["hf_repo"]["error"]=str(e) | |
| else:results["hf_repo"]["error"]="HF not configured (missing HF_TOKEN or HF_DATASET_REPO)" | |
| tile_cache=CACHE/"tilecache" | |
| if tile_cache.exists(): | |
| bad_pngs=[] | |
| for p in tile_cache.rglob("*.png"): | |
| try: | |
| size=p.stat().st_size | |
| if size<67:bad_pngs.append({"path":str(p.relative_to(CACHE)),"size":size}) | |
| if not dry_run:p.unlink(missing_ok=True) | |
| except:pass | |
| if bad_pngs:results["tilecache_png"]={"found":len(bad_pngs),"files":bad_pngs[:50]} | |
| return results | |
| def __debug_cache_paths(): | |
| paths={"CACHE_ROOT":str(CACHE),"tilecache":str(CACHE/"tilecache"),"rawtilecache":str(CACHE/"rawtilecache"),"forecast":str(CACHE/"forecast"),"tracks":str(CACHE/"tracks")} | |
| status={} | |
| for name,path in paths.items(): | |
| p=Path(path) | |
| if p.exists(): | |
| if p.is_dir(): | |
| try:file_count=sum(1 for _ in p.rglob("*")if _.is_file());status[name]={"exists":True,"is_dir":True,"file_count":file_count} | |
| except Exception as e:status[name]={"exists":True,"is_dir":True,"error":str(e)} | |
| else:status[name]={"exists":True,"is_dir":False,"size":p.stat().st_size} | |
| else:status[name]={"exists":False} | |
| tok,repo=_hf_cfg();hf_info={"configured":bool(tok and repo),"repo":repo,"has_token":bool(tok)} | |
| return{"paths":paths,"status":status,"hf_repo":hf_info} | |
| def __debug_rebuild_cog(date:str=Query(...,regex=r"^\d{8}$"),dom:str=Query("zz"),wipe_tiles:bool=Query(True)): | |
| dom=(dom or"zz").lower();valid=f"{date}05";deleted=[] | |
| for pattern in[ | |
| f"fcst_melt24h_union_end_{valid}_{dom}_cog.tif", | |
| f"fcst_melt72h_end_{valid}_{dom}_cog.tif", | |
| f"fcst_snowpack_union_end_{valid}_{dom}_cog.tif", | |
| f"fcst_melt_t0024_*_{valid}_{dom}_cog.tif", | |
| f"fcst_melt_t0024_*_{valid}_*_cog.tif", | |
| ]: | |
| fc=_forecast_dir() | |
| for p in fc.glob(pattern): | |
| try:COG_CACHE.evict(p) | |
| except:pass | |
| try:p.unlink(missing_ok=True);deleted.append(p.name) | |
| except Exception as e:deleted.append(f"{p.name}: FAIL {e!r}") | |
| tiles_deleted=0 | |
| if wipe_tiles: | |
| for kind in["tilecache","rawtilecache"]: | |
| for h in WARM_HOURS_LIST: | |
| d=CACHE/kind/dom/str(h)/date | |
| if d.exists(): | |
| try:shutil.rmtree(d,ignore_errors=True);tiles_deleted+=1 | |
| except:pass | |
| return{"deleted_cogs":deleted,"tiles_dirs_deleted":tiles_deleted,"date":date,"valid":valid,"dom":dom,"message":"COGs and tiles deleted. Next request will rebuild everything."} | |
| def __debug_inspect_melt_nc(date:str=Query(...,regex=r"^\d{8}$"),dom:str=Query("zz")): | |
| def _safe(v): | |
| try: | |
| f=float(v) | |
| if not math.isfinite(f):return None | |
| return f | |
| except:return None | |
| dom=(dom or"zz").lower();valid=_norm_ts(f"{date}05") | |
| sel=_pick_forecast_for_valid(valid,dom_prefer=dom) | |
| if not sel.get("ok"):return{"ok":False,"error":"pick_failed","detail":str(sel)} | |
| url=sel["melt_urls"][0]if sel.get("melt_urls")else None | |
| if not url:return{"ok":False,"error":"no_melt_url"} | |
| with tempfile.TemporaryDirectory()as td: | |
| desc,name=_download_url_to_grid(url,Path(td)) | |
| da=_open_grid(desc) | |
| attrs=dict(da.attrs or{}) | |
| units=str(attrs.get("units","")).strip() | |
| vals=da.values.astype("float32");valid_mask=np.isfinite(vals)&(vals>-9990.0)&(vals<100000.0) | |
| valid_vals=vals[valid_mask] | |
| raw_info={"type":desc.get("type"),"note":"not netcdf"} | |
| if desc.get("type")=="netcdf": | |
| try: | |
| ds=xr.open_dataset(desc["path"].as_posix(),engine="netcdf4") | |
| raw_var=_pick_netcdf_raster_var(ds) | |
| raw_attrs=dict(raw_var.attrs or{}) | |
| raw_vals=raw_var.values.astype("float32").ravel() | |
| raw_valid=raw_vals[np.isfinite(raw_vals)&(raw_vals>-9990.0)&(raw_vals<1e20)] | |
| raw_info={"var_name":raw_var.name,"units":str(raw_attrs.get("units","")),"raw_mean":_safe(np.mean(raw_valid)),"raw_max":_safe(np.max(raw_valid)),"raw_min":_safe(np.min(raw_valid)),"raw_median":_safe(np.median(raw_valid)),"shape":list(raw_var.shape),"all_attrs":{k:str(v)for k,v in raw_attrs.items()}} | |
| ds.close() | |
| except Exception as e: | |
| raw_info={"error":repr(e)} | |
| samples={} | |
| for label,lon,lat in[("Rouses_Point",-73.365,44.991),("Ogdensburg",-75.486,44.694),("Belleville_ON",-77.385,44.163),("Montreal",-73.567,45.502),("Ottawa",-75.698,45.421)]: | |
| try: | |
| px=da.sel(x=lon,y=lat,method="nearest").values | |
| v=float(px) | |
| samples[label]={"mm":_safe(v),"inches":_safe(v/25.4)if np.isfinite(v)else None} | |
| except Exception as e: | |
| samples[label]={"error":str(e)} | |
| return{ | |
| "ok":True,"url":url,"filename":name,"valid":valid,"run_init":sel.get("run_init"),"source_type":"ttna"if"ttnats"in url.lower()else"forecast", | |
| "after_open_grid":{"units":units,"mean_mm":_safe(np.mean(valid_vals)),"max_mm":_safe(np.max(valid_vals)),"min_mm":_safe(np.min(valid_vals)),"median_mm":_safe(np.median(valid_vals)),"valid_pixels":int(valid_mask.sum()),"total_pixels":int(vals.size),"attrs":{k:str(v)for k,v in attrs.items()}}, | |
| "raw_file":raw_info, | |
| "point_samples_after_processing":samples, | |
| } | |
| # def _hot_swap_rebuild_date(date:str,dom:str)->None: | |
| # """Rebuild tiles from latest data, then swap out old tiles atomically. | |
| # Users always see baked tiles during the transition.""" | |
| # dom=(dom or"zz").lower() | |
| # valid_05=f"{date}05";valid_11=f"{date}11" | |
| # # 1. Build new COGs from latest NOAA data (old tiles still being served) | |
| # _log("INFO",f"[hot_swap] {date}: building fresh COGs") | |
| # # Delete only COGs, not tiles | |
| # for valid in[valid_05,valid_11]: | |
| # for pattern in[f"fcst_melt24h_union_end_{valid}_{dom}_cog.tif",f"fcst_melt72h_end_{valid}_{dom}_cog.tif",f"fcst_snowpack_union_end_{valid}_{dom}_cog.tif",f"fcst_melt_t0024_*_{valid}_{dom}_cog.tif",f"fcst_melt_t0024_*_{valid}_*_cog.tif"]: | |
| # for p in _forecast_dir().glob(pattern): | |
| # try:COG_CACHE.evict(p) | |
| # except:pass | |
| # try:p.unlink(missing_ok=True) | |
| # except:pass | |
| # # Also delete 72h COGs for next 2 dates | |
| # for offset in[1,2]: | |
| # try: | |
| # fd=(datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d") | |
| # for fv in[f"{fd}05",f"{fd}11"]: | |
| # for p in _forecast_dir().glob(f"fcst_melt72h_end_{fv}_*_cog.tif"): | |
| # try:COG_CACHE.evict(p) | |
| # except:pass | |
| # try:p.unlink(missing_ok=True) | |
| # except:pass | |
| # except:pass | |
| # # Clear negative cache | |
| # with _HF_NEGATIVE_CACHE_LK: | |
| # to_del=[k for k in _HF_NEGATIVE_CACHE if date in k] | |
| # for k in to_del:del _HF_NEGATIVE_CACHE[k] | |
| # best=_find_best_melt_for_date(date,dom_prefer=dom) | |
| # _log("INFO",f"[hot_swap] {date}: selected source_type={best.get('source_type','?')} valid={best.get('valid','?')}") | |
| # actual_valid=best.get("valid",f"{date}05") | |
| # r=_ensure_forecast_for_valid(actual_valid,dom_prefer=dom) | |
| # if not r.get("ok"):_log("WARN",f"[hot_swap] {date}: COG build failed: {r}");return | |
| # # 2. Delete old tiles so bake doesn't skip them | |
| # _log("INFO",f"[hot_swap] {date}: deleting old tiles") | |
| # for kind in["tilecache","rawtilecache"]: | |
| # for h in WARM_HOURS_LIST: | |
| # d=CACHE/kind/dom/str(h)/date | |
| # if d.exists():shutil.rmtree(d,ignore_errors=True) | |
| # # 3. Bake NEW tiles | |
| # _log("INFO",f"[hot_swap] {date}: baking new tiles") | |
| # with concurrent.futures.ThreadPoolExecutor(max_workers=2)as pool: | |
| # futs={} | |
| # for label,fn,extra in[("corridor_png",_bake_corridor_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL)),("corridor_raw",_bake_corridor_raw_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL))]: | |
| # futs[label]=pool.submit(fn,date_yyyymmdd=date,dom=dom,hours_list=WARM_HOURS_LIST,zmin=WARM_ZMIN,zmax=WARM_ZMAX,**extra) | |
| # for label,fut in futs.items(): | |
| # try: | |
| # r=fut.result();_log("INFO",f"[hot_swap] {date}: {label} ok={r.get('ok_count',0)} skip={r.get('skip_count',0)} fail={r.get('fail_count',0)}") | |
| # except Exception as e:_log("WARN",f"[hot_swap] {date}: {label} failed: {e!r}") | |
| # for label,fn,extra in[("lowres_png",_bake_lowres_box_tiles_for_date,dict(conc=1)),("lowres_raw",_bake_lowres_box_raw_tiles_for_date,dict(conc=1))]: | |
| # try:fn(date_yyyymmdd=date,dom=dom,hours_list=WARM_HOURS_LIST,zmin=LOWRES_BOX_ZMIN,zmax=LOWRES_BOX_ZMAX,**extra) | |
| # except:pass | |
| # for offset in[1,2]: | |
| # try: | |
| # fd=(datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d") | |
| # fb=_find_best_melt_for_date(fd,dom_prefer=dom) | |
| # fv=fb.get("valid",f"{fd}05")if fb.get("ok")else f"{fd}05" | |
| # try:_forecast_melt72h_end_cog(fv,dom_prefer=dom) | |
| # except:pass | |
| # _log("INFO",f"[hot_swap] rebaking 72h for {fd}") | |
| # with concurrent.futures.ThreadPoolExecutor(max_workers=2)as pool: | |
| # for fn,extra in[(_bake_corridor_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL)),(_bake_corridor_raw_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL))]: | |
| # pool.submit(fn,date_yyyymmdd=fd,dom=dom,hours_list=[72],zmin=WARM_ZMIN,zmax=WARM_ZMAX,**extra) | |
| # for fn,extra in[(_bake_lowres_box_tiles_for_date,dict(conc=1)),(_bake_lowres_box_raw_tiles_for_date,dict(conc=1))]: | |
| # try:fn(date_yyyymmdd=fd,dom=dom,hours_list=[72],zmin=LOWRES_BOX_ZMIN,zmax=LOWRES_BOX_ZMAX,**extra) | |
| # except:pass | |
| # except Exception as e:_log("WARN",f"[hot_swap] 72h rebake +{offset} failed: {e!r}") | |
| # # 4. NOW delete old HF archives and push new ones | |
| # _log("INFO",f"[hot_swap] {date}: deleting old HF archives") | |
| # tok,repo=_hf_cfg();api=_hf_api() | |
| # if tok and repo and api is not None: | |
| # # Delete old archives | |
| # for kind in["tilecache","rawtilecache"]: | |
| # for h in WARM_HOURS_LIST: | |
| # ap=f"archives/{kind}_{dom}_h{h}_{date}.tar.gz" | |
| # try:api.create_commit(repo_id=repo,repo_type="dataset",operations=[CommitOperationDelete(path_in_repo=ap)],commit_message=f"hot_swap: delete old {ap}") | |
| # except:pass | |
| # # Delete 72h archives for next 2 dates | |
| # for offset in[1,2]: | |
| # try: | |
| # fd=(datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d") | |
| # for kind in["tilecache","rawtilecache"]: | |
| # ap=f"archives/{kind}_{dom}_h72_{fd}.tar.gz" | |
| # try:api.create_commit(repo_id=repo,repo_type="dataset",operations=[CommitOperationDelete(path_in_repo=ap)],commit_message=f"hot_swap: delete old 72h {ap}") | |
| # except:pass | |
| # except:pass | |
| # # Delete tar markers | |
| # marker_dir=CACHE/"_tar_markers" | |
| # if marker_dir.exists(): | |
| # for mk in marker_dir.glob(f"*_{date}.tar.gz.done"): | |
| # try:mk.unlink(missing_ok=True) | |
| # except:pass | |
| # # 5. Push new tiles | |
| # push_dates=[date] | |
| # for offset in[1,2]: | |
| # try:push_dates.append((datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d")) | |
| # except:pass | |
| # _log("INFO",f"[hot_swap] pushing new tiles for {push_dates}") | |
| # _hf_push_baked_as_tar(push_dates,dom,WARM_HOURS_LIST) | |
| # # 6. Record signature | |
| # if best.get("ok")and best.get("melt_urls"): | |
| # _record_baked_sig(date,dom,best["melt_urls"][0],source_type=best.get("source_type")) | |
| # _log("INFO",f"[hot_swap] {date}: complete (source={best.get('source_type','?')})") | |
| def _hot_swap_rebuild_date(date:str,dom:str)->None: | |
| dom=(dom or"zz").lower() | |
| valid_05=f"{date}05";valid_11=f"{date}11" | |
| # 1. Build new COGs from latest NOAA data | |
| # Old COGs are deleted so they get rebuilt from fresh NOAA data. | |
| # Old TILES are NOT deleted yet — they keep serving users. | |
| _log("INFO",f"[hot_swap] {date}: building fresh COGs") | |
| for valid in[valid_05,valid_11]: | |
| for pattern in[f"fcst_melt24h_union_end_{valid}_{dom}_cog.tif",f"fcst_melt72h_end_{valid}_{dom}_cog.tif",f"fcst_snowpack_union_end_{valid}_{dom}_cog.tif",f"fcst_melt_t0024_*_{valid}_{dom}_cog.tif",f"fcst_melt_t0024_*_{valid}_*_cog.tif"]: | |
| for p in _forecast_dir().glob(pattern): | |
| try:COG_CACHE.evict(p) | |
| except:pass | |
| try:p.unlink(missing_ok=True) | |
| except:pass | |
| for offset in[1,2]: | |
| try: | |
| fd=(datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d") | |
| for fv in[f"{fd}05",f"{fd}11"]: | |
| for p in _forecast_dir().glob(f"fcst_melt72h_end_{fv}_*_cog.tif"): | |
| try:COG_CACHE.evict(p) | |
| except:pass | |
| try:p.unlink(missing_ok=True) | |
| except:pass | |
| except:pass | |
| with _HF_NEGATIVE_CACHE_LK: | |
| to_del=[k for k in _HF_NEGATIVE_CACHE if date in k] | |
| for k in to_del:del _HF_NEGATIVE_CACHE[k] | |
| best=_find_best_melt_for_date(date,dom_prefer=dom) | |
| _log("INFO",f"[hot_swap] {date}: selected source_type={best.get('source_type','?')} valid={best.get('valid','?')}") | |
| actual_valid=best.get("valid",f"{date}05") | |
| r=_ensure_forecast_for_valid(actual_valid,dom_prefer=dom) | |
| if not r.get("ok"):_log("WARN",f"[hot_swap] {date}: COG build failed: {r}");return | |
| # 2. Bake NEW tiles into staging directories | |
| # Old tiles remain on disk at the real paths, still serving users. | |
| _log("INFO",f"[hot_swap] {date}: baking new tiles to staging (old tiles still serving)") | |
| staging_root=CACHE/"_staging_swap"/date | |
| try: | |
| if staging_root.exists():shutil.rmtree(staging_root,ignore_errors=True) | |
| except:pass | |
| old_dirs=[] | |
| for kind in["tilecache","rawtilecache"]: | |
| for h in WARM_HOURS_LIST: | |
| d=CACHE/kind/dom/str(h)/date | |
| if d.exists(): | |
| backup=CACHE/"_staging_swap"/f"{kind}_{dom}_{h}_{date}_old" | |
| backup.parent.mkdir(parents=True,exist_ok=True) | |
| try: | |
| # Rename is atomic on same filesystem — instant, no downtime | |
| d.rename(backup) | |
| old_dirs.append((d,backup)) | |
| _log("DEBUG",f"[hot_swap] moved {d} -> {backup}") | |
| except Exception as e: | |
| # If rename fails (cross-device?), fall back to just deleting | |
| _log("WARN",f"[hot_swap] rename failed, deleting old tiles: {e!r}") | |
| shutil.rmtree(d,ignore_errors=True) | |
| # 3. Bake tiles — they go to the real paths (which are now empty) | |
| # PNG first so users see visual tiles sooner, then raw for mouse readouts | |
| _log("INFO",f"[hot_swap] {date}: baking corridor PNG tiles") | |
| try: | |
| r=_bake_corridor_tiles_for_date_optimized(date_yyyymmdd=date,dom=dom,hours_list=WARM_HOURS_LIST,zmin=WARM_ZMIN,zmax=WARM_ZMAX,miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL) | |
| _log("INFO",f"[hot_swap] {date}: corridor_png ok={r.get('ok_count',0)} skip={r.get('skip_count',0)} fail={r.get('fail_count',0)}") | |
| except Exception as e:_log("WARN",f"[hot_swap] {date}: corridor_png failed: {e!r}") | |
| _log("INFO",f"[hot_swap] {date}: baking corridor raw tiles") | |
| try: | |
| r=_bake_corridor_raw_tiles_for_date_optimized(date_yyyymmdd=date,dom=dom,hours_list=WARM_HOURS_LIST,zmin=WARM_ZMIN,zmax=WARM_ZMAX,miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL) | |
| _log("INFO",f"[hot_swap] {date}: corridor_raw ok={r.get('ok_count',0)} skip={r.get('skip_count',0)} fail={r.get('fail_count',0)}") | |
| except Exception as e:_log("WARN",f"[hot_swap] {date}: corridor_raw failed: {e!r}") | |
| for label,fn,extra in[("lowres_png",_bake_lowres_box_tiles_for_date,dict(conc=1)),("lowres_raw",_bake_lowres_box_raw_tiles_for_date,dict(conc=1))]: | |
| try:fn(date_yyyymmdd=date,dom=dom,hours_list=WARM_HOURS_LIST,zmin=LOWRES_BOX_ZMIN,zmax=LOWRES_BOX_ZMAX,**extra) | |
| except:pass | |
| # 4. Clean up old backup dirs (new tiles are now in place) | |
| for orig,backup in old_dirs: | |
| try:shutil.rmtree(backup,ignore_errors=True) | |
| except:pass | |
| try: | |
| if staging_root.exists():shutil.rmtree(staging_root,ignore_errors=True) | |
| except:pass | |
| # 5. Rebake 72h for future dates (+1, +2) | |
| for offset in[1,2]: | |
| try: | |
| fd=(datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d") | |
| fb=_find_best_melt_for_date(fd,dom_prefer=dom) | |
| fv=fb.get("valid",f"{fd}05")if fb.get("ok")else f"{fd}05" | |
| try:_forecast_melt72h_end_cog(fv,dom_prefer=dom) | |
| except:pass | |
| _log("INFO",f"[hot_swap] rebaking 72h for {fd}") | |
| # Delete old 72h tiles so bake doesn't skip them | |
| for kind in["tilecache","rawtilecache"]: | |
| d=CACHE/kind/dom/"72"/fd | |
| if d.exists(): | |
| shutil.rmtree(d,ignore_errors=True) | |
| _log("DEBUG",f"[hot_swap] deleted stale 72h tiles: {d}") | |
| # Also delete old 72h lowres tiles | |
| for kind in["tilecache","rawtilecache"]: | |
| d=CACHE/kind/dom/"72"/fd | |
| # Lowres tiles are at the same path under different zoom levels, | |
| # so the rmtree above already handles them | |
| # Bake fresh 72h tiles sequentially | |
| for fn,extra in[(_bake_corridor_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL)),(_bake_corridor_raw_tiles_for_date_optimized,dict(miles=WARM_MILES,cap_per_zoom=WARM_CAP_PER_ZOOM,max_tiles_total=WARM_MAX_TILES_TOTAL))]: | |
| try:fn(date_yyyymmdd=fd,dom=dom,hours_list=[72],zmin=WARM_ZMIN,zmax=WARM_ZMAX,**extra) | |
| except:pass | |
| for fn,extra in[(_bake_lowres_box_tiles_for_date,dict(conc=1)),(_bake_lowres_box_raw_tiles_for_date,dict(conc=1))]: | |
| try:fn(date_yyyymmdd=fd,dom=dom,hours_list=[72],zmin=LOWRES_BOX_ZMIN,zmax=LOWRES_BOX_ZMAX,**extra) | |
| except:pass | |
| except Exception as e:_log("WARN",f"[hot_swap] 72h rebake +{offset} failed: {e!r}") | |
| # 6. Delete old HF archives and push new ones | |
| _log("INFO",f"[hot_swap] {date}: deleting old HF archives") | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if tok and repo and api is not None: | |
| for kind in["tilecache","rawtilecache"]: | |
| for h in WARM_HOURS_LIST: | |
| ap=f"archives/{kind}_{dom}_h{h}_{date}.tar.gz" | |
| try:api.create_commit(repo_id=repo,repo_type="dataset",operations=[CommitOperationDelete(path_in_repo=ap)],commit_message=f"hot_swap: delete old {ap}") | |
| except:pass | |
| for offset in[1,2]: | |
| try: | |
| fd=(datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d") | |
| for kind in["tilecache","rawtilecache"]: | |
| ap=f"archives/{kind}_{dom}_h72_{fd}.tar.gz" | |
| try:api.create_commit(repo_id=repo,repo_type="dataset",operations=[CommitOperationDelete(path_in_repo=ap)],commit_message=f"hot_swap: delete old 72h {ap}") | |
| except:pass | |
| except:pass | |
| marker_dir=CACHE/"_tar_markers" | |
| if marker_dir.exists(): | |
| for mk in marker_dir.glob(f"*_{date}.tar.gz.done"): | |
| try:mk.unlink(missing_ok=True) | |
| except:pass | |
| # 7. Push new tiles | |
| push_dates=[date] | |
| for offset in[1,2]: | |
| try:push_dates.append((datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d")) | |
| except:pass | |
| _log("INFO",f"[hot_swap] pushing new tiles for {push_dates}") | |
| _hf_push_baked_as_tar(push_dates,dom,WARM_HOURS_LIST) | |
| # 8. Record signature | |
| if best.get("ok")and best.get("melt_urls"): | |
| _record_baked_sig(date,dom,best["melt_urls"][0],source_type=best.get("source_type")) | |
| _log("INFO",f"[hot_swap] {date}: complete (source={best.get('source_type','?')})") | |
| def __debug_nuke_date(date:str=Query(...,regex=r"^\d{8}$"),dom:str=Query("zz")): | |
| dom=(dom or"zz").lower();deleted_cogs=[];deleted_archives=[] | |
| # 1. Delete local COGs (forces rebuild from NOAA) | |
| for valid in[f"{date}05",f"{date}11"]: | |
| for pattern in[f"fcst_melt24h_union_end_{valid}_{dom}_cog.tif",f"fcst_melt72h_end_{valid}_{dom}_cog.tif",f"fcst_snowpack_union_end_{valid}_{dom}_cog.tif",f"fcst_melt_t0024_*_{valid}_{dom}_cog.tif",f"fcst_melt_t0024_*_{valid}_*_cog.tif"]: | |
| for p in _forecast_dir().glob(pattern): | |
| try:COG_CACHE.evict(p) | |
| except:pass | |
| try:p.unlink(missing_ok=True);deleted_cogs.append(p.name) | |
| except:pass | |
| # 2. Do NOT delete tiles — they keep serving users until rebake replaces them | |
| tile_dirs_present=0 | |
| for kind in["tilecache","rawtilecache"]: | |
| for h in WARM_HOURS_LIST: | |
| d=CACHE/kind/dom/str(h)/date | |
| if d.exists():tile_dirs_present+=1 | |
| # 3. Delete local tar markers so they don't block re-pull | |
| marker_dir=CACHE/"_tar_markers" | |
| markers_cleared=0 | |
| if marker_dir.exists(): | |
| for mk in marker_dir.glob(f"*_{date}.tar.gz.done"): | |
| try:mk.unlink(missing_ok=True);markers_cleared+=1 | |
| except:pass | |
| # 4. Delete tar archives from HF repo | |
| tok,repo=_hf_cfg();api=_hf_api() | |
| if tok and repo and api is not None: | |
| for kind in["tilecache","rawtilecache"]: | |
| for h in WARM_HOURS_LIST: | |
| archive_path=f"archives/{kind}_{dom}_h{h}_{date}.tar.gz" | |
| try: | |
| api.create_commit(repo_id=repo,repo_type="dataset",operations=[CommitOperationDelete(path_in_repo=archive_path)],commit_message=f"nuke: delete {archive_path}") | |
| deleted_archives.append(archive_path);_log("INFO",f"[nuke] deleted HF archive: {archive_path}") | |
| except Exception as e: | |
| err=repr(e) | |
| if"404"in err or"EntryNotFound"in err or"does not exist"in err: | |
| deleted_archives.append(f"{archive_path} (already gone)") | |
| else: | |
| deleted_archives.append(f"{archive_path}: FAIL {err}") | |
| # Also handle 72h for future dates | |
| for offset in[1,2]: | |
| try: | |
| fd=(datetime.strptime(date,"%Y%m%d")+timedelta(days=offset)).strftime("%Y%m%d") | |
| for fv in[f"{fd}05",f"{fd}11"]: | |
| for p in _forecast_dir().glob(f"fcst_melt72h_end_{fv}_*_cog.tif"): | |
| try:COG_CACHE.evict(p) | |
| except:pass | |
| try:p.unlink(missing_ok=True);deleted_cogs.append(p.name) | |
| except:pass | |
| if tok and repo and api is not None: | |
| for kind in["tilecache","rawtilecache"]: | |
| ap=f"archives/{kind}_{dom}_h72_{fd}.tar.gz" | |
| try:api.create_commit(repo_id=repo,repo_type="dataset",operations=[CommitOperationDelete(path_in_repo=ap)],commit_message=f"nuke: delete stale 72h {ap}") | |
| except:pass | |
| except:pass | |
| # 5. Clear negative cache | |
| with _HF_NEGATIVE_CACHE_LK: | |
| to_del=[k for k in _HF_NEGATIVE_CACHE if date in k] | |
| for k in to_del:del _HF_NEGATIVE_CACHE[k] | |
| return{ | |
| "deleted_cogs":deleted_cogs, | |
| "tile_dirs_kept":tile_dirs_present, | |
| "tiles_note":"Old tiles kept for serving — rebake will replace them", | |
| "hf_archives_deleted":deleted_archives, | |
| "tar_markers_cleared":markers_cleared, | |
| "negative_cache_cleared":len(to_del), | |
| "date":date, | |
| "message":f"Nuked COGs for {date} (tiles kept). Hit /__debug/rebake_date?date={date} to rebuild with fresh data." | |
| } | |
| def __debug_rebake_date(date:str=Query(...,regex=r"^\d{8}$"),dom:str=Query("zz")): | |
| dom=(dom or"zz").lower() | |
| def _do_rebake(): | |
| try:_hot_swap_rebuild_date(date,dom) | |
| except Exception as e:_log("WARN",f"[rebake] {date}: fatal error: {e!r}") | |
| threading.Thread(target=_do_rebake,daemon=True,name=f"rebake_{date}").start() | |
| return{"ok":True,"date":date,"message":f"Hot-swap rebake started for {date}. Old tiles served until new ones are ready."} | |
| def __debug_noaa_sigs(): | |
| with _NOAA_FILE_SIGS_LK: | |
| return{"sigs":{k:v for k,v in _NOAA_FILE_SIGS.items()}} | |
| def __debug_inspect_full_tile(date:str=Query(...,regex=r"^\d{8}$"),dom:str=Query("zz")): | |
| dom=(dom or"zz").lower() | |
| sel=_pick_forecast_for_valid(f"{date}05",dom_prefer=dom) | |
| best=_find_best_melt_for_date(date,dom_prefer=dom) | |
| melt_valid=best.get("valid","?") | |
| snow_urls=sel.get("snowpack_urls",[]) | |
| snow_ts=sel.get("snowpack_ts","?") | |
| # Check what snowpack COG exists | |
| snow_cogs=[] | |
| for v in[f"{date}11",f"{date}05"]: | |
| p=_forecast_dir()/f"fcst_snowpack_union_end_{v}_{dom}_cog.tif" | |
| if p.exists():snow_cogs.append({"name":p.name,"valid":v,"size":p.stat().st_size}) | |
| return{ | |
| "date":date, | |
| "melt_source":best.get("source_type","?"), | |
| "melt_valid":melt_valid, | |
| "melt_url":best.get("melt_urls",["?"])[0].split("/")[-1]if best.get("melt_urls")else"?", | |
| "snowpack_ts_from_sel":snow_ts, | |
| "snowpack_urls_count":len(snow_urls), | |
| "snowpack_urls":[u.split("/")[-1]for u in snow_urls[:3]], | |
| "snowpack_cogs_on_disk":snow_cogs, | |
| "run_init":best.get("run_init","?"), | |
| } | |
| if __name__=="__main__": | |
| import uvicorn | |
| host=os.environ.get("HOST","0.0.0.0");port=int(os.environ.get("PORT","8000"));workers=int(os.environ.get("UVICORN_WORKERS","3")or"3") | |
| module=(os.environ.get("APP_MODULE","")or"").strip() | |
| if workers>1 and not module:raise SystemExit("UVICORN_WORKERS>1 requires APP_MODULE like 'main:app'. Example: APP_MODULE=main:app UVICORN_WORKERS=3 python main.py") | |
| if module:uvicorn.run(module,host=host,port=port,workers=workers,log_level="info",access_log=False) | |
| else:uvicorn.run(app,host=host,port=port,workers=1,log_level="info",access_log=False) |