From 03894e86c79e9d27e027e371b44ebfc1d1f285b8 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 10:41:00 -0400 Subject: [PATCH 1/8] Display reference data on map. --- .../fusion_engine_client/analysis/analyzer.py | 59 +++++++++++++++++-- .../analysis/reference.py | 12 +++- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 15c3a9d1..a1a1cd2a 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1143,9 +1143,12 @@ def plot_relative_position(self): solution_type=solution_type, displacement_enu_m=displacement_enu_m, std_enu_m=std_enu_m) - def plot_map(self, mapbox_token): + def plot_map(self, mapbox_token, reference: Optional[ReferenceData] = None): """! @brief Plot a map of the position data. + + @param reference If specified, also plot this reference/truth position, restricted to the time range + covered by the pose data. """ pose_source_ids = self._get_pose_source_ids() if self.output_dir is None or len(pose_source_ids) == 0: @@ -1246,6 +1249,8 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma primary_source_id = min(pose_source_ids) overall_t_min = None overall_t_max = None + overall_gps_t_min = None + overall_gps_t_max = None for source_id in pose_source_ids: result = self.reader.read(message_types=[PoseMessage], source_ids=[source_id], **self.params) pose_data = result[PoseMessage.MESSAGE_TYPE] @@ -1273,9 +1278,16 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma overall_t_min = t_min if overall_t_min is None else min(overall_t_min, t_min) overall_t_max = t_max if overall_t_max is None else max(overall_t_max, t_max) - customdata_all = _build_position_customdata(p1_time=p1_time, - gps_time=pose_data.gps_time[valid_idx], - lla_deg=lla_deg, std_enu_m=std_enu_m) + gps_time = pose_data.gps_time[valid_idx] + valid_gps_idx = ~np.isnan(gps_time) + if np.any(valid_gps_idx): + gps_t_min = float(np.min(gps_time[valid_gps_idx])) + gps_t_max = float(np.max(gps_time[valid_gps_idx])) + overall_gps_t_min = gps_t_min if overall_gps_t_min is None else min(overall_gps_t_min, gps_t_min) + overall_gps_t_max = gps_t_max if overall_gps_t_max is None else max(overall_gps_t_max, gps_t_max) + + customdata_all = _build_position_customdata(p1_time=p1_time, gps_time=gps_time, lla_deg=lla_deg, + std_enu_m=std_enu_m) for type, info in _SOLUTION_TYPE_MAP.items(): if len(pose_source_ids) > 1: @@ -1288,6 +1300,41 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma if not have_pose_data: return + # Add reference/truth data to the map, if available, restricted to the time range covered by the pose data. + # Built as a separate list and prepended below (rather than appended to map_data directly) so the reference + # is drawn first -- Scattermapbox layers later traces on top, and we want the pose data on top of the + # reference, not the other way around. + ref_traces = [] + if reference is not None and (reference.is_stationary or overall_gps_t_min is not None): + if reference.is_stationary: + ref_lla_deg = reference.lla_deg.reshape(3, 1) + ref_solution_type = None + else: + in_range = np.logical_and(reference.gps_time_sec >= overall_gps_t_min, + reference.gps_time_sec <= overall_gps_t_max) + ref_lla_deg = reference.lla_deg[:, in_range] + ref_solution_type = reference.solution_type[in_range] + + if ref_lla_deg.shape[1] > 0: + is_fixed = (np.full(ref_lla_deg.shape[1], True) if ref_solution_type is None + else ref_solution_type == SolutionType.RTKFixed) + for name, color, idx in (('Reference (RTK Fixed)', '#EBFFA3', is_fixed), + ('Reference (Not Fixed)', '#A8C443', ~is_fixed)): + if np.any(idx): + ref_traces.append(go.Scattermapbox(lat=ref_lla_deg[0, idx], lon=ref_lla_deg[1, idx], + name=name, mode='markers', + marker={'size': 8, 'color': color}, + showlegend=True, legendgroup='ref')) + + if ref_traces: + # Shift the pose traces' button indices to account for the reference traces now being inserted ahead of + # them, and keep the reference visible regardless of which quality-selection button is active. + offset = len(ref_traces) + for indices in indices_by_engine.values(): + indices[:] = [i + offset for i in indices] + indices.extend(range(offset)) + map_data = ref_traces + map_data + # Create the map. title = 'Vehicle Trajectory' if mapbox_token is None: @@ -4170,7 +4217,7 @@ def main(args=None): analyzer.plot_pose() analyzer.plot_position_displacement(reference_type=options.displacement_type) analyzer.plot_relative_position() - analyzer.plot_map(mapbox_token=options.mapbox_token) + analyzer.plot_map(mapbox_token=options.mapbox_token, reference=reference_data) analyzer.plot_calibration() if reference_data is not None: @@ -4219,7 +4266,7 @@ def main(args=None): for func in functions: if func == 'plot_map': - analyzer.plot_map(mapbox_token=options.mapbox_token) + analyzer.plot_map(mapbox_token=options.mapbox_token, reference=reference_data) elif func == 'plot_skyplot': analyzer.plot_gnss_skyplot(decimate=False) elif func == 'plot_pose_error': diff --git a/python/fusion_engine_client/analysis/reference.py b/python/fusion_engine_client/analysis/reference.py index df0536d7..0092703a 100644 --- a/python/fusion_engine_client/analysis/reference.py +++ b/python/fusion_engine_client/analysis/reference.py @@ -3,7 +3,7 @@ import re import numpy as np -from pymap3d import geodetic2ecef +from pymap3d import ecef2geodetic, geodetic2ecef from .data_loader import DataLoader, TimeAlignmentMode from ..messages import PoseMessage, PoseAuxMessage, SolutionType @@ -76,6 +76,9 @@ def __init__(self, description: str, is_truth: bool, # time, end time, and sample count (not the full time vector) to avoid recomputing for repeated queries. self._interp_cache = {} + # Cached data generated on demand. + self._lla_deg = None + @property def is_stationary(self) -> bool: return self.gps_time_sec is None @@ -95,6 +98,13 @@ def displacement_label(self) -> str: """ return 'Error' if self.is_truth else 'Displacement' + @property + def lla_deg(self) -> np.ndarray: + if self._lla_deg is None: + self._lla_deg = np.array(ecef2geodetic(self.position_ecef_m[0, :], self.position_ecef_m[1, :], + self.position_ecef_m[2, :], deg=True)) + return self._lla_deg + def __repr__(self): extent = 'stationary' if self.is_stationary else f'{len(self.gps_time_sec)} samples' return f'ReferenceData({self.description!r}, is_truth={self.is_truth}, {extent})' From 8aa5f78e10440cc4d3c01800956035533bfe2cd6 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 11:17:49 -0400 Subject: [PATCH 2/8] Added optional position std dev to ReferenceData. --- python/fusion_engine_client/analysis/reference.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/python/fusion_engine_client/analysis/reference.py b/python/fusion_engine_client/analysis/reference.py index 0092703a..175334c1 100644 --- a/python/fusion_engine_client/analysis/reference.py +++ b/python/fusion_engine_client/analysis/reference.py @@ -44,6 +44,7 @@ class ReferenceData: def __init__(self, description: str, is_truth: bool, position_ecef_m: np.ndarray, + position_std_enu_m: Optional[np.ndarray] = None, gps_time_sec: Optional[np.ndarray] = None, solution_type: Optional[np.ndarray] = None, velocity_enu_mps: Optional[np.ndarray] = None, @@ -58,6 +59,7 @@ def __init__(self, description: str, is_truth: bool, separate reference log). If `False`, this data is derived from the primary log's own data (e.g., its median or first position). @param position_ecef_m A 3-element (stationary) or 3xN (time-varying) ECEF position array, in meters. + @param position_std_enu_m Optional ENU position standard deviation (3-element or 3xN), in meters. @param gps_time_sec GPS time of week/epoch (sec) for each time-varying sample, sorted in ascending order. `None` for a stationary reference. @param solution_type Solution type for each time-varying sample. `None` for a stationary reference. @@ -67,6 +69,7 @@ def __init__(self, description: str, is_truth: bool, self.description = description self.is_truth = is_truth self.position_ecef_m = np.asarray(position_ecef_m, dtype=float) + self.position_std_enu_m = position_std_enu_m self.gps_time_sec = None if gps_time_sec is None else np.asarray(gps_time_sec, dtype=float) self.solution_type = solution_type self.velocity_enu_mps = velocity_enu_mps @@ -281,20 +284,25 @@ def from_own_log(cls, loader: DataLoader, statistic: str, source_id: Optional[in position_ecef_m = np.array(geodetic2ecef(lat=lla_deg[0, :], lon=lla_deg[1, :], alt=lla_deg[2, :], deg=True)) velocity_enu_mps = aux_data.velocity_enu_mps[:, selected_idx] ypr_deg = pose_data.ypr_deg[:, selected_idx] + position_std_enu_m = pose_data.position_std_enu_m[:, selected_idx] if statistic in ('first', 'first_fixed'): position_ecef_m = position_ecef_m[:, 0] velocity_enu_mps = None if np.any(np.isnan(velocity_enu_mps[:, 0])) else velocity_enu_mps[:, 0] ypr_deg = None if np.any(np.isnan(ypr_deg[:, 0])) else ypr_deg[:, 0] + position_std_enu_m = (None if np.any(np.isnan(position_std_enu_m[:, 0])) + else position_std_enu_m[:, 0]) else: position_ecef_m = np.median(position_ecef_m, axis=1) with np.errstate(invalid='ignore'): velocity_enu_mps = (None if np.all(np.isnan(velocity_enu_mps)) else np.nanmedian(velocity_enu_mps, axis=1)) ypr_deg = None if np.all(np.isnan(ypr_deg)) else np.nanmedian(ypr_deg, axis=1) + position_std_enu_m = (None if np.all(np.isnan(position_std_enu_m)) + else np.nanmedian(position_std_enu_m, axis=1)) return cls(description=description, is_truth=False, position_ecef_m=position_ecef_m, - velocity_enu_mps=velocity_enu_mps, ypr_deg=ypr_deg) + velocity_enu_mps=velocity_enu_mps, ypr_deg=ypr_deg, position_std_enu_m=position_std_enu_m) @classmethod def from_reference_log(cls, path_or_loader: Union[str, DataLoader], log_base_dir: str = None, @@ -354,10 +362,11 @@ def from_reference_log(cls, path_or_loader: Union[str, DataLoader], log_base_dir position_ecef_m = np.array(geodetic2ecef(lat=lla_deg[0, :], lon=lla_deg[1, :], alt=lla_deg[2, :], deg=True)) velocity_enu_mps = aux_data.velocity_enu_mps[:, valid_idx][:, order] ypr_deg = pose_data.ypr_deg[:, valid_idx][:, order] + position_std_enu_m = pose_data.position_std_enu_m[:, valid_idx][:, order] return cls(description=description, is_truth=True, position_ecef_m=position_ecef_m, - gps_time_sec=gps_time_sec, solution_type=solution_type, velocity_enu_mps=velocity_enu_mps, - ypr_deg=ypr_deg) + position_std_enu_m=position_std_enu_m, gps_time_sec=gps_time_sec, solution_type=solution_type, + velocity_enu_mps=velocity_enu_mps, ypr_deg=ypr_deg) # ------------------------------------------------------------------------------------------------------------- # CLI argument parsing From 3ba5c0bd535ea9c8646b871077c89b84406f9642 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 12:48:16 -0400 Subject: [PATCH 3/8] Moved ENU position error calculation to helper function. --- .../fusion_engine_client/analysis/analyzer.py | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index a1a1cd2a..96680094 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -15,7 +15,7 @@ import plotly import plotly.graph_objs as go from plotly.subplots import make_subplots -from pymap3d import geodetic2ecef +from pymap3d import ecef2geodetic, geodetic2ecef # If running as a script, add fusion-engine-client/ to the Python import path and correct __package__ to enable relative # imports. @@ -1078,12 +1078,12 @@ def _plot_pose_displacement(self, reference: Optional[ReferenceData] = None): position_ecef_m = np.array(geodetic2ecef(lat=lla_deg[0, :], lon=lla_deg[1, :], alt=lla_deg[2, :], deg=True)) - # Interpolate the reference position onto this log's GPS timestamps, warning if the reference does not fully - # cover this log's time range. Any timestamps that fall outside the reference's coverage (or that could not - # be interpolated, e.g. due to a gap in the reference data) are dropped below. - valid_ref_idx = reference.get_coverage_mask(gps_time) - reference_ecef_m = reference.interpolate_position_ecef_m(gps_time) - valid_ref_idx = np.logical_and(valid_ref_idx, ~np.any(np.isnan(reference_ecef_m), axis=0)) + # Compute the ENU displacement/error vs. the reference, warning if the reference does not fully cover this + # log's time range. Any timestamps that fall outside the reference's coverage (or that could not be + # interpolated, e.g. due to a gap in the reference data) are dropped below. + reference.get_coverage_mask(gps_time) + displacement_enu_m, valid_ref_idx = self._compute_position_error_enu_m( + reference=reference, gps_time_sec=gps_time, position_ecef_m=position_ecef_m) if not np.any(valid_ref_idx): self.logger.warning(f"Reference data '{reference.description}' does not overlap with this log's time " f"range. Skipping displacement plots.") @@ -1092,14 +1092,8 @@ def _plot_pose_displacement(self, reference: Optional[ReferenceData] = None): p1_time = p1_time[valid_ref_idx] gps_time = gps_time[valid_ref_idx] solution_type = solution_type[valid_ref_idx] - lla_deg = lla_deg[:, valid_ref_idx] std_enu_m = std_enu_m[:, valid_ref_idx] - position_ecef_m = position_ecef_m[:, valid_ref_idx] - reference_ecef_m = reference_ecef_m[:, valid_ref_idx] - - displacement_ecef_m = position_ecef_m - reference_ecef_m - c_enu_ecef = get_enu_rotation_matrix(*lla_deg[0:2, 0], deg=True) - displacement_enu_m = c_enu_ecef.dot(displacement_ecef_m) + displacement_enu_m = displacement_enu_m[:, valid_ref_idx] axis_title = reference.displacement_label source = f'Position {axis_title} vs. {"Reference" if reference.is_truth else reference.description}' @@ -4009,6 +4003,33 @@ def _time_source_to_display_name(cls, time_source: SystemTimeSource) -> str: elif time_source == SystemTimeSource.TIMESTAMPED_ON_RECEPTION: return 'System' + @classmethod + def _compute_position_error_enu_m(cls, reference: ReferenceData, gps_time_sec: np.ndarray, + position_ecef_m: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """! + @brief Interpolate `reference`'s position onto `gps_time_sec`, then compute the resulting ENU position error + (`position_ecef_m` minus the interpolated reference position) in a local ENU frame. + + @param reference The reference/truth position to compare against. + @param gps_time_sec GPS timestamps (sec) at which `position_ecef_m` was sampled. + @param position_ecef_m The 3xN ECEF position(s) being evaluated, in meters. + + @return A tuple `(error_enu_m, valid_idx)`: the 3xN ENU error, and a boolean mask marking samples where the + reference had valid (non-NaN, in-range) data to interpolate against. + """ + reference_ecef_m = reference.interpolate_position_ecef_m(gps_time_sec) + valid_idx = ~np.any(np.isnan(reference_ecef_m), axis=0) + + first_pos_idx = find_first(~np.isnan(position_ecef_m[0, :])) + if first_pos_idx >= 0: + origin_ecef_m = position_ecef_m[:, first_pos_idx] + origin_lat_deg, origin_lon_deg, _ = ecef2geodetic(*origin_ecef_m, deg=True) + c_enu_ecef = get_enu_rotation_matrix(latitude=origin_lat_deg, longitude=origin_lon_deg, deg=True) + error_enu_m = c_enu_ecef.dot(position_ecef_m - reference_ecef_m) + else: + error_enu_m = np.full_like(position_ecef_m, np.nan) + return error_enu_m, valid_idx + @classmethod def _get_colors(cls, num_colors=None): colors = Tableau_20.hex_colors From 331c40ae0bbb5d535f659f0d272e6b2c5ee5d3a7 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 12:58:13 -0400 Subject: [PATCH 4/8] Include std dev and position error in map hover text. --- .../fusion_engine_client/analysis/analyzer.py | 84 ++++++++++++++----- .../analysis/plotly_map_time_slider.js | 5 +- 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 96680094..e2856e00 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1167,15 +1167,20 @@ def plot_map(self, mapbox_token, reference: Optional[ReferenceData] = None): # `%{x}` tied to a real date-typed axis) -- but a customdata entry with no format spec at all is substituted # verbatim, so we precompute the UTC string in Python (cheap, vectorized) and reference it that way. _POSITION_HOVERTEMPLATE = ( - "LLA: %{lat:.8f}, %{lon:.8f}, %{customdata[4]:.2f}
" - "Rel: %{customdata[0]:.3f} sec (P1: %{customdata[1]:.3f} sec)
" - "UTC: %{customdata[8]}
" - "GPS: %{customdata[2]:.0f}:%{customdata[3]:.3f}
" - "Std (ENU): (%{customdata[5]:.2f}, %{customdata[6]:.2f}, %{customdata[7]:.2f}) m" + "LLA: %{lat:.8f}, %{lon:.8f}, %{customdata[5]:.2f}
" + "Rel: %{customdata[1]:.3f} sec (P1: %{customdata[2]:.3f} sec)
" + "UTC: %{customdata[0]}
" + "GPS: %{customdata[3]:.0f}:%{customdata[4]:.3f}
" + "Std Dev: %{customdata[6]:.2f} m (2D), %{customdata[7]:.2f} m (3D)" + ) + # Used instead of _POSITION_HOVERTEMPLATE when reference data is avaiable to compute position error. + _POSITION_HOVERTEMPLATE_WITH_ERROR = ( + _POSITION_HOVERTEMPLATE + + "
Error: %{customdata[8]:.2f} m (2D), %{customdata[9]:.2f} m (3D)" ) - def _build_position_customdata(p1_time: np.ndarray, gps_time: np.ndarray, - lla_deg: np.ndarray, std_enu_m: np.ndarray) -> list: + def _build_position_customdata(p1_time: np.ndarray, gps_time: np.ndarray, lla_deg: np.ndarray, + std_enu_m: np.ndarray, error_enu_m: Optional[np.ndarray] = None) -> list: rel_time = p1_time - float(self.reader.t0) gps_week = np.floor(gps_time / SECONDS_PER_WEEK) gps_tow_sec = gps_time - gps_week * SECONDS_PER_WEEK @@ -1191,16 +1196,25 @@ def _build_position_customdata(p1_time: np.ndarray, gps_time: np.ndarray, # # utc_strs is a string column mixed in with the numeric ones above, so this can't be a single numpy # array (that would coerce every column to strings, breaking the numeric %{customdata[N]:.3f}-style - # formatting for the rest); build it as a plain list of per-point rows instead. - numeric = np.column_stack((rel_time, p1_time, gps_week, gps_tow_sec, lla_deg[2], std_enu_m[0], - std_enu_m[1], std_enu_m[2])) - return [row.tolist() + [utc_str] for row, utc_str in zip(numeric, utc_strs)] + # formatting for the rest); build it as a plain list of per-point rows instead. error_enu_m, when + # present, is appended after the UTC string so its indices stay fixed regardless of whether it's used. + numeric = np.column_stack((rel_time, p1_time, gps_week, gps_tow_sec, lla_deg[2], + np.linalg.norm(std_enu_m[0:2, :], axis=0), + np.linalg.norm(std_enu_m, axis=0))) + if error_enu_m is None: + return [[utc_str] + row.tolist()for utc_str, row in zip(utc_strs, numeric)] + else: + error_numeric = np.column_stack((np.linalg.norm(error_enu_m[0:2, :], axis=0), + np.linalg.norm(error_enu_m, axis=0))) + return [[utc_str] + row.tolist() + error_row.tolist() + for utc_str, row, error_row in zip(utc_strs, numeric, error_numeric)] # Add data to the map. map_data = [] indices_by_engine = defaultdict(list) - def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, marker_style=None): + def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, marker_style=None, + hovertemplate=_POSITION_HOVERTEMPLATE): style = {'mode': 'markers', 'marker': {'size': 8}, 'showlegend': True} if marker_style is not None: style['marker'].update(marker_style) @@ -1217,7 +1231,7 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma idx = is_nav_engine map_data.append(go.Scattermapbox(lat=lla_deg[0, idx], lon=lla_deg[1, idx], name=name, customdata=[customdata_all[i] for i in np.nonzero(idx)[0]], - hovertemplate=_POSITION_HOVERTEMPLATE, + hovertemplate=hovertemplate, legendgroup=legendgroup, visible=visible, **style)) indices_by_engine['Nav Engine'].append(len(map_data) - 1) @@ -1228,7 +1242,7 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma map_data.append(go.Scattermapbox(lat=lla_deg[0, idx], lon=lla_deg[1, idx], name=name + ' (Receiver Solution)', customdata=[customdata_all[i] for i in np.nonzero(idx)[0]], - hovertemplate=_POSITION_HOVERTEMPLATE, + hovertemplate=hovertemplate, legendgroup=legendgroup, visible=visible, **style)) indices_by_engine['Receiver Solution'].append(len(map_data) - 1) @@ -1280,8 +1294,21 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma overall_gps_t_min = gps_t_min if overall_gps_t_min is None else min(overall_gps_t_min, gps_t_min) overall_gps_t_max = gps_t_max if overall_gps_t_max is None else max(overall_gps_t_max, gps_t_max) + # If we have reference data, compute and display position error. + # + # Position error applies to the source at the device's output lever arm location, which we assume is the + # location of the reference data, not to other pose sources (e.g., position of the IMU). + error_enu_m = None + hovertemplate = _POSITION_HOVERTEMPLATE + if reference is not None and source_id == SourceIdentifier.OUTPUT_LEVER_ARM: + position_ecef_m = np.array(geodetic2ecef(lat=lla_deg[0, :], lon=lla_deg[1, :], alt=lla_deg[2, :], + deg=True)) + error_enu_m, _ = self._compute_position_error_enu_m(reference=reference, gps_time_sec=gps_time, + position_ecef_m=position_ecef_m) + hovertemplate = _POSITION_HOVERTEMPLATE_WITH_ERROR + customdata_all = _build_position_customdata(p1_time=p1_time, gps_time=gps_time, lla_deg=lla_deg, - std_enu_m=std_enu_m) + std_enu_m=std_enu_m, error_enu_m=error_enu_m) for type, info in _SOLUTION_TYPE_MAP.items(): if len(pose_source_ids) > 1: @@ -1289,7 +1316,8 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma else: name = info.name _plot_data(name=name, selected_idx=solution_type == type, flags=flags, source_id=source_id, - lla_deg=lla_deg, customdata_all=customdata_all, marker_style=info.style) + lla_deg=lla_deg, customdata_all=customdata_all, marker_style=info.style, + hovertemplate=hovertemplate) if not have_pose_data: return @@ -1298,19 +1326,33 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma # Built as a separate list and prepended below (rather than appended to map_data directly) so the reference # is drawn first -- Scattermapbox layers later traces on top, and we want the pose data on top of the # reference, not the other way around. + _REF_HOVERTEMPLATE = ( + "Std Dev: %{customdata[5]:.2f} m (2D), %{customdata[6]:.2f} m (3D)" + ) + ref_traces = [] if reference is not None and (reference.is_stationary or overall_gps_t_min is not None): if reference.is_stationary: ref_lla_deg = reference.lla_deg.reshape(3, 1) ref_solution_type = None + ref_std_enu_m = (None if reference.position_std_enu_m is None + else reference.position_std_enu_m.reshape(3, 1)) else: in_range = np.logical_and(reference.gps_time_sec >= overall_gps_t_min, reference.gps_time_sec <= overall_gps_t_max) ref_lla_deg = reference.lla_deg[:, in_range] ref_solution_type = reference.solution_type[in_range] + ref_std_enu_m = (None if reference.position_std_enu_m is None + else reference.position_std_enu_m[:, in_range]) if ref_lla_deg.shape[1] > 0: - is_fixed = (np.full(ref_lla_deg.shape[1], True) if ref_solution_type is None + n = ref_lla_deg.shape[1] + if ref_std_enu_m is None: + ref_std_enu_m = np.full((3, n), np.nan) + + ref_customdata = np.column_stack((ref_std_enu_m[0], ref_std_enu_m[1], ref_std_enu_m[2])) + + is_fixed = (np.full(n, True) if ref_solution_type is None else ref_solution_type == SolutionType.RTKFixed) for name, color, idx in (('Reference (RTK Fixed)', '#EBFFA3', is_fixed), ('Reference (Not Fixed)', '#A8C443', ~is_fixed)): @@ -1318,7 +1360,9 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma ref_traces.append(go.Scattermapbox(lat=ref_lla_deg[0, idx], lon=ref_lla_deg[1, idx], name=name, mode='markers', marker={'size': 8, 'color': color}, - showlegend=True, legendgroup='ref')) + showlegend=True, legendgroup='ref', + customdata=ref_customdata[idx], + hovertemplate=_REF_HOVERTEMPLATE)) if ref_traces: # Shift the pose traces' button indices to account for the reference traces now being inserted ahead of @@ -3913,7 +3957,9 @@ def _map_time_slider_js(self, t_min: float, t_max: float, profile_time_sec: Opti injected the same way as `plotly_data_support.js` (see @ref __write_html_and_inject_js()); this decimates and JSON-encodes the per-log data that static file reads from a handful of `MAP_SLIDER_*` globals. - @param t_min/t_max The full P1 time range (sec) spanned by the map's traces (matches `customdata[1]`). + @param t_min/t_max The full P1 time range (sec) spanned by the map's traces (matches `customdata[2]`, per + `P1_TIME_CUSTOMDATA_INDEX` in `plotly_map_time_slider.js` -- must stay in sync with the column order + built by @ref _build_position_customdata()). @param profile_time_sec/profile_speed_mps Parallel arrays of P1 time (sec) and 3D speed (m/s) for the background chart, from the default pose source (e.g., from @ref _estimate_speed_mps()). `None` (or empty) if no speed data is available at all, in which case the chart is simply left blank. diff --git a/python/fusion_engine_client/analysis/plotly_map_time_slider.js b/python/fusion_engine_client/analysis/plotly_map_time_slider.js index 2e8e8855..f9a7b834 100644 --- a/python/fusion_engine_client/analysis/plotly_map_time_slider.js +++ b/python/fusion_engine_client/analysis/plotly_map_time_slider.js @@ -13,6 +13,9 @@ var PROFILE_SPEED = MAP_SLIDER_PROFILE_SPEED; var PROFILE_GPS_TIME = MAP_SLIDER_PROFILE_GPS_TIME; var SECONDS_PER_WEEK = 7 * 24 * 3600.0; + // Column index of P1 time within each point's customdata row -- must match the column order built by + // Analyzer._build_position_customdata() in analyzer.py (currently [utc_str, rel_time, p1_time, ...]). + var P1_TIME_CUSTOMDATA_INDEX = 2; var SLIDER_HEIGHT_PX = 80; var READOUT_HEIGHT_PX = 22; var TRACK_INSET_PX = 16; @@ -318,7 +321,7 @@ if (!orig.customdata) continue; var lat = [], lon = [], cd = []; for (var j = 0; j < orig.customdata.length; j++) { - var t = orig.customdata[j][1]; + var t = orig.customdata[j][P1_TIME_CUSTOMDATA_INDEX]; if (t >= winStart && t <= winEnd) { lat.push(orig.lat[j]); lon.push(orig.lon[j]); From f47f8a37d04e15fd9d48f80bf395b305dd6713db Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 13:05:33 -0400 Subject: [PATCH 5/8] Include times in reference hover text. --- .../fusion_engine_client/analysis/analyzer.py | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index e2856e00..58da78f7 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1326,17 +1326,12 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma # Built as a separate list and prepended below (rather than appended to map_data directly) so the reference # is drawn first -- Scattermapbox layers later traces on top, and we want the pose data on top of the # reference, not the other way around. - _REF_HOVERTEMPLATE = ( - "Std Dev: %{customdata[5]:.2f} m (2D), %{customdata[6]:.2f} m (3D)" - ) - ref_traces = [] if reference is not None and (reference.is_stationary or overall_gps_t_min is not None): if reference.is_stationary: ref_lla_deg = reference.lla_deg.reshape(3, 1) ref_solution_type = None - ref_std_enu_m = (None if reference.position_std_enu_m is None - else reference.position_std_enu_m.reshape(3, 1)) + ref_std_enu_m = None else: in_range = np.logical_and(reference.gps_time_sec >= overall_gps_t_min, reference.gps_time_sec <= overall_gps_t_max) @@ -1346,23 +1341,36 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma else reference.position_std_enu_m[:, in_range]) if ref_lla_deg.shape[1] > 0: - n = ref_lla_deg.shape[1] - if ref_std_enu_m is None: - ref_std_enu_m = np.full((3, n), np.nan) - - ref_customdata = np.column_stack((ref_std_enu_m[0], ref_std_enu_m[1], ref_std_enu_m[2])) - - is_fixed = (np.full(n, True) if ref_solution_type is None + if reference.is_stationary: + ref_customdata = None + ref_hovertemplate = None + else: + # Reuse the same Rel/P1/UTC/GPS/Std layout as the pose data hover -- converting the reference's + # GPS time to this log's P1 time (via the primary log's known P1<->GPS relationship) so "Rel" + # and "P1" line up with the pose data's own time base. + ref_gps_time = reference.gps_time_sec[in_range] + ref_p1_time = self.time_provider.gps_to_p1(ref_gps_time) + if ref_std_enu_m is None: + ref_std_enu_m = np.full_like(ref_lla_deg, np.nan) + ref_customdata = _build_position_customdata(p1_time=ref_p1_time, gps_time=ref_gps_time, + lla_deg=ref_lla_deg, std_enu_m=ref_std_enu_m) + ref_hovertemplate = _POSITION_HOVERTEMPLATE + + is_fixed = (np.full_like(ref_lla_deg, True) if ref_solution_type is None else ref_solution_type == SolutionType.RTKFixed) for name, color, idx in (('Reference (RTK Fixed)', '#EBFFA3', is_fixed), ('Reference (Not Fixed)', '#A8C443', ~is_fixed)): if np.any(idx): + if ref_customdata is None: + trace_customdata = None + else: + trace_customdata = [ref_customdata[i] for i in np.nonzero(idx)[0]] ref_traces.append(go.Scattermapbox(lat=ref_lla_deg[0, idx], lon=ref_lla_deg[1, idx], name=name, mode='markers', marker={'size': 8, 'color': color}, showlegend=True, legendgroup='ref', - customdata=ref_customdata[idx], - hovertemplate=_REF_HOVERTEMPLATE)) + customdata=trace_customdata, + hovertemplate=ref_hovertemplate)) if ref_traces: # Shift the pose traces' button indices to account for the reference traces now being inserted ahead of From 3d5edcbee47f81e30ec5577d7b8dad894eb59d2c Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 13:13:38 -0400 Subject: [PATCH 6/8] Display trace names inside hover box. --- python/fusion_engine_client/analysis/analyzer.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 58da78f7..3a4ff7fd 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1209,6 +1209,13 @@ def _build_position_customdata(p1_time: np.ndarray, gps_time: np.ndarray, lla_de return [[utc_str] + row.tolist() + error_row.tolist() for utc_str, row, error_row in zip(utc_strs, numeric, error_numeric)] + def _with_bold_name(hovertemplate: Optional[str], name: str) -> str: + # Note: hides Plotly's secondary box, which by default displays the trace name to the right + # of the hover box with a hard-to-read transparent background. + if not hovertemplate: + return f"{name}" + return f"{name}
{hovertemplate}" + # Add data to the map. map_data = [] indices_by_engine = defaultdict(list) @@ -1231,7 +1238,7 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma idx = is_nav_engine map_data.append(go.Scattermapbox(lat=lla_deg[0, idx], lon=lla_deg[1, idx], name=name, customdata=[customdata_all[i] for i in np.nonzero(idx)[0]], - hovertemplate=hovertemplate, + hovertemplate=_with_bold_name(hovertemplate, name), legendgroup=legendgroup, visible=visible, **style)) indices_by_engine['Nav Engine'].append(len(map_data) - 1) @@ -1239,10 +1246,11 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma idx = is_gnss_rx style['marker']['opacity'] = 0.5 style['marker']['size'] = 5 + gnss_name = name + ' (Receiver Solution)' map_data.append(go.Scattermapbox(lat=lla_deg[0, idx], lon=lla_deg[1, idx], - name=name + ' (Receiver Solution)', + name=gnss_name, customdata=[customdata_all[i] for i in np.nonzero(idx)[0]], - hovertemplate=hovertemplate, + hovertemplate=_with_bold_name(hovertemplate, gnss_name), legendgroup=legendgroup, visible=visible, **style)) indices_by_engine['Receiver Solution'].append(len(map_data) - 1) @@ -1370,7 +1378,7 @@ def _plot_data(name, selected_idx, flags, source_id, lla_deg, customdata_all, ma marker={'size': 8, 'color': color}, showlegend=True, legendgroup='ref', customdata=trace_customdata, - hovertemplate=ref_hovertemplate)) + hovertemplate=_with_bold_name(ref_hovertemplate, name))) if ref_traces: # Shift the pose traces' button indices to account for the reference traces now being inserted ahead of From 366955d2f8bb8b596d198c7b1b2ff5e12ba20cf9 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 13:22:44 -0400 Subject: [PATCH 7/8] Clarified displacement plot naming. --- .../fusion_engine_client/analysis/analyzer.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 3a4ff7fd..4b641746 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1025,7 +1025,7 @@ def plot_pose_error(self, reference: ReferenceData): @param reference The truth reference to compare against. """ - return self._plot_pose_displacement(reference=reference) + return self._plot_position_displacement_vs_reference(reference=reference) def plot_position_displacement(self, reference_type: str): """! @@ -1034,17 +1034,17 @@ def plot_position_displacement(self, reference_type: str): @param reference_type The desired reference type name. """ - # Default to the median position taken from this log's own data (we use median instead of centroid just in - # case there are one or two huge outliers). + # Note that this is using the ReferenceData class to compute the median, etc. position from the log's pose + # messages to pass to _plot_pose_displacement(). It is not loading truth data. reference = ReferenceData.resolve_cli_argument(reference=reference_type, loader=self.reader, source_id=self.default_source_id) if reference is None: self.logger.info('No valid position solutions detected. Skipping displacement plots.') return None else: - return self._plot_pose_displacement(reference=reference) + return self._plot_position_displacement_vs_reference(reference=reference) - def _plot_pose_displacement(self, reference: Optional[ReferenceData] = None): + def _plot_position_displacement_vs_reference(self, reference: Optional[ReferenceData] = None): """! @brief Generate a topocentric (top-down) plot of position displacement (or error, if `reference` is an independent truth source -- see @ref ReferenceData) vs a reference position, as well as a plot of @@ -1061,13 +1061,14 @@ def _plot_pose_displacement(self, reference: Optional[ReferenceData] = None): pose_data = result[PoseMessage.MESSAGE_TYPE] if len(pose_data.p1_time) == 0: - self.logger.info('No pose data available. Skipping displacement plots.') + self.logger.info(f'No pose data available. Skipping position {reference.displacement_label.lower()} plots.') return None # Remove invalid solutions. valid_idx = np.logical_and(~np.isnan(pose_data.p1_time), pose_data.solution_type != SolutionType.Invalid) if not np.any(valid_idx): - self.logger.info('No valid position solutions detected. Skipping displacement plots.') + self.logger.info(f'No valid position solutions detected. Skipping position ' + f'{reference.displacement_label.lower()} plots.') return None p1_time = pose_data.p1_time[valid_idx] @@ -1086,7 +1087,7 @@ def _plot_pose_displacement(self, reference: Optional[ReferenceData] = None): reference=reference, gps_time_sec=gps_time, position_ecef_m=position_ecef_m) if not np.any(valid_ref_idx): self.logger.warning(f"Reference data '{reference.description}' does not overlap with this log's time " - f"range. Skipping displacement plots.") + f"range. Skipping position {reference.displacement_label.lower()} plots.") return None elif not np.all(valid_ref_idx): p1_time = p1_time[valid_ref_idx] From cd27598a2281ef9327b1019f86fec3a40873d8ae Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Mon, 27 Jul 2026 13:27:18 -0400 Subject: [PATCH 8/8] Only plot position error for output lever arm source. --- .../fusion_engine_client/analysis/analyzer.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 4b641746..6661a033 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -971,7 +971,7 @@ def _plot_data(name, idx, marker_style=None): max_y = 1.0 time_figure['layout']['yaxis1'].update(range=[0, max_y]) - name = source.replace(' ', '_').lower() + name = source.replace(' ', '_').replace('.', '').replace('(', '').replace(')', '').lower() # Topocentric hover: X/Y are spatial (East/North), not time, so both P1 and GPS time must come directly from # customdata (rows 0/1) rather than from the point's axis position -- see BuildTimeHoverTextFromTimes(). @@ -1025,7 +1025,8 @@ def plot_pose_error(self, reference: ReferenceData): @param reference The truth reference to compare against. """ - return self._plot_position_displacement_vs_reference(reference=reference) + return self._plot_position_displacement_vs_reference(reference=reference, + source_id=SourceIdentifier.OUTPUT_LEVER_ARM) def plot_position_displacement(self, reference_type: str): """! @@ -1039,12 +1040,13 @@ def plot_position_displacement(self, reference_type: str): reference = ReferenceData.resolve_cli_argument(reference=reference_type, loader=self.reader, source_id=self.default_source_id) if reference is None: - self.logger.info('No valid position solutions detected. Skipping displacement plots.') + self.logger.info('No valid position solutions detected. Skipping position displacement plots.') return None else: - return self._plot_position_displacement_vs_reference(reference=reference) + return self._plot_position_displacement_vs_reference(reference=reference, source_id=self.default_source_id) - def _plot_position_displacement_vs_reference(self, reference: Optional[ReferenceData] = None): + def _plot_position_displacement_vs_reference(self, reference: Optional[ReferenceData] = None, + source_id: Optional[int] = None): """! @brief Generate a topocentric (top-down) plot of position displacement (or error, if `reference` is an independent truth source -- see @ref ReferenceData) vs a reference position, as well as a plot of @@ -1056,8 +1058,11 @@ def _plot_position_displacement_vs_reference(self, reference: Optional[Reference if self.output_dir is None: return None + if source_id is None: + source_id = self.default_source_id + # Read the pose data. - result = self.reader.read(message_types=[PoseMessage], source_ids=self.default_source_id, **self.params) + result = self.reader.read(message_types=[PoseMessage], source_ids=source_id, **self.params) pose_data = result[PoseMessage.MESSAGE_TYPE] if len(pose_data.p1_time) == 0: @@ -1098,6 +1103,8 @@ def _plot_position_displacement_vs_reference(self, reference: Optional[Reference axis_title = reference.displacement_label source = f'Position {axis_title} vs. {"Reference" if reference.is_truth else reference.description}' + if source_id != SourceIdentifier.OUTPUT_LEVER_ARM: + source += f' (Source {source_id})' self._plot_displacement(source=source, title=axis_title, p1_time=p1_time, gps_time=gps_time, solution_type=solution_type,