From 6c17892fce211da09ceec1b8aab22f22d2a02ed5 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Thu, 8 Oct 2026 15:31:44 -0400 Subject: [PATCH 1/4] Interpolate reference yaw and roll across the +/-180 degree wrap. Previously, samples at 179 and -179 degrees interpolated to 0 instead of 180. --- .../analysis/reference.py | 39 +++++++++++++++---- python/tests/test_reference.py | 38 ++++++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/python/fusion_engine_client/analysis/reference.py b/python/fusion_engine_client/analysis/reference.py index 9059994f..b84d4ee6 100644 --- a/python/fusion_engine_client/analysis/reference.py +++ b/python/fusion_engine_client/analysis/reference.py @@ -16,24 +16,44 @@ _OWN_LOG_STATISTICS = ('first', 'first_fixed', 'median', 'median_fixed') -def _interp_columns(x: np.ndarray, y: Optional[np.ndarray], x_new: np.ndarray) -> Optional[np.ndarray]: +def _interp_columns(x: np.ndarray, y: Optional[np.ndarray], x_new: np.ndarray, + angle_rows: tuple = ()) -> Optional[np.ndarray]: """! - @brief Linearly interpolate each row of `y` (sampled at `x`) onto `x_new`, returning NaN for any query point - outside the range of `x` or where too few valid (non-NaN) samples exist to interpolate. + @brief Linearly interpolate each row of `y` (sampled at `x`) onto `x_new`. + + NaN samples in `y` are skipped, so a query point between two valid samples is interpolated across any NaN gap + between them. The result is NaN for any query point outside the range of `x`, and for every query point in a row + with fewer than 2 valid samples. + + @param x The sample times, sorted in ascending order. + @param y An MxN array of values sampled at `x`, or `None`. + @param x_new The times to interpolate onto. + @param angle_rows Indices of rows in `y` containing angles (in degrees) that wrap at +/-180 degrees. These rows are + unwrapped before interpolating, so the result follows the shorter arc between samples, and the result is + wrapped back to [-180, 180). + + @return An Mx`len(x_new)` array of interpolated values, or `None` if `y` is `None`. """ if y is None: return None + # Values outside the range of the sample times are left as NaN. out = np.full((y.shape[0], len(x_new)), np.nan) in_range = np.logical_and(x_new >= x[0], x_new <= x[-1]) if not np.any(in_range): return out + # Interpolate each row using only its valid samples. for row in range(y.shape[0]): valid = ~np.isnan(y[row, :]) if np.count_nonzero(valid) < 2: continue - out[row, in_range] = np.interp(x_new[in_range], x[valid], y[row, valid]) + + if row in angle_rows: + y_valid = np.degrees(np.unwrap(np.radians(y[row, valid]))) + out[row, in_range] = (np.interp(x_new[in_range], x[valid], y_valid) + 180.0) % 360.0 - 180.0 + else: + out[row, in_range] = np.interp(x_new[in_range], x[valid], y[row, valid]) return out @@ -134,7 +154,8 @@ def get_coverage_mask(self, gps_time_sec: np.ndarray, warn: bool = True) -> np.n "range will be omitted." % (self.description, coverage_percent)) return in_range - def _interpolate_cached(self, key: str, y: np.ndarray, gps_time_sec: np.ndarray) -> np.ndarray: + def _interpolate_cached(self, key: str, y: np.ndarray, gps_time_sec: np.ndarray, + angle_rows: tuple = ()) -> np.ndarray: """! @brief Interpolate `y` onto `gps_time_sec`, caching the result to avoid recomputing for repeated queries. @@ -145,6 +166,7 @@ def _interpolate_cached(self, key: str, y: np.ndarray, gps_time_sec: np.ndarray) @param key A name identifying which field is being interpolated (e.g., 'position'). @param y The data to be interpolated, sampled at `self.gps_time_sec`. @param gps_time_sec The GPS timestamps (sec) to interpolate onto. + @param angle_rows Indices of rows in `y` containing angles (in degrees) that wrap at +/-180 degrees. @return The interpolated data. """ @@ -156,7 +178,7 @@ def _interpolate_cached(self, key: str, y: np.ndarray, gps_time_sec: np.ndarray) else: signature = None - result = _interp_columns(self.gps_time_sec, y, gps_time_sec) + result = _interp_columns(self.gps_time_sec, y, gps_time_sec, angle_rows=angle_rows) if signature is not None: self._interp_cache[key] = (signature, result) return result @@ -190,13 +212,16 @@ def interpolate_ypr_deg(self, gps_time_sec: np.ndarray) -> Optional[np.ndarray]: @brief Interpolate this reference's YPR orientation (deg) onto the specified GPS timestamps (sec). Returns `None` if orientation is not available. Entries outside the covered time range are set to NaN. + + Yaw and roll are interpolated along the shorter arc between samples, so they wrap correctly at +/-180 degrees, + and are returned in the range [-180, 180). Pitch is bounded to +/-90 degrees and is interpolated directly. """ if self.ypr_deg is None: return None elif self.is_stationary: return np.tile(self.ypr_deg.reshape(3, 1), (1, len(gps_time_sec))) else: - return self._interpolate_cached('ypr', self.ypr_deg, gps_time_sec) + return self._interpolate_cached('ypr', self.ypr_deg, gps_time_sec, angle_rows=(0, 2)) # ------------------------------------------------------------------------------------------------------------- # Constructors diff --git a/python/tests/test_reference.py b/python/tests/test_reference.py index f9076c25..c907ec85 100644 --- a/python/tests/test_reference.py +++ b/python/tests/test_reference.py @@ -337,6 +337,44 @@ def test_interpolate_velocity_with_all_nan_row(self, moving_ref): def test_interpolate_ypr_none_when_unavailable(self, moving_ref): assert moving_ref.interpolate_ypr_deg(np.array([5.0])) is None + @staticmethod + def _ypr_ref(ypr_deg): + return ReferenceData(description='test', is_truth=True, + position_ecef_m=np.zeros((3, ypr_deg.shape[1])), + gps_time_sec=np.arange(ypr_deg.shape[1], dtype=float) * 10.0, + ypr_deg=ypr_deg) + + def test_interpolate_ypr_wraps_yaw_and_roll(self): + # Yaw and roll midway between 179 and -179 degrees are 180, not 0. + ref = self._ypr_ref(np.array([[179.0, -179.0], [10.0, 20.0], [-179.0, 179.0]])) + ypr = ref.interpolate_ypr_deg(np.array([2.5, 5.0, 7.5])) + assert ypr[0, :] == pytest.approx([179.5, -180.0, -179.5]) + assert ypr[2, :] == pytest.approx([-179.5, -180.0, 179.5]) + assert ypr[1, :] == pytest.approx([12.5, 15.0, 17.5]) + + def test_interpolate_ypr_without_wrap_is_linear(self): + ref = self._ypr_ref(np.array([[10.0, 50.0], [-80.0, 80.0], [-30.0, 30.0]])) + ypr = ref.interpolate_ypr_deg(np.array([0.0, 5.0, 10.0])) + assert ypr[0, :] == pytest.approx([10.0, 30.0, 50.0]) + assert ypr[1, :] == pytest.approx([-80.0, 0.0, 80.0]) + assert ypr[2, :] == pytest.approx([-30.0, 0.0, 30.0]) + + def test_interpolate_ypr_does_not_unwrap_pitch(self): + # Pitch values far apart are interpolated directly, not along the shorter arc through +/-180. + ref = self._ypr_ref(np.array([[0.0, 0.0], [89.0, -89.0], [0.0, 0.0]])) + ypr = ref.interpolate_ypr_deg(np.array([5.0])) + assert ypr[1, 0] == pytest.approx(0.0) + + def test_interpolate_ypr_nan_handling(self): + # A NaN sample is skipped (the wrap is still followed across the gap), an all-NaN row stays NaN, and query + # times outside the reference are NaN. + ref = self._ypr_ref(np.array([[170.0, np.nan, -170.0], [np.nan, np.nan, np.nan], [0.0, 1.0, 2.0]])) + ypr = ref.interpolate_ypr_deg(np.array([-1.0, 10.0, 21.0])) + assert ypr[0, 1] == pytest.approx(-180.0) + assert np.all(np.isnan(ypr[1, :])) + assert ypr[2, 1] == pytest.approx(1.0) + assert np.all(np.isnan(ypr[:, [0, 2]])) + def test_stationary_interpolation_tiles(self): ref = ReferenceData.from_stationary_ecef(np.array([1.0, 2.0, 3.0])) pos = ref.interpolate_position_ecef_m(np.array([0.0, 1.0, 2.0])) From 0b7a59e428d49acf0d06a8e0b2410925188fbbe1 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Thu, 8 Oct 2026 10:20:55 -0400 Subject: [PATCH 2/4] Show P1 time in hover text for decimated time scale min/max points. --- python/fusion_engine_client/analysis/analyzer.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index e232968d..48a43962 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -463,10 +463,12 @@ def _calc_stats(input): mode='markers', marker={'color': 'red'}), 1, 1) if dp1_stats is not None: - figure.add_trace(go.Scattergl(x=time, y=dp1_stats['max'], name='P1 Time Interval (Max)', + figure.add_trace(go.Scattergl(x=time, y=dp1_stats['max'], customdata=customdata, + name='P1 Time Interval (Max)', mode='markers', marker={'symbol': 'triangle-up-open'}), 1, 1) - figure.add_trace(go.Scattergl(x=time, y=dp1_stats['min'], name='P1 Time Interval (Min)', + figure.add_trace(go.Scattergl(x=time, y=dp1_stats['min'], customdata=customdata, + name='P1 Time Interval (Min)', mode='markers', marker={'symbol': 'triangle-down-open'}), 1, 1) @@ -474,10 +476,12 @@ def _calc_stats(input): mode='markers', marker={'color': 'green'}), 1, 1) if dgps_stats is not None: - figure.add_trace(go.Scattergl(x=time, y=dgps_stats['max'], name='GPS Time Interval (Max)', + figure.add_trace(go.Scattergl(x=time, y=dgps_stats['max'], customdata=customdata, + name='GPS Time Interval (Max)', mode='markers', marker={'symbol': 'triangle-up-open'}), 1, 1) - figure.add_trace(go.Scattergl(x=time, y=dgps_stats['min'], name='GPS Time Interval (Min)', + figure.add_trace(go.Scattergl(x=time, y=dgps_stats['min'], customdata=customdata, + name='GPS Time Interval (Min)', mode='markers', marker={'symbol': 'triangle-down-open'}), 1, 1) From 08cad970f14a875be9bd78719d686c9e8dc8dc28 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Thu, 8 Oct 2026 10:20:55 -0400 Subject: [PATCH 3/4] Show GPS time in seconds alongside week:tow in map hover text. --- python/fusion_engine_client/analysis/analyzer.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 48a43962..28b4a6a6 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1357,16 +1357,16 @@ 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[5]:.2f}
" + "LLA: %{lat:.8f}, %{lon:.8f}, %{customdata[6]:.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)" + "GPS: %{customdata[3]:.0f}:%{customdata[4]:.3f} (%{customdata[5]:.3f} sec)
" + "Std Dev: %{customdata[7]:.2f} m (2D), %{customdata[8]:.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)" + "
Error: %{customdata[9]:.2f} m (2D), %{customdata[10]:.2f} m (3D)" ) def _build_position_customdata(p1_time: np.ndarray, gps_time: np.ndarray, lla_deg: np.ndarray, @@ -1388,7 +1388,7 @@ def _build_position_customdata(p1_time: np.ndarray, gps_time: np.ndarray, lla_de # 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. 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], + numeric = np.column_stack((rel_time, p1_time, gps_week, gps_tow_sec, gps_time, 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: From caca389bc7aaf444a8782a4d51fa64ad2b558700 Mon Sep 17 00:00:00 2001 From: Adam Shapiro Date: Thu, 8 Oct 2026 10:24:27 -0400 Subject: [PATCH 4/4] Show Rel/P1/UTC/GPS time in sky plot hover text, matching other plots. --- .../fusion_engine_client/analysis/analyzer.py | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/python/fusion_engine_client/analysis/analyzer.py b/python/fusion_engine_client/analysis/analyzer.py index 28b4a6a6..54d66548 100755 --- a/python/fusion_engine_client/analysis/analyzer.py +++ b/python/fusion_engine_client/analysis/analyzer.py @@ -1771,8 +1771,8 @@ def _plot_gnss_skyplot_for_source(self, source_id: int, decimate=True): color_by_sv.append(color_by_prn[sv_id.get_prn()]) color_by_cn0.append(max_cn0_dbhz) - text = ['P1: %.1f sec
(Az, El): (%.2f, %.2f) deg
C/N0: %.1f dB-Hz' % - (t, a, e, c) for t, a, e, c in zip(p1_time, az_deg, el_deg, max_cn0_dbhz)] + text = ['(Az, El): (%.2f, %.2f) deg
C/N0: %.1f dB-Hz
%s' % (a, e, c, time_text) + for a, e, c, time_text in zip(az_deg, el_deg, max_cn0_dbhz, self._time_hover_text(p1_time))] # The per-point P1 time is what the time slider below the plot filters on (see _time_slider_js()). It # is not referenced by the hover text, which uses `text` above. @@ -4406,6 +4406,39 @@ def _time_hover_customdata(self, p1_time: np.ndarray, gps_time: Optional[np.ndar else: return np.vstack((p1_time,)) + def _time_hover_text(self, p1_time: np.ndarray, gps_time: Optional[np.ndarray] = None) -> List[str]: + """! + @brief Build the same Rel/P1/UTC/GPS hover text as `BuildTimeHoverTextFromTimes()` in Python. + + For plots whose hover text is generated ahead of time instead of by the injected hover JS (see + `plotly_data_support.js`). + + @param p1_time The P1 time for each point. + @param gps_time The GPS time for each point, or `None` to compute it from `p1_time`. + + @return A list of hover text strings, one per point, with lines separated by `
`. + """ + # Look up the GPS and UTC times for each point. + if gps_time is None: + gps_time = self.time_provider.p1_to_gps(p1_time) + utc_time = self.time_provider.gps_sec_to_datetime64_array(gps_time) + p1_t0_sec = float(self.t0) if self.t0 is not None else np.nan + + # Format the lines for each point, skipping any time that is not available. + result = [] + for p1, gps, utc in zip(p1_time, gps_time, utc_time): + lines = [] + if not np.isnan(p1): + lines.append(f'Rel: {p1 - p1_t0_sec:.3f} sec (P1: {p1:.3f} sec)') + if not np.isnan(gps): + if not np.isnat(utc): + lines.append(f"UTC: {np.datetime_as_string(utc, unit='ms').replace('T', ' ')}") + week = int(gps // SECONDS_PER_WEEK) + tow_sec = gps - week * SECONDS_PER_WEEK + lines.append(f'GPS: {week}:{tow_sec:.3f} ({gps:.3f} sec)') + result.append('
'.join(lines)) + return result + def _custom_tooltip_js(self, time_source: str = 'p1', precision: Optional[int] = 3, value_label: Optional[str] = None, show_name: bool = True, show_value: bool = True) -> str: """!