Source code for perda.utils.gps_analysis

import ipywidgets as widgets
import numpy as np
import plotly.graph_objects as go
from numpy import float64
from numpy.typing import NDArray

from ..core_data_structures.data_instance import DataInstance, left_join_data_instances
from ..plotting.parametric_plot import (
    plot_parametric_curve_square,
    plot_parametric_trimmer,
)
from ..plotting.plotting_constants import (
    DEFAULT_FONT_CONFIG,
    DEFAULT_GPS_MAP_CONFIG,
    DEFAULT_LAYOUT_CONFIG,
    FontConfig,
    GpsMapConfig,
    LayoutConfig,
)
from ..units import Timescale


[docs] def plot_gps_trimmer( lat: DataInstance, lon: DataInstance, title: str | None = None, layout_config: LayoutConfig = DEFAULT_LAYOUT_CONFIG, font_config: FontConfig = DEFAULT_FONT_CONFIG, timestamp_unit: Timescale = Timescale.MS, ) -> widgets.VBox: """ Interactive GPS coordinate trimmer with a dual-handle range slider. Displays a 2D scatter plot of GPS coordinates (longitude vs latitude) alongside an ipywidgets IntRangeSlider that trims which points are shown. Designed for use in Jupyter notebooks. Parameters ---------- lat : DataInstance Latitude values. Must have the same length and identical timestamps as `lon`. lon : DataInstance Longitude values. Must have the same length and identical timestamps as `lat`. title : str | None, optional layout_config : LayoutConfig, optional font_config : FontConfig, optional timestamp_unit : Timescale, optional Timestamp unit used for the slider timestamp labels. Returns ------- ipywidgets.VBox Examples -------- >>> widget = plot_gps_trimmer(lat_di, lon_di, title="GPS Track") >>> display(widget) """ if len(lat) != len(lon): raise ValueError( f"lat and lon DataInstances have different lengths ({len(lat)} vs {len(lon)}). " "Align them first using a join (e.g., inner_join_data_instances, left_join_data_instances)." ) if not np.array_equal(lat.timestamp_np, lon.timestamp_np): raise ValueError( "lat and lon DataInstances have mismatched timestamps. " "Align them first using a join (e.g., inner_join_data_instances, left_join_data_instances)." ) return plot_parametric_trimmer( x=lon.value_np, y=lat.value_np, timestamps=lat.timestamp_np, x_label="Longitude", y_label="Latitude", title=title, timestamp_unit=timestamp_unit, layout_config=layout_config, font_config=font_config, )
def _filter_gps_bounds( lat: NDArray[float64], lon: NDArray[float64], gps_map_config: GpsMapConfig, ) -> tuple[NDArray[float64], NDArray[float64]]: """ Remove GPS points outside valid lat/lon bounds. Parameters ---------- lat : NDArray[float64] Latitude values. lon : NDArray[float64] Longitude values. gps_map_config : GpsMapConfig Configuration containing lat_range and lon_range bounds. Returns ------- tuple[NDArray[float64], NDArray[float64]] Filtered (lat, lon) arrays. """ lat_lo, lat_hi = gps_map_config.lat_range lon_lo, lon_hi = gps_map_config.lon_range mask = (lat >= lat_lo) & (lat <= lat_hi) & (lon >= lon_lo) & (lon <= lon_hi) return lat[mask], lon[mask]
[docs] def create_representative_gps_image( lat_raw: DataInstance, lon_raw: DataInstance, vel_raw: DataInstance | None = None, vel_thresh: float = 0.5, title: str | None = None, layout_config: LayoutConfig = DEFAULT_LAYOUT_CONFIG, font_config: FontConfig = DEFAULT_FONT_CONFIG, gps_map_config: GpsMapConfig = DEFAULT_GPS_MAP_CONFIG, ) -> go.Figure | None: """ Generate a GPS trace on an interactive map background. Points outside the configured lat/lon bounds are filtered out. Uses CARTO Positron tiles by default (free, no API key required). Parameters ---------- lat_raw : DataInstance Raw latitude data instance, timestamp alignment base. lon_raw : DataInstance Raw longitude data instance. vel_raw : DataInstance | None, optional Raw velocity data instance. vel_thresh : float, optional Velocity threshold for trimming, by default 0.5, same unit as velocity. title : str | None, optional Title for the plot. layout_config : LayoutConfig, optional font_config : FontConfig, optional gps_map_config : GpsMapConfig, optional Map overlay and GPS filtering configuration. Returns ------- go.Figure | None Plotly figure with map background, or None if velocity never exceeds the threshold or no points remain after filtering. Examples -------- >>> fig = create_representative_gps_image(lat_di, lon_di, vel_raw=vel_di, title="Lap Track") >>> fig.show() """ lat_aligned, lon_aligned = left_join_data_instances(lat_raw, lon_raw) if vel_raw is not None: _, vel_aligned = left_join_data_instances(lat_aligned, vel_raw) mask = vel_aligned.value_np > vel_thresh ts_first_idx = np.argmax(mask) if mask.any() else -1 ts_last_idx = len(mask) - 1 - np.argmax(mask[::-1]) if mask.any() else -1 if ts_first_idx == -1 or ts_last_idx == -1: print("Velocity never exceeds threshold, skip graphing") return None lon_trimmed = lon_aligned.value_np[ts_first_idx : ts_last_idx + 1] lat_trimmed = lat_aligned.value_np[ts_first_idx : ts_last_idx + 1] else: lon_trimmed = lon_aligned.value_np lat_trimmed = lat_aligned.value_np lat_filtered, lon_filtered = _filter_gps_bounds( lat_trimmed, lon_trimmed, gps_map_config ) if len(lat_filtered) == 0: print("No GPS points remain after bounds filtering, skip graphing") return None fig = go.Figure() fig.add_trace( go.Scattermapbox( lat=lat_filtered, lon=lon_filtered, mode="lines+markers", marker=dict(size=gps_map_config.marker_size), line=dict( width=gps_map_config.line_width, color=gps_map_config.line_color, ), ) ) center_lat = (lat_filtered.min() + lat_filtered.max()) / 2 center_lon = (lon_filtered.min() + lon_filtered.max()) / 2 lat_span = lat_filtered.max() - lat_filtered.min() lon_span = lon_filtered.max() - lon_filtered.min() max_span = max(lat_span, lon_span) * (1 + gps_map_config.zoom_padding) zoom = np.log2(360 / max_span) if max_span > 0 else 15 mapbox_kwargs: dict[str, object] = dict( style=gps_map_config.mapbox_style, center=dict(lat=float(center_lat), lon=float(center_lon)), zoom=float(zoom), ) if gps_map_config.mapbox_token: mapbox_kwargs["accesstoken"] = gps_map_config.mapbox_token fig.update_layout( title=dict( text=title, x=layout_config.title_x, xanchor=layout_config.title_xanchor, yanchor=layout_config.title_yanchor, font=dict(size=font_config.large), ), mapbox=mapbox_kwargs, showlegend=False, width=int(layout_config.height), height=int(layout_config.height), margin=layout_config.margin, ) return fig