Compare commits
No commits in common. "47c726d68dca85abe44108d3f65aef640b35e7cf" and "fdf5969e2776f0d8542f825acd1c82ed5ab76b2e" have entirely different histories.
47c726d68d
...
fdf5969e27
20 changed files with 57 additions and 669 deletions
|
|
@ -1,41 +0,0 @@
|
||||||
"""vessels — daily VesselAPI snapshots for DVR as-of
|
|
||||||
|
|
||||||
Revision ID: 009_vessels
|
|
||||||
Revises: 008_summary_kind
|
|
||||||
Create Date: 2026-08-29
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "009_vessels"
|
|
||||||
down_revision = "008_summary_kind"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS vessels (
|
|
||||||
mmsi TEXT NOT NULL,
|
|
||||||
poll_at TIMESTAMPTZ NOT NULL,
|
|
||||||
lat DOUBLE PRECISION NOT NULL,
|
|
||||||
lon DOUBLE PRECISION NOT NULL,
|
|
||||||
heading DOUBLE PRECISION,
|
|
||||||
speed DOUBLE PRECISION,
|
|
||||||
label TEXT,
|
|
||||||
extra JSONB,
|
|
||||||
PRIMARY KEY (mmsi, poll_at)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
op.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_vessels_poll_at ON vessels (poll_at DESC)"
|
|
||||||
)
|
|
||||||
op.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_vessels_bbox ON vessels (lon, lat)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.execute("DROP TABLE IF EXISTS vessels")
|
|
||||||
|
|
@ -51,9 +51,7 @@ PC_SAS_TOKEN = "https://planetarycomputer.microsoft.com/api/sas/v1/token/sentine
|
||||||
# nginx vhost. Relative template — Leaflet resolves it against the page origin,
|
# nginx vhost. Relative template — Leaflet resolves it against the page origin,
|
||||||
# so the browser never touches a raw loopback port or titiler.xyz.
|
# 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}}"
|
TITILER_COG_TILES = f"{TITILER_PUBLIC_BASE}/cog/tiles/WebMercatorQuad/{{z}}/{{x}}/{{y}}"
|
||||||
SENTINEL1_TTL = 6 * 3600 # S-1 revisit is 6–12 days; cache the COG all afternoon
|
SENTINEL1_TTL = 20 * 60 # 15–30 min quota-friendly window
|
||||||
SENTINEL1_CELL = 2.0 # degrees — pan/zoom inside a cell reuses the same scene
|
|
||||||
SENTINEL1_STAC_LIMIT = 8
|
|
||||||
SENTINEL1_ATTRIBUTION = "Copernicus Sentinel-1 / Microsoft Planetary Computer"
|
SENTINEL1_ATTRIBUTION = "Copernicus Sentinel-1 / Microsoft Planetary Computer"
|
||||||
|
|
||||||
# GPSJAM (John Wiseman / ADS-B Exchange): daily H3 hexes of aircraft nav
|
# GPSJAM (John Wiseman / ADS-B Exchange): daily H3 hexes of aircraft nav
|
||||||
|
|
@ -1246,71 +1244,25 @@ def sentinel1_tile_url(signed_cog: str) -> str:
|
||||||
return f"{TITILER_COG_TILES}?{params}"
|
return f"{TITILER_COG_TILES}?{params}"
|
||||||
|
|
||||||
|
|
||||||
def feature_bbox(feat: dict) -> list[float] | None:
|
|
||||||
"""STAC Feature bbox as [minlon, minlat, maxlon, maxlat], or None."""
|
|
||||||
raw = feat.get("bbox") if isinstance(feat, dict) else None
|
|
||||||
if isinstance(raw, (list, tuple)) and len(raw) >= 4:
|
|
||||||
try:
|
|
||||||
return [float(raw[0]), float(raw[1]), float(raw[2]), float(raw[3])]
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
geom = (feat or {}).get("geometry") or {}
|
|
||||||
coords = geom.get("coordinates") if isinstance(geom, dict) else None
|
|
||||||
if not coords:
|
|
||||||
return None
|
|
||||||
lons: list[float] = []
|
|
||||||
lats: list[float] = []
|
|
||||||
|
|
||||||
def _walk(node: Any) -> None:
|
|
||||||
if isinstance(node, (list, tuple)) and node and isinstance(node[0], (int, float)):
|
|
||||||
lons.append(float(node[0]))
|
|
||||||
lats.append(float(node[1]))
|
|
||||||
elif isinstance(node, (list, tuple)):
|
|
||||||
for child in node:
|
|
||||||
_walk(child)
|
|
||||||
|
|
||||||
_walk(coords)
|
|
||||||
if not lons:
|
|
||||||
return None
|
|
||||||
return [min(lons), min(lats), max(lons), max(lats)]
|
|
||||||
|
|
||||||
|
|
||||||
def pick_sentinel_feature(features: list, lon: float, lat: float) -> dict | None:
|
|
||||||
"""Prefer the scene whose bbox covers the viewport center; else first."""
|
|
||||||
if not features:
|
|
||||||
return None
|
|
||||||
for feat in features:
|
|
||||||
bb = feature_bbox(feat)
|
|
||||||
if bb and bb[0] <= lon <= bb[2] and bb[1] <= lat <= bb[3]:
|
|
||||||
return feat
|
|
||||||
return features[0]
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_sentinel1(bbox: str) -> dict | None:
|
async def fetch_sentinel1(bbox: str) -> dict | None:
|
||||||
"""Most recent Sentinel-1 GRD COG for a viewport, signed and TiTiler-ready.
|
"""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
|
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
|
covers the bbox in the last 7 days (caller maps to 404). Queries Planetary
|
||||||
Computer only when called; cached per 2° cell + UTC day so pan/zoom inside
|
Computer only when called; cached per quantized bbox + UTC day.
|
||||||
the same region reuses the COG instead of picking a neighbouring swath.
|
|
||||||
"""
|
"""
|
||||||
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
||||||
qminlon, qminlat, qmaxlon, qmaxlat = quantize_bbox(
|
|
||||||
minlon, minlat, maxlon, maxlat, step=SENTINEL1_CELL,
|
|
||||||
)
|
|
||||||
day = datetime.now(timezone.utc).date().isoformat()
|
day = datetime.now(timezone.utc).date().isoformat()
|
||||||
key = f"sentinel1:{day}:{qminlon:.4f},{qminlat:.4f},{qmaxlon:.4f},{qmaxlat:.4f}"
|
key = f"sentinel1:{day}:{bbox_cell_key(bbox)}"
|
||||||
clon = (qminlon + qmaxlon) / 2.0
|
|
||||||
clat = (qminlat + qmaxlat) / 2.0
|
|
||||||
|
|
||||||
async def _load() -> dict | None:
|
async def _load() -> dict | None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
week_ago = now - timedelta(days=7)
|
week_ago = now - timedelta(days=7)
|
||||||
payload = {
|
payload = {
|
||||||
"collections": ["sentinel-1-grd"],
|
"collections": ["sentinel-1-grd"],
|
||||||
"bbox": [qminlon, qminlat, qmaxlon, qmaxlat],
|
"bbox": [minlon, minlat, maxlon, maxlat],
|
||||||
"datetime": f"{week_ago.isoformat()}/{now.isoformat()}",
|
"datetime": f"{week_ago.isoformat()}/{now.isoformat()}",
|
||||||
"limit": SENTINEL1_STAC_LIMIT,
|
"limit": 1,
|
||||||
"sortby": [{"field": "datetime", "direction": "desc"}],
|
"sortby": [{"field": "datetime", "direction": "desc"}],
|
||||||
}
|
}
|
||||||
data = await _pc_call(_post_json(PC_STAC_SEARCH, json=payload))
|
data = await _pc_call(_post_json(PC_STAC_SEARCH, json=payload))
|
||||||
|
|
@ -1318,9 +1270,7 @@ async def fetch_sentinel1(bbox: str) -> dict | None:
|
||||||
if not features:
|
if not features:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
feat = pick_sentinel_feature(features, clon, clat)
|
feat = features[0]
|
||||||
if not feat:
|
|
||||||
return None
|
|
||||||
assets = feat.get("assets") or {}
|
assets = feat.get("assets") or {}
|
||||||
chosen_href: str | None = None
|
chosen_href: str | None = None
|
||||||
polarization: str | None = None
|
polarization: str | None = None
|
||||||
|
|
@ -1349,7 +1299,6 @@ async def fetch_sentinel1(bbox: str) -> dict | None:
|
||||||
"datetime": props.get("datetime") or feat.get("datetime"),
|
"datetime": props.get("datetime") or feat.get("datetime"),
|
||||||
"polarization": polarization,
|
"polarization": polarization,
|
||||||
"attribution": SENTINEL1_ATTRIBUTION,
|
"attribution": SENTINEL1_ATTRIBUTION,
|
||||||
"bbox": feature_bbox(feat),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await _ttl_get(key, float(SENTINEL1_TTL), _load)
|
return await _ttl_get(key, float(SENTINEL1_TTL), _load)
|
||||||
|
|
|
||||||
33
app/main.py
33
app/main.py
|
|
@ -74,11 +74,6 @@ async def _lifespan(app: FastAPI):
|
||||||
await refresh_cache()
|
await refresh_cache()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
|
||||||
from vesselapi import hydrate_last_known
|
|
||||||
await hydrate_last_known()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
from config import AISSTREAM_IN_APP, VESSELAPI_IN_APP
|
from config import AISSTREAM_IN_APP, VESSELAPI_IN_APP
|
||||||
ais_task = None
|
ais_task = None
|
||||||
vesselapi_task = None
|
vesselapi_task = None
|
||||||
|
|
@ -1268,7 +1263,6 @@ async def list_news_summaries(
|
||||||
|
|
||||||
|
|
||||||
_FLAGGED = ("critical", "high")
|
_FLAGGED = ("critical", "high")
|
||||||
_LESSER = ("medium", "low")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/news/ticker", response_model=list[NewsTickerItemOut])
|
@app.get("/api/news/ticker", response_model=list[NewsTickerItemOut])
|
||||||
|
|
@ -1279,7 +1273,7 @@ async def list_news_ticker(
|
||||||
),
|
),
|
||||||
limit: int = Query(20, ge=1, le=50),
|
limit: int = Query(20, ge=1, le=50),
|
||||||
):
|
):
|
||||||
"""Ticker rows: critical/high first; medium/low if nothing is flagged."""
|
"""Flagged ticker rows (critical/high), newest first. No LLM required."""
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(news_items)
|
select(news_items)
|
||||||
|
|
@ -1293,19 +1287,6 @@ async def list_news_ticker(
|
||||||
stmt = stmt.where(news_items.c.created_at >= since)
|
stmt = stmt.where(news_items.c.created_at >= since)
|
||||||
stmt = stmt.limit(limit)
|
stmt = stmt.limit(limit)
|
||||||
rows = (await session.execute(stmt)).mappings().all()
|
rows = (await session.execute(stmt)).mappings().all()
|
||||||
if not rows:
|
|
||||||
stmt = (
|
|
||||||
select(news_items)
|
|
||||||
.where(
|
|
||||||
news_items.c.kind == "ticker",
|
|
||||||
news_items.c.importance.in_(_LESSER),
|
|
||||||
)
|
|
||||||
.order_by(news_items.c.created_at.desc())
|
|
||||||
)
|
|
||||||
if since:
|
|
||||||
stmt = stmt.where(news_items.c.created_at >= since)
|
|
||||||
stmt = stmt.limit(limit)
|
|
||||||
rows = (await session.execute(stmt)).mappings().all()
|
|
||||||
return [
|
return [
|
||||||
NewsTickerItemOut(
|
NewsTickerItemOut(
|
||||||
id=r["id"], headline=r["headline"], importance=r["importance"],
|
id=r["id"], headline=r["headline"], importance=r["importance"],
|
||||||
|
|
@ -1549,17 +1530,7 @@ async def list_vessels(
|
||||||
from tracks import fetch_positions_at, parse_timestamp
|
from tracks import fetch_positions_at, parse_timestamp
|
||||||
ts = parse_timestamp(timestamp)
|
ts = parse_timestamp(timestamp)
|
||||||
if ts is not None:
|
if ts is not None:
|
||||||
from vesselapi import fetch_vessels_as_of
|
return overlay_json(await fetch_positions_at("vessel", ts, bbox, limit), 5)
|
||||||
if src == "vesselapi":
|
|
||||||
return overlay_json(await fetch_vessels_as_of(ts, bbox, limit), 5)
|
|
||||||
ais = await fetch_positions_at("vessel", ts, bbox, limit)
|
|
||||||
if src == "aisstream":
|
|
||||||
return overlay_json(ais, 5)
|
|
||||||
va = await fetch_vessels_as_of(ts, bbox, limit)
|
|
||||||
by_id = {m["id"]: m for m in ais}
|
|
||||||
for m in va:
|
|
||||||
by_id[m["id"]] = m
|
|
||||||
return overlay_json(list(by_id.values())[:limit], 5)
|
|
||||||
return overlay_json(await fetch_vessels(bbox, limit, src=src), 5)
|
return overlay_json(await fetch_vessels(bbox, limit, src=src), 5)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(422, str(exc)) from exc
|
raise HTTPException(422, str(exc)) from exc
|
||||||
|
|
|
||||||
|
|
@ -358,11 +358,6 @@
|
||||||
.lp-future .lp-name { cursor: not-allowed; }
|
.lp-future .lp-name { cursor: not-allowed; }
|
||||||
.lp-error { color: var(--red); font-size: 0.66rem; }
|
.lp-error { color: var(--red); font-size: 0.66rem; }
|
||||||
.lp-note { font-size: 0.6rem; color: var(--muted); opacity: 0.85; line-height: 1.35; }
|
.lp-note { font-size: 0.6rem; color: var(--muted); opacity: 0.85; line-height: 1.35; }
|
||||||
.gf-list { display: flex; flex-direction: column; gap: 0.22rem; max-height: 8rem; overflow-y: auto; }
|
|
||||||
.gf-item { display: flex; justify-content: space-between; align-items: center; gap: 0.4rem; font-size: 0.64rem; }
|
|
||||||
.gf-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
|
||||||
.gf-del { background: transparent; border: 1px solid var(--magenta); color: var(--magenta); font-family: 'Share Tech Mono', monospace; font-size: 0.58rem; letter-spacing: 0.08em; text-transform: uppercase; padding: 0.12rem 0.35rem; border-radius: 3px; cursor: pointer; }
|
|
||||||
.gf-del:hover { background: rgba(255,46,151,0.16); }
|
|
||||||
|
|
||||||
/* ── Camera / blip popup thumbnails ── */
|
/* ── Camera / blip popup thumbnails ── */
|
||||||
.cam-pop { min-width: 210px; max-width: 260px; }
|
.cam-pop { min-width: 210px; max-width: 260px; }
|
||||||
|
|
@ -588,8 +583,6 @@
|
||||||
.tick-item .b-tag { font-family: 'Share Tech Mono', monospace; font-size: 0.6rem; color: #1c0311; background: var(--magenta); border-radius: 2px; padding: 0.08rem 0.4rem; letter-spacing: 0.1em; }
|
.tick-item .b-tag { font-family: 'Share Tech Mono', monospace; font-size: 0.6rem; color: #1c0311; background: var(--magenta); border-radius: 2px; padding: 0.08rem 0.4rem; letter-spacing: 0.1em; }
|
||||||
.tick-item .b-tag.critical { background: var(--red); }
|
.tick-item .b-tag.critical { background: var(--red); }
|
||||||
.tick-item .b-tag.high { background: var(--amber); }
|
.tick-item .b-tag.high { background: var(--amber); }
|
||||||
.tick-item .b-tag.medium { background: var(--cyan); color: #041018; }
|
|
||||||
.tick-item .b-tag.low { background: var(--muted); color: #041018; }
|
|
||||||
.tick-item.brief:hover { color: var(--magenta); }
|
.tick-item.brief:hover { color: var(--magenta); }
|
||||||
.tick-item.standby { color: var(--muted); opacity: 0.75; }
|
.tick-item.standby { color: var(--muted); opacity: 0.75; }
|
||||||
.tick-item.standby .price { color: var(--muted); }
|
.tick-item.standby .price { color: var(--muted); }
|
||||||
|
|
@ -913,8 +906,7 @@
|
||||||
<div class="lp-row">
|
<div class="lp-row">
|
||||||
<button class="btn" id="gf-draw" type="button" onclick="toggleGeofenceDraw()">Draw geofence</button>
|
<button class="btn" id="gf-draw" type="button" onclick="toggleGeofenceDraw()">Draw geofence</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="gf-list" class="gf-list"></div>
|
<div class="lp-note">No Leaflet.Draw — click vertices, double-click to close. Saved to POST /api/geofences.</div>
|
||||||
<div class="lp-note">Click vertices, double-click to close. Delete a saved fence from the list or its popup.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="lp-layer">
|
<div class="lp-layer">
|
||||||
<div class="lp-row">
|
<div class="lp-row">
|
||||||
|
|
@ -1428,7 +1420,7 @@ function initMarketTicker() {
|
||||||
|
|
||||||
/* ═══════════════ NEWS FEED + TICKER ═══════════════ */
|
/* ═══════════════ NEWS FEED + TICKER ═══════════════ */
|
||||||
let newsInterval = null;
|
let newsInterval = null;
|
||||||
const NEWS_REFRESH_MS = 180000; // 3-min ticker/brief poll
|
const NEWS_REFRESH_MS = 900000; // 15-min cycle
|
||||||
let newsTickerBuilt = false;
|
let newsTickerBuilt = false;
|
||||||
|
|
||||||
function newsEsc(s) {
|
function newsEsc(s) {
|
||||||
|
|
@ -1535,11 +1527,10 @@ function renderNewsTicker(articles, summaries, tickerItems) {
|
||||||
if (ticks.length) {
|
if (ticks.length) {
|
||||||
ticks.forEach(t => {
|
ticks.forEach(t => {
|
||||||
const imp = String(t.importance || '').toLowerCase();
|
const imp = String(t.importance || '').toLowerCase();
|
||||||
const tag = imp === 'critical' ? 'CRITICAL' : imp === 'high' ? 'HIGH' : imp === 'medium' ? 'MED' : 'LOW';
|
const tag = imp === 'critical' ? 'CRITICAL' : 'HIGH';
|
||||||
const tagClass = (imp === 'critical' || imp === 'high' || imp === 'medium' || imp === 'low') ? imp : 'high';
|
|
||||||
const loc = (t.location_name || '').trim();
|
const loc = (t.location_name || '').trim();
|
||||||
const inner =
|
const inner =
|
||||||
`<span class="b-tag ${tagClass}">${tag}</span>` +
|
`<span class="b-tag ${imp === 'critical' ? 'critical' : 'high'}">${tag}</span>` +
|
||||||
`<span>${newsEsc((t.headline || '').trim())}</span>` +
|
`<span>${newsEsc((t.headline || '').trim())}</span>` +
|
||||||
(loc ? `<span class="dom">${newsEsc(loc)}</span>` : '') +
|
(loc ? `<span class="dom">${newsEsc(loc)}</span>` : '') +
|
||||||
`<span class="tt">${newsTimeAgo(t.created_at)}</span><span class="sep">▸</span>`;
|
`<span class="tt">${newsTimeAgo(t.created_at)}</span><span class="sep">▸</span>`;
|
||||||
|
|
@ -1915,7 +1906,6 @@ let extraAttribs = new Set();
|
||||||
let radarLayer = null, radarOn = true, radarOpacity = 0.7, radarMeta = null, radarTimer = null;
|
let radarLayer = null, radarOn = true, radarOpacity = 0.7, radarMeta = null, radarTimer = null;
|
||||||
let thermalLayer = null, thermalOn = false;
|
let thermalLayer = null, thermalOn = false;
|
||||||
let sentinelLayer = null, sentinelOn = false, sentinelOpacity = 0.8;
|
let sentinelLayer = null, sentinelOn = false, sentinelOpacity = 0.8;
|
||||||
let sentinelItemId = null, sentinelBounds = null;
|
|
||||||
let wxAlertsGroup = null, wxAlertsOn = true;
|
let wxAlertsGroup = null, wxAlertsOn = true;
|
||||||
let perimGroup = null, perimOn = true;
|
let perimGroup = null, perimOn = true;
|
||||||
let incidentsGroup = null, incidentsOn = false;
|
let incidentsGroup = null, incidentsOn = false;
|
||||||
|
|
@ -3081,8 +3071,6 @@ async function toggleSentinel1() {
|
||||||
if (sentinelOn) await loadSentinel1();
|
if (sentinelOn) await loadSentinel1();
|
||||||
else {
|
else {
|
||||||
sentinelLayer = dropLayer(sentinelLayer);
|
sentinelLayer = dropLayer(sentinelLayer);
|
||||||
sentinelItemId = null;
|
|
||||||
sentinelBounds = null;
|
|
||||||
const n = document.getElementById('lp-sentinel-count');
|
const n = document.getElementById('lp-sentinel-count');
|
||||||
if (n) n.textContent = '7d';
|
if (n) n.textContent = '7d';
|
||||||
}
|
}
|
||||||
|
|
@ -3092,15 +3080,8 @@ function setSentinelOpacity(v) {
|
||||||
document.getElementById('lp-sentinel-val').textContent = `${Math.round(v)}%`;
|
document.getElementById('lp-sentinel-val').textContent = `${Math.round(v)}%`;
|
||||||
if (sentinelLayer) sentinelLayer.setOpacity(sentinelOpacity);
|
if (sentinelLayer) sentinelLayer.setOpacity(sentinelOpacity);
|
||||||
}
|
}
|
||||||
function sentinelStillCovers() {
|
|
||||||
if (!map || !sentinelBounds || sentinelBounds.length < 4) return false;
|
|
||||||
const c = map.getCenter();
|
|
||||||
return c.lng >= sentinelBounds[0] && c.lng <= sentinelBounds[2]
|
|
||||||
&& c.lat >= sentinelBounds[1] && c.lat <= sentinelBounds[3];
|
|
||||||
}
|
|
||||||
async function loadSentinel1() {
|
async function loadSentinel1() {
|
||||||
if (!map || !sentinelOn) return;
|
if (!map || !sentinelOn) return;
|
||||||
if (sentinelLayer && sentinelStillCovers()) return;
|
|
||||||
const req = ++overlayReq.sar;
|
const req = ++overlayReq.sar;
|
||||||
const countEl = document.getElementById('lp-sentinel-count');
|
const countEl = document.getElementById('lp-sentinel-count');
|
||||||
const hint = document.getElementById('map-hint');
|
const hint = document.getElementById('map-hint');
|
||||||
|
|
@ -3112,8 +3093,6 @@ async function loadSentinel1() {
|
||||||
if (req !== overlayReq.sar) return;
|
if (req !== overlayReq.sar) return;
|
||||||
if (r.status === 404 && body && body.error === 'no_imagery') {
|
if (r.status === 404 && body && body.error === 'no_imagery') {
|
||||||
sentinelLayer = dropLayer(sentinelLayer);
|
sentinelLayer = dropLayer(sentinelLayer);
|
||||||
sentinelItemId = null;
|
|
||||||
sentinelBounds = null;
|
|
||||||
if (hint) hint.textContent = 'No Sentinel-1 imagery for this view in the last 7 days.';
|
if (hint) hint.textContent = 'No Sentinel-1 imagery for this view in the last 7 days.';
|
||||||
if (countEl) countEl.textContent = 'none';
|
if (countEl) countEl.textContent = 'none';
|
||||||
return;
|
return;
|
||||||
|
|
@ -3130,31 +3109,13 @@ async function loadSentinel1() {
|
||||||
if (countEl) countEl.textContent = 'err';
|
if (countEl) countEl.textContent = 'err';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (body.itemId && body.itemId === sentinelItemId && sentinelLayer) {
|
|
||||||
if (Array.isArray(body.bbox) && body.bbox.length >= 4) sentinelBounds = body.bbox;
|
|
||||||
sentinelLayer.setOpacity(sentinelOpacity);
|
|
||||||
if (countEl) countEl.textContent = String(body.polarization || 'SAR').toUpperCase();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const attrib = body.attribution || '';
|
const attrib = body.attribution || '';
|
||||||
const layerOpts = {
|
sentinelLayer = dropLayer(sentinelLayer);
|
||||||
|
sentinelLayer = L.tileLayer(body.tileUrl, {
|
||||||
opacity: sentinelOpacity,
|
opacity: sentinelOpacity,
|
||||||
maxZoom: 18,
|
maxZoom: 18,
|
||||||
attribution: attrib,
|
attribution: attrib,
|
||||||
noWrap: true,
|
}).addTo(map);
|
||||||
};
|
|
||||||
if (Array.isArray(body.bbox) && body.bbox.length >= 4) {
|
|
||||||
sentinelBounds = body.bbox;
|
|
||||||
layerOpts.bounds = L.latLngBounds(
|
|
||||||
[body.bbox[1], body.bbox[0]],
|
|
||||||
[body.bbox[3], body.bbox[2]],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
sentinelBounds = null;
|
|
||||||
}
|
|
||||||
sentinelLayer = dropLayer(sentinelLayer);
|
|
||||||
sentinelLayer = L.tileLayer(body.tileUrl, layerOpts).addTo(map);
|
|
||||||
sentinelItemId = body.itemId || null;
|
|
||||||
if (countEl) countEl.textContent = String(body.polarization || 'SAR').toUpperCase();
|
if (countEl) countEl.textContent = String(body.polarization || 'SAR').toUpperCase();
|
||||||
if (hint) {
|
if (hint) {
|
||||||
const when = body.datetime ? ` · ${body.datetime}` : '';
|
const when = body.datetime ? ` · ${body.datetime}` : '';
|
||||||
|
|
@ -3516,44 +3477,17 @@ async function onGfClose(e) {
|
||||||
toggleGeofenceDraw();
|
toggleGeofenceDraw();
|
||||||
loadGeofences();
|
loadGeofences();
|
||||||
}
|
}
|
||||||
async function deleteGeofence(id) {
|
|
||||||
if (!id) return;
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${API}/api/geofences/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
||||||
if (!r.ok && r.status !== 204) throw new Error('delete failed');
|
|
||||||
} catch (err) { console.error('geofence delete failed', err); }
|
|
||||||
loadGeofences();
|
|
||||||
}
|
|
||||||
async function loadGeofences() {
|
async function loadGeofences() {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
try {
|
try {
|
||||||
const r = await overlayFetch(`${API}/api/geofences`);
|
const r = await overlayFetch(`${API}/api/geofences`);
|
||||||
const rows = await r.json();
|
const rows = await r.json();
|
||||||
const list = Array.isArray(rows) ? rows : [];
|
|
||||||
const box = document.getElementById('gf-list');
|
|
||||||
if (box) {
|
|
||||||
box.innerHTML = list.map(f => {
|
|
||||||
const id = newsEsc(f.id || '');
|
|
||||||
const name = newsEsc(f.name || 'Fence');
|
|
||||||
return `<div class="gf-item"><span title="${name}">${name}</span>` +
|
|
||||||
`<button class="gf-del" type="button" onclick="deleteGeofence('${id}')">Delete</button></div>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
if (gfSaved && map.hasLayer(gfSaved)) map.removeLayer(gfSaved);
|
if (gfSaved && map.hasLayer(gfSaved)) map.removeLayer(gfSaved);
|
||||||
const feats = list.map(f => ({
|
const feats = (Array.isArray(rows) ? rows : []).map(f => ({
|
||||||
type: 'Feature', properties: { name: f.name, id: f.id }, geometry: f.geojson,
|
type: 'Feature', properties: { name: f.name, id: f.id }, geometry: f.geojson,
|
||||||
}));
|
}));
|
||||||
gfSaved = L.geoJSON({ type: 'FeatureCollection', features: feats }, {
|
gfSaved = L.geoJSON({ type: 'FeatureCollection', features: feats }, {
|
||||||
style: { color: '#ff2e97', weight: 2, fillOpacity: 0.08 },
|
style: { color: '#ff2e97', weight: 2, fillOpacity: 0.08 },
|
||||||
onEachFeature: (feat, layer) => {
|
|
||||||
const id = feat.properties && feat.properties.id;
|
|
||||||
const name = newsEsc((feat.properties && feat.properties.name) || 'Fence');
|
|
||||||
if (!id) return;
|
|
||||||
layer.bindPopup(
|
|
||||||
`<div class="gf-pop"><b>${name}</b><br>` +
|
|
||||||
`<button class="gf-del" type="button" onclick="deleteGeofence('${newsEsc(id)}')">Delete</button></div>`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
} catch (e) { if (!isAbort(e)) console.error('geofences load failed', e); }
|
} catch (e) { if (!isAbort(e)) console.error('geofences load failed', e); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,14 +186,15 @@ async def track_range() -> dict:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
row = (await session.execute(text(
|
row = (await session.execute(text(
|
||||||
"""
|
"""
|
||||||
SELECT min(t) AS tmin, max(t) AS tmax FROM (
|
SELECT
|
||||||
SELECT min(bucket) AS t FROM vessel_tracks_1min
|
LEAST(
|
||||||
UNION ALL SELECT max(bucket) FROM vessel_tracks_1min
|
(SELECT min(bucket) FROM vessel_tracks_1min),
|
||||||
UNION ALL SELECT min(bucket) FROM aircraft_tracks_1min
|
(SELECT min(bucket) FROM aircraft_tracks_1min)
|
||||||
UNION ALL SELECT max(bucket) FROM aircraft_tracks_1min
|
) AS tmin,
|
||||||
UNION ALL SELECT min(poll_at) FROM vessels
|
GREATEST(
|
||||||
UNION ALL SELECT max(poll_at) FROM vessels
|
(SELECT max(bucket) FROM vessel_tracks_1min),
|
||||||
) s
|
(SELECT max(bucket) FROM aircraft_tracks_1min)
|
||||||
|
) AS tmax
|
||||||
"""
|
"""
|
||||||
))).mappings().first()
|
))).mappings().first()
|
||||||
if not row or row["tmin"] is None:
|
if not row or row["tmin"] is None:
|
||||||
|
|
|
||||||
228
app/vesselapi.py
228
app/vesselapi.py
|
|
@ -17,7 +17,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import calendar
|
import calendar
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
@ -33,7 +32,7 @@ from config import (
|
||||||
VESSELAPI_MAX_CALLS_PER_DAY,
|
VESSELAPI_MAX_CALLS_PER_DAY,
|
||||||
)
|
)
|
||||||
from database import async_session, engine, metadata
|
from database import async_session, engine, metadata
|
||||||
from live_layers import parse_bbox, to_marker, upsert_vessel, vessel_last_known, vessel_lock
|
from live_layers import to_marker, upsert_vessel
|
||||||
|
|
||||||
logger = logging.getLogger("osint.vesselapi")
|
logger = logging.getLogger("osint.vesselapi")
|
||||||
|
|
||||||
|
|
@ -177,49 +176,6 @@ def transform_vesselapi_payload(payload: dict | None) -> list[dict]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def utc_day_start(now: datetime) -> datetime:
|
|
||||||
"""Floor ``now`` to 00:00:00 UTC."""
|
|
||||||
if now.tzinfo is None:
|
|
||||||
now = now.replace(tzinfo=timezone.utc)
|
|
||||||
now = now.astimezone(timezone.utc)
|
|
||||||
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
|
|
||||||
|
|
||||||
def pick_poll_at(poll_times: list[datetime], as_of: datetime) -> datetime | None:
|
|
||||||
"""Latest poll timestamp at or before ``as_of`` (DVR as-of)."""
|
|
||||||
if as_of.tzinfo is None:
|
|
||||||
as_of = as_of.replace(tzinfo=timezone.utc)
|
|
||||||
else:
|
|
||||||
as_of = as_of.astimezone(timezone.utc)
|
|
||||||
eligible: list[datetime] = []
|
|
||||||
for raw in poll_times:
|
|
||||||
ts = raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc)
|
|
||||||
ts = ts.astimezone(timezone.utc)
|
|
||||||
if ts <= as_of:
|
|
||||||
eligible.append(ts)
|
|
||||||
return max(eligible) if eligible else None
|
|
||||||
|
|
||||||
|
|
||||||
def snapshot_as_of(rows: list[dict], as_of: datetime) -> list[dict]:
|
|
||||||
"""Keep only rows from the latest poll_at ≤ ``as_of``."""
|
|
||||||
chosen = pick_poll_at(
|
|
||||||
[r["poll_at"] for r in rows if r.get("poll_at") is not None],
|
|
||||||
as_of,
|
|
||||||
)
|
|
||||||
if chosen is None:
|
|
||||||
return []
|
|
||||||
out = []
|
|
||||||
for row in rows:
|
|
||||||
ts = row.get("poll_at")
|
|
||||||
if ts is None:
|
|
||||||
continue
|
|
||||||
if ts.tzinfo is None:
|
|
||||||
ts = ts.replace(tzinfo=timezone.utc)
|
|
||||||
if ts.astimezone(timezone.utc) == chosen:
|
|
||||||
out.append(row)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ── Durable daily quota (Postgres, survives restarts) ─────────────────────
|
# ── Durable daily quota (Postgres, survives restarts) ─────────────────────
|
||||||
# Mirrors keystore.api_keys: lazy CREATE TABLE IF NOT EXISTS, no alembic fork.
|
# Mirrors keystore.api_keys: lazy CREATE TABLE IF NOT EXISTS, no alembic fork.
|
||||||
|
|
||||||
|
|
@ -305,183 +261,6 @@ class PgQuotaStore:
|
||||||
return (int(existing) if existing else 0) + 1
|
return (int(existing) if existing else 0) + 1
|
||||||
|
|
||||||
|
|
||||||
# ── Daily VesselAPI snapshots (DVR as-of + survive restarts) ──────────────
|
|
||||||
# Cleared at the UTC day boundary so the table holds today's 5 polls only.
|
|
||||||
|
|
||||||
_CREATE_VESSELS_SQL = text(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS vessels (
|
|
||||||
mmsi TEXT NOT NULL,
|
|
||||||
poll_at TIMESTAMPTZ NOT NULL,
|
|
||||||
lat DOUBLE PRECISION NOT NULL,
|
|
||||||
lon DOUBLE PRECISION NOT NULL,
|
|
||||||
heading DOUBLE PRECISION,
|
|
||||||
speed DOUBLE PRECISION,
|
|
||||||
label TEXT,
|
|
||||||
extra JSONB,
|
|
||||||
PRIMARY KEY (mmsi, poll_at)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
_CREATE_VESSELS_POLL_IDX = text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_vessels_poll_at ON vessels (poll_at DESC)"
|
|
||||||
)
|
|
||||||
_CREATE_VESSELS_BBOX_IDX = text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_vessels_bbox ON vessels (lon, lat)"
|
|
||||||
)
|
|
||||||
|
|
||||||
_vessels_lock = asyncio.Lock()
|
|
||||||
_vessels_ensured = False
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_vessels_table() -> None:
|
|
||||||
global _vessels_ensured
|
|
||||||
if _vessels_ensured:
|
|
||||||
return
|
|
||||||
async with _vessels_lock:
|
|
||||||
if _vessels_ensured:
|
|
||||||
return
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.execute(_CREATE_VESSELS_SQL)
|
|
||||||
await conn.execute(_CREATE_VESSELS_POLL_IDX)
|
|
||||||
await conn.execute(_CREATE_VESSELS_BBOX_IDX)
|
|
||||||
_vessels_ensured = True
|
|
||||||
|
|
||||||
|
|
||||||
def _marker_from_vessel_row(r) -> dict:
|
|
||||||
extra = r.get("extra") or {}
|
|
||||||
if isinstance(extra, str):
|
|
||||||
try:
|
|
||||||
extra = json.loads(extra)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
extra = {}
|
|
||||||
if not isinstance(extra, dict):
|
|
||||||
extra = {}
|
|
||||||
extra.setdefault("src", "vesselapi")
|
|
||||||
poll_at = r.get("poll_at")
|
|
||||||
if poll_at is not None and hasattr(poll_at, "isoformat"):
|
|
||||||
extra["poll_at"] = poll_at.isoformat()
|
|
||||||
marker = to_marker(
|
|
||||||
str(r["id"]), r["lat"], r["lon"],
|
|
||||||
heading=r.get("heading"), speed=r.get("speed"),
|
|
||||||
label=r.get("label") or str(r["id"]),
|
|
||||||
extra=extra,
|
|
||||||
)
|
|
||||||
marker["seen_at"] = extra.get("poll_at") or datetime.now(timezone.utc).isoformat()
|
|
||||||
return marker
|
|
||||||
|
|
||||||
|
|
||||||
async def persist_vessel_snapshot(markers: list[dict], poll_at: datetime) -> None:
|
|
||||||
"""Write one VesselAPI poll into ``vessels`` (today's snapshots)."""
|
|
||||||
await ensure_vessels_table()
|
|
||||||
if not markers:
|
|
||||||
return
|
|
||||||
async with async_session() as session:
|
|
||||||
for m in markers:
|
|
||||||
vid = str(m.get("id") or "")
|
|
||||||
lat, lon = m.get("lat"), m.get("lon")
|
|
||||||
if not vid or lat is None or lon is None:
|
|
||||||
continue
|
|
||||||
extra = dict(m.get("extra") or {})
|
|
||||||
extra.setdefault("src", "vesselapi")
|
|
||||||
await session.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
INSERT INTO vessels
|
|
||||||
(mmsi, poll_at, lat, lon, heading, speed, label, extra)
|
|
||||||
VALUES
|
|
||||||
(:mmsi, :poll_at, :lat, :lon, :heading, :speed, :label,
|
|
||||||
CAST(:extra AS jsonb))
|
|
||||||
ON CONFLICT (mmsi, poll_at) DO UPDATE SET
|
|
||||||
lat = EXCLUDED.lat,
|
|
||||||
lon = EXCLUDED.lon,
|
|
||||||
heading = EXCLUDED.heading,
|
|
||||||
speed = EXCLUDED.speed,
|
|
||||||
label = EXCLUDED.label,
|
|
||||||
extra = EXCLUDED.extra
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"mmsi": vid,
|
|
||||||
"poll_at": poll_at,
|
|
||||||
"lat": float(lat),
|
|
||||||
"lon": float(lon),
|
|
||||||
"heading": m.get("heading"),
|
|
||||||
"speed": m.get("speed"),
|
|
||||||
"label": m.get("label") or vid,
|
|
||||||
"extra": json.dumps(extra),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def purge_old_vessels(before: datetime | None = None) -> None:
|
|
||||||
"""Drop snapshots from before the current UTC day (or ``before``)."""
|
|
||||||
await ensure_vessels_table()
|
|
||||||
cutoff = before or utc_day_start(datetime.now(timezone.utc))
|
|
||||||
async with async_session() as session:
|
|
||||||
await session.execute(
|
|
||||||
text("DELETE FROM vessels WHERE poll_at < :cutoff"),
|
|
||||||
{"cutoff": cutoff},
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_vessels_as_of(
|
|
||||||
ts: datetime,
|
|
||||||
bbox: str | None = None,
|
|
||||||
limit: int = 2000,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Latest VesselAPI poll at or before ``ts`` (DVR as-of, not exact minute)."""
|
|
||||||
try:
|
|
||||||
await ensure_vessels_table()
|
|
||||||
async with async_session() as session:
|
|
||||||
poll = (await session.execute(
|
|
||||||
text("SELECT max(poll_at) FROM vessels WHERE poll_at <= :ts"),
|
|
||||||
{"ts": ts},
|
|
||||||
)).scalar()
|
|
||||||
if poll is None:
|
|
||||||
return []
|
|
||||||
sql = """
|
|
||||||
SELECT mmsi AS id, lat, lon, heading, speed, label, extra, poll_at
|
|
||||||
FROM vessels
|
|
||||||
WHERE poll_at = :poll
|
|
||||||
"""
|
|
||||||
params: dict = {"poll": poll, "limit": limit}
|
|
||||||
if bbox:
|
|
||||||
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
|
||||||
sql += (
|
|
||||||
" AND lon BETWEEN :minlon AND :maxlon"
|
|
||||||
" AND lat BETWEEN :minlat AND :maxlat"
|
|
||||||
)
|
|
||||||
params.update(
|
|
||||||
minlon=minlon, minlat=minlat, maxlon=maxlon, maxlat=maxlat,
|
|
||||||
)
|
|
||||||
sql += " LIMIT :limit"
|
|
||||||
rows = (await session.execute(text(sql), params)).mappings().all()
|
|
||||||
return [_marker_from_vessel_row(r) for r in rows]
|
|
||||||
except Exception:
|
|
||||||
logger.exception("VesselAPI snapshot fetch failed")
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
async def hydrate_last_known() -> int:
|
|
||||||
"""Seed in-memory last-known from today's latest poll (app boot)."""
|
|
||||||
try:
|
|
||||||
rows = await fetch_vessels_as_of(datetime.now(timezone.utc))
|
|
||||||
except Exception:
|
|
||||||
logger.exception("VesselAPI hydrate failed")
|
|
||||||
return 0
|
|
||||||
if not rows:
|
|
||||||
return 0
|
|
||||||
async with vessel_lock:
|
|
||||||
for m in rows:
|
|
||||||
vid = str(m.get("id") or "")
|
|
||||||
if vid:
|
|
||||||
vessel_last_known[vid] = m
|
|
||||||
return len(rows)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Budget / scheduling (pure, unit-testable) ─────────────────────────────
|
# ── Budget / scheduling (pure, unit-testable) ─────────────────────────────
|
||||||
|
|
||||||
def days_left_in_month(now: datetime) -> int:
|
def days_left_in_month(now: datetime) -> int:
|
||||||
|
|
@ -627,11 +406,6 @@ async def poll_once(store, boxes: list[tuple[float, float, float, float]], key:
|
||||||
markers = transform_vesselapi_payload(data)
|
markers = transform_vesselapi_payload(data)
|
||||||
for m in markers:
|
for m in markers:
|
||||||
await upsert_vessel(m)
|
await upsert_vessel(m)
|
||||||
try:
|
|
||||||
await persist_vessel_snapshot(markers, now)
|
|
||||||
await purge_old_vessels(utc_day_start(now))
|
|
||||||
except Exception: # noqa: BLE001 — live overlay must not die on persist
|
|
||||||
logger.exception("VesselAPI snapshot persist failed")
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"VesselAPI poll OK: %d vessels (remaining=%s, calls_today=%d)",
|
"VesselAPI poll OK: %d vessels (remaining=%s, calls_today=%d)",
|
||||||
len(markers), remaining, calls,
|
len(markers), remaining, calls,
|
||||||
|
|
|
||||||
17
docs/news.md
17
docs/news.md
|
|
@ -128,12 +128,11 @@ Key set **unchanged** (no `lat`/`lon` on articles; geo lives on `/api/news/map`)
|
||||||
`?kind=daily_recap` pins the nightly 24h recap. Empty DB → `[]` (no crash).
|
`?kind=daily_recap` pins the nightly 24h recap. Empty DB → `[]` (no crash).
|
||||||
Malformed `kind` → `422`.
|
Malformed `kind` → `422`.
|
||||||
|
|
||||||
### GET /api/news/ticker — HUD headlines
|
### GET /api/news/ticker — flagged HUD headlines
|
||||||
|
|
||||||
Critical/high `news_items` with `kind=ticker` first. If none are flagged,
|
Critical/high `news_items` with `kind=ticker` only. Do **not** reuse
|
||||||
medium/low ticker rows fill the tape so the dock is not blank. Do **not**
|
`GET /api/alerts`. Bottom HUD `#nt-track` scrolls these rows, not a dump of
|
||||||
reuse `GET /api/alerts`. Bottom HUD `#nt-track` scrolls these rows, not a
|
the whole brief.
|
||||||
dump of the whole brief.
|
|
||||||
|
|
||||||
| Query param | Meaning | Default |
|
| Query param | Meaning | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -227,10 +226,10 @@ markdown json fences, then brace-slices:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Persist ticker for critical/high first; if none, persist medium/low so the
|
Persist ticker/map only for `importance` in `critical`/`high`. Map rows also
|
||||||
tape is not empty. Map rows stay critical/high with valid coords; Unknown /
|
need valid coords; Unknown / invented places are dropped. Caps: 12 ticker
|
||||||
invented places are dropped. Caps: 12 ticker (≤140 chars, no markdown), 20
|
(≤140 chars, no markdown), 20 map. Empty ticker is allowed. `summary_en`
|
||||||
map. `summary_en` lands in `article_summaries.summary_text`.
|
lands in `article_summaries.summary_text`.
|
||||||
|
|
||||||
## Configuration (all via env / `.env`)
|
## Configuration (all via env / `.env`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import re
|
||||||
|
|
||||||
_EMPTY = {"summary_en": "", "ticker": [], "map_items": []}
|
_EMPTY = {"summary_en": "", "ticker": [], "map_items": []}
|
||||||
_KEEP = frozenset({"critical", "high"})
|
_KEEP = frozenset({"critical", "high"})
|
||||||
_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
||||||
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
||||||
_FENCE_RE = re.compile(r"```(?:json)?", re.IGNORECASE)
|
_FENCE_RE = re.compile(r"```(?:json)?", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
@ -59,29 +58,19 @@ def _trimmed_headline(row: dict, limit: int) -> str:
|
||||||
|
|
||||||
|
|
||||||
def select_ticker(rows: list) -> list:
|
def select_ticker(rows: list) -> list:
|
||||||
flagged = []
|
out = []
|
||||||
medium = []
|
|
||||||
low = []
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
imp = row.get("importance")
|
if row.get("importance") not in _KEEP:
|
||||||
if imp not in _RANK:
|
|
||||||
continue
|
continue
|
||||||
headline = _trimmed_headline(row, TICKER_HEADLINE_MAX)
|
headline = _trimmed_headline(row, TICKER_HEADLINE_MAX)
|
||||||
if not headline:
|
if not headline:
|
||||||
continue
|
continue
|
||||||
item = dict(row)
|
item = dict(row)
|
||||||
item["headline"] = headline
|
item["headline"] = headline
|
||||||
if imp in _KEEP:
|
out.append(item)
|
||||||
flagged.append(item)
|
if len(out) >= TICKER_CAP:
|
||||||
elif imp == "medium":
|
|
||||||
medium.append(item)
|
|
||||||
else:
|
|
||||||
low.append(item)
|
|
||||||
if len(flagged) >= TICKER_CAP:
|
|
||||||
break
|
break
|
||||||
if flagged:
|
return out
|
||||||
return flagged[:TICKER_CAP]
|
|
||||||
return (medium + low)[:TICKER_CAP]
|
|
||||||
|
|
||||||
|
|
||||||
def select_map(items: list) -> list:
|
def select_map(items: list) -> list:
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,6 @@ import httpx
|
||||||
|
|
||||||
_DEFAULT_UA = "osint-dashboard-news-summarizer"
|
_DEFAULT_UA = "osint-dashboard-news-summarizer"
|
||||||
_DEFAULT_BASE = "https://inference-api.nousresearch.com/v1"
|
_DEFAULT_BASE = "https://inference-api.nousresearch.com/v1"
|
||||||
_JSON_SYSTEM = (
|
|
||||||
"You are an OSINT executive briefer. Reply with a single complete JSON object. "
|
|
||||||
"Never truncate mid-sentence. If you run out of room, drop the lowest-priority item."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def chat(prompt, *, api_key, model, base_url, json_mode=False) -> str:
|
def chat(prompt, *, api_key, model, base_url, json_mode=False) -> str:
|
||||||
|
|
@ -21,37 +17,20 @@ def chat(prompt, *, api_key, model, base_url, json_mode=False) -> str:
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"User-Agent": os.environ.get("OSINT_USER_AGENT") or _DEFAULT_UA,
|
"User-Agent": os.environ.get("OSINT_USER_AGENT") or _DEFAULT_UA,
|
||||||
}
|
}
|
||||||
max_tokens = 8192 if json_mode else 4096
|
|
||||||
timeout = 120.0 if json_mode else 60.0
|
|
||||||
messages = [{"role": "user", "content": prompt}]
|
|
||||||
if json_mode:
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": _JSON_SYSTEM},
|
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
]
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": 4096,
|
||||||
}
|
}
|
||||||
if json_mode:
|
if json_mode:
|
||||||
payload["response_format"] = {"type": "json_object"}
|
payload["response_format"] = {"type": "json_object"}
|
||||||
last_content = ""
|
|
||||||
try:
|
try:
|
||||||
for attempt in range(2):
|
with httpx.Client(timeout=60.0) as client:
|
||||||
with httpx.Client(timeout=timeout) as client:
|
resp = client.post(url, headers=headers, json=payload)
|
||||||
resp = client.post(url, headers=headers, json=payload)
|
if resp.status_code == 401 or resp.status_code >= 500:
|
||||||
if resp.status_code == 401 or resp.status_code >= 500:
|
return ""
|
||||||
return ""
|
data = resp.json()
|
||||||
data = resp.json()
|
return data["choices"][0]["message"]["content"]
|
||||||
choice = (data.get("choices") or [{}])[0]
|
|
||||||
last_content = (choice.get("message") or {}).get("content") or ""
|
|
||||||
finish = choice.get("finish_reason")
|
|
||||||
if finish == "length" and attempt == 0:
|
|
||||||
payload["max_tokens"] = min(int(payload["max_tokens"]) * 2, 16384)
|
|
||||||
continue
|
|
||||||
return last_content
|
|
||||||
return last_content
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ FUTURES_TICKERS = {
|
||||||
MAP_PROMPT_DEFAULT = """\
|
MAP_PROMPT_DEFAULT = """\
|
||||||
You are a precise, factual OSINT news processor. Your ONLY source of information is the articles provided below. Do NOT add external knowledge, assumptions, training data, or invented facts.
|
You are a precise, factual OSINT news processor. Your ONLY source of information is the articles provided below. Do NOT add external knowledge, assumptions, training data, or invented facts.
|
||||||
|
|
||||||
Focus on breaking important news (geopolitical, military/conflict, security, disasters, major political developments). Ignore futures prices, commodity tape, ticker chatter, and routine market moves unless they themselves are the breaking event. If the batch has no critical/high stories, still extract minor incidents and crime reports.
|
Focus on breaking important news (geopolitical, military/conflict, security, disasters, major political developments). Ignore futures prices, commodity tape, ticker chatter, and routine market moves unless they themselves are the breaking event.
|
||||||
|
|
||||||
Write every field in English. Translate if the article is not English.
|
Write every field in English. Translate if the article is not English.
|
||||||
|
|
||||||
|
|
@ -133,11 +133,7 @@ You are writing an English operator HUD brief from the article facts in DATA bel
|
||||||
|
|
||||||
Always write a real summary_en that recaps the most important stories present in DATA. Rank geopolitics, military/conflict, security, disasters, and major political developments first. Ignore futures prices, commodity tape, ticker chatter, and routine market data — do not treat price ticks as news.
|
Always write a real summary_en that recaps the most important stories present in DATA. Rank geopolitics, military/conflict, security, disasters, and major political developments first. Ignore futures prices, commodity tape, ticker chatter, and routine market data — do not treat price ticks as news.
|
||||||
|
|
||||||
Lead with critical and high breaking events. If DATA has no critical/high stories, fill the brief with minor incidents and crime reports rather than writing an empty or unfinished brief. Never truncate mid-sentence; finish every sentence. If you run out of room, drop the lowest-priority item instead of cutting a line short.
|
ticker and map_items may be empty if nothing is critical or high. Never replace summary_en with a canned empty-brief sentence when DATA contains article facts.
|
||||||
|
|
||||||
ticker: prefer critical and high. If nothing is critical or high, fill ticker with medium then low incidents and crime so the HUD is not blank.
|
|
||||||
|
|
||||||
map_items may be empty if no located critical/high event is explicit in the data.
|
|
||||||
|
|
||||||
Demand a single JSON object (no markdown fences) with this exact shape:
|
Demand a single JSON object (no markdown fences) with this exact shape:
|
||||||
|
|
||||||
|
|
@ -147,9 +143,9 @@ Demand a single JSON object (no markdown fences) with this exact shape:
|
||||||
"map_items": [{"headline": "", "importance": "critical", "location_name": "", "lat": 0, "lon": 0, "location_confidence": "city", "category": "military/conflict", "url": ""}]
|
"map_items": [{"headline": "", "importance": "critical", "location_name": "", "lat": 0, "lon": 0, "location_confidence": "city", "category": "military/conflict", "url": ""}]
|
||||||
}
|
}
|
||||||
|
|
||||||
ticker: max 12, ≤140 chars, no markdown. Rank critical > high > medium > low.
|
ticker: only critical and high, max 12, ≤140 chars, no markdown.
|
||||||
map_items: only where a real-world location is explicit in the data. Estimate lat/lon. If location is Unknown or not in the data, omit the item. Never invent a place. Max 20.
|
map_items: only critical and high where a real-world location is explicit in the data. Estimate lat/lon. If location is Unknown or not in the data, omit the item. Never invent a place. Max 20.
|
||||||
summary_en: English markdown executive brief for an operator HUD (4–8 complete bullets or short paragraphs). Cover the actual stories in DATA. Complete — never an unfinished sentence.
|
summary_en: English markdown brief of breaking important news for an operator HUD (bullets or short paragraphs). Cover the actual stories in DATA.
|
||||||
|
|
||||||
DATA:
|
DATA:
|
||||||
{final_input}
|
{final_input}
|
||||||
|
|
@ -160,9 +156,7 @@ You are writing a daily recap of the last 24 hours of news for an OSINT operator
|
||||||
|
|
||||||
Always write a real summary_en daily recap of the most important stories in DATA. Rank geopolitics, military/conflict, security, disasters, and major political developments first. Ignore futures prices, commodity tape, ticker chatter, and routine market data — do not treat price ticks as news.
|
Always write a real summary_en daily recap of the most important stories in DATA. Rank geopolitics, military/conflict, security, disasters, and major political developments first. Ignore futures prices, commodity tape, ticker chatter, and routine market data — do not treat price ticks as news.
|
||||||
|
|
||||||
Lead with critical and high breaking events. If DATA has no critical/high stories, fill the recap with minor incidents and crime reports rather than writing an empty or unfinished recap. Never truncate mid-sentence; finish every sentence.
|
ticker and map_items may be empty if nothing is critical or high. Never replace summary_en with a canned empty-brief sentence when DATA contains article facts.
|
||||||
|
|
||||||
ticker: prefer critical and high. If nothing is critical or high, fill ticker with medium then low incidents and crime so the HUD is not blank.
|
|
||||||
|
|
||||||
Demand a single JSON object (no markdown fences) with this exact shape:
|
Demand a single JSON object (no markdown fences) with this exact shape:
|
||||||
|
|
||||||
|
|
@ -172,9 +166,9 @@ Demand a single JSON object (no markdown fences) with this exact shape:
|
||||||
"map_items": [{"headline": "", "importance": "critical", "location_name": "", "lat": 0, "lon": 0, "location_confidence": "city", "category": "military/conflict", "url": ""}]
|
"map_items": [{"headline": "", "importance": "critical", "location_name": "", "lat": 0, "lon": 0, "location_confidence": "city", "category": "military/conflict", "url": ""}]
|
||||||
}
|
}
|
||||||
|
|
||||||
ticker: max 12, ≤140 chars, no markdown. Rank critical > high > medium > low.
|
ticker: only critical and high, max 12, ≤140 chars, no markdown.
|
||||||
map_items: only where a real-world location is explicit in the data. Estimate lat/lon. If location is Unknown or not in the data, omit the item. Never invent a place. Max 20.
|
map_items: only critical and high where a real-world location is explicit in the data. Estimate lat/lon. If location is Unknown or not in the data, omit the item. Never invent a place. Max 20.
|
||||||
summary_en: English markdown daily recap of the last 24 hours. Complete sentences. Cover the actual stories in DATA.
|
summary_en: English markdown daily recap of the last 24 hours of breaking important news. Cover the actual stories in DATA.
|
||||||
|
|
||||||
DATA:
|
DATA:
|
||||||
{final_input}
|
{final_input}
|
||||||
|
|
|
||||||
|
|
@ -32,16 +32,6 @@ def test_select_ticker_keeps_critical_high_caps_12():
|
||||||
assert len(out) == 12
|
assert len(out) == 12
|
||||||
assert all(r["importance"] in ("critical", "high") for r in out)
|
assert all(r["importance"] in ("critical", "high") for r in out)
|
||||||
|
|
||||||
|
|
||||||
def test_select_ticker_falls_back_to_medium_low_when_nothing_flagged():
|
|
||||||
rows = [
|
|
||||||
{"headline": "shop theft", "importance": "low"},
|
|
||||||
{"headline": "highway crash", "importance": "medium"},
|
|
||||||
{"headline": "none", "importance": "none"},
|
|
||||||
]
|
|
||||||
out = select_ticker(rows)
|
|
||||||
assert [r["headline"] for r in out] == ["highway crash", "shop theft"]
|
|
||||||
|
|
||||||
def test_select_map_requires_valid_coords_and_flag():
|
def test_select_map_requires_valid_coords_and_flag():
|
||||||
items = [
|
items = [
|
||||||
{"headline": "A", "importance": "critical", "lat": 50.45, "lon": 30.52, "location_name": "Kyiv"},
|
{"headline": "A", "importance": "critical", "lat": 50.45, "lon": 30.52, "location_name": "Kyiv"},
|
||||||
|
|
|
||||||
|
|
@ -67,33 +67,6 @@ def test_json_mode_sets_response_format(monkeypatch):
|
||||||
captured = _install_fake(monkeypatch, lambda *a: _ok_response("{}"))
|
captured = _install_fake(monkeypatch, lambda *a: _ok_response("{}"))
|
||||||
chat("p", api_key="k", model="m", base_url=BASE, json_mode=True)
|
chat("p", api_key="k", model="m", base_url=BASE, json_mode=True)
|
||||||
assert captured["json"]["response_format"] == {"type": "json_object"}
|
assert captured["json"]["response_format"] == {"type": "json_object"}
|
||||||
assert captured["json"]["max_tokens"] >= 8192
|
|
||||||
roles = [m["role"] for m in captured["json"]["messages"]]
|
|
||||||
assert "system" in roles
|
|
||||||
assert "user" in roles
|
|
||||||
|
|
||||||
|
|
||||||
def test_retries_once_when_finish_reason_is_length(monkeypatch):
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
def post_impl(*a):
|
|
||||||
calls["n"] += 1
|
|
||||||
if calls["n"] == 1:
|
|
||||||
resp = MagicMock()
|
|
||||||
resp.status_code = 200
|
|
||||||
resp.json.return_value = {
|
|
||||||
"choices": [{
|
|
||||||
"message": {"content": "{\"summary_en\": \"cut off"},
|
|
||||||
"finish_reason": "length",
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
return _ok_response('{"summary_en": "complete brief."}')
|
|
||||||
|
|
||||||
_install_fake(monkeypatch, post_impl)
|
|
||||||
out = chat("p", api_key="k", model="m", base_url=BASE, json_mode=True)
|
|
||||||
assert calls["n"] == 2
|
|
||||||
assert "complete brief" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_401_returns_empty_string(monkeypatch):
|
def test_401_returns_empty_string(monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,6 @@ def test_summary_prompt_focuses_on_breaking_news_not_futures():
|
||||||
assert "commodity" in p or "market" in p
|
assert "commodity" in p or "market" in p
|
||||||
|
|
||||||
|
|
||||||
def test_summary_prompt_covers_critical_then_incidents():
|
|
||||||
p = SUMMARY_PROMPT_DEFAULT.lower()
|
|
||||||
assert "critical" in p
|
|
||||||
assert "crime" in p
|
|
||||||
assert "incident" in p
|
|
||||||
assert "complete" in p or "truncat" in p or "unfinished" in p or "mid-sentence" in p
|
|
||||||
|
|
||||||
|
|
||||||
def test_summary_prompt_does_not_bail_out_with_canned_empty_brief():
|
def test_summary_prompt_does_not_bail_out_with_canned_empty_brief():
|
||||||
p = SUMMARY_PROMPT_DEFAULT
|
p = SUMMARY_PROMPT_DEFAULT
|
||||||
assert "AND STOP" not in p
|
assert "AND STOP" not in p
|
||||||
|
|
|
||||||
|
|
@ -230,21 +230,6 @@ def test_api_news_ticker_returns_only_flagged(clean_news):
|
||||||
assert item["url"] == "https://example.com/ticker"
|
assert item["url"] == "https://example.com/ticker"
|
||||||
|
|
||||||
|
|
||||||
@requires_db
|
|
||||||
def test_api_news_ticker_falls_back_to_lesser_when_nothing_flagged(clean_news):
|
|
||||||
sid = _seed_summary("quiet brief", "2026-08-27T18:05:00+00:00", "Hermes-4.3-36B")
|
|
||||||
_seed_news_item(
|
|
||||||
sid, "ticker", "Shop theft downtown", "low",
|
|
||||||
location_name="Raleigh", url="https://example.com/theft",
|
|
||||||
)
|
|
||||||
resp = _get("/api/news/ticker")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.json()
|
|
||||||
assert len(body) == 1
|
|
||||||
assert body[0]["headline"] == "Shop theft downtown"
|
|
||||||
assert body[0]["importance"] == "low"
|
|
||||||
|
|
||||||
|
|
||||||
@requires_db
|
@requires_db
|
||||||
def test_api_news_map_returns_only_flagged_with_coords(clean_news):
|
def test_api_news_map_returns_only_flagged_with_coords(clean_news):
|
||||||
_seed_flagged_items()
|
_seed_flagged_items()
|
||||||
|
|
|
||||||
|
|
@ -72,14 +72,6 @@ def test_chokepoint_skips_aisstream_subscribe_outside_conus():
|
||||||
assert "minlat,minlon,maxlat,maxlon" in HTML.split("function chokepointLeafletBounds")[1][:400]
|
assert "minlat,minlon,maxlat,maxlon" in HTML.split("function chokepointLeafletBounds")[1][:400]
|
||||||
|
|
||||||
|
|
||||||
def test_news_ticker_polls_more_often_than_summarizer_cycle():
|
|
||||||
assert "NEWS_REFRESH_MS" in HTML
|
|
||||||
# Summarizer is 15 min; ticker should refresh on a shorter cadence so
|
|
||||||
# lesser-news fills show up without waiting for the next brief.
|
|
||||||
line = [ln for ln in HTML.splitlines() if "NEWS_REFRESH_MS" in ln][0]
|
|
||||||
assert "900000" not in line
|
|
||||||
|
|
||||||
|
|
||||||
def test_phone_chokepoints_use_select_not_buttons():
|
def test_phone_chokepoints_use_select_not_buttons():
|
||||||
mobile = HTML.split("@media (max-width: 820px)")[1].split("@media (prefers-reduced-motion")[0]
|
mobile = HTML.split("@media (max-width: 820px)")[1].split("@media (prefers-reduced-motion")[0]
|
||||||
assert "#chokepoint-select { display: block; }" in mobile
|
assert "#chokepoint-select { display: block; }" in mobile
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
"""Geofence layer panel: draw + delete (DELETE /api/geofences/{id})."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
HTML = (ROOT / "app/static/index.html").read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def test_geofence_panel_has_list_and_delete_hook():
|
|
||||||
assert 'id="gf-draw"' in HTML
|
|
||||||
assert 'id="gf-list"' in HTML
|
|
||||||
assert "function deleteGeofence" in HTML
|
|
||||||
assert "method: 'DELETE'" in HTML or 'method: "DELETE"' in HTML
|
|
||||||
assert "/api/geofences/" in HTML
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_geofences_renders_delete_controls():
|
|
||||||
js = HTML.split("async function loadGeofences", 1)[1].split(
|
|
||||||
"async function loadFireAircraftHits", 1
|
|
||||||
)[0]
|
|
||||||
assert "gf-list" in js
|
|
||||||
assert "deleteGeofence" in js
|
|
||||||
assert "onEachFeature" in js
|
|
||||||
assert "bindPopup" in js
|
|
||||||
|
|
@ -7,7 +7,6 @@ from live_layers import (
|
||||||
filter_points_bbox,
|
filter_points_bbox,
|
||||||
parse_bbox,
|
parse_bbox,
|
||||||
quantize_bbox,
|
quantize_bbox,
|
||||||
pick_sentinel_feature,
|
|
||||||
rainviewer_tile_url,
|
rainviewer_tile_url,
|
||||||
sign_cog_url,
|
sign_cog_url,
|
||||||
sentinel1_tile_url,
|
sentinel1_tile_url,
|
||||||
|
|
@ -735,9 +734,8 @@ def test_fetch_sentinel1_vv_signed_tile_url(monkeypatch):
|
||||||
post_url, post_json = calls[0][1], calls[0][2]
|
post_url, post_json = calls[0][1], calls[0][2]
|
||||||
assert post_url.endswith("/api/stac/v1/search")
|
assert post_url.endswith("/api/stac/v1/search")
|
||||||
assert post_json["collections"] == ["sentinel-1-grd"]
|
assert post_json["collections"] == ["sentinel-1-grd"]
|
||||||
assert post_json["limit"] >= 1
|
assert post_json["limit"] == 1
|
||||||
assert post_json["sortby"][0]["direction"] == "desc"
|
assert post_json["sortby"][0]["direction"] == "desc"
|
||||||
assert "bbox" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_sentinel1_uses_hh_when_vv_missing(monkeypatch):
|
def test_fetch_sentinel1_uses_hh_when_vv_missing(monkeypatch):
|
||||||
|
|
@ -785,22 +783,3 @@ def test_fetch_sentinel1_none_when_no_vv_or_hh(monkeypatch):
|
||||||
_cache.clear()
|
_cache.clear()
|
||||||
|
|
||||||
assert asyncio.run(fetch_sentinel1("-80,35,-79,36")) is None
|
assert asyncio.run(fetch_sentinel1("-80,35,-79,36")) is None
|
||||||
|
|
||||||
|
|
||||||
def test_pick_sentinel_feature_prefers_scene_covering_center():
|
|
||||||
features = [
|
|
||||||
{"id": "far", "bbox": [10.0, 10.0, 12.0, 12.0]},
|
|
||||||
{"id": "cover", "bbox": [-80.5, 34.5, -78.5, 36.5]},
|
|
||||||
{"id": "also-far", "bbox": [-10.0, 0.0, -8.0, 2.0]},
|
|
||||||
]
|
|
||||||
picked = pick_sentinel_feature(features, -79.5, 35.5)
|
|
||||||
assert picked["id"] == "cover"
|
|
||||||
|
|
||||||
|
|
||||||
def test_pick_sentinel_feature_falls_back_to_first_when_none_cover():
|
|
||||||
features = [
|
|
||||||
{"id": "a", "bbox": [10.0, 10.0, 12.0, 12.0]},
|
|
||||||
{"id": "b", "bbox": [20.0, 20.0, 22.0, 22.0]},
|
|
||||||
]
|
|
||||||
assert pick_sentinel_feature(features, -79.5, 35.5)["id"] == "a"
|
|
||||||
assert pick_sentinel_feature([], -79.5, 35.5) is None
|
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,3 @@ def test_sentinel1_not_fetched_on_init_unless_on():
|
||||||
assert "loadSentinel1()" not in init
|
assert "loadSentinel1()" not in init
|
||||||
refresh = HTML.split("function refreshLiveOverlays", 1)[1].split("function addExtraAttrib", 1)[0]
|
refresh = HTML.split("function refreshLiveOverlays", 1)[1].split("function addExtraAttrib", 1)[0]
|
||||||
assert "if (sentinelOn) loadSentinel1();" in refresh
|
assert "if (sentinelOn) loadSentinel1();" in refresh
|
||||||
|
|
||||||
|
|
||||||
def test_sentinel1_reuses_covering_scene_and_clips_tiles():
|
|
||||||
js = HTML.split("async function loadSentinel1", 1)[1].split("function loadThermal", 1)[0]
|
|
||||||
assert "sentinelStillCovers" in HTML
|
|
||||||
assert "itemId" in js
|
|
||||||
assert "L.latLngBounds" in js
|
|
||||||
assert "sentinelBounds" in HTML
|
|
||||||
|
|
|
||||||
|
|
@ -234,8 +234,6 @@ def _patch_side_effects(monkeypatch):
|
||||||
|
|
||||||
monkeypatch.setattr("tracks.record_position", _noop)
|
monkeypatch.setattr("tracks.record_position", _noop)
|
||||||
monkeypatch.setattr("geofence.record_and_notify", _noop)
|
monkeypatch.setattr("geofence.record_and_notify", _noop)
|
||||||
monkeypatch.setattr(vesselapi, "persist_vessel_snapshot", _noop)
|
|
||||||
monkeypatch.setattr(vesselapi, "purge_old_vessels", _noop)
|
|
||||||
|
|
||||||
|
|
||||||
def test_poll_once_lands_markers_in_vessel_last_known(monkeypatch):
|
def test_poll_once_lands_markers_in_vessel_last_known(monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
"""VesselAPI daily snapshot store — as-of DVR + UTC-day purge (no DB)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from vesselapi import pick_poll_at, snapshot_as_of, utc_day_start
|
|
||||||
|
|
||||||
|
|
||||||
def test_utc_day_start_floors_to_midnight_utc():
|
|
||||||
now = datetime(2026, 8, 29, 15, 30, 12, tzinfo=timezone.utc)
|
|
||||||
assert utc_day_start(now) == datetime(2026, 8, 29, 0, 0, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pick_poll_at_returns_latest_snapshot_at_or_before_as_of():
|
|
||||||
t1 = datetime(2026, 8, 29, 0, 0, tzinfo=timezone.utc)
|
|
||||||
t2 = datetime(2026, 8, 29, 4, 48, tzinfo=timezone.utc)
|
|
||||||
t3 = datetime(2026, 8, 29, 9, 36, tzinfo=timezone.utc)
|
|
||||||
as_of = datetime(2026, 8, 29, 6, 0, tzinfo=timezone.utc)
|
|
||||||
assert pick_poll_at([t1, t2, t3], as_of) == t2
|
|
||||||
assert pick_poll_at([t1, t2, t3], t1) == t1
|
|
||||||
assert pick_poll_at([t1, t2, t3], datetime(2026, 8, 28, 23, tzinfo=timezone.utc)) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_snapshot_as_of_returns_the_matching_poll_only():
|
|
||||||
t1 = datetime(2026, 8, 29, 0, 0, tzinfo=timezone.utc)
|
|
||||||
t2 = datetime(2026, 8, 29, 4, 48, tzinfo=timezone.utc)
|
|
||||||
rows = [
|
|
||||||
{"id": "1", "poll_at": t1, "lat": 26.5, "lon": 56.0},
|
|
||||||
{"id": "2", "poll_at": t1, "lat": 26.6, "lon": 56.1},
|
|
||||||
{"id": "1", "poll_at": t2, "lat": 26.7, "lon": 56.2},
|
|
||||||
]
|
|
||||||
out = snapshot_as_of(rows, datetime(2026, 8, 29, 6, 0, tzinfo=timezone.utc))
|
|
||||||
assert {r["id"] for r in out} == {"1"}
|
|
||||||
assert out[0]["lat"] == 26.7
|
|
||||||
assert all(r["poll_at"] == t2 for r in out)
|
|
||||||
Loading…
Add table
Reference in a new issue