Compare commits

..

24 Commits

Author SHA1 Message Date
Poulpe
f66bb52fec perf(pipeline): in-memory cache for usv_track (warm: 20ms vs 1.8s cold) 2026-04-28 15:06:09 +00:00
Poulpe
dd6f0cf435 perf(viewer): polylines USV <3s — Phase 1 usv_track endpoint + immediate applyTrailAndCursor 2026-04-28 15:03:53 +00:00
Flag
f5788a01f4 fix(viewer): polylines + batched T3 load
- loadSortieData: call applyTrailAndCursor() after sortie load so map
  polylines appear when allPoints already populated from datebar
- loadDiveData: split into Phase1 (track only, batched by 4) + Phase2
  (series + sub, batched by 4) — map draws as soon as tracks load
- loadShipSession split into fetchShipTrack + fetchShipSeries helpers
- T3: filter ship sessions by date (sess.start.slice(0,10) === filterDate)
- Pass date param from loadDate to loadDiveData

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:54:16 +00:00
f5debc8afc fix(viewer): tNow=tMax so trail shows data on init + synced zoom across all Plotly charts
Bug 1: cursor slider started at tMin (no data in 60s window at start of data).
Fix: start slider at tMax so trail window covers the last trailMs of actual data.

Feature 2 (Alexandre Larribau): setupSyncedZoom() propagates xaxis.range
to all sibling charts on plotly_relayout, with _syncing guard vs infinite loop.
Applied to global charts (depth/PWM/USBL), USV tab, and AUV tab independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 06:15:26 +00:00
Poulpe
63270beeff fix(viewer): BUG1d switchTab before renderUSV to fix Plotly on hidden divs
Plotly.react fails silently on display:none divs.
Switch tab to USV first, then render so divs are visible when Plotly mounts.
2026-04-27 23:07:20 +00:00
Poulpe
4164f32694 fix(viewer): normalize mission IDs hash vs underscore + auto-switch USV tab
BUG3b: data-dates API returns hash prefix, missions API returns underscore.
Normalize both sides before comparing to properly filter mission fetches.
BUG1c: auto-switch to USV tab after sortie data loaded so charts are visible.
2026-04-27 23:05:57 +00:00
Poulpe
b45a5368ee fix(viewer): 4 bugs — Plotly resize, no-data overlay, fetch overkill, tab resize
Bug1: populatePlotlyCharts now forces requestAnimationFrame Plotly.Plots.resize
      after react() so charts render correctly even after hidden/late init
Bug1b: switchTab(charts) triggers resize on all 4 chart divs
Bug2: loadSortieData calls showNoDataOverlay(false) when USV data loaded
Bug3: loadDate filters missions via availableDates cache before fetching
      all dives — avoids O(n*missions) requests when date entry known
2026-04-27 23:03:21 +00:00
Poulpe
7a5d442fbd feat(viewer): auto-detect API URLs + 9 UX improvements (no-data overlay, tabs, labels, layer toggles map) 2026-04-27 22:36:08 +00:00
b21e306a86 fix(pipeline): scan_sorties sync (was async, broke to_thread → coroutine instead of list → 500) 2026-04-27 22:13:02 +00:00
Poulpe
a79f63e59e fix(viewer): map visible — wrap USV/AUV panels in graphs-section, minmax(200px) map row 2026-04-27 22:08:49 +00:00
Poulpe
31b5a221b8 fix(viewer+backend): cache sorties TTL 10min + /sorties/local + viewer non-bloquant 2026-04-27 22:02:21 +00:00
Poulpe
b962997008 feat(viewer): merge USV M1+M2 into one graph, add history window control
- USV panel: usv-m1 + usv-m2 → single usv-motors multi-trace (2 lines)
- AUV panel: auv-motors already combined (6 lines), no change
- Add window dropdown (10s/30s/60s/5min/15min/ALL) next to trail control
- updateCursor applies xaxis.range=[T-win, T] on all panel graphs
- Plots before: ~9 USV + ~9 AUV = ~18; after: ~7 USV + ~8 AUV = ~15
2026-04-27 21:54:19 +00:00
Poulpe
6978b36650 merge feat/flag-local: pipeline-runner + viewer USV/AUV panels 2026-04-27 21:34:07 +00:00
70608de221 fix(pipeline): docker-compose env GDRIVE_REMOTE=cosma + rclone.conf mount + NAS sorties bind 2026-04-27 21:33:56 +00:00
Flagabat
0a0dac7fda feat: viewer — sortie selector + USV/AUV panels HTML + CSS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 23:28:05 +02:00
Flagabat
62091a09b7 feat: pipeline-runner — FastAPI endpoints + SSE 2026-04-27 23:24:44 +02:00
Flagabat
3e1da53cc7 feat: pipeline-runner — runner rclone + orchestration 2026-04-27 23:23:13 +02:00
Flagabat
24f9394c75 feat: pipeline-runner — processor LTTB + signal extraction 2026-04-27 23:22:41 +02:00
Flagabat
c9dca1d071 feat: pipeline-runner — config 2026-04-27 23:21:44 +02:00
Flagabat
682050ef14 feat: pipeline-runner — scaffold docker + deps 2026-04-27 23:18:21 +02:00
Flagabat
4aec9d6295 fix: extract_mcap_signals — flatten signals output, unify time keys
- Merge new signals (pitch/roll/yaw/altitude/battery_v/obstacle_dist) at
  top level of output dict via **unpacking, not nested under 'signals' key
- Replace t_ms -> t in all new signal appends to match depth/state format
- Fix all_t computation to use unified 't' key across old and new signals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 23:17:02 +02:00
Flagabat
af2bb6581f feat: extract_mcap_signals — pitch/roll/yaw, altitude, obstacle, battery
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 23:15:09 +02:00
Flagabat
bd3a2359d9 docs: plan implémentation pipeline GDrive → replay USV/AUV (12 tâches) 2026-04-27 22:29:41 +02:00
Flagabat
02e357b874 docs: spec pipeline GDrive → replay USV/AUV dans viewer 2026-04-27 22:23:18 +02:00
12 changed files with 2701 additions and 118 deletions

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
version: "3.9"
services:
pipeline-runner:
build:
context: .
dockerfile: pipeline_runner/Dockerfile
container_name: cosma-pipeline-runner
ports:
- "8767:8767"
volumes:
- /mnt/nas-cosma/cosma/sorties:/data/sorties
- /home/floppyrj45/.config/rclone/rclone.conf:/root/.config/rclone/rclone.conf:ro
environment:
- GDRIVE_REMOTE=cosma:06-Operations/06 - Sorties
- OUTPUT_DIR=/data/sorties
- TOOLS_DIR=/app/tools
restart: unless-stopped

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
# Design : GDrive Pipeline Replay
**Date :** 2026-04-27
**Repo :** cosma-nav-tools
**Branche :** feat/flag-local
---
## Objectif
Bouton dans le viewer 8765 qui déclenche un sync GDrive → pipeline Python → affichage replay des signaux USV et AUV sur la même page, synchronisés avec le slider 24h existant.
---
## Architecture globale
```
GDrive (G:)
└─ 06-Operations/06-Sorties/#XX-lieu/YYYYMMDD-lieu/raw_data/
├─ SHIP/*.csv (USV nav + USBL + full)
└─ SUB/*.mcap (AUV ROS2) + bin/
↓ rclone sync (déclenché par bouton viewer)
.83 /data/sorties/#XX/raw/
├─ SHIP/
└─ SUB/
↓ pipeline Python (scripts existants cosma-nav-tools/tools/)
.83 /data/sorties/#XX/processed/
├─ usv.json.gz ← signaux USV downsamplés LTTB
├─ auv_AUV010.json.gz ← signaux AUV010 downsamplés LTTB
├─ auv_AUVxxx.json.gz ← un fichier par AUV détecté
└─ tracks.geojson ← USV + AUV tracks (Leaflet)
↓ servi par
port 8767 pipeline-runner FastAPI (cosma-nav-tools/pipeline_runner/)
port 8765 viewer/index.html ← page unique, fetch 8766 + 8767
```
---
## Section 1 : Pipeline-runner (port 8767)
### Structure
```
cosma-nav-tools/
pipeline_runner/
main.py ← FastAPI app + endpoints
runner.py ← logique rclone + orchestration scripts
config.py ← chemins GDrive, output dir, rclone remote
docker-compose.yml ← service pipeline-runner port 8767
data/sorties/ ← gitignored
```
### Endpoints
| Méthode | Route | Description |
|---------|-------|-------------|
| `GET` | `/sorties` | Liste sorties détectées sur GDrive (scan dossier) |
| `POST` | `/run/{sortie_id}` | Lance rclone pull + pipeline complet |
| `GET` | `/events/{sortie_id}` | SSE stream progress (étape + %) |
| `GET` | `/sorties/{id}/usv` | Données USV processed (JSON gzip) |
| `GET` | `/sorties/{id}/auvs` | Liste AUVs disponibles pour cette sortie |
| `GET` | `/sorties/{id}/auv/{auv_id}` | Données AUV processed (JSON gzip) |
| `GET` | `/sorties/{id}/tracks` | GeoJSON tracks USV + AUV |
### Flow interne POST /run/{sortie_id}
```
1. rclone sync GDrive/#id → /data/sorties/#id/raw/ SSE: "sync" 0→50%
2. parse_usv_nav.py + extract_usv_pwm.py SSE: "usv_parse" 50→65%
3. parse_kogger_usbl.py + merge_nav_usbl.py SSE: "usbl_merge" 65→80%
4. extract_mcap_signals.py (par AUV détecté) SSE: "auv_parse" 80→90%
5. usbl_to_json.py SSE: "usbl_json" 90→95%
6. downsample LTTB + écriture .json.gz SSE: "write" 95→100%
```
---
## Section 2 : Format données
### Signaux (usv.json.gz, auv_*.json.gz)
```json
{
"meta": {
"sortie": "#71-golrest",
"date": "2026-04-16",
"vehicle": "USV001",
"t_start": 1713268477,
"t_end": 1713282877
},
"signals": {
"yaw": [{ "t": 1713268477, "v": 142.3 }, ...],
"heading": [...],
"gps_status": [{ "t": ..., "v": "3D_FIX" }, ...],
"battery_v": [...],
"usbl_dist": [...],
"usbl_angle": [...],
"motor_1": [...],
"motor_2": [...],
"auv_status": [{ "t": ..., "v": "MISSION" }, ...]
}
}
```
- `t` = epoch UNIX secondes
- Max **4000 points par signal** via LTTB (Plotly optimal)
- Valeurs discrètes (status, mode) = strings → step-plot Plotly
- Endpoint optionnel `/range?from=&to=` pour zoom fin sur brutes
### Tracks
- **GeoJSON** FeatureCollection : une Feature LineString par véhicule
- Poids estimé : ~300KB gzip pour 4h à 1Hz
---
## Section 3 : Viewer 8765 — extensions
### Layout (page unique)
```
datebar ← inchangé
header ← + dropdown sortie + [Sync & Process] + progress bar SSE
map Leaflet ← inchangé
slider 24h ← shared X axis, curseur vertical sur tous les graphs
─────────────────────────────────────────────
USV PANEL
[Yaw] [Heading]
[GPS status] [Battery voltage]
[USBL dist] [USBL angle]
[Motor 1] [Motor 2]
[AUV status: disarm/mission/goto — step-plot]
─────────────────────────────────────────────
AUV PANEL ← tabs [AUV010] [AUV011] … si multi-AUV
[Pitch/Roll/Yaw — 3 traces] [Depth]
[Altitude] [Obstacle dist]
[USBL dist] [USBL angle]
[Battery voltage] [Arm/Disarm/Mode — step-plot]
[Motors ×6 PWM — 1 graph multi-trace M1…M6]
```
### Synchronisation slider
- Slider 24h existant → événement → `updateCursor(t)` sur tous les graphs Plotly via `Plotly.relayout` shapes
- Données chargées une fois en mémoire au click de sortie, pas de re-fetch au scrub
### Bouton Sync & Process
- `POST /run/{sortie_id}` → ouvre SSE `/events/{sortie_id}`
- Progress bar inline dans le header (ex: `rclone 34% → usv_parse…`)
- À 100% → fetch automatique USV + AUV + tracks → render graphs
---
## Section 4 : Déploiement .83
```yaml
# docker-compose.yml (ajout)
pipeline-runner:
build: ./pipeline_runner
ports:
- "8767:8767"
volumes:
- /data/sorties:/data/sorties
- /mnt/gdrive:/mnt/gdrive # rclone mount ou rclone exec
environment:
- GDRIVE_REMOTE=gdrive:Cosma - Internal/06-Operations/06 - Sorties
- OUTPUT_DIR=/data/sorties
```
---
## Hors scope (v1)
- Authentification / accès multi-user
- HDF5 archivage (peut venir en v2)
- Replay animé temps-réel (curseur qui avance automatiquement)
- Tests unitaires pipeline

