Compare commits

...

2 Commits

Author SHA1 Message Date
Flag
6f2f6d2d72 feat(viewer): v5 grid 2x2 + datebar calendar + mission label
- NAV viewer v5: grid 2x2 (map + charts), trail slider, play, layer toggles
- Datebar: date picker + datalist, fetches /api/data-dates from :8766
- Mission label shows #NN-folder (X sessions) in green or grey
- Tools: parse_usv_nav, extract_mcap_signals, extract_usv_pwm, merge_nav_usbl, usbl_to_json, check_sync, parse_kogger_usbl
- Vendor: Kogger-Protocol docs
2026-04-27 13:48:36 +00:00
Poulpe
ad6c197f5c feat(viewer): calendar date-picker + mission name from backend 8766
- date-picker bar with datalist highlighting available dates
- fetch /api/data-dates from 192.168.0.83:8766 on load
- mission name + session count shown on date change
- Aujourd hui button sets today
- slider 24h intact
2026-04-27 13:45:20 +00:00
13 changed files with 1777 additions and 151 deletions

5
.gitignore vendored
View File

@@ -2,3 +2,8 @@ data/
output/ output/
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/
data/
viewer/data/
viewer/screenshots/
screenshots/

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "vendor/Kogger-Protocol"]
path = vendor/Kogger-Protocol
url = https://github.com/koggertech/Kogger-Protocol.git

71
tools/check_sync.py Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Check temporal alignment between MCAP AUV, USV PWM, and USBL data."""
import json, os, sys
from datetime import datetime, timezone
def fmt(ms):
if ms == 0: return 'N/A'
return datetime.fromtimestamp(ms/1000, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
def load(path):
with open(path) as f:
return json.load(f)
base = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'output')
sources = {}
# MCAP signals
mcap_path = os.path.join(base, 'mcap_signals.json')
if os.path.exists(mcap_path):
d = load(mcap_path)
n = len(d.get('depth',[])) + len(d.get('pwm_auv',{}).get('samples',[])) + len(d.get('state',[]))
sources['MCAP AUV'] = {'t_min': d['t_min_utc_ms'], 't_max': d['t_max_utc_ms'], 'n': n}
else:
print(f"MISSING: {mcap_path}")
# USV PWM
usv_path = os.path.join(base, 'usv_pwm.json')
if os.path.exists(usv_path):
d = load(usv_path)
n = sum(len(v) for v in d.get('M',{}).values()) + sum(len(v) for v in d.get('RC',{}).values())
sources['USV PWM'] = {'t_min': d['t_min_utc_ms'], 't_max': d['t_max_utc_ms'], 'n': n}
else:
print(f"MISSING: {usv_path}")
# USBL
usbl_path = os.path.join(base, 'usbl.json')
if os.path.exists(usbl_path):
d = load(usbl_path)
pts = d.get('points', [])
if pts:
t_vals = [p['t_ms'] for p in pts]
sources['USBL'] = {'t_min': min(t_vals), 't_max': max(t_vals), 'n': len(pts)}
else:
sources['USBL'] = {'t_min': 0, 't_max': 0, 'n': 0}
else:
print(f"MISSING: {usbl_path}")
print(f"\n{'Source':<12} | {'t_min UTC':<20} | {'t_max UTC':<20} | {'n_pts':>6}")
print('-' * 68)
for name, s in sources.items():
print(f"{name:<12} | {fmt(s['t_min']):<20} | {fmt(s['t_max']):<20} | {s['n']:>6}")
# Overlap MCAP vs USV
if 'MCAP AUV' in sources and 'USV PWM' in sources:
mcap = sources['MCAP AUV']
usv = sources['USV PWM']
overlap_ms = min(mcap['t_max'], usv['t_max']) - max(mcap['t_min'], usv['t_min'])
print(f"\nMCAP t_min: {fmt(mcap['t_min'])} UTC")
print(f"USV t_min: {fmt(usv['t_min'])} UTC")
diff_min = (mcap['t_min'] - usv['t_min']) / 60000
print(f"t_min diff: {diff_min:+.1f} min (MCAP vs USV)")
if overlap_ms > 60000:
print(f"OK - overlap: {overlap_ms//1000} s")
elif overlap_ms < 0:
print(f"WARNING: no overlap! gap = {-overlap_ms//1000} s")
else:
print(f"SUSPECT: overlap <60s: {overlap_ms//1000} s")
if __name__ == '__main__':
pass

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Extract AUV signals from MCAP files: depth, PWM, state."""
import argparse, glob, json, os, sys
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--session-dir', required=True)
parser.add_argument('--max-pts', type=int, default=5000)
args = parser.parse_args()
session_name = os.path.basename(args.session_dir.rstrip('/'))
pattern = os.path.join(args.session_dir, '*.mcap')
mcap_files = sorted(glob.glob(pattern))
if not mcap_files:
print(f"No MCAP files in {args.session_dir}", file=sys.stderr)
sys.exit(1)
print(f"Found {len(mcap_files)} MCAP files")
try:
from mcap.reader import make_reader
from mcap_ros2.decoder import DecoderFactory
except ImportError as e:
print(f"Import error: {e}", file=sys.stderr)
sys.exit(1)
depth_raw = []
pwm_raw = []
state_raw = []
TOPICS = ['/mavros/imu/static_pressure', '/mavros/rc/out', '/mavros/state']
for mcap_file in mcap_files:
try:
with open(mcap_file, 'rb') as f:
reader = make_reader(f, decoder_factories=[DecoderFactory()])
for schema, channel, message, ros_msg in reader.iter_decoded_messages(topics=TOPICS):
t_ms = message.publish_time // 1_000_000
topic = channel.topic
if topic == '/mavros/imu/static_pressure':
try:
p = float(ros_msg.fluid_pressure)
depth_m = (p - 101325.0) / (1025.0 * 9.80665)
depth_raw.append({'t': t_ms, 'v': round(depth_m, 4)})
except Exception:
pass
elif topic == '/mavros/rc/out':
try:
ch = list(ros_msg.channels)
pwm_raw.append({'t': t_ms, 'v': ch})
except Exception:
pass
elif topic == '/mavros/state':
try:
state_raw.append({'t': t_ms, 'mode': str(ros_msg.mode), 'armed': bool(ros_msg.armed)})
except Exception:
pass
except Exception as e:
print(f" Skip {os.path.basename(mcap_file)}: {e}")
def sample(lst, max_pts):
if len(lst) <= max_pts:
return lst
stride = len(lst) // max_pts
sampled = lst[::stride]
if sampled[-1] is not lst[-1]:
sampled.append(lst[-1])
return sampled
depth = sample(depth_raw, args.max_pts)
pwm_samples = sample(pwm_raw, args.max_pts)
state = state_raw # events, keep all
all_t = [p['t'] for p in depth_raw + pwm_raw + state_raw]
t_min = min(all_t) if all_t else 0
t_max = max(all_t) if all_t else 0
n_ch = max((len(s['v']) for s in pwm_raw), default=0)
channels = list(range(n_ch))
from datetime import datetime, timezone
fmt = lambda ms: datetime.fromtimestamp(ms/1000, tz=timezone.utc).isoformat()
print(f"depth: {len(depth)} pts (raw {len(depth_raw)})")
if depth:
dvals = [p['v'] for p in depth]
print(f" depth range: {min(dvals):.3f} .. {max(dvals):.3f} m")
print(f"pwm_auv: {len(pwm_samples)} samples (raw {len(pwm_raw)}), {n_ch} channels")
print(f"state: {len(state)} events")
print(f"t_min: {fmt(t_min)}")
print(f"t_max: {fmt(t_max)}")
out = {
'session': session_name,
't_min_utc_ms': t_min,
't_max_utc_ms': t_max,
'depth': depth,
'pwm_auv': {'channels': channels, 'samples': pwm_samples},
'state': state,
}
outdir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'output')
os.makedirs(outdir, exist_ok=True)
outpath = os.path.join(outdir, 'mcap_signals.json')
with open(outpath, 'w') as f:
json.dump(out, f)
print(f"Written: {outpath}")
if __name__ == '__main__':
main()

