Compare commits
2 Commits
main
...
6f2f6d2d72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f2f6d2d72 | ||
|
|
ad6c197f5c |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,3 +2,8 @@ data/
|
|||||||
output/
|
output/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
data/
|
||||||
|
viewer/data/
|
||||||
|
viewer/screenshots/
|
||||||
|
screenshots/
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal 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
71
tools/check_sync.py
Normal 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
|
||||||
107
tools/extract_mcap_signals.py
Normal file
107
tools/extract_mcap_signals.py
Normal 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
89
tools/extract_usv_pwm.py
Normal 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
319
tools/merge_nav_usbl.py
Normal 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
242
tools/parse_kogger_usbl.py
Normal 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()
|
||||||
@@ -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
125
tools/usbl_to_json.py
Normal 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()
|
||||||
BIN
vendor/Kogger-Protocol/Kogger SB protocol.odt
vendored
Normal file
BIN
vendor/Kogger-Protocol/Kogger SB protocol.odt
vendored
Normal file
Binary file not shown.
BIN
vendor/Kogger-Protocol/Kogger SB protocol.pdf
vendored
Normal file
BIN
vendor/Kogger-Protocol/Kogger SB protocol.pdf
vendored
Normal file
Binary file not shown.
51
vendor/Kogger-Protocol/README.md
vendored
Normal file
51
vendor/Kogger-Protocol/README.md
vendored
Normal 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
|
||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data loading ──────────────────────────────────────────────────────────
|
// == State ==
|
||||||
let points = [];
|
let allPoints=[], usblPoints=[], manifest=null, usblMeta=null;
|
||||||
let trackLayer = null;
|
let mcapSignals=null, usvPwm=null;
|
||||||
let marker = 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;
|
||||||
|
|
||||||
async function loadData() {
|
// == Layer toggles ==
|
||||||
try {
|
function toggleLayer(name) {
|
||||||
const [trackResp, pointsResp] = await Promise.all([
|
layerVis[name] = !layerVis[name];
|
||||||
fetch('track.geojson'),
|
const btn = document.getElementById('btn-'+(name==='panel'?'usbl-panel':name));
|
||||||
fetch('points.json'),
|
btn.classList.toggle('active', layerVis[name]);
|
||||||
]);
|
btn.classList.toggle('inactive', !layerVis[name]);
|
||||||
if (!trackResp.ok) throw new Error('track.geojson not found');
|
if (name==='usv') trackLayers.forEach(l => layerVis.usv ? map.addLayer(l) : map.removeLayer(l));
|
||||||
if (!pointsResp.ok) throw new Error('points.json not found');
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
const trackGeo = await trackResp.json();
|
// == View All ==
|
||||||
points = await pointsResp.json();
|
function viewAll() {
|
||||||
|
document.getElementById('trail-select').value = '0';
|
||||||
|
trailMs = 0;
|
||||||
|
applyTrailAndCursor();
|
||||||
|
}
|
||||||
|
|
||||||
// Draw track
|
// == Charts ==
|
||||||
trackLayer = L.geoJSON(trackGeo, {
|
function makeChartOptions() {
|
||||||
style: { color: '#00b4d8', weight: 2, opacity: 0.8 },
|
return {
|
||||||
}).addTo(map);
|
animation: false, parsing: false, responsive: true, maintainAspectRatio: false,
|
||||||
map.fitBounds(trackLayer.getBounds(), { padding: [40, 40] });
|
plugins: {
|
||||||
|
legend: { display: false, labels: { color:'#a0c4ff', font:{size:9,family:'monospace'}, boxWidth:12 } },
|
||||||
// Init slider
|
annotation: { annotations: {} }
|
||||||
const slider = document.getElementById('slider');
|
},
|
||||||
slider.max = points.length - 1;
|
scales: {
|
||||||
slider.value = 0;
|
x: { type:'linear', ticks:{ color:'#666', font:{size:9,family:'monospace'}, maxTicksLimit:6,
|
||||||
slider.addEventListener('input', () => updateMarker(+slider.value));
|
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'} }
|
||||||
// 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`;
|
}
|
||||||
} catch (e) {
|
function initCharts() {
|
||||||
document.getElementById('info').textContent = 'Erreur: ' + e.message;
|
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMarker(idx) {
|
// == Apply trail + cursor ==
|
||||||
const p = points[idx];
|
function applyTrailAndCursor() {
|
||||||
if (!p || !marker) return;
|
const trail = +document.getElementById('trail-select').value;
|
||||||
marker.setLatLng([p.lat, p.lon]);
|
trailMs = trail;
|
||||||
marker.setIcon(makeArrowIcon(p.heading || 0));
|
const t0 = trailMs===0 ? tMin : Math.max(tMin, tNow - trailMs);
|
||||||
updateInfo(idx);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild AUV trail layer
|
||||||
|
if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer=null; }
|
||||||
|
if (trailPtsUsbl.length >= 2) {
|
||||||
|
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)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateInfo(idx) {
|
// == Cursor slider ==
|
||||||
const p = points[idx];
|
let cursorSlider = null;
|
||||||
if (!p) return;
|
function initCursorSlider() {
|
||||||
const hdg = p.heading !== null ? `${p.heading.toFixed(1)}°` : 'N/A';
|
cursorSlider = noUiSlider.create(document.getElementById('cursor-slider'), {
|
||||||
document.getElementById('info').textContent =
|
start: [tMin],
|
||||||
`[${idx+1}/${points.length}] ${p.t} | Lat: ${p.lat.toFixed(6)} Lon: ${p.lon.toFixed(6)} | Cap: ${hdg}`;
|
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() {
|
||||||
|
try {
|
||||||
|
const [trackResp, pointsResp, manifestResp, usblResp] = await Promise.all([
|
||||||
|
fetch('data/track.geojson'), fetch('data/points.json'),
|
||||||
|
fetch('data/manifest.json'), fetch('data/usbl.json'),
|
||||||
|
]);
|
||||||
|
if (!trackResp.ok) throw new Error('track.geojson 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();
|
||||||
|
allPoints = await pointsResp.json();
|
||||||
|
manifest = await manifestResp.json();
|
||||||
|
usblMeta = await usblResp.json();
|
||||||
|
usblPoints = usblMeta.points.sort((a,b)=>a.t_ms-b.t_ms);
|
||||||
|
|
||||||
|
// Static faded USV track background
|
||||||
|
L.geoJSON(trackGeo, { style:(f)=>({ color:f.properties.color||'#00b4d8', weight:2, opacity:0.3 }) }).addTo(map);
|
||||||
|
|
||||||
|
if (allPoints.length > 0) {
|
||||||
|
const lats=allPoints.map(p=>p.lat), lons=allPoints.map(p=>p.lon);
|
||||||
|
map.fitBounds([[Math.min(...lats),Math.min(...lons)],[Math.max(...lats),Math.max(...lons)]], {padding:[40,40]});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
document.getElementById('stats').textContent = 'Erreur: '+e.message;
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGraphData() {
|
||||||
|
initCharts();
|
||||||
|
try {
|
||||||
|
const [mcapResp, usvPwmResp] = await Promise.allSettled([
|
||||||
|
fetch('data/mcap_signals.json'), fetch('data/usv_pwm.json'),
|
||||||
|
]);
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Datebar ==
|
||||||
|
let availableDates = [];
|
||||||
|
|
||||||
|
async function initDatebar() {
|
||||||
|
const label = document.getElementById('mission-label');
|
||||||
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user