View File

@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y rclone && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY tools/ ./tools/
COPY vendor/ ./vendor/
COPY pipeline_runner/ ./pipeline_runner/
CMD ["uvicorn", "pipeline_runner.main:app", "--host", "0.0.0.0", "--port", "8767"]

View File

View File

@@ -0,0 +1,7 @@
import os
from pathlib import Path
GDRIVE_REMOTE = os.getenv("GDRIVE_REMOTE", "gdrive:Cosma - Internal/06-Operations/06 - Sorties")
OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/data/sorties"))
TOOLS_DIR = Path(os.getenv("TOOLS_DIR", Path(__file__).parent.parent / "tools"))
LTTB_MAX_PTS = 4000

221
pipeline_runner/main.py Normal file
View File

@@ -0,0 +1,221 @@
import asyncio
import csv
import gzip
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import AsyncGenerator
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from .config import OUTPUT_DIR
from .runner import run_pipeline, scan_sorties, scan_sorties_local
app = FastAPI(title="COSMA Pipeline Runner")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# Active pipeline jobs: sortie_id → asyncio.Queue
_jobs: dict[str, asyncio.Queue] = {}
# Cache sorties avec TTL 10min
_sorties_cache: list | None = None
_sorties_cache_ts: float = 0.0
_SORTIES_TTL = 600.0 # 10 minutes
_sorties_refresh_lock: asyncio.Lock | None = None
def _get_lock() -> asyncio.Lock:
global _sorties_refresh_lock
if _sorties_refresh_lock is None:
_sorties_refresh_lock = asyncio.Lock()
return _sorties_refresh_lock
async def _refresh_sorties_cache() -> None:
"""Refresh cache in background (holds lock to avoid parallel rclone calls)."""
global _sorties_cache, _sorties_cache_ts
lock = _get_lock()
async with lock:
# Double-check after acquiring lock
if time.monotonic() - _sorties_cache_ts < _SORTIES_TTL:
return
result = await asyncio.to_thread(scan_sorties)
_sorties_cache = result
_sorties_cache_ts = time.monotonic()
@app.get("/sorties")
async def list_sorties():
global _sorties_cache, _sorties_cache_ts
now = time.monotonic()
if _sorties_cache is None:
# Premier appel: bloquant (cache vide)
await _refresh_sorties_cache()
elif now - _sorties_cache_ts >= _SORTIES_TTL:
# Cache périmé: retourne le cache, refresh en arrière-plan
asyncio.create_task(_refresh_sorties_cache())
return _sorties_cache or []
@app.get("/sorties/local")
async def list_sorties_local():
"""Scan /data/sorties local (NAS, instantané) sans rclone."""
sorties = await asyncio.to_thread(scan_sorties_local)
return sorties
@app.post("/run/{sortie_id:path}")
async def run_sortie(sortie_id: str):
if sortie_id in _jobs:
return {"status": "already_running"}
queue: asyncio.Queue = asyncio.Queue()
_jobs[sortie_id] = queue
asyncio.create_task(_run_and_cleanup(sortie_id, queue))
return {"status": "started"}
async def _run_and_cleanup(sortie_id: str, queue: asyncio.Queue):
try:
await run_pipeline(sortie_id, queue)
finally:
await asyncio.sleep(30)
_jobs.pop(sortie_id, None)
@app.get("/events/{sortie_id:path}")
async def sse_events(sortie_id: str):
if sortie_id not in _jobs:
raise HTTPException(404, "No active job for this sortie")
async def generate() -> AsyncGenerator[str, None]:
queue = _jobs[sortie_id]
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=60)
yield f"data: {json.dumps(event)}\n\n"
if event.get("step") in ("error", "write") and event.get("pct") in (0, 100):
break
except asyncio.TimeoutError:
yield "data: {\"step\":\"ping\"}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
def _read_gz(path: Path) -> dict:
with gzip.open(path) as f:
return json.loads(f.read())
@app.get("/sorties/{sortie_id:path}/usv")
async def get_usv(sortie_id: str):
p = OUTPUT_DIR / sortie_id / "processed" / "usv.json.gz"
if not p.exists():
raise HTTPException(404, "USV data not found — run pipeline first")
return JSONResponse(_read_gz(p))
@app.get("/sorties/{sortie_id:path}/auvs")
async def list_auvs(sortie_id: str):
proc = OUTPUT_DIR / sortie_id / "processed"
auvs = [p.name.removesuffix(".json.gz").removeprefix("auv_")
for p in proc.glob("auv_*.json.gz")]
return sorted(auvs)
@app.get("/sorties/{sortie_id:path}/auv/{auv_id}")
async def get_auv(sortie_id: str, auv_id: str):
p = OUTPUT_DIR / sortie_id / "processed" / f"auv_{auv_id}.json.gz"
if not p.exists():
raise HTTPException(404, f"AUV {auv_id} data not found")
return JSONResponse(_read_gz(p))
@app.get("/sorties/{sortie_id:path}/tracks")
async def get_tracks(sortie_id: str):
p = OUTPUT_DIR / sortie_id / "processed" / "tracks.geojson"
if not p.exists():
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
_track_cache: dict[str, list[dict]] = {}
@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."""
if sortie_id in _track_cache:
return JSONResponse(_track_cache[sortie_id])
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"])
_track_cache[sortie_id] = pts
return JSONResponse(pts)

View File

@@ -0,0 +1,174 @@
import csv
import gzip
import json
import math
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import numpy as np
from .config import LTTB_MAX_PTS
def _lttb(points: list[dict], max_pts: int) -> list[dict]:
"""Largest Triangle Three Buckets downsampling."""
n = len(points)
if n <= max_pts:
return points
ts = np.array([p["t"] for p in points], dtype=float)
vs = np.array([p["v"] if isinstance(p["v"], (int, float)) else 0.0 for p in points], dtype=float)
bucket_size = (n - 2) / (max_pts - 2)
out = [points[0]]
a = 0
for i in range(max_pts - 2):
avg_start = int((i + 1) * bucket_size) + 1
avg_end = min(int((i + 2) * bucket_size) + 1, n)
avg_t = np.mean(ts[avg_start:avg_end])
avg_v = np.mean(vs[avg_start:avg_end])
rng_start = int(i * bucket_size) + 1
rng_end = min(int((i + 1) * bucket_size) + 1, n)
max_area = -1.0
best = rng_start
at, av = ts[a], vs[a]
for j in range(rng_start, rng_end):
area = abs((at - avg_t) * (vs[j] - av) - (ts[j] - at) * (avg_v - av)) * 0.5
if area > max_area:
max_area = area
best = j
out.append(points[best])
a = best
out.append(points[-1])
return out
def _ts_to_epoch(ts_str: str) -> float:
"""Parse 'YYYY-MM-DD HH:MM:SS.ffffff' → epoch seconds."""
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
try:
dt = datetime.strptime(ts_str.strip(), fmt).replace(tzinfo=timezone.utc)
return dt.timestamp()
except ValueError:
continue
return 0.0
def _read_nav_log(nav_log_path: Path) -> dict[str, list[dict]]:
"""Read long-format navigation_log.csv → dict of signal arrays."""
signals: dict[str, list] = {}
wanted = {"Yaw", "Heading", "Roll", "Pitch", "BattVoltage", "gps_fix",
"Armed", "Mode", "M1", "M2"}
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 wanted:
continue
t = _ts_to_epoch(ts_str)
try:
v: Any = float(val)
except ValueError:
v = val.strip()
signals.setdefault(field, []).append({"t": t, "v": v})
return signals
def _read_usbl_csv(usbl_csv_path: Path) -> dict[str, list[dict]]:
"""Read combined_usbl.csv → Dist and Azimuth signal arrays."""
dist_pts, az_pts = [], []
with open(usbl_csv_path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
try:
t = _ts_to_epoch(row["Timestamp"])
d = float(row["Dist"]) if row["Dist"] else None
az = float(row["Azimuth"]) if row["Azimuth"] else None
except (KeyError, ValueError):
continue
if d is not None and not math.isnan(d):
dist_pts.append({"t": t, "v": d})
if az is not None and not math.isnan(az):
az_pts.append({"t": t, "v": az})
return {"usbl_dist": dist_pts, "usbl_angle": az_pts}
def _read_mcap_signals(mcap_json_path: Path) -> dict[str, list[dict]]:
"""Read mcap_signals.json → depth, motors M1-M6, state signals."""
with open(mcap_json_path) as f:
data = json.load(f)
signals: dict[str, list[dict]] = {}
def _unpack(key: str, series: list):
pts = []
for item in series:
t = item.get("t_ms", item.get("t", 0))
if isinstance(t, (int, float)) and t > 1e9:
t = t / 1000.0 # ms → s
pts.append({"t": float(t), "v": item.get("v", item.get("value", 0))})
signals[key] = pts
if "depth" in data:
_unpack("depth", data["depth"])
if "pwm_auv" in data:
for m_key, series in data["pwm_auv"].items():
_unpack(m_key.lower(), series)
if "state" in data:
_unpack("arm_status", data["state"])
# New signals added by extended extract_mcap_signals.py
for key in ("pitch", "roll", "yaw", "altitude", "battery_v", "obstacle_dist"):
if key in data:
_unpack(key, data[key])
return signals
def write_usv_json(
nav_log_path: Path,
usbl_csv_path: Path | None,
output_path: Path,
sortie_id: str,
date: str,
) -> None:
signals = _read_nav_log(nav_log_path)
if usbl_csv_path and usbl_csv_path.exists():
signals.update(_read_usbl_csv(usbl_csv_path))
t_all = [p["t"] for pts in signals.values() for p in pts if pts]
meta = {
"sortie": sortie_id,
"date": date,
"vehicle": "USV",
"t_start": min(t_all) if t_all else 0,
"t_end": max(t_all) if t_all else 0,
}
downsampled = {k: _lttb(v, LTTB_MAX_PTS) for k, v in signals.items()}
payload = json.dumps({"meta": meta, "signals": downsampled}).encode()
output_path.parent.mkdir(parents=True, exist_ok=True)
with gzip.open(output_path, "wb") as f:
f.write(payload)
def write_auv_json(
mcap_json_path: Path,
output_path: Path,
auv_id: str,
sortie_id: str,
date: str,
) -> None:
signals = _read_mcap_signals(mcap_json_path)
t_all = [p["t"] for pts in signals.values() for p in pts if pts]
meta = {
"sortie": sortie_id,
"date": date,
"vehicle": auv_id,
"t_start": min(t_all) if t_all else 0,
"t_end": max(t_all) if t_all else 0,
}
downsampled = {k: _lttb(v, LTTB_MAX_PTS) for k, v in signals.items()}
payload = json.dumps({"meta": meta, "signals": downsampled}).encode()
output_path.parent.mkdir(parents=True, exist_ok=True)
with gzip.open(output_path, "wb") as f:
f.write(payload)

149
pipeline_runner/runner.py Normal file
View File

@@ -0,0 +1,149 @@
import asyncio
import re
import shutil
import subprocess
from pathlib import Path
from .config import GDRIVE_REMOTE, OUTPUT_DIR, TOOLS_DIR
from .processor import write_auv_json, write_usv_json
async def _emit(queue: asyncio.Queue, step: str, pct: int, msg: str = ""):
await queue.put({"step": step, "pct": pct, "msg": msg})
def _find_nav_log(ship_dir: Path) -> Path | None:
for p in ship_dir.glob("*_navigation_log.csv"):
return p
return None
def _find_usbl_csv(ship_dir: Path) -> Path | None:
for p in ship_dir.glob("*_usbl.csv"):
return p
return None
def _detect_auvs(sub_dir: Path) -> list[str]:
"""Return AUV IDs from SUB/ subfolders (e.g. '20260416_125418_AUV010''AUV010')."""
auvs = []
for d in sub_dir.iterdir():
if d.is_dir():
m = re.search(r"(AUV\d+)", d.name, re.IGNORECASE)
if m:
auvs.append(m.group(1).upper())
return sorted(set(auvs))
def _detect_session_dir(sub_dir: Path, auv_id: str) -> Path | None:
for d in sub_dir.iterdir():
if d.is_dir() and auv_id.upper() in d.name.upper():
return d
return None
def _run_script(script_name: str, args: list[str]) -> subprocess.CompletedProcess:
cmd = ["python3", str(TOOLS_DIR / script_name)] + args
return subprocess.run(cmd, capture_output=True, text=True)
async def run_pipeline(sortie_id: str, queue: asyncio.Queue) -> None:
"""Full pipeline: rclone sync → parse → process → write JSON.gz"""
raw_dir = OUTPUT_DIR / sortie_id / "raw"
proc_dir = OUTPUT_DIR / sortie_id / "processed"
proc_dir.mkdir(parents=True, exist_ok=True)
# Step 1: rclone sync
await _emit(queue, "sync", 0, "rclone sync en cours…")
gdrive_path = f'{GDRIVE_REMOTE}/{sortie_id}'
result = subprocess.run(
["rclone", "sync", gdrive_path, str(raw_dir), "--progress"],
capture_output=True, text=True
)
if result.returncode != 0:
await _emit(queue, "error", 0, f"rclone: {result.stderr[:200]}")
return
await _emit(queue, "sync", 50, "rclone terminé")
# Detect SHIP/SUB dirs inside raw/
ship_dir = next(raw_dir.rglob("logs/SHIP"), None)
sub_dir = next(raw_dir.rglob("logs/SUB"), None)
if not ship_dir or not ship_dir.exists():
await _emit(queue, "error", 50, "Dossier SHIP introuvable après sync")
return
# Step 2: USV parsing
await _emit(queue, "usv_parse", 55, "Parsing logs USV…")
nav_log = _find_nav_log(ship_dir)
usbl_csv_raw = _find_usbl_csv(ship_dir)
usbl_parsed_path = proc_dir / "combined_usbl.csv"
if usbl_csv_raw:
_run_script("parse_kogger_usbl.py", [str(usbl_csv_raw), "-o", str(usbl_parsed_path)])
date_str = sortie_id.split("/")[-1][:10] if "/" in sortie_id else sortie_id[:10]
if nav_log:
write_usv_json(
nav_log_path=nav_log,
usbl_csv_path=usbl_parsed_path if usbl_parsed_path.exists() else None,
output_path=proc_dir / "usv.json.gz",
sortie_id=sortie_id,
date=date_str,
)
await _emit(queue, "usv_parse", 70, "USV OK")
# Step 3: AUV parsing
if sub_dir and sub_dir.exists():
auv_ids = _detect_auvs(sub_dir)
total = len(auv_ids) or 1
for i, auv_id in enumerate(auv_ids):
await _emit(queue, "auv_parse", 70 + int(20 * i / total), f"AUV {auv_id}")
session_dir = _detect_session_dir(sub_dir, auv_id)
if not session_dir:
continue
mcap_out = proc_dir / f"mcap_{auv_id}.json"
_run_script("extract_mcap_signals.py", ["--session-dir", str(session_dir), "--max-pts", "0"])
# extract_mcap_signals writes to tools/../output/mcap_signals.json (hardcoded)
default_out = TOOLS_DIR.parent / "output" / "mcap_signals.json"
if not default_out.exists():
default_out = session_dir / "mcap_signals.json"
if default_out.exists():
shutil.copy(default_out, mcap_out)
if mcap_out.exists():
write_auv_json(
mcap_json_path=mcap_out,
output_path=proc_dir / f"auv_{auv_id}.json.gz",
auv_id=auv_id,
sortie_id=sortie_id,
date=date_str,
)
await _emit(queue, "write", 100, "Pipeline terminé")
def scan_sorties() -> list[dict]:
"""List available sorties on GDrive via rclone lsd (lent ~30s)."""
result = subprocess.run(
["rclone", "lsd", GDRIVE_REMOTE],
capture_output=True, text=True
)
sorties = []
for line in result.stdout.splitlines():
parts = line.strip().split(None, 4)
if len(parts) == 5:
name = parts[4].strip()
processed = (OUTPUT_DIR / name / "processed" / "usv.json.gz").exists()
sorties.append({"id": name, "processed": processed})
return sorties
def scan_sorties_local() -> list[dict]:
"""List sorties already synced locally in OUTPUT_DIR (instantané, pas rclone)."""
if not OUTPUT_DIR.exists():
return []
sorties = []
for d in sorted(OUTPUT_DIR.iterdir()):
if d.is_dir():
processed = (d / "processed" / "usv.json.gz").exists()
sorties.append({"id": d.name, "processed": processed})
return sorties

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
aiofiles==24.1.0
numpy==2.1.1

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Extract AUV signals from MCAP files: depth, PWM, state.""" """Extract AUV signals from MCAP files: depth, PWM, state."""
import argparse, glob, json, os, sys import argparse, glob, json, math, os, sys
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@@ -26,7 +26,16 @@ def main():
depth_raw = [] depth_raw = []
pwm_raw = [] pwm_raw = []
state_raw = [] state_raw = []
TOPICS = ['/mavros/imu/static_pressure', '/mavros/rc/out', '/mavros/state'] signals = {}
TOPICS = [
'/mavros/imu/static_pressure',
'/mavros/rc/out',
'/mavros/state',
'/mavros/imu/data',
'/mavros/altitude',
'/mavros/battery',
'/mavros/distance_sensor/hrlv_ez4_pub',
]
for mcap_file in mcap_files: for mcap_file in mcap_files:
try: try:
@@ -53,6 +62,40 @@ def main():
state_raw.append({'t': t_ms, 'mode': str(ros_msg.mode), 'armed': bool(ros_msg.armed)}) state_raw.append({'t': t_ms, 'mode': str(ros_msg.mode), 'armed': bool(ros_msg.armed)})
except Exception: except Exception:
pass pass
elif topic == '/mavros/imu/data':
try:
q = ros_msg.orientation
sinr = 2*(q.w*q.x + q.y*q.z)
cosr = 1 - 2*(q.x*q.x + q.y*q.y)
roll = math.degrees(math.atan2(sinr, cosr))
sinp = 2*(q.w*q.y - q.z*q.x)
pitch = math.degrees(math.asin(max(-1, min(1, sinp))))
siny = 2*(q.w*q.z + q.x*q.y)
cosy = 1 - 2*(q.y*q.y + q.z*q.z)
yaw = math.degrees(math.atan2(siny, cosy))
signals.setdefault('pitch', []).append({'t': t_ms, 'v': pitch})
signals.setdefault('roll', []).append({'t': t_ms, 'v': roll})
signals.setdefault('yaw', []).append({'t': t_ms, 'v': yaw})
except Exception:
pass
elif topic == '/mavros/altitude':
try:
signals.setdefault('altitude', []).append(
{'t': t_ms, 'v': ros_msg.relative})
except Exception:
pass
elif topic == '/mavros/battery':
try:
signals.setdefault('battery_v', []).append(
{'t': t_ms, 'v': ros_msg.voltage})
except Exception:
pass
elif topic == '/mavros/distance_sensor/hrlv_ez4_pub':
try:
signals.setdefault('obstacle_dist', []).append(
{'t': t_ms, 'v': ros_msg.range})
except Exception:
pass
except Exception as e: except Exception as e:
print(f" Skip {os.path.basename(mcap_file)}: {e}") print(f" Skip {os.path.basename(mcap_file)}: {e}")
@@ -69,7 +112,8 @@ def main():
pwm_samples = sample(pwm_raw, args.max_pts) pwm_samples = sample(pwm_raw, args.max_pts)
state = state_raw # events, keep all state = state_raw # events, keep all
all_t = [p['t'] for p in depth_raw + pwm_raw + state_raw] signals_flat = [pt for pts in signals.values() for pt in pts]
all_t = [p['t'] for p in depth_raw + pwm_raw + state_raw + signals_flat]
t_min = min(all_t) if all_t else 0 t_min = min(all_t) if all_t else 0
t_max = max(all_t) if all_t else 0 t_max = max(all_t) if all_t else 0
@@ -94,6 +138,7 @@ def main():
'depth': depth, 'depth': depth,
'pwm_auv': {'channels': channels, 'samples': pwm_samples}, 'pwm_auv': {'channels': channels, 'samples': pwm_samples},
'state': state, 'state': state,
**{k: sample(v, args.max_pts) for k, v in signals.items()},
} }
outdir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'output') outdir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'output')

