Files
cosma-nav-tools/tools/parse_kogger_usbl.py
Poulpe be2cd1d156 feat: Kogger USBL decoder + nav merge
- tools/parse_kogger_usbl.py: decode SBP protocol (ID=0x65 USBL_SOLUTION)
  from raw *_usbl.csv files, output combined_usbl.csv with Dist/Az/Elev/SNR
- tools/merge_nav_usbl.py: merge USBL data with navigation_log.csv,
  interpolate USV lat/lon/heading, compute AUV absolute position
  (azimuth relative to USV heading convention)
- vendor/Kogger-Protocol: SBP spec reference (submodule)
- 69-sttropez: 13986 USBL records decoded, avg USV-AUV dist 39m
2026-04-25 21:24:00 +00:00

243 lines
7.8 KiB
Python

#!/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()