Files
maglink-console/rtcm_detailed_parser.py
brentperteet 5703c05c1d Initial commit
2026-06-24 11:12:44 -05:00

402 lines
15 KiB
Python

"""
Enhanced RTCM3 parser with detailed field extraction and CSV export.
Supports parsing common RTCM message types with human-readable output.
"""
import csv
import math
from datetime import datetime
from typing import Any
class RTCMDetailedParser:
"""Enhanced RTCM parser that extracts detailed fields from messages."""
def __init__(self):
self.messages: list[dict[str, Any]] = []
self.message_count = 0
def parse_messages(self, data: bytes, timestamp: str | None = None) -> list[dict[str, Any]]:
"""Parse RTCM3 messages and extract detailed fields."""
messages = []
i = 0
while i < len(data):
if data[i] == 0xD3 and i + 2 < len(data):
length = ((data[i+1] & 0x03) << 8) | data[i+2]
msg_total_len = 3 + length + 3
if i + msg_total_len <= len(data) and length >= 3:
msg_type = (data[i+3] << 4) | (data[i+4] >> 4)
msg_data = data[i+3:i+3+length]
self.message_count += 1
msg_info: dict[str, Any] = {
"timestamp": timestamp or datetime.utcnow().isoformat(),
"message_number": self.message_count,
"message_type": msg_type,
"length_bytes": length,
}
# Parse specific message types
if msg_type == 1005:
msg_info.update(self._parse_1005(msg_data))
elif msg_type == 1006:
msg_info.update(self._parse_1006(msg_data))
elif msg_type == 1007:
msg_info.update(self._parse_1007(msg_data))
elif msg_type == 1008:
msg_info.update(self._parse_1008(msg_data))
elif msg_type == 1033:
msg_info.update(self._parse_1033(msg_data))
elif msg_type in [1074, 1084, 1094, 1124]:
msg_info.update(self._parse_msm4(msg_data, msg_type))
elif msg_type in [1077, 1087, 1097, 1127]:
msg_info.update(self._parse_msm7(msg_data, msg_type))
elif msg_type == 1019:
msg_info.update(self._parse_1019(msg_data))
elif msg_type == 1020:
msg_info.update(self._parse_1020(msg_data))
messages.append(msg_info)
self.messages.append(msg_info)
i += msg_total_len
continue
i += 1
return messages
def _parse_1005(self, data: bytes) -> dict[str, Any]:
"""Parse RTCM 1005: Stationary RTK Reference Station ARP."""
result = {"message_name": "Stationary RTK Reference Station ARP"}
try:
if len(data) < 19:
return result
station_id = ((data[1] & 0x0F) << 8) | data[2]
itrf_year = (data[3] >> 2) & 0x3F
gps_ind = (data[3] >> 1) & 0x01
glonass_ind = data[3] & 0x01
galileo_ind = (data[4] >> 7) & 0x01
ref_station_ind = (data[4] >> 6) & 0x01
# ECEF-X (38 bits signed)
ecef_x_raw = ((data[3] & 0x03) << 36) | (data[4] << 28) | (data[5] << 20) | (data[6] << 12) | (data[7] << 4) | (data[8] >> 4)
if ecef_x_raw & (1 << 37):
ecef_x_raw -= (1 << 38)
ecef_x = ecef_x_raw * 0.0001
# ECEF-Y (38 bits signed)
ecef_y_raw = ((data[8] & 0x0F) << 34) | (data[9] << 26) | (data[10] << 18) | (data[11] << 10) | (data[12] << 2) | (data[13] >> 6)
if ecef_y_raw & (1 << 37):
ecef_y_raw -= (1 << 38)
ecef_y = ecef_y_raw * 0.0001
# ECEF-Z (38 bits signed)
ecef_z_raw = ((data[13] & 0x3F) << 32) | (data[14] << 24) | (data[15] << 16) | (data[16] << 8) | data[17]
if ecef_z_raw & (1 << 37):
ecef_z_raw -= (1 << 38)
ecef_z = ecef_z_raw * 0.0001
lat, lon, alt = self._ecef_to_lla(ecef_x, ecef_y, ecef_z)
result.update({
"station_id": station_id,
"itrf_year": itrf_year + 1980 if itrf_year else None,
"gps_indicator": gps_ind,
"glonass_indicator": glonass_ind,
"galileo_indicator": galileo_ind,
"reference_station_indicator": ref_station_ind,
"ecef_x_m": ecef_x,
"ecef_y_m": ecef_y,
"ecef_z_m": ecef_z,
"latitude_deg": lat,
"longitude_deg": lon,
"altitude_m": alt,
})
except Exception as e:
result["parse_error"] = str(e)
return result
def _parse_1006(self, data: bytes) -> dict[str, Any]:
"""Parse RTCM 1006: Stationary RTK Reference Station ARP with Antenna Height."""
result = self._parse_1005(data)
result["message_name"] = "Stationary RTK Reference Station ARP + Antenna Height"
try:
if len(data) >= 21:
antenna_height_raw = (data[18] << 8) | data[19]
result["antenna_height_m"] = antenna_height_raw * 0.0001
except Exception as e:
result["parse_error"] = str(e)
return result
def _parse_1007(self, data: bytes) -> dict[str, Any]:
"""Parse RTCM 1007: Antenna Descriptor."""
result = {"message_name": "Antenna Descriptor"}
try:
if len(data) < 5:
return result
station_id = ((data[1] & 0x0F) << 8) | data[2]
descriptor_len = data[3]
if len(data) >= 4 + descriptor_len:
descriptor = data[4:4+descriptor_len].decode('ascii', errors='ignore')
result.update({
"station_id": station_id,
"antenna_descriptor": descriptor,
"descriptor_length": descriptor_len,
})
except Exception as e:
result["parse_error"] = str(e)
return result
def _parse_1008(self, data: bytes) -> dict[str, Any]:
"""Parse RTCM 1008: Antenna Descriptor & Serial Number."""
result = {"message_name": "Antenna Descriptor & Serial Number"}
try:
if len(data) < 5:
return result
station_id = ((data[1] & 0x0F) << 8) | data[2]
descriptor_len = data[3]
if len(data) >= 4 + descriptor_len:
descriptor = data[4:4+descriptor_len].decode('ascii', errors='ignore')
# Setup ID (8 bits)
setup_id_offset = 4 + descriptor_len
if len(data) > setup_id_offset:
setup_id = data[setup_id_offset]
# Serial number length and value
serial_len_offset = setup_id_offset + 1
if len(data) > serial_len_offset:
serial_len = data[serial_len_offset]
serial_offset = serial_len_offset + 1
if len(data) >= serial_offset + serial_len:
serial_number = data[serial_offset:serial_offset+serial_len].decode('ascii', errors='ignore')
result.update({
"station_id": station_id,
"antenna_descriptor": descriptor,
"antenna_setup_id": setup_id,
"antenna_serial_number": serial_number,
"descriptor_length": descriptor_len,
"serial_length": serial_len,
})
except Exception as e:
result["parse_error"] = str(e)
return result
def _parse_1033(self, data: bytes) -> dict[str, Any]:
"""Parse RTCM 1033: Receiver and Antenna Descriptors."""
result = {"message_name": "Receiver and Antenna Descriptors"}
try:
if len(data) < 6:
return result
station_id = ((data[1] & 0x0F) << 8) | data[2]
# Antenna descriptor
antenna_desc_len = data[3]
offset = 4
antenna_descriptor = ""
if len(data) >= offset + antenna_desc_len:
antenna_descriptor = data[offset:offset+antenna_desc_len].decode('ascii', errors='ignore')
offset += antenna_desc_len
# Antenna setup ID
antenna_setup_id = data[offset] if len(data) > offset else None
offset += 1
# Antenna serial number
antenna_serial_len = data[offset] if len(data) > offset else 0
offset += 1
antenna_serial = ""
if len(data) >= offset + antenna_serial_len:
antenna_serial = data[offset:offset+antenna_serial_len].decode('ascii', errors='ignore')
offset += antenna_serial_len
# Receiver descriptor
receiver_desc_len = data[offset] if len(data) > offset else 0
offset += 1
receiver_descriptor = ""
if len(data) >= offset + receiver_desc_len:
receiver_descriptor = data[offset:offset+receiver_desc_len].decode('ascii', errors='ignore')
offset += receiver_desc_len
# Receiver firmware
receiver_fw_len = data[offset] if len(data) > offset else 0
offset += 1
receiver_firmware = ""
if len(data) >= offset + receiver_fw_len:
receiver_firmware = data[offset:offset+receiver_fw_len].decode('ascii', errors='ignore')
offset += receiver_fw_len
# Receiver serial number
receiver_serial_len = data[offset] if len(data) > offset else 0
offset += 1
receiver_serial = ""
if len(data) >= offset + receiver_serial_len:
receiver_serial = data[offset:offset+receiver_serial_len].decode('ascii', errors='ignore')
result.update({
"station_id": station_id,
"antenna_descriptor": antenna_descriptor,
"antenna_setup_id": antenna_setup_id,
"antenna_serial_number": antenna_serial,
"receiver_descriptor": receiver_descriptor,
"receiver_firmware": receiver_firmware,
"receiver_serial_number": receiver_serial,
})
except Exception as e:
result["parse_error"] = str(e)
return result
def _parse_msm4(self, data: bytes, msg_type: int) -> dict[str, Any]:
"""Parse MSM4 messages (basic observation data)."""
constellation = {1074: "GPS", 1084: "GLONASS", 1094: "Galileo", 1124: "BeiDou"}
result = {
"message_name": f"{constellation.get(msg_type, 'Unknown')} MSM4 Observations",
"constellation": constellation.get(msg_type, "Unknown"),
}
try:
station_id = ((data[1] & 0x0F) << 8) | data[2]
epoch_time_raw = (data[3] << 22) | (data[4] << 14) | (data[5] << 6) | (data[6] >> 2)
epoch_time_ms = epoch_time_raw
result.update({
"station_id": station_id,
"epoch_time_ms": epoch_time_ms,
"multiple_message_bit": (data[6] >> 1) & 0x01,
})
except Exception as e:
result["parse_error"] = str(e)
return result
def _parse_msm7(self, data: bytes, msg_type: int) -> dict[str, Any]:
"""Parse MSM7 messages (full observation data)."""
constellation = {1077: "GPS", 1087: "GLONASS", 1097: "Galileo", 1127: "BeiDou"}
result = {
"message_name": f"{constellation.get(msg_type, 'Unknown')} MSM7 Observations",
"constellation": constellation.get(msg_type, "Unknown"),
}
try:
station_id = ((data[1] & 0x0F) << 8) | data[2]
epoch_time_raw = (data[3] << 22) | (data[4] << 14) | (data[5] << 6) | (data[6] >> 2)
epoch_time_ms = epoch_time_raw
result.update({
"station_id": station_id,
"epoch_time_ms": epoch_time_ms,
"multiple_message_bit": (data[6] >> 1) & 0x01,
})
except Exception as e:
result["parse_error"] = str(e)
return result
def _parse_1019(self, data: bytes) -> dict[str, Any]:
"""Parse RTCM 1019: GPS Ephemeris."""
result = {"message_name": "GPS Ephemeris"}
try:
if len(data) < 62:
return result
satellite_id = (data[1] & 0x0F) << 2 | (data[2] >> 6)
week_number = ((data[2] & 0x3F) << 4) | (data[3] >> 4)
result.update({
"satellite_id": satellite_id,
"gps_week_number": week_number,
})
except Exception as e:
result["parse_error"] = str(e)
return result
def _parse_1020(self, data: bytes) -> dict[str, Any]:
"""Parse RTCM 1020: GLONASS Ephemeris."""
result = {"message_name": "GLONASS Ephemeris"}
try:
if len(data) < 45:
return result
satellite_id = (data[1] & 0x0F) << 2 | (data[2] >> 6)
result.update({
"satellite_id": satellite_id,
})
except Exception as e:
result["parse_error"] = str(e)
return result
def _ecef_to_lla(self, x: float, y: float, z: float) -> tuple[float, float, float]:
"""Convert ECEF to latitude, longitude, altitude (WGS84)."""
a = 6378137.0
e2 = 6.69437999014e-3
lon = math.atan2(y, x) if (x != 0 or y != 0) else 0.0
p = math.sqrt(x * x + y * y)
lat = math.atan2(z, p * (1 - e2))
for _ in range(10):
N = a / math.sqrt(1 - e2 * math.sin(lat) ** 2)
alt = p / math.cos(lat) - N
lat_new = math.atan2(z, p * (1 - e2 * N / (N + alt)))
if abs(lat_new - lat) < 1e-12:
break
lat = lat_new
N = a / math.sqrt(1 - e2 * math.sin(lat) ** 2)
alt = p / math.cos(lat) - N if abs(math.cos(lat)) > 1e-10 else z / math.sin(lat) - N * (1 - e2)
return math.degrees(lat), math.degrees(lon), alt
def export_to_csv(self, filename: str, message_types: list[int] | None = None):
"""Export parsed messages to CSV file."""
if not self.messages:
return
# Filter by message type if specified
messages_to_export = self.messages
if message_types:
messages_to_export = [m for m in self.messages if m.get("message_type") in message_types]
if not messages_to_export:
return
# Collect all unique field names
fieldnames = set()
for msg in messages_to_export:
fieldnames.update(msg.keys())
# Sort fieldnames for consistent column order
fieldnames = sorted(fieldnames)
with open(filename, 'w', newline='', encoding='utf-8') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for msg in messages_to_export:
writer.writerow(msg)
print(f"Exported {len(messages_to_export)} messages to {filename}")