View File

@@ -12,8 +12,9 @@
height: 100vh; overflow: hidden; height: 100vh; overflow: hidden;
font-family: monospace; background: #1a1a2e; color: #e0e0e0; font-family: monospace; background: #1a1a2e; color: #e0e0e0;
display: grid; display: grid;
grid-template-rows: 36px 40px 1fr 54px 1fr; grid-template-rows: 36px 40px minmax(200px, 1fr) 54px 28px 1fr;
} }
.hidden { display: none !important; }
/* Row 0: datebar */ /* Row 0: datebar */
#datebar { #datebar {
background: #0d0d20; border-bottom: 1px solid #0f3460; background: #0d0d20; border-bottom: 1px solid #0f3460;
@@ -45,7 +46,7 @@
} }
#title { font-size: 13px; font-weight: bold; color: #e94560; white-space: nowrap; } #title { font-size: 13px; font-weight: bold; color: #e94560; white-space: nowrap; }
#stats { font-size: 11px; color: #a0c4ff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #stats { font-size: 11px; color: #a0c4ff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#layer-toggles { display: flex; gap: 6px; align-items: center; flex-shrink: 0; } #layer-toggles { display: none; }
.layer-btn { .layer-btn {
font-family: monospace; font-size: 10px; padding: 2px 7px; cursor: pointer; font-family: monospace; font-size: 10px; padding: 2px 7px; cursor: pointer;
border-radius: 2px; border: 1px solid; background: transparent; border-radius: 2px; border: 1px solid; background: transparent;
@@ -60,6 +61,20 @@
/* Row 2: map */ /* Row 2: map */
#map { position: relative; min-height: 0; } #map { position: relative; min-height: 0; }
/* No-data overlay */
#no-data-overlay {
position: absolute; inset: 0; z-index: 2000;
background: rgba(10,10,26,0.82);
display: flex; align-items: center; justify-content: center;
pointer-events: none;
}
#no-data-overlay.hidden { display: none; }
#no-data-overlay .nodata-msg {
font-size: 18px; color: #555; font-family: monospace; text-align: center;
border: 1px solid #1a1a3e; padding: 20px 32px; background: #0a0a1a;
pointer-events: none;
}
/* USBL panel over map */ /* USBL panel over map */
#usbl-panel { #usbl-panel {
position: absolute; top: 10px; left: 50px; z-index: 1000; position: absolute; top: 10px; left: 50px; z-index: 1000;
@@ -73,6 +88,11 @@
display: inline-block; background: #ff8800; color: #1a1a2e; display: inline-block; background: #ff8800; color: #1a1a2e;
font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 2px; margin-top: 4px; font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 2px; margin-top: 4px;
} }
/* layer toggles bottom-right on map */
#map-layer-toggles {
position: absolute; bottom: 10px; right: 10px; z-index: 1000;
display: flex; flex-direction: column; gap: 4px; align-items: flex-end;
}
/* legend over map */ /* legend over map */
#legend { #legend {
position: absolute; bottom: 10px; left: 10px; z-index: 1000; position: absolute; bottom: 10px; left: 10px; z-index: 1000;
@@ -115,15 +135,23 @@
} }
#btn-viewall:hover, #btn-play:hover { background: #00b4d8; color: #1a1a2e; } #btn-viewall:hover, #btn-play:hover { background: #00b4d8; color: #1a1a2e; }
/* Row 4: 2×2 charts grid */ /* Row 4: scroll container with 2x2 charts + USV/AUV panels */
#graphs-section { #graphs-section {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background: #0a0a1a;
}
#charts-4grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr; grid-template-rows: 1fr 1fr;
gap: 3px; gap: 3px;
background: #0a0a1a;
padding: 3px; padding: 3px;
min-height: 0; overflow: hidden; flex: 1;
min-height: 180px;
} }
.chart-wrap { .chart-wrap {
background: #12122a; background: #12122a;
@@ -138,6 +166,21 @@
.chart-wrap .plotly-wrap { flex: 1; min-height: 0; position: relative; } .chart-wrap .plotly-wrap { flex: 1; min-height: 0; position: relative; }
.chart-wrap .plotly-wrap > div { width: 100% !important; height: 100% !important; } .chart-wrap .plotly-wrap > div { width: 100% !important; height: 100% !important; }
/* Tab navigation */
#panels-tabs {
background: #0d0d20; border-top: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
display: flex; gap: 0; flex-shrink: 0;
}
.panel-tab {
font-family: monospace; font-size: 11px; padding: 6px 16px; cursor: pointer;
border: none; border-right: 1px solid #0f3460; background: transparent;
color: #666;
}
.panel-tab.active { background: #16213e; color: #e0e0e0; }
.panel-tab:hover:not(.active) { background: #0f3460; color: #a0c4ff; }
.panel-section { display: none; }
.panel-section.active { display: block; }
/* Pipeline overlay */ /* Pipeline overlay */
#pipeline-overlay { #pipeline-overlay {
display: none; display: none;
@@ -169,6 +212,39 @@
border-radius: 2px; flex-shrink: 0; border-radius: 2px; flex-shrink: 0;
} }
#btn-pipeline:hover { background: #a855f7; color: #1a1a2e; } #btn-pipeline:hover { background: #a855f7; color: #1a1a2e; }
#sortie-select {
background: #0f3460; border: 1px solid #e94560; color: #e0e0e0;
font-family: monospace; font-size: 11px; padding: 2px 6px; border-radius: 2px;
cursor: pointer; max-width: 160px;
}
#btn-sync {
background: #0f3460; border: 1px solid #e94560; color: #e94560;
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px;
}
#btn-sync:hover { background: #e94560; color: #1a1a2e; }
#btn-sync:disabled { opacity: 0.4; cursor: not-allowed; }
#sync-progress {
font-size: 10px; color: #06d6a0; flex: 1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.panel-header {
background: #0d0d20; border-top: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
padding: 4px 14px; font-size: 11px; font-weight: bold; color: #e94560;
display: flex; align-items: center; gap: 8px;
}
.auv-tab {
font-family: monospace; font-size: 10px; padding: 2px 8px; cursor: pointer;
border: 1px solid #0f3460; background: transparent; color: #a0c4ff; border-radius: 2px;
}
.auv-tab.active { background: #0f3460; color: #e0e0e0; }
.graphs-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px;
padding: 4px 8px; background: #12122a;
}
.graph-cell { height: 130px; background: #1a1a2e; }
.graph-cell.wide { grid-column: span 2; height: 130px; }
</style> </style>
</head> </head>
<body> <body>
@@ -177,8 +253,8 @@
<div id="datebar"> <div id="datebar">
<input type="date" id="date-picker"> <input type="date" id="date-picker">
<button id="btn-today" onclick="datePickerToday()">Aujourd'hui</button> <button id="btn-today" onclick="datePickerToday()">Aujourd'hui</button>
<span id="mission-label" class="no-data">Chargement...</span> <span id="mission-label" class="no-data"></span>
<span id="load-status"></span> <span id="load-status" style="font-size:10px;color:#888;font-style:italic;flex:1;"></span>
<button id="btn-pipeline" onclick="togglePipeline()">Pipeline</button> <button id="btn-pipeline" onclick="togglePipeline()">Pipeline</button>
</div> </div>
@@ -231,17 +307,24 @@ flowchart LR
<!-- Row 1: Header --> <!-- Row 1: Header -->
<div id="header"> <div id="header">
<span id="title">COSMA NAV v6</span> <span id="title">COSMA NAV v6</span>
<span id="stats">Chargement...</span> <span id="stats" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:#a0c4ff;"></span>
<div id="layer-toggles"> <select id="sortie-select"><option value="">Chargement Gdrive (~30s)...</option></select>
<button class="layer-btn active" id="btn-usv" onclick="toggleLayer('usv')">USV</button> <span id="mission-label-header" style="font-size:11px;font-family:monospace;padding:2px 8px;color:#555;white-space:nowrap;"></span>
<button class="layer-btn active" id="btn-auv" onclick="toggleLayer('auv')">AUV</button> <button id="btn-sync" disabled>Sync &amp; Process</button>
<button class="layer-btn active" id="btn-vec" onclick="toggleLayer('vec')">USBL vec</button> <span id="sync-progress"></span>
<button class="layer-btn active" id="btn-usbl-panel" onclick="toggleLayer('panel')">Stats</button>
</div>
</div> </div>
<!-- Row 2: Map --> <!-- Row 2: Map -->
<div id="map"> <div id="map">
<div id="no-data-overlay">
<div class="nodata-msg">⬇ Sélectionnez une sortie pour charger les données</div>
</div>
<div id="map-layer-toggles">
<button class="layer-btn active" id="btn-usv" onclick="toggleLayer('usv')">⛵ USV</button>
<button class="layer-btn active" id="btn-auv" onclick="toggleLayer('auv')">🛥 AUV</button>
<button class="layer-btn active" id="btn-vec" onclick="toggleLayer('vec')">USBL vec</button>
<button class="layer-btn active" id="btn-usbl-panel" onclick="toggleLayer('panel')">Stats</button>
</div>
<div id="legend"></div> <div id="legend"></div>
<div id="usbl-panel"> <div id="usbl-panel">
<div class="uprow"><span class="uplabel">Dist</span><span class="upval" id="up-dist"></span></div> <div class="uprow"><span class="uplabel">Dist</span><span class="upval" id="up-dist"></span></div>
@@ -255,10 +338,10 @@ flowchart LR
<!-- Row 3: Controls --> <!-- Row 3: Controls -->
<div id="controls"> <div id="controls">
<div id="ctrl-row1"> <div id="ctrl-row1">
<span id="ctrl-label">t</span> <span id="ctrl-label" style="font-size:10px;color:#666;white-space:nowrap;">Heure:</span>
<div id="cursor-slider-wrap"><div id="cursor-slider"></div></div> <div id="cursor-slider-wrap"><div id="cursor-slider"></div></div>
<span id="cursor-time"></span> <span id="cursor-time" style="font-size:11px;color:#e0e0e0;white-space:nowrap;min-width:70px;"></span>
<label for="trail-select">trail</label> <label for="trail-select" style="font-size:10px;color:#666;white-space:nowrap;">Trail:</label>
<select id="trail-select"> <select id="trail-select">
<option value="10000">10s</option> <option value="10000">10s</option>
<option value="30000">30s</option> <option value="30000">30s</option>
@@ -270,14 +353,31 @@ flowchart LR
</select> </select>
<button id="btn-viewall" onclick="viewAll()">View all</button> <button id="btn-viewall" onclick="viewAll()">View all</button>
<button id="btn-play"></button> <button id="btn-play"></button>
<label for="window-select" style="font-size:10px;color:#666;white-space:nowrap;">Window:</label>
<select id="window-select" style="background:#0f3460;border:1px solid #a855f7;color:#a855f7;font-family:monospace;font-size:11px;padding:2px 6px;border-radius:2px;cursor:pointer;">
<option value="10000">10s</option>
<option value="30000">30s</option>
<option value="60000" selected>60s</option>
<option value="300000">5min</option>
<option value="900000">15min</option>
<option value="0">ALL</option>
</select>
</div> </div>
<div id="ctrl-row2"> <div id="ctrl-row2">
<span id="cursor-info" style="font-size:10px;color:#888;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span> <span id="cursor-info" style="font-size:10px;color:#888;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
</div> </div>
</div> </div>
<!-- Row 4: 2×2 Plotly charts --> <!-- Row 4: tabs + panels -->
<div id="panels-tabs">
<button class="panel-tab active" onclick="switchTab('charts')">🗺 Charts globaux</button>
<button class="panel-tab" onclick="switchTab('usv')">⛵ USV</button>
<button class="panel-tab" onclick="switchTab('auv')">🛥 AUV <span id="auv-tabs"></span></button>
</div>
<div id="graphs-section"> <div id="graphs-section">
<!-- Tab: Charts globaux -->
<div id="panel-charts" class="panel-section active">
<div id="charts-4grid">
<div class="chart-wrap"> <div class="chart-wrap">
<div class="chart-title">Depth AUV (m)</div> <div class="chart-title">Depth AUV (m)</div>
<div class="plotly-wrap"><div id="chart-depth"></div></div> <div class="plotly-wrap"><div id="chart-depth"></div></div>
@@ -294,6 +394,37 @@ flowchart LR
<div class="chart-title">USBL Distance (m)</div> <div class="chart-title">USBL Distance (m)</div>
<div class="plotly-wrap"><div id="chart-usbl"></div></div> <div class="plotly-wrap"><div id="chart-usbl"></div></div>
</div> </div>
</div>
</div>
<!-- Tab: USV -->
<div id="panel-usv" class="panel-section hidden">
<div class="panel-header">⛵ USV</div>
<div class="graphs-grid" id="usv-graphs">
<div class="graph-cell" id="usv-yaw"></div>
<div class="graph-cell" id="usv-heading"></div>
<div class="graph-cell" id="usv-batt"></div>
<div class="graph-cell" id="usv-gps"></div>
<div class="graph-cell" id="usv-usbl-dist"></div>
<div class="graph-cell" id="usv-usbl-angle"></div>
<div class="graph-cell wide" id="usv-motors"></div>
<div class="graph-cell wide" id="usv-status"></div>
</div>
</div>
<!-- Tab: AUV -->
<div id="panel-auv" class="panel-section hidden">
<div class="panel-header" id="auv-panel-header">🛥 AUV</div>
<div class="graphs-grid" id="auv-graphs">
<div class="graph-cell" id="auv-pry"></div>
<div class="graph-cell" id="auv-depth"></div>
<div class="graph-cell" id="auv-alt"></div>
<div class="graph-cell" id="auv-obs"></div>
<div class="graph-cell" id="auv-usbl-dist"></div>
<div class="graph-cell" id="auv-usbl-angle"></div>
<div class="graph-cell" id="auv-batt"></div>
<div class="graph-cell" id="auv-status"></div>
<div class="graph-cell wide" id="auv-motors"></div>
</div>
</div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
@@ -302,7 +433,12 @@ flowchart LR
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script> <script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<script> <script>
// == Constants == // == Constants ==
const API = 'http://192.168.0.83:8766'; const API = (location.hostname === 'laboratoire.freeboxos.fr')
? '/cosma-pK8j876lkj-api'
: 'http://192.168.0.83:8766';
const API2 = (location.hostname === 'laboratoire.freeboxos.fr')
? '/cosma-pK8j876lkj-pipe'
: 'http://192.168.0.83:8767';
const COLORS = ['#00b4d8','#06d6a0','#ffd166','#e94560','#a855f7','#ff8800','#7dc8e0','#b8f0d4']; const COLORS = ['#00b4d8','#06d6a0','#ffd166','#e94560','#a855f7','#ff8800','#7dc8e0','#b8f0d4'];
const AUV_COLOR = '#ff8800'; const AUV_COLOR = '#ff8800';
const PLOTLY_LAYOUT = { const PLOTLY_LAYOUT = {
@@ -438,6 +574,14 @@ function populatePlotlyCharts() {
Plotly.react('chart-pwm-auv', pwmAuvTraces, { ...layout, showlegend: pwmAuvTraces.length > 1 }, PLOTLY_CONFIG); Plotly.react('chart-pwm-auv', pwmAuvTraces, { ...layout, showlegend: pwmAuvTraces.length > 1 }, PLOTLY_CONFIG);
Plotly.react('chart-pwm-usv', pwmUsvTraces, { ...layout, showlegend: pwmUsvTraces.length > 1 }, PLOTLY_CONFIG); Plotly.react('chart-pwm-usv', pwmUsvTraces, { ...layout, showlegend: pwmUsvTraces.length > 1 }, PLOTLY_CONFIG);
Plotly.react('chart-usbl', usblDistTraces, layout, PLOTLY_CONFIG); Plotly.react('chart-usbl', usblDistTraces, layout, PLOTLY_CONFIG);
// Force resize: Plotly can't compute size on hidden/zero-size divs at init time
requestAnimationFrame(() => {
['chart-depth','chart-pwm-auv','chart-pwm-usv','chart-usbl'].forEach(id => {
const el = document.getElementById(id);
if (el && el._fullLayout) Plotly.Plots.resize(el);
});
setupSyncedZoom(['chart-depth','chart-pwm-auv','chart-pwm-usv','chart-usbl']);
});
} }
// Update cursor line on all Plotly charts // Update cursor line on all Plotly charts
@@ -533,6 +677,7 @@ function applyTrailAndCursor() {
`t: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`; `t: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`;
updateChartsCursor(); updateChartsCursor();
if (tNow) updateCursor(tNow / 1000);
} }
// == Cursor slider == // == Cursor slider ==
@@ -540,13 +685,16 @@ function initCursorSlider() {
if (cursorSlider) { cursorSlider.destroy(); cursorSlider = null; } if (cursorSlider) { cursorSlider.destroy(); cursorSlider = null; }
const el = document.getElementById('cursor-slider'); const el = document.getElementById('cursor-slider');
cursorSlider = noUiSlider.create(el, { cursorSlider = noUiSlider.create(el, {
start: [tMin], start: [tMax], // BUG1 FIX: start at end so trail window shows recent data
range: { min: tMin, max: tMax > tMin ? tMax : tMin + 1000 }, range: { min: tMin, max: tMax > tMin ? tMax : tMin + 1000 },
step: 1000, step: 1000,
}); });
cursorSlider.on('update', (values) => { cursorSlider.on('update', (values) => {
tNow = Math.round(+values[0]); tNow = Math.round(+values[0]);
document.getElementById('cursor-time').textContent = fmtMs(tNow); // Show HH:MM:SS for slider label
const d = new Date(tNow);
const hms = d.toISOString().slice(11,19);
document.getElementById('cursor-time').textContent = `Heure: ${hms}`;
applyTrailAndCursor(); applyTrailAndCursor();
}); });
document.getElementById('trail-select').addEventListener('change', () => applyTrailAndCursor()); document.getElementById('trail-select').addEventListener('change', () => applyTrailAndCursor());
@@ -587,7 +735,8 @@ function clearMapLayers() {
// == Load data for a given date == // == Load data for a given date ==
async function loadDate(date) { async function loadDate(date) {
setStatus('Chargement...'); setStatus('chargement...');
showNoDataOverlay(true);
clearMapLayers(); clearMapLayers();
allPoints = []; allPoints = [];
usblPoints = []; usblPoints = [];
@@ -603,7 +752,18 @@ async function loadDate(date) {
const dateStr = date.replace(/-/g,''); // YYYYMMDD const dateStr = date.replace(/-/g,''); // YYYYMMDD
const fetches = missions.map(async mission => { // BUG3 FIX: filter missions using availableDates to avoid fetching all missions' dives
const dateEntry = availableDates.find(d => d.date === date);
const knownMissions = dateEntry && dateEntry.missions && dateEntry.missions.length
? dateEntry.missions
: null;
// BUG3b FIX: API data-dates uses #71-golrest but /api/missions uses _71-golrest — normalize for compare
const normalize = id => (id || '').replace(/^[#_]/, '').toLowerCase();
const missionsToFetch = knownMissions
? missions.filter(m => knownMissions.some(km => normalize(m.id) === normalize(km)))
: missions;
const fetches = (missionsToFetch.length > 0 ? missionsToFetch : missions).map(async mission => {
const dResp = await fetch(`${API}/api/missions/${mission.id}/dives`); const dResp = await fetch(`${API}/api/missions/${mission.id}/dives`);
const dives = await dResp.json(); const dives = await dResp.json();
return { mission, dives: dives.filter(d => d.id.startsWith(dateStr)) }; return { mission, dives: dives.filter(d => d.id.startsWith(dateStr)) };
@@ -623,7 +783,7 @@ async function loadDate(date) {
for (const { mission, dives } of missionDives) { for (const { mission, dives } of missionDives) {
for (const dive of dives) { for (const dive of dives) {
allFetches.push(loadDiveData(mission.id, dive.id)); allFetches.push(loadDiveData(mission.id, dive.id, date));
totalShip += dive.ship_session_count || 0; totalShip += dive.ship_session_count || 0;
totalSub += dive.sub_session_count || 0; totalSub += dive.sub_session_count || 0;
} }
@@ -645,7 +805,7 @@ async function loadDate(date) {
]; ];
tMin = Math.min(...allTimes); tMin = Math.min(...allTimes);
tMax = Math.max(...allTimes); tMax = Math.max(...allTimes);
tNow = tMin; tNow = tMax; // BUG1 FIX: start at end so trail window [tMax-trailMs, tMax] contains data
// Fit map // Fit map
const allLats = [ const allLats = [
@@ -677,6 +837,7 @@ async function loadDate(date) {
populatePlotlyCharts(); populatePlotlyCharts();
initCursorSlider(); initCursorSlider();
applyTrailAndCursor(); applyTrailAndCursor();
showNoDataOverlay(false);
document.getElementById('title').textContent = `COSMA v6 — ${date}`; document.getElementById('title').textContent = `COSMA v6 — ${date}`;
setStatus(`${totalShip} USV sess. | ${totalSub} AUV sess. | ${allPoints.length} pts USV | ${usblPoints.length} pts USBL`); setStatus(`${totalShip} USV sess. | ${totalSub} AUV sess. | ${allPoints.length} pts USV | ${usblPoints.length} pts USBL`);
@@ -687,45 +848,60 @@ async function loadDate(date) {
} }
} }
async function loadDiveData(missionId, diveId) { async function loadDiveData(missionId, diveId, filterDate) {
try { try {
const sResp = await fetch(`${API}/api/dives/${missionId}/${diveId}/sessions`); const sResp = await fetch(`${API}/api/dives/${missionId}/${diveId}/sessions`);
const sessions = await sResp.json(); const sessions = await sResp.json();
const sessionFetches = [];
// Ship sessions // T3 FIX: filter ship sessions by date if provided (session.start = "YYYY-MM-DD_HH-MM-SS")
if (sessions.ship) { const shipSessions = (sessions.ship || []).filter(sess => {
sessions.ship.forEach(sess => { if (!filterDate) return true;
sessionFetches.push(loadShipSession(missionId, diveId, sess.id)); // sess.start: "2026-04-17_07-43-23" → compare first 10 chars "2026-04-17" to filterDate
return !sess.start || sess.start.slice(0, 10) === filterDate;
}); });
}
// Sub sessions // Phase 1: track-only fetches (needed for map polylines) — batch by 4
if (sessions.sub) { const trackFetches = shipSessions.map(sess =>
sessions.sub.forEach(sess => { fetchShipTrack(missionId, diveId, sess.id)
sessionFetches.push(loadSubSession(missionId, diveId, sess.id)); );
}); await _batchedAll(trackFetches, 4);
}
await Promise.all(sessionFetches); // Phase 2: series + sub sessions in background (charts data)
const seriesFetches = [
...shipSessions.map(sess => fetchShipSeries(missionId, diveId, sess.id)),
...(sessions.sub || []).map(sess => loadSubSession(missionId, diveId, sess.id)),
];
await _batchedAll(seriesFetches, 4);
} catch(e) { console.warn('loadDiveData error', diveId, e); } } catch(e) { console.warn('loadDiveData error', diveId, e); }
} }
async function loadShipSession(missionId, diveId, sessionId) { // Run an array of promise-factories in batches of size n
async function _batchedAll(fns, n) {
for (let i = 0; i < fns.length; i += n) {
await Promise.all(fns.slice(i, i + n));
}
}
async function fetchShipTrack(missionId, diveId, sessionId) {
try { try {
const [trackResp, seriesResp] = await Promise.all([ const resp = await fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`,
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`), { signal: AbortSignal.timeout(20000) });
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/series`), if (!resp.ok) { console.warn('fetchShipTrack fail', sessionId, resp.status); return; }
]); const d = await resp.json();
if (trackResp.ok) {
const d = await trackResp.json();
const pts = (d.points||[]).map(p => ({ const pts = (d.points||[]).map(p => ({
t_ms: isoToMs(p.t), lat: p.lat, lon: p.lon, t_ms: isoToMs(p.t), lat: p.lat, lon: p.lon,
heading: p.heading || null, source: sessionId heading: p.heading || null, source: sessionId
})); }));
allPoints.push(...pts); allPoints.push(...pts);
} } catch(e) { console.warn('fetchShipTrack error', sessionId, e.name); }
if (seriesResp.ok) { }
const d = await seriesResp.json();
// USV PWM: M1..M8 async function fetchShipSeries(missionId, diveId, sessionId) {
try {
const resp = await fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/series`,
{ signal: AbortSignal.timeout(20000) });
if (!resp.ok) return;
const d = await resp.json();
const motorKeys = Object.keys(d).filter(k => /^M\d+$/.test(k)); const motorKeys = Object.keys(d).filter(k => /^M\d+$/.test(k));
motorKeys.forEach((k, i) => { motorKeys.forEach((k, i) => {
const pts = d[k]; const pts = d[k];
@@ -733,24 +909,48 @@ async function loadShipSession(missionId, diveId, sessionId) {
pwmUsvTraces.push({ pwmUsvTraces.push({
x: pts.map(p => new Date(isoToMs(p.t))), x: pts.map(p => new Date(isoToMs(p.t))),
y: pts.map(p => p.v), y: pts.map(p => p.v),
name: k, name: k, type: 'scatter', mode: 'lines',
type: 'scatter', mode: 'lines',
line: { color: COLORS[i % COLORS.length], width: 1 }, line: { color: COLORS[i % COLORS.length], width: 1 },
}); });
}); });
} } catch(e) { console.warn('fetchShipSeries error', sessionId, e.name); }
} catch(e) { console.warn('loadShipSession error', sessionId, e); }
} }
async function loadSubSession(missionId, diveId, sessionId) { async function loadSubSession(missionId, diveId, sessionId) {
try { // Both usbl_track and series can hang — use parallel with timeout, non-blocking
const [seriesResp, usblResp] = await Promise.all([ const [usblResult, seriesResult] = await Promise.allSettled([
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/series`), fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/usbl_track`,
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/usbl_track`), { signal: AbortSignal.timeout(25000) }),
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/series`,
{ signal: AbortSignal.timeout(25000) }),
]); ]);
if (seriesResp.ok) { if (usblResult.status === 'fulfilled' && usblResult.value.ok) {
const d = await seriesResp.json(); try {
// Depth trace const d = await usblResult.value.json();
const pts = (d.points||[]);
if (pts.length) {
usblPoints.push(...pts.map(p => ({
t_ms: unixToMs(p.t),
auv_lat: p.lat, auv_lon: p.lon,
dist: p.distance_m, az: p.azimuth_deg, elev: p.elevation_deg || 0, snr: p.snr,
})));
usblDistTraces.push({
x: pts.map(p => new Date(unixToMs(p.t))),
y: pts.map(p => p.distance_m),
name: sessionId,
type: 'scatter', mode: 'lines',
line: { color: '#a855f7', width: 1.5 },
});
}
} catch(e) { console.warn('loadSubSession usbl json error', sessionId, e); }
} else {
console.warn('loadSubSession usbl timeout/fail', sessionId,
usblResult.status === 'rejected' ? usblResult.reason.name : usblResult.value?.status);
}
if (seriesResult.status === 'fulfilled' && seriesResult.value.ok) {
try {
const d = await seriesResult.value.json();
if (d.depth && d.depth.length) { if (d.depth && d.depth.length) {
depthTraces.push({ depthTraces.push({
x: d.depth.map(p => new Date(unixToMs(p.t))), x: d.depth.map(p => new Date(unixToMs(p.t))),
@@ -760,7 +960,6 @@ async function loadSubSession(missionId, diveId, sessionId) {
line: { color: '#06d6a0', width: 1.5 }, line: { color: '#06d6a0', width: 1.5 },
}); });
} }
// AUV motors: m1..m8
const motorKeys = Object.keys(d).filter(k => /^m\d+$/.test(k)); const motorKeys = Object.keys(d).filter(k => /^m\d+$/.test(k));
motorKeys.forEach((k, i) => { motorKeys.forEach((k, i) => {
const pts = d[k]; const pts = d[k];
@@ -773,28 +972,11 @@ async function loadSubSession(missionId, diveId, sessionId) {
line: { color: COLORS[(i + 4) % COLORS.length], width: 1 }, line: { color: COLORS[(i + 4) % COLORS.length], width: 1 },
}); });
}); });
} catch(e) { console.warn('loadSubSession series json error', sessionId, e); }
} else {
console.warn('loadSubSession series timeout/fail', sessionId,
seriesResult.status === 'rejected' ? seriesResult.reason.name : seriesResult.value?.status);
} }
if (usblResp.ok) {
const d = await usblResp.json();
const pts = (d.points||[]);
if (pts.length) {
// Build usblPoints — API fields: t(unix s), lat/lon(AUV), usv_lat/usv_lon, distance_m, azimuth_deg, snr
usblPoints.push(...pts.map(p => ({
t_ms: unixToMs(p.t),
auv_lat: p.lat, auv_lon: p.lon,
dist: p.distance_m, az: p.azimuth_deg, elev: p.elevation_deg || 0, snr: p.snr,
})));
// USBL distance trace
usblDistTraces.push({
x: pts.map(p => new Date(unixToMs(p.t))),
y: pts.map(p => p.distance_m),
name: sessionId,
type: 'scatter', mode: 'lines',
line: { color: '#a855f7', width: 1.5 },
});
}
}
} catch(e) { console.warn('loadSubSession error', sessionId, e); }
} }
function setStatus(msg) { function setStatus(msg) {
@@ -857,6 +1039,37 @@ function datePickerToday() {
loadDate(today); loadDate(today);
} }
// == Tab switching ==
function switchTab(name) {
['charts','usv','auv'].forEach(t => {
const p = document.getElementById('panel-'+t);
if (p) p.classList.toggle('active', t === name);
if (p) p.classList.toggle('hidden', t !== name);
});
document.querySelectorAll('.panel-tab').forEach((btn, i) => {
const names = ['charts','usv','auv'];
btn.classList.toggle('active', names[i] === name);
});
// BUG1 FIX: resize Plotly charts when tab becomes visible
if (name === 'charts') {
requestAnimationFrame(() => {
['chart-depth','chart-pwm-auv','chart-pwm-usv','chart-usbl'].forEach(id => {
const el = document.getElementById(id);
if (el && el._fullLayout) Plotly.Plots.resize(el);
});
});
}
}
// == No-data overlay ==
function showNoDataOverlay(show) {
const el = document.getElementById('no-data-overlay');
if (el) el.classList.toggle('hidden', !show);
// Show/hide layer toggles
const lt = document.getElementById('map-layer-toggles');
if (lt) lt.style.display = show ? 'none' : 'flex';
}
// == Pipeline overlay == // == Pipeline overlay ==
let _pipelineRendered = false; let _pipelineRendered = false;
function togglePipeline() { function togglePipeline() {
@@ -872,6 +1085,347 @@ function hidePipelineOnBackdrop(e) {
} }
document.addEventListener('keydown', e => { if (e.key === 'Escape') { const ov = document.getElementById('pipeline-overlay'); if (ov.classList.contains('visible')) togglePipeline(); } }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { const ov = document.getElementById('pipeline-overlay'); if (ov.classList.contains('visible')) togglePipeline(); } });
// == Task 8: USV rendering helpers ==
function _pts(sig) {
if (!sig || !sig.length) return [[], []];
return [sig.map(p => new Date(p.t * 1000)), sig.map(p => p.v)];
}
const PLOTLY_LAYOUT_BASE = {
margin: {l:40, r:8, t:20, b:30},
paper_bgcolor: '#1a1a2e', plot_bgcolor: '#1a1a2e',
font: {color: '#e0e0e0', size: 9, family: 'monospace'},
xaxis: {color: '#555', gridcolor: '#1e1e3a', type: 'date'},
yaxis: {color: '#555', gridcolor: '#1e1e3a'},
showlegend: false,
};
function _layout(title, yLabel) {
const l = JSON.parse(JSON.stringify(PLOTLY_LAYOUT_BASE));
l.title = {text: title, font: {size: 9, color: '#888'}};
if (yLabel) l.yaxis.title = {text: yLabel, font: {size: 8}};
return l;
}
function renderUSV(signals) {
const cfg = {responsive: true, displayModeBar: false};
const [yt, yv] = _pts(signals.Yaw);
Plotly.react('usv-yaw', [{x:yt, y:yv, type:'scatter', mode:'lines', line:{color:'#00b4d8',width:1}, name:'Yaw'}], _layout('Yaw','°'), cfg);
const [ht, hv] = _pts(signals.Heading);
Plotly.react('usv-heading', [{x:ht, y:hv, type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1}}], _layout('Heading','°'), cfg);
const [bt, bv] = _pts(signals.BattVoltage);
Plotly.react('usv-batt', [{x:bt, y:bv, type:'scatter', mode:'lines', line:{color:'#ffd166',width:1}}], _layout('Battery','V'), cfg);
const [gt, gv] = _pts(signals.gps_fix);
Plotly.react('usv-gps', [{x:gt, y:gv, type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1,shape:'hv'}}], _layout('GPS fix'), cfg);
const [dt, dv] = _pts(signals.usbl_dist);
Plotly.react('usv-usbl-dist', [{x:dt, y:dv, type:'scatter', mode:'lines', line:{color:'#a0c4ff',width:1}}], _layout('USBL dist','m'), cfg);
const [at, av] = _pts(signals.usbl_angle);
Plotly.react('usv-usbl-angle', [{x:at, y:av, type:'scatter', mode:'lines', line:{color:'#c77dff',width:1}}], _layout('USBL angle','°'), cfg);
const motorColorsUSV = ['#ef476f','#ff8800'];
const motorTracesUSV = ['M1','M2'].map((mk,i) => {
const [t,v] = _pts(signals[mk]);
return {x:t,y:v,type:'scatter',mode:'lines',name:mk,line:{color:motorColorsUSV[i],width:1}};
});
Plotly.react('usv-motors', motorTracesUSV,
Object.assign(_layout('Motors USV','cmd'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',orientation:'h',x:0,y:1}}), cfg);
const armPts = _pts(signals.Armed);
const modePts = _pts(signals.Mode);
const statusTraces = [];
if (armPts[0].length) statusTraces.push({x:armPts[0], y:armPts[1], name:'Armed', type:'scatter', mode:'lines', line:{color:'#ffd166',width:1,shape:'hv'}});
if (modePts[0].length) statusTraces.push({x:modePts[0], y:modePts[1], name:'Mode', type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1,shape:'hv'}});
Plotly.react('usv-status', statusTraces.length ? statusTraces : [{x:[],y:[]}],
Object.assign(_layout('USV status'), {showlegend: statusTraces.length > 1}), cfg);
requestAnimationFrame(() => {
setupSyncedZoom(['usv-yaw','usv-heading','usv-batt','usv-gps','usv-usbl-dist','usv-usbl-angle','usv-motors','usv-status']);
});
}
// == Task 9: AUV rendering + tabs ==
let _currentSortieId = null;
async function loadAuvTabs(sortieId) {
const resp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auvs`);
const auvs = await resp.json();
const tabsEl = document.getElementById('auv-tabs');
tabsEl.innerHTML = '';
auvs.forEach((auv, i) => {
const btn = document.createElement('button');
btn.className = 'auv-tab' + (i === 0 ? ' active' : '');
btn.textContent = auv;
btn.onclick = async () => {
document.querySelectorAll('.auv-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const r = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auv/${auv}`);
const data = await r.json();
renderAUV(data.signals);
};
tabsEl.appendChild(btn);
});
if (auvs.length > 0) {
const r = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auv/${auvs[0]}`);
const data = await r.json();
renderAUV(data.signals);
}
}
function renderAUV(signals) {
const cfg = {responsive: true, displayModeBar: false};
const pryTraces = [
['pitch', '#ef476f'], ['roll', '#06d6a0'], ['yaw', '#00b4d8']
].map(([k, c]) => { const [t,v]=_pts(signals[k]); return {x:t,y:v,name:k,type:'scatter',mode:'lines',line:{color:c,width:1}}; });
Plotly.react('auv-pry', pryTraces, Object.assign(_layout('Pitch/Roll/Yaw','°'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',x:0,y:1}}), cfg);
const [dpt, dpv] = _pts(signals.depth);
Plotly.react('auv-depth', [{x:dpt,y:dpv,type:'scatter',mode:'lines',line:{color:'#a0c4ff',width:1}}], _layout('Depth','m'), cfg);
const [alt, alv] = _pts(signals.altitude);
Plotly.react('auv-alt', [{x:alt,y:alv,type:'scatter',mode:'lines',line:{color:'#ffd166',width:1}}], _layout('Altitude','m'), cfg);
const [obt, obv] = _pts(signals.obstacle_dist);
Plotly.react('auv-obs', [{x:obt,y:obv,type:'scatter',mode:'lines',line:{color:'#c77dff',width:1}}], _layout('Obstacle','m'), cfg);
const [udt, udv] = _pts(signals.usbl_dist);
Plotly.react('auv-usbl-dist', [{x:udt,y:udv,type:'scatter',mode:'lines',line:{color:'#a0c4ff',width:1}}], _layout('USBL dist','m'), cfg);
const [uat, uav] = _pts(signals.usbl_angle);
Plotly.react('auv-usbl-angle', [{x:uat,y:uav,type:'scatter',mode:'lines',line:{color:'#c77dff',width:1}}], _layout('USBL angle','°'), cfg);
const [bbt, bbv] = _pts(signals.battery_v);
Plotly.react('auv-batt', [{x:bbt,y:bbv,type:'scatter',mode:'lines',line:{color:'#ffd166',width:1}}], _layout('Battery','V'), cfg);
const [stt, stv] = _pts(signals.arm_status);
Plotly.react('auv-status', [{x:stt,y:stv,type:'scatter',mode:'lines',line:{color:'#06d6a0',width:1,shape:'hv'}}], _layout('Arm/Mode'), cfg);
const motorColors = ['#ef476f','#ffd166','#06d6a0','#00b4d8','#a0c4ff','#c77dff'];
const motorTraces = ['m1','m2','m3','m4','m5','m6'].map((mk,i) => {
const [t,v] = _pts(signals[mk]);
return {x:t,y:v,type:'scatter',mode:'lines',name:`M${i+1}`,line:{color:motorColors[i],width:1}};
});
Plotly.react('auv-motors', motorTraces,
Object.assign(_layout('Motors x6 PWM','µs'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',orientation:'h',x:0,y:1}}), cfg);
requestAnimationFrame(() => {
setupSyncedZoom(['auv-pry','auv-depth','auv-alt','auv-obs','auv-usbl-dist','auv-usbl-angle','auv-batt','auv-status','auv-motors']);
});
}
// == Task 10: Slider cursor sync ==
const ALL_GRAPH_IDS = [
'chart-depth', 'chart-pwm-auv', 'chart-pwm-usv', 'chart-usbl',
'usv-yaw', 'usv-heading', 'usv-batt', 'usv-gps',
'usv-usbl-dist', 'usv-usbl-angle', 'usv-motors', 'usv-status',
'auv-pry', 'auv-depth', 'auv-alt', 'auv-obs',
'auv-usbl-dist', 'auv-usbl-angle', 'auv-batt', 'auv-status', 'auv-motors',
];
function updateCursor(epochSec) {
const ts = new Date(epochSec * 1000).toISOString();
const tMs = epochSec * 1000;
const winMs = +document.getElementById('window-select').value;
const t0 = winMs === 0 ? null : new Date(tMs - winMs).toISOString();
const t1 = new Date(tMs).toISOString();
const shape = {
type: 'line', x0: ts, x1: ts, y0: 0, y1: 1,
yref: 'paper', line: {color: '#e94560', width: 1, dash: 'dot'},
};
const rangeUpdate = t0 ? {'xaxis.range': [t0, t1]} : {'xaxis.autorange': true};
ALL_GRAPH_IDS.forEach(id => {
const el = document.getElementById(id);
if (el && el._fullLayout) {
Plotly.relayout(id, {...rangeUpdate, 'shapes': [shape]});
}
});
}
document.getElementById('window-select').addEventListener('change', () => {
if (tNow) updateCursor(tNow / 1000);
});
// == Feature: Synced zoom across all Plotly charts ==
let _syncing = false;
function setupSyncedZoom(chartIds) {
chartIds.forEach(id => {
const div = document.getElementById(id);
if (!div) return;
div.on('plotly_relayout', (ev) => {
if (_syncing) return;
const x0 = ev['xaxis.range[0]'] || (ev['xaxis.range'] && ev['xaxis.range'][0]);
const x1 = ev['xaxis.range[1]'] || (ev['xaxis.range'] && ev['xaxis.range'][1]);
const reset = ev['xaxis.autorange'];
if (x0 == null && x1 == null && !reset) return;
_syncing = true;
chartIds.forEach(otherId => {
if (otherId === id) return;
const otherDiv = document.getElementById(otherId);
if (!otherDiv || !otherDiv._fullLayout) return;
if (reset) {
Plotly.relayout(otherDiv, { 'xaxis.autorange': true });
} else {
Plotly.relayout(otherDiv, { 'xaxis.range': [x0, x1] });
}
});
setTimeout(() => { _syncing = false; }, 50);
});
});
}
// == Task 11: loadSortieData + sorties loading + wiring ==
async function loadSortieData(sortieId) {
const prog = document.getElementById('sync-progress');
try {
// 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
showNoDataOverlay(false); // BUG2 FIX: hide overlay when data loaded
renderUSV(usvData.signals);
}
prog.textContent = 'Chargement AUV…';
await loadAuvTabs(sortieId);
prog.textContent = `${sortieId} chargé`;
// Re-apply after AUV usbl_track loaded (adds AUV trail if available)
if (allPoints.length > 0) applyTrailAndCursor();
} catch(e) {
prog.textContent = `Erreur: ${e.message}`;
}
}
function _wireSortieSelect() {
const sel = document.getElementById('sortie-select');
// Evite double-binding si appelé plusieurs fois
if (sel._wired) return;
sel._wired = true;
sel.addEventListener('change', () => {
const btn = document.getElementById('btn-sync');
btn.disabled = !sel.value;
_updateMissionLabelHeader(sel.value);
if (sel.value) {
const opt = sel.options[sel.selectedIndex];
if (opt.textContent.includes('✓')) {
loadSortieData(sel.value);
}
}
});
}
function _populateSortieSelect(sorties) {
const sel = document.getElementById('sortie-select');
// Vider sauf première option placeholder
while (sel.options.length > 1) sel.remove(1);
if (!sorties || !sorties.length) {
sel.options[0].textContent = '— Aucune sortie —';
return;
}
sel.options[0].textContent = `${sorties.length} sorties dispo — sélectionnez`;
sorties.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.id + (s.processed ? ' ✓' : '');
sel.appendChild(opt);
});
_wireSortieSelect();
}
function _updateMissionLabelHeader(sortieId) {
const el = document.getElementById('mission-label-header');
if (!el) return;
el.textContent = sortieId ? `Mission: ${sortieId}` : '';
el.style.color = sortieId ? '#06d6a0' : '#555';
}
function loadSorties() {
const sel = document.getElementById('sortie-select');
sel.options[0].textContent = 'Sorties: chargement…';
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
fetch(`${API2}/sorties`, { signal: controller.signal })
.then(resp => {
clearTimeout(timeoutId);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
})
.then(sorties => { _populateSortieSelect(sorties); })
.catch(e => {
clearTimeout(timeoutId);
sel.options[0].textContent = '— Pipeline indisponible —';
console.warn('loadSorties:', e.message);
});
}
document.getElementById('btn-sync').addEventListener('click', async () => {
const sortieId = document.getElementById('sortie-select').value;
if (!sortieId) return;
const btn = document.getElementById('btn-sync');
const prog = document.getElementById('sync-progress');
btn.disabled = true;
prog.textContent = 'Démarrage…';
const encoded = encodeURIComponent(sortieId);
await fetch(`${API2}/run/${encoded}`, {method: 'POST'});
const es = new EventSource(`${API2}/events/${encoded}`);
es.onmessage = async (e) => {
const evt = JSON.parse(e.data);
if (evt.step === 'ping') return;
prog.textContent = `[${evt.step}] ${evt.pct}% ${evt.msg}`;
if (evt.step === 'write' && evt.pct === 100) {
es.close();
btn.disabled = false;
prog.textContent = 'Terminé — chargement…';
await loadSortieData(sortieId);
}
if (evt.step === 'error') {
es.close();
btn.disabled = false;
prog.textContent = `Erreur: ${evt.msg}`;
}
};
});
loadSorties();
// == Init == // == Init ==
mermaid.initialize({ startOnLoad: false, theme: 'dark' }); mermaid.initialize({ startOnLoad: false, theme: 'dark' });
initCharts(); initCharts();