From 9d870805eedef20567ce2695ccb48f72ea20b0c5 Mon Sep 17 00:00:00 2001 From: Ben Hers Date: Tue, 21 Jul 2026 08:07:55 -0700 Subject: [PATCH 1/9] Made sure to drop secondary GNSSSignals messages for the plots. --- python/fusion_engine_client/analysis/analyzer.py | 8 ++++++++ python/fusion_engine_client/messages/defs.py | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 13c3e06f..2271bc1c 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1804,6 +1804,14 @@ def _get_gnss_signals_data(self): params = copy.deepcopy(self.params) params['return_numpy'] = False + # Some devices output GNSSSignalsMessage data for both the primary and secondary GNSS antennas. Exclude the + # secondary antenna's data so it is not plotted on top of the primary antenna's. Source ID 1 is a legacy + # secondary antenna identifier predating the SourceIdentifier reserved ranges, used by some older devices + # instead of SECONDARY_GNSS_ANTENNA (301). + secondary_source_ids = {1, SourceIdentifier.SECONDARY_GNSS_ANTENNA} + available_source_ids = self.reader.get_available_source_ids() + params['source_ids'] = available_source_ids - secondary_source_ids + result = self.reader.read(message_types=[GNSSSignalsMessage], **params) data = result[GNSSSignalsMessage.MESSAGE_TYPE] diff --git a/python/fusion_engine_client/messages/defs.py b/python/fusion_engine_client/messages/defs.py index 30e88b00..84ea71a9 100644 --- a/python/fusion_engine_client/messages/defs.py +++ b/python/fusion_engine_client/messages/defs.py @@ -225,6 +225,19 @@ def is_response(message_type: MessageType) -> bool: return message_type in RESPONSE_MESSAGES +class SourceIdentifier(IntEnum): + # 0 - 99 is reserved for pose solutions. + ## The location on the vehicle defined by the device's output lever arm setting. + OUTPUT_LEVER_ARM = 0 + # 100 - 199 is reserved for IMUs. + # 300 - 399 is reserved for GNSS receivers/antennae. + PRIMARY_GNSS_ANTENNA = 300 + SECONDARY_GNSS_ANTENNA = 301 + # 500 - 599 is reserved for external pose sources, such as an external SLAM or VIO/LIO. + ## Invalid source identifier. + INVALID = 0xFFFFFFFF + + class MessageHeader: INVALID_SOURCE_ID = 0xFFFFFFFF From a5f18adf83b8f06b99a6656dad78ec500ab74b55 Mon Sep 17 00:00:00 2001 From: Ben Hers Date: Tue, 21 Jul 2026 09:03:42 -0700 Subject: [PATCH 2/9] Added in plotting of the secondary antenna data. --- .../fusion_engine_client/analysis/analyzer.py | 110 ++++++++++++------ 1 file changed, 72 insertions(+), 38 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 2271bc1c..390e133a 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -25,8 +25,8 @@ from ..messages import * from .attitude import get_enu_rotation_matrix -from .data_loader import DataLoader, TimeRange from .reference import ReferenceData, _OWN_LOG_STATISTICS +from .data_loader import DataLoader, MessageData, TimeRange from ..parsers.file_index import HostTimeIndexMap from ..utils import trace as logging from ..utils.argument_parser import ArgumentParser, ExtendedBooleanAction, TriStateBooleanAction, CSVAction @@ -196,7 +196,7 @@ def __init__(self, self._mapbox_token_missing = False - self._gnss_signals_data = None + self._gnss_signals_data = {} if self.output_dir is not None: if not os.path.exists(self.output_dir): @@ -1198,17 +1198,19 @@ def _plot_data(name, selected_idx, flags, source_id, marker_style=None): self._add_figure(name="map", figure=figure, title="Vehicle Trajectory (Map)", config={'scrollZoom': True}) - def plot_gnss_skyplot(self, decimate=True): + def plot_gnss_skyplot(self, decimate=True, secondary: bool = False): + antenna = 'Secondary' if secondary else 'Primary' + # Read the GNSS signal data. - data = self._get_gnss_signals_data() + data = self._get_gnss_signals_data(secondary=secondary) if len(data.messages) == 0: - self.logger.info('No GNSS signal data available. Skipping sky plot.') + self.logger.info(f'No {antenna.lower()} GNSS signal data available. Skipping sky plot.') return have_gnss_signals_message = not data.using_legacy_satellite_message # Setup the figure. figure = go.Figure() - figure['layout'].update(title='GNSS Sky Plot') + figure['layout'].update(title=f'{antenna} GNSS Sky Plot') figure['layout']['polar']['radialaxis'].update(range=[90, 0]) figure['layout']['polar']['angularaxis'].update(visible=False) @@ -1332,13 +1334,19 @@ def plot_gnss_skyplot(self, decimate=True): figure['layout']['updatemenus'] = updatemenus - self._add_figure(name='gnss_skyplot', figure=figure, title='GNSS Sky Plot') + name = 'gnss_skyplot_secondary' if secondary else 'gnss_skyplot' + self._add_figure(name=name, figure=figure, title=f'{antenna} GNSS Sky Plot') + + def plot_gnss_skyplot_secondary(self, decimate=True): + self.plot_gnss_skyplot(decimate=decimate, secondary=True) + + def plot_gnss_cn0(self, secondary: bool = False): + antenna = 'Secondary' if secondary else 'Primary' - def plot_gnss_cn0(self): # Read the GNSS signal data. - data = self._get_gnss_signals_data() + data = self._get_gnss_signals_data(secondary=secondary) if len(data.messages) == 0: - self.logger.info('No GNSS signal data available. Skipping C/N0 plot.') + self.logger.info(f'No {antenna.lower()} GNSS signal data available. Skipping C/N0 plot.') return have_gnss_signals_message = not data.using_legacy_satellite_message @@ -1396,7 +1404,11 @@ def plot_gnss_cn0(self): 'yanchor': 'top' }] - self._add_figure(name='gnss_cn0', figure=figure, title='GNSS C/N0 vs. Time') + name = 'gnss_cn0_secondary' if secondary else 'gnss_cn0' + self._add_figure(name=name, figure=figure, title=f'{antenna} GNSS C/N0 vs Time') + + def plot_gnss_cn0_secondary(self): + self.plot_gnss_cn0(secondary=True) def plot_gnss_azimuth_elevation(self): """! @@ -1482,14 +1494,15 @@ def plot_gnss_azimuth_elevation(self): self._add_figure(name='gnss_azimuth_elevation', figure=figure, title='GNSS Azimuth & Elevation Vs. Time') - def plot_gnss_signal_status(self): - filename = 'gnss_signal_status' - figure_title = "GNSS Signal Status" + def plot_gnss_signal_status(self, secondary: bool = False): + antenna = 'Secondary' if secondary else 'Primary' + filename = 'gnss_signal_status_secondary' if secondary else 'gnss_signal_status' + figure_title = f'{antenna} GNSS Signal Status' # Read the GNSS signal data. - data = self._get_gnss_signals_data() + data = self._get_gnss_signals_data(secondary=secondary) if len(data.messages) == 0: - self.logger.info('No GNSS signal data available. Skipping signal status plot.') + self.logger.info(f'No {antenna.lower()} GNSS signal data available. Skipping signal status plot.') return have_gnss_signals_message = not data.using_legacy_satellite_message @@ -1795,39 +1808,57 @@ def _count_selected(selected_p1_times, return_nonzero_time=False): self._add_figure(name=filename, figure=figure, title=figure_title, inject_js=hover_js) - def _get_gnss_signals_data(self): + def plot_gnss_signal_status_secondary(self): + self.plot_gnss_signal_status(secondary=True) + + def _get_gnss_signals_data(self, secondary: bool = False): # If we already have data cached, return it. - if self._gnss_signals_data is not None: - return self._gnss_signals_data + if self._gnss_signals_data.get(secondary) is not None: + return self._gnss_signals_data[secondary] # See if we have GNSSSignalsMessages. If so, prefer those. params = copy.deepcopy(self.params) params['return_numpy'] = False - # Some devices output GNSSSignalsMessage data for both the primary and secondary GNSS antennas. Exclude the - # secondary antenna's data so it is not plotted on top of the primary antenna's. Source ID 1 is a legacy - # secondary antenna identifier predating the SourceIdentifier reserved ranges, used by some older devices - # instead of SECONDARY_GNSS_ANTENNA (301). + # Some devices output GNSSSignalsMessage data for both the primary and secondary GNSS antennas, distinguished + # by source ID. Source ID 1 is a legacy secondary antenna identifier predating the SourceIdentifier reserved + # ranges, used by some older devices instead of SECONDARY_GNSS_ANTENNA (301). secondary_source_ids = {1, SourceIdentifier.SECONDARY_GNSS_ANTENNA} available_source_ids = self.reader.get_available_source_ids() - params['source_ids'] = available_source_ids - secondary_source_ids - - result = self.reader.read(message_types=[GNSSSignalsMessage], **params) - data = result[GNSSSignalsMessage.MESSAGE_TYPE] + if secondary: + requested_source_ids = available_source_ids & secondary_source_ids + else: + requested_source_ids = available_source_ids - secondary_source_ids + params['source_ids'] = requested_source_ids + + # If there's no secondary antenna in this log, requested_source_ids will be empty for secondary=True (and, + # in the rare case a log has only secondary data, empty for secondary=False too). Passing an empty set to the + # reader doesn't filter to nothing — it disables the filter, so we'd get back the other antenna's data instead. + # To avoid that, skip the read and build an empty MessageData directly, so len(data.messages) == 0 and the + # existing skip logic below fires as expected. + if len(requested_source_ids) == 0: + data = MessageData(message_type=GNSSSignalsMessage.MESSAGE_TYPE, params=params) + else: + result = self.reader.read(message_types=[GNSSSignalsMessage], **params) + data = result[GNSSSignalsMessage.MESSAGE_TYPE] # We store the result now, even if there were no GNSSSignalsMessage messages. That way if we also don't have any # GNSSSatelliteMessage messages below, we'll have _something_ to return and we won't try to reload from disk on # each call to this function. - self._gnss_signals_data = data - self._gnss_signals_data.using_legacy_satellite_message = False + self._gnss_signals_data[secondary] = data + data.using_legacy_satellite_message = False # If we don't have any GNSSSignalsMessages, see if we have the legacy GNSSSatelliteMessage and fall back to - # that. - if len(data.messages) == 0: + # that. The legacy message predates dual-antenna support, so there's no secondary antenna data to fall back + # to. + if len(data.messages) == 0 and not secondary: # The legacy GNSSSatelliteMessage contains data per satellite, not per signal. The plotted C/N0 values will # reflect the L1 signal, unless L1 is not being tracked. - result = self.reader.read(message_types=[GNSSSatelliteMessage], **params) - data = result[GNSSSatelliteMessage.MESSAGE_TYPE] + if len(requested_source_ids) == 0: + data = MessageData(message_type=GNSSSatelliteMessage.MESSAGE_TYPE, params=params) + else: + result = self.reader.read(message_types=[GNSSSatelliteMessage], **params) + data = result[GNSSSatelliteMessage.MESSAGE_TYPE] # Convert to GNSSSignalsMessages. Some of the fields, like signal type and tracking/usage status, will be # approximated and may not be plotted. @@ -1840,18 +1871,18 @@ def _get_gnss_signals_data(self): data.messages = [m.to_gnss_signals_message() for m in data.messages] data.message_type = GNSSSignalsMessage.MESSAGE_TYPE data.message_class = GNSSSignalsMessage - self._gnss_signals_data = data - self._gnss_signals_data.using_legacy_satellite_message = True + self._gnss_signals_data[secondary] = data + data.using_legacy_satellite_message = True - self._gnss_signals_data.to_numpy() + self._gnss_signals_data[secondary].to_numpy() - return self._gnss_signals_data + return self._gnss_signals_data[secondary] def clear_gnss_signal_data_cache(self): """! @brief Clear cached GNSSSignalsMessage data to free memory when finished plotting. """ - self._gnss_signals_data = None + self._gnss_signals_data = {} def plot_dop(self): """! @@ -3507,8 +3538,11 @@ def main(args=None): analyzer.plot_pose_error(reference=reference_data) analyzer.plot_gnss_cn0() + analyzer.plot_gnss_cn0_secondary() analyzer.plot_gnss_signal_status() + analyzer.plot_gnss_signal_status_secondary() analyzer.plot_gnss_skyplot() + analyzer.plot_gnss_skyplot_secondary() analyzer.plot_gnss_azimuth_elevation() analyzer.clear_gnss_signal_data_cache() From 88ceb3e9a05aa2485305b6506af3cc35a45152ce Mon Sep 17 00:00:00 2001 From: Ben Hers Date: Tue, 21 Jul 2026 12:38:18 -0700 Subject: [PATCH 3/9] Got rid of the not secondary on the len(data.messages)==0 IF statement. --- python/fusion_engine_client/analysis/analyzer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 390e133a..4e2fdfee 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1849,9 +1849,9 @@ def _get_gnss_signals_data(self, secondary: bool = False): data.using_legacy_satellite_message = False # If we don't have any GNSSSignalsMessages, see if we have the legacy GNSSSatelliteMessage and fall back to - # that. The legacy message predates dual-antenna support, so there's no secondary antenna data to fall back - # to. - if len(data.messages) == 0 and not secondary: + # that. Currently, we can still emit the legacy message, and does so per-antenna just like + # GNSSSignalsMessage, so apply the same source ID filtering here. + if len(data.messages) == 0: # The legacy GNSSSatelliteMessage contains data per satellite, not per signal. The plotted C/N0 values will # reflect the L1 signal, unless L1 is not being tracked. if len(requested_source_ids) == 0: From d11ace4025edcc5dd3898f8f5baabce61cbaa8ca Mon Sep 17 00:00:00 2001 From: Ben Hers Date: Tue, 21 Jul 2026 13:36:52 -0700 Subject: [PATCH 4/9] Generalized the per source-id plotting. --- .../fusion_engine_client/analysis/analyzer.py | 116 +++++++++--------- 1 file changed, 61 insertions(+), 55 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 4e2fdfee..08aa3329 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1198,19 +1198,23 @@ def _plot_data(name, selected_idx, flags, source_id, marker_style=None): self._add_figure(name="map", figure=figure, title="Vehicle Trajectory (Map)", config={'scrollZoom': True}) - def plot_gnss_skyplot(self, decimate=True, secondary: bool = False): - antenna = 'Secondary' if secondary else 'Primary' + def plot_gnss_skyplot(self, decimate=True): + for source_id in sorted(self.source_ids): + self._plot_gnss_skyplot_for_source(source_id, decimate=decimate) + + def _plot_gnss_skyplot_for_source(self, source_id: int, decimate=True): + label = self._gnss_antenna_label(source_id) # Read the GNSS signal data. - data = self._get_gnss_signals_data(secondary=secondary) + data = self._get_gnss_signals_data(source_id) if len(data.messages) == 0: - self.logger.info(f'No {antenna.lower()} GNSS signal data available. Skipping sky plot.') + self.logger.info(f'No GNSS signal data available for source ID {source_id}. Skipping sky plot.') return have_gnss_signals_message = not data.using_legacy_satellite_message # Setup the figure. figure = go.Figure() - figure['layout'].update(title=f'{antenna} GNSS Sky Plot') + figure['layout'].update(title=f'{label} GNSS Sky Plot') figure['layout']['polar']['radialaxis'].update(range=[90, 0]) figure['layout']['polar']['angularaxis'].update(visible=False) @@ -1334,19 +1338,20 @@ def plot_gnss_skyplot(self, decimate=True, secondary: bool = False): figure['layout']['updatemenus'] = updatemenus - name = 'gnss_skyplot_secondary' if secondary else 'gnss_skyplot' - self._add_figure(name=name, figure=figure, title=f'{antenna} GNSS Sky Plot') + name = self._gnss_plot_filename('gnss_skyplot', source_id) + self._add_figure(name=name, figure=figure, title=f'{label} GNSS Sky Plot') - def plot_gnss_skyplot_secondary(self, decimate=True): - self.plot_gnss_skyplot(decimate=decimate, secondary=True) + def plot_gnss_cn0(self): + for source_id in sorted(self.source_ids): + self._plot_gnss_cn0_for_source(source_id) - def plot_gnss_cn0(self, secondary: bool = False): - antenna = 'Secondary' if secondary else 'Primary' + def _plot_gnss_cn0_for_source(self, source_id: int): + label = self._gnss_antenna_label(source_id) # Read the GNSS signal data. - data = self._get_gnss_signals_data(secondary=secondary) + data = self._get_gnss_signals_data(source_id) if len(data.messages) == 0: - self.logger.info(f'No {antenna.lower()} GNSS signal data available. Skipping C/N0 plot.') + self.logger.info(f'No GNSS signal data available for source ID {source_id}. Skipping C/N0 plot.') return have_gnss_signals_message = not data.using_legacy_satellite_message @@ -1404,18 +1409,15 @@ def plot_gnss_cn0(self, secondary: bool = False): 'yanchor': 'top' }] - name = 'gnss_cn0_secondary' if secondary else 'gnss_cn0' - self._add_figure(name=name, figure=figure, title=f'{antenna} GNSS C/N0 vs Time') - - def plot_gnss_cn0_secondary(self): - self.plot_gnss_cn0(secondary=True) + name = self._gnss_plot_filename('gnss_cn0', source_id) + self._add_figure(name=name, figure=figure, title=f'{label} GNSS C/N0 vs Time') def plot_gnss_azimuth_elevation(self): """! @brief Plot GNSS azimuth/elevation angles. """ # Read the GNSS signal data. - data = self._get_gnss_signals_data() + data = self._get_gnss_signals_data(self.default_source_id) if len(data.messages) == 0: self.logger.info('No GNSS signal data available. Skipping azimuth/elevation time series plot.') return @@ -1494,15 +1496,20 @@ def plot_gnss_azimuth_elevation(self): self._add_figure(name='gnss_azimuth_elevation', figure=figure, title='GNSS Azimuth & Elevation Vs. Time') - def plot_gnss_signal_status(self, secondary: bool = False): - antenna = 'Secondary' if secondary else 'Primary' - filename = 'gnss_signal_status_secondary' if secondary else 'gnss_signal_status' - figure_title = f'{antenna} GNSS Signal Status' + def plot_gnss_signal_status(self): + for source_id in sorted(self.source_ids): + self._plot_gnss_signal_status_for_source(source_id) + + def _plot_gnss_signal_status_for_source(self, source_id: int): + label = self._gnss_antenna_label(source_id) + filename = self._gnss_plot_filename('gnss_signal_status', source_id) + figure_title = f'{label} GNSS Signal Status' # Read the GNSS signal data. - data = self._get_gnss_signals_data(secondary=secondary) + data = self._get_gnss_signals_data(source_id) if len(data.messages) == 0: - self.logger.info(f'No {antenna.lower()} GNSS signal data available. Skipping signal status plot.') + self.logger.info(f'No GNSS signal data available for source ID {source_id}. Skipping signal status ' + 'plot.') return have_gnss_signals_message = not data.using_legacy_satellite_message @@ -1808,35 +1815,37 @@ def _count_selected(selected_p1_times, return_nonzero_time=False): self._add_figure(name=filename, figure=figure, title=figure_title, inject_js=hover_js) - def plot_gnss_signal_status_secondary(self): - self.plot_gnss_signal_status(secondary=True) + def _gnss_antenna_label(self, source_id: int) -> str: + if source_id in (0, SourceIdentifier.PRIMARY_GNSS_ANTENNA): + return 'Primary' + elif source_id in (1, SourceIdentifier.SECONDARY_GNSS_ANTENNA): + return 'Secondary' + else: + return f'Source {source_id}' + + def _gnss_plot_filename(self, base_name: str, source_id: int) -> str: + # Keep the primary antenna's filename unsuffixed for backward compatibility with existing links/tooling. + if source_id in (0, SourceIdentifier.PRIMARY_GNSS_ANTENNA): + return base_name + else: + return f'{base_name}_{self._gnss_antenna_label(source_id).lower().replace(" ", "_")}' - def _get_gnss_signals_data(self, secondary: bool = False): + def _get_gnss_signals_data(self, source_id: int): # If we already have data cached, return it. - if self._gnss_signals_data.get(secondary) is not None: - return self._gnss_signals_data[secondary] + if self._gnss_signals_data.get(source_id) is not None: + return self._gnss_signals_data[source_id] # See if we have GNSSSignalsMessages. If so, prefer those. params = copy.deepcopy(self.params) params['return_numpy'] = False + params['source_ids'] = {source_id} - # Some devices output GNSSSignalsMessage data for both the primary and secondary GNSS antennas, distinguished - # by source ID. Source ID 1 is a legacy secondary antenna identifier predating the SourceIdentifier reserved - # ranges, used by some older devices instead of SECONDARY_GNSS_ANTENNA (301). - secondary_source_ids = {1, SourceIdentifier.SECONDARY_GNSS_ANTENNA} available_source_ids = self.reader.get_available_source_ids() - if secondary: - requested_source_ids = available_source_ids & secondary_source_ids - else: - requested_source_ids = available_source_ids - secondary_source_ids - params['source_ids'] = requested_source_ids - - # If there's no secondary antenna in this log, requested_source_ids will be empty for secondary=True (and, - # in the rare case a log has only secondary data, empty for secondary=False too). Passing an empty set to the - # reader doesn't filter to nothing — it disables the filter, so we'd get back the other antenna's data instead. - # To avoid that, skip the read and build an empty MessageData directly, so len(data.messages) == 0 and the - # existing skip logic below fires as expected. - if len(requested_source_ids) == 0: + + # DataLoader/MixedLogReader treat an empty (but non-None) source_ids filter as "no filter" rather than + # "match nothing", so if this source ID isn't actually present in the log, skip the read entirely instead + # of getting back every source ID's data. + if source_id not in available_source_ids: data = MessageData(message_type=GNSSSignalsMessage.MESSAGE_TYPE, params=params) else: result = self.reader.read(message_types=[GNSSSignalsMessage], **params) @@ -1845,16 +1854,16 @@ def _get_gnss_signals_data(self, secondary: bool = False): # We store the result now, even if there were no GNSSSignalsMessage messages. That way if we also don't have any # GNSSSatelliteMessage messages below, we'll have _something_ to return and we won't try to reload from disk on # each call to this function. - self._gnss_signals_data[secondary] = data + self._gnss_signals_data[source_id] = data data.using_legacy_satellite_message = False # If we don't have any GNSSSignalsMessages, see if we have the legacy GNSSSatelliteMessage and fall back to - # that. Currently, we can still emit the legacy message, and does so per-antenna just like + # that. Current firmware can still emit the legacy message, and does so per-antenna just like # GNSSSignalsMessage, so apply the same source ID filtering here. if len(data.messages) == 0: # The legacy GNSSSatelliteMessage contains data per satellite, not per signal. The plotted C/N0 values will # reflect the L1 signal, unless L1 is not being tracked. - if len(requested_source_ids) == 0: + if source_id not in available_source_ids: data = MessageData(message_type=GNSSSatelliteMessage.MESSAGE_TYPE, params=params) else: result = self.reader.read(message_types=[GNSSSatelliteMessage], **params) @@ -1871,12 +1880,12 @@ def _get_gnss_signals_data(self, secondary: bool = False): data.messages = [m.to_gnss_signals_message() for m in data.messages] data.message_type = GNSSSignalsMessage.MESSAGE_TYPE data.message_class = GNSSSignalsMessage - self._gnss_signals_data[secondary] = data + self._gnss_signals_data[source_id] = data data.using_legacy_satellite_message = True - self._gnss_signals_data[secondary].to_numpy() + self._gnss_signals_data[source_id].to_numpy() - return self._gnss_signals_data[secondary] + return self._gnss_signals_data[source_id] def clear_gnss_signal_data_cache(self): """! @@ -3538,11 +3547,8 @@ def main(args=None): analyzer.plot_pose_error(reference=reference_data) analyzer.plot_gnss_cn0() - analyzer.plot_gnss_cn0_secondary() analyzer.plot_gnss_signal_status() - analyzer.plot_gnss_signal_status_secondary() analyzer.plot_gnss_skyplot() - analyzer.plot_gnss_skyplot_secondary() analyzer.plot_gnss_azimuth_elevation() analyzer.clear_gnss_signal_data_cache() From 0a87f957b657bab797c4d90da0706eeac07c677b Mon Sep 17 00:00:00 2001 From: Ben Hers Date: Wed, 22 Jul 2026 08:16:36 -0700 Subject: [PATCH 5/9] Added fix to max the max cn0 calculation more robust. --- python/fusion_engine_client/analysis/analyzer.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 08aa3329..89e1c1c9 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1254,7 +1254,21 @@ def _plot_gnss_skyplot_for_source(self, source_id: int, decimate=True): idx = all_signal_sv_hashes == sv_hash cn0_per_epoch = np.split(data.signal_data['cn0_dbhz'][idx], np.unique(data.signal_data['p1_time'][idx], return_index=True)[1][1:]) - max_cn0_dbhz = np.array([max(cn0) for cn0 in cn0_per_epoch]) + max_cn0_dbhz = np.full(len(p1_time), np.nan) + if idx.any(): + # Sort by time first since the split-by-unique-time trick below requires each epoch's entries to be + # contiguous, which is not guaranteed if the log merges multiple out-of-order sources (e.g., duo logs). + sort_idx = np.argsort(data.signal_data['p1_time'][idx], kind='stable') + sorted_cn0 = data.signal_data['cn0_dbhz'][idx][sort_idx] + sorted_time = data.signal_data['p1_time'][idx][sort_idx] + unique_times, group_start = np.unique(sorted_time, return_index=True) + cn0_per_epoch = np.split(sorted_cn0, group_start[1:]) + # Map by time value rather than assuming the signal epochs line up 1:1 with sv_data's p1_time: the two + # message streams can have different epochs (e.g., a signal dropout, or merged/duo logs). + max_cn0_by_time = dict(zip(unique_times, (max(cn0) for cn0 in cn0_per_epoch))) + for i, t in enumerate(p1_time): + if t in max_cn0_by_time: + max_cn0_dbhz[i] = max_cn0_by_time[t] if have_gnss_signals_message: sv_signal_types = signal_types_by_sv[sv_hash] From 85ad541bc36320a2af540acfd7a05124c7cac99e Mon Sep 17 00:00:00 2001 From: Ben Hers Date: Wed, 22 Jul 2026 08:32:06 -0700 Subject: [PATCH 6/9] Reverted the change to max_cn0_dbhz --- python/fusion_engine_client/analysis/analyzer.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 89e1c1c9..08aa3329 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1254,21 +1254,7 @@ def _plot_gnss_skyplot_for_source(self, source_id: int, decimate=True): idx = all_signal_sv_hashes == sv_hash cn0_per_epoch = np.split(data.signal_data['cn0_dbhz'][idx], np.unique(data.signal_data['p1_time'][idx], return_index=True)[1][1:]) - max_cn0_dbhz = np.full(len(p1_time), np.nan) - if idx.any(): - # Sort by time first since the split-by-unique-time trick below requires each epoch's entries to be - # contiguous, which is not guaranteed if the log merges multiple out-of-order sources (e.g., duo logs). - sort_idx = np.argsort(data.signal_data['p1_time'][idx], kind='stable') - sorted_cn0 = data.signal_data['cn0_dbhz'][idx][sort_idx] - sorted_time = data.signal_data['p1_time'][idx][sort_idx] - unique_times, group_start = np.unique(sorted_time, return_index=True) - cn0_per_epoch = np.split(sorted_cn0, group_start[1:]) - # Map by time value rather than assuming the signal epochs line up 1:1 with sv_data's p1_time: the two - # message streams can have different epochs (e.g., a signal dropout, or merged/duo logs). - max_cn0_by_time = dict(zip(unique_times, (max(cn0) for cn0 in cn0_per_epoch))) - for i, t in enumerate(p1_time): - if t in max_cn0_by_time: - max_cn0_dbhz[i] = max_cn0_by_time[t] + max_cn0_dbhz = np.array([max(cn0) for cn0 in cn0_per_epoch]) if have_gnss_signals_message: sv_signal_types = signal_types_by_sv[sv_hash] From 5d9b8208233a7f3c01cb2afe237dbc1243b21ab2 Mon Sep 17 00:00:00 2001 From: Ben Hers Date: Wed, 22 Jul 2026 09:15:18 -0700 Subject: [PATCH 7/9] Addressed Copilot comments. --- .../fusion_engine_client/analysis/analyzer.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 08aa3329..9eaca8e5 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -197,6 +197,7 @@ def __init__(self, self._mapbox_token_missing = False self._gnss_signals_data = {} + self._gnss_antenna_source_ids = None if self.output_dir is not None: if not os.path.exists(self.output_dir): @@ -1199,7 +1200,7 @@ def _plot_data(name, selected_idx, flags, source_id, marker_style=None): self._add_figure(name="map", figure=figure, title="Vehicle Trajectory (Map)", config={'scrollZoom': True}) def plot_gnss_skyplot(self, decimate=True): - for source_id in sorted(self.source_ids): + for source_id in self._get_gnss_antenna_source_ids(): self._plot_gnss_skyplot_for_source(source_id, decimate=decimate) def _plot_gnss_skyplot_for_source(self, source_id: int, decimate=True): @@ -1342,7 +1343,7 @@ def _plot_gnss_skyplot_for_source(self, source_id: int, decimate=True): self._add_figure(name=name, figure=figure, title=f'{label} GNSS Sky Plot') def plot_gnss_cn0(self): - for source_id in sorted(self.source_ids): + for source_id in self._get_gnss_antenna_source_ids(): self._plot_gnss_cn0_for_source(source_id) def _plot_gnss_cn0_for_source(self, source_id: int): @@ -1497,7 +1498,7 @@ def plot_gnss_azimuth_elevation(self): self._add_figure(name='gnss_azimuth_elevation', figure=figure, title='GNSS Azimuth & Elevation Vs. Time') def plot_gnss_signal_status(self): - for source_id in sorted(self.source_ids): + for source_id in self._get_gnss_antenna_source_ids(): self._plot_gnss_signal_status_for_source(source_id) def _plot_gnss_signal_status_for_source(self, source_id: int): @@ -1815,6 +1816,20 @@ def _count_selected(selected_p1_times, return_nonzero_time=False): self._add_figure(name=filename, figure=figure, title=figure_title, inject_js=hover_js) + def _get_gnss_antenna_source_ids(self) -> List[int]: + """! + @brief Get the source IDs, restricted to known GNSS antenna identifiers, present in this log. + + `self.source_ids` includes every source ID seen across all message types (and may be further restricted by + the user's --source-id argument), so it isn't specific to GNSS antennas. Restrict it here to the known + antenna identifiers so we don't attempt (and skip) a GNSS signal read for unrelated source IDs, e.g. ones + only used for IMU or wheel speed data. + """ + if self._gnss_antenna_source_ids is None: + antenna_source_ids = {0, 1, SourceIdentifier.PRIMARY_GNSS_ANTENNA, SourceIdentifier.SECONDARY_GNSS_ANTENNA} + self._gnss_antenna_source_ids = sorted(self.source_ids.intersection(antenna_source_ids)) + return self._gnss_antenna_source_ids + def _gnss_antenna_label(self, source_id: int) -> str: if source_id in (0, SourceIdentifier.PRIMARY_GNSS_ANTENNA): return 'Primary' From 49311dbc8d3658ea5d54a9a01fd65fd937734be6 Mon Sep 17 00:00:00 2001 From: Ben Hers Date: Wed, 22 Jul 2026 09:30:15 -0700 Subject: [PATCH 8/9] Generalized the plotting for the source ID range from 300 to 399. --- python/fusion_engine_client/analysis/analyzer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 9eaca8e5..c9463c5c 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1826,8 +1826,10 @@ def _get_gnss_antenna_source_ids(self) -> List[int]: only used for IMU or wheel speed data. """ if self._gnss_antenna_source_ids is None: - antenna_source_ids = {0, 1, SourceIdentifier.PRIMARY_GNSS_ANTENNA, SourceIdentifier.SECONDARY_GNSS_ANTENNA} - self._gnss_antenna_source_ids = sorted(self.source_ids.intersection(antenna_source_ids)) + # 0/1 are the legacy primary/secondary antenna identifiers, predating the SourceIdentifier reserved + # ranges. 300-399 is reserved for GNSS receivers/antennae. + self._gnss_antenna_source_ids = sorted( + sid for sid in self.source_ids if sid in (0, 1) or 300 <= sid <= 399) return self._gnss_antenna_source_ids def _gnss_antenna_label(self, source_id: int) -> str: From 8c44adf3271dac521525f7588d489c1a4c13bfb1 Mon Sep 17 00:00:00 2001 From: Ben Hers Date: Wed, 22 Jul 2026 12:11:15 -0700 Subject: [PATCH 9/9] Fixed out of order alphabetical import. --- python/fusion_engine_client/analysis/analyzer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index c9463c5c..4150eb80 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -25,8 +25,8 @@ from ..messages import * from .attitude import get_enu_rotation_matrix -from .reference import ReferenceData, _OWN_LOG_STATISTICS from .data_loader import DataLoader, MessageData, TimeRange +from .reference import ReferenceData, _OWN_LOG_STATISTICS from ..parsers.file_index import HostTimeIndexMap from ..utils import trace as logging from ..utils.argument_parser import ArgumentParser, ExtendedBooleanAction, TriStateBooleanAction, CSVAction