89
tools/extract_usv_pwm.py Normal file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Extract USV PWM signals from navigation log CSVs."""
import argparse, csv, glob, json, os, re, sys
from datetime import datetime, timezone
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--nav-dir', required=True)
args = parser.parse_args()
pattern = os.path.join(args.nav_dir, '*_navigation_log.csv')
csv_files = sorted(glob.glob(pattern))
if not csv_files:
print(f"No navigation_log.csv in {args.nav_dir}", file=sys.stderr)
sys.exit(1)
print(f"Found {len(csv_files)} nav CSV files")
M_data = {}
RC_data = {}
for csv_file in csv_files:
print(f" Parsing {os.path.basename(csv_file)}")
try:
with open(csv_file, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
ts_str = row.get('timestamp', '').strip()
data = row.get('data', '').strip()
val_str = row.get('value', '').strip()
if not ts_str or not data or not val_str:
continue
is_M = re.match(r'^M\d+$', data)
is_RC = re.match(r'^RC\d+$', data)
if not is_M and not is_RC:
continue
try:
val = float(val_str)
except ValueError:
continue
try:
dt = datetime.strptime(ts_str, '%Y-%m-%d %H:%M:%S.%f')
except ValueError:
try:
dt = datetime.strptime(ts_str, '%Y-%m-%d %H:%M:%S')
except ValueError:
continue
# CET -> UTC: subtract 3600s
t_ms = int(dt.timestamp() * 1000) - 3600 * 1000
pt = {'t': t_ms, 'v': val}
if is_M:
M_data.setdefault(data, []).append(pt)
else:
RC_data.setdefault(data, []).append(pt)
except Exception as e:
print(f" Error {csv_file}: {e}")
all_t = []
for pts in list(M_data.values()) + list(RC_data.values()):
all_t.extend(p['t'] for p in pts)
t_min = min(all_t) if all_t else 0
t_max = max(all_t) if all_t else 0
for k in sorted(M_data):
print(f" {k}: {len(M_data[k])} pts")
for k in sorted(RC_data):
print(f" {k}: {len(RC_data[k])} pts")
fmt = lambda ms: datetime.fromtimestamp(ms/1000, tz=timezone.utc).isoformat()
print(f"t_min UTC: {fmt(t_min)}")
print(f"t_max UTC: {fmt(t_max)}")
out = {
'tz_assumed': 'CET (UTC+1)',
'tz_converted_to': 'UTC',
't_min_utc_ms': t_min,
't_max_utc_ms': t_max,
'M': M_data,
'RC': RC_data,
}
outdir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'output')
os.makedirs(outdir, exist_ok=True)
outpath = os.path.join(outdir, 'usv_pwm.json')
with open(outpath, 'w') as f:
json.dump(out, f)
print(f"Written: {outpath}")
if __name__ == '__main__':
main()

319
tools/merge_nav_usbl.py Normal file
View File

@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""
merge_nav_usbl.py — Merge USBL decoded data with USV navigation log
Inputs:
--usbl : combined_usbl.csv (output of parse_kogger_usbl.py)
--nav-dir: directory containing *_navigation_log.csv files
--output : output CSV (default: combined_nav_usbl.csv)
Nav log format: timestamp,data,value (long format)
data=Lat → latitude_deg
data=Lon → longitude_deg
data=Heading → heading_deg
Interpolation: for each USBL timestamp, find nearest nav point within 1s window.
If multiple sessions, use session matching by timestamp overlap.
AUV position calculation:
azimuth_deg from USBL is RELATIVE to USV heading (yaw) based on USBL hardware mounting.
AUV bearing from North = (USV heading + azimuth_deg) mod 360
Horizontal dist = dist_m * cos(elevation_deg * pi/180)
Note: if azimuth is already absolute (referenced to North), do NOT add heading.
Check: usbl_yaw in payload should match nav Heading if relative azimuth.
We use RELATIVE convention (add USV heading) — documented here.
Geodetic forward (haversine):
Using flat-earth approximation valid for distances < 500m:
dlat = horiz_dist * cos(bearing) / R_earth
dlon = horiz_dist * sin(bearing) / (R_earth * cos(lat))
R_earth = 6371000 m
"""
import csv
import io
import sys
import math
import os
R_EARTH = 6371000.0 # meters
def parse_nav_log(nav_file):
"""
Parse navigation_log.csv into:
- nav_points: sorted list of (timestamp_str, lat, lon) from Lat+Lon entries
- heading_series: sorted list of (timestamp_str, heading_deg) from Heading entries
Lat/Lon and Heading have different timestamps so must be interpolated separately.
Returns (nav_points, heading_series).
"""
lat_by_ts = {}
lon_by_ts = {}
heading_series_raw = []
with open(nav_file) as f:
reader = csv.DictReader(f)
for row in reader:
data = row.get('data', '')
ts = row.get('timestamp', '')
try:
val_raw = row.get('value', '') or ''
if not val_raw:
continue
val = float(val_raw)
except (ValueError, TypeError):
continue
if data == 'Lat':
lat_by_ts[ts] = val
elif data == 'Lon':
lon_by_ts[ts] = val
elif data == 'Heading':
heading_series_raw.append((ts, val))
# Build nav_points: timestamps where both Lat and Lon appear (same ts)
nav_points = []
for ts in sorted(set(lat_by_ts.keys()) & set(lon_by_ts.keys())):
nav_points.append((ts, lat_by_ts[ts], lon_by_ts[ts]))
# heading_series sorted by timestamp
heading_series = sorted(heading_series_raw, key=lambda x: x[0])
return nav_points, heading_series
def ts_to_seconds(ts_str):
"""Convert '2026-03-24 09:29:05.230392' to float seconds since epoch (approx)."""
# Simple: parse date+time, compute offset
try:
date_part, time_part = ts_str.strip().split(' ', 1)
y, mo, d = date_part.split('-')
parts = time_part.split(':')
h, m = int(parts[0]), int(parts[1])
s_str = parts[2]
s = float(s_str)
# Days since fixed epoch (don't need absolute, just relative diffs)
total = (int(y)*365 + int(mo)*30 + int(d)) * 86400 + h*3600 + m*60 + s
return total
except Exception:
return 0.0
def find_nearest(ts_sec, series_sec, series, max_gap=1.0):
"""Find nearest entry in sorted series within max_gap seconds."""
best_idx = -1
best_dt = float('inf')
for i, (s_sec, entry) in enumerate(zip(series_sec, series)):
dt = abs(s_sec - ts_sec)
if dt < best_dt:
best_dt = dt
best_idx = i
elif s_sec > ts_sec + max_gap:
break
if best_idx >= 0 and best_dt <= max_gap:
return series[best_idx], best_dt
return None, None
def geodetic_forward(lat_deg, lon_deg, bearing_deg, dist_m):
"""
Compute destination point given start lat/lon, bearing (deg from North), distance (m).
Flat-earth approximation valid for dist < 500m.
"""
bearing_rad = math.radians(bearing_deg)
lat_rad = math.radians(lat_deg)
dlat = dist_m * math.cos(bearing_rad) / R_EARTH
dlon = dist_m * math.sin(bearing_rad) / (R_EARTH * math.cos(lat_rad))
auv_lat = lat_deg + math.degrees(dlat)
auv_lon = lon_deg + math.degrees(dlon)
return auv_lat, auv_lon
def haversine_dist(lat1, lon1, lat2, lon2):
"""Distance in meters between two lat/lon points."""
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
return 2 * R_EARTH * math.asin(math.sqrt(a))
def find_nav_file_for_session(usbl_file, nav_dir):
"""Match nav file by common timestamp prefix."""
base = os.path.basename(usbl_file)
# e.g. 2026-03-24_09-28-44_USV003_usbl.csv -> 2026-03-24_09-28-44_USV003
prefix = base.replace('_usbl.csv', '')
nav_candidate = os.path.join(nav_dir, prefix + '_navigation_log.csv')
if os.path.exists(nav_candidate):
return nav_candidate
return None
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--usbl', required=True, help='combined_usbl.csv')
parser.add_argument('--nav-dir', required=True, help='Directory with *_navigation_log.csv')
parser.add_argument('--output', default='combined_nav_usbl.csv')
parser.add_argument('--max-gap', type=float, default=1.0, help='Max timestamp gap in seconds')
args = parser.parse_args()
# Load USBL records
usbl_records = []
with open(args.usbl) as f:
reader = csv.DictReader(f)
for row in reader:
usbl_records.append(row)
print("USBL records loaded: %d" % len(usbl_records))
# Group by source_file
from collections import defaultdict
by_source = defaultdict(list)
for rec in usbl_records:
by_source[rec.get('source_file', '')].append(rec)
# Load nav files
nav_data = {} # source_file -> (nav_points, nav_points_sec, heading_series, heading_sec)
nav_dir = args.nav_dir
for source_file in by_source.keys():
# Try to match nav file
prefix = source_file.replace('_usbl.csv', '')
nav_file = os.path.join(nav_dir, prefix + '_navigation_log.csv')
if not os.path.exists(nav_file):
# Try to find by scanning directory
matched = None
for fn in os.listdir(nav_dir):
if prefix in fn and 'navigation_log' in fn:
matched = os.path.join(nav_dir, fn)
break
if matched is None:
print("WARNING: no nav file found for %s" % source_file)
nav_data[source_file] = ([], [], [], [])
continue
nav_file = matched
nav_points, heading_series = parse_nav_log(nav_file)
nav_points_sec = [ts_to_seconds(pt[0]) for pt in nav_points]
heading_sec = [ts_to_seconds(h[0]) for h in heading_series]
nav_data[source_file] = (nav_points, nav_points_sec, heading_series, heading_sec)
print("Nav loaded for %s: %d pos points, %d heading points" % (
source_file, len(nav_points), len(heading_series)))
# Process and write output
output_rows = []
stats_match = 0
stats_nomatch = 0
for source_file, records in by_source.items():
nav_points, nav_points_sec, heading_series, heading_sec = nav_data.get(
source_file, ([], [], [], []))
for rec in records:
ts_str = rec.get('Timestamp', '')
ts_sec = ts_to_seconds(ts_str)
dist_str = rec.get('Dist', '')
azimuth_str = rec.get('Azimuth', '')
elev_str = rec.get('Elev', '')
snr_str = rec.get('SNR', '')
try:
dist = float(dist_str) if dist_str else float('nan')
azimuth = float(azimuth_str) if azimuth_str else float('nan')
elev = float(elev_str) if elev_str else float('nan')
snr = float(snr_str) if snr_str else float('nan')
except ValueError:
dist, azimuth, elev, snr = float('nan'), float('nan'), float('nan'), float('nan')
nav_pt, dt = find_nearest(ts_sec, nav_points_sec, nav_points, args.max_gap)
hdg_pt, _ = find_nearest(ts_sec, heading_sec, heading_series, args.max_gap)
if nav_pt is None:
stats_nomatch += 1
lat_usv, lon_usv, heading_usv = float('nan'), float('nan'), float('nan')
auv_lat, auv_lon = float('nan'), float('nan')
else:
stats_match += 1
_, lat_usv, lon_usv = nav_pt
heading_usv = hdg_pt[1] if hdg_pt is not None else float('nan')
# Calculate AUV absolute position
# Azimuth from USBL is relative to USV heading (yaw convention)
# AUV bearing from North = (USV Heading + azimuth_deg) mod 360
if not (math.isnan(dist) or math.isnan(azimuth) or
math.isnan(lat_usv) or math.isnan(heading_usv)):
horiz_dist = dist * math.cos(math.radians(elev)) if not math.isnan(elev) else dist
abs_bearing = (heading_usv + azimuth) % 360
auv_lat, auv_lon = geodetic_forward(lat_usv, lon_usv, abs_bearing, horiz_dist)
else:
auv_lat, auv_lon = float('nan'), float('nan')
output_rows.append({
'Timestamp': ts_str,
'lat': '%.7f' % lat_usv if not math.isnan(lat_usv) else '',
'lon': '%.7f' % lon_usv if not math.isnan(lon_usv) else '',
'Heading': '%.2f' % heading_usv if not math.isnan(heading_usv) else '',
'Dist': '%.4f' % dist if not math.isnan(dist) else '',
'Azimuth': '%.4f' % azimuth if not math.isnan(azimuth) else '',
'Elev': '%.4f' % elev if not math.isnan(elev) else '',
'SNR': '%.4f' % snr if not math.isnan(snr) else '',
'auv_lat': '%.7f' % auv_lat if not math.isnan(auv_lat) else '',
'auv_lon': '%.7f' % auv_lon if not math.isnan(auv_lon) else '',
'nav_dt_s': '%.3f' % dt if dt is not None else '',
})
print("\n=== Merge stats ===")
print("Matched: %d No nav match: %d" % (stats_match, stats_nomatch))
with open(args.output, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['Timestamp', 'lat', 'lon', 'Heading', 'Dist', 'Azimuth', 'Elev', 'SNR',
'auv_lat', 'auv_lon', 'nav_dt_s'])
for row in output_rows:
writer.writerow([
row['Timestamp'], row['lat'], row['lon'], row['Heading'],
row['Dist'], row['Azimuth'], row['Elev'], row['SNR'],
row['auv_lat'], row['auv_lon'], row['nav_dt_s']
])
print("Output: %s (%d rows)" % (args.output, len(output_rows)))
# Sample output
if output_rows:
print("\n=== Sample (first 5 rows) ===")
print("Timestamp,lat,lon,Heading,Dist,Azimuth,Elev,SNR,auv_lat,auv_lon")
for row in output_rows[:5]:
print("%s,%s,%s,%s,%s,%s,%s,%s,%s,%s" % (
row['Timestamp'], row['lat'], row['lon'], row['Heading'],
row['Dist'], row['Azimuth'], row['Elev'], row['SNR'],
row['auv_lat'], row['auv_lon']))
# AUV position stats
auv_lats = [float(r['auv_lat']) for r in output_rows if r['auv_lat']]
auv_lons = [float(r['auv_lon']) for r in output_rows if r['auv_lon']]
usv_lats = [float(r['lat']) for r in output_rows if r['lat']]
usv_lons = [float(r['lon']) for r in output_rows if r['lon']]
if auv_lats and usv_lats:
print("\n=== Position stats ===")
print("AUV lat range: %.6f - %.6f" % (min(auv_lats), max(auv_lats)))
print("AUV lon range: %.6f - %.6f" % (min(auv_lons), max(auv_lons)))
print("USV lat range: %.6f - %.6f" % (min(usv_lats), max(usv_lats)))
# Average USV-AUV distance
dists = []
for r in output_rows:
if r['lat'] and r['auv_lat']:
d = haversine_dist(float(r['lat']), float(r['lon']),
float(r['auv_lat']), float(r['auv_lon']))
dists.append(d)
if dists:
print("Avg USV-AUV dist: %.2f m (min=%.2f max=%.2f)" % (
sum(dists)/len(dists), min(dists), max(dists)))
if __name__ == '__main__':
main()

