Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
4 changes: 4 additions & 0 deletions package.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
<test_depend>ament_cmake_pytest</test_depend>
<test_depend>ament_cmake_test</test_depend>
<test_depend>catch2</test_depend>
<!-- Explorer subpackage deps (optional [explorer] extra) needed to import
polymath_kinematics.explorer during test_explorer. -->
<test_depend>python3-pandas</test_depend>
<test_depend>python3-matplotlib</test_depend>

<export>
<build_type>ament_cmake</build_type>
Expand Down
81 changes: 81 additions & 0 deletions polymath_kinematics/explorer/__init__.py
Original file line number Diff line number Diff line change
@@ -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',
]
67 changes: 67 additions & 0 deletions polymath_kinematics/explorer/cli.py
Original file line number Diff line number Diff line change
@@ -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())
120 changes: 120 additions & 0 deletions polymath_kinematics/explorer/config.py
Original file line number Diff line number Diff line change
@@ -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$$
"""
Loading
Loading