diff --git a/pipeline_runner/main.py b/pipeline_runner/main.py index a78905d..dafbed0 100644 --- a/pipeline_runner/main.py +++ b/pipeline_runner/main.py @@ -1,7 +1,9 @@ import asyncio +import csv import gzip import json import time +from datetime import datetime, timezone from pathlib import Path from typing import AsyncGenerator @@ -138,3 +140,76 @@ async def get_tracks(sortie_id: str): raise HTTPException(404, "tracks.geojson not found") with open(p) as f: return JSONResponse(json.load(f)) + + +def _ts_nav(ts_str: str) -> float: + for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"): + try: + return datetime.strptime(ts_str.strip(), fmt).replace(tzinfo=timezone.utc).timestamp() + except ValueError: + continue + return 0.0 + + +def _read_usv_track(nav_log_path: Path, max_pts: int = 2000) -> list[dict]: + """Read navigation_log.csv → [{t_ms, lat, lon, heading, source}] downsampled.""" + pts: list[dict] = [] + lat_map: dict[float, float] = {} + lon_map: dict[float, float] = {} + heading_map: dict[float, float] = {} + with open(nav_log_path, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + next(reader, None) # skip header + for row in reader: + if len(row) < 3: + continue + ts_str, field, val = row[0], row[1], row[2] + if field not in ("Lat", "Lon", "Heading"): + continue + t = _ts_nav(ts_str) + try: + v = float(val) + except ValueError: + continue + if field == "Lat": + lat_map[t] = v + elif field == "Lon": + lon_map[t] = v + else: + heading_map[t] = v + # Join on lat timestamps (master) + source = nav_log_path.parent.name + for t, lat in sorted(lat_map.items()): + lon = lon_map.get(t) + if lon is None: + # nearest lon within 1s + near = min(lon_map.keys(), key=lambda x: abs(x - t), default=None) + if near is None or abs(near - t) > 1.0: + continue + lon = lon_map[near] + pts.append({ + "t_ms": int(t * 1000), + "lat": lat, + "lon": lon, + "heading": heading_map.get(t), + "source": source, + }) + # Simple stride downsampling + if len(pts) > max_pts: + step = len(pts) // max_pts + pts = pts[::step] + return pts + + +@app.get("/sorties/{sortie_id:path}/usv_track") +async def get_usv_track(sortie_id: str): + """Return USV GPS track [{t_ms, lat, lon, heading, source}] for map polylines.""" + raw_dir = OUTPUT_DIR / sortie_id / "raw" + nav_logs = list(raw_dir.rglob("*_navigation_log.csv")) if raw_dir.exists() else [] + if not nav_logs: + raise HTTPException(404, "No navigation_log.csv found — run pipeline first") + pts: list[dict] = [] + for nav_log in nav_logs: + pts.extend(await asyncio.to_thread(_read_usv_track, nav_log)) + pts.sort(key=lambda p: p["t_ms"]) + return JSONResponse(pts) diff --git a/viewer/index.html b/viewer/index.html index 57f8fd3..a1dbaa8 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -1280,8 +1280,42 @@ function setupSyncedZoom(chartIds) { async function loadSortieData(sortieId) { const prog = document.getElementById('sync-progress'); try { - prog.textContent = 'Chargement USV…'; - const usvResp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/usv`); + // Reset state for sortie mode (independent from datebar) + clearMapLayers(); + allPoints = []; + usblPoints = []; + + // Phase 1: USV GPS track → polylines on map immediately (<3s target) + prog.textContent = 'Chargement track USV…'; + const trackResp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/usv_track`, + { signal: AbortSignal.timeout(15000) }); + if (trackResp.ok) { + const trackPts = await trackResp.json(); + allPoints.push(...trackPts); + allPoints.sort((a, b) => a.t_ms - b.t_ms); + if (allPoints.length > 0) { + const times = allPoints.map(p => p.t_ms); + tMin = Math.min(...times); + tMax = Math.max(...times); + tNow = tMax; + // Fit map bounds + const lats = allPoints.map(p => p.lat).filter(Boolean); + const lons = allPoints.map(p => p.lon).filter(Boolean); + if (lats.length) { + map.fitBounds([ + [Math.min(...lats), Math.min(...lons)], + [Math.max(...lats), Math.max(...lons)], + ], { padding: [40, 40] }); + } + showNoDataOverlay(false); + applyTrailAndCursor(); // PERF FIX: polylines visible NOW, before charts + prog.textContent = `Track USV ${allPoints.length} pts — chargement charts…`; + } + } + + // Phase 2: series + AUV (charts populate progressively) + const usvResp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/usv`, + { signal: AbortSignal.timeout(20000) }); if (usvResp.ok) { const usvData = await usvResp.json(); switchTab('usv'); // BUG1d FIX: switch BEFORE renderUSV so Plotly renders on visible divs @@ -1291,7 +1325,7 @@ async function loadSortieData(sortieId) { prog.textContent = 'Chargement AUV…'; await loadAuvTabs(sortieId); prog.textContent = `${sortieId} chargé`; - // POLYLINE FIX: ensure map trail is drawn if allPoints already loaded from datebar + // Re-apply after AUV usbl_track loaded (adds AUV trail if available) if (allPoints.length > 0) applyTrailAndCursor(); } catch(e) { prog.textContent = `Erreur: ${e.message}`;