From f8f0ec04ce04b27b4bfe28c2b7ebb62460cc3a41 Mon Sep 17 00:00:00 2001 From: Zeerek Date: Thu, 13 Aug 2026 13:41:22 -0700 Subject: [PATCH] add the Kinematic Explorer A Streamlit app for visualising trajectory lattices, kinematic relationships and vehicle footprints across all three models. Every trajectory it draws comes from the C++ projectors, so the explorer and production code share one forward-simulation path rather than drifting apart. The polymath_kinematics.explorer subpackage holds pure functions - simulation, plotting, export, config and types - so they are testable without a Streamlit runtime. The app body stays in kinematic_explorer_app.py because that module *is* the Streamlit script: importing it from the console-script entry point would run every widget in bare mode before the server starts, so cli.py shells out to `streamlit run` instead. Offers geometry sliders including body overhangs, a front/rear axle reference selector, single-trajectory ramp controls, and CSV / JSON / PNG / SVG / PDF download. CI widens the wheel job to run the explorer suite, check that the subpackage imports cleanly, and start the console script headless - a healthy start is what proves the entry point does not execute the app body on import. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 20 +- CMakeLists.txt | 3 + package.xml | 4 + polymath_kinematics/explorer/__init__.py | 81 ++ polymath_kinematics/explorer/cli.py | 67 ++ polymath_kinematics/explorer/config.py | 120 ++ polymath_kinematics/explorer/export.py | 124 ++ polymath_kinematics/explorer/plotting.py | 542 +++++++++ polymath_kinematics/explorer/simulation.py | 490 ++++++++ polymath_kinematics/explorer/types.py | 95 ++ polymath_kinematics/kinematic_explorer_app.py | 1045 +++++++++++++++++ test/test_explorer.py | 657 +++++++++++ 12 files changed, 3244 insertions(+), 4 deletions(-) create mode 100644 polymath_kinematics/explorer/__init__.py create mode 100644 polymath_kinematics/explorer/cli.py create mode 100644 polymath_kinematics/explorer/config.py create mode 100644 polymath_kinematics/explorer/export.py create mode 100644 polymath_kinematics/explorer/plotting.py create mode 100644 polymath_kinematics/explorer/simulation.py create mode 100644 polymath_kinematics/explorer/types.py create mode 100644 polymath_kinematics/kinematic_explorer_app.py create mode 100644 test/test_explorer.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ea33596..719fed0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,17 +36,29 @@ jobs: name: colcon-logs-${{ matrix.ros }} path: ros_ws/log - # The pytest suites only run under colcon above, so cover the wheel path as well. - wheel: + # The pytest suites only run under colcon above, so cover the wheel path the explorer uses. + wheel_and_explorer: runs-on: ubuntu-latest - name: Wheel + name: Wheel and explorer steps: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@v7 - name: Install package with dev extra run: uv venv && uv pip install -e ".[dev]" - name: Python tests - run: uv run pytest test/test_python_bindings.py -v + run: uv run pytest test/test_python_bindings.py test/test_explorer.py -v + - name: Explorer imports cleanly + run: uv run python -c "import polymath_kinematics.explorer" + - name: Console script resolves and launches + # A healthy headless start proves the entry point doesn't run the app body on import. + run: | + uv run kinematic-explorer --server.headless=true --server.port=8501 & + for _ in $(seq 1 60); do + if curl -sf http://localhost:8501/_stcore/health; then exit 0; fi + sleep 1 + done + echo "explorer failed to become healthy" >&2 + exit 1 standalone_cmake: runs-on: ubuntu-latest diff --git a/CMakeLists.txt b/CMakeLists.txt index e654c43..1569f7d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -133,6 +133,9 @@ if(BUILD_TESTING) ament_add_pytest_test(test_python_bindings test/test_python_bindings.py WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} ) + ament_add_pytest_test(test_explorer test/test_explorer.py + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + ) endif() endif() diff --git a/package.xml b/package.xml index 8615ed8..1195cf1 100644 --- a/package.xml +++ b/package.xml @@ -17,6 +17,10 @@ ament_cmake_pytest ament_cmake_test catch2 + + python3-pandas + python3-matplotlib ament_cmake diff --git a/polymath_kinematics/explorer/__init__.py b/polymath_kinematics/explorer/__init__.py new file mode 100644 index 0000000..e7f73de --- /dev/null +++ b/polymath_kinematics/explorer/__init__.py @@ -0,0 +1,81 @@ +# Copyright (c) 2025-present Polymath Robotics, Inc. All rights reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Kinematic Explorer - trajectory simulation and visualization. + +This subpackage provides pure functions for trajectory simulation, plotting, +and export. The streamlit UI is in kinematic_explorer_app.py. +""" + +from .config import ( + KINEMATIC_EQUATIONS, + LATTICE_CONFIG, + TRAJECTORY_EQUATIONS, + LatticeConfig, +) +from .export import trajectories_to_dataframe +from .plotting import ( + get_traj_attr, + plot_analysis, + plot_articulated_footprint, + plot_lattice, + plot_trajectory_with_footprints, + plot_vehicle_footprint, + select_symmetric_trajectories, +) +from .simulation import ( + generate_lattice_articulated, + generate_lattice_bicycle, + generate_lattice_differential, + single_articulated_trajectory, + single_bicycle_trajectory, + single_differential_trajectory, +) +from .types import ( + AnyTrajectory, + ArticulatedTrajectory, + BicycleTrajectory, + DifferentialTrajectory, + Trajectory, +) + +__all__ = [ + # Types + 'AnyTrajectory', + 'ArticulatedTrajectory', + 'BicycleTrajectory', + 'DifferentialTrajectory', + 'Trajectory', + # Config + 'KINEMATIC_EQUATIONS', + 'LATTICE_CONFIG', + 'LatticeConfig', + 'TRAJECTORY_EQUATIONS', + # Simulation + 'generate_lattice_articulated', + 'generate_lattice_bicycle', + 'generate_lattice_differential', + 'single_articulated_trajectory', + 'single_bicycle_trajectory', + 'single_differential_trajectory', + # Plotting + 'get_traj_attr', + 'plot_analysis', + 'plot_articulated_footprint', + 'plot_lattice', + 'plot_trajectory_with_footprints', + 'plot_vehicle_footprint', + 'select_symmetric_trajectories', + # Export + 'trajectories_to_dataframe', +] diff --git a/polymath_kinematics/explorer/cli.py b/polymath_kinematics/explorer/cli.py new file mode 100644 index 0000000..0d80581 --- /dev/null +++ b/polymath_kinematics/explorer/cli.py @@ -0,0 +1,67 @@ +# Copyright (c) 2025-present Polymath Robotics, Inc. All rights reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Launcher for the Kinematic Explorer Streamlit app. + +Kept out of ``kinematic_explorer_app`` because that module's body *is* the Streamlit script: +importing it from an entry point would run every widget in bare mode before the server starts. +""" + +from __future__ import annotations + +import pathlib +import subprocess +import sys + + +def app_path() -> pathlib.Path: + """Absolute path to the Streamlit app script.""" + return pathlib.Path(__file__).resolve().parent.parent / 'kinematic_explorer_app.py' + + +def main(argv: list[str] | None = None) -> int: + """Run ``streamlit run kinematic_explorer_app.py``, forwarding any extra arguments. + + Extra arguments go to Streamlit, so e.g. ``kinematic-explorer --server.port=8600`` works. + """ + args = list(sys.argv[1:] if argv is None else argv) + + script = app_path() + if not script.is_file(): + print(f'error: could not locate the explorer app at {script}', file=sys.stderr) + return 1 + + command = [ + sys.executable, + '-m', + 'streamlit', + 'run', + str(script), + '--browser.gatherUsageStats=false', + ] + # Default to loopback; pass --server.address=0.0.0.0 to serve over the network. + if not any(arg.startswith('--server.address') for arg in args): + command.append('--server.address=localhost') + command.extend(args) + try: + return subprocess.call(command) + except FileNotFoundError: + print( + 'error: streamlit is not installed. Install the explorer extra with:\n uv pip install -e ".[explorer]"', + file=sys.stderr, + ) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/polymath_kinematics/explorer/config.py b/polymath_kinematics/explorer/config.py new file mode 100644 index 0000000..2b25541 --- /dev/null +++ b/polymath_kinematics/explorer/config.py @@ -0,0 +1,120 @@ +# Copyright (c) 2025-present Polymath Robotics, Inc. All rights reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Configuration constants and data structures for kinematic exploration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +# Footprint overhang slider defaults (metres, measured beyond the reference axle/centre). + +# Bicycle: pose reference is the rear axle, so front_overhang_m = wheelbase + front overhang. +DEFAULT_BICYCLE_FRONT_OVERHANG_M = 0.6 +DEFAULT_BICYCLE_REAR_OVERHANG_M = 0.5 + +# Differential drive: pose reference is the body centre, so the overhangs pass through directly. +DEFAULT_DIFFERENTIAL_FRONT_OVERHANG_M = 0.4 +DEFAULT_DIFFERENTIAL_REAR_OVERHANG_M = 0.4 + +# Articulated: base_link is the articulation joint; joint-to-bumper distances are axle distance + +# overhang, so a positive overhang makes the body extend behind the rear axle (counterweight) and +# ahead of the front axle (bucket). +DEFAULT_ARTICULATED_FRONT_OVERHANG_M = 1.0 +DEFAULT_ARTICULATED_REAR_OVERHANG_M = 0.8 + + +@dataclass +class LatticeConfig: + """Configuration for plotting trajectory lattices.""" + + group_key: str + group_label: str + group_unit: str + color_key: str + color_label: str + color_unit: str + color_is_angle: bool = False + angle_key: str = '' # Key for angle in trajectory selection + vel_key: str = '' # Key for velocity in trajectory selection + + +LATTICE_CONFIG: dict[str, LatticeConfig] = { + 'Differential Drive': LatticeConfig( + group_key='base_wheel_velocity', + group_label='Base Wheel Velocity', + group_unit='rad/s', + color_key='angular_velocity', + color_label='Angular Velocity', + color_unit='rad/s', + color_is_angle=False, + angle_key='angular_velocity', + vel_key='base_wheel_velocity', + ), + 'Bicycle': LatticeConfig( + group_key='drive_velocity', + group_label='Drive Velocity', + group_unit='m/s', + color_key='steering_angle', + color_label='Steering Angle', + color_unit='deg', + color_is_angle=True, + angle_key='steering_angle', + vel_key='drive_velocity', + ), + 'Articulated': LatticeConfig( + group_key='drive_velocity', + group_label='Drive Velocity', + group_unit='m/s', + color_key='articulation_angle', + color_label='Articulation Angle', + color_unit='deg', + color_is_angle=True, + angle_key='articulation_angle', + vel_key='drive_velocity', + ), +} + + +KINEMATIC_EQUATIONS = { + 'Differential Drive': { + 'title': 'Differential Drive Kinematics', + 'equations': [ + r'v = \frac{r}{2}(\omega_L + \omega_R)', + r'\omega = \frac{r}{W}(\omega_R - \omega_L)', + ], + 'variables': r'$r$ = wheel radius, $W$ = track width, $\omega_L, \omega_R$ = wheel velocities', + }, + 'Bicycle': { + 'title': 'Bicycle Model Kinematics', + 'equations': [ + r'\omega = \frac{v \tan(\delta)}{L}', + r'R = \frac{L}{\tan(\delta)}', + ], + 'variables': r'$L$ = wheelbase, $\delta$ = steering angle, $R$ = turning radius', + }, + 'Articulated': { + 'title': 'Articulated Vehicle Kinematics (Corke & Ridley)', + 'equations': [ + r'\omega = \frac{v \sin\gamma + L_r \dot{\gamma}}{L_f \cos\gamma + L_r}', + r'R_f = \frac{L_f \cos\gamma + L_r}{\sin\gamma}', + ], + 'variables': r'$L_f, L_r$ = front/rear distances to articulation joint, $\gamma$ = articulation angle, $\dot{\gamma}$ = articulation rate', + 'reference': "Corke & Ridley, IEEE IO'A 2001", + }, +} + +TRAJECTORY_EQUATIONS = r""" +**Trajectory Integration (Euler method):** +$$\dot{x} = v \cos(\theta), \quad \dot{y} = v \sin(\theta), \quad \dot{\theta} = \omega$$ +""" diff --git a/polymath_kinematics/explorer/export.py b/polymath_kinematics/explorer/export.py new file mode 100644 index 0000000..9e40aeb --- /dev/null +++ b/polymath_kinematics/explorer/export.py @@ -0,0 +1,124 @@ +# Copyright (c) 2025-present Polymath Robotics, Inc. All rights reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Export functions for trajectory data.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from .types import AnyTrajectory, ArticulatedTrajectory, BicycleTrajectory, DifferentialTrajectory + + +def trajectories_to_dataframe( + trajectories: list[AnyTrajectory], + model_type: str, +) -> pd.DataFrame: + """Convert trajectories to a pandas DataFrame for export. + + Uses vectorized construction for better performance. + + Args: + trajectories: List of trajectory dataclasses + model_type: One of "Differential Drive", "Bicycle", or "Articulated" + + Returns: + DataFrame with trajectory data, columns depend on model type + """ + if not trajectories: + return pd.DataFrame() + + # Calculate total size for preallocation + num_total = sum(len(trajectory) for trajectory in trajectories) + + # Preallocate arrays for common columns + trajectory_ids = np.empty(num_total, dtype=int) + time_array = np.empty(num_total) + x_array = np.empty(num_total) + y_array = np.empty(num_total) + theta_array = np.empty(num_total) + linear_velocity_array = np.empty(num_total) + angular_velocity_array = np.empty(num_total) + + # Model-specific arrays + if model_type == 'Differential Drive': + left_wheel_array = np.empty(num_total) + right_wheel_array = np.empty(num_total) + base_wheel_velocity_array = np.empty(num_total) + elif model_type == 'Bicycle': + drive_velocity_array = np.empty(num_total) + steering_angle_array = np.empty(num_total) + turning_radius_array = np.empty(num_total) + elif model_type == 'Articulated': + drive_velocity_array = np.empty(num_total) + articulation_angle_array = np.empty(num_total) + turning_radius_array = np.empty(num_total) + + # Fill arrays + current_index = 0 + for trajectory_index, trajectory in enumerate(trajectories): + num_points = len(trajectory) + end_index = current_index + num_points + + trajectory_ids[current_index:end_index] = trajectory_index + time_array[current_index:end_index] = trajectory.time + x_array[current_index:end_index] = trajectory.x + y_array[current_index:end_index] = trajectory.y + theta_array[current_index:end_index] = trajectory.theta + linear_velocity_array[current_index:end_index] = trajectory.linear_velocity + angular_velocity_array[current_index:end_index] = trajectory.angular_velocity + + if model_type == 'Differential Drive': + trajectory_differential: DifferentialTrajectory = trajectory # type: ignore[assignment] + left_wheel_array[current_index:end_index] = trajectory_differential.left_wheel + right_wheel_array[current_index:end_index] = trajectory_differential.right_wheel + base_wheel_velocity_array[current_index:end_index] = trajectory_differential.base_wheel_velocity + elif model_type == 'Bicycle': + trajectory_bicycle: BicycleTrajectory = trajectory # type: ignore[assignment] + drive_velocity_array[current_index:end_index] = trajectory_bicycle.drive_velocity + steering_angle_array[current_index:end_index] = trajectory_bicycle.steering_angle + turning_radius_array[current_index:end_index] = trajectory_bicycle.turning_radius + elif model_type == 'Articulated': + trajectory_articulated: ArticulatedTrajectory = trajectory # type: ignore[assignment] + drive_velocity_array[current_index:end_index] = trajectory_articulated.drive_velocity + articulation_angle_array[current_index:end_index] = trajectory_articulated.articulation_angle + turning_radius_array[current_index:end_index] = trajectory_articulated.turning_radius + + current_index = end_index + + # Build DataFrame from arrays (single allocation) + data: dict[str, np.ndarray] = { + 'trajectory_id': trajectory_ids, + 'time': time_array, + 'x': x_array, + 'y': y_array, + 'theta': theta_array, + 'linear_velocity': linear_velocity_array, + 'angular_velocity': angular_velocity_array, + } + + if model_type == 'Differential Drive': + data['left_wheel_velocity'] = left_wheel_array + data['right_wheel_velocity'] = right_wheel_array + data['base_wheel_velocity'] = base_wheel_velocity_array + elif model_type == 'Bicycle': + data['drive_velocity'] = drive_velocity_array + data['steering_angle'] = steering_angle_array + data['turning_radius'] = turning_radius_array + elif model_type == 'Articulated': + data['drive_velocity'] = drive_velocity_array + data['articulation_angle'] = articulation_angle_array + data['turning_radius'] = turning_radius_array + + return pd.DataFrame(data) diff --git a/polymath_kinematics/explorer/plotting.py b/polymath_kinematics/explorer/plotting.py new file mode 100644 index 0000000..eea164a --- /dev/null +++ b/polymath_kinematics/explorer/plotting.py @@ -0,0 +1,542 @@ +# Copyright (c) 2025-present Polymath Robotics, Inc. All rights reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Plotting functions for kinematic visualization. + +All functions return matplotlib Figure objects and have no streamlit dependencies. +""" + +from __future__ import annotations + +import matplotlib.pyplot as plt +import numpy as np + +from .config import LATTICE_CONFIG +from .types import AnyTrajectory, ArticulatedTrajectory, BicycleTrajectory, DifferentialTrajectory + + +def get_traj_attr(trajectory: AnyTrajectory, key: str) -> float: + """Get trajectory attribute by key name.""" + return getattr(trajectory, key) + + +def plot_vehicle_footprint( + ax: plt.Axes, + x: float, + y: float, + theta: float, + body_corners: np.ndarray, + color: str = 'blue', + alpha: float = 0.5, + steering_angle: float | None = None, + front_axle_offset_m: float | None = None, + track_width_m: float | None = None, + wheel_length_frac: float = 0.18, + wheel_width_frac: float = 0.08, +) -> None: + """Draw a single-body vehicle footprint at a given pose. + + ``body_corners`` is the projector's world-frame ``footprint`` — any vertex count, so the + heading arrow is measured from the polygon's extent along ``theta`` rather than from fixed + corner indices. + + ``steering_angle`` (radians) draws two front-wheel indicators at ``front_axle_offset_m`` + ahead of the pose reference, spaced by ``track_width_m``. Pass ``None`` for models without + steered wheels (e.g. differential drive). + """ + cos_theta, sin_theta = np.cos(theta), np.sin(theta) + rotation_matrix = np.array([[cos_theta, -sin_theta], [sin_theta, cos_theta]]) + + body = np.asarray(body_corners, dtype=float) + ax.fill(body[:, 0], body[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5) + + # Heading arrow spans the body's extent along the heading, measured through the pose. + origin = np.array([x, y]) + heading = np.array([cos_theta, sin_theta]) + lateral = np.array([-sin_theta, cos_theta]) + relative = body - origin + along = relative @ heading + across = relative @ lateral + body_length = float(along.max() - along.min()) + body_width = float(across.max() - across.min()) + arrow_start = origin + heading * along.min() + ax.arrow( + arrow_start[0], + arrow_start[1], + heading[0] * body_length, + heading[1] * body_length, + head_width=body_width * 0.3, + head_length=body_length * 0.1, + fc=color, + ec=color, + alpha=min(1.0, alpha + 0.3), + length_includes_head=True, + ) + + if steering_angle is not None and front_axle_offset_m is not None and track_width_m is not None: + # Front-wheel indicators: rectangles at the front axle, offset left/right by half the + # track width, each rotated by steering_angle in the body frame. + wheel_l = body_length * wheel_length_frac + wheel_w = body_width * wheel_width_frac + wheel_corners_centered = np.array([ + [-wheel_l / 2.0, -wheel_w / 2.0], + [+wheel_l / 2.0, -wheel_w / 2.0], + [+wheel_l / 2.0, +wheel_w / 2.0], + [-wheel_l / 2.0, +wheel_w / 2.0], + [-wheel_l / 2.0, -wheel_w / 2.0], + ]) + cos_s, sin_s = np.cos(steering_angle), np.sin(steering_angle) + wheel_rotation = np.array([[cos_s, -sin_s], [sin_s, cos_s]]) + wheel_corners_steered = (wheel_rotation @ wheel_corners_centered.T).T # (5, 2) + for lateral_offset in (-track_width_m / 2.0, +track_width_m / 2.0): + corners_body = wheel_corners_steered + np.array([front_axle_offset_m, lateral_offset]) + corners_in_world = (rotation_matrix @ corners_body.T).T + np.array([x, y]) + ax.fill( + corners_in_world[:, 0], + corners_in_world[:, 1], + color=color, + alpha=min(1.0, alpha + 0.2), + edgecolor=color, + linewidth=0.5, + ) + + +def plot_articulated_footprint( + ax: plt.Axes, + front_corners: np.ndarray, + rear_corners: np.ndarray, + joint_xy: np.ndarray, + front_theta: float, + color: str = 'blue', + alpha: float = 0.5, +) -> None: + """Draw an articulated vehicle footprint: two independently posed body polygons. + + Each polygon comes from the projector already in the world frame, anchored at its own axle, + so nothing here assumes a vertex count or ordering. ``joint_xy`` and ``front_theta`` come from + the projector's ``joint_pose`` and articulation angle; they place the joint marker and the + front-body heading arrow, which cannot be inferred from an arbitrary polygon. + """ + fc = np.asarray(front_corners, dtype=float) + rc = np.asarray(rear_corners, dtype=float) + joint = np.asarray(joint_xy, dtype=float) + + ax.fill(fc[:, 0], fc[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5) + ax.fill(rc[:, 0], rc[:, 1], color=color, alpha=alpha * 0.8, edgecolor=color, linewidth=0.5) + ax.plot(joint[0], joint[1], 'o', color=color, markersize=4, alpha=min(1.0, alpha + 0.3)) + + # Arrow runs from the joint to the front body's forward extent along the front-body heading. + heading = np.array([np.cos(front_theta), np.sin(front_theta)]) + lateral = np.array([-np.sin(front_theta), np.cos(front_theta)]) + relative = fc - joint + front_length = float((relative @ heading).max()) + front_width = float(np.ptp(relative @ lateral)) + if front_length > 0.0: + ax.arrow( + joint[0], + joint[1], + heading[0] * front_length, + heading[1] * front_length, + head_width=front_width * 0.2, + head_length=front_length * 0.08, + fc=color, + ec=color, + alpha=min(1.0, alpha + 0.3), + length_includes_head=True, + ) + + +def select_symmetric_trajectories( + trajectories: list[AnyTrajectory], + model_type: str, + num_angles: int = 5, + num_velocities: int = 1, +) -> list[AnyTrajectory]: + """Select a symmetric subset of trajectories for visualization. + + Args: + trajectories: Full list of trajectory dataclasses + model_type: Model type to determine which keys to use + num_angles: Number of steering/articulation angles (should be odd for symmetry) + num_velocities: Number of velocities to include + + Returns: + Filtered list of trajectories, symmetrically selected + """ + if not trajectories: + return [] + + config = LATTICE_CONFIG.get(model_type) + if config is None: + return trajectories[:5] + + angle_key = config.angle_key + vel_key = config.vel_key + + all_angles = sorted(set(get_traj_attr(trajectory, angle_key) for trajectory in trajectories)) + all_velocities = sorted(set(get_traj_attr(trajectory, vel_key) for trajectory in trajectories)) + + # Select symmetric angles + if len(all_angles) <= num_angles: + selected_angles = set(all_angles) + else: + indices = np.linspace(0, len(all_angles) - 1, num_angles, dtype=int) + selected_angles = {all_angles[i] for i in indices} + + # Select velocities + if len(all_velocities) <= num_velocities: + selected_velocities = set(all_velocities) + else: + indices = np.linspace(0, len(all_velocities) - 1, num_velocities, dtype=int) + selected_velocities = {all_velocities[i] for i in indices} + + selected = [ + trajectory + for trajectory in trajectories + if any(abs(get_traj_attr(trajectory, angle_key) - angle) < 1e-6 for angle in selected_angles) + and any(abs(get_traj_attr(trajectory, vel_key) - velocity) < 1e-6 for velocity in selected_velocities) + ] + + selected.sort(key=lambda trajectory: get_traj_attr(trajectory, angle_key)) + return selected + + +def _build_legend_label(trajectory: AnyTrajectory, model_type: str) -> str: + """Build legend label for a trajectory based on model type.""" + if model_type == 'Articulated': + articulated: ArticulatedTrajectory = trajectory # type: ignore[assignment] + return f'y={np.degrees(articulated.articulation_angle):+.0f} deg, v={articulated.drive_velocity:.1f}' + elif model_type == 'Bicycle': + bicycle: BicycleTrajectory = trajectory # type: ignore[assignment] + return f'd={np.degrees(bicycle.steering_angle):+.0f} deg, v={bicycle.drive_velocity:.1f}' + elif model_type == 'Differential Drive': + differential: DifferentialTrajectory = trajectory # type: ignore[assignment] + return f'w={differential.angular_velocity:+.1f}, base={differential.base_wheel_velocity:.0f}' + return '' + + +def plot_trajectory_with_footprints( + trajectories: list[AnyTrajectory], + model_type: str, + model_params: dict, + num_footprints: int = 5, +) -> plt.Figure: + """Plot selected trajectories with vehicle footprints at intervals. + + A trajectory with no footprint series (projector given no body dims) still has its path + drawn; only the overlay is skipped. + + Args: + trajectories: List of trajectory dataclasses (already filtered/selected) + model_type: "Differential Drive", "Bicycle", or "Articulated" + model_params: Bicycle only — ``wheelbase`` and ``track_width`` for the steered-wheel + indicators. Unused for the other models. + num_footprints: Number of footprints per trajectory + + Returns: + matplotlib Figure + """ + fig, ax = plt.subplots(figsize=(16, 9), layout='constrained') + + colormap = plt.cm.tab10 + legend_handles = [] + legend_labels = [] + + for index, trajectory in enumerate(trajectories): + color = colormap(index % 10) + + (line,) = ax.plot(trajectory.x, trajectory.y, color=color, linewidth=2, alpha=0.7) + legend_handles.append(line) + legend_labels.append(_build_legend_label(trajectory, model_type)) + + num_points = len(trajectory) + footprint_indices = np.linspace(0, num_points - 1, num_footprints, dtype=int) + + for footprint_num, footprint_index in enumerate(footprint_indices): + footprint_alpha = 0.3 + 0.5 * (footprint_num / max(1, num_footprints - 1)) + + if model_type == 'Articulated': + articulated: ArticulatedTrajectory = trajectory # type: ignore[assignment] + if ( + articulated.front_footprint_series is None + or articulated.rear_footprint_series is None + or articulated.joint_pose_series is None + ): + continue + joint_x, joint_y, joint_theta = articulated.joint_pose_series[footprint_index] + if articulated.articulation_angle_series is not None: + gamma = float(articulated.articulation_angle_series[footprint_index]) + else: + gamma = articulated.articulation_angle + plot_articulated_footprint( + ax, + articulated.front_footprint_series[footprint_index], + articulated.rear_footprint_series[footprint_index], + joint_xy=np.array([joint_x, joint_y]), + # joint_pose.theta is the rear-body heading; the front body leads it by gamma. + front_theta=joint_theta + gamma, + color=color, + alpha=footprint_alpha, + ) + elif model_type == 'Bicycle': + bicycle: BicycleTrajectory = trajectory # type: ignore[assignment] + if bicycle.footprint_series is None: + continue + if bicycle.steering_angle_series is not None: + sample_steering_angle = float(bicycle.steering_angle_series[footprint_index]) + else: + sample_steering_angle = bicycle.steering_angle + plot_vehicle_footprint( + ax, + trajectory.x[footprint_index], + trajectory.y[footprint_index], + trajectory.theta[footprint_index], + bicycle.footprint_series[footprint_index], + color=color, + alpha=footprint_alpha, + steering_angle=sample_steering_angle, + front_axle_offset_m=model_params['wheelbase'], + track_width_m=model_params['track_width'], + ) + else: # Differential Drive — no steered wheels. + if trajectory.footprint_series is None: + continue + plot_vehicle_footprint( + ax, + trajectory.x[footprint_index], + trajectory.y[footprint_index], + trajectory.theta[footprint_index], + trajectory.footprint_series[footprint_index], + color=color, + alpha=footprint_alpha, + ) + + ax.set_xlabel('X (m)') + ax.set_ylabel('Y (m)') + ax.set_title(f'Trajectory with Vehicle Footprints ({model_type})') + # Equal data scaling (circles stay circular) but force a 16:9 landscape box; the view + # limits expand to fill the box rather than distorting the geometry. + ax.set_aspect('equal') + ax.set_box_aspect(9 / 16) + ax.grid(True, alpha=0.3) + ax.axhline(y=0, color='k', linewidth=0.5) + ax.axvline(x=0, color='k', linewidth=0.5) + + if legend_handles: + title = { + 'Articulated': 'Articulation', + 'Bicycle': 'Steering', + 'Differential Drive': 'Angular Vel', + }.get(model_type, '') + ax.legend(legend_handles, legend_labels, loc='upper right', title=title) + + return fig + + +def plot_lattice( + trajectories: list[AnyTrajectory], + model_type: str, + group_values: list[float], +) -> plt.Figure: + """Plot trajectory lattice grouped by velocity, colored by steering/articulation angle. + + Args: + trajectories: List of trajectory dataclasses + model_type: "Differential Drive", "Bicycle", or "Articulated" + group_values: List of velocity values to create subplots for + + Returns: + matplotlib Figure + """ + config = LATTICE_CONFIG[model_type] + + num_columns = len(group_values) + # 16:9 landscape; grow width for the (rare) multi-column case. + fig_width = max(16, 8 * num_columns) + fig, axes = plt.subplots(1, num_columns, figsize=(fig_width, 9), layout='constrained') + + if num_columns == 1: + axes = [axes] + + color_values = [get_traj_attr(trajectory, config.color_key) for trajectory in trajectories] + if config.color_is_angle: + color_values_display = [np.degrees(value) for value in color_values] + else: + color_values_display = color_values + + colormap = plt.cm.coolwarm + normalizer = plt.Normalize(vmin=min(color_values_display), vmax=max(color_values_display)) + + for ax, group_value in zip(axes, group_values): + ax.set_title(f'{config.group_label} = {group_value:.1f} {config.group_unit}') + ax.set_xlabel('X (m)') + ax.set_ylabel('Y (m)') + # Equal data scaling (circles stay circular) with a 16:9 landscape box. + ax.set_aspect('equal') + ax.set_box_aspect(9 / 16) + ax.grid(True, alpha=0.3) + ax.axhline(y=0, color='k', linewidth=0.5) + ax.axvline(x=0, color='k', linewidth=0.5) + + for trajectory in trajectories: + if abs(get_traj_attr(trajectory, config.group_key) - group_value) < 0.001: + raw_color_value = get_traj_attr(trajectory, config.color_key) + color_value = np.degrees(raw_color_value) if config.color_is_angle else raw_color_value + color = colormap(normalizer(color_value)) + ax.plot(trajectory.x, trajectory.y, color=color, linewidth=1.5, alpha=0.8) + ax.plot(trajectory.x[-1], trajectory.y[-1], 'o', color=color, markersize=3) + + scalar_mappable = plt.cm.ScalarMappable(cmap=colormap, norm=normalizer) + scalar_mappable.set_array([]) + colorbar = fig.colorbar(scalar_mappable, ax=axes, orientation='horizontal', fraction=0.05, pad=0.12) + colorbar.set_label(f'{config.color_label} ({config.color_unit})') + + return fig + + +def plot_analysis( + trajectories: list[AnyTrajectory], + model_type: str, + group_values: list[float], +) -> plt.Figure: + """Plot kinematic analysis for all velocities in the lattice. + + Args: + trajectories: List of trajectory dataclasses + model_type: "Differential Drive", "Bicycle", or "Articulated" + group_values: List of velocity values to analyze + + Returns: + matplotlib Figure with two subplots + """ + fig, axes = plt.subplots(1, 2, figsize=(10, 4), layout='constrained') + + if model_type == 'Articulated': + ax_left = axes[0] + for velocity in group_values: + angles = [] + angular_velocities = [] + for trajectory in trajectories: + articulated: ArticulatedTrajectory = trajectory # type: ignore[assignment] + if abs(articulated.drive_velocity - velocity) < 0.001: + angles.append(np.degrees(articulated.articulation_angle)) + angular_velocities.append(articulated.angular_velocity) + if angles: + ax_left.plot(angles, angular_velocities, 'o-', label=f'v={velocity:.1f} m/s', markersize=5) + ax_left.set_xlabel('Articulation Angle (deg)') + ax_left.set_ylabel('Angular Velocity (rad/s)') + ax_left.set_title('Articulation -> Angular Velocity') + ax_left.legend() + ax_left.grid(True, alpha=0.3) + + ax_right = axes[1] + radii = [] + angles = [] + for trajectory in trajectories: + articulated: ArticulatedTrajectory = trajectory # type: ignore[assignment] + if abs(articulated.articulation_angle) > 0.00001: + radii.append(abs(articulated.turning_radius)) + angles.append(np.degrees(abs(articulated.articulation_angle))) + if angles: + ax_right.plot(angles, radii, 'o-', markersize=5) + + ax_right.set_xlabel('|Articulation Angle| (deg)') + ax_right.set_ylabel('Turning Radius (m)') + ax_right.set_title('Turning Radius vs Articulation') + # Single unlabeled series — no legend. + ax_right.grid(True, alpha=0.3) + ax_right.set_ylim(bottom=0) + + elif model_type == 'Bicycle': + ax_left = axes[0] + for velocity in group_values: + angles = [] + angular_velocities = [] + for trajectory in trajectories: + bicycle: BicycleTrajectory = trajectory # type: ignore[assignment] + if abs(bicycle.drive_velocity - velocity) < 0.001: + angles.append(np.degrees(bicycle.steering_angle)) + angular_velocities.append(bicycle.angular_velocity) + if angles: + ax_left.plot(angles, angular_velocities, 'o-', label=f'v={velocity:.1f} m/s', markersize=5) + ax_left.set_xlabel('Steering Angle (deg)') + ax_left.set_ylabel('Angular Velocity (rad/s)') + ax_left.set_title('Steering -> Angular Velocity') + ax_left.legend() + ax_left.grid(True, alpha=0.3) + + ax_right = axes[1] + + radii = [] + angles = [] + for trajectory in trajectories: + bicycle: BicycleTrajectory = trajectory # type: ignore[assignment] + if abs(bicycle.steering_angle) > 0.00001: + radii.append(abs(bicycle.turning_radius)) + angles.append(np.degrees(abs(bicycle.steering_angle))) + + if angles: + ax_right.plot(angles, radii, 'o-', markersize=5) + + ax_right.set_xlabel('|Steering Angle| (deg)') + ax_right.set_ylabel('Turning Radius (m)') + ax_right.set_title('Turning Radius vs Steering') + # Single unlabeled series — no legend. + ax_right.grid(True, alpha=0.3) + ax_right.set_ylim(bottom=0) + + elif model_type == 'Differential Drive': + ax_left = axes[0] + base_velocity = group_values[len(group_values) // 2] + left_wheel_velocities = [] + right_wheel_velocities = [] + angular_velocities = [] + for trajectory in trajectories: + differential: DifferentialTrajectory = trajectory # type: ignore[assignment] + if abs(differential.base_wheel_velocity - base_velocity) < 0.001: + left_wheel_velocities.append(differential.left_wheel) + right_wheel_velocities.append(differential.right_wheel) + angular_velocities.append(differential.angular_velocity) + if left_wheel_velocities: + ax_left.plot(angular_velocities, left_wheel_velocities, 'o-', label='Left wheel', markersize=5) + ax_left.plot(angular_velocities, right_wheel_velocities, 's-', label='Right wheel', markersize=5) + ax_left.set_xlabel('Angular Velocity (rad/s)') + ax_left.set_ylabel('Wheel Velocity (rad/s)') + ax_left.set_title(f'Wheel Velocities (base={base_velocity:.0f} rad/s)') + ax_left.legend() + ax_left.grid(True, alpha=0.3) + + ax_right = axes[1] + for base_velocity in group_values: + linear_velocities = [] + angular_velocities_list = [] + for trajectory in trajectories: + differential: DifferentialTrajectory = trajectory # type: ignore[assignment] + if abs(differential.base_wheel_velocity - base_velocity) < 0.001: + linear_velocities.append(differential.linear_velocity) + angular_velocities_list.append(differential.angular_velocity) + if linear_velocities: + ax_right.plot( + angular_velocities_list, + linear_velocities, + 'o-', + label=f'base={base_velocity:.0f} rad/s', + markersize=5, + ) + ax_right.set_xlabel('Angular Velocity (rad/s)') + ax_right.set_ylabel('Linear Velocity (m/s)') + ax_right.set_title('Body Velocities') + ax_right.legend() + ax_right.grid(True, alpha=0.3) + + return fig diff --git a/polymath_kinematics/explorer/simulation.py b/polymath_kinematics/explorer/simulation.py new file mode 100644 index 0000000..66e513d --- /dev/null +++ b/polymath_kinematics/explorer/simulation.py @@ -0,0 +1,490 @@ +# Copyright (c) 2025-present Polymath Robotics, Inc. All rights reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Trajectory simulation and lattice generation functions. + +Every trajectory comes from the matching C++ projector, so the explorer and the production +kinematics share one forward-simulation codepath. Lattice cells set initial == target to hold +a constant command; the single-trajectory helpers drive initial != target to show the ramp. + +All functions are pure (no streamlit dependency) for testability and reuse. +""" + +from __future__ import annotations + +import numpy as np + +from polymath_kinematics import ( + ArticulatedModel, + ArticulatedProjector, + AxleReference, + BicycleModel, + BicycleProjector, + DifferentialDriveModel, + DifferentialDriveProjector, + Footprint, + Pose2D, +) + +from .types import ArticulatedTrajectory, BicycleTrajectory, DifferentialTrajectory + + +def _states_to_arrays(states): + """Pull (time, x, y, theta, omega) arrays out of a list of ProjectedState samples.""" + n = len(states) + time_arr = np.empty(n) + x_arr = np.empty(n) + y_arr = np.empty(n) + theta_arr = np.empty(n) + omega_arr = np.empty(n) + for i, s in enumerate(states): + time_arr[i] = s.time_s + x_arr[i] = s.pose.x + y_arr[i] = s.pose.y + theta_arr[i] = s.pose.theta + omega_arr[i] = s.angular_velocity_rad_s + return time_arr, x_arr, y_arr, theta_arr, omega_arr + + +def _footprint_series(states, attr: str): + """Stack the per-sample footprint polygons from a projection into an (N, V, 2) array. + + ``attr`` is the projected-state attribute holding the footprint (``footprint`` for single-body + models, ``front_footprint`` / ``rear_footprint`` for articulated). V is the polygon's vertex + count. Returns ``None`` if any sample has an empty footprint (no footprint was set on the + projector), so downstream consumers can fall back cleanly. + """ + corners = [] + for s in states: + polygon = getattr(s, attr) + if not polygon: + return None + corners.append([[p.x, p.y] for p in polygon]) + return np.asarray(corners, dtype=float) + + +def _joint_pose_series(states): + """Stack the articulated projector's per-sample joint pose into an (N, 3) [x, y, theta] array.""" + return np.asarray([[s.joint_pose.x, s.joint_pose.y, s.joint_pose.theta] for s in states], dtype=float) + + +def generate_lattice_differential( + wheel_radius: float, + track_width: float, + base_wheel_velocities: tuple[float, ...], + wheel_velocity_diffs: tuple[float, ...], + duration: float, + time_step: float = 0.02, + footprint: Footprint | None = None, +) -> list[DifferentialTrajectory]: + """Generate trajectory lattice by sweeping wheel velocity differences. + + Each wheel-velocity pair becomes a body command, then a constant-command projection. + """ + model = DifferentialDriveModel(wheel_radius, track_width) + + # Resolve all cells first: the projector bounds must bracket the sweep, or outer cells clamp. + cells = [] + for base_velocity in base_wheel_velocities: + for velocity_diff in wheel_velocity_diffs: + left_wheel = base_velocity - velocity_diff / 2 + right_wheel = base_velocity + velocity_diff / 2 + body_velocity = model.wheel_velocities_to_body_velocity(left_wheel, right_wheel) + cells.append(( + base_velocity, + left_wheel, + right_wheel, + body_velocity.linear_velocity_m_s, + body_velocity.angular_velocity_rad_s, + )) + + if not cells: + return [] + + linear_velocities = [cell[3] for cell in cells] + angular_velocities = [cell[4] for cell in cells] + projector = DifferentialDriveProjector( + model, + min(linear_velocities), + max(linear_velocities), + min(angular_velocities), + max(angular_velocities), + footprint or [], + ) + + trajectories: list[DifferentialTrajectory] = [] + for base_velocity, left_wheel, right_wheel, linear_velocity, angular_velocity in cells: + states = projector.project( + horizon_s=duration, + dt_s=time_step, + initial_pose=Pose2D(), + initial_linear_velocity_m_s=linear_velocity, + initial_angular_velocity_rad_s=angular_velocity, + target_linear_velocity_m_s=linear_velocity, + target_angular_velocity_rad_s=angular_velocity, + # initial == target, so the ramp never fires and the acceleration is irrelevant. + linear_acceleration_m_s2=0.0, + angular_acceleration_rad_s2=0.0, + ) + time_arr, x_arr, y_arr, theta_arr, _omega = _states_to_arrays(states) + + trajectories.append( + DifferentialTrajectory( + time=time_arr, + x=x_arr, + y=y_arr, + theta=theta_arr, + linear_velocity=linear_velocity, + angular_velocity=angular_velocity, + left_wheel=left_wheel, + right_wheel=right_wheel, + base_wheel_velocity=base_velocity, + footprint_series=_footprint_series(states, 'footprint'), + ) + ) + + return trajectories + + +def generate_lattice_bicycle( + wheelbase: float, + track_width: float, + wheel_radius: float, + drive_velocities: tuple[float, ...], + steering_angles: tuple[float, ...], + duration: float, + time_step: float = 0.02, + min_steering_angle_rad: float | None = None, + max_steering_angle_rad: float | None = None, + steering_rate_rad_s: float = 0.0, + axle_reference: AxleReference = AxleReference.REAR, + footprint: Footprint | None = None, +) -> list[BicycleTrajectory]: + """Generate trajectory lattice by sweeping steering angles. + + Each lattice cell is generated via BicycleProjector.project() with the + initial steering angle equal to the target, so no ramp occurs and the + behavior matches a constant-steering sweep. ``steering_rate_rad_s`` is + exposed for future use; with a non-zero value and ``min/max`` set, the + projector will ramp toward the target — but a constant-input lattice + is the typical use. + """ + angles = list(steering_angles) + if not angles: + return [] + min_angle = min_steering_angle_rad if min_steering_angle_rad is not None else min(angles) + max_angle = max_steering_angle_rad if max_steering_angle_rad is not None else max(angles) + + model = BicycleModel(wheelbase, track_width, wheel_radius) + projector = BicycleProjector(model, min_angle, max_angle, axle_reference, footprint or []) + + trajectories: list[BicycleTrajectory] = [] + for drive_velocity in drive_velocities: + for steering_angle in angles: + states = projector.project( + horizon_s=duration, + dt_s=time_step, + initial_pose=Pose2D(), + initial_steering_angle_rad=steering_angle, + target_steering_angle_rad=steering_angle, + steering_rate_rad_s=steering_rate_rad_s, + linear_velocity_m_s=drive_velocity, + ) + time_arr, x_arr, y_arr, theta_arr, _omega = _states_to_arrays(states) + + trajectories.append( + BicycleTrajectory( + time=time_arr, + x=x_arr, + y=y_arr, + theta=theta_arr, + linear_velocity=drive_velocity, + angular_velocity=states[0].angular_velocity_rad_s, + drive_velocity=drive_velocity, + steering_angle=steering_angle, + turning_radius=states[0].steering_state.turning_radius_m, + footprint_series=_footprint_series(states, 'footprint'), + ) + ) + + return trajectories + + +def generate_lattice_articulated( + articulation_to_front: float, + articulation_to_rear: float, + front_track: float, + rear_track: float, + front_wheel_radius: float, + rear_wheel_radius: float, + drive_velocities: tuple[float, ...], + articulation_angles: tuple[float, ...], + duration: float, + time_step: float = 0.02, + min_articulation_angle_rad: float | None = None, + max_articulation_angle_rad: float | None = None, + articulation_rate_rad_s: float = 0.0, + axle_reference: AxleReference = AxleReference.REAR, + front_footprint: Footprint | None = None, + rear_footprint: Footprint | None = None, +) -> list[ArticulatedTrajectory]: + """Generate trajectory lattice by sweeping articulation angles. + + See ``generate_lattice_bicycle`` for the projector-based rationale. + """ + angles = list(articulation_angles) + if not angles: + return [] + min_angle = min_articulation_angle_rad if min_articulation_angle_rad is not None else min(angles) + max_angle = max_articulation_angle_rad if max_articulation_angle_rad is not None else max(angles) + + model = ArticulatedModel( + articulation_to_front, + articulation_to_rear, + front_track, + rear_track, + front_wheel_radius, + rear_wheel_radius, + ) + projector = ArticulatedProjector( + model, min_angle, max_angle, axle_reference, front_footprint or [], rear_footprint or [] + ) + + trajectories: list[ArticulatedTrajectory] = [] + for drive_velocity in drive_velocities: + for articulation_angle in angles: + states = projector.project( + horizon_s=duration, + dt_s=time_step, + initial_pose=Pose2D(), + initial_articulation_angle_rad=articulation_angle, + target_articulation_angle_rad=articulation_angle, + articulation_rate_rad_s=articulation_rate_rad_s, + linear_velocity_m_s=drive_velocity, + ) + time_arr, x_arr, y_arr, theta_arr, _omega = _states_to_arrays(states) + + trajectories.append( + ArticulatedTrajectory( + time=time_arr, + x=x_arr, + y=y_arr, + theta=theta_arr, + linear_velocity=drive_velocity, + angular_velocity=states[0].angular_velocity_rad_s, + drive_velocity=drive_velocity, + articulation_angle=articulation_angle, + turning_radius=states[0].vehicle_state.front_axle_turning_radius_m, + front_footprint_series=_footprint_series(states, 'front_footprint'), + rear_footprint_series=_footprint_series(states, 'rear_footprint'), + joint_pose_series=_joint_pose_series(states), + ) + ) + + return trajectories + + +# ---------------------------------------------------------------------------- +# Single-trajectory helpers — used by the "Single Projected Trajectory" sections +# of the Streamlit explorer. Each returns one trajectory generated by the +# corresponding C++ projector, packed into a dataclass that the existing +# plot_trajectory_with_footprints helper can consume. +# ---------------------------------------------------------------------------- + + +def single_bicycle_trajectory( + wheelbase: float, + track_width: float, + wheel_radius: float, + initial_steering_angle_rad: float, + target_steering_angle_rad: float, + steering_rate_rad_s: float, + drive_velocity: float, + duration: float, + time_step: float = 0.02, + min_steering_angle_rad: float | None = None, + max_steering_angle_rad: float | None = None, + axle_reference: AxleReference = AxleReference.REAR, + footprint: Footprint | None = None, +) -> BicycleTrajectory: + """Ramp a bicycle steering angle from `initial` toward `target` at `rate` rad/s + over `duration` seconds, returning the resulting trajectory. + + Default min/max bracket `[min(initial, target), max(initial, target)]` so the + clamp never fires unless the caller explicitly sets bounds. + """ + if min_steering_angle_rad is None: + min_steering_angle_rad = min(initial_steering_angle_rad, target_steering_angle_rad) + if max_steering_angle_rad is None: + max_steering_angle_rad = max(initial_steering_angle_rad, target_steering_angle_rad) + + model = BicycleModel(wheelbase, track_width, wheel_radius) + projector = BicycleProjector(model, min_steering_angle_rad, max_steering_angle_rad, axle_reference, footprint or []) + states = projector.project( + horizon_s=duration, + dt_s=time_step, + initial_pose=Pose2D(), + initial_steering_angle_rad=initial_steering_angle_rad, + target_steering_angle_rad=target_steering_angle_rad, + steering_rate_rad_s=steering_rate_rad_s, + linear_velocity_m_s=drive_velocity, + ) + time_arr, x_arr, y_arr, theta_arr, _omega = _states_to_arrays(states) + steering_series = np.asarray([s.steering_angle_rad for s in states]) + return BicycleTrajectory( + time=time_arr, + x=x_arr, + y=y_arr, + theta=theta_arr, + linear_velocity=drive_velocity, + angular_velocity=states[0].angular_velocity_rad_s, + drive_velocity=drive_velocity, + steering_angle=target_steering_angle_rad, + turning_radius=states[-1].steering_state.turning_radius_m, + steering_angle_series=steering_series, + footprint_series=_footprint_series(states, 'footprint'), + ) + + +def single_articulated_trajectory( + articulation_to_front: float, + articulation_to_rear: float, + front_track: float, + rear_track: float, + front_wheel_radius: float, + rear_wheel_radius: float, + initial_articulation_angle_rad: float, + target_articulation_angle_rad: float, + articulation_rate_rad_s: float, + drive_velocity: float, + duration: float, + time_step: float = 0.02, + min_articulation_angle_rad: float | None = None, + max_articulation_angle_rad: float | None = None, + axle_reference: AxleReference = AxleReference.REAR, + front_footprint: Footprint | None = None, + rear_footprint: Footprint | None = None, +) -> ArticulatedTrajectory: + """Ramp an articulation angle from `initial` toward `target` at `rate` rad/s.""" + if min_articulation_angle_rad is None: + min_articulation_angle_rad = min(initial_articulation_angle_rad, target_articulation_angle_rad) + if max_articulation_angle_rad is None: + max_articulation_angle_rad = max(initial_articulation_angle_rad, target_articulation_angle_rad) + + model = ArticulatedModel( + articulation_to_front, + articulation_to_rear, + front_track, + rear_track, + front_wheel_radius, + rear_wheel_radius, + ) + projector = ArticulatedProjector( + model, + min_articulation_angle_rad, + max_articulation_angle_rad, + axle_reference, + front_footprint or [], + rear_footprint or [], + ) + states = projector.project( + horizon_s=duration, + dt_s=time_step, + initial_pose=Pose2D(), + initial_articulation_angle_rad=initial_articulation_angle_rad, + target_articulation_angle_rad=target_articulation_angle_rad, + articulation_rate_rad_s=articulation_rate_rad_s, + linear_velocity_m_s=drive_velocity, + ) + time_arr, x_arr, y_arr, theta_arr, _omega = _states_to_arrays(states) + articulation_series = np.asarray([s.articulation_angle_rad for s in states]) + return ArticulatedTrajectory( + time=time_arr, + x=x_arr, + y=y_arr, + theta=theta_arr, + linear_velocity=drive_velocity, + angular_velocity=states[0].angular_velocity_rad_s, + drive_velocity=drive_velocity, + articulation_angle=target_articulation_angle_rad, + turning_radius=states[-1].vehicle_state.front_axle_turning_radius_m, + articulation_angle_series=articulation_series, + front_footprint_series=_footprint_series(states, 'front_footprint'), + rear_footprint_series=_footprint_series(states, 'rear_footprint'), + joint_pose_series=_joint_pose_series(states), + ) + + +def single_differential_trajectory( + wheel_radius: float, + track_width: float, + initial_linear_velocity: float, + initial_angular_velocity: float, + target_linear_velocity: float, + target_angular_velocity: float, + linear_acceleration: float, + angular_acceleration: float, + duration: float, + time_step: float = 0.02, + min_linear_velocity: float | None = None, + max_linear_velocity: float | None = None, + min_angular_velocity: float | None = None, + max_angular_velocity: float | None = None, + footprint: Footprint | None = None, +) -> DifferentialTrajectory: + """Ramp diff-drive body command (v, omega) from initial toward target at the + given accelerations, integrating pose forward over `duration`. + """ + if min_linear_velocity is None: + min_linear_velocity = min(initial_linear_velocity, target_linear_velocity) + if max_linear_velocity is None: + max_linear_velocity = max(initial_linear_velocity, target_linear_velocity) + if min_angular_velocity is None: + min_angular_velocity = min(initial_angular_velocity, target_angular_velocity) + if max_angular_velocity is None: + max_angular_velocity = max(initial_angular_velocity, target_angular_velocity) + + model = DifferentialDriveModel(wheel_radius, track_width) + projector = DifferentialDriveProjector( + model, + min_linear_velocity, + max_linear_velocity, + min_angular_velocity, + max_angular_velocity, + footprint or [], + ) + states = projector.project( + horizon_s=duration, + dt_s=time_step, + initial_pose=Pose2D(), + initial_linear_velocity_m_s=initial_linear_velocity, + initial_angular_velocity_rad_s=initial_angular_velocity, + target_linear_velocity_m_s=target_linear_velocity, + target_angular_velocity_rad_s=target_angular_velocity, + linear_acceleration_m_s2=linear_acceleration, + angular_acceleration_rad_s2=angular_acceleration, + ) + time_arr, x_arr, y_arr, theta_arr, _omega = _states_to_arrays(states) + final_wheels = states[-1].wheel_velocities + return DifferentialTrajectory( + time=time_arr, + x=x_arr, + y=y_arr, + theta=theta_arr, + linear_velocity=states[-1].linear_velocity_m_s, + angular_velocity=states[-1].angular_velocity_rad_s, + left_wheel=final_wheels.left_wheel_velocity_rad_s, + right_wheel=final_wheels.right_wheel_velocity_rad_s, + base_wheel_velocity=(final_wheels.left_wheel_velocity_rad_s + final_wheels.right_wheel_velocity_rad_s) / 2.0, + footprint_series=_footprint_series(states, 'footprint'), + ) diff --git a/polymath_kinematics/explorer/types.py b/polymath_kinematics/explorer/types.py new file mode 100644 index 0000000..cb83450 --- /dev/null +++ b/polymath_kinematics/explorer/types.py @@ -0,0 +1,95 @@ +# Copyright (c) 2025-present Polymath Robotics, Inc. All rights reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Trajectory data types for kinematic exploration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Union + +import numpy as np + + +@dataclass +class Trajectory: + """Base trajectory data with common fields. + + ``footprint_series``, when present, is the per-sample single-body footprint produced by the + C++ projector: an ``(N, V, 2)`` array of world-frame polygon vertices (N samples, V vertices, + xy). It is ``None`` when no footprint was set on the projector (single-body models only; the + articulated model uses the front/rear series on ``ArticulatedTrajectory``). + """ + + time: np.ndarray + x: np.ndarray + y: np.ndarray + theta: np.ndarray + linear_velocity: float + angular_velocity: float + footprint_series: Optional[np.ndarray] = None + + def __len__(self) -> int: + return len(self.time) + + +@dataclass +class DifferentialTrajectory(Trajectory): + """Trajectory for differential drive model.""" + + left_wheel: float = 0.0 + right_wheel: float = 0.0 + base_wheel_velocity: float = 0.0 + + +@dataclass +class BicycleTrajectory(Trajectory): + """Trajectory for bicycle model. + + ``steering_angle`` is the steady-state / target value used for legend labels and + analysis plots. For ramped trajectories, ``steering_angle_series`` holds the + per-sample steering angle (same length as ``time``); the footprint visualizer + uses it to draw front-wheel indicators at the angle reported for each sample. + """ + + drive_velocity: float = 0.0 + steering_angle: float = 0.0 + turning_radius: float = 0.0 + steering_angle_series: Optional[np.ndarray] = None + + +@dataclass +class ArticulatedTrajectory(Trajectory): + """Trajectory for articulated vehicle model. + + ``articulation_angle`` is the steady-state / target value used for legend labels + and analysis plots. For ramped trajectories, ``articulation_angle_series`` holds + the per-sample articulation angle (same length as ``time``); the footprint + visualizer uses it to fold the rear segment progressively across the trajectory. + """ + + drive_velocity: float = 0.0 + articulation_angle: float = 0.0 + turning_radius: float = 0.0 + articulation_angle_series: Optional[np.ndarray] = None + # Per-sample front/rear body footprints from the C++ projector, each an (N, V, 2) array of + # world-frame polygon vertices. None when no footprint was set on the projector. + front_footprint_series: Optional[np.ndarray] = None + rear_footprint_series: Optional[np.ndarray] = None + # Per-sample articulation-joint pose as an (N, 3) array of [x, y, rear-body theta]. The joint + # cannot be recovered from an arbitrary polygon, so the projector reports it directly. + joint_pose_series: Optional[np.ndarray] = None + + +# Type alias for any trajectory type +AnyTrajectory = Union[DifferentialTrajectory, BicycleTrajectory, ArticulatedTrajectory] diff --git a/polymath_kinematics/kinematic_explorer_app.py b/polymath_kinematics/kinematic_explorer_app.py new file mode 100644 index 0000000..67fdc47 --- /dev/null +++ b/polymath_kinematics/kinematic_explorer_app.py @@ -0,0 +1,1045 @@ +# Copyright (c) 2025-present Polymath Robotics, Inc. All rights reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Interactive Kinematic Model Explorer - Streamlit UI. + +Run with: streamlit run kinematic_explorer_app.py +""" + +from __future__ import annotations + +import io +import json +from datetime import datetime + +import matplotlib.pyplot as plt +import numpy as np +import streamlit as st + +from polymath_kinematics import AxleReference, rectangle_footprint +from polymath_kinematics.explorer import ( + KINEMATIC_EQUATIONS, + LATTICE_CONFIG, + TRAJECTORY_EQUATIONS, + AnyTrajectory, + ArticulatedTrajectory, + BicycleTrajectory, + DifferentialTrajectory, + generate_lattice_articulated, + generate_lattice_bicycle, + generate_lattice_differential, + get_traj_attr, + plot_analysis, + plot_lattice, + plot_trajectory_with_footprints, + select_symmetric_trajectories, + single_articulated_trajectory, + single_bicycle_trajectory, + single_differential_trajectory, + trajectories_to_dataframe, +) +from polymath_kinematics.explorer.config import ( + DEFAULT_ARTICULATED_FRONT_OVERHANG_M, + DEFAULT_ARTICULATED_REAR_OVERHANG_M, + DEFAULT_BICYCLE_FRONT_OVERHANG_M, + DEFAULT_BICYCLE_REAR_OVERHANG_M, + DEFAULT_DIFFERENTIAL_FRONT_OVERHANG_M, + DEFAULT_DIFFERENTIAL_REAR_OVERHANG_M, +) + +AXLE_REFERENCES = {'Rear axle': AxleReference.REAR, 'Front axle': AxleReference.FRONT} + + +def _bicycle_footprint( + wheelbase: float, track_width: float, front_overhang: float, rear_overhang: float, axle_reference: str +): + """Rectangle for a boxy bicycle body, expressed in the selected axle's frame. + + The sliders describe the body relative to the axles it sits between: `front_overhang` ahead of + the FRONT axle, `rear_overhang` behind the REAR axle. Whichever axle is the reference, the body + lands in the same physical place. + """ + if AXLE_REFERENCES[axle_reference] == AxleReference.FRONT: + return rectangle_footprint(front_overhang, wheelbase + rear_overhang, track_width) + return rectangle_footprint(wheelbase + front_overhang, rear_overhang, track_width) + + +@st.cache_data +def _cached_lattice_differential( + wheel_radius: float, + track_width: float, + base_wheel_velocities: tuple[float, ...], + wheel_velocity_diffs: tuple[float, ...], + duration: float, + time_step: float, + front_overhang: float, + rear_overhang: float, +) -> list[DifferentialTrajectory]: + return generate_lattice_differential( + wheel_radius, + track_width, + base_wheel_velocities, + wheel_velocity_diffs, + duration, + time_step, + # Pose reference is the body centre, so the overhangs are the bumper distances directly. + footprint=rectangle_footprint(front_overhang, rear_overhang, track_width), + ) + + +@st.cache_data +def _cached_lattice_bicycle( + wheelbase: float, + track_width: float, + wheel_radius: float, + drive_velocities: tuple[float, ...], + steering_angles: tuple[float, ...], + duration: float, + time_step: float, + front_overhang: float, + rear_overhang: float, + axle_reference: str, +) -> list[BicycleTrajectory]: + return generate_lattice_bicycle( + wheelbase, + track_width, + wheel_radius, + drive_velocities, + steering_angles, + duration, + time_step, + axle_reference=AXLE_REFERENCES[axle_reference], + footprint=_bicycle_footprint(wheelbase, track_width, front_overhang, rear_overhang, axle_reference), + ) + + +@st.cache_data +def _cached_lattice_articulated( + articulation_to_front: float, + articulation_to_rear: float, + front_track: float, + rear_track: float, + front_wheel_radius: float, + rear_wheel_radius: float, + drive_velocities: tuple[float, ...], + articulation_angles: tuple[float, ...], + duration: float, + time_step: float, + front_overhang: float, + rear_overhang: float, + axle_reference: str, +) -> list[ArticulatedTrajectory]: + return generate_lattice_articulated( + articulation_to_front, + articulation_to_rear, + front_track, + rear_track, + front_wheel_radius, + rear_wheel_radius, + drive_velocities, + articulation_angles, + duration, + time_step, + axle_reference=AXLE_REFERENCES[axle_reference], + front_footprint=rectangle_footprint(front_overhang, articulation_to_front, front_track), + rear_footprint=rectangle_footprint(articulation_to_rear, rear_overhang, rear_track), + ) + + +@st.cache_data +def _cached_single_bicycle( + wheelbase: float, + track_width: float, + wheel_radius: float, + initial_steering_angle_rad: float, + target_steering_angle_rad: float, + steering_rate_rad_s: float, + drive_velocity: float, + duration: float, + time_step: float, + front_overhang: float, + rear_overhang: float, + axle_reference: str, +) -> BicycleTrajectory: + return single_bicycle_trajectory( + wheelbase, + track_width, + wheel_radius, + initial_steering_angle_rad=initial_steering_angle_rad, + target_steering_angle_rad=target_steering_angle_rad, + steering_rate_rad_s=steering_rate_rad_s, + drive_velocity=drive_velocity, + duration=duration, + time_step=time_step, + axle_reference=AXLE_REFERENCES[axle_reference], + footprint=_bicycle_footprint(wheelbase, track_width, front_overhang, rear_overhang, axle_reference), + ) + + +@st.cache_data +def _cached_single_articulated( + articulation_to_front: float, + articulation_to_rear: float, + front_track: float, + rear_track: float, + front_wheel_radius: float, + rear_wheel_radius: float, + initial_articulation_angle_rad: float, + target_articulation_angle_rad: float, + articulation_rate_rad_s: float, + drive_velocity: float, + duration: float, + time_step: float, + front_overhang: float, + rear_overhang: float, + axle_reference: str, +) -> ArticulatedTrajectory: + return single_articulated_trajectory( + articulation_to_front, + articulation_to_rear, + front_track, + rear_track, + front_wheel_radius, + rear_wheel_radius, + initial_articulation_angle_rad=initial_articulation_angle_rad, + target_articulation_angle_rad=target_articulation_angle_rad, + articulation_rate_rad_s=articulation_rate_rad_s, + drive_velocity=drive_velocity, + duration=duration, + time_step=time_step, + axle_reference=AXLE_REFERENCES[axle_reference], + # Each body polygon is measured from its own axle: the joint is L_f behind the front axle + # and L_r ahead of the rear axle. + front_footprint=rectangle_footprint(front_overhang, articulation_to_front, front_track), + rear_footprint=rectangle_footprint(articulation_to_rear, rear_overhang, rear_track), + ) + + +@st.cache_data +def _cached_single_differential( + wheel_radius: float, + track_width: float, + initial_linear_velocity: float, + initial_angular_velocity: float, + target_linear_velocity: float, + target_angular_velocity: float, + linear_acceleration: float, + angular_acceleration: float, + duration: float, + time_step: float, + front_overhang: float, + rear_overhang: float, +) -> DifferentialTrajectory: + return single_differential_trajectory( + wheel_radius, + track_width, + initial_linear_velocity=initial_linear_velocity, + initial_angular_velocity=initial_angular_velocity, + target_linear_velocity=target_linear_velocity, + target_angular_velocity=target_angular_velocity, + linear_acceleration=linear_acceleration, + angular_acceleration=angular_acceleration, + duration=duration, + time_step=time_step, + # Pose reference is the body centre; see _cached_lattice_differential. + footprint=rectangle_footprint(front_overhang, rear_overhang, track_width), + ) + + +def get_config_dict(model_type: str) -> dict: + """Get current configuration as a dictionary (depends on session state).""" + config = { + 'model_type': model_type, + 'timestamp': datetime.now().isoformat(), + 'simulation': { + 'duration': st.session_state.sim_duration, + 'dt': st.session_state.sim_dt, + }, + } + + if model_type == 'Differential Drive': + config['model_parameters'] = { + 'wheel_radius': st.session_state.diff_wheel_radius, + 'track_width': st.session_state.diff_track_width, + 'front_overhang': st.session_state.diff_front_overhang, + 'rear_overhang': st.session_state.diff_rear_overhang, + } + config['control_inputs'] = { + 'base_vel_min': st.session_state.diff_base_vel_min, + 'base_vel_max': st.session_state.diff_base_vel_max, + 'n_base_vels': st.session_state.diff_n_base_vels, + 'max_wheel_diff': st.session_state.diff_max_wheel_diff, + 'n_samples': st.session_state.diff_n_samples, + } + elif model_type == 'Bicycle': + config['model_parameters'] = { + 'wheelbase': st.session_state.bike_wheelbase, + 'track_width': st.session_state.bike_track_width, + 'wheel_radius': st.session_state.bike_wheel_radius, + 'front_overhang': st.session_state.bike_front_overhang, + 'rear_overhang': st.session_state.bike_rear_overhang, + 'axle_reference': st.session_state.bike_axle_reference, + } + config['control_inputs'] = { + 'v_min': st.session_state.bike_v_min, + 'v_max': st.session_state.bike_v_max, + 'n_velocities': st.session_state.bike_n_velocities, + 'max_steer_deg': st.session_state.bike_max_steer, + 'n_steer': st.session_state.bike_n_steer, + } + elif model_type == 'Articulated': + config['model_parameters'] = { + 'articulation_to_front_axle': st.session_state.art_to_front, + 'articulation_to_rear_axle': st.session_state.art_to_rear, + 'front_track_width': st.session_state.art_front_track, + 'rear_track_width': st.session_state.art_rear_track, + 'front_wheel_radius': st.session_state.art_front_wheel_r, + 'rear_wheel_radius': st.session_state.art_rear_wheel_r, + 'front_overhang': st.session_state.art_front_overhang, + 'rear_overhang': st.session_state.art_rear_overhang, + 'axle_reference': st.session_state.art_axle_reference, + } + config['control_inputs'] = { + 'v_min': st.session_state.art_v_min, + 'v_max': st.session_state.art_v_max, + 'n_velocities': st.session_state.art_n_velocities, + 'max_articulation_deg': st.session_state.art_max_angle, + 'n_angles': st.session_state.art_n_angles, + } + + return config + + +def init_session_state(): + """Initialize default values for all model parameters.""" + defaults = { + # Differential Drive + 'diff_wheel_radius': 0.1, + 'diff_track_width': 0.5, + 'diff_front_overhang': DEFAULT_DIFFERENTIAL_FRONT_OVERHANG_M, + 'diff_rear_overhang': DEFAULT_DIFFERENTIAL_REAR_OVERHANG_M, + 'diff_base_vel_min': 5.0, + 'diff_base_vel_max': 15.0, + 'diff_n_base_vels': 3, + 'diff_max_wheel_diff': 10.0, + 'diff_n_samples': 9, + # Bicycle + 'bike_wheelbase': 2.5, + 'bike_track_width': 1.5, + 'bike_wheel_radius': 0.3, + 'bike_axle_reference': 'Rear axle', + 'bike_front_overhang': DEFAULT_BICYCLE_FRONT_OVERHANG_M, + 'bike_rear_overhang': DEFAULT_BICYCLE_REAR_OVERHANG_M, + 'bike_v_min': 1.0, + 'bike_v_max': 3.0, + 'bike_n_velocities': 3, + 'bike_max_steer': 30.0, + 'bike_n_steer': 9, + # Articulated + 'art_to_front': 1.5, + 'art_to_rear': 1.2, + 'art_front_track': 1.8, + 'art_rear_track': 1.6, + 'art_axle_reference': 'Rear axle', + 'art_front_overhang': DEFAULT_ARTICULATED_FRONT_OVERHANG_M, + 'art_rear_overhang': DEFAULT_ARTICULATED_REAR_OVERHANG_M, + 'art_front_wheel_r': 0.4, + 'art_rear_wheel_r': 0.5, + 'art_v_min': 1.0, + 'art_v_max': 3.0, + 'art_n_velocities': 3, + 'art_max_angle': 30.0, + 'art_n_angles': 9, + # Simulation + 'sim_duration': 3.0, + 'sim_dt': 0.02, + 'export_dpi': 150, + 'export_format': 'png', + } + for key, value in defaults.items(): + if key not in st.session_state: + st.session_state[key] = value + + +st.set_page_config(page_title='Kinematic Explorer', layout='wide') +st.title('Kinematic Model Explorer') + +init_session_state() + +# Sidebar - Model Selection +st.sidebar.header('Model Configuration') +model_type = st.sidebar.selectbox('Model Type', ['Differential Drive', 'Bicycle', 'Articulated']) + +st.sidebar.subheader('Model Parameters') + +# Model-specific parameters +if model_type == 'Differential Drive': + st.session_state.diff_wheel_radius = st.sidebar.slider( + 'Wheel Radius (m)', 0.05, 0.5, st.session_state.diff_wheel_radius, 0.01 + ) + st.session_state.diff_track_width = st.sidebar.slider( + 'Track Width (m)', 0.2, 2.0, st.session_state.diff_track_width, 0.05 + ) + st.session_state.diff_front_overhang = st.sidebar.slider( + 'Front Overhang (m)', + 0.0, + 2.0, + st.session_state.diff_front_overhang, + 0.05, + help='Body length ahead of the body centre (the pose reference).', + ) + st.session_state.diff_rear_overhang = st.sidebar.slider( + 'Rear Overhang (m)', + 0.0, + 2.0, + st.session_state.diff_rear_overhang, + 0.05, + help='Body length behind the body centre (the pose reference).', + ) + st.sidebar.markdown('---') + st.sidebar.markdown(f'**Wheel Radius:** {st.session_state.diff_wheel_radius:.2f} m') + st.sidebar.markdown(f'**Track Width:** {st.session_state.diff_track_width:.2f} m') + st.sidebar.markdown( + f'**Body Length:** {st.session_state.diff_front_overhang + st.session_state.diff_rear_overhang:.2f} m' + ) + +elif model_type == 'Bicycle': + st.session_state.bike_wheelbase = st.sidebar.slider('Wheelbase (m)', 1.0, 5.0, st.session_state.bike_wheelbase, 0.1) + st.session_state.bike_track_width = st.sidebar.slider( + 'Track Width (m)', 1.0, 3.0, st.session_state.bike_track_width, 0.1 + ) + st.session_state.bike_wheel_radius = st.sidebar.slider( + 'Wheel Radius (m)', 0.1, 0.6, st.session_state.bike_wheel_radius, 0.05 + ) + st.session_state.bike_axle_reference = st.sidebar.selectbox( + 'Pose / Footprint Reference', + list(AXLE_REFERENCES), + index=list(AXLE_REFERENCES).index(st.session_state.bike_axle_reference), + help='Axle that trajectory poses and the footprint polygon are measured from.', + ) + st.session_state.bike_front_overhang = st.sidebar.slider( + 'Front Overhang (m)', + 0.0, + 2.0, + st.session_state.bike_front_overhang, + 0.05, + help='Body length ahead of the FRONT axle. The pose reference is the rear axle, so the ' + 'front bumper sits a wheelbase plus this overhang ahead of it.', + ) + st.session_state.bike_rear_overhang = st.sidebar.slider( + 'Rear Overhang (m)', + 0.0, + 2.0, + st.session_state.bike_rear_overhang, + 0.05, + help='Body length behind the rear axle (the pose reference).', + ) + st.sidebar.markdown('---') + st.sidebar.markdown(f'**Wheelbase:** {st.session_state.bike_wheelbase:.2f} m') + st.sidebar.markdown(f'**Track Width:** {st.session_state.bike_track_width:.2f} m') + st.sidebar.markdown( + f'**Body Length:** ' + f'{st.session_state.bike_wheelbase + st.session_state.bike_front_overhang + st.session_state.bike_rear_overhang:.2f} m' + ) + +elif model_type == 'Articulated': + st.session_state.art_to_front = st.sidebar.slider( + 'Articulation to Front Axle (m)', 0.5, 3.0, st.session_state.art_to_front, 0.1 + ) + st.session_state.art_to_rear = st.sidebar.slider( + 'Articulation to Rear Axle (m)', 0.5, 3.0, st.session_state.art_to_rear, 0.1 + ) + st.session_state.art_front_track = st.sidebar.slider( + 'Front Track Width (m)', 1.0, 3.0, st.session_state.art_front_track, 0.1 + ) + st.session_state.art_rear_track = st.sidebar.slider( + 'Rear Track Width (m)', 1.0, 3.0, st.session_state.art_rear_track, 0.1 + ) + st.session_state.art_axle_reference = st.sidebar.selectbox( + 'Pose Reference', + list(AXLE_REFERENCES), + index=list(AXLE_REFERENCES).index(st.session_state.art_axle_reference), + help='Axle that trajectory poses are measured from. Each body footprint is always measured from its own axle.', + ) + st.session_state.art_front_overhang = st.sidebar.slider( + 'Front Overhang (m)', + 0.0, + 3.0, + st.session_state.art_front_overhang, + 0.1, + help='Body length ahead of the front axle (e.g. bucket).', + ) + st.session_state.art_rear_overhang = st.sidebar.slider( + 'Rear Overhang (m)', + 0.0, + 3.0, + st.session_state.art_rear_overhang, + 0.1, + help='Body length behind the rear axle (e.g. counterweight).', + ) + st.session_state.art_front_wheel_r = st.sidebar.slider( + 'Front Wheel Radius (m)', 0.2, 0.8, st.session_state.art_front_wheel_r, 0.05 + ) + st.session_state.art_rear_wheel_r = st.sidebar.slider( + 'Rear Wheel Radius (m)', 0.2, 0.8, st.session_state.art_rear_wheel_r, 0.05 + ) + st.sidebar.markdown('---') + st.sidebar.markdown(f'**Total Wheelbase:** {st.session_state.art_to_front + st.session_state.art_to_rear:.2f} m') + +# Lattice Configuration +st.sidebar.header('Lattice Configuration') +st.session_state.sim_duration = st.sidebar.slider( + 'Simulation Duration (s)', 1.0, 10.0, st.session_state.sim_duration, 0.5 +) + +with st.sidebar.expander('Advanced Settings'): + st.caption('Trajectories come from the C++ projectors (Euler integration at the time step below).') + st.session_state.sim_dt = st.slider( + 'Time Step (s)', + 0.005, + 0.1, + st.session_state.sim_dt, + 0.005, + help='Smaller values are more accurate but slower', + ) + st.caption(f'Steps per trajectory: {int(st.session_state.sim_duration / st.session_state.sim_dt)}') + +# Generate trajectories based on model type +trajectories: list[AnyTrajectory] +group_values: list[float] + +if model_type == 'Differential Drive': + st.sidebar.subheader('Control Inputs') + col1, col2 = st.sidebar.columns(2) + with col1: + st.session_state.diff_base_vel_min = st.number_input( + 'Min Base Vel (rad/s)', 1.0, 20.0, st.session_state.diff_base_vel_min, 1.0 + ) + with col2: + st.session_state.diff_base_vel_max = st.number_input( + 'Max Base Vel (rad/s)', 1.0, 30.0, st.session_state.diff_base_vel_max, 1.0 + ) + st.session_state.diff_n_base_vels = st.sidebar.slider( + 'Number of Base Velocities', 1, 5, st.session_state.diff_n_base_vels + ) + base_wheel_velocities = tuple( + np.linspace( + st.session_state.diff_base_vel_min, st.session_state.diff_base_vel_max, st.session_state.diff_n_base_vels + ) + ) + + st.session_state.diff_max_wheel_diff = st.sidebar.slider( + 'Max Wheel Velocity Diff (rad/s)', 1.0, 20.0, st.session_state.diff_max_wheel_diff, 1.0 + ) + st.session_state.diff_n_samples = st.sidebar.slider('Diff Samples', 3, 15, st.session_state.diff_n_samples, 2) + wheel_diffs = tuple( + np.linspace( + -st.session_state.diff_max_wheel_diff, st.session_state.diff_max_wheel_diff, st.session_state.diff_n_samples + ) + ) + + trajectories = _cached_lattice_differential( + wheel_radius=st.session_state.diff_wheel_radius, + track_width=st.session_state.diff_track_width, + base_wheel_velocities=base_wheel_velocities, + wheel_velocity_diffs=wheel_diffs, + duration=st.session_state.sim_duration, + time_step=st.session_state.sim_dt, + front_overhang=st.session_state.diff_front_overhang, + rear_overhang=st.session_state.diff_rear_overhang, + ) + group_values = list(base_wheel_velocities) + +elif model_type == 'Bicycle': + st.sidebar.subheader('Control Inputs') + col1, col2 = st.sidebar.columns(2) + with col1: + st.session_state.bike_v_min = st.number_input('Min Drive Vel (m/s)', 0.5, 5.0, st.session_state.bike_v_min, 0.5) + with col2: + st.session_state.bike_v_max = st.number_input( + 'Max Drive Vel (m/s)', 0.5, 10.0, st.session_state.bike_v_max, 0.5 + ) + st.session_state.bike_n_velocities = st.sidebar.slider( + 'Number of Velocities', 1, 5, st.session_state.bike_n_velocities + ) + drive_velocities = tuple( + np.linspace(st.session_state.bike_v_min, st.session_state.bike_v_max, st.session_state.bike_n_velocities) + ) + + st.session_state.bike_max_steer = st.sidebar.slider( + 'Max Steering Angle (deg)', 5.0, 45.0, st.session_state.bike_max_steer, 5.0 + ) + st.session_state.bike_n_steer = st.sidebar.slider('Steering Samples', 3, 15, st.session_state.bike_n_steer, 2) + steering_angles = tuple( + np.linspace( + -np.radians(st.session_state.bike_max_steer), + np.radians(st.session_state.bike_max_steer), + st.session_state.bike_n_steer, + ) + ) + + trajectories = _cached_lattice_bicycle( + wheelbase=st.session_state.bike_wheelbase, + track_width=st.session_state.bike_track_width, + wheel_radius=st.session_state.bike_wheel_radius, + drive_velocities=drive_velocities, + steering_angles=steering_angles, + duration=st.session_state.sim_duration, + time_step=st.session_state.sim_dt, + front_overhang=st.session_state.bike_front_overhang, + rear_overhang=st.session_state.bike_rear_overhang, + axle_reference=st.session_state.bike_axle_reference, + ) + group_values = list(drive_velocities) + +else: # Articulated + st.sidebar.subheader('Control Inputs') + col1, col2 = st.sidebar.columns(2) + with col1: + st.session_state.art_v_min = st.number_input('Min Drive Vel (m/s)', 0.5, 5.0, st.session_state.art_v_min, 0.5) + with col2: + st.session_state.art_v_max = st.number_input('Max Drive Vel (m/s)', 0.5, 10.0, st.session_state.art_v_max, 0.5) + st.session_state.art_n_velocities = st.sidebar.slider( + 'Number of Velocities', 1, 5, st.session_state.art_n_velocities + ) + drive_velocities = tuple( + np.linspace(st.session_state.art_v_min, st.session_state.art_v_max, st.session_state.art_n_velocities) + ) + + st.session_state.art_max_angle = st.sidebar.slider( + 'Max Articulation Angle (deg)', 5.0, 45.0, st.session_state.art_max_angle, 5.0 + ) + st.session_state.art_n_angles = st.sidebar.slider('Articulation Samples', 3, 15, st.session_state.art_n_angles, 2) + articulation_angles = tuple( + np.linspace( + -np.radians(st.session_state.art_max_angle), + np.radians(st.session_state.art_max_angle), + st.session_state.art_n_angles, + ) + ) + + trajectories = _cached_lattice_articulated( + articulation_to_front=st.session_state.art_to_front, + articulation_to_rear=st.session_state.art_to_rear, + front_track=st.session_state.art_front_track, + rear_track=st.session_state.art_rear_track, + front_wheel_radius=st.session_state.art_front_wheel_r, + rear_wheel_radius=st.session_state.art_rear_wheel_r, + drive_velocities=drive_velocities, + articulation_angles=articulation_angles, + duration=st.session_state.sim_duration, + time_step=st.session_state.sim_dt, + front_overhang=st.session_state.art_front_overhang, + rear_overhang=st.session_state.art_rear_overhang, + axle_reference=st.session_state.art_axle_reference, + ) + group_values = list(drive_velocities) + +# Kinematic Equations Section +with st.expander('Kinematic Equations', expanded=False): + eq_info = KINEMATIC_EQUATIONS[model_type] + st.subheader(eq_info['title']) + for eq in eq_info['equations']: + st.latex(eq) + st.markdown(eq_info['variables']) + st.markdown('---') + st.markdown(TRAJECTORY_EQUATIONS) + +# Trajectory Lattice — only the longest (highest-velocity) group is shown. Lower velocities are +# the same trajectory fan at a shorter distance, so rendering all of them is redundant. +longest_group = [max(group_values)] if group_values else group_values +st.header('Trajectory Lattice') +lattice_fig = plot_lattice(trajectories, model_type, longest_group) +lattice_col, _ = st.columns([2, 1]) # constrain to 2/3 page width so the 16:9 plot isn't full-bleed +lattice_col.pyplot(lattice_fig) +plt.close(lattice_fig) + +# Kinematic Analysis +st.header('Kinematic Analysis') +analysis_fig = plot_analysis(trajectories, model_type, group_values) +analysis_col, _ = st.columns([2, 1]) # constrain to 2/3 page width so the plot isn't full-bleed +analysis_col.pyplot(analysis_fig) +plt.close(analysis_fig) + +# Vehicle Footprint Visualization +st.header('Vehicle Visualization') + +vel_options = list(group_values) +# Body outlines come from the projector footprints; model_params only carries what the corners +# can't give us — the bicycle's front-axle offset and track width. +model_params: dict = {} +if model_type == 'Differential Drive': + vel_label = 'Base Wheel Velocity (rad/s)' + body_length = st.session_state.diff_front_overhang + st.session_state.diff_rear_overhang + st.caption(f'Vehicle dimensions: {body_length:.2f}m x {st.session_state.diff_track_width:.2f}m') +elif model_type == 'Bicycle': + # front_axle_offset is measured from the pose reference, so it collapses to 0 when the pose + # already sits at the front axle. + model_params = { + 'wheelbase': 0.0 + if AXLE_REFERENCES[st.session_state.bike_axle_reference] == AxleReference.FRONT + else st.session_state.bike_wheelbase, + 'track_width': st.session_state.bike_track_width, + } + vel_label = 'Drive Velocity (m/s)' + body_length = ( + st.session_state.bike_wheelbase + st.session_state.bike_front_overhang + st.session_state.bike_rear_overhang + ) + st.caption( + f'Vehicle dimensions: {body_length:.2f}m x {st.session_state.bike_track_width:.2f}m ' + f'(wheelbase={st.session_state.bike_wheelbase:.2f}m)' + ) +else: # Articulated + vel_label = 'Drive Velocity (m/s)' + # Body lengths are measured from the articulation joint (base_link) to each bumper: + # joint-to-axle + overhang. + front_length = st.session_state.art_to_front + st.session_state.art_front_overhang + rear_length = st.session_state.art_to_rear + st.session_state.art_rear_overhang + st.caption( + f'Articulated: front={front_length:.2f}m x {st.session_state.art_front_track:.2f}m, ' + f'rear={rear_length:.2f}m x {st.session_state.art_rear_track:.2f}m' + ) + +col1, col2 = st.columns([3, 1]) +with col2: + st.markdown('**Visualization Settings**') + num_footprints = st.slider('Footprints per trajectory', 3, 8, 4, 1, key='num_footprints') + + if model_type == 'Differential Drive': + angle_label = 'Angular velocities' + elif model_type == 'Bicycle': + angle_label = 'Steering angles' + else: + angle_label = 'Articulation angles' + + num_angles_viz = st.slider( + angle_label, 1, 9, 5, 2, key='num_angles_viz', help='Number of angles to show (symmetric)' + ) + + if len(vel_options) > 1: + viz_vel = st.select_slider( + vel_label, + options=[round(v, 1) for v in vel_options], + value=round(vel_options[len(vel_options) // 2], 1), + key='viz_velocity', + ) + else: + viz_vel = vel_options[0] + st.caption(f'{vel_label}: {viz_vel:.1f}') + +with col1: + config = LATTICE_CONFIG[model_type] + # Narrow to the chosen velocity first, then let the library pick a symmetric angle subset. + velocity_filtered = [ + trajectory for trajectory in trajectories if abs(get_traj_attr(trajectory, config.vel_key) - viz_vel) < 0.05 + ] + viz_trajectories = select_symmetric_trajectories( + velocity_filtered, model_type, num_angles=num_angles_viz, num_velocities=1 + ) + + if viz_trajectories: + footprint_fig = plot_trajectory_with_footprints(viz_trajectories, model_type, model_params, num_footprints) + st.pyplot(footprint_fig) + plt.close(footprint_fig) + else: + st.warning('No trajectories selected. Adjust the visualization settings.') + +# Single Projected Trajectory — ramps the model command from initial → target at a chosen rate, +# clamped to [min, max], and plots the resulting trajectory live. Velocity/horizon are inherited. +st.header('Single Projected Trajectory') +st.caption( + 'Set the initial input, target input, and rate of change. The plot updates as you move the ' + 'sliders. Drive velocity and simulation horizon are inherited from the sections above.' +) + + +def _render_single_trajectory(single_traj): + """Render one projected trajectory with footprints (shared by all model branches below).""" + fig = plot_trajectory_with_footprints([single_traj], model_type, model_params, num_footprints=5) + plot_col, _ = st.columns([2, 1]) # constrain to 2/3 page width so the 16:9 plot isn't full-bleed + plot_col.pyplot(fig) + plt.close(fig) + + +if model_type == 'Bicycle': + col_a, col_b, col_c = st.columns(3) + with col_a: + single_initial_deg = st.slider( + 'Initial Steering (deg)', + -st.session_state.bike_max_steer, + st.session_state.bike_max_steer, + 0.0, + 1.0, + key='bike_single_initial_deg', + ) + with col_b: + single_target_deg = st.slider( + 'Target Steering (deg)', + -st.session_state.bike_max_steer, + st.session_state.bike_max_steer, + float(st.session_state.bike_max_steer / 2), + 1.0, + key='bike_single_target_deg', + ) + with col_c: + single_rate_deg_s = st.slider( + 'Steering Rate (deg/s)', + 0.0, + 180.0, + 30.0, + 1.0, + key='bike_single_rate_deg_s', + ) + + single_traj = _cached_single_bicycle( + wheelbase=st.session_state.bike_wheelbase, + track_width=st.session_state.bike_track_width, + wheel_radius=st.session_state.bike_wheel_radius, + initial_steering_angle_rad=float(np.radians(single_initial_deg)), + target_steering_angle_rad=float(np.radians(single_target_deg)), + steering_rate_rad_s=float(np.radians(single_rate_deg_s)), + drive_velocity=float(viz_vel), + duration=st.session_state.sim_duration, + time_step=st.session_state.sim_dt, + front_overhang=st.session_state.bike_front_overhang, + rear_overhang=st.session_state.bike_rear_overhang, + axle_reference=st.session_state.bike_axle_reference, + ) + _render_single_trajectory(single_traj) + st.caption( + f'Drive velocity: {viz_vel:.2f} m/s · ' + f'Horizon: {st.session_state.sim_duration:.1f}s · ' + f'dt: {st.session_state.sim_dt:.3f}s' + ) + +elif model_type == 'Articulated': + col_a, col_b, col_c = st.columns(3) + with col_a: + single_initial_deg = st.slider( + 'Initial Articulation (deg)', + -st.session_state.art_max_angle, + st.session_state.art_max_angle, + 0.0, + 1.0, + key='art_single_initial_deg', + ) + with col_b: + single_target_deg = st.slider( + 'Target Articulation (deg)', + -st.session_state.art_max_angle, + st.session_state.art_max_angle, + float(st.session_state.art_max_angle / 2), + 1.0, + key='art_single_target_deg', + ) + with col_c: + single_rate_deg_s = st.slider( + 'Articulation Rate (deg/s)', + 0.0, + 90.0, + 15.0, + 1.0, + key='art_single_rate_deg_s', + ) + + single_traj = _cached_single_articulated( + articulation_to_front=st.session_state.art_to_front, + articulation_to_rear=st.session_state.art_to_rear, + front_track=st.session_state.art_front_track, + rear_track=st.session_state.art_rear_track, + front_wheel_radius=st.session_state.art_front_wheel_r, + rear_wheel_radius=st.session_state.art_rear_wheel_r, + initial_articulation_angle_rad=float(np.radians(single_initial_deg)), + target_articulation_angle_rad=float(np.radians(single_target_deg)), + articulation_rate_rad_s=float(np.radians(single_rate_deg_s)), + drive_velocity=float(viz_vel), + duration=st.session_state.sim_duration, + time_step=st.session_state.sim_dt, + front_overhang=st.session_state.art_front_overhang, + rear_overhang=st.session_state.art_rear_overhang, + axle_reference=st.session_state.art_axle_reference, + ) + _render_single_trajectory(single_traj) + st.caption( + f'Drive velocity: {viz_vel:.2f} m/s · ' + f'Horizon: {st.session_state.sim_duration:.1f}s · ' + f'dt: {st.session_state.sim_dt:.3f}s' + ) + +else: # Differential Drive — ramp linear AND angular body command. + st.markdown('**Body command**') + col_a, col_b, col_c, col_d = st.columns(4) + with col_a: + single_initial_v = st.slider( + 'Initial Linear (m/s)', + -3.0, + 3.0, + 0.0, + 0.1, + key='diff_single_initial_v', + ) + with col_b: + single_target_v = st.slider( + 'Target Linear (m/s)', + -3.0, + 3.0, + 1.0, + 0.1, + key='diff_single_target_v', + ) + with col_c: + single_initial_omega = st.slider( + 'Initial Angular (rad/s)', + -3.0, + 3.0, + 0.0, + 0.1, + key='diff_single_initial_omega', + ) + with col_d: + single_target_omega = st.slider( + 'Target Angular (rad/s)', + -3.0, + 3.0, + 0.5, + 0.1, + key='diff_single_target_omega', + ) + st.markdown('**Acceleration limits**') + col_e, col_f = st.columns(2) + with col_e: + single_linear_accel = st.slider( + 'Linear Accel (m/s²)', + 0.0, + 5.0, + 1.0, + 0.1, + key='diff_single_linear_accel', + ) + with col_f: + single_angular_accel = st.slider( + 'Angular Accel (rad/s²)', + 0.0, + 5.0, + 1.0, + 0.1, + key='diff_single_angular_accel', + ) + + single_traj = _cached_single_differential( + wheel_radius=st.session_state.diff_wheel_radius, + track_width=st.session_state.diff_track_width, + initial_linear_velocity=float(single_initial_v), + initial_angular_velocity=float(single_initial_omega), + target_linear_velocity=float(single_target_v), + target_angular_velocity=float(single_target_omega), + linear_acceleration=float(single_linear_accel), + angular_acceleration=float(single_angular_accel), + duration=st.session_state.sim_duration, + time_step=st.session_state.sim_dt, + front_overhang=st.session_state.diff_front_overhang, + rear_overhang=st.session_state.diff_rear_overhang, + ) + _render_single_trajectory(single_traj) + st.caption(f'Horizon: {st.session_state.sim_duration:.1f}s · dt: {st.session_state.sim_dt:.3f}s') + +# Parameter Table +with st.expander('Current Configuration', expanded=False): + config_dict = get_config_dict(model_type) + + col1, col2 = st.columns(2) + with col1: + st.markdown('**Model Parameters:**') + for key, value in config_dict['model_parameters'].items(): + st.markdown(f'- {key.replace("_", " ").title()}: `{value}`') + + with col2: + st.markdown('**Control Inputs:**') + for key, value in config_dict['control_inputs'].items(): + st.markdown(f'- {key.replace("_", " ").title()}: `{value}`') + + st.markdown('**Simulation Settings:**') + st.markdown(f'- Duration: `{config_dict["simulation"]["duration"]}s`, dt: `{config_dict["simulation"]["dt"]}s`') + +# Summary stats +st.header('Summary') +col1, col2, col3, col4 = st.columns(4) + +with col1: + st.metric('Model Type', model_type) +with col2: + st.metric('Total Trajectories', len(trajectories)) +with col3: + if model_type == 'Articulated': + max_art = max(abs(trajectory.articulation_angle) for trajectory in trajectories) # type: ignore[union-attr] + st.metric('Max Articulation', f'{np.degrees(max_art):.1f} deg') + elif model_type == 'Bicycle': + max_steer = max(abs(trajectory.steering_angle) for trajectory in trajectories) # type: ignore[union-attr] + st.metric('Max Steering', f'{np.degrees(max_steer):.1f} deg') + elif model_type == 'Differential Drive': + st.metric('Track Width', f'{st.session_state.diff_track_width:.2f} m') +with col4: + max_omega = max(abs(trajectory.angular_velocity) for trajectory in trajectories) + st.metric('Max Angular Vel', f'{max_omega:.2f} rad/s') + +# Export Section +st.sidebar.header('Export') + +with st.sidebar.expander('Export Settings'): + st.session_state.export_format = st.selectbox( + 'Image Format', + ['png', 'svg', 'pdf'], + index=['png', 'svg', 'pdf'].index(st.session_state.export_format), + ) + st.session_state.export_dpi = st.select_slider( + 'DPI (for PNG)', + [72, 150, 300, 600], + value=st.session_state.export_dpi, + ) + +# CSV Export +df = trajectories_to_dataframe(trajectories, model_type) +csv_buffer = io.StringIO() +df.to_csv(csv_buffer, index=False) +st.sidebar.download_button( + label='Download Trajectory CSV', + data=csv_buffer.getvalue(), + file_name=f'trajectories_{model_type.lower().replace(" ", "_")}.csv', + mime='text/csv', +) + +# JSON Config Export +config_export = get_config_dict(model_type) +st.sidebar.download_button( + label='Download Config JSON', + data=json.dumps(config_export, indent=2), + file_name=f'config_{model_type.lower().replace(" ", "_")}.json', + mime='application/json', +) + +# Image Export — matches the displayed lattice (longest/highest-velocity group only), rendered +# into memory so the browser downloads it rather than the server writing a file. +image_format = st.session_state.export_format +image_buffer = io.BytesIO() +export_fig = plot_lattice(trajectories, model_type, longest_group) +save_kwargs = {'bbox_inches': 'tight', 'format': image_format} +if image_format == 'png': + save_kwargs['dpi'] = st.session_state.export_dpi +export_fig.savefig(image_buffer, **save_kwargs) +plt.close(export_fig) +st.sidebar.download_button( + label=f'Download Lattice Image ({image_format.upper()})', + data=image_buffer.getvalue(), + file_name=f'lattice_{model_type.lower().replace(" ", "_")}.{image_format}', + mime={'png': 'image/png', 'svg': 'image/svg+xml', 'pdf': 'application/pdf'}[image_format], +) diff --git a/test/test_explorer.py b/test/test_explorer.py new file mode 100644 index 0000000..242d57c --- /dev/null +++ b/test/test_explorer.py @@ -0,0 +1,657 @@ +# Copyright (c) 2025-present Polymath Robotics, Inc. All rights reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the kinematic explorer module.""" + +import math + +import numpy as np +import pytest + +from polymath_kinematics import AxleReference, DifferentialDriveModel, rectangle_footprint +from polymath_kinematics.explorer import ( + KINEMATIC_EQUATIONS, + LATTICE_CONFIG, + generate_lattice_articulated, + generate_lattice_bicycle, + generate_lattice_differential, + plot_analysis, + plot_lattice, + plot_trajectory_with_footprints, + select_symmetric_trajectories, + single_articulated_trajectory, + single_bicycle_trajectory, + single_differential_trajectory, + trajectories_to_dataframe, +) + + +class TestLatticeGeneration: + def test_generate_lattice_differential(self): + trajectories = generate_lattice_differential( + wheel_radius=0.1, + track_width=0.5, + base_wheel_velocities=(10.0,), + wheel_velocity_diffs=(-5.0, 0.0, 5.0), + duration=1.0, + time_step=0.1, + ) + + # ceil(duration/dt) + 1 samples, same as the bicycle and articulated lattices. + assert len(trajectories) == 3 + assert len(trajectories[0].time) == 11 + assert trajectories[0].linear_velocity == pytest.approx(1.0) + + def test_generate_lattice_differential_footprints(self): + # With body dimensions supplied, the projector emits a footprint per sample. + trajectories = generate_lattice_differential( + wheel_radius=0.1, + track_width=0.5, + base_wheel_velocities=(10.0,), + wheel_velocity_diffs=(0.0, 5.0), + duration=1.0, + time_step=0.1, + footprint=rectangle_footprint(0.4, 0.4, 0.5), + ) + for trajectory in trajectories: + assert trajectory.footprint_series is not None + assert trajectory.footprint_series.shape == (len(trajectory.time), 4, 2) + + def test_generate_lattice_differential_no_dims_has_no_footprints(self): + trajectories = generate_lattice_differential( + wheel_radius=0.1, + track_width=0.5, + base_wheel_velocities=(10.0,), + wheel_velocity_diffs=(0.0,), + duration=1.0, + time_step=0.1, + ) + assert trajectories[0].footprint_series is None + + def test_generate_lattice_differential_outer_cells_are_not_clamped(self): + # Bounds must span the sweep, or the extreme cells get clamped back toward the middle. + diffs = (-10.0, 0.0, 10.0) + trajectories = generate_lattice_differential( + wheel_radius=0.1, + track_width=0.5, + base_wheel_velocities=(10.0,), + wheel_velocity_diffs=diffs, + duration=1.0, + time_step=0.1, + ) + model = DifferentialDriveModel(0.1, 0.5) + for trajectory, velocity_diff in zip(trajectories, diffs): + expected = model.wheel_velocities_to_body_velocity(10.0 - velocity_diff / 2, 10.0 + velocity_diff / 2) + assert trajectory.angular_velocity == pytest.approx(expected.angular_velocity_rad_s) + assert trajectory.linear_velocity == pytest.approx(expected.linear_velocity_m_s) + + def test_generate_lattice_differential_multiple_velocities(self): + trajectories = generate_lattice_differential( + wheel_radius=0.1, + track_width=0.5, + base_wheel_velocities=(10.0, 15.0), + wheel_velocity_diffs=(-5.0, 0.0, 5.0), + duration=1.0, + time_step=0.1, + ) + assert len(trajectories) == 6 + + def test_generate_lattice_bicycle(self): + trajectories = generate_lattice_bicycle( + wheelbase=2.5, + track_width=1.5, + wheel_radius=0.3, + drive_velocities=(2.0,), + steering_angles=(0.0, math.radians(15)), + duration=1.0, + time_step=0.1, + ) + # Projector returns ceil(duration/dt) + 1 samples (initial state seeded as element 0). + assert len(trajectories) == 2 + assert len(trajectories[0].time) == 11 + assert trajectories[0].drive_velocity == pytest.approx(2.0) + + def test_generate_lattice_articulated(self): + trajectories = generate_lattice_articulated( + articulation_to_front=1.5, + articulation_to_rear=1.2, + front_track=1.8, + rear_track=1.6, + front_wheel_radius=0.4, + rear_wheel_radius=0.5, + drive_velocities=(2.0,), + articulation_angles=(0.0, math.radians(15)), + duration=1.0, + time_step=0.1, + ) + assert len(trajectories) == 2 + assert len(trajectories[0].time) == 11 + assert trajectories[0].drive_velocity == pytest.approx(2.0) + + +class TestDataframeExport: + def test_differential_drive_export(self): + trajectories = generate_lattice_differential( + wheel_radius=0.1, + track_width=0.5, + base_wheel_velocities=(10.0,), + wheel_velocity_diffs=(-5.0, 0.0, 5.0), + duration=1.0, + time_step=0.1, + ) + dataframe = trajectories_to_dataframe(trajectories, 'Differential Drive') + + expected_columns = [ + 'trajectory_id', + 'time', + 'x', + 'y', + 'theta', + 'linear_velocity', + 'angular_velocity', + 'left_wheel_velocity', + 'right_wheel_velocity', + 'base_wheel_velocity', + ] + assert list(dataframe.columns) == expected_columns + assert len(dataframe) == 33 # 3 trajectories * 11 samples each + + def test_bicycle_export(self): + trajectories = generate_lattice_bicycle( + wheelbase=2.5, + track_width=1.5, + wheel_radius=0.3, + drive_velocities=(2.0,), + steering_angles=(0.0,), + duration=1.0, + time_step=0.1, + ) + dataframe = trajectories_to_dataframe(trajectories, 'Bicycle') + + assert 'drive_velocity' in dataframe.columns + assert 'steering_angle' in dataframe.columns + assert 'turning_radius' in dataframe.columns + + def test_articulated_export(self): + trajectories = generate_lattice_articulated( + articulation_to_front=1.5, + articulation_to_rear=1.2, + front_track=1.8, + rear_track=1.6, + front_wheel_radius=0.4, + rear_wheel_radius=0.5, + drive_velocities=(2.0,), + articulation_angles=(0.0,), + duration=1.0, + time_step=0.1, + ) + dataframe = trajectories_to_dataframe(trajectories, 'Articulated') + + assert 'drive_velocity' in dataframe.columns + assert 'articulation_angle' in dataframe.columns + assert 'turning_radius' in dataframe.columns + + def test_empty_trajectories(self): + dataframe = trajectories_to_dataframe([], 'Bicycle') + assert dataframe.empty + + +class TestTrajectorySelection: + def test_select_symmetric_trajectories(self): + trajectories = generate_lattice_differential( + wheel_radius=0.1, + track_width=0.5, + base_wheel_velocities=(10.0, 15.0), + wheel_velocity_diffs=(-5.0, -2.5, 0.0, 2.5, 5.0), + duration=1.0, + time_step=0.1, + ) + selected = select_symmetric_trajectories(trajectories, 'Differential Drive', num_angles=3, num_velocities=1) + assert len(selected) == 3 + + def test_select_all_when_fewer_than_requested(self): + trajectories = generate_lattice_bicycle( + wheelbase=2.5, + track_width=1.5, + wheel_radius=0.3, + drive_velocities=(2.0,), + steering_angles=(0.0, math.radians(15)), + duration=1.0, + time_step=0.1, + ) + selected = select_symmetric_trajectories(trajectories, 'Bicycle', num_angles=5, num_velocities=1) + assert len(selected) == 2 + + def test_empty_trajectories_selection(self): + selected = select_symmetric_trajectories([], 'Bicycle', num_angles=5, num_velocities=1) + assert selected == [] + + +class TestPlotting: + @pytest.fixture + def differential_trajectories(self): + # Body dims are supplied so the footprint-overlay path is actually exercised. + return generate_lattice_differential( + wheel_radius=0.1, + track_width=0.5, + base_wheel_velocities=(10.0, 15.0), + wheel_velocity_diffs=(-5.0, 0.0, 5.0), + duration=1.0, + time_step=0.1, + footprint=rectangle_footprint(0.4, 0.4, 0.5), + ) + + @pytest.fixture + def bicycle_trajectories(self): + return generate_lattice_bicycle( + wheelbase=2.5, + track_width=1.5, + wheel_radius=0.3, + drive_velocities=(2.0, 3.0), + steering_angles=(-math.radians(15), 0.0, math.radians(15)), + duration=1.0, + time_step=0.1, + footprint=rectangle_footprint(2.5 + 0.6, 0.5, 1.5), + ) + + @pytest.fixture + def articulated_trajectories(self): + return generate_lattice_articulated( + articulation_to_front=1.5, + articulation_to_rear=1.2, + front_track=1.8, + rear_track=1.6, + front_wheel_radius=0.4, + rear_wheel_radius=0.5, + drive_velocities=(2.0,), + articulation_angles=(-math.radians(15), 0.0, math.radians(15)), + duration=1.0, + time_step=0.1, + # Each polygon is measured from its own axle; the joint is L_f behind the front + # axle and L_r ahead of the rear axle. + front_footprint=rectangle_footprint(1.0, 1.5, 1.8), + rear_footprint=rectangle_footprint(1.2, 0.8, 1.6), + ) + + def test_plot_lattice_differential(self, differential_trajectories): + assert plot_lattice(differential_trajectories, 'Differential Drive', [10.0, 15.0]) is not None + + def test_plot_lattice_bicycle(self, bicycle_trajectories): + assert plot_lattice(bicycle_trajectories, 'Bicycle', [2.0, 3.0]) is not None + + def test_plot_lattice_articulated(self, articulated_trajectories): + assert plot_lattice(articulated_trajectories, 'Articulated', [2.0]) is not None + + def test_plot_analysis_differential(self, differential_trajectories): + assert plot_analysis(differential_trajectories, 'Differential Drive', [10.0, 15.0]) is not None + + def test_plot_analysis_bicycle(self, bicycle_trajectories): + assert plot_analysis(bicycle_trajectories, 'Bicycle', [2.0, 3.0]) is not None + + def test_plot_analysis_articulated(self, articulated_trajectories): + assert plot_analysis(articulated_trajectories, 'Articulated', [2.0]) is not None + + def test_plot_trajectory_with_footprints_differential(self, differential_trajectories): + selected = select_symmetric_trajectories( + differential_trajectories, 'Differential Drive', num_angles=3, num_velocities=1 + ) + figure = plot_trajectory_with_footprints(selected, 'Differential Drive', {}, num_footprints=3) + assert figure is not None + + def test_plot_trajectory_with_footprints_bicycle(self, bicycle_trajectories): + selected = select_symmetric_trajectories(bicycle_trajectories, 'Bicycle', num_angles=3, num_velocities=1) + figure = plot_trajectory_with_footprints( + selected, 'Bicycle', {'wheelbase': 2.5, 'track_width': 1.5}, num_footprints=3 + ) + assert figure is not None + + def test_plot_trajectory_with_footprints_articulated(self, articulated_trajectories): + selected = select_symmetric_trajectories( + articulated_trajectories, 'Articulated', num_angles=3, num_velocities=1 + ) + figure = plot_trajectory_with_footprints(selected, 'Articulated', {}, num_footprints=3) + assert figure is not None + + def test_plot_trajectory_with_footprints_skips_missing_footprints(self): + # No body dims: the path is still drawn, the overlay skipped rather than raising. + without_dims = generate_lattice_bicycle( + wheelbase=2.5, + track_width=1.5, + wheel_radius=0.3, + drive_velocities=(2.0,), + steering_angles=(math.radians(15),), + duration=1.0, + time_step=0.1, + ) + assert without_dims[0].footprint_series is None + figure = plot_trajectory_with_footprints(without_dims, 'Bicycle', {}, num_footprints=3) + assert figure is not None + + +class TestConfig: + def test_lattice_config_keys(self): + assert 'Differential Drive' in LATTICE_CONFIG + assert 'Bicycle' in LATTICE_CONFIG + assert 'Articulated' in LATTICE_CONFIG + + def test_lattice_config_fields(self): + config = LATTICE_CONFIG['Bicycle'] + assert config.group_key == 'drive_velocity' + assert config.vel_key == 'drive_velocity' + assert config.angle_key == 'steering_angle' + + def test_kinematic_equations_present(self): + for model_type in ('Differential Drive', 'Bicycle', 'Articulated'): + assert model_type in KINEMATIC_EQUATIONS + assert 'title' in KINEMATIC_EQUATIONS[model_type] + assert 'equations' in KINEMATIC_EQUATIONS[model_type] + assert 'variables' in KINEMATIC_EQUATIONS[model_type] + + +class TestSingleTrajectory: + def test_single_bicycle_trajectory_reaches_target(self): + # Initial=0, target=0.3, rate=0.3 rad/s, duration=2s → step adds at most 0.3*dt; + # over 2s we reach the target well before the horizon ends. The trajectory's + # `steering_angle` field reflects the target (steady-state). + traj = single_bicycle_trajectory( + wheelbase=2.5, + track_width=1.5, + wheel_radius=0.3, + initial_steering_angle_rad=0.0, + target_steering_angle_rad=0.3, + steering_rate_rad_s=0.3, + drive_velocity=1.0, + duration=2.0, + time_step=0.1, + ) + assert traj.steering_angle == pytest.approx(0.3) + assert len(traj.time) == 21 + # Pose advanced from origin (we drove forward). + assert traj.x[-1] > 0.0 + + def test_single_articulated_trajectory_reaches_target(self): + traj = single_articulated_trajectory( + articulation_to_front=1.66, + articulation_to_rear=1.44, + front_track=2.0, + rear_track=2.0, + front_wheel_radius=0.723, + rear_wheel_radius=0.723, + initial_articulation_angle_rad=0.0, + target_articulation_angle_rad=0.4, + articulation_rate_rad_s=0.5, + drive_velocity=1.0, + duration=2.0, + time_step=0.1, + ) + assert traj.articulation_angle == pytest.approx(0.4) + assert len(traj.time) == 21 + assert traj.x[-1] > 0.0 + + def test_single_differential_trajectory_ramps_both_velocities(self): + traj = single_differential_trajectory( + wheel_radius=0.1, + track_width=0.5, + initial_linear_velocity=0.0, + initial_angular_velocity=0.0, + target_linear_velocity=1.0, + target_angular_velocity=0.5, + linear_acceleration=1.0, + angular_acceleration=1.0, + duration=2.0, + time_step=0.1, + ) + # Targets reached in ~1.0s and ~0.5s respectively; final state pinned to target. + assert traj.linear_velocity == pytest.approx(1.0) + assert traj.angular_velocity == pytest.approx(0.5) + assert len(traj.time) == 21 + + def test_single_bicycle_zero_rate_keeps_initial_angle(self): + # With rate=0 the angle never advances; turning_radius reflects the (unchanging) + # initial angle, not the target. + traj = single_bicycle_trajectory( + wheelbase=2.5, + track_width=1.5, + wheel_radius=0.3, + initial_steering_angle_rad=0.0, + target_steering_angle_rad=0.5, + steering_rate_rad_s=0.0, + drive_velocity=1.0, + duration=1.0, + time_step=0.1, + ) + # Zero steering → straight line along +x. + assert traj.x[-1] == pytest.approx(1.0) + assert traj.y[-1] == pytest.approx(0.0) + + def test_single_bicycle_steering_series_brackets_initial_and_target(self): + # Initial=0 → target=0.3 at rate=0.3 rad/s; ramp completes in 1s. Over duration=2s + # the series starts at 0 and ends pinned at 0.3. + traj = single_bicycle_trajectory( + wheelbase=2.5, + track_width=1.5, + wheel_radius=0.3, + initial_steering_angle_rad=0.0, + target_steering_angle_rad=0.3, + steering_rate_rad_s=0.3, + drive_velocity=1.0, + duration=2.0, + time_step=0.1, + ) + assert traj.steering_angle_series is not None + assert len(traj.steering_angle_series) == len(traj.time) + assert traj.steering_angle_series[0] == pytest.approx(0.0) + assert traj.steering_angle_series[-1] == pytest.approx(0.3) + + def test_single_articulated_articulation_series_brackets_initial_and_target(self): + traj = single_articulated_trajectory( + articulation_to_front=1.66, + articulation_to_rear=1.44, + front_track=2.0, + rear_track=2.0, + front_wheel_radius=0.723, + rear_wheel_radius=0.723, + initial_articulation_angle_rad=0.0, + target_articulation_angle_rad=0.4, + articulation_rate_rad_s=0.5, + drive_velocity=1.0, + duration=2.0, + time_step=0.1, + ) + assert traj.articulation_angle_series is not None + assert len(traj.articulation_angle_series) == len(traj.time) + assert traj.articulation_angle_series[0] == pytest.approx(0.0) + assert traj.articulation_angle_series[-1] == pytest.approx(0.4) + + def test_single_bicycle_footprint_series_none_without_dims(self): + traj = single_bicycle_trajectory( + wheelbase=2.5, + track_width=1.5, + wheel_radius=0.3, + initial_steering_angle_rad=0.0, + target_steering_angle_rad=0.2, + steering_rate_rad_s=0.0, + drive_velocity=1.0, + duration=1.0, + time_step=0.1, + ) + # No footprint dimensions passed → projector emits empty footprints → series is None. + assert traj.footprint_series is None + + def test_single_bicycle_footprint_series_present_with_dims(self): + traj = single_bicycle_trajectory( + wheelbase=2.5, + track_width=1.5, + wheel_radius=0.3, + initial_steering_angle_rad=0.0, + target_steering_angle_rad=0.2, + steering_rate_rad_s=0.0, + drive_velocity=1.0, + duration=1.0, + time_step=0.1, + footprint=rectangle_footprint(3.0, 1.0, 1.5), + ) + assert traj.footprint_series is not None + # (N samples, 4 corners, xy) + assert traj.footprint_series.shape == (len(traj.time), 4, 2) + + def test_single_articulated_footprint_series_present_with_dims(self): + traj = single_articulated_trajectory( + articulation_to_front=1.66, + articulation_to_rear=1.44, + front_track=2.0, + rear_track=2.0, + front_wheel_radius=0.723, + rear_wheel_radius=0.723, + initial_articulation_angle_rad=0.0, + target_articulation_angle_rad=0.4, + articulation_rate_rad_s=0.5, + drive_velocity=1.0, + duration=1.0, + time_step=0.1, + front_footprint=rectangle_footprint(0.54, 1.66, 2.0), + rear_footprint=rectangle_footprint(1.44, 0.56, 2.0), + ) + assert traj.front_footprint_series is not None + assert traj.rear_footprint_series is not None + assert traj.front_footprint_series.shape == (len(traj.time), 4, 2) + assert traj.rear_footprint_series.shape == (len(traj.time), 4, 2) + + def test_single_articulated_footprint_extends_behind_rear_axle(self): + # The rear polygon is measured from the rear axle, and the pose (REAR reference) IS the + # rear axle, so a rear_overhang of 0.8 must put the rearmost point at exactly -0.8. + articulation_to_rear = 1.44 + rear_overhang = 0.8 + traj = single_articulated_trajectory( + articulation_to_front=1.66, + articulation_to_rear=articulation_to_rear, + front_track=2.0, + rear_track=2.0, + front_wheel_radius=0.723, + rear_wheel_radius=0.723, + initial_articulation_angle_rad=0.0, + target_articulation_angle_rad=0.0, + articulation_rate_rad_s=0.0, + drive_velocity=0.0, # stay at the origin so the geometry is exact + duration=0.2, + time_step=0.1, + front_footprint=rectangle_footprint(1.0, 1.66, 2.0), + rear_footprint=rectangle_footprint(articulation_to_rear, rear_overhang, 2.0), + ) + assert traj.rear_footprint_series is not None + rear0 = traj.rear_footprint_series[0] # (4, 2) corners of the first sample + assert rear0[:, 0].min() == pytest.approx(-rear_overhang) + # Forward extent reaches the joint, which sits L_r ahead of the rear axle. + assert rear0[:, 0].max() == pytest.approx(articulation_to_rear) + + def test_single_bicycle_footprint_extends_ahead_of_front_axle(self): + # Pose reference is the rear axle, so front_overhang_m = wheelbase + overhang. Regression + # guard: a wheelbase fraction used to put the front bumper behind the front axle. + wheelbase = 2.5 + front_overhang = 0.6 + rear_overhang = 0.5 + traj = single_bicycle_trajectory( + wheelbase=wheelbase, + track_width=1.5, + wheel_radius=0.3, + initial_steering_angle_rad=0.0, + target_steering_angle_rad=0.0, + steering_rate_rad_s=0.0, + drive_velocity=0.0, # stay at the origin so the geometry is exact + duration=0.2, + time_step=0.1, + footprint=rectangle_footprint(wheelbase + front_overhang, rear_overhang, 1.5), + ) + assert traj.footprint_series is not None + body0 = traj.footprint_series[0] # (4, 2) corners of the first sample + max_x = body0[:, 0].max() + min_x = body0[:, 0].min() + assert max_x == pytest.approx(wheelbase + front_overhang) + assert max_x > wheelbase # front bumper is AHEAD of the front axle + assert min_x == pytest.approx(-rear_overhang) + assert max_x - min_x == pytest.approx(wheelbase + front_overhang + rear_overhang) + + def test_single_bicycle_axle_reference_describes_the_same_body(self): + # The same physical vehicle described from each axle must put the body in the same place. + wheelbase, front_overhang, rear_overhang, width = 2.5, 0.6, 0.5, 1.5 + common = dict( + track_width=width, + wheel_radius=0.3, + initial_steering_angle_rad=0.0, + target_steering_angle_rad=0.0, + steering_rate_rad_s=0.0, + drive_velocity=0.0, + duration=0.2, + time_step=0.1, + ) + from_rear = single_bicycle_trajectory( + wheelbase=wheelbase, + axle_reference=AxleReference.REAR, + footprint=rectangle_footprint(wheelbase + front_overhang, rear_overhang, width), + **common, + ) + from_front = single_bicycle_trajectory( + wheelbase=wheelbase, + axle_reference=AxleReference.FRONT, + footprint=rectangle_footprint(front_overhang, wheelbase + rear_overhang, width), + **common, + ) + # Both start at the origin *of their own reference axle*, so the front-referenced body + # sits a wheelbase back in world terms; shift it to compare the same physical placement. + rear_body = from_rear.footprint_series[0] + front_body = from_front.footprint_series[0] + np.array([wheelbase, 0.0]) + assert front_body == pytest.approx(rear_body) + + def test_single_articulated_reports_joint_pose_series(self): + articulation_to_rear = 1.44 + traj = single_articulated_trajectory( + articulation_to_front=1.66, + articulation_to_rear=articulation_to_rear, + front_track=2.0, + rear_track=2.0, + front_wheel_radius=0.723, + rear_wheel_radius=0.723, + initial_articulation_angle_rad=0.0, + target_articulation_angle_rad=0.0, + articulation_rate_rad_s=0.0, + drive_velocity=0.0, + duration=0.2, + time_step=0.1, + ) + assert traj.joint_pose_series is not None + assert traj.joint_pose_series.shape == (len(traj.time), 3) + # REAR reference at the origin: the joint sits L_r straight ahead. + assert traj.joint_pose_series[0][0] == pytest.approx(articulation_to_rear) + assert traj.joint_pose_series[0][1] == pytest.approx(0.0) + + def test_single_differential_footprint_straddles_body_centre(self): + # Pose reference is the body centre, so the overhangs are the bumper distances directly. + front_overhang = 0.4 + rear_overhang = 0.3 + traj = single_differential_trajectory( + wheel_radius=0.1, + track_width=0.5, + initial_linear_velocity=0.0, + initial_angular_velocity=0.0, + target_linear_velocity=0.0, + target_angular_velocity=0.0, + linear_acceleration=0.0, + angular_acceleration=0.0, + duration=0.2, + time_step=0.1, + footprint=rectangle_footprint(front_overhang, rear_overhang, 0.5), + ) + assert traj.footprint_series is not None + body0 = traj.footprint_series[0] + assert body0[:, 0].max() == pytest.approx(front_overhang) + assert body0[:, 0].min() == pytest.approx(-rear_overhang)