Source code for suboptimumg.loganalysis._utils
"""Shared internal utilities for the loganalysis package."""
from __future__ import annotations
import numpy as np
import pandas as pd
def get_time_array(df: pd.DataFrame, time_col: str = "time_s") -> np.ndarray:
"""Extract the time array from a DataFrame, checking columns then index."""
if time_col in df.columns:
return df[time_col].values.astype(np.float64)
if df.index.name == time_col:
return df.index.values.astype(np.float64)
raise KeyError(
f"Time column '{time_col}' not found in columns or index. "
f"Columns: {list(df.columns)}, Index name: {df.index.name}"
)
def detect_time_divisor(timestamps: np.ndarray) -> float:
"""Return the divisor to convert timestamp *differences* to seconds.
Heuristic based on the total span of the timestamp array:
* span > 1e8 → microseconds (divisor = 1 000 000)
* span > 1e4 → milliseconds (divisor = 1 000)
* else → seconds (divisor = 1)
This works reliably for any log session longer than ~100 seconds
(the typical minimum for CAN logs). For unusually short sessions
the caller can override via an explicit parameter.
"""
print(
"""
DEPRECATION WARNING: detect_time_divisor is deprecated and will be removed in a future release.
Please check the latest PERDA documentation. PERDA now deterministically parses and stores
the timestamp units of the log (e.g. seconds, milliseconds, microseconds), and supports converting
between units.
"""
)
if len(timestamps) < 2:
return 1.0
span = float(timestamps[-1] - timestamps[0])
if span > 1e8:
return 1e6 # microseconds
elif span > 1e4:
return 1e3 # milliseconds
return 1.0 # seconds
def time_label(divisor: float) -> str:
"""Human-readable label for the timestamp unit."""
if divisor >= 1e6:
return "μs"
elif divisor >= 1e3:
return "ms"
return "s"
[docs]
def unwrap_degrees(
df: pd.DataFrame,
columns: str | list[str],
suffix: str = "",
discontinuity: float = 180.0,
) -> pd.DataFrame:
"""Unwrap angle columns (degrees) to remove +-180 discontinuities.
The VN-300 yaw output reads 0 at north, +90 east, +180 south,
then jumps to -180 and climbs to 0. This creates step
discontinuities that break derivatives and integrations.
``np.unwrap`` removes them by adding/subtracting 360 at each jump.
Parameters
----------
df : pd.DataFrame
columns : str or list[str]
Column name(s) to unwrap.
suffix : str
If non-empty, the unwrapped result is written to
``<col><suffix>`` and the original column is kept.
If empty (default), the column is overwritten in place.
discontinuity : float
Maximum allowed step between consecutive samples (degrees).
Jumps larger than this are treated as wraps.
Returns
-------
pd.DataFrame
Same DataFrame with unwrapped column(s).
"""
if isinstance(columns, str):
columns = [columns]
for col in columns:
raw = df[col].values.astype(np.float64)
unwrapped = np.degrees(
np.unwrap(np.radians(raw), discont=np.radians(discontinuity))
)
out_col = col + suffix if suffix else col
df[out_col] = unwrapped
print(f"[utils] unwrap_degrees: {col} -> {out_col} ({len(df)} pts)")
return df
def downsample_indices(
n_points: int,
duration_s: float,
max_display_hz: float | None,
) -> np.ndarray:
"""Return integer indices that thin *n_points* to ~*max_display_hz*.
If *max_display_hz* is ``None`` or ``0``, returns all indices (no
downsampling). If the actual sample rate is already at or below
*max_display_hz*, returns all indices and prints a warning.
Parameters
----------
n_points : int
Total number of data points.
duration_s : float
Time span of the data in seconds.
max_display_hz : float or None
Target display sample rate. ``None`` or ``0`` disables.
Returns
-------
np.ndarray[int]
Indices into the original array.
"""
print(
"""
DEPRECATION WARNING: downsample_indices is deprecated and will be removed in a future release.
Please check the latest PERDA documentation. PERDA now includes built-in support for downsampling data for
display, either manually via `perda.plotting.subplots.stride_downsample` or automatically by passing in `max_display_resolution`
configuration objects when creating plots.
"""
)
if not max_display_hz or duration_s <= 0 or n_points <= 1:
return np.arange(n_points)
actual_hz = n_points / duration_s
if actual_hz <= max_display_hz:
import warnings
warnings.warn(
f"[plot] Requested {max_display_hz:.0f} Hz display rate but data "
f"is only {actual_hz:.1f} Hz — not upsampling."
)
return np.arange(n_points)
step = max(1, int(round(actual_hz / max_display_hz)))
idx = np.arange(0, n_points, step)
print(
f"[plot] Downsampled {n_points} → {len(idx)} pts for display "
f"(~{max_display_hz:.0f} Hz). Pass max_display_hz=0 to disable."
)
return idx