242
tools/parse_kogger_usbl.py Normal file
View File

@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""
parse_kogger_usbl.py — Decode Kogger USBL raw CSV files (SBP protocol)
Protocol spec:
Frame: BB 55 | ROUTE | MODE | ID | LENGTH | PAYLOAD[LENGTH] | CHKSUM1 | CHKSUM2
Checksum: Fletcher-16 over (ROUTE + MODE + ID + LENGTH + PAYLOAD)
Frame ID 0x65 = ID_USBL_SOLUTION
Struct (packed, little-endian):
id(U1) role(U1) watermark(U2)
timestamp_us(S8) ping_counter(U4) carrier_counter(S8)
distance_m(F4) distance_unc(F4)
azimuth_deg(F4) azimuth_unc(F4)
elevation_deg(F4) elevation_unc(F4)
snr(F4)
x_m(F4) y_m(F4) latitude_deg(D8) longitude_deg(D8) depth_m(F4)
usbl_yaw(F4) usbl_pitch(F4) usbl_roll(F4)
usbl_latitude(D8) usbl_longitude(D8) last_iTOW(U4)
beacon_n(F4) beacon_e(F4)
[+ 32 bytes extra NaN padding observed in firmware v2]
Timestamp assignment: timestamp from the last RECEIVED packet before the frame sync byte.
Usage:
python3 parse_kogger_usbl.py FILE1.csv [FILE2.csv ...] -o combined_usbl.csv
"""
import ast
import csv
import io
import os
import struct
import sys
import collections
import math
SYNC = b"\xbb\x55"
ID_USBL_SOLUTION = 0x65
USBL_FMT = '<BBHqIq' + 'f'*7 + 'ff' + 'dd' + 'f' + 'fff' + 'dd' + 'I' + 'ff'
USBL_FMT_SIZE = struct.calcsize(USBL_FMT)
def parse_bytes_field(field):
"""Parse b'...' Python literal from CSV field."""
field = field.strip()
if not (field.startswith("b'") or field.startswith('b"')):
return b""
try:
result = ast.literal_eval(field)
if isinstance(result, str):
result = result.encode('latin-1')
return result
except Exception:
return b""
def fletcher16(data):
c1, c2 = 0, 0
for byte in data:
c1 = (c1 + byte) & 0xFF
c2 = (c2 + c1) & 0xFF
return c1, c2
def parse_usbl_csv(csv_file):
"""
Parse a raw USBL CSV file, reconstruct byte stream, decode SBP frames.
Returns list of dicts with decoded USBL_SOLUTION data.
"""
with open(csv_file) as f:
content = f.read()
buf = b""
ts_offsets = [] # (byte_offset_in_buf, timestamp_str)
reader = csv.reader(io.StringIO(content))
for row in reader:
if len(row) < 3:
continue
ts, direction, raw = row[0], row[1], row[2]
if direction != "RECEIVED":
continue
b = parse_bytes_field(raw)
if b:
off = len(buf)
buf += b
ts_offsets.append((off, ts))
# Find all BB55 sync positions
positions = []
i = 0
while True:
pos = buf.find(SYNC, i)
if pos == -1:
break
positions.append(pos)
i = pos + 1
frame_id_counter = collections.Counter()
usbl_records = []
valid_total = 0
for pos in positions:
if pos + 6 > len(buf):
continue
route = buf[pos+2]
mode = buf[pos+3]
frame_id = buf[pos+4]
length = buf[pos+5]
if pos + 6 + length + 2 > len(buf):
continue
payload = buf[pos+6:pos+6+length]
chk1_a = buf[pos+6+length]
chk2_a = buf[pos+6+length+1]
c1, c2 = fletcher16(buf[pos+2:pos+6+length])
if c1 != chk1_a or c2 != chk2_a:
continue
valid_total += 1
frame_id_counter[frame_id] += 1
if frame_id != ID_USBL_SOLUTION:
continue
# Get timestamp: last ts_offset entry before this position
ts = ts_offsets[0][1] if ts_offsets else ""
for off, t in ts_offsets:
if off <= pos:
ts = t
else:
break
if len(payload) < USBL_FMT_SIZE:
continue
fields = struct.unpack_from(USBL_FMT, payload)
rec = {
'Timestamp': ts,
'usbl_id': fields[0],
'usbl_role': fields[1],
'usbl_timestamp_us': fields[3],
'ping_counter': fields[4],
'Dist': fields[6],
'dist_unc': fields[7],
'Azimuth': fields[8],
'azimuth_unc': fields[9],
'Elev': fields[10],
'elev_unc': fields[11],
'SNR': fields[12],
'x_m': fields[13],
'y_m': fields[14],
'usbl_lat_computed': fields[15],
'usbl_lon_computed': fields[16],
'depth_m': fields[17],
'usbl_yaw': fields[18],
'usbl_pitch': fields[19],
'usbl_roll': fields[20],
'source_file': os.path.basename(csv_file),
}
usbl_records.append(rec)
return usbl_records, frame_id_counter, valid_total, len(positions)
def main():
import argparse
parser = argparse.ArgumentParser(description='Decode Kogger USBL raw CSV files')
parser.add_argument('files', nargs='+', help='Input *_usbl.csv files')
parser.add_argument('-o', '--output', default='combined_usbl.csv', help='Output CSV')
args = parser.parse_args()
all_records = []
total_sync = 0
total_valid = 0
global_id_counter = collections.Counter()
for csv_file in args.files:
print("Processing: %s" % csv_file)
records, id_counter, valid, n_sync = parse_usbl_csv(csv_file)
all_records.extend(records)
total_sync += n_sync
total_valid += valid
global_id_counter.update(id_counter)
print(" Sync markers: %d Valid frames: %d USBL records: %d" % (n_sync, valid, len(records)))
print("\n=== Summary ===")
print("Total sync markers (BB55): %d" % total_sync)
print("Total valid frames: %d" % total_valid)
print("Total USBL_SOLUTION records: %d" % len(all_records))
print("\nFrame ID histogram:")
for fid, cnt in sorted(global_id_counter.items(), key=lambda x: -x[1]):
name = "USBL_SOLUTION" if fid == 0x65 else ("USBL_CONTROL" if fid == 0x68 else "UNKNOWN")
print(" ID=0x%02x(%3d) %-15s : %d frames" % (fid, fid, name, cnt))
if all_records:
dists = [r['Dist'] for r in all_records if not math.isnan(r['Dist'])]
azs = [r['Azimuth'] for r in all_records if not math.isnan(r['Azimuth'])]
snrs = [r['SNR'] for r in all_records if not math.isnan(r['SNR'])]
if dists:
dists_sorted = sorted(dists)
n = len(dists_sorted)
median = dists_sorted[n//2]
print("\nDist (m): min=%.2f median=%.2f max=%.2f" % (min(dists), median, max(dists)))
if azs:
print("Azimuth : min=%.2f max=%.2f" % (min(azs), max(azs)))
if snrs:
print("SNR : min=%.2f max=%.2f" % (min(snrs), max(snrs)))
# Write output CSV
with open(args.output, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['Timestamp', 'Dist', 'Azimuth', 'Elev', 'SNR', 'FrameID',
'x_m', 'y_m', 'depth_m', 'dist_unc', 'azimuth_unc', 'elev_unc',
'usbl_yaw', 'usbl_pitch', 'usbl_roll', 'source_file'])
for r in all_records:
writer.writerow([
r['Timestamp'],
'' if math.isnan(r['Dist']) else '%.4f' % r['Dist'],
'' if math.isnan(r['Azimuth']) else '%.4f' % r['Azimuth'],
'' if math.isnan(r['Elev']) else '%.4f' % r['Elev'],
'' if math.isnan(r['SNR']) else '%.4f' % r['SNR'],
'0x65',
'' if math.isnan(r['x_m']) else '%.4f' % r['x_m'],
'' if math.isnan(r['y_m']) else '%.4f' % r['y_m'],
'' if math.isnan(r['depth_m']) else '%.4f' % r['depth_m'],
'%.4f' % r['dist_unc'],
'%.4f' % r['azimuth_unc'],
'%.4f' % r['elev_unc'],
'%.4f' % r['usbl_yaw'],
'%.4f' % r['usbl_pitch'],
'%.4f' % r['usbl_roll'],
r['source_file'],
])
print("\nOutput: %s (%d records)" % (args.output, len(all_records)))
if __name__ == '__main__':
main()

View File

@@ -1,24 +1,39 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Parse USV long-format CSV → track.geojson + points.json""" """Parse USV long-format CSV → track.geojson + points.json + manifest.json
v2: multi-session support via --input-dir, retro-compat with --input (single file)
"""
import argparse import argparse
import csv import csv
import glob
import json import json
import os import os
import sys import sys
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone
MAX_SLIDER_POINTS = 5000 MAX_SLIDER_POINTS = 10000
def parse_args(): def parse_args():
p = argparse.ArgumentParser(description="Parse USV nav CSV") p = argparse.ArgumentParser(description="Parse USV nav CSV v2")
p.add_argument("--input", required=True, help="CSV navigation log") g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--input", help="Single CSV navigation log (v1 compat)")
g.add_argument("--input-dir", help="Directory: glob *navigation_log*.csv")
p.add_argument("--output", required=True, help="Output directory") p.add_argument("--output", required=True, help="Output directory")
return p.parse_args() return p.parse_args()
def find_csvs(input_dir):
pattern = os.path.join(input_dir, "*navigation_log*.csv")
files = sorted(glob.glob(pattern))
if not files:
print(f"No navigation_log CSVs found in {input_dir}", file=sys.stderr)
sys.exit(1)
return files
def load_csv(path): def load_csv(path):
"""Load long-format CSV into {timestamp: {field: value}}""" """Load long-format CSV {timestamp: {field: value}}"""
rows_by_ts = defaultdict(dict) rows_by_ts = defaultdict(dict)
with open(path, newline="", encoding="utf-8") as f: with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f) reader = csv.DictReader(f)
@@ -41,13 +56,26 @@ def get_float(d, *keys):
return None return None
def build_points(rows_by_ts): def ts_to_ms(ts_str):
"""Build sorted list of {t, lat, lon, heading} where lat/lon valid.""" """Convert ISO-like timestamp string to epoch ms (UTC)."""
# We need to track last known lat/lon/heading per timestamp cluster. # Try formats: '2026-03-24 09:04:07.123456' or '2026-03-24T09:04:07.123456'
# Strategy: walk timestamps in order, emit a point each time we see a Lat or Lon update. for fmt in (
# Accumulate state across timestamps. "%Y-%m-%dT%H:%M:%S.%f",
timestamps = sorted(rows_by_ts.keys()) "%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%d %H:%M:%S",
):
try:
dt = datetime.strptime(ts_str, fmt).replace(tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def build_points(rows_by_ts, source_name):
"""Build sorted list of {t, t_ms, lat, lon, heading, source}."""
timestamps = sorted(rows_by_ts.keys())
state = {} state = {}
points = [] points = []
@@ -55,7 +83,6 @@ def build_points(rows_by_ts):
updates = rows_by_ts[ts] updates = rows_by_ts[ts]
state.update(updates) state.update(updates)
# Only emit point if we have both Lat and Lon from this or earlier ts
lat = get_float(state, "Lat", "RAW_Lat") lat = get_float(state, "Lat", "RAW_Lat")
lon = get_float(state, "Lon", "RAW_Lon") lon = get_float(state, "Lon", "RAW_Lon")
heading = get_float(state, "Heading", "Yaw") heading = get_float(state, "Heading", "Yaw")
@@ -64,7 +91,6 @@ def build_points(rows_by_ts):
continue continue
if lat == 0.0 and lon == 0.0: if lat == 0.0 and lon == 0.0:
continue continue
# GPS_RAW_INT fallback (1e-7 degrees)
if abs(lat) < 1 and abs(lon) < 1: if abs(lat) < 1 and abs(lon) < 1:
raw_lat = get_float(state, "GPS_RAW_INT_lat") raw_lat = get_float(state, "GPS_RAW_INT_lat")
raw_lon = get_float(state, "GPS_RAW_INT_lon") raw_lon = get_float(state, "GPS_RAW_INT_lon")
@@ -74,87 +100,200 @@ def build_points(rows_by_ts):
else: else:
continue continue
# Only emit if Lat or Lon just updated (reduce duplicate consecutive points)
if "Lat" in updates or "Lon" in updates or "RAW_Lat" in updates or "RAW_Lon" in updates: if "Lat" in updates or "Lon" in updates or "RAW_Lat" in updates or "RAW_Lon" in updates:
t_ms = ts_to_ms(ts)
points.append({ points.append({
"t": ts, "t": ts,
"t_ms": t_ms,
"lat": round(lat, 8), "lat": round(lat, 8),
"lon": round(lon, 8), "lon": round(lon, 8),
"heading": round(heading, 2) if heading is not None else None, "heading": round(heading, 2) if heading is not None else None,
"source": source_name,
}) })
return points return points
def sample_points(points, max_n): def sample_points_session(points, max_total, n_sessions):
if len(points) <= max_n: """Sample per session, always keeping first+last point of each session."""
if not points:
return points return points
step = len(points) / max_n quota = max(10, max_total // max(n_sessions, 1))
return [points[int(i * step)] for i in range(max_n)] if len(points) <= quota:
return points
step = (len(points) - 2) / max(quota - 2, 1)
sampled = [points[0]]
for i in range(1, quota - 1):
sampled.append(points[min(int(1 + i * step), len(points) - 2)])
sampled.append(points[-1])
return sampled
def write_geojson(points, path): def session_bbox(points):
coords = [[p["lon"], p["lat"]] for p in points] lats = [p["lat"] for p in points]
geojson = { lons = [p["lon"] for p in points]
"type": "FeatureCollection", return [min(lons), min(lats), max(lons), max(lats)]
"features": [{
def write_outputs(all_sessions, output_dir):
"""Write track.geojson, points.json, manifest.json."""
os.makedirs(output_dir, exist_ok=True)
# Colors for multi-track
COLORS = ["#00b4d8", "#e94560", "#06d6a0", "#ffd166", "#a855f7", "#f97316"]
# ── track.geojson (MultiLineString per session) ──
features = []
for i, sess in enumerate(all_sessions):
coords = [[p["lon"], p["lat"]] for p in sess["points"]]
features.append({
"type": "Feature", "type": "Feature",
"geometry": {"type": "LineString", "coordinates": coords}, "geometry": {"type": "LineString", "coordinates": coords},
"properties": { "properties": {
"start": points[0]["t"] if points else None, "source_file": sess["source_file"],
"end": points[-1]["t"] if points else None, "source_name": sess["source_name"],
"n_points": len(points), "start_iso": sess["t_start"],
"end_iso": sess["t_end"],
"n_points": len(coords),
"color": COLORS[i % len(COLORS)],
"session_index": i,
} }
}] })
}
with open(path, "w") as f: geojson = {"type": "FeatureCollection", "features": features}
geo_path = os.path.join(output_dir, "track.geojson")
with open(geo_path, "w") as f:
json.dump(geojson, f) json.dump(geojson, f)
print(f" track.geojson: {len(coords)} coords → {path}") print(f" track.geojson: {len(features)} sessions → {geo_path}")
# ── points.json (all sampled, sorted by t_ms) ──
all_points = []
n_sessions = len(all_sessions)
for sess in all_sessions:
sampled = sample_points_session(sess["points"], MAX_SLIDER_POINTS, n_sessions)
all_points.extend(sampled)
# Sort by t_ms (sessions may overlap in time)
all_points.sort(key=lambda p: (p["t_ms"] or 0))
pts_path = os.path.join(output_dir, "points.json")
with open(pts_path, "w") as f:
json.dump(all_points, f)
print(f" points.json: {len(all_points)} points (sampled) → {pts_path}")
# ── manifest.json ──
all_lats = [p["lat"] for s in all_sessions for p in s["points"]]
all_lons = [p["lon"] for s in all_sessions for p in s["points"]]
global_bbox = [min(all_lons), min(all_lats), max(all_lons), max(all_lats)]
all_t_ms = [p["t_ms"] for s in all_sessions for p in s["points"] if p["t_ms"]]
t_min_ms = min(all_t_ms) if all_t_ms else None
t_max_ms = max(all_t_ms) if all_t_ms else None
sessions_meta = []
for sess in all_sessions:
sessions_meta.append({
"file": sess["source_file"],
"source_name": sess["source_name"],
"n_points": sess["n_points_raw"],
"t_start": sess["t_start"],
"t_end": sess["t_end"],
"t_start_ms": sess["t_start_ms"],
"t_end_ms": sess["t_end_ms"],
"bbox": sess["bbox"],
})
manifest = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"n_sessions": len(all_sessions),
"sessions": sessions_meta,
"global_bbox": global_bbox,
"t_min": all_sessions[0]["t_start"] if all_sessions else None,
"t_max": all_sessions[-1]["t_end"] if all_sessions else None,
"t_min_ms": t_min_ms,
"t_max_ms": t_max_ms,
"n_points_total_raw": sum(s["n_points_raw"] for s in all_sessions),
"n_points_sampled": len(all_points),
}
mf_path = os.path.join(output_dir, "manifest.json")
with open(mf_path, "w") as f:
json.dump(manifest, f, indent=2)
print(f" manifest.json → {mf_path}")
return manifest
def write_points_json(points, path): def print_global_stats(manifest, all_sessions):
with open(path, "w") as f: print(f"\n=== Stats globales ===")
json.dump(points, f) print(f" Sessions: {manifest['n_sessions']}")
print(f" points.json: {len(points)} points → {path}") print(f" Points bruts: {manifest['n_points_total_raw']}")
print(f" Points sampled: {manifest['n_points_sampled']}")
print(f" t_min: {manifest['t_min']}")
print(f" t_max: {manifest['t_max']}")
bb = manifest["global_bbox"]
print(f" Bbox: lon [{bb[0]:.5f}, {bb[2]:.5f}] lat [{bb[1]:.5f}, {bb[3]:.5f}]")
if manifest["t_min_ms"] and manifest["t_max_ms"]:
dur_s = (manifest["t_max_ms"] - manifest["t_min_ms"]) / 1000
h, rem = divmod(int(dur_s), 3600)
m, s = divmod(rem, 60)
print(f" Durée totale: {h}h{m:02d}m{s:02d}s")
for i, sess in enumerate(all_sessions):
print(f" Session {i+1}: {sess['source_name']} {sess['n_points_raw']} pts {sess['t_start']}{sess['t_end']}")
def print_stats(points): def process_file(path):
source_name = os.path.basename(path)
print(f"\nChargement {source_name} ...")
rows = load_csv(path)
print(f" {len(rows)} timestamps uniques")
points = build_points(rows, source_name)
if not points: if not points:
print("No valid points found!") print(f" WARNING: aucun point GPS valide dans {source_name}")
return return None
# Filter points without t_ms
points = [p for p in points if p["t_ms"] is not None]
lats = [p["lat"] for p in points] lats = [p["lat"] for p in points]
lons = [p["lon"] for p in points] lons = [p["lon"] for p in points]
print(f"\n=== Stats ===") return {
print(f" N points (full): {len(points)}") "source_file": path,
print(f" First ts: {points[0]['t']}") "source_name": source_name,
print(f" Last ts: {points[-1]['t']}") "points": points,
print(f" Bbox lat: {min(lats):.6f}{max(lats):.6f}") "n_points_raw": len(points),
print(f" Bbox lon: {min(lons):.6f}{max(lons):.6f}") "t_start": points[0]["t"],
headings = [p["heading"] for p in points if p["heading"] is not None] "t_end": points[-1]["t"],
print(f" Heading data: {'yes' if headings else 'no'} ({len(headings)} values)") "t_start_ms": points[0]["t_ms"],
"t_end_ms": points[-1]["t_ms"],
"bbox": [min(lons), min(lats), max(lons), max(lats)],
}
def main(): def main():
args = parse_args() args = parse_args()
os.makedirs(args.output, exist_ok=True)
print(f"Loading {args.input} ...") if args.input:
rows = load_csv(args.input) csv_files = [args.input]
print(f" {len(rows)} unique timestamps") else:
csv_files = find_csvs(args.input_dir)
points = build_points(rows) print(f"Fichiers trouvés: {len(csv_files)}")
print_stats(points) for f in csv_files:
print(f" {os.path.basename(f)}")
if not points: all_sessions = []
for path in csv_files:
sess = process_file(path)
if sess:
all_sessions.append(sess)
if not all_sessions:
print("Aucune session valide.", file=sys.stderr)
sys.exit(1) sys.exit(1)
write_geojson(points, os.path.join(args.output, "track.geojson")) manifest = write_outputs(all_sessions, args.output)
print_global_stats(manifest, all_sessions)
sampled = sample_points(points, MAX_SLIDER_POINTS)
if len(sampled) < len(points):
print(f" Sampled {len(sampled)} points for slider (from {len(points)})")
write_points_json(sampled, os.path.join(args.output, "points.json"))
print("\nDone.") print("\nDone.")

125
tools/usbl_to_json.py Normal file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""usbl_to_json.py - Convert combined_nav_usbl.csv to usbl.json + auv_track.geojson"""
import csv, json, math, argparse, statistics
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_INPUT = ROOT / "output" / "combined_nav_usbl.csv"
OUTPUT_DIR = ROOT / "output"
def parse_ts(s):
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
try:
dt = datetime.strptime(s.strip(), fmt).replace(tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
except ValueError:
pass
return None
def haversine_m(lat1, lon1, lat2, lon2):
R = 6371000.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--input", default=str(DEFAULT_INPUT))
ap.add_argument("--max-points", type=int, default=10000)
args = ap.parse_args()
rows = []
with open(args.input, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
try:
dist = float(row["Dist"])
auv_lat = float(row["auv_lat"])
auv_lon = float(row["auv_lon"])
except (ValueError, KeyError):
continue
if dist <= 0 or auv_lat == 0 or auv_lon == 0:
continue
t_ms = parse_ts(row["Timestamp"])
if t_ms is None:
continue
rows.append({
"t": row["Timestamp"].strip(),
"t_ms": t_ms,
"usv_lat": float(row["lat"]),
"usv_lon": float(row["lon"]),
"heading": float(row["Heading"]),
"dist": dist,
"az": float(row["Azimuth"]),
"elev": float(row["Elev"]),
"snr": float(row["SNR"]),
"auv_lat": auv_lat,
"auv_lon": auv_lon,
})
rows.sort(key=lambda r: r["t_ms"])
n_raw = len(rows)
# Sample if > max-points (preserve begin/end)
if n_raw > args.max_points:
step = n_raw / args.max_points
indices = set()
indices.add(0)
indices.add(n_raw - 1)
for i in range(1, args.max_points - 1):
indices.add(int(i * step))
rows = [rows[i] for i in sorted(indices)]
n = len(rows)
dists = [r["dist"] for r in rows]
snrs = [r["snr"] for r in rows]
auv_lats = [r["auv_lat"] for r in rows]
auv_lons = [r["auv_lon"] for r in rows]
auv_bbox = [min(auv_lons), min(auv_lats), max(auv_lons), max(auv_lats)]
out = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"n_points": n,
"n_raw": n_raw,
"auv_bbox": auv_bbox,
"stats": {
"dist_min": round(min(dists), 3),
"dist_max": round(max(dists), 3),
"dist_median": round(statistics.median(dists), 3),
"snr_min": round(min(snrs), 4),
"snr_max": round(max(snrs), 4),
},
"points": rows,
}
usbl_out = OUTPUT_DIR / "usbl.json"
with open(usbl_out, "w") as f:
json.dump(out, f, separators=(",", ":"))
print(f"usbl.json: {n} points (raw={n_raw}) -> {usbl_out}")
# AUV track GeoJSON
coords = [[r["auv_lon"], r["auv_lat"]] for r in rows]
geojson = {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"geometry": {"type": "LineString", "coordinates": coords},
"properties": {"name": "AUV track (USBL projection)", "color": "#ff8800"},
}]
}
track_out = OUTPUT_DIR / "auv_track.geojson"
with open(track_out, "w") as f:
json.dump(geojson, f, separators=(",", ":"))
print(f"auv_track.geojson: {len(coords)} coords -> {track_out}")
# Sanity check: first point haversine vs USBL dist
r0 = rows[0]
hav = haversine_m(r0["usv_lat"], r0["usv_lon"], r0["auv_lat"], r0["auv_lon"])
print(f"Sanity [0]: USV=({r0['usv_lat']:.6f},{r0['usv_lon']:.6f}) AUV=({r0['auv_lat']:.6f},{r0['auv_lon']:.6f}) hav={hav:.2f}m USBL_dist={r0['dist']:.2f}m diff={abs(hav-r0['dist']):.3f}m")
if __name__ == "__main__":
main()

Binary file not shown.

Binary file not shown.

51
vendor/Kogger-Protocol/README.md vendored Normal file
View File

@@ -0,0 +1,51 @@
# Open Serial Binary Protocol (SBP) specification
## Protocol frame structure
SYNC1 | SYNC2 | ROUTE | MODE | ID | LENGTH | PAYLOAD | CHECK1 | CHECK2|
|----------|----------|----------|----------|----------|----------|----------|----------|----------|
U1 | U1 | U1 | U1 | U1 | U1 | BYTE[LENGTH] | U1 | U1 |
0xBB | 0x55 | BITFIELD | BITFIELD | 1 … 255 | 0 … 128 | BYTEARRAY | 0 … 0xFF | 0 … 0xFF |
### ROUTE
Name | Bits | Description
|----------|----------|----------|
DEV_ADDRESS | 0:3 bit | Device address. Default and broadcast address is 0x0.
RESERVED | 2 bit | Reserved
### MODE
Name | Bits | Description
|----------|----------|----------|
TYPE | 0:1 bit | 0 — Reserved, 1 — CONTENT: DEVICE → HOST, 2 — SETTING: HOST → DEVICE, 3 —GETTING: HOST → DEVICE
RESERVED | 2 bit | Reserved
VERSION | 3:5 bit | Field defines the payload data version
MARK | 6 bit | Once device is switched on, this flag is always in reset state (ZERO). It can be set to active state (ONE) by the host (see the CMD_MARK command) and the slave device keeps the flag in active state in every frame until hardware reset occurs or is reset by the host. Therefore the host monitors the device's actual settings.
RESPONSE | 7 bit | HOST → DEVICE: Set the flag to active state (ONE) in order to get the result of processing the command. The flag doesn't affect the response if one is provided by the TYPE field. DEVICE → HOST: The flag is in reset state (ZERO) by default. Payload goes according to the command specification. If flag is set, the payload contains the result of command processing (see CMD_RESP command).
## Checksum
The checksum algorithm used is the Fletcher-16.
Example source code for calculating the checksum:
```
uint8_t CHECK1 = 0;
uint8_t CHECK2 = 0;
void CheckSumUpdate(uint8_t byte) {
CHECK1 += byte;
CHECK2 += CHECK1;
}
```
## Number Formats
- Multi-byte values are ordered in Little Endian format
- Floating point values are transmitted in IEEE754 single or double precision
- bit-field in LSB format
Name | Type | Size (Bytes) | Range
|----------|----------|----------|----------|
S1 | int8_t | 1 | -128 ... 127
U1 | uint8_t | 1 | 0 … 255
S2 | int16_t | 2 | -32768 … 32767
U2 | uint16_t | 2 | 0 … 65535
S4 | int32_t | 4 | -2'147'483'648 ... 2'147'483'647
U4 | uint32_t | 4 | 0 … 4'294'967'295
F4 | float | 4 | -1*2^+127 ... 2^+127
D8 | double | 8 | -1*2^+1023 ... 2^+1023

View File

@@ -2,141 +2,616 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>COSMA — USV Track Viewer</title> <title>COSMA — NAV Viewer v5</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; display: flex; flex-direction: column; height: 100vh; } html, body {
#map { flex: 1; } height: 100vh; overflow: hidden;
#controls { font-family: monospace; background: #1a1a2e; color: #e0e0e0;
background: #16213e; display: grid;
padding: 8px 12px; grid-template-rows: 36px 40px 1fr 54px 1fr;
display: flex; }
align-items: center; /* Row 0: datebar */
gap: 12px; #datebar {
border-top: 1px solid #0f3460; background: #0d0d20; border-bottom: 1px solid #0f3460;
display: flex; align-items: center; gap: 8px; padding: 0 14px;
flex-shrink: 0; height: 36px; overflow: hidden;
}
#date-picker {
background: #0f3460; border: 1px solid #00b4d8; color: #e0e0e0;
font-family: monospace; font-size: 11px; padding: 2px 6px; border-radius: 2px;
cursor: pointer;
}
#date-picker::-webkit-calendar-picker-indicator { filter: invert(0.8); cursor: pointer; }
#btn-today {
background: #0f3460; border: 1px solid #00b4d8; color: #00b4d8;
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px;
}
#btn-today:hover { background: #00b4d8; color: #1a1a2e; }
#mission-label {
font-size: 11px; font-family: monospace; padding: 2px 8px;
}
#mission-label.has-data { color: #06d6a0; }
#mission-label.no-data { color: #555; }
/* Row 1: header */
#header {
background: #12122a; border-bottom: 1px solid #0f3460;
display: flex; align-items: center; gap: 12px; padding: 0 14px;
flex-shrink: 0; height: 40px; overflow: hidden;
} }
#slider { flex: 1; cursor: pointer; }
#info { font-size: 11px; min-width: 340px; color: #a0c4ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#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; }
#layer-toggles { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
.layer-btn {
font-family: monospace; font-size: 10px; padding: 2px 7px; cursor: pointer;
border-radius: 2px; border: 1px solid; background: transparent;
}
.layer-btn.active { opacity: 1; }
.layer-btn.inactive { opacity: 0.35; }
#btn-usv { color: #00b4d8; border-color: #00b4d8; }
#btn-auv { color: #ff8800; border-color: #ff8800; }
#btn-vec { color: #888; border-color: #888; }
#btn-usbl-panel { color: #aaa; border-color: #444; }
/* Row 2: map */
#map { position: relative; min-height: 0; }
/* USBL panel over map */
#usbl-panel {
position: absolute; top: 10px; left: 50px; z-index: 1000;
background: rgba(22,33,62,0.92); padding: 8px 12px; border: 1px solid #0f3460;
font-size: 11px; font-family: monospace; min-width: 180px; display: none;
}
#usbl-panel .uprow { display: flex; justify-content: space-between; gap: 16px; margin: 1px 0; }
#usbl-panel .uplabel { color: #666; }
#usbl-panel .upval { color: #ff8800; font-weight: bold; }
#usbl-panel .badge {
display: inline-block; background: #ff8800; color: #1a1a2e;
font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 2px; margin-top: 4px;
}
/* legend over map */
#legend {
position: absolute; bottom: 10px; left: 10px; z-index: 1000;
background: rgba(22,33,62,0.92); padding: 8px 10px; border: 1px solid #0f3460;
font-size: 11px; font-family: monospace;
}
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.legend-dot { width: 14px; height: 4px; border-radius: 2px; flex-shrink: 0; }
.legend-dashed { border-top: 2px dashed #888; width: 14px; flex-shrink: 0; }
/* Row 3: controls */
#controls {
background: #16213e; border-top: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
padding: 4px 14px; display: flex; flex-direction: column; gap: 4px;
flex-shrink: 0;
}
#ctrl-row1, #ctrl-row2 { display: flex; align-items: center; gap: 10px; }
#ctrl-label { font-size: 10px; color: #666; white-space: nowrap; }
#cursor-slider-wrap { flex: 1; padding: 4px 0; }
.noUi-target { background: #0f3460; border: none; border-radius: 2px; box-shadow: none; }
.noUi-horizontal { height: 6px; }
.noUi-connect { background: #e94560; }
.noUi-handle {
background: #e94560; border: 2px solid #fff;
border-radius: 50%; width: 14px !important; height: 14px !important;
top: -5px !important; right: -7px !important;
box-shadow: none; cursor: pointer;
}
.noUi-handle::before, .noUi-handle::after { display: none; }
#cursor-time { font-size: 11px; color: #e0e0e0; white-space: nowrap; min-width: 180px; }
#trail-select {
background: #0f3460; border: 1px solid #00b4d8; color: #00b4d8;
font-family: monospace; font-size: 11px; padding: 2px 6px; border-radius: 2px; cursor: pointer;
}
#btn-viewall, #btn-play {
background: #0f3460; border: 1px solid #00b4d8; color: #00b4d8;
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px;
}
#btn-viewall:hover, #btn-play:hover { background: #00b4d8; color: #1a1a2e; }
#ctrl-labels { display: flex; align-items: center; gap: 6px; font-size: 10px; color: #666; }
label[for="trail-select"] { font-size: 10px; color: #666; }
/* Row 4: 2×2 charts grid */
#graphs-section {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 3px;
background: #0a0a1a;
padding: 3px;
min-height: 0; overflow: hidden;
}
.chart-wrap {
background: #12122a;
border: 1px solid #0f3460;
display: flex; flex-direction: column;
padding: 4px 8px 3px;
min-height: 0;
overflow: hidden;
}
.chart-title { font-size: 10px; color: #a0c4ff; margin-bottom: 2px; flex-shrink: 0; }
.chart-wrap canvas { flex: 1; min-height: 0; display: block; }
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3/dist/chartjs-plugin-annotation.min.js"></script>
</head> </head>
<body> <body>
<div id="map"></div>
<!-- Row 0: Datebar -->
<div id="datebar">
<input type="date" id="date-picker" list="available-dates">
<datalist id="available-dates"></datalist>
<button id="btn-today" onclick="datePickerToday()">Aujourd'hui</button>
<span id="mission-label" class="no-data">Chargement...</span>
</div>
<!-- Row 1: Header -->
<div id="header">
<span id="title">COSMA NAV v5</span>
<span id="stats">Chargement...</span>
<div id="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>
<!-- Row 2: Map -->
<div id="map">
<div id="legend"></div>
<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">Az (rel)</span><span class="upval" id="up-az"></span></div>
<div class="uprow"><span class="uplabel">Elev</span><span class="upval" id="up-elev"></span></div>
<div class="uprow"><span class="uplabel">SNR</span><span class="upval" id="up-snr"></span></div>
<div><span class="badge">REL HEADING</span></div>
</div>
</div>
<!-- Row 3: Controls -->
<div id="controls"> <div id="controls">
<span id="title">USV Track</span> <div id="ctrl-row1">
<input type="range" id="slider" min="0" value="0"> <span id="ctrl-label">t</span>
<div id="info">Chargement…</div> <div id="cursor-slider-wrap"><div id="cursor-slider"></div></div>
<span id="cursor-time"></span>
<label for="trail-select">trail</label>
<select id="trail-select">
<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="3600000">1h</option>
<option value="0">ALL</option>
</select>
<button id="btn-viewall" onclick="viewAll()">View all</button>
<button id="btn-play"></button>
</div>
<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>
</div>
</div>
<!-- Row 4: 2×2 Charts -->
<div id="graphs-section">
<div class="chart-wrap"><div class="chart-title">Profondeur AUV (m)</div><canvas id="chart-depth"></canvas></div>
<div class="chart-wrap"><div class="chart-title">PWM AUV (canaux)</div><canvas id="chart-pwm-auv"></canvas></div>
<div class="chart-wrap"><div class="chart-title">PWM USV (M1-M8)</div><canvas id="chart-pwm-usv"></canvas></div>
<div class="chart-wrap"><div class="chart-title">USBL Distance (m)</div><canvas id="chart-usbl"></canvas></div>
</div> </div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.css"/>
<script> <script>
// ── Map init ────────────────────────────────────────────────────────────── // == Constants ==
const map = L.map('map', { zoomControl: true }); const COLORS = ['#00b4d8','#e94560','#06d6a0','#ffd166','#a855f7','#f97316'];
const AUV_COLOR = '#ff8800';
const PWM_COLORS_AUV = ['#ff8800','#ffd166','#06d6a0','#00b4d8','#a855f7','#f97316','#e94560','#88c0d0'];
const PWM_COLORS_USV = ['#00b4d8','#0096b4','#0077a0','#005f8c','#ffd166','#e6b800','#c49a00','#a07d00'];
// == Map init ==
const map = L.map('map', { zoomControl: true });
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors', attribution: '(c) OpenStreetMap contributors', maxZoom: 19
maxZoom: 19,
}); });
osm.addTo(map); osm.addTo(map);
const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', { const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
attribution: '© OpenSeaMap', attribution: '(c) OpenSeaMap', maxZoom: 18, opacity: 0.8
maxZoom: 18,
opacity: 0.8,
}); });
// GEBCO bathymetry (tile-based, simpler than WMS)
const gebco = L.tileLayer( const gebco = L.tileLayer(
'https://tiles.arcgis.com/tiles/C8EMgrsFcRFL6LrL/arcgis/rest/services/GEBCO_basemap_NCEI/MapServer/tile/{z}/{y}/{x}', 'https://tiles.arcgis.com/tiles/C8EMgrsFcRFL6LrL/arcgis/rest/services/GEBCO_basemap_NCEI/MapServer/tile/{z}/{y}/{x}',
{ { attribution: 'GEBCO / NCEI', maxZoom: 13, opacity: 0.6 }
attribution: 'GEBCO / NCEI',
maxZoom: 13,
opacity: 0.6,
}
); );
L.control.layers( L.control.layers(
{ 'OpenStreetMap': osm }, { 'OpenStreetMap': osm },
{ 'OpenSeaMap (balises)': seamarks, 'GEBCO (bathymétrie)': gebco }, { 'OpenSeaMap (balises)': seamarks, 'GEBCO (bathymetrie)': gebco },
{ collapsed: false } { collapsed: false }
).addTo(map); ).addTo(map);
// ── SVG arrow marker factory ────────────────────────────────────────────── // == Helpers ==
function makeArrowIcon(heading) { function makeArrowIcon(heading, color) {
color = color || '#e94560';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="-16 -16 32 32"> const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="-16 -16 32 32">
<g transform="rotate(${heading || 0})"> <g transform="rotate(${heading||0})"><polygon points="0,-12 6,8 0,4 -6,8" fill="${color}" stroke="#fff" stroke-width="1.5"/></g></svg>`;
<polygon points="0,-12 6,8 0,4 -6,8" fill="#e94560" stroke="#fff" stroke-width="1.5"/> return L.divIcon({ html: svg, className: '', iconSize: [32,32], iconAnchor: [16,16] });
</g> }
</svg>`; function makeAuvIcon() {
return L.divIcon({ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-10 -10 20 20">
html: svg, <circle r="7" fill="${AUV_COLOR}" stroke="#fff" stroke-width="2"/><circle r="2" fill="#fff"/></svg>`;
className: '', return L.divIcon({ html: svg, className: '', iconSize: [20,20], iconAnchor: [10,10] });
iconSize: [32, 32], }
iconAnchor: [16, 16], function bisectLeft(arr, val) {
let lo = 0, hi = arr.length;
while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid].t_ms < val) lo = mid+1; else hi = mid; }
return lo;
}
function findNearest(arr, tms) {
if (!arr.length) return null;
let i = bisectLeft(arr, tms);
if (i === 0) return arr[0];
if (i >= arr.length) return arr[arr.length-1];
return (arr[i].t_ms - tms) < (tms - arr[i-1].t_ms) ? arr[i] : arr[i-1];
}
function filterWindow(arr, t0, t1) {
const lo = bisectLeft(arr, t0);
const res = [];
for (let i = lo; i < arr.length && arr[i].t_ms <= t1; i++) res.push(arr[i]);
return res;
}
function fmtMs(ms) {
if (!ms) return '—';
return new Date(ms).toISOString().replace('T',' ').slice(0,19)+' UTC';
}
function fmtDur(ms) {
const s = Math.floor(ms/1000), h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sc = s%60;
return h>0 ? `${h}h${String(m).padStart(2,'0')}m` : `${m}m${String(sc).padStart(2,'0')}s`;
}
// == State ==
let allPoints=[], usblPoints=[], manifest=null, usblMeta=null;
let mcapSignals=null, usvPwm=null;
let tMin=0, tMax=0;
let tNow=0;
let trailMs=60000;
let charts={};
let trackLayers=[], auvTrackLayer=null, cursorMarker=null, auvMarker=null, usblVector=null;
const layerVis = { usv:true, auv:true, vec:true, panel:true };
let playTimer=null;
// == Layer toggles ==
function toggleLayer(name) {
layerVis[name] = !layerVis[name];
const btn = document.getElementById('btn-'+(name==='panel'?'usbl-panel':name));
btn.classList.toggle('active', layerVis[name]);
btn.classList.toggle('inactive', !layerVis[name]);
if (name==='usv') trackLayers.forEach(l => layerVis.usv ? map.addLayer(l) : map.removeLayer(l));
if (name==='auv') {
if (auvTrackLayer) layerVis.auv ? map.addLayer(auvTrackLayer) : map.removeLayer(auvTrackLayer);
if (auvMarker) layerVis.auv ? map.addLayer(auvMarker) : map.removeLayer(auvMarker);
}
if (name==='vec' && usblVector) layerVis.vec ? map.addLayer(usblVector) : map.removeLayer(usblVector);
if (name==='panel') document.getElementById('usbl-panel').style.display = layerVis.panel ? 'block' : 'none';
}
// == View All ==
function viewAll() {
document.getElementById('trail-select').value = '0';
trailMs = 0;
applyTrailAndCursor();
}
// == Charts ==
function makeChartOptions() {
return {
animation: false, parsing: false, responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false, labels: { color:'#a0c4ff', font:{size:9,family:'monospace'}, boxWidth:12 } },
annotation: { annotations: {} }
},
scales: {
x: { type:'linear', ticks:{ color:'#666', font:{size:9,family:'monospace'}, maxTicksLimit:6,
callback:(v)=>new Date(v).toISOString().substr(11,8) }, grid:{color:'#1a1a3e'} },
y: { ticks:{ color:'#666', font:{size:9,family:'monospace'}, maxTicksLimit:5 }, grid:{color:'#1a1a3e'} }
}
};
}
function initCharts() {
const mkDs = (lbl, col) => ({ label:lbl, data:[], borderColor:col, borderWidth:1.5, pointRadius:0, tension:0 });
charts.depth = new Chart(document.getElementById('chart-depth'), { type:'line', data:{datasets:[mkDs('depth','#06d6a0')]}, options:makeChartOptions() });
charts.pwmAuv = new Chart(document.getElementById('chart-pwm-auv'), { type:'line', data:{datasets:[]}, options:makeChartOptions() });
charts.pwmUsv = new Chart(document.getElementById('chart-pwm-usv'), { type:'line', data:{datasets:[]}, options:makeChartOptions() });
charts.usbl = new Chart(document.getElementById('chart-usbl'), { type:'line', data:{datasets:[mkDs('dist','#a855f7')]}, options:makeChartOptions() });
}
function populateCharts() {
if (mcapSignals) {
if (mcapSignals.depth) { charts.depth.data.datasets[0].data = mcapSignals.depth.map(p=>({x:p.t,y:p.v})); charts.depth.update('none'); }
if (mcapSignals.pwm_auv) {
const {channels,samples} = mcapSignals.pwm_auv;
charts.pwmAuv.data.datasets = channels.map((ch,i)=>({
label:'Ch'+ch, data:samples.map(s=>({x:s.t,y:s.v[i]})),
borderColor:PWM_COLORS_AUV[i%8], borderWidth:1, pointRadius:0, tension:0
}));
charts.pwmAuv.options.plugins.legend.display = channels.length>1;
charts.pwmAuv.update('none');
}
}
if (usvPwm && usvPwm.M) {
const keys = Object.keys(usvPwm.M).sort();
charts.pwmUsv.data.datasets = keys.map((k,i)=>({
label:k, data:usvPwm.M[k].map(p=>({x:p.t,y:p.v})),
borderColor:PWM_COLORS_USV[i%8], borderWidth:1, pointRadius:0, tension:0
}));
charts.pwmUsv.options.plugins.legend.display = keys.length>1;
charts.pwmUsv.update('none');
}
if (usblPoints && usblPoints.length) {
charts.usbl.data.datasets[0].data = usblPoints.map(p=>({x:p.t_ms,y:p.dist!==undefined?p.dist:p.distance}));
charts.usbl.update('none');
}
}
// == Apply trail + cursor ==
function applyTrailAndCursor() {
const trail = +document.getElementById('trail-select').value;
trailMs = trail;
const t0 = trailMs===0 ? tMin : Math.max(tMin, tNow - trailMs);
const t1 = tNow;
// Update chart x-window
const ann = { type:'line', xMin:tNow, xMax:tNow, borderColor:'#e94560', borderWidth:1.5, borderDash:[4,2] };
const chartT0 = trailMs===0 ? tMin : t0;
const chartT1 = trailMs===0 ? tMax : Math.max(t1, tMin+1);
for (const c of Object.values(charts)) {
c.options.scales.x.min = chartT0;
c.options.scales.x.max = chartT1;
c.options.plugins.annotation.annotations = { cursor: ann };
c.update('none');
}
// Trail track on map
const trailPtsUsv = filterWindow(allPoints, t0, t1);
const trailPtsUsbl = filterWindow(usblPoints, t0, t1);
// Rebuild USV trail layers
trackLayers.forEach(l => map.removeLayer(l));
trackLayers = [];
if (manifest) {
const groups = {};
trailPtsUsv.forEach(p => { if (!groups[p.source]) groups[p.source]=[]; groups[p.source].push(p); });
manifest.sessions.forEach((sess, i) => {
const pts = groups[sess.source_name]||[];
if (pts.length < 2) return;
const layer = L.polyline(pts.map(p=>[p.lat,p.lon]), { color:COLORS[i%COLORS.length], weight:2.5, opacity:0.85 });
trackLayers.push(layer);
if (layerVis.usv) layer.addTo(map);
}); });
} }
// ── Data loading ────────────────────────────────────────────────────────── // Rebuild AUV trail layer
let points = []; if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer=null; }
let trackLayer = null; if (trailPtsUsbl.length >= 2) {
let marker = null; auvTrackLayer = L.polyline(trailPtsUsbl.map(p=>[p.auv_lat,p.auv_lon]), { color:AUV_COLOR, weight:2.5, opacity:0.85 });
if (layerVis.auv) auvTrackLayer.addTo(map);
}
// USV cursor marker at tNow
const pUsv = findNearest(allPoints, tNow);
if (pUsv) {
const sessIdx = manifest ? manifest.sessions.findIndex(s=>s.source_name===pUsv.source) : 0;
const color = COLORS[Math.max(0,sessIdx)%COLORS.length];
if (!cursorMarker) {
cursorMarker = L.marker([pUsv.lat,pUsv.lon], { icon:makeArrowIcon(pUsv.heading||0,color), zIndexOffset:1000 }).addTo(map);
} else {
cursorMarker.setLatLng([pUsv.lat,pUsv.lon]);
cursorMarker.setIcon(makeArrowIcon(pUsv.heading||0,color));
}
document.getElementById('cursor-info').textContent =
`${pUsv.t} | ${pUsv.lat.toFixed(6)}, ${pUsv.lon.toFixed(6)} | cap: ${pUsv.heading!==null&&pUsv.heading!==undefined?pUsv.heading.toFixed(1):'N/A'} | ${pUsv.source}`;
}
// AUV + USBL at tNow
const pUsbl = findNearest(usblPoints, tNow);
if (pUsbl) {
if (layerVis.auv) {
if (!auvMarker) {
auvMarker = L.marker([pUsbl.auv_lat,pUsbl.auv_lon], { icon:makeAuvIcon(), zIndexOffset:900 }).addTo(map);
} else {
if (!map.hasLayer(auvMarker)) map.addLayer(auvMarker);
auvMarker.setLatLng([pUsbl.auv_lat,pUsbl.auv_lon]);
}
}
const usvPos = pUsv ? [pUsv.lat,pUsv.lon] : [pUsbl.usv_lat,pUsbl.usv_lon];
if (layerVis.vec) {
if (!usblVector) {
usblVector = L.polyline([usvPos,[pUsbl.auv_lat,pUsbl.auv_lon]], { color:'#888', weight:1.5, dashArray:'6,4', opacity:0.9 }).addTo(map);
} else {
if (!map.hasLayer(usblVector)) map.addLayer(usblVector);
usblVector.setLatLngs([usvPos,[pUsbl.auv_lat,pUsbl.auv_lon]]);
}
}
if (layerVis.panel) {
document.getElementById('usbl-panel').style.display='block';
document.getElementById('up-dist').textContent = `${pUsbl.dist.toFixed(2)} m`;
document.getElementById('up-az').textContent = `${pUsbl.az.toFixed(1)} deg`;
document.getElementById('up-elev').textContent = `${pUsbl.elev.toFixed(1)} deg`;
document.getElementById('up-snr').textContent = `${pUsbl.snr.toFixed(1)}`;
}
}
// stats
const dur = tNow - (trailMs===0?tMin:t0);
document.getElementById('stats').textContent =
`trail: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`;
}
// == Cursor slider ==
let cursorSlider = null;
function initCursorSlider() {
cursorSlider = noUiSlider.create(document.getElementById('cursor-slider'), {
start: [tMin],
range: { min: tMin, max: tMax },
step: 1000,
});
cursorSlider.on('update', (values) => {
tNow = Math.round(+values[0]);
document.getElementById('cursor-time').textContent = fmtMs(tNow);
applyTrailAndCursor();
});
// trail select
document.getElementById('trail-select').addEventListener('change', () => applyTrailAndCursor());
}
// == Play ==
document.getElementById('btn-play').addEventListener('click', () => {
if (playTimer) { clearInterval(playTimer); playTimer=null; document.getElementById('btn-play').textContent='▶'; return; }
document.getElementById('btn-play').textContent='❚❚';
playTimer = setInterval(() => {
if (tNow >= tMax) { clearInterval(playTimer); playTimer=null; document.getElementById('btn-play').textContent='▶'; return; }
tNow = Math.min(tNow + 5000, tMax);
if (cursorSlider) cursorSlider.set(tNow);
}, 100);
});
// == Legend ==
function buildLegend() {
let html = '';
if (manifest) {
manifest.sessions.forEach((s,i) => {
const name = s.source_name.replace('_navigation_log','').replace('.csv','');
html += `<div class="legend-item"><div class="legend-dot" style="background:${COLORS[i%COLORS.length]}"></div><span>USV ${name}</span></div>`;
});
}
html += `<div class="legend-item"><div class="legend-dot" style="background:${AUV_COLOR}"></div><span>AUV (USBL proj.)</span></div>`;
html += `<div class="legend-item"><div class="legend-dashed"></div><span>Vecteur USBL</span></div>`;
document.getElementById('legend').innerHTML = html;
}
// == Load ==
async function loadData() { async function loadData() {
try { try {
const [trackResp, pointsResp] = await Promise.all([ const [trackResp, pointsResp, manifestResp, usblResp] = await Promise.all([
fetch('track.geojson'), fetch('data/track.geojson'), fetch('data/points.json'),
fetch('points.json'), fetch('data/manifest.json'), fetch('data/usbl.json'),
]); ]);
if (!trackResp.ok) throw new Error('track.geojson not found'); if (!trackResp.ok) throw new Error('track.geojson not found');
if (!pointsResp.ok) throw new Error('points.json not found'); if (!pointsResp.ok) throw new Error('points.json not found');
if (!manifestResp.ok) throw new Error('manifest.json not found');
if (!usblResp.ok) throw new Error('usbl.json not found');
const trackGeo = await trackResp.json(); const trackGeo = await trackResp.json();
points = await pointsResp.json(); allPoints = await pointsResp.json();
manifest = await manifestResp.json();
usblMeta = await usblResp.json();
usblPoints = usblMeta.points.sort((a,b)=>a.t_ms-b.t_ms);
// Draw track // Static faded USV track background
trackLayer = L.geoJSON(trackGeo, { L.geoJSON(trackGeo, { style:(f)=>({ color:f.properties.color||'#00b4d8', weight:2, opacity:0.3 }) }).addTo(map);
style: { color: '#00b4d8', weight: 2, opacity: 0.8 },
}).addTo(map);
map.fitBounds(trackLayer.getBounds(), { padding: [40, 40] });
// Init slider if (allPoints.length > 0) {
const slider = document.getElementById('slider'); const lats=allPoints.map(p=>p.lat), lons=allPoints.map(p=>p.lon);
slider.max = points.length - 1; map.fitBounds([[Math.min(...lats),Math.min(...lons)],[Math.max(...lats),Math.max(...lons)]], {padding:[40,40]});
slider.value = 0;
slider.addEventListener('input', () => updateMarker(+slider.value));
// Init marker
if (points.length > 0) {
const p = points[0];
marker = L.marker([p.lat, p.lon], { icon: makeArrowIcon(p.heading || 0) }).addTo(map);
updateInfo(0);
} }
document.getElementById('title').textContent = `USV Track — ${points.length} pts`; tMin = manifest.t_min_ms || Math.min(...allPoints.map(p=>p.t_ms||Infinity));
tMax = manifest.t_max_ms || Math.max(...allPoints.map(p=>p.t_ms||-Infinity));
if (tMin===tMax) tMax=tMin+1000;
tNow = tMin;
document.getElementById('title').textContent =
`COSMA v5 — USV ${manifest.n_sessions} sess. ${manifest.n_points_sampled} pts | AUV ${usblMeta.n_points} pts`;
buildLegend();
initCursorSlider();
applyTrailAndCursor();
} catch(e) { } catch(e) {
document.getElementById('info').textContent = 'Erreur: ' + e.message; document.getElementById('stats').textContent = 'Erreur: '+e.message;
console.error(e);
} }
} }
function updateMarker(idx) { async function loadGraphData() {
const p = points[idx]; initCharts();
if (!p || !marker) return; try {
marker.setLatLng([p.lat, p.lon]); const [mcapResp, usvPwmResp] = await Promise.allSettled([
marker.setIcon(makeArrowIcon(p.heading || 0)); fetch('data/mcap_signals.json'), fetch('data/usv_pwm.json'),
updateInfo(idx); ]);
if (mcapResp.status==='fulfilled' && mcapResp.value.ok) mcapSignals = await mcapResp.value.json();
if (usvPwmResp.status==='fulfilled' && usvPwmResp.value.ok) usvPwm = await usvPwmResp.value.json();
populateCharts();
} catch(e) { console.warn('Graph data error:',e); }
} }
function updateInfo(idx) { // == Datebar ==
const p = points[idx]; let availableDates = [];
if (!p) return;
const hdg = p.heading !== null ? `${p.heading.toFixed(1)}°` : 'N/A'; async function initDatebar() {
document.getElementById('info').textContent = const label = document.getElementById('mission-label');
`[${idx+1}/${points.length}] ${p.t} | Lat: ${p.lat.toFixed(6)} Lon: ${p.lon.toFixed(6)} | Cap: ${hdg}`; const picker = document.getElementById('date-picker');
const dl = document.getElementById('available-dates');
try {
const resp = await fetch('http://192.168.0.83:8766/api/data-dates');
if (!resp.ok) throw new Error('data-dates HTTP ' + resp.status);
const data = await resp.json();
availableDates = data.dates || [];
if (!availableDates.length) {
label.textContent = 'Aucune donnée';
label.className = 'no-data';
return;
}
// Populate datalist
dl.innerHTML = '';
availableDates.forEach(d => {
const opt = document.createElement('option');
opt.value = d.date;
dl.appendChild(opt);
});
// Min/max
const dates = availableDates.map(d => d.date).sort();
picker.min = dates[0];
picker.max = dates[dates.length - 1];
// Default: last date
picker.value = dates[dates.length - 1];
updateMissionLabel(picker.value);
picker.addEventListener('change', () => updateMissionLabel(picker.value));
} catch(e) {
label.textContent = 'API indisponible';
label.className = 'no-data';
console.warn('datebar error:', e);
}
} }
function updateMissionLabel(date) {
const label = document.getElementById('mission-label');
const entry = availableDates.find(d => d.date === date);
if (!entry) {
label.textContent = 'Aucune donnée';
label.className = 'no-data';
return;
}
const mission = entry.missions && entry.missions.length ? entry.missions[0] : '?';
const n = entry.session_count || 0;
label.textContent = `${mission} (${n} session${n>1?'s':''})`;
label.className = 'has-data';
}
function datePickerToday() {
const today = new Date().toISOString().slice(0, 10);
const picker = document.getElementById('date-picker');
picker.value = today;
updateMissionLabel(today);
}
initDatebar();
loadData(); loadData();
loadGraphData();
</script> </script>
</body> </body>
</html> </html>