Compare commits
No commits in common. "b0657cf3e421d4d5464f2ced80e2ec027762d8f8" and "7b48caa7933eea7b00f5f91889c76627e95612b9" have entirely different histories.
b0657cf3e4
...
7b48caa793
6 changed files with 5 additions and 346 deletions
|
|
@ -72,13 +72,6 @@ OSINT_USER_AGENT = os.getenv(
|
||||||
"OSINT_USER_AGENT", "osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)"
|
"OSINT_USER_AGENT", "osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Self-hosted TiTiler (warps Sentinel-1 signed COGs into XYZ tiles on the Pi).
|
|
||||||
# TITILER_PUBLIC_BASE is the same-origin path prefix the browser hits through
|
|
||||||
# the osint.rpi.local nginx vhost (`location /titiler/` → 127.0.0.1:8001).
|
|
||||||
# TITILER_INTERNAL_URL is the compose-DNS address, used only for health checks.
|
|
||||||
TITILER_PUBLIC_BASE = os.getenv("TITILER_PUBLIC_BASE", "/titiler").rstrip("/")
|
|
||||||
TITILER_INTERNAL_URL = os.getenv("TITILER_INTERNAL_URL", "http://titiler:8000")
|
|
||||||
|
|
||||||
# AISStream (server-side WebSocket only). Idle when unset.
|
# AISStream (server-side WebSocket only). Idle when unset.
|
||||||
AISSTREAM_API_KEY = os.getenv("AISSTREAM_API_KEY", "")
|
AISSTREAM_API_KEY = os.getenv("AISSTREAM_API_KEY", "")
|
||||||
# Bounding box(es) as minlat,minlon,maxlat,maxlon — note lat/lon order (AISStream).
|
# Bounding box(es) as minlat,minlon,maxlat,maxlon — note lat/lon order (AISStream).
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,13 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from urllib.parse import quote, urlencode
|
from urllib.parse import quote
|
||||||
|
|
||||||
from config import OSINT_USER_AGENT, TITILER_PUBLIC_BASE
|
from config import OSINT_USER_AGENT
|
||||||
|
|
||||||
logger = logging.getLogger("osint.live_layers")
|
logger = logging.getLogger("osint.live_layers")
|
||||||
|
|
||||||
|
|
@ -45,15 +45,6 @@ WFIGS_PERIMETERS = (
|
||||||
)
|
)
|
||||||
NHC_STORMS = "https://www.nhc.noaa.gov/CurrentStorms.json"
|
NHC_STORMS = "https://www.nhc.noaa.gov/CurrentStorms.json"
|
||||||
|
|
||||||
PC_STAC_SEARCH = "https://planetarycomputer.microsoft.com/api/stac/v1/search"
|
|
||||||
PC_SAS_TOKEN = "https://planetarycomputer.microsoft.com/api/sas/v1/token/sentinel-1-grd"
|
|
||||||
# Self-hosted TiTiler on the Pi, exposed same-origin through the osint.rpi.local
|
|
||||||
# nginx vhost. Relative template — Leaflet resolves it against the page origin,
|
|
||||||
# so the browser never touches a raw loopback port or titiler.xyz.
|
|
||||||
TITILER_COG_TILES = f"{TITILER_PUBLIC_BASE}/cog/tiles/WebMercatorQuad/{{z}}/{{x}}/{{y}}@1x"
|
|
||||||
SENTINEL1_TTL = 20 * 60 # 15–30 min quota-friendly window
|
|
||||||
SENTINEL1_ATTRIBUTION = "Copernicus Sentinel-1 / Microsoft Planetary Computer"
|
|
||||||
|
|
||||||
IEM_NEXRAD = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q/{z}/{x}/{y}.png"
|
IEM_NEXRAD = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q/{z}/{x}/{y}.png"
|
||||||
GIBS_THERMAL = (
|
GIBS_THERMAL = (
|
||||||
"https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/"
|
"https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/"
|
||||||
|
|
@ -119,15 +110,6 @@ def overlay_catalog() -> dict:
|
||||||
"maxZoom": 9,
|
"maxZoom": 9,
|
||||||
"attribution": "NASA GIBS / EOSDIS",
|
"attribution": "NASA GIBS / EOSDIS",
|
||||||
},
|
},
|
||||||
"sentinel1_sar": {
|
|
||||||
"id": "sentinel1_sar",
|
|
||||||
"title": "Sentinel-1 SAR (Cloud-Penetrating)",
|
|
||||||
"kind": "raster",
|
|
||||||
"tileUrl": None, # filled from /api/map/sentinel1 (signed COG)
|
|
||||||
"endpoint": "/api/map/sentinel1",
|
|
||||||
"maxZoom": 14,
|
|
||||||
"attribution": SENTINEL1_ATTRIBUTION,
|
|
||||||
},
|
|
||||||
"nws_alerts": {"id": "nws_alerts", "kind": "geojson", "endpoint": "/api/weather-alerts"},
|
"nws_alerts": {"id": "nws_alerts", "kind": "geojson", "endpoint": "/api/weather-alerts"},
|
||||||
"wfigs_incidents": {"id": "wfigs_incidents", "kind": "points", "endpoint": "/api/fire-incidents"},
|
"wfigs_incidents": {"id": "wfigs_incidents", "kind": "points", "endpoint": "/api/fire-incidents"},
|
||||||
"wfigs_perimeters": {"id": "wfigs_perimeters", "kind": "geojson", "endpoint": "/api/fire-perimeters"},
|
"wfigs_perimeters": {"id": "wfigs_perimeters", "kind": "geojson", "endpoint": "/api/fire-perimeters"},
|
||||||
|
|
@ -1154,116 +1136,3 @@ async def fetch_storms() -> list[dict]:
|
||||||
return transform_nhc_storms(await _get_json(NHC_STORMS))
|
return transform_nhc_storms(await _get_json(NHC_STORMS))
|
||||||
|
|
||||||
return await _ttl_get("nhc:storms", 300.0, _load)
|
return await _ttl_get("nhc:storms", 300.0, _load)
|
||||||
|
|
||||||
|
|
||||||
# ── Sentinel-1 SAR (Planetary Computer STAC → signed COG tile template) ────
|
|
||||||
|
|
||||||
class UpstreamRateLimited(Exception):
|
|
||||||
"""Planetary Computer returned 429. Carries Retry-After for the client."""
|
|
||||||
|
|
||||||
def __init__(self, retry_after: str | None = None):
|
|
||||||
self.retry_after = retry_after
|
|
||||||
super().__init__("planetary computer rate limited")
|
|
||||||
|
|
||||||
|
|
||||||
async def _post_json(
|
|
||||||
url: str, json: dict | None = None, headers: dict | None = None,
|
|
||||||
) -> Any:
|
|
||||||
if _http is None:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
timeout=_HTTP_TIMEOUT, follow_redirects=True, headers=_headers(),
|
|
||||||
) as client:
|
|
||||||
resp = await client.post(url, json=json, headers=headers)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
resp = await _http.post(url, json=json, headers=headers)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def _pc_call(coro: Awaitable[Any]) -> Any:
|
|
||||||
"""Run a Planetary Computer call, mapping 429 → UpstreamRateLimited."""
|
|
||||||
try:
|
|
||||||
return await coro
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
if exc.response.status_code == 429:
|
|
||||||
raise UpstreamRateLimited(
|
|
||||||
exc.response.headers.get("Retry-After")
|
|
||||||
) from exc
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def sign_cog_url(href: str, token: str) -> str:
|
|
||||||
"""Append a SAS token to a PC blob URL (respect existing query string)."""
|
|
||||||
sep = "&" if "?" in href else "?"
|
|
||||||
return f"{href}{sep}token={quote(token, safe='')}"
|
|
||||||
|
|
||||||
|
|
||||||
def sentinel1_tile_url(signed_cog: str) -> str:
|
|
||||||
"""TiTiler XYZ template for a signed COG (Leaflet substitutes {z}/{x}/{y})."""
|
|
||||||
params = urlencode({
|
|
||||||
"url": signed_cog,
|
|
||||||
"rescale": "0,500",
|
|
||||||
"colormap_name": "cfastie",
|
|
||||||
})
|
|
||||||
return f"{TITILER_COG_TILES}?{params}"
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_sentinel1(bbox: str) -> dict | None:
|
|
||||||
"""Most recent Sentinel-1 GRD COG for a viewport, signed and TiTiler-ready.
|
|
||||||
|
|
||||||
Returns the overlay tile-template dict, or ``None`` when no GRD imagery
|
|
||||||
covers the bbox in the last 7 days (caller maps to 404). Queries Planetary
|
|
||||||
Computer only when called; cached per quantized bbox + UTC day.
|
|
||||||
"""
|
|
||||||
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
|
||||||
day = datetime.now(timezone.utc).date().isoformat()
|
|
||||||
key = f"sentinel1:{day}:{bbox_cell_key(bbox)}"
|
|
||||||
|
|
||||||
async def _load() -> dict | None:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
week_ago = now - timedelta(days=7)
|
|
||||||
payload = {
|
|
||||||
"collections": ["sentinel-1-grd"],
|
|
||||||
"bbox": [minlon, minlat, maxlon, maxlat],
|
|
||||||
"datetime": f"{week_ago.isoformat()}/{now.isoformat()}",
|
|
||||||
"limit": 1,
|
|
||||||
"sortby": [{"field": "datetime", "direction": "desc"}],
|
|
||||||
}
|
|
||||||
data = await _pc_call(_post_json(PC_STAC_SEARCH, json=payload))
|
|
||||||
features = data.get("features") or []
|
|
||||||
if not features:
|
|
||||||
return None
|
|
||||||
|
|
||||||
feat = features[0]
|
|
||||||
assets = feat.get("assets") or {}
|
|
||||||
chosen_href: str | None = None
|
|
||||||
polarization: str | None = None
|
|
||||||
for pol in ("vv", "hh"):
|
|
||||||
href = (assets.get(pol) or {}).get("href")
|
|
||||||
if href:
|
|
||||||
chosen_href = href
|
|
||||||
polarization = pol
|
|
||||||
break
|
|
||||||
if not chosen_href:
|
|
||||||
return None
|
|
||||||
|
|
||||||
sas = await _pc_call(_get_json(PC_SAS_TOKEN))
|
|
||||||
token = (sas or {}).get("token")
|
|
||||||
if not token:
|
|
||||||
raise RuntimeError("planetarycomputer SAS token missing")
|
|
||||||
signed = sign_cog_url(chosen_href, token)
|
|
||||||
|
|
||||||
props = feat.get("properties") or {}
|
|
||||||
return {
|
|
||||||
"id": "sentinel-1-sar",
|
|
||||||
"kind": "raster",
|
|
||||||
"tileUrl": sentinel1_tile_url(signed),
|
|
||||||
"opacity": 0.8,
|
|
||||||
"itemId": feat.get("id"),
|
|
||||||
"datetime": props.get("datetime") or feat.get("datetime"),
|
|
||||||
"polarization": polarization,
|
|
||||||
"attribution": SENTINEL1_ATTRIBUTION,
|
|
||||||
}
|
|
||||||
|
|
||||||
return await _ttl_get(key, float(SENTINEL1_TTL), _load)
|
|
||||||
|
|
|
||||||
32
app/main.py
32
app/main.py
|
|
@ -53,9 +53,8 @@ from keystore import KeyFormatError, delete_key, list_keys, set_key
|
||||||
from settings_store import SettingsError, get_app_settings, list_models, set_summary_model
|
from settings_store import SettingsError, get_app_settings, list_models, set_summary_model
|
||||||
from live_layers import (
|
from live_layers import (
|
||||||
fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters,
|
fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters,
|
||||||
fetch_planespotters_photo, fetch_radar_meta, fetch_sentinel1, fetch_storms,
|
fetch_planespotters_photo, fetch_radar_meta, fetch_storms, fetch_trains,
|
||||||
fetch_trains, fetch_vessels, fetch_weather_alerts, overlay_catalog,
|
fetch_vessels, fetch_weather_alerts, overlay_catalog, parse_bbox,
|
||||||
parse_bbox, UpstreamRateLimited,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
@ -1414,33 +1413,6 @@ async def map_radar():
|
||||||
_upstream_or_502(exc, "radar")
|
_upstream_or_502(exc, "radar")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/map/sentinel1")
|
|
||||||
async def map_sentinel1(bbox: str = Query(..., description="minlon,minlat,maxlon,maxlat")):
|
|
||||||
"""Most recent Sentinel-1 GRD as a signed COG tile template (TiTiler).
|
|
||||||
|
|
||||||
Queries Planetary Computer only on demand; no tiles proxied through the Pi.
|
|
||||||
"""
|
|
||||||
_parse_bbox_query(bbox)
|
|
||||||
try:
|
|
||||||
result = await fetch_sentinel1(bbox)
|
|
||||||
except UpstreamRateLimited as exc:
|
|
||||||
headers = {"Retry-After": exc.retry_after} if exc.retry_after else None
|
|
||||||
raise HTTPException(
|
|
||||||
429, "Planetary Computer rate limit", headers=headers,
|
|
||||||
) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
_upstream_or_502(exc, "sentinel1")
|
|
||||||
if result is None:
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content={
|
|
||||||
"error": "no_imagery",
|
|
||||||
"message": "No Sentinel-1 GRD in the last 7 days for this bbox",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return overlay_json(result, 300)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/aircraft")
|
@app.get("/api/aircraft")
|
||||||
async def list_aircraft(
|
async def list_aircraft(
|
||||||
bbox: str = Query(..., description="minlon,minlat,maxlon,maxlat"),
|
bbox: str = Query(..., description="minlon,minlat,maxlon,maxlat"),
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# osint.rpi.local — Sentinel-1 SAR tile proxy (/titiler/)
|
|
||||||
#
|
|
||||||
# GitOps: this file is the source of truth. On the Pi:
|
|
||||||
# sudo cp deploy/osint-titiler.nginx.conf /etc/nginx/snippets/osint-titiler.conf
|
|
||||||
# then `include snippets/osint-titiler.conf;` inside the osint.rpi.local server
|
|
||||||
# block (before `location /`), `nginx -t && systemctl reload nginx`.
|
|
||||||
#
|
|
||||||
# The browser hits /titiler/cog/tiles/... (same-origin). We strip the /titiler
|
|
||||||
# prefix so self-hosted TiTiler (127.0.0.1:8001) sees /cog/tiles/... and proxy
|
|
||||||
# its response straight back. Tiles are heavy PNGs — disable buffering so a
|
|
||||||
# slow client doesn't hold a worker open.
|
|
||||||
|
|
||||||
location /titiler/ {
|
|
||||||
proxy_pass http://127.0.0.1:8001/;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
}
|
|
||||||
|
|
@ -141,9 +141,6 @@ services:
|
||||||
VESSELAPI_INTERVAL: ${VESSELAPI_INTERVAL:-17280}
|
VESSELAPI_INTERVAL: ${VESSELAPI_INTERVAL:-17280}
|
||||||
VESSELAPI_MAX_CALLS_PER_DAY: ${VESSELAPI_MAX_CALLS_PER_DAY:-5}
|
VESSELAPI_MAX_CALLS_PER_DAY: ${VESSELAPI_MAX_CALLS_PER_DAY:-5}
|
||||||
VESSELAPI_IN_APP: ${VESSELAPI_IN_APP:-1}
|
VESSELAPI_IN_APP: ${VESSELAPI_IN_APP:-1}
|
||||||
# ── Self-hosted TiTiler (Sentinel-1 SAR tiles) ──
|
|
||||||
TITILER_PUBLIC_BASE: ${TITILER_PUBLIC_BASE:-/titiler}
|
|
||||||
TITILER_INTERNAL_URL: ${TITILER_INTERNAL_URL:-http://titiler:8000}
|
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
deploy:
|
deploy:
|
||||||
|
|
@ -156,27 +153,6 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# ── Self-hosted TiTiler (Sentinel-1 SAR COG → XYZ tiles) ────────────────
|
|
||||||
# Warps the signed Planetary Computer COG into WebMercator XYZ tiles so the
|
|
||||||
# browser never loads a multi-GB GeoTIFF. The FastAPI app signs the COG URL
|
|
||||||
# and returns a /titiler/... template; nginx routes /titiler/ here.
|
|
||||||
# Listens on 8000 INSIDE the container (the app already owns host 8000);
|
|
||||||
# published on host loopback 127.0.0.1:8001 only.
|
|
||||||
titiler:
|
|
||||||
image: ghcr.io/developmentseed/titiler:latest
|
|
||||||
container_name: osint-titiler
|
|
||||||
platform: linux/arm64
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- PORT=8000
|
|
||||||
- WORKERS_PER_CORE=1
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:8001:8000"
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 1G
|
|
||||||
|
|
||||||
camera-service:
|
camera-service:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ from live_layers import (
|
||||||
parse_bbox,
|
parse_bbox,
|
||||||
quantize_bbox,
|
quantize_bbox,
|
||||||
rainviewer_tile_url,
|
rainviewer_tile_url,
|
||||||
sign_cog_url,
|
|
||||||
sentinel1_tile_url,
|
|
||||||
slim_alert_properties,
|
slim_alert_properties,
|
||||||
to_marker,
|
to_marker,
|
||||||
transform_adsb_lol,
|
transform_adsb_lol,
|
||||||
|
|
@ -17,8 +15,6 @@ from live_layers import (
|
||||||
transform_amtraker,
|
transform_amtraker,
|
||||||
transform_nhc_storms,
|
transform_nhc_storms,
|
||||||
transform_wfigs_incidents,
|
transform_wfigs_incidents,
|
||||||
SENTINEL1_ATTRIBUTION,
|
|
||||||
TITILER_COG_TILES,
|
|
||||||
_cache,
|
_cache,
|
||||||
_ttl_get,
|
_ttl_get,
|
||||||
_wfigs_params,
|
_wfigs_params,
|
||||||
|
|
@ -656,129 +652,3 @@ def test_planespotters_headers_add_contact_when_ua_is_generic(monkeypatch):
|
||||||
ua = live_layers._planespotters_headers()["User-Agent"]
|
ua = live_layers._planespotters_headers()["User-Agent"]
|
||||||
assert "osint-dashboard" in ua
|
assert "osint-dashboard" in ua
|
||||||
assert "@" in ua
|
assert "@" in ua
|
||||||
|
|
||||||
|
|
||||||
# ── Sentinel-1 SAR (Planetary Computer STAC → signed COG template) ────────
|
|
||||||
|
|
||||||
def test_sign_cog_url_appends_token():
|
|
||||||
assert sign_cog_url("https://blob.example/x.tif", "tok=abc") == \
|
|
||||||
"https://blob.example/x.tif?token=tok%3Dabc"
|
|
||||||
# Existing query string → append with &
|
|
||||||
assert sign_cog_url("https://blob.example/x.tif?st=1", "tok") == \
|
|
||||||
"https://blob.example/x.tif?st=1&token=tok"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sentinel1_tile_url_contains_titiler_rescale_and_cfastie():
|
|
||||||
signed = "https://blob.example/x.tif?token=secret"
|
|
||||||
url = sentinel1_tile_url(signed)
|
|
||||||
assert url.startswith(TITILER_COG_TILES + "?")
|
|
||||||
assert "WebMercatorQuad/{z}/{x}/{y}@1x" in url
|
|
||||||
assert "url=https%3A%2F%2Fblob.example%2Fx.tif%3Ftoken%3Dsecret" in url
|
|
||||||
assert "rescale=0%2C500" in url
|
|
||||||
assert "colormap_name=cfastie" in url
|
|
||||||
|
|
||||||
|
|
||||||
def test_sentinel1_tile_url_is_same_origin_relative():
|
|
||||||
# Self-hosted TiTiler: the browser must hit the Pi's nginx vhost, not
|
|
||||||
# titiler.xyz or a raw host:port. The template is a root-relative path.
|
|
||||||
url = sentinel1_tile_url("https://blob.example/x.tif")
|
|
||||||
assert url.startswith("/titiler/cog/tiles/WebMercatorQuad/")
|
|
||||||
assert "://" not in url
|
|
||||||
assert "titiler.xyz" not in url
|
|
||||||
|
|
||||||
|
|
||||||
def _stac_feature(assets: dict) -> dict:
|
|
||||||
return {
|
|
||||||
"type": "Feature",
|
|
||||||
"id": "S1A_IW_GRDH_1SDV_20240820T000000",
|
|
||||||
"properties": {"datetime": "2024-08-20T00:00:00Z"},
|
|
||||||
"assets": assets,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_sentinel1_vv_signed_tile_url(monkeypatch):
|
|
||||||
import asyncio
|
|
||||||
from live_layers import fetch_sentinel1, _cache
|
|
||||||
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
async def fake_post(url, json=None, headers=None):
|
|
||||||
calls.append(("post", url, json))
|
|
||||||
return {"features": [_stac_feature({
|
|
||||||
"vv": {"href": "https://blob.example/grd-vv.tif"},
|
|
||||||
})]}
|
|
||||||
|
|
||||||
async def fake_get(url, params=None, headers=None):
|
|
||||||
calls.append(("get", url))
|
|
||||||
return {"token": "sig=abc123"}
|
|
||||||
|
|
||||||
monkeypatch.setattr("live_layers._post_json", fake_post)
|
|
||||||
monkeypatch.setattr("live_layers._get_json", fake_get)
|
|
||||||
_cache.clear()
|
|
||||||
|
|
||||||
out = asyncio.run(fetch_sentinel1("-80,35,-79,36"))
|
|
||||||
assert out["id"] == "sentinel-1-sar"
|
|
||||||
assert out["kind"] == "raster"
|
|
||||||
assert out["polarization"] == "vv"
|
|
||||||
assert out["opacity"] == 0.8
|
|
||||||
assert out["itemId"].startswith("S1A")
|
|
||||||
assert out["attribution"] == SENTINEL1_ATTRIBUTION
|
|
||||||
assert "WebMercatorQuad/{z}/{x}/{y}@1x" in out["tileUrl"]
|
|
||||||
assert "rescale=0%2C500" in out["tileUrl"]
|
|
||||||
assert "colormap_name=cfastie" in out["tileUrl"]
|
|
||||||
# SAS token "sig=abc123" is signed as ?token=sig%3Dabc123, then the whole
|
|
||||||
# COG URL is percent-encoded again as a query param (=> sig%253Dabc123).
|
|
||||||
assert "sig%253Dabc123" in out["tileUrl"]
|
|
||||||
# STAC search payload shape
|
|
||||||
post_url, post_json = calls[0][1], calls[0][2]
|
|
||||||
assert post_url.endswith("/api/stac/v1/search")
|
|
||||||
assert post_json["collections"] == ["sentinel-1-grd"]
|
|
||||||
assert post_json["limit"] == 1
|
|
||||||
assert post_json["sortby"][0]["direction"] == "desc"
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_sentinel1_uses_hh_when_vv_missing(monkeypatch):
|
|
||||||
import asyncio
|
|
||||||
from live_layers import fetch_sentinel1, _cache
|
|
||||||
|
|
||||||
async def fake_post(url, json=None, headers=None):
|
|
||||||
return {"features": [_stac_feature({
|
|
||||||
"hh": {"href": "https://blob.example/grd-hh.tif"},
|
|
||||||
})]}
|
|
||||||
|
|
||||||
async def fake_get(url, params=None, headers=None):
|
|
||||||
return {"token": "tok"}
|
|
||||||
|
|
||||||
monkeypatch.setattr("live_layers._post_json", fake_post)
|
|
||||||
monkeypatch.setattr("live_layers._get_json", fake_get)
|
|
||||||
_cache.clear()
|
|
||||||
|
|
||||||
out = asyncio.run(fetch_sentinel1("-80,35,-79,36"))
|
|
||||||
assert out["polarization"] == "hh"
|
|
||||||
assert "url=https%3A%2F%2Fblob.example%2Fgrd-hh.tif" in out["tileUrl"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_sentinel1_none_on_empty_features(monkeypatch):
|
|
||||||
import asyncio
|
|
||||||
from live_layers import fetch_sentinel1, _cache
|
|
||||||
|
|
||||||
async def fake_post(url, json=None, headers=None):
|
|
||||||
return {"features": []}
|
|
||||||
|
|
||||||
monkeypatch.setattr("live_layers._post_json", fake_post)
|
|
||||||
_cache.clear()
|
|
||||||
|
|
||||||
assert asyncio.run(fetch_sentinel1("-80,35,-79,36")) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_sentinel1_none_when_no_vv_or_hh(monkeypatch):
|
|
||||||
import asyncio
|
|
||||||
from live_layers import fetch_sentinel1, _cache
|
|
||||||
|
|
||||||
async def fake_post(url, json=None, headers=None):
|
|
||||||
return {"features": [_stac_feature({"thumbnail": {"href": "https://x"}})]}
|
|
||||||
|
|
||||||
monkeypatch.setattr("live_layers._post_json", fake_post)
|
|
||||||
_cache.clear()
|
|
||||||
|
|
||||||
assert asyncio.run(fetch_sentinel1("-80,35,-79,36")) is None
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue