From 681d291ab2c55e22e387d6e390ebb5007b84c083 Mon Sep 17 00:00:00 2001 From: Zeerek Date: Mon, 11 May 2026 22:24:29 +0000 Subject: [PATCH 1/9] Add projections and tests! Add pre-commit --- .gitignore | 4 + CMakeLists.txt | 4 + .../polymath_kinematics/articulated_model.hpp | 24 ++- .../articulated_projector.hpp | 116 +++++++++++++ .../polymath_kinematics/bicycle_projector.hpp | 117 +++++++++++++ include/polymath_kinematics/pose2d.hpp | 44 +++++ polymath_kinematics/__init__.py | 16 +- src/articulated_model.cpp | 16 +- src/articulated_projector.cpp | 103 +++++++++++ src/bicycle_projector.cpp | 103 +++++++++++ src/kinematics_pybind.cpp | 104 ++++++++++- test/test_articulated_model.cpp | 37 ++++ test/test_articulated_projector.cpp | 161 ++++++++++++++++++ test/test_bicycle_projector.cpp | 145 ++++++++++++++++ test/test_python_bindings.py | 125 ++++++++++++++ 15 files changed, 1101 insertions(+), 18 deletions(-) create mode 100644 include/polymath_kinematics/articulated_projector.hpp create mode 100644 include/polymath_kinematics/bicycle_projector.hpp create mode 100644 include/polymath_kinematics/pose2d.hpp create mode 100644 src/articulated_projector.cpp create mode 100644 src/bicycle_projector.cpp create mode 100644 test/test_articulated_projector.cpp create mode 100644 test/test_bicycle_projector.cpp diff --git a/.gitignore b/.gitignore index ce0d030..7005901 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ /build/ /install/ /log/ + +# Python +__pycache__/ +*.py[cod] diff --git a/CMakeLists.txt b/CMakeLists.txt index 6624f4c..e6e8bc9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,8 +53,10 @@ find_package(pybind11 REQUIRED) # C++ library with all kinematic models add_library(polymath_kinematics SHARED src/articulated_model.cpp + src/articulated_projector.cpp src/differential_drive_model.cpp src/bicycle_model.cpp + src/bicycle_projector.cpp ) target_include_directories(polymath_kinematics PUBLIC $ @@ -118,6 +120,8 @@ if(BUILD_TESTING) add_kinematics_catch2_test(test_differential_drive test/test_differential_drive.cpp) add_kinematics_catch2_test(test_bicycle_model test/test_bicycle_model.cpp) add_kinematics_catch2_test(test_articulated_model test/test_articulated_model.cpp) + add_kinematics_catch2_test(test_bicycle_projector test/test_bicycle_projector.cpp) + add_kinematics_catch2_test(test_articulated_projector test/test_articulated_projector.cpp) ament_add_pytest_test(test_python_bindings test) endif() diff --git a/include/polymath_kinematics/articulated_model.hpp b/include/polymath_kinematics/articulated_model.hpp index 8969f47..490631a 100644 --- a/include/polymath_kinematics/articulated_model.hpp +++ b/include/polymath_kinematics/articulated_model.hpp @@ -40,7 +40,6 @@ struct ArticulatedAxleVelocities }; /// @brief Kinematic model for articulated vehicles (e.g., wheel loaders, articulated dump trucks) -/// @note TODO: (Zeerek) We want to add articulated angle turning velocity into our estimations class ArticulatedModel { public: @@ -68,17 +67,36 @@ class ArticulatedModel ~ArticulatedModel() = default; /// @brief Convert body velocity to vehicle state (articulation angle and wheel speeds) + /// Convenience overload that assumes a zero articulation turning velocity (steady articulation). /// @param linear_velocity_m_s Desired linear velocity in m/s /// @param angular_velocity_rad_s Desired angular velocity in rad/s /// @return Vehicle state including required articulation angle and wheel speeds ArticulatedVehicleState bodyVelocityToVehicleState(double linear_velocity_m_s, double angular_velocity_rad_s); + /// @brief Convert body velocity to vehicle state, including the effect of the articulation joint + /// turning velocity (gamma-dot). + /// @param linear_velocity_m_s Desired linear velocity in m/s + /// @param angular_velocity_rad_s Desired angular velocity in rad/s + /// @param articulation_turning_velocity_rad_s Rate of change of the articulation angle in rad/s + /// @return Vehicle state including required articulation angle and wheel speeds + ArticulatedVehicleState bodyVelocityToVehicleState( + double linear_velocity_m_s, double angular_velocity_rad_s, double articulation_turning_velocity_rad_s); + /// @brief Convert articulation state to axle turning velocities + /// Convenience overload that assumes a zero articulation turning velocity (steady articulation). /// @param linear_velocity_m_s Current linear velocity in m/s /// @param articulation_angle_rad Current articulation angle in radians /// @return Axle turning velocities for front and rear axles ArticulatedAxleVelocities articulationToAxleVelocities(double linear_velocity_m_s, double articulation_angle_rad); + /// @brief Convert articulation state to axle turning velocities, including gamma-dot + /// @param linear_velocity_m_s Current linear velocity in m/s + /// @param articulation_angle_rad Current articulation angle in radians + /// @param articulation_turning_velocity_rad_s Rate of change of the articulation angle in rad/s + /// @return Axle turning velocities for front and rear axles + ArticulatedAxleVelocities articulationToAxleVelocities( + double linear_velocity_m_s, double articulation_angle_rad, double articulation_turning_velocity_rad_s); + double get_articulation_to_front_axle_m() const { return articulation_to_front_axle_m_; @@ -117,10 +135,6 @@ class ArticulatedModel double front_wheel_radius_m_; double rear_wheel_radius_m_; - /// @brief Articulation turning velocity once calculated or available - /// TODO: (Zeerek) Add ability to pass this in when generating estimations - static constexpr double articulation_turning_velocity_rad_s_ = 0.0; - /// @brief Threshold below which a velocity is treated as zero to avoid numerical /// singularities (e.g. 0/0 in acos, or inf*0 in wheel speed calculations) static constexpr double ZERO_VELOCITY_THRESHOLD = 1e-9; diff --git a/include/polymath_kinematics/articulated_projector.hpp b/include/polymath_kinematics/articulated_projector.hpp new file mode 100644 index 0000000..9a1142b --- /dev/null +++ b/include/polymath_kinematics/articulated_projector.hpp @@ -0,0 +1,116 @@ +// 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. + +#ifndef POLYMATH_KINEMATICS__ARTICULATED_PROJECTOR_HPP__ +#define POLYMATH_KINEMATICS__ARTICULATED_PROJECTOR_HPP__ + +#include + +#include "polymath_kinematics/articulated_model.hpp" +#include "polymath_kinematics/pose2d.hpp" + +// TODO(naming): when this projector is in wide use, consider renaming the underlying +// ArticulatedModel methods for clarity: +// - bodyVelocityToVehicleState -> inverseKinematics +// - articulationToAxleVelocities -> forwardKinematics +// And dropping the _m / _rad / _rad_s unit suffixes from struct fields (units in docs). + +namespace polymath::kinematics +{ + +/// @brief One sample of an articulated-model projection. Pose is tracked at the rear segment. +struct ArticulatedProjectedState +{ + double time_s; ///< Elapsed time from the start of the projection + Pose2D pose; ///< Rear-segment pose at this sample + double articulation_angle_rad; ///< Post-ramp articulation angle (gamma) in radians + double linear_velocity_m_s; ///< Commanded linear velocity in m/s + double angular_velocity_rad_s; ///< Rear-axle turning rate used for theta integration + ArticulatedVehicleState vehicle_state; ///< Full kinematic snapshot (wheel speeds + turning radii) +}; + +/// @brief Forward-projection wrapper around ArticulatedModel. Ramps articulation angle (gamma) +/// toward a target at a bounded rate (gamma-dot), clamping the target to [min, max] first. +/// Pose is integrated with Euler at the rear axle. +class ArticulatedProjector +{ +public: + /// @brief Construct a projector with articulation-angle limits. + /// @param model Articulated kinematics model (stored by value) + /// @param min_articulation_angle_rad Minimum allowed articulation angle (typically negative) + /// @param max_articulation_angle_rad Maximum allowed articulation angle (typically positive) + ArticulatedProjector(ArticulatedModel model, double min_articulation_angle_rad, double max_articulation_angle_rad) + : model_(model) + , min_articulation_angle_rad_(min_articulation_angle_rad) + , max_articulation_angle_rad_(max_articulation_angle_rad) + {} + + ~ArticulatedProjector() = default; + + /// @brief Advance the vehicle one time step. + /// The articulation angle ramps from the current value toward clamp(target, min, max) at + /// |articulation_rate_rad_s| per second, never overshooting. Pose is integrated with Euler + /// using the post-ramp angle. + /// @param dt_s Step duration in seconds (must be > 0) + /// @param current_pose Rear-segment pose at the start of the step + /// @param current_articulation_angle_rad Articulation angle at the start of the step + /// @param target_articulation_angle_rad Desired articulation angle (clamped to [min, max] internally) + /// @param articulation_rate_rad_s Magnitude of the ramp rate in rad/s (sign is ignored) + /// @param linear_velocity_m_s Commanded linear velocity in m/s + /// @return Projected state at the end of the step (time_s = dt_s) + ArticulatedProjectedState step( + double dt_s, + const Pose2D & current_pose, + double current_articulation_angle_rad, + double target_articulation_angle_rad, + double articulation_rate_rad_s, + double linear_velocity_m_s); + + /// @brief Project a trajectory forward over `horizon_s` at `dt_s` steps. + /// Element 0 is the initial state (time_s=0); element N is the final state. + /// Trajectory length is ceil(horizon_s / dt_s) + 1. + /// @return Sequence of timestamped states (initial state included as element 0) + std::vector project( + double horizon_s, + double dt_s, + const Pose2D & initial_pose, + double initial_articulation_angle_rad, + double target_articulation_angle_rad, + double articulation_rate_rad_s, + double linear_velocity_m_s); + + const ArticulatedModel & get_model() const + { + return model_; + } + + double get_min_articulation_angle_rad() const + { + return min_articulation_angle_rad_; + } + + double get_max_articulation_angle_rad() const + { + return max_articulation_angle_rad_; + } + +private: + ArticulatedModel model_; + double min_articulation_angle_rad_; + double max_articulation_angle_rad_; +}; + +} // namespace polymath::kinematics + +#endif // POLYMATH_KINEMATICS__ARTICULATED_PROJECTOR_HPP__ diff --git a/include/polymath_kinematics/bicycle_projector.hpp b/include/polymath_kinematics/bicycle_projector.hpp new file mode 100644 index 0000000..e3c4175 --- /dev/null +++ b/include/polymath_kinematics/bicycle_projector.hpp @@ -0,0 +1,117 @@ +// 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. + +#ifndef POLYMATH_KINEMATICS__BICYCLE_PROJECTOR_HPP__ +#define POLYMATH_KINEMATICS__BICYCLE_PROJECTOR_HPP__ + +#include + +#include "polymath_kinematics/bicycle_model.hpp" +#include "polymath_kinematics/pose2d.hpp" + +// TODO(naming): when this projector is in wide use, consider renaming the underlying +// BicycleModel methods for clarity: +// - bodyVelocityToSteering -> inverseKinematics +// - steeringToBodyVelocity -> forwardKinematics +// And dropping the _m / _rad / _rad_s unit suffixes from struct fields (units in docs). +// Also unify the Python Pose2D in polymath_kinematics/__init__.py with the C++ Pose2D +// bound here. + +namespace polymath::kinematics +{ + +/// @brief One sample of a bicycle-model projection: pose + steering + wheel speeds at a point in time. +struct BicycleProjectedState +{ + double time_s; ///< Elapsed time from the start of the projection + Pose2D pose; ///< Body pose at this sample + double steering_angle_rad; ///< Post-ramp steering angle in radians + double linear_velocity_m_s; ///< Commanded linear velocity in m/s + double angular_velocity_rad_s; ///< Body angular velocity used for theta integration + BicycleSteeringState steering_state; ///< Full kinematic snapshot (wheel speeds + turning radius) +}; + +/// @brief Forward-projection wrapper around BicycleModel that ramps steering toward a target at +/// a bounded rate, clamping the target to [min, max] before ramping, and integrates pose with Euler. +class BicycleProjector +{ +public: + /// @brief Construct a projector with steering-angle limits. + /// @param model Bicycle kinematics model (stored by value) + /// @param min_steering_angle_rad Minimum allowed steering angle (typically negative) + /// @param max_steering_angle_rad Maximum allowed steering angle (typically positive) + BicycleProjector(BicycleModel model, double min_steering_angle_rad, double max_steering_angle_rad) + : model_(model) + , min_steering_angle_rad_(min_steering_angle_rad) + , max_steering_angle_rad_(max_steering_angle_rad) + {} + + ~BicycleProjector() = default; + + /// @brief Advance the vehicle one time step. + /// Steering ramps from current_steering_angle_rad toward clamp(target, min, max) at + /// |steering_rate_rad_s| per second, never overshooting. Pose is integrated with Euler + /// using the post-ramp angle. + /// @param dt_s Step duration in seconds (must be > 0) + /// @param current_pose Pose at the start of the step + /// @param current_steering_angle_rad Steering angle at the start of the step + /// @param target_steering_angle_rad Desired steering angle (clamped to [min, max] internally) + /// @param steering_rate_rad_s Magnitude of the ramp rate in rad/s (sign is ignored) + /// @param linear_velocity_m_s Commanded linear velocity in m/s + /// @return Projected state at the end of the step (time_s = dt_s) + BicycleProjectedState step( + double dt_s, + const Pose2D & current_pose, + double current_steering_angle_rad, + double target_steering_angle_rad, + double steering_rate_rad_s, + double linear_velocity_m_s); + + /// @brief Project a trajectory forward over `horizon_s` at `dt_s` steps. + /// Element 0 is the initial state (time_s=0); element N is the final state. + /// Trajectory length is ceil(horizon_s / dt_s) + 1. + /// @return Sequence of timestamped states (initial state included as element 0) + std::vector project( + double horizon_s, + double dt_s, + const Pose2D & initial_pose, + double initial_steering_angle_rad, + double target_steering_angle_rad, + double steering_rate_rad_s, + double linear_velocity_m_s); + + const BicycleModel & get_model() const + { + return model_; + } + + double get_min_steering_angle_rad() const + { + return min_steering_angle_rad_; + } + + double get_max_steering_angle_rad() const + { + return max_steering_angle_rad_; + } + +private: + BicycleModel model_; + double min_steering_angle_rad_; + double max_steering_angle_rad_; +}; + +} // namespace polymath::kinematics + +#endif // POLYMATH_KINEMATICS__BICYCLE_PROJECTOR_HPP__ diff --git a/include/polymath_kinematics/pose2d.hpp b/include/polymath_kinematics/pose2d.hpp new file mode 100644 index 0000000..46dabf4 --- /dev/null +++ b/include/polymath_kinematics/pose2d.hpp @@ -0,0 +1,44 @@ +// 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. + +#ifndef POLYMATH_KINEMATICS__POSE2D_HPP__ +#define POLYMATH_KINEMATICS__POSE2D_HPP__ + +#include + +namespace polymath::kinematics +{ + +/// @brief Planar pose: position (x, y) and heading theta (radians, CCW from +x). +struct Pose2D +{ + double x; + double y; + double theta; +}; + +/// @brief Wrap an angle to [-pi, pi] +inline double normalizeAngle(double angle) +{ + constexpr double kTwoPi = 2.0 * M_PI; + double a = std::fmod(angle + M_PI, kTwoPi); + if (a < 0.0) { + a += kTwoPi; + } + return a - M_PI; +} + +} // namespace polymath::kinematics + +#endif // POLYMATH_KINEMATICS__POSE2D_HPP__ diff --git a/polymath_kinematics/__init__.py b/polymath_kinematics/__init__.py index fdf2e74..c76a10f 100644 --- a/polymath_kinematics/__init__.py +++ b/polymath_kinematics/__init__.py @@ -17,21 +17,30 @@ from polymath_kinematics_cpp import ( ArticulatedAxleVelocities, ArticulatedModel, + ArticulatedProjectedState, + ArticulatedProjector, ArticulatedVehicleState, BicycleBodyVelocity, BicycleModel, + BicycleProjectedState, + BicycleProjector, BicycleSteeringState, DifferentialDriveBodyVelocity, DifferentialDriveModel, DifferentialDriveWheelVelocities, + Pose2D, ) __all__ = [ 'ArticulatedAxleVelocities', 'ArticulatedModel', + 'ArticulatedProjectedState', + 'ArticulatedProjector', 'ArticulatedVehicleState', 'BicycleBodyVelocity', 'BicycleModel', + 'BicycleProjectedState', + 'BicycleProjector', 'BicycleSteeringState', 'DifferentialDriveBodyVelocity', 'DifferentialDriveModel', @@ -43,13 +52,6 @@ ] -class Pose2D: - def __init__(self, x=0.0, y=0.0, theta=0.0): - self.x = x - self.y = y - self.theta = theta - - class Twist2D: def __init__(self, linear_x=0.0, linear_y=0.0, angular_z=0.0): self.linear_x = linear_x diff --git a/src/articulated_model.cpp b/src/articulated_model.cpp index 0b04a59..8715c83 100644 --- a/src/articulated_model.cpp +++ b/src/articulated_model.cpp @@ -23,6 +23,12 @@ namespace polymath::kinematics ArticulatedVehicleState ArticulatedModel::bodyVelocityToVehicleState( double linear_velocity_m_s, double angular_velocity_rad_s) +{ + return bodyVelocityToVehicleState(linear_velocity_m_s, angular_velocity_rad_s, 0.0); +} + +ArticulatedVehicleState ArticulatedModel::bodyVelocityToVehicleState( + double linear_velocity_m_s, double angular_velocity_rad_s, double articulation_turning_velocity_rad_s) { // Guard: fully stationary — sqrt denominator collapses to 0, producing 0/0 = NaN if ( @@ -92,13 +98,19 @@ ArticulatedVehicleState ArticulatedModel::bodyVelocityToVehicleState( ArticulatedAxleVelocities ArticulatedModel::articulationToAxleVelocities( double linear_velocity_m_s, double articulation_angle_rad) +{ + return articulationToAxleVelocities(linear_velocity_m_s, articulation_angle_rad, 0.0); +} + +ArticulatedAxleVelocities ArticulatedModel::articulationToAxleVelocities( + double linear_velocity_m_s, double articulation_angle_rad, double articulation_turning_velocity_rad_s) { double front_axle_turning_velocity = (linear_velocity_m_s * std::sin(articulation_angle_rad) + - articulation_to_rear_axle_m_ * articulation_turning_velocity_rad_s_) / + articulation_to_rear_axle_m_ * articulation_turning_velocity_rad_s) / (articulation_to_front_axle_m_ * std::cos(articulation_angle_rad) + articulation_to_rear_axle_m_); - double rear_axle_turning_velocity = front_axle_turning_velocity - articulation_turning_velocity_rad_s_; + double rear_axle_turning_velocity = front_axle_turning_velocity - articulation_turning_velocity_rad_s; return ArticulatedAxleVelocities{linear_velocity_m_s, front_axle_turning_velocity, rear_axle_turning_velocity}; } diff --git a/src/articulated_projector.cpp b/src/articulated_projector.cpp new file mode 100644 index 0000000..67d6cfd --- /dev/null +++ b/src/articulated_projector.cpp @@ -0,0 +1,103 @@ +// 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. + +#include "polymath_kinematics/articulated_projector.hpp" + +#include +#include +#include + +namespace polymath::kinematics +{ + +ArticulatedProjectedState ArticulatedProjector::step( + double dt_s, + const Pose2D & current_pose, + double current_articulation_angle_rad, + double target_articulation_angle_rad, + double articulation_rate_rad_s, + double linear_velocity_m_s) +{ + // Clamp target into the joint's mechanical bounds before ramping. + double clamped_target = + std::clamp(target_articulation_angle_rad, min_articulation_angle_rad_, max_articulation_angle_rad_); + + // Slew toward the clamped target at |rate| per second, never overshooting. + double max_delta = std::abs(articulation_rate_rad_s) * dt_s; + double delta = clamped_target - current_articulation_angle_rad; + if (std::abs(delta) > max_delta) { + delta = std::copysign(max_delta, delta); + } + double new_articulation_angle_rad = current_articulation_angle_rad + delta; + // Actual gamma-dot realized during this step (zero once the angle pins at clamped_target). + double actual_articulation_rate_rad_s = delta / dt_s; + + // Rear-axle turning velocity drives theta integration; feed the realized gamma-dot in. + ArticulatedAxleVelocities axle = model_.articulationToAxleVelocities( + linear_velocity_m_s, new_articulation_angle_rad, actual_articulation_rate_rad_s); + double angular_velocity_rad_s = axle.rear_axle_turning_velocity_rad_s; + + // Full vehicle state (wheel speeds + turning radii) for the snapshot. + ArticulatedVehicleState inner = + model_.bodyVelocityToVehicleState(linear_velocity_m_s, angular_velocity_rad_s, actual_articulation_rate_rad_s); + + // Euler pose update (heading taken at start of step). + Pose2D new_pose{ + current_pose.x + linear_velocity_m_s * std::cos(current_pose.theta) * dt_s, + current_pose.y + linear_velocity_m_s * std::sin(current_pose.theta) * dt_s, + normalizeAngle(current_pose.theta + angular_velocity_rad_s * dt_s)}; + + return ArticulatedProjectedState{ + dt_s, new_pose, new_articulation_angle_rad, linear_velocity_m_s, angular_velocity_rad_s, inner}; +} + +std::vector ArticulatedProjector::project( + double horizon_s, + double dt_s, + const Pose2D & initial_pose, + double initial_articulation_angle_rad, + double target_articulation_angle_rad, + double articulation_rate_rad_s, + double linear_velocity_m_s) +{ + std::vector trajectory; + if (dt_s <= 0.0 || horizon_s < 0.0) { + return trajectory; + } + + // Seed element 0 with the initial state. + ArticulatedAxleVelocities initial_axle = + model_.articulationToAxleVelocities(linear_velocity_m_s, initial_articulation_angle_rad); + double initial_omega = initial_axle.rear_axle_turning_velocity_rad_s; + ArticulatedVehicleState initial_inner = model_.bodyVelocityToVehicleState(linear_velocity_m_s, initial_omega); + trajectory.push_back(ArticulatedProjectedState{ + 0.0, initial_pose, initial_articulation_angle_rad, linear_velocity_m_s, initial_omega, initial_inner}); + + std::size_t n_steps = static_cast(std::ceil(horizon_s / dt_s)); + trajectory.reserve(n_steps + 1); + + Pose2D pose = initial_pose; + double articulation_angle = initial_articulation_angle_rad; + for (std::size_t i = 0; i < n_steps; ++i) { + ArticulatedProjectedState s = + step(dt_s, pose, articulation_angle, target_articulation_angle_rad, articulation_rate_rad_s, linear_velocity_m_s); + s.time_s = static_cast(i + 1) * dt_s; + pose = s.pose; + articulation_angle = s.articulation_angle_rad; + trajectory.push_back(s); + } + return trajectory; +} + +} // namespace polymath::kinematics diff --git a/src/bicycle_projector.cpp b/src/bicycle_projector.cpp new file mode 100644 index 0000000..19a59fd --- /dev/null +++ b/src/bicycle_projector.cpp @@ -0,0 +1,103 @@ +// 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. + +#include "polymath_kinematics/bicycle_projector.hpp" + +#include +#include +#include + +namespace polymath::kinematics +{ + +BicycleProjectedState BicycleProjector::step( + double dt_s, + const Pose2D & current_pose, + double current_steering_angle_rad, + double target_steering_angle_rad, + double steering_rate_rad_s, + double linear_velocity_m_s) +{ + // Clamp target into the actuator's bounds before ramping, so out-of-range commands + // saturate at the limit rather than oscillating or overshooting. + double clamped_target = std::clamp(target_steering_angle_rad, min_steering_angle_rad_, max_steering_angle_rad_); + + // Slew toward the clamped target at |rate| per second, never overshooting. + double max_delta = std::abs(steering_rate_rad_s) * dt_s; + double delta = clamped_target - current_steering_angle_rad; + if (std::abs(delta) > max_delta) { + delta = std::copysign(max_delta, delta); + } + double new_steering_angle_rad = current_steering_angle_rad + delta; + + // Forward kinematics with the post-ramp angle gives the body omega used for theta integration. + BicycleBodyVelocity body_vel = model_.steeringToBodyVelocity(linear_velocity_m_s, new_steering_angle_rad); + + // Inverse kinematics populates wheel speeds + turning radius for the snapshot. + BicycleSteeringState inner = model_.bodyVelocityToSteering(linear_velocity_m_s, body_vel.angular_velocity_rad_s); + + // Euler pose update (heading taken at start of step, matching the plan's integration choice). + Pose2D new_pose{ + current_pose.x + linear_velocity_m_s * std::cos(current_pose.theta) * dt_s, + current_pose.y + linear_velocity_m_s * std::sin(current_pose.theta) * dt_s, + normalizeAngle(current_pose.theta + body_vel.angular_velocity_rad_s * dt_s)}; + + return BicycleProjectedState{ + dt_s, new_pose, new_steering_angle_rad, linear_velocity_m_s, body_vel.angular_velocity_rad_s, inner}; +} + +std::vector BicycleProjector::project( + double horizon_s, + double dt_s, + const Pose2D & initial_pose, + double initial_steering_angle_rad, + double target_steering_angle_rad, + double steering_rate_rad_s, + double linear_velocity_m_s) +{ + std::vector trajectory; + if (dt_s <= 0.0 || horizon_s < 0.0) { + return trajectory; + } + + // Seed element 0 with the initial state so plots have a clean t=0 anchor. + // Populate the steering_state field by running inverse kinematics on the implied omega. + BicycleBodyVelocity initial_body = model_.steeringToBodyVelocity(linear_velocity_m_s, initial_steering_angle_rad); + BicycleSteeringState initial_inner = + model_.bodyVelocityToSteering(linear_velocity_m_s, initial_body.angular_velocity_rad_s); + trajectory.push_back(BicycleProjectedState{ + 0.0, + initial_pose, + initial_steering_angle_rad, + linear_velocity_m_s, + initial_body.angular_velocity_rad_s, + initial_inner}); + + std::size_t n_steps = static_cast(std::ceil(horizon_s / dt_s)); + trajectory.reserve(n_steps + 1); + + Pose2D pose = initial_pose; + double steering_angle = initial_steering_angle_rad; + for (std::size_t i = 0; i < n_steps; ++i) { + BicycleProjectedState s = + step(dt_s, pose, steering_angle, target_steering_angle_rad, steering_rate_rad_s, linear_velocity_m_s); + s.time_s = static_cast(i + 1) * dt_s; + pose = s.pose; + steering_angle = s.steering_angle_rad; + trajectory.push_back(s); + } + return trajectory; +} + +} // namespace polymath::kinematics diff --git a/src/kinematics_pybind.cpp b/src/kinematics_pybind.cpp index 0079e67..5158b09 100644 --- a/src/kinematics_pybind.cpp +++ b/src/kinematics_pybind.cpp @@ -13,9 +13,13 @@ // limitations under the License. #include "polymath_kinematics/articulated_model.hpp" +#include "polymath_kinematics/articulated_projector.hpp" #include "polymath_kinematics/bicycle_model.hpp" +#include "polymath_kinematics/bicycle_projector.hpp" #include "polymath_kinematics/differential_drive_model.hpp" +#include "polymath_kinematics/pose2d.hpp" #include "pybind11/pybind11.h" +#include "pybind11/stl.h" namespace py = pybind11; @@ -55,14 +59,16 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) py::arg("rear_wheel_radius_m")) .def( "body_velocity_to_vehicle_state", - &ArticulatedModel::bodyVelocityToVehicleState, + py::overload_cast(&ArticulatedModel::bodyVelocityToVehicleState), py::arg("linear_velocity_m_s"), - py::arg("angular_velocity_rad_s")) + py::arg("angular_velocity_rad_s"), + py::arg("articulation_turning_velocity_rad_s") = 0.0) .def( "articulation_to_axle_velocities", - &ArticulatedModel::articulationToAxleVelocities, + py::overload_cast(&ArticulatedModel::articulationToAxleVelocities), py::arg("linear_velocity_m_s"), - py::arg("articulation_angle_rad")) + py::arg("articulation_angle_rad"), + py::arg("articulation_turning_velocity_rad_s") = 0.0) .def_property_readonly("articulation_to_front_axle", &ArticulatedModel::get_articulation_to_front_axle_m) .def_property_readonly("articulation_to_rear_axle", &ArticulatedModel::get_articulation_to_rear_axle_m) .def_property_readonly("front_track_width", &ArticulatedModel::get_front_track_width_m) @@ -130,6 +136,96 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) .def_property_readonly("wheelbase", &BicycleModel::get_wheelbase_m) .def_property_readonly("track_width", &BicycleModel::get_track_width_m) .def_property_readonly("wheel_radius", &BicycleModel::get_wheel_radius_m); + + // Shared Pose2D + py::class_(m, "Pose2D") + .def(py::init<>()) + .def( + py::init([](double x, double y, double theta) { return Pose2D{x, y, theta}; }), + py::arg("x") = 0.0, + py::arg("y") = 0.0, + py::arg("theta") = 0.0) + .def_readwrite("x", &Pose2D::x) + .def_readwrite("y", &Pose2D::y) + .def_readwrite("theta", &Pose2D::theta); + + // Bicycle projector bindings + py::class_(m, "BicycleProjectedState") + .def(py::init<>()) + .def_readwrite("time_s", &BicycleProjectedState::time_s) + .def_readwrite("pose", &BicycleProjectedState::pose) + .def_readwrite("steering_angle_rad", &BicycleProjectedState::steering_angle_rad) + .def_readwrite("linear_velocity_m_s", &BicycleProjectedState::linear_velocity_m_s) + .def_readwrite("angular_velocity_rad_s", &BicycleProjectedState::angular_velocity_rad_s) + .def_readwrite("steering_state", &BicycleProjectedState::steering_state); + + py::class_(m, "BicycleProjector") + .def( + py::init(), + py::arg("model"), + py::arg("min_steering_angle_rad"), + py::arg("max_steering_angle_rad")) + .def( + "step", + &BicycleProjector::step, + py::arg("dt_s"), + py::arg("current_pose"), + py::arg("current_steering_angle_rad"), + py::arg("target_steering_angle_rad"), + py::arg("steering_rate_rad_s"), + py::arg("linear_velocity_m_s")) + .def( + "project", + &BicycleProjector::project, + py::arg("horizon_s"), + py::arg("dt_s"), + py::arg("initial_pose"), + py::arg("initial_steering_angle_rad"), + py::arg("target_steering_angle_rad"), + py::arg("steering_rate_rad_s"), + py::arg("linear_velocity_m_s")) + .def_property_readonly("model", &BicycleProjector::get_model, py::return_value_policy::reference_internal) + .def_property_readonly("min_steering_angle_rad", &BicycleProjector::get_min_steering_angle_rad) + .def_property_readonly("max_steering_angle_rad", &BicycleProjector::get_max_steering_angle_rad); + + // Articulated projector bindings + py::class_(m, "ArticulatedProjectedState") + .def(py::init<>()) + .def_readwrite("time_s", &ArticulatedProjectedState::time_s) + .def_readwrite("pose", &ArticulatedProjectedState::pose) + .def_readwrite("articulation_angle_rad", &ArticulatedProjectedState::articulation_angle_rad) + .def_readwrite("linear_velocity_m_s", &ArticulatedProjectedState::linear_velocity_m_s) + .def_readwrite("angular_velocity_rad_s", &ArticulatedProjectedState::angular_velocity_rad_s) + .def_readwrite("vehicle_state", &ArticulatedProjectedState::vehicle_state); + + py::class_(m, "ArticulatedProjector") + .def( + py::init(), + py::arg("model"), + py::arg("min_articulation_angle_rad"), + py::arg("max_articulation_angle_rad")) + .def( + "step", + &ArticulatedProjector::step, + py::arg("dt_s"), + py::arg("current_pose"), + py::arg("current_articulation_angle_rad"), + py::arg("target_articulation_angle_rad"), + py::arg("articulation_rate_rad_s"), + py::arg("linear_velocity_m_s")) + .def( + "project", + &ArticulatedProjector::project, + py::arg("horizon_s"), + py::arg("dt_s"), + py::arg("initial_pose"), + py::arg("initial_articulation_angle_rad"), + py::arg("target_articulation_angle_rad"), + py::arg("articulation_rate_rad_s"), + py::arg("linear_velocity_m_s")) + .def_property_readonly("model", &ArticulatedProjector::get_model, py::return_value_policy::reference_internal) + .def_property_readonly("min_articulation_angle_rad", &ArticulatedProjector::get_min_articulation_angle_rad) + .def_property_readonly("max_articulation_angle_rad", &ArticulatedProjector::get_max_articulation_angle_rad); } } // namespace polymath::kinematics diff --git a/test/test_articulated_model.cpp b/test/test_articulated_model.cpp index 564bb95..063d0c7 100644 --- a/test/test_articulated_model.cpp +++ b/test/test_articulated_model.cpp @@ -222,4 +222,41 @@ TEST_CASE("ArticulatedModel roundtrip reverse - bodyVelocityToVehicleState to ar CHECK(axle_vel.front_axle_turning_velocity_rad_s == Approx(angular_velocity).margin(1e-6)); } +TEST_CASE("ArticulatedModel articulationToAxleVelocities - 2-arg delegates to 3-arg with rate=0") +{ + ArticulatedModel model(1.5, 1.2, 1.8, 1.6, 0.4, 0.5); + + auto two_arg = model.articulationToAxleVelocities(2.0, 0.3); + auto three_arg_zero = model.articulationToAxleVelocities(2.0, 0.3, 0.0); + + CHECK(two_arg.front_axle_turning_velocity_rad_s == Approx(three_arg_zero.front_axle_turning_velocity_rad_s)); + CHECK(two_arg.rear_axle_turning_velocity_rad_s == Approx(three_arg_zero.rear_axle_turning_velocity_rad_s)); +} + +TEST_CASE("ArticulatedModel articulationToAxleVelocities - nonzero rate adds gamma-dot contribution") +{ + ArticulatedModel model(1.5, 1.2, 1.8, 1.6, 0.4, 0.5); + + // From the derivation: omega_rear = omega_front - gamma_dot. So passing a non-zero rate must + // shift the rear-axle turning velocity by exactly -gamma_dot compared to the rate=0 case + // (the front-axle term changes too, but the rear-front difference is exactly gamma_dot). + double gamma_dot = 0.2; + auto rest = model.articulationToAxleVelocities(2.0, 0.3, 0.0); + auto with_rate = model.articulationToAxleVelocities(2.0, 0.3, gamma_dot); + + CHECK(with_rate.front_axle_turning_velocity_rad_s - with_rate.rear_axle_turning_velocity_rad_s == Approx(gamma_dot)); + CHECK(with_rate.front_axle_turning_velocity_rad_s != Approx(rest.front_axle_turning_velocity_rad_s)); +} + +TEST_CASE("ArticulatedModel bodyVelocityToVehicleState - 2-arg delegates to 3-arg with rate=0") +{ + ArticulatedModel model(1.5, 1.2, 1.8, 1.6, 0.4, 0.5); + + auto two_arg = model.bodyVelocityToVehicleState(2.0, 0.5); + auto three_arg_zero = model.bodyVelocityToVehicleState(2.0, 0.5, 0.0); + + CHECK(two_arg.articulation_angle_rad == Approx(three_arg_zero.articulation_angle_rad)); + CHECK(two_arg.front_axle_turning_radius_m == Approx(three_arg_zero.front_axle_turning_radius_m)); +} + } // namespace polymath::kinematics diff --git a/test/test_articulated_projector.cpp b/test/test_articulated_projector.cpp new file mode 100644 index 0000000..f07cba7 --- /dev/null +++ b/test/test_articulated_projector.cpp @@ -0,0 +1,161 @@ +// 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. + +#include + +#include "catch2_compat.hpp" +#include "polymath_kinematics/articulated_projector.hpp" + +namespace polymath::kinematics +{ + +namespace +{ +// stueve-style parameters +constexpr double kFrontArm = 1.66; +constexpr double kRearArm = 1.44; +constexpr double kFrontTrack = 2.0; +constexpr double kRearTrack = 2.0; +constexpr double kFrontWheelRadius = 0.723; +constexpr double kRearWheelRadius = 0.723; +constexpr double kMinAngle = -0.785; +constexpr double kMaxAngle = 0.785; + +ArticulatedModel make_model() +{ + return ArticulatedModel(kFrontArm, kRearArm, kFrontTrack, kRearTrack, kFrontWheelRadius, kRearWheelRadius); +} +} // namespace + +TEST_CASE("ArticulatedProjector construction stores model and limits") +{ + ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + CHECK(projector.get_min_articulation_angle_rad() == Approx(kMinAngle)); + CHECK(projector.get_max_articulation_angle_rad() == Approx(kMaxAngle)); + CHECK(projector.get_model().get_articulation_to_front_axle_m() == Approx(kFrontArm)); +} + +TEST_CASE("ArticulatedProjector step - zero rate freezes articulation angle") +{ + ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + auto result = projector.step(0.1, pose, 0.3, 0.6, 0.0, 1.0); + CHECK(result.articulation_angle_rad == Approx(0.3)); +} + +TEST_CASE("ArticulatedProjector step - large rate snaps to target without overshoot") +{ + ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + auto result = projector.step(0.1, pose, 0.0, 0.4, 100.0, 1.0); + CHECK(result.articulation_angle_rad == Approx(0.4)); +} + +TEST_CASE("ArticulatedProjector step - rate-limited slew advances by rate*dt") +{ + ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + // rate*dt = 0.02, target far away + auto result = projector.step(0.1, pose, 0.0, 0.5, 0.2, 1.0); + CHECK(result.articulation_angle_rad == Approx(0.02)); +} + +TEST_CASE("ArticulatedProjector step - target above max saturates at max") +{ + ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + auto result = projector.step(0.1, pose, 0.0, 2.0, 100.0, 1.0); + CHECK(result.articulation_angle_rad == Approx(kMaxAngle)); +} + +TEST_CASE("ArticulatedProjector step - negative articulation rate is treated as magnitude") +{ + ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + auto result = projector.step(0.1, pose, 0.0, 0.5, -0.2, 1.0); + CHECK(result.articulation_angle_rad == Approx(0.02)); +} + +TEST_CASE("ArticulatedProjector project - straight line: zero articulation, theta unchanged") +{ + ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + auto trajectory = projector.project(1.0, 0.1, pose, 0.0, 0.0, 0.0, 1.5); + CHECK_EQ(trajectory.size(), 11); + CHECK_EQ(trajectory.back().pose.x, Approx(1.5)); + CHECK_EQ(trajectory.back().pose.y, Approx(0.0)); + CHECK_EQ(trajectory.back().pose.theta, Approx(0.0)); + CHECK_EQ(trajectory.back().articulation_angle_rad, Approx(0.0)); +} + +TEST_CASE("ArticulatedProjector project - articulation ramps then saturates at max") +{ + ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + // target = 0.785 (= max), rate = 0.2 rad/s, dt = 0.1 → step adds 0.02 + // Reach max in ceil(0.785 / 0.02) = 40 steps; horizon 5.0s → 50 steps total. + auto trajectory = projector.project(5.0, 0.1, pose, 0.0, 0.785, 0.2, 1.0); + CHECK_EQ(trajectory.size(), 51); + CHECK_EQ(trajectory[40].articulation_angle_rad, Approx(0.785)); + CHECK_EQ(trajectory.back().articulation_angle_rad, Approx(0.785)); +} + +TEST_CASE("ArticulatedProjector project - initial state stored as element 0") +{ + ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + Pose2D pose{1.0, 2.0, 0.5}; + + auto trajectory = projector.project(0.5, 0.1, pose, 0.2, 0.6, 0.5, 1.0); + CHECK(trajectory.front().time_s == Approx(0.0)); + CHECK(trajectory.front().pose.x == Approx(1.0)); + CHECK(trajectory.front().pose.y == Approx(2.0)); + CHECK(trajectory.front().pose.theta == Approx(0.5)); + CHECK(trajectory.front().articulation_angle_rad == Approx(0.2)); +} + +TEST_CASE("ArticulatedProjector step - heading rotates with non-zero articulation at steady state") +{ + ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + // With current == clamped target, the realized gamma-dot is zero (no joint motion this step), + // so the rear-axle turning velocity is purely a function of v and the articulation angle. + // Positive articulation + forward v → positive (CCW) rear-axle omega and theta increase. + auto result = projector.step(0.1, pose, 0.3, 0.3, 100.0, 1.0); + CHECK(result.articulation_angle_rad == Approx(0.3)); + CHECK(result.angular_velocity_rad_s > 0.0); + CHECK(result.pose.theta > 0.0); +} + +TEST_CASE("ArticulatedProjector step - realized gamma-dot affects rear-axle omega") +{ + ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + // Same starting angle, same target, same v: stepping with a small rate (slow ramp) versus + // a large rate (snap) yields different rear-axle angular velocities because gamma-dot + // contributes to the kinematics. + auto slow_ramp = projector.step(0.1, pose, 0.0, 0.3, 0.2, 1.0); // step adds 0.02 (small gamma-dot) + auto snap = projector.step(0.1, pose, 0.0, 0.3, 100.0, 1.0); // snaps to 0.3 (huge gamma-dot) + CHECK(slow_ramp.angular_velocity_rad_s != Approx(snap.angular_velocity_rad_s)); +} + +} // namespace polymath::kinematics diff --git a/test/test_bicycle_projector.cpp b/test/test_bicycle_projector.cpp new file mode 100644 index 0000000..eda09cc --- /dev/null +++ b/test/test_bicycle_projector.cpp @@ -0,0 +1,145 @@ +// 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. + +#include + +#include "catch2_compat.hpp" +#include "polymath_kinematics/bicycle_projector.hpp" + +namespace polymath::kinematics +{ + +namespace +{ +constexpr double kWheelbase = 2.5; +constexpr double kTrack = 1.5; +constexpr double kWheelRadius = 0.3; +constexpr double kMinAngle = -0.6; +constexpr double kMaxAngle = 0.6; +} // namespace + +TEST_CASE("BicycleProjector construction stores model and limits") +{ + BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + CHECK(projector.get_min_steering_angle_rad() == Approx(kMinAngle)); + CHECK(projector.get_max_steering_angle_rad() == Approx(kMaxAngle)); + CHECK(projector.get_model().get_wheelbase_m() == Approx(kWheelbase)); +} + +TEST_CASE("BicycleProjector step - zero rate freezes the steering angle") +{ + BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + auto result = projector.step(0.1, pose, 0.2, 0.5, 0.0, 1.0); + CHECK(result.steering_angle_rad == Approx(0.2)); +} + +TEST_CASE("BicycleProjector step - large rate reaches target in one step without overshoot") +{ + BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + // |delta| = 0.3, max_delta = rate * dt = 10.0 * 0.1 = 1.0 >> 0.3 → snaps exactly to target. + auto result = projector.step(0.1, pose, 0.0, 0.3, 10.0, 1.0); + CHECK(result.steering_angle_rad == Approx(0.3)); +} + +TEST_CASE("BicycleProjector step - rate-limited slew advances by rate*dt toward target") +{ + BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + // rate*dt = 0.05, target far away → expect 0.05 increment. + auto result = projector.step(0.1, pose, 0.0, 0.5, 0.5, 1.0); + CHECK(result.steering_angle_rad == Approx(0.05)); +} + +TEST_CASE("BicycleProjector step - negative steering rate is treated as magnitude") +{ + BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + auto result = projector.step(0.1, pose, 0.0, 0.5, -0.5, 1.0); + CHECK(result.steering_angle_rad == Approx(0.05)); +} + +TEST_CASE("BicycleProjector step - target above max saturates at max") +{ + BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + // With large rate the angle snaps to clamped_target == max. + auto result = projector.step(0.1, pose, 0.0, 5.0, 100.0, 1.0); + CHECK(result.steering_angle_rad == Approx(kMaxAngle)); +} + +TEST_CASE("BicycleProjector project - straight line traces +x with theta unchanged") +{ + BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + auto trajectory = projector.project(1.0, 0.1, pose, 0.0, 0.0, 1.0, 2.0); + // ceil(1.0 / 0.1) + 1 = 11 samples + CHECK_EQ(trajectory.size(), 11); + CHECK_EQ(trajectory.front().time_s, Approx(0.0)); + CHECK_EQ(trajectory.front().pose.x, Approx(0.0)); + CHECK_EQ(trajectory.back().time_s, Approx(1.0)); + CHECK_EQ(trajectory.back().pose.x, Approx(2.0)); // x = v * t + CHECK_EQ(trajectory.back().pose.y, Approx(0.0)); + CHECK_EQ(trajectory.back().pose.theta, Approx(0.0)); +} + +TEST_CASE("BicycleProjector project - ramp reaches target in expected number of steps") +{ + BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + // target=0.5, rate=0.5, dt=0.1 → step adds 0.05; takes 10 steps to reach 0.5. + auto trajectory = projector.project(2.0, 0.1, pose, 0.0, 0.5, 0.5, 0.0); + CHECK(trajectory[10].steering_angle_rad == Approx(0.5)); + CHECK(trajectory.back().steering_angle_rad == Approx(0.5)); // pinned to target afterwards +} + +TEST_CASE("BicycleProjector project - sign symmetry: negating target mirrors trajectory across y=0") +{ + BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + Pose2D pose{0.0, 0.0, 0.0}; + + auto left = projector.project(2.0, 0.05, pose, 0.0, 0.4, 1.0, 1.0); + auto right = projector.project(2.0, 0.05, pose, 0.0, -0.4, 1.0, 1.0); + REQUIRE(left.size() == right.size()); + + for (std::size_t i = 0; i < left.size(); ++i) { + CHECK(left[i].pose.x == Approx(right[i].pose.x)); + CHECK(left[i].pose.y == Approx(-right[i].pose.y)); + CHECK(left[i].pose.theta == Approx(-right[i].pose.theta)); + CHECK(left[i].steering_angle_rad == Approx(-right[i].steering_angle_rad)); + } +} + +TEST_CASE("BicycleProjector project - initial state stored as element 0") +{ + BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + Pose2D pose{1.5, -2.0, 0.25}; + + auto trajectory = projector.project(0.5, 0.1, pose, 0.1, 0.3, 0.5, 1.0); + CHECK(trajectory.front().time_s == Approx(0.0)); + CHECK(trajectory.front().pose.x == Approx(1.5)); + CHECK(trajectory.front().pose.y == Approx(-2.0)); + CHECK(trajectory.front().pose.theta == Approx(0.25)); + CHECK(trajectory.front().steering_angle_rad == Approx(0.1)); +} + +} // namespace polymath::kinematics diff --git a/test/test_python_bindings.py b/test/test_python_bindings.py index 1cf1880..d6733bd 100644 --- a/test/test_python_bindings.py +++ b/test/test_python_bindings.py @@ -18,8 +18,11 @@ from polymath_kinematics import ( ArticulatedModel, + ArticulatedProjector, BicycleModel, + BicycleProjector, DifferentialDriveModel, + Pose2D, ) @@ -141,3 +144,125 @@ def test_reverse_roundtrip(self): axle_vel = model.articulation_to_axle_velocities(linear_velocity, vehicle_state.articulation_angle_rad) assert axle_vel.front_axle_turning_velocity_rad_s == pytest.approx(angular_velocity, abs=1e-6) + + def test_axle_velocities_rate_defaults_to_zero(self): + model = ArticulatedModel(1.5, 1.2, 1.8, 1.6, 0.4, 0.5) + no_arg = model.articulation_to_axle_velocities(2.0, 0.3) + explicit_zero = model.articulation_to_axle_velocities(2.0, 0.3, 0.0) + + assert no_arg.front_axle_turning_velocity_rad_s == pytest.approx( + explicit_zero.front_axle_turning_velocity_rad_s + ) + assert no_arg.rear_axle_turning_velocity_rad_s == pytest.approx(explicit_zero.rear_axle_turning_velocity_rad_s) + + def test_axle_velocities_nonzero_rate_changes_rear(self): + model = ArticulatedModel(1.5, 1.2, 1.8, 1.6, 0.4, 0.5) + gamma_dot = 0.25 + with_rate = model.articulation_to_axle_velocities(2.0, 0.3, gamma_dot) + # rear-axle turning velocity = front-axle turning velocity - gamma_dot, exactly. + assert ( + with_rate.front_axle_turning_velocity_rad_s - with_rate.rear_axle_turning_velocity_rad_s + == pytest.approx(gamma_dot) + ) + + +class TestBicycleProjector: + def _make(self): + return BicycleProjector( + model=BicycleModel(2.5, 1.5, 0.3), + min_steering_angle_rad=-0.6, + max_steering_angle_rad=0.6, + ) + + def test_construction(self): + projector = self._make() + assert projector.min_steering_angle_rad == pytest.approx(-0.6) + assert projector.max_steering_angle_rad == pytest.approx(0.6) + assert projector.model.wheelbase == pytest.approx(2.5) + + def test_zero_rate_freezes_angle(self): + projector = self._make() + result = projector.step( + dt_s=0.1, + current_pose=Pose2D(), + current_steering_angle_rad=0.2, + target_steering_angle_rad=0.5, + steering_rate_rad_s=0.0, + linear_velocity_m_s=1.0, + ) + assert result.steering_angle_rad == pytest.approx(0.2) + + def test_clamping_saturates_at_max(self): + projector = self._make() + result = projector.step(0.1, Pose2D(), 0.0, 5.0, 100.0, 1.0) + assert result.steering_angle_rad == pytest.approx(0.6) + + def test_rate_limited_ramp(self): + projector = self._make() + result = projector.step(0.1, Pose2D(), 0.0, 0.5, 0.5, 1.0) + assert result.steering_angle_rad == pytest.approx(0.05) + + def test_project_straight_line(self): + projector = self._make() + traj = projector.project(1.0, 0.1, Pose2D(), 0.0, 0.0, 1.0, 2.0) + assert len(traj) == 11 + assert traj[0].time_s == pytest.approx(0.0) + assert traj[-1].time_s == pytest.approx(1.0) + assert traj[-1].pose.x == pytest.approx(2.0) + assert traj[-1].pose.y == pytest.approx(0.0) + assert traj[-1].pose.theta == pytest.approx(0.0) + + def test_project_ramps_to_target(self): + projector = self._make() + # target=0.5, rate=0.5, dt=0.1 → step adds 0.05; reaches 0.5 at step 10. + traj = projector.project(2.0, 0.1, Pose2D(), 0.0, 0.5, 0.5, 0.0) + assert traj[10].steering_angle_rad == pytest.approx(0.5) + assert traj[-1].steering_angle_rad == pytest.approx(0.5) + + +class TestArticulatedProjector: + def _make(self): + return ArticulatedProjector( + model=ArticulatedModel(1.66, 1.44, 2.0, 2.0, 0.723, 0.723), + min_articulation_angle_rad=-0.785, + max_articulation_angle_rad=0.785, + ) + + def test_construction(self): + projector = self._make() + assert projector.min_articulation_angle_rad == pytest.approx(-0.785) + assert projector.max_articulation_angle_rad == pytest.approx(0.785) + assert projector.model.articulation_to_front_axle == pytest.approx(1.66) + + def test_zero_rate_freezes_angle(self): + projector = self._make() + result = projector.step(0.1, Pose2D(), 0.3, 0.6, 0.0, 1.0) + assert result.articulation_angle_rad == pytest.approx(0.3) + + def test_clamping_saturates_at_max(self): + projector = self._make() + result = projector.step(0.1, Pose2D(), 0.0, 2.0, 100.0, 1.0) + assert result.articulation_angle_rad == pytest.approx(0.785) + + def test_rate_limited_ramp(self): + projector = self._make() + result = projector.step(0.1, Pose2D(), 0.0, 0.5, 0.2, 1.0) + assert result.articulation_angle_rad == pytest.approx(0.02) + + def test_project_stueve_max_articulation(self): + projector = self._make() + # target=0.785, rate=0.2 rad/s, dt=0.1 → 0.02 per step; reaches max at step 40 (~3.925s ceil to 4.0s). + traj = projector.project(5.0, 0.1, Pose2D(), 0.0, 0.785, 0.2, 1.0) + assert len(traj) == 51 + assert traj[40].articulation_angle_rad == pytest.approx(0.785) + assert traj[-1].articulation_angle_rad == pytest.approx(0.785) + + def test_project_initial_state_anchored(self): + projector = self._make() + initial = Pose2D(x=1.0, y=2.0, theta=0.5) + traj = projector.project(0.5, 0.1, initial, 0.2, 0.6, 0.5, 1.0) + assert traj[0].time_s == pytest.approx(0.0) + assert traj[0].pose.x == pytest.approx(1.0) + assert traj[0].pose.y == pytest.approx(2.0) + assert traj[0].pose.theta == pytest.approx(0.5) + assert traj[0].articulation_angle_rad == pytest.approx(0.2) From 6325a9f31693a401da95948bc17e0855a00f0ca7 Mon Sep 17 00:00:00 2001 From: Zeerek Date: Tue, 12 May 2026 01:50:42 +0000 Subject: [PATCH 2/9] Add in visualization tool Brief pre-commit --- .gitignore | 11 +- CMakeLists.txt | 133 +- README.md | 273 ++- package.xml | 21 - polymath_kinematics/explorer/__init__.py | 81 + polymath_kinematics/explorer/config.py | 115 + polymath_kinematics/explorer/export.py | 124 ++ polymath_kinematics/explorer/plotting.py | 511 +++++ polymath_kinematics/explorer/simulation.py | 324 +++ polymath_kinematics/explorer/types.py | 67 + polymath_kinematics/kinematic_explorer_app.py | 632 ++++++ pyproject.toml | 47 +- setup.cfg | 5 - test/test_articulated_projector.cpp | 16 +- test/test_bicycle_projector.cpp | 14 +- test/test_explorer.py | 308 +++ uv.lock | 1899 +++++++++++++++++ 17 files changed, 4384 insertions(+), 197 deletions(-) delete mode 100644 package.xml create mode 100644 polymath_kinematics/explorer/__init__.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 delete mode 100644 setup.cfg create mode 100644 test/test_explorer.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 7005901..ee3b474 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,15 @@ -# Colcon +# Colcon (when built from the workspace root, build/ lives there) /build/ /install/ /log/ -# Python +# Python / uv +.venv/ __pycache__/ *.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ + +# Build artifacts from CMake direct invocation +*.so diff --git a/CMakeLists.txt b/CMakeLists.txt index e6e8bc9..3546bdf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,122 +12,65 @@ # See the License for the specific language governing permissions and # limitations under the License. -cmake_minimum_required(VERSION 3.8) -project(polymath_kinematics) +cmake_minimum_required(VERSION 3.15) +project(polymath_kinematics CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) -if(NOT CMAKE_CXX_STANDARD) - set(CMAKE_CXX_STANDARD 17) -endif() if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic -Werror) - add_link_options(-Wl,-no-undefined) endif() -# Ubuntu detection (override with -DBUILD_JAMMY=ON/OFF) -if(NOT DEFINED BUILD_JAMMY) - set(BUILD_JAMMY OFF) - if(EXISTS "/etc/os-release") - file(READ "/etc/os-release" _OS_RELEASE) - string(REGEX MATCH "VERSION_CODENAME=([^\n\r]+)" _match "${_OS_RELEASE}") - if(CMAKE_MATCH_1) - set(_UBUNTU_CODENAME "${CMAKE_MATCH_1}") - string(TOLOWER "${_UBUNTU_CODENAME}" _UBUNTU_CODENAME) - if(_UBUNTU_CODENAME STREQUAL "jammy") - set(BUILD_JAMMY ON) - message(STATUS "Ubuntu=jammy (22.04) -> Catch2 v2") - else() - set(BUILD_JAMMY OFF) - message(STATUS "Ubuntu codename='${_UBUNTU_CODENAME}' -> defaulting to Catch2 v3") - endif() - endif() - endif() -endif() +find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) +find_package(pybind11 CONFIG REQUIRED) -# Find packages -find_package(ament_cmake REQUIRED) -find_package(ament_cmake_python REQUIRED) -find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module) -find_package(pybind11_vendor REQUIRED) -find_package(pybind11 REQUIRED) - -# C++ library with all kinematic models -add_library(polymath_kinematics SHARED +# Core static library — the kinematic models and projectors live here. +add_library(polymath_kinematics_core STATIC src/articulated_model.cpp src/articulated_projector.cpp - src/differential_drive_model.cpp src/bicycle_model.cpp src/bicycle_projector.cpp + src/differential_drive_model.cpp ) -target_include_directories(polymath_kinematics PUBLIC +target_include_directories(polymath_kinematics_core PUBLIC $ - $ ) +set_target_properties(polymath_kinematics_core PROPERTIES POSITION_INDEPENDENT_CODE ON) -# Python bindings -set(PYBIND_TARGET polymath_kinematics_cpp) -pybind11_add_module(${PYBIND_TARGET} - src/kinematics_pybind.cpp -) -set_target_properties(${PYBIND_TARGET} PROPERTIES LINK_OPTIONS "") -target_link_libraries(${PYBIND_TARGET} PRIVATE polymath_kinematics) -_ament_cmake_python_register_environment_hook() -install(TARGETS ${PYBIND_TARGET} DESTINATION "${PYTHON_INSTALL_DIR}") +# Python extension — exposes everything as `polymath_kinematics_cpp`. +pybind11_add_module(polymath_kinematics_cpp src/kinematics_pybind.cpp) +target_link_libraries(polymath_kinematics_cpp PRIVATE polymath_kinematics_core) -# Install C++ library -install( - TARGETS polymath_kinematics - EXPORT ${PROJECT_NAME}_TARGETS - ARCHIVE DESTINATION lib - LIBRARY DESTINATION lib - RUNTIME DESTINATION lib/${PROJECT_NAME} -) -install( - EXPORT ${PROJECT_NAME}_TARGETS - NAMESPACE ${PROJECT_NAME}:: - DESTINATION share/${PROJECT_NAME}/cmake -) -install( - DIRECTORY include/ - DESTINATION include/ -) +# Install the extension module at the wheel root so `from polymath_kinematics_cpp import ...` works. +install(TARGETS polymath_kinematics_cpp DESTINATION .) -# Python module -ament_python_install_package(${PROJECT_NAME}) +# ---------------------------------------------------------------------- +# Optional C++ tests (off by default; build with -DBUILD_TESTING=ON). +# Not part of the wheel build. Run with: cmake --build && ctest. +# ---------------------------------------------------------------------- +option(BUILD_TESTING "Build C++ Catch2 tests" OFF) -# Testing if(BUILD_TESTING) - find_package(ament_cmake_pytest REQUIRED) include(CTest) - if(BUILD_JAMMY) + find_package(Catch2 3 QUIET) + if(NOT Catch2_FOUND) find_package(Catch2 2 REQUIRED) - else() - find_package(Catch2 3 REQUIRED) endif() - include(Catch OPTIONAL) - function(add_kinematics_catch2_test TEST_NAME TEST_SOURCE) - add_executable(${TEST_NAME} ${TEST_SOURCE}) - target_link_libraries(${TEST_NAME} PRIVATE polymath_kinematics Catch2::Catch2WithMain) - target_include_directories(${TEST_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/test) - if(COMMAND catch_discover_tests) - catch_discover_tests(${TEST_NAME}) - else() - add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) - endif() - endfunction() - - add_kinematics_catch2_test(test_differential_drive test/test_differential_drive.cpp) - add_kinematics_catch2_test(test_bicycle_model test/test_bicycle_model.cpp) - add_kinematics_catch2_test(test_articulated_model test/test_articulated_model.cpp) - add_kinematics_catch2_test(test_bicycle_projector test/test_bicycle_projector.cpp) - add_kinematics_catch2_test(test_articulated_projector test/test_articulated_projector.cpp) - ament_add_pytest_test(test_python_bindings test) + set(_pk_tests + test_bicycle_model + test_articulated_model + test_differential_drive + test_bicycle_projector + test_articulated_projector + ) + foreach(tgt IN LISTS _pk_tests) + add_executable(${tgt} test/${tgt}.cpp) + target_link_libraries(${tgt} PRIVATE polymath_kinematics_core Catch2::Catch2WithMain) + target_include_directories(${tgt} PRIVATE test) + add_test(NAME ${tgt} COMMAND ${tgt}) + endforeach() endif() - -# Export targets -ament_export_targets(${PROJECT_NAME}_TARGETS HAS_LIBRARY_TARGET) -ament_export_include_directories(include) -ament_export_libraries(polymath_kinematics) - -ament_package() diff --git a/README.md b/README.md index baeb07a..4d9e0af 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,96 @@ # polymath_kinematics -Kinematic models for differential-drive, bicycle (Ackermann), and articulated vehicles. +Kinematic models and forward-projection helpers for differential-drive, bicycle (Ackermann), and articulated vehicles. C++ library with Python bindings via pybind11, packaged as a single Python wheel built by [scikit-build-core]. -## Derivations +Includes an interactive **Kinematic Explorer** (Streamlit) for visualising trajectory lattices, kinematic relationships, and vehicle footprints. -Math and equations behind each model: +[scikit-build-core]: https://scikit-build-core.readthedocs.io/ -- [Differential drive](derivations/differential_drive.md) — forward/inverse kinematics for two independently driven wheels -- [Bicycle model (Ackermann)](derivations/bicycle_model.md) — steering-angle kinematics with four-wheel ICR geometry -- [Articulated model](derivations/articulated_model.md) — pivot-joint kinematics for front/rear section vehicles +## Installation -## C++ usage +**Prerequisites:** Python 3.10+, a C++17 compiler, CMake 3.15+, and [uv](https://github.com/astral-sh/uv). -```cmake -find_package(polymath_kinematics REQUIRED) -target_link_libraries(my_target PRIVATE polymath_kinematics::polymath_kinematics) -``` +```bash +cd src/polymath_kinematics -```cpp -#include -#include -#include +# C++ bindings only +uv pip install -e . + +# Bindings + Kinematic Explorer (streamlit, matplotlib, pandas) +uv pip install -e ".[explorer]" + +# Bindings + Explorer + pytest +uv pip install -e ".[dev]" ``` -### DifferentialDriveModel +The first install builds the pybind11 extension via CMake; subsequent installs are incremental. -```cpp -polymath::kinematics::DifferentialDriveModel model(0.15, 0.5); // wheel_radius_m, track_width_m +### ROS2 / colcon -auto wheels = model.bodyVelocityToWheelVelocities(1.0, 0.3); // linear m/s, angular rad/s -// wheels.left_wheel_velocity_rad_s, wheels.right_wheel_velocity_rad_s +The package also builds inside a colcon workspace (it has a top-level `CMakeLists.txt`, no `package.xml` — colcon picks it up via its CMake detector): -auto body = model.wheelVelocitiesToBodyVelocity(6.0, 7.0); // left rad/s, right rad/s -// body.linear_velocity_m_s, body.angular_velocity_rad_s +```bash +# From the workspace root +colcon build --merge-install --packages-select polymath_kinematics ``` -### BicycleModel +To also build the C++ tests: -```cpp -polymath::kinematics::BicycleModel model(2.7, 1.6, 0.35); // wheelbase_m, track_width_m, wheel_radius_m +```bash +colcon build --merge-install --packages-select polymath_kinematics --cmake-args -DBUILD_TESTING=ON +``` -auto state = model.bodyVelocityToSteering(2.0, 0.2); // linear m/s, angular rad/s -// state.steering_angle_rad, state.turning_radius_m -// state.front_right_wheel_rad_s, state.front_left_wheel_rad_s -// state.rear_right_wheel_rad_s, state.rear_left_wheel_rad_s +## Running the Kinematic Explorer + +After `uv pip install -e ".[explorer]"`: -auto body = model.steeringToBodyVelocity(2.0, 0.15); // velocity m/s, steering_angle rad -// body.linear_velocity_m_s, body.angular_velocity_rad_s +```bash +# Console script (recommended) +kinematic-explorer -double radius = model.turningRadius(0.15); // steering_angle rad -> meters -double angle = model.steeringAngleFromRadius(10.0); // radius m -> radians +# Or invoke streamlit directly +uv run python -m streamlit run polymath_kinematics/kinematic_explorer_app.py ``` -### ArticulatedModel +The app opens at `http://localhost:8501` and exposes: -```cpp -polymath::kinematics::ArticulatedModel model( - 1.8, 1.5, // articulation_to_front_axle_m, articulation_to_rear_axle_m - 2.0, 2.0, // front_track_width_m, rear_track_width_m - 0.6, 0.6); // front_wheel_radius_m, rear_wheel_radius_m +- Model selection (Differential Drive / Bicycle / Articulated) +- Geometry sliders (wheelbase, track width, wheel radii, etc.) +- Trajectory lattice visualisation across steering / articulation angles +- Kinematic analysis plots (angle → angular velocity, turning radius vs angle) +- Vehicle-footprint overlays along trajectories +- CSV / JSON / PNG / SVG / PDF export + +## Running tests -auto state = model.bodyVelocityToVehicleState(1.5, 0.1); // linear m/s, angular rad/s -// state.articulation_angle_rad -// state.front_right_wheel_speed_rad_s, state.front_left_wheel_speed_rad_s -// state.rear_right_wheel_speed_rad_s, state.rear_left_wheel_speed_rad_s -// state.front_axle_turning_radius_m, state.rear_axle_turning_radius_m +```bash +# Python (bindings + explorer) +uv run pytest -auto axles = model.articulationToAxleVelocities(1.5, 0.3); // linear m/s, articulation_angle rad -// axles.front_axle_turning_velocity_rad_s, axles.rear_axle_turning_velocity_rad_s +# C++ (via colcon) +colcon build --merge-install --packages-select polymath_kinematics --cmake-args -DBUILD_TESTING=ON +cd ../../build/polymath_kinematics && ctest --output-on-failure ``` +## Derivations + +Math and equations behind each model: + +- [Differential drive](derivations/differential_drive.md) — forward/inverse kinematics for two independently driven wheels +- [Bicycle model (Ackermann)](derivations/bicycle_model.md) — steering-angle kinematics with four-wheel ICR geometry +- [Articulated model](derivations/articulated_model.md) — pivot-joint kinematics for front/rear section vehicles + ## Python usage ```python -from polymath_kinematics import DifferentialDriveModel, BicycleModel, ArticulatedModel +from polymath_kinematics import ( + DifferentialDriveModel, + BicycleModel, + ArticulatedModel, + BicycleProjector, + ArticulatedProjector, + Pose2D, +) ``` ### DifferentialDriveModel @@ -115,17 +132,164 @@ model = ArticulatedModel( rear_wheel_radius_m=0.6, ) +# Steady articulation (gamma-dot = 0) state = model.body_velocity_to_vehicle_state( linear_velocity_m_s=1.5, angular_velocity_rad_s=0.1 ) print(state.articulation_angle_rad, state.front_axle_turning_radius_m) +# With explicit articulation rate (gamma-dot, rad/s) +state_with_rate = model.body_velocity_to_vehicle_state( + linear_velocity_m_s=1.5, + angular_velocity_rad_s=0.1, + articulation_turning_velocity_rad_s=0.2, +) + axles = model.articulation_to_axle_velocities( - linear_velocity_m_s=1.5, articulation_angle_rad=0.3 + linear_velocity_m_s=1.5, + articulation_angle_rad=0.3, + articulation_turning_velocity_rad_s=0.2, # optional, defaults to 0 ) print(axles.front_axle_turning_velocity_rad_s, axles.rear_axle_turning_velocity_rad_s) ``` +### Projectors — forward simulation with steering-rate limits + +`BicycleProjector` and `ArticulatedProjector` wrap their respective models and integrate pose forward in time under realistic actuator constraints: the steering / articulation angle ramps toward a target at a bounded rate, clamped to `[min, max]`. + +```python +projector = BicycleProjector( + model=BicycleModel(wheelbase_m=2.7, track_width_m=1.6, wheel_radius_m=0.35), + min_steering_angle_rad=-0.6, + max_steering_angle_rad=0.6, +) + +# One-step advance +result = projector.step( + dt_s=0.1, + current_pose=Pose2D(x=0.0, y=0.0, theta=0.0), + current_steering_angle_rad=0.0, + target_steering_angle_rad=0.3, + steering_rate_rad_s=0.5, + linear_velocity_m_s=1.0, +) +print(result.pose.x, result.steering_angle_rad) + +# Full trajectory +trajectory = projector.project( + horizon_s=5.0, + dt_s=0.05, + initial_pose=Pose2D(), + initial_steering_angle_rad=0.0, + target_steering_angle_rad=0.4, + steering_rate_rad_s=0.3, + linear_velocity_m_s=1.5, +) +# trajectory[0] is the initial state; trajectory[-1] is the end-of-horizon state. +``` + +`ArticulatedProjector` has the same shape — the angle is the articulation angle γ and the rate is γ̇: + +```python +projector = ArticulatedProjector( + model=ArticulatedModel(1.66, 1.44, 2.0, 2.0, 0.723, 0.723), + min_articulation_angle_rad=-0.785, + max_articulation_angle_rad=0.785, +) +trajectory = projector.project( + horizon_s=5.0, dt_s=0.05, + initial_pose=Pose2D(), + initial_articulation_angle_rad=0.0, + target_articulation_angle_rad=0.5, + articulation_rate_rad_s=0.2, + linear_velocity_m_s=1.0, +) +``` + +## C++ usage + +The C++ headers and library are installed under the package's CMake export when built via colcon: + +```cmake +find_package(polymath_kinematics REQUIRED) +target_link_libraries(my_target PRIVATE polymath_kinematics::polymath_kinematics) +``` + +```cpp +#include +#include +#include +#include +#include +#include +``` + +### DifferentialDriveModel + +```cpp +polymath::kinematics::DifferentialDriveModel model(0.15, 0.5); // wheel_radius_m, track_width_m + +auto wheels = model.bodyVelocityToWheelVelocities(1.0, 0.3); // linear m/s, angular rad/s +auto body = model.wheelVelocitiesToBodyVelocity(6.0, 7.0); // left rad/s, right rad/s +``` + +### BicycleModel + +```cpp +polymath::kinematics::BicycleModel model(2.7, 1.6, 0.35); // wheelbase, track, wheel_radius (m) + +auto state = model.bodyVelocityToSteering(2.0, 0.2); // linear m/s, angular rad/s +auto body = model.steeringToBodyVelocity(2.0, 0.15); // velocity m/s, steering rad + +double radius = model.turningRadius(0.15); // steering rad → meters +double angle = model.steeringAngleFromRadius(10.0); // radius m → radians +``` + +### ArticulatedModel + +```cpp +polymath::kinematics::ArticulatedModel model( + 1.8, 1.5, // articulation_to_front_axle_m, articulation_to_rear_axle_m + 2.0, 2.0, // front_track_width_m, rear_track_width_m + 0.6, 0.6); // front_wheel_radius_m, rear_wheel_radius_m + +// 2-arg form: assumes a zero articulation turning velocity +auto state = model.bodyVelocityToVehicleState(1.5, 0.1); + +// 3-arg form: feeds the actual gamma-dot into the kinematics +auto state_with_rate = model.bodyVelocityToVehicleState(1.5, 0.1, 0.2); + +auto axles = model.articulationToAxleVelocities(1.5, 0.3); // gamma-dot = 0 +auto axles_with_rate = model.articulationToAxleVelocities(1.5, 0.3, 0.2); +``` + +### Projectors + +```cpp +polymath::kinematics::BicycleProjector projector( + polymath::kinematics::BicycleModel(2.7, 1.6, 0.35), + -0.6, 0.6); // min/max steering angle (rad) + +polymath::kinematics::Pose2D pose{0.0, 0.0, 0.0}; + +auto step = projector.step( + /*dt_s=*/0.1, pose, + /*current_steering=*/0.0, + /*target_steering=*/0.3, + /*steering_rate=*/0.5, + /*linear_velocity=*/1.0); + +auto trajectory = projector.project( + /*horizon_s=*/5.0, /*dt_s=*/0.05, pose, + /*initial_steering=*/0.0, + /*target_steering=*/0.4, + /*steering_rate=*/0.3, + /*linear_velocity=*/1.5); +// trajectory.front() is the initial state, trajectory.back() is the end of the horizon. +``` + +`ArticulatedProjector` is shaped identically — the angle is the articulation angle (γ) and the rate is γ̇. + ## Models reference ### DifferentialDriveModel @@ -168,5 +332,14 @@ print(axles.front_axle_turning_velocity_rad_s, axles.rear_axle_turning_velocity_ | Method (C++ / Python) | Parameters | Returns | |---|---|---| -| `bodyVelocityToVehicleState` / `body_velocity_to_vehicle_state` | linear vel (m/s), angular vel (rad/s) | `ArticulatedVehicleState` | -| `articulationToAxleVelocities` / `articulation_to_axle_velocities` | linear vel (m/s), articulation angle (rad) | `ArticulatedAxleVelocities` | +| `bodyVelocityToVehicleState` / `body_velocity_to_vehicle_state` | linear vel (m/s), angular vel (rad/s), [articulation rate (rad/s) = 0] | `ArticulatedVehicleState` | +| `articulationToAxleVelocities` / `articulation_to_axle_velocities` | linear vel (m/s), articulation angle (rad), [articulation rate (rad/s) = 0] | `ArticulatedAxleVelocities` | + +### Projectors + +| Class | Constructor | Notable methods | +|---|---|---| +| `BicycleProjector` | `(BicycleModel, min_steering, max_steering)` | `step(dt, pose, current, target, rate, v)`, `project(horizon, dt, pose, initial, target, rate, v)` | +| `ArticulatedProjector` | `(ArticulatedModel, min_articulation, max_articulation)` | `step(dt, pose, current, target, rate, v)`, `project(horizon, dt, pose, initial, target, rate, v)` | + +Both projectors clamp the target angle to `[min, max]` before ramping, then advance the current angle toward the clamped target at the given rate (never overshooting), and integrate pose with Euler. diff --git a/package.xml b/package.xml deleted file mode 100644 index 0019e51..0000000 --- a/package.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - polymath_kinematics - 0.1.0 - Simple mixed C++/Python kinematics library - Polymath Robotics - Apache-2.0 - - ament_cmake - ament_cmake_python - - pybind11_vendor - - ament_cmake_pytest - catch2 - - - ament_cmake - - diff --git a/polymath_kinematics/explorer/__init__.py b/polymath_kinematics/explorer/__init__.py new file mode 100644 index 0000000..bbba8e4 --- /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, + TRAJECTORY_EQUATIONS_RK4, + LatticeConfig, +) +from .export import trajectories_to_dataframe +from .plotting import ( + 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, + simulate_trajectory, + simulate_trajectory_euler, + simulate_trajectory_rk4, +) +from .types import ( + AnyTrajectory, + ArticulatedTrajectory, + BicycleTrajectory, + DifferentialTrajectory, + Trajectory, +) + +__all__ = [ + # Types + 'AnyTrajectory', + 'ArticulatedTrajectory', + 'BicycleTrajectory', + 'DifferentialTrajectory', + 'Trajectory', + # Config + 'KINEMATIC_EQUATIONS', + 'LATTICE_CONFIG', + 'LatticeConfig', + 'TRAJECTORY_EQUATIONS', + 'TRAJECTORY_EQUATIONS_RK4', + # Simulation + 'generate_lattice_articulated', + 'generate_lattice_bicycle', + 'generate_lattice_differential', + 'simulate_trajectory', + 'simulate_trajectory_euler', + 'simulate_trajectory_rk4', + # Plotting + '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/config.py b/polymath_kinematics/explorer/config.py new file mode 100644 index 0000000..91b2058 --- /dev/null +++ b/polymath_kinematics/explorer/config.py @@ -0,0 +1,115 @@ +# 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 + +# Vehicle footprint positioning (fraction of length) +REAR_AXLE_POSITION = 0.3 # How far back the rear axle is from center +FRONT_OVERHANG = 0.7 # How far forward the front extends from center +HEADING_ARROW_LENGTH = 0.4 # Arrow length as fraction of vehicle length +HEADING_ARROW_WIDTH = 0.3 # Arrow head width as fraction of vehicle width + + +@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 (currently 0)', + '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$$ +""" + +TRAJECTORY_EQUATIONS_RK4 = r""" +**Trajectory Integration (RK4 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..0cfa021 --- /dev/null +++ b/polymath_kinematics/explorer/plotting.py @@ -0,0 +1,511 @@ +# 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 ( + FRONT_OVERHANG, + HEADING_ARROW_LENGTH, + HEADING_ARROW_WIDTH, + LATTICE_CONFIG, + REAR_AXLE_POSITION, +) +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, + length: float = 1.0, + width: float = 0.5, + color: str = 'blue', + alpha: float = 0.5, +) -> None: + """Draw a vehicle footprint (rectangle) at a given pose. + + The vehicle is centered on the rear axle position, with REAR_AXLE_POSITION + controlling how far back the rear is, and FRONT_OVERHANG controlling + how far forward the front extends. + """ + corners_local = np.array([ + [-length * REAR_AXLE_POSITION, -width / 2], + [length * FRONT_OVERHANG, -width / 2], + [length * FRONT_OVERHANG, width / 2], + [-length * REAR_AXLE_POSITION, width / 2], + [-length * REAR_AXLE_POSITION, -width / 2], + ]) + + cos_theta, sin_theta = np.cos(theta), np.sin(theta) + rotation_matrix = np.array([[cos_theta, -sin_theta], [sin_theta, cos_theta]]) + corners_world = (rotation_matrix @ corners_local.T).T + np.array([x, y]) + + ax.fill(corners_world[:, 0], corners_world[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5) + + arrow_length = length * HEADING_ARROW_LENGTH + ax.arrow( + x, + y, + arrow_length * np.cos(theta), + arrow_length * np.sin(theta), + head_width=width * HEADING_ARROW_WIDTH, + head_length=length * 0.1, + fc=color, + ec=color, + alpha=min(1.0, alpha + 0.3), + ) + + +def plot_articulated_footprint( + ax: plt.Axes, + x_front_axle: float, + y_front_axle: float, + theta: float, + articulation_angle: float, + front_length: float, + rear_length: float, + front_width: float, + rear_width: float, + color: str = 'blue', + alpha: float = 0.5, +) -> None: + """Draw an articulated vehicle footprint (two connected rectangles) at a given pose. + + Args: + x_front_axle, y_front_axle: Position of the front axle center + theta: Heading of the front body + articulation_angle: Angle between front and rear bodies (positive = left turn) + front_length: Distance from articulation joint to front axle (L_f) + rear_length: Distance from articulation joint to rear axle (L_r) + front_width, rear_width: Track widths + color: Fill color + alpha: Transparency + """ + # Compute articulation joint position + joint_x = x_front_axle - front_length * np.cos(theta) + joint_y = y_front_axle - front_length * np.sin(theta) + + # Front body corners + front_corners_local = np.array([ + [0, -front_width / 2], + [front_length, -front_width / 2], + [front_length, front_width / 2], + [0, front_width / 2], + [0, -front_width / 2], + ]) + + cos_front, sin_front = np.cos(theta), np.sin(theta) + rotation_front = np.array([[cos_front, -sin_front], [sin_front, cos_front]]) + front_corners_world = (rotation_front @ front_corners_local.T).T + np.array([joint_x, joint_y]) + ax.fill( + front_corners_world[:, 0], front_corners_world[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5 + ) + + # Rear body + rear_theta = theta - articulation_angle + rear_corners_local = np.array([ + [-rear_length, -rear_width / 2], + [0, -rear_width / 2], + [0, rear_width / 2], + [-rear_length, rear_width / 2], + [-rear_length, -rear_width / 2], + ]) + + cos_rear, sin_rear = np.cos(rear_theta), np.sin(rear_theta) + rotation_rear = np.array([[cos_rear, -sin_rear], [sin_rear, cos_rear]]) + rear_corners_world = (rotation_rear @ rear_corners_local.T).T + np.array([joint_x, joint_y]) + ax.fill( + rear_corners_world[:, 0], + rear_corners_world[:, 1], + color=color, + alpha=alpha * 0.8, + edgecolor=color, + linewidth=0.5, + ) + + # Articulation joint marker + ax.plot(joint_x, joint_y, 'o', color=color, markersize=4, alpha=min(1.0, alpha + 0.3)) + + # Heading arrow + arrow_length = front_length * 0.4 + ax.arrow( + x_front_axle, + y_front_axle, + arrow_length * np.cos(theta), + arrow_length * np.sin(theta), + head_width=front_width * 0.2, + head_length=front_length * 0.08, + fc=color, + ec=color, + alpha=min(1.0, alpha + 0.3), + ) + + +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. + + Args: + trajectories: List of trajectory dataclasses (already filtered/selected) + model_type: "Differential Drive", "Bicycle", or "Articulated" + model_params: Dict with model dimensions + num_footprints: Number of footprints per trajectory + + Returns: + matplotlib Figure + """ + fig, ax = plt.subplots(figsize=(10, 8), 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] + plot_articulated_footprint( + ax, + trajectory.x[footprint_index], + trajectory.y[footprint_index], + trajectory.theta[footprint_index], + articulation_angle=articulated.articulation_angle, + front_length=model_params['front_length'], + rear_length=model_params['rear_length'], + front_width=model_params['front_width'], + rear_width=model_params['rear_width'], + color=color, + alpha=footprint_alpha, + ) + else: + plot_vehicle_footprint( + ax, + trajectory.x[footprint_index], + trajectory.y[footprint_index], + trajectory.theta[footprint_index], + length=model_params['length'], + width=model_params['width'], + 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})') + ax.set_aspect('equal') + 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) + fig, axes = plt.subplots(1, num_columns, figsize=(5 * num_columns, 5), 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)') + ax.set_aspect('equal') + 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') + ax_right.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') + ax_right.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..00633e7 --- /dev/null +++ b/polymath_kinematics/explorer/simulation.py @@ -0,0 +1,324 @@ +# 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. + +Bicycle and articulated lattices are generated via the C++ BicycleProjector / +ArticulatedProjector forward-simulation classes (Euler integration with +steering-rate ramping support). The differential drive lattice keeps a +vectorized Euler integration here because there is no DifferentialDriveProjector +in the C++ library. + +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, + BicycleModel, + BicycleProjector, + DifferentialDriveModel, + Pose2D, +) + +from .types import ArticulatedTrajectory, BicycleTrajectory, DifferentialTrajectory + + +def simulate_trajectory_euler( + linear_velocity: float, + angular_velocity: float, + duration: float = 5.0, + time_step: float = 0.02, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Simulate trajectory using vectorized Euler integration. + + For constant angular velocity, theta is analytic: theta(t) = omega * t. + This allows fully vectorized computation of x and y via cumulative sum. + + Returns: + Tuple of (time, x, y, theta) arrays + """ + num_steps = int(duration / time_step) + time_array = np.linspace(0, duration, num_steps) + + # Theta is analytic for constant angular velocity + theta = angular_velocity * time_array + + # Compute velocities at each timestep (vectorized) + velocity_x = linear_velocity * np.cos(theta) + velocity_y = linear_velocity * np.sin(theta) + + # Integrate via cumulative sum (Euler method, vectorized) + x = np.concatenate([[0], np.cumsum(velocity_x[:-1]) * time_step]) + y = np.concatenate([[0], np.cumsum(velocity_y[:-1]) * time_step]) + + return time_array, x, y, theta + + +def simulate_trajectory_rk4( + linear_velocity: float, + angular_velocity: float, + duration: float = 5.0, + time_step: float = 0.02, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Simulate trajectory using 4th-order Runge-Kutta integration. + + For constant angular velocity, we can still vectorize theta, then use + RK4-style weighted averaging for x and y positions. + + Returns: + Tuple of (time, x, y, theta) arrays + """ + num_steps = int(duration / time_step) + time_array = np.linspace(0, duration, num_steps) + + theta = angular_velocity * time_array + theta_prev = theta[:-1] + theta_mid = theta_prev + angular_velocity * time_step / 2 + theta_next = theta_prev + angular_velocity * time_step + + k1_x = linear_velocity * np.cos(theta_prev) + k1_y = linear_velocity * np.sin(theta_prev) + k2_x = linear_velocity * np.cos(theta_mid) + k2_y = linear_velocity * np.sin(theta_mid) + k4_x = linear_velocity * np.cos(theta_next) + k4_y = linear_velocity * np.sin(theta_next) + + delta_x = (k1_x + 4 * k2_x + k4_x) / 6 * time_step + delta_y = (k1_y + 4 * k2_y + k4_y) / 6 * time_step + + x = np.concatenate([[0], np.cumsum(delta_x)]) + y = np.concatenate([[0], np.cumsum(delta_y)]) + + return time_array, x, y, theta + + +def simulate_trajectory( + linear_velocity: float, + angular_velocity: float, + duration: float = 5.0, + time_step: float = 0.02, + method: str = 'euler', +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Simulate a constant-cmd_vel trajectory. + + Args: + linear_velocity: Linear velocity (m/s) + angular_velocity: Angular velocity (rad/s) + duration: Simulation duration (s) + time_step: Time step (s) + method: Integration method ("euler" or "rk4") + """ + if method == 'rk4': + return simulate_trajectory_rk4(linear_velocity, angular_velocity, duration, time_step) + return simulate_trajectory_euler(linear_velocity, angular_velocity, duration, time_step) + + +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 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, + method: str = 'euler', +) -> list[DifferentialTrajectory]: + """Generate trajectory lattice by sweeping wheel velocity differences.""" + model = DifferentialDriveModel(wheel_radius, track_width) + trajectories: list[DifferentialTrajectory] = [] + + 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) + linear_velocity = body_velocity.linear_velocity_m_s + angular_velocity = body_velocity.angular_velocity_rad_s + + time_array, x, y, theta = simulate_trajectory( + linear_velocity, angular_velocity, duration, time_step, method + ) + + trajectories.append( + DifferentialTrajectory( + time=time_array, + x=x, + y=y, + theta=theta, + linear_velocity=linear_velocity, + angular_velocity=angular_velocity, + left_wheel=left_wheel, + right_wheel=right_wheel, + base_wheel_velocity=base_velocity, + ) + ) + + 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, + method: str = 'euler', # accepted for API compatibility; projector is Euler-only + min_steering_angle_rad: float | None = None, + max_steering_angle_rad: float | None = None, + steering_rate_rad_s: float = 0.0, +) -> 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. + + Note: ``method`` is accepted for backward compatibility but ignored. The + projector uses Euler integration only; RK4 trajectory generation has + been retired in favor of a single forward-simulation codepath. + """ + del method # accepted for API compat; projector is Euler-only. + 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) + + 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, + ) + ) + + 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, + method: str = 'euler', # accepted for API compatibility; projector is Euler-only + min_articulation_angle_rad: float | None = None, + max_articulation_angle_rad: float | None = None, + articulation_rate_rad_s: float = 0.0, +) -> list[ArticulatedTrajectory]: + """Generate trajectory lattice by sweeping articulation angles. + + See ``generate_lattice_bicycle`` for the projector-based rationale and + the meaning of ``method``. + """ + del method # accepted for API compat; projector is Euler-only. + 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) + + 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, + ) + ) + + return trajectories diff --git a/polymath_kinematics/explorer/types.py b/polymath_kinematics/explorer/types.py new file mode 100644 index 0000000..0a18406 --- /dev/null +++ b/polymath_kinematics/explorer/types.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. +"""Trajectory data types for kinematic exploration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Union + +import numpy as np + + +@dataclass +class Trajectory: + """Base trajectory data with common fields.""" + + time: np.ndarray + x: np.ndarray + y: np.ndarray + theta: np.ndarray + linear_velocity: float + angular_velocity: float + + 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.""" + + drive_velocity: float = 0.0 + steering_angle: float = 0.0 + turning_radius: float = 0.0 + + +@dataclass +class ArticulatedTrajectory(Trajectory): + """Trajectory for articulated vehicle model.""" + + drive_velocity: float = 0.0 + articulation_angle: float = 0.0 + turning_radius: float = 0.0 + + +# 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..626a10a --- /dev/null +++ b/polymath_kinematics/kinematic_explorer_app.py @@ -0,0 +1,632 @@ +# 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.explorer import ( + KINEMATIC_EQUATIONS, + LATTICE_CONFIG, + TRAJECTORY_EQUATIONS, + TRAJECTORY_EQUATIONS_RK4, + AnyTrajectory, + ArticulatedTrajectory, + BicycleTrajectory, + DifferentialTrajectory, + generate_lattice_articulated, + generate_lattice_bicycle, + generate_lattice_differential, + plot_analysis, + plot_lattice, + plot_trajectory_with_footprints, + trajectories_to_dataframe, +) +from polymath_kinematics.explorer.plotting import _get_traj_attr + + +@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, + method: str, +) -> list[DifferentialTrajectory]: + return generate_lattice_differential( + wheel_radius, track_width, base_wheel_velocities, wheel_velocity_diffs, duration, time_step, method + ) + + +@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, + method: str, +) -> list[BicycleTrajectory]: + return generate_lattice_bicycle( + wheelbase, track_width, wheel_radius, drive_velocities, steering_angles, duration, time_step, method + ) + + +@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, + method: 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, + method, + ) + + +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, + 'method': st.session_state.get('sim_method', 'euler'), + }, + } + + if model_type == 'Differential Drive': + config['model_parameters'] = { + 'wheel_radius': st.session_state.diff_wheel_radius, + 'track_width': st.session_state.diff_track_width, + } + 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, + } + 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, + } + 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_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_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_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, + 'sim_method': 'euler', + '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.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') + +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.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') + +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_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.session_state.sim_method = st.selectbox( + 'Integration Method', + ['euler', 'rk4'], + index=0 if st.session_state.sim_method == 'euler' else 1, + help='RK4 is more accurate, Euler is faster', + ) + 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, + method=st.session_state.sim_method, + ) + 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, + method=st.session_state.sim_method, + ) + 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, + method=st.session_state.sim_method, + ) + 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('---') + if st.session_state.sim_method == 'rk4': + st.markdown(TRAJECTORY_EQUATIONS_RK4) + else: + st.markdown(TRAJECTORY_EQUATIONS) + +# Trajectory Lattice +st.header('Trajectory Lattice') +lattice_fig = plot_lattice(trajectories, model_type, group_values) +st.pyplot(lattice_fig) +plt.close(lattice_fig) + +# Kinematic Analysis +st.header('Kinematic Analysis') +analysis_fig = plot_analysis(trajectories, model_type, group_values) +st.pyplot(analysis_fig) +plt.close(analysis_fig) + +# Vehicle Footprint Visualization +st.header('Vehicle Visualization') + +if model_type == 'Differential Drive': + model_params = { + 'length': st.session_state.diff_track_width * 1.5, + 'width': st.session_state.diff_track_width, + } + vel_options = list(group_values) + vel_label = 'Base Wheel Velocity (rad/s)' + st.caption(f'Vehicle dimensions: {model_params["length"]:.2f}m x {model_params["width"]:.2f}m') +elif model_type == 'Bicycle': + model_params = { + 'length': st.session_state.bike_wheelbase, + 'width': st.session_state.bike_track_width, + } + vel_options = list(group_values) + vel_label = 'Drive Velocity (m/s)' + st.caption(f'Vehicle dimensions: wheelbase={model_params["length"]:.2f}m, track={model_params["width"]:.2f}m') +else: # Articulated + model_params = { + 'front_length': st.session_state.art_to_front, + 'rear_length': st.session_state.art_to_rear, + 'front_width': st.session_state.art_front_track, + 'rear_width': st.session_state.art_rear_track, + } + vel_options = list(group_values) + vel_label = 'Drive Velocity (m/s)' + st.caption( + f'Articulated: front={model_params["front_length"]:.2f}m x {model_params["front_width"]:.2f}m, ' + f'rear={model_params["rear_length"]:.2f}m x {model_params["rear_width"]:.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] + velocity_filtered = [ + trajectory for trajectory in trajectories if abs(_get_traj_attr(trajectory, config.vel_key) - viz_vel) < 0.05 + ] + + all_angles = sorted(set(_get_traj_attr(trajectory, config.angle_key) for trajectory in velocity_filtered)) + if len(all_angles) <= num_angles_viz: + selected_angles = set(all_angles) + else: + indices = np.linspace(0, len(all_angles) - 1, num_angles_viz, dtype=int) + selected_angles = {all_angles[index] for index in indices} + + viz_trajectories = [ + trajectory + for trajectory in velocity_filtered + if any(abs(_get_traj_attr(trajectory, config.angle_key) - angle) < 1e-6 for angle in selected_angles) + ] + viz_trajectories.sort(key=lambda trajectory: _get_traj_attr(trajectory, config.angle_key)) + + 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.') + +# 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`, ' + f'dt: `{config_dict["simulation"]["dt"]}s`, ' + f'Method: `{config_dict["simulation"]["method"]}`' + ) + +# 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 +if st.sidebar.button('Export Lattice Image'): + export_fig = plot_lattice(trajectories, model_type, group_values) + filename = f'lattice_export.{st.session_state.export_format}' + if st.session_state.export_format == 'png': + export_fig.savefig(filename, dpi=st.session_state.export_dpi, bbox_inches='tight') + else: + export_fig.savefig(filename, bbox_inches='tight') + plt.close(export_fig) + st.sidebar.success(f'Saved: {filename}') + + +def main(): + """Entry point for launching the Kinematic Explorer via ros2 run or CLI.""" + import subprocess + import sys + + cmd = [ + sys.executable, + '-m', + 'streamlit', + 'run', + __file__, + '--server.address=localhost', + '--browser.gatherUsageStats=false', + ] + sys.exit(subprocess.call(cmd)) diff --git a/pyproject.toml b/pyproject.toml index 617b5ed..90c9a7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,45 @@ [build-system] -requires = ["setuptools", "wheel"] -build-backend = "setuptools.build_meta" +requires = ["scikit-build-core>=0.10", "pybind11>=2.12"] +build-backend = "scikit_build_core.build" [project] name = "polymath_kinematics" -version = "0.1.0" -description = "Simple mixed C++/Python kinematics library" -license = {text = "Apache-2.0"} +version = "0.2.0" +description = "Mixed C++/Python kinematics library for vehicle models" +license = { text = "Apache-2.0" } maintainers = [ - {name = "Polymath Robotics", email = "dev@polymathrobotics.com"} + { name = "Polymath Robotics", email = "dev@polymathrobotics.com" }, +] +requires-python = ">=3.10" +dependencies = [ + "numpy>=1.21.0", +] + +[project.optional-dependencies] +explorer = [ + "streamlit>=1.20.0", + "matplotlib>=3.5.0", + "pandas>=1.4.0", +] +dev = [ + "pytest>=7.0.0", + "polymath_kinematics[explorer]", ] -requires-python = ">=3.8" -[tool.setuptools] -packages = ["polymath_kinematics"] +[project.scripts] +kinematic-explorer = "polymath_kinematics.kinematic_explorer_app:main" + +[tool.scikit-build] +cmake.version = ">=3.15" +build.verbose = false +# C++ tests are built out-of-band via `cmake -DBUILD_TESTING=ON`, not as part of the wheel. +cmake.args = ["-DBUILD_TESTING=OFF"] +wheel.packages = ["polymath_kinematics"] + +[dependency-groups] +dev = [ + "pytest>=7.0.0", + "streamlit>=1.20.0", + "matplotlib>=3.5.0", + "pandas>=1.4.0", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3414b8a..0000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[develop] -script_dir=$base/lib/polymath_kinematics - -[install] -install_scripts=$base/lib/polymath_kinematics diff --git a/test/test_articulated_projector.cpp b/test/test_articulated_projector.cpp index f07cba7..5580a07 100644 --- a/test/test_articulated_projector.cpp +++ b/test/test_articulated_projector.cpp @@ -98,11 +98,11 @@ TEST_CASE("ArticulatedProjector project - straight line: zero articulation, thet Pose2D pose{0.0, 0.0, 0.0}; auto trajectory = projector.project(1.0, 0.1, pose, 0.0, 0.0, 0.0, 1.5); - CHECK_EQ(trajectory.size(), 11); - CHECK_EQ(trajectory.back().pose.x, Approx(1.5)); - CHECK_EQ(trajectory.back().pose.y, Approx(0.0)); - CHECK_EQ(trajectory.back().pose.theta, Approx(0.0)); - CHECK_EQ(trajectory.back().articulation_angle_rad, Approx(0.0)); + CHECK(trajectory.size() == 11); + CHECK(trajectory.back().pose.x == Approx(1.5)); + CHECK(trajectory.back().pose.y == Approx(0.0)); + CHECK(trajectory.back().pose.theta == Approx(0.0)); + CHECK(trajectory.back().articulation_angle_rad == Approx(0.0)); } TEST_CASE("ArticulatedProjector project - articulation ramps then saturates at max") @@ -113,9 +113,9 @@ TEST_CASE("ArticulatedProjector project - articulation ramps then saturates at m // target = 0.785 (= max), rate = 0.2 rad/s, dt = 0.1 → step adds 0.02 // Reach max in ceil(0.785 / 0.02) = 40 steps; horizon 5.0s → 50 steps total. auto trajectory = projector.project(5.0, 0.1, pose, 0.0, 0.785, 0.2, 1.0); - CHECK_EQ(trajectory.size(), 51); - CHECK_EQ(trajectory[40].articulation_angle_rad, Approx(0.785)); - CHECK_EQ(trajectory.back().articulation_angle_rad, Approx(0.785)); + CHECK(trajectory.size() == 51); + CHECK(trajectory[40].articulation_angle_rad == Approx(0.785)); + CHECK(trajectory.back().articulation_angle_rad == Approx(0.785)); } TEST_CASE("ArticulatedProjector project - initial state stored as element 0") diff --git a/test/test_bicycle_projector.cpp b/test/test_bicycle_projector.cpp index eda09cc..8262aac 100644 --- a/test/test_bicycle_projector.cpp +++ b/test/test_bicycle_projector.cpp @@ -92,13 +92,13 @@ TEST_CASE("BicycleProjector project - straight line traces +x with theta unchang auto trajectory = projector.project(1.0, 0.1, pose, 0.0, 0.0, 1.0, 2.0); // ceil(1.0 / 0.1) + 1 = 11 samples - CHECK_EQ(trajectory.size(), 11); - CHECK_EQ(trajectory.front().time_s, Approx(0.0)); - CHECK_EQ(trajectory.front().pose.x, Approx(0.0)); - CHECK_EQ(trajectory.back().time_s, Approx(1.0)); - CHECK_EQ(trajectory.back().pose.x, Approx(2.0)); // x = v * t - CHECK_EQ(trajectory.back().pose.y, Approx(0.0)); - CHECK_EQ(trajectory.back().pose.theta, Approx(0.0)); + CHECK(trajectory.size() == 11); + CHECK(trajectory.front().time_s == Approx(0.0)); + CHECK(trajectory.front().pose.x == Approx(0.0)); + CHECK(trajectory.back().time_s == Approx(1.0)); + CHECK(trajectory.back().pose.x == Approx(2.0)); // x = v * t + CHECK(trajectory.back().pose.y == Approx(0.0)); + CHECK(trajectory.back().pose.theta == Approx(0.0)); } TEST_CASE("BicycleProjector project - ramp reaches target in expected number of steps") diff --git a/test/test_explorer.py b/test/test_explorer.py new file mode 100644 index 0000000..9e5231a --- /dev/null +++ b/test/test_explorer.py @@ -0,0 +1,308 @@ +# 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 pytest + +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, + 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, + ) + + # Differential drive still uses manual Euler: num_steps = int(duration/time_step). + assert len(trajectories) == 3 + assert len(trajectories[0].time) == 10 + assert trajectories[0].linear_velocity == pytest.approx(1.0) + + 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) + + def test_method_param_accepted_for_compatibility(self): + # method='rk4' is accepted but the projector path is Euler-only — the call must still succeed. + trajectories = 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, + method='rk4', + ) + assert len(trajectories) == 1 + + +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) == 30 # 3 trajectories * 10 points 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): + 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, + ) + + @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, + ) + + @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, + ) + + 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', {'length': 0.75, 'width': 0.5}, 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', {'length': 2.5, '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 + ) + model_params = { + 'front_length': 1.5, + 'rear_length': 1.2, + 'front_width': 1.8, + 'rear_width': 1.6, + } + figure = plot_trajectory_with_footprints(selected, 'Articulated', model_params, 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] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8b9dd6b --- /dev/null +++ b/uv.lock @@ -0,0 +1,1899 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] + +[[package]] +name = "altair" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/1e/365a9144db3254f86f1b974660b9ede1e9a38c9dc0730e4a9b1192eec5d6/altair-6.1.0.tar.gz", hash = "sha256:dda699216cf85b040d968ae5a569ad45957616811e38760a85e5118269daca67", size = 765519, upload-time = "2026-04-21T13:08:46.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/63/5dacc8d8306c715088b897a479e551bc0779fd2f0f26c97fec5e36542b4e/altair-6.1.0-py3-none-any.whl", hash = "sha256:fdf5fd939512e5b2fc4441c82dfd2635e706defbd037db0ac429ef5ddce66c3b", size = 796996, upload-time = "2026-04-21T13:08:48.549Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "cachetools" +version = "7.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e2/85f227594656000ff4d8adadae91a21f536d4a84c6c716a86bd6685874be/cachetools-7.1.1.tar.gz", hash = "sha256:27bdf856d68fd3c71c26c01b5edc312124ed427524d1ddb31aa2b7746fe20d4b", size = 40202, upload-time = "2026-05-03T20:00:29.391Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/0f/f897abe4ea0a8c408ae65c8c83bffab4936ad65d6032d4fb4cd35bbdc3ee/cachetools-7.1.1-py3-none-any.whl", hash = "sha256:0335cd7a0952d2b22327441fb0628139e234c565559eeb91a8a4ac7551c5353d", size = 16775, upload-time = "2026-05-03T20:00:27.857Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" }, + { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" }, + { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" }, + { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, + { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, + { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, + { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "idna" +version = "3.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/6f/340b04986e67aac6f66c5145ce68bf72c64bed30f92c8913499a6e6b8f99/matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217", size = 8296625, upload-time = "2026-04-24T00:11:43.376Z" }, + { url = "https://files.pythonhosted.org/packages/bb/2f/127081eb83162053ebb9678ceac64220b93a663e0167432566e9c7c82aab/matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b", size = 8188790, upload-time = "2026-04-24T00:11:46.556Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b7/d8bcec2626c35f96972bff656299fef4578113ea6193c8fdad324710410c/matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", size = 8769389, upload-time = "2026-04-24T00:11:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/b78e214a527ea732033b7f4d37f7afb504d74ba9d134bd47938230dfb8b1/matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", size = 9589657, upload-time = "2026-04-24T00:11:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/5f/15/5246f7b43beae19c74dfee651d58d6cc8112e06f77adb4e88cc04f2e3a23/matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", size = 9651983, upload-time = "2026-04-24T00:11:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/5acecfe672ba0fa1b8c0454f69ce155d1e6fc5852fa7206bf9afaf767121/matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda", size = 8199701, upload-time = "2026-04-24T00:11:58.389Z" }, + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2b/0e92ad0ac446633f928a1563db4aa8add407e1924faf0ded5b95b35afb27/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", size = 8293058, upload-time = "2026-04-24T00:13:56.339Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/74682fd369f5299ceda438fea2a0662e6383b85c9383fb9cdfcf04713e07/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", size = 8186627, upload-time = "2026-04-24T00:13:58.623Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117, upload-time = "2026-04-24T00:14:01.684Z" }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, +] + +[[package]] +name = "narwhals" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/0e/3ad61eb87088cc4932e0d851531fa82f845a6230b68b091a0e298cc7e537/narwhals-2.21.0.tar.gz", hash = "sha256:7c6e7f50528e62b7a967dd864d7e117d2955d38d4f730653ce46a9861358e2dc", size = 633083, upload-time = "2026-05-08T12:29:02.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e1/68c2256b69a314eba133673377ba9118c356f6342a0c02b61de449cf2bf2/narwhals-2.21.0-py3-none-any.whl", hash = "sha256:1e6617d0fca68ae1fda29e5397c4eaacd3ffc9fffe6bcd6ded0c690475e853be", size = 451943, upload-time = "2026-05-08T12:29:01.058Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "polymath-kinematics" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.optional-dependencies] +dev = [ + { name = "matplotlib" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pytest" }, + { name = "streamlit" }, +] +explorer = [ + { name = "matplotlib" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "streamlit" }, +] + +[package.dev-dependencies] +dev = [ + { name = "matplotlib" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pytest" }, + { name = "streamlit" }, +] + +[package.metadata] +requires-dist = [ + { name = "matplotlib", marker = "extra == 'explorer'", specifier = ">=3.5.0" }, + { name = "numpy", specifier = ">=1.21.0" }, + { name = "pandas", marker = "extra == 'explorer'", specifier = ">=1.4.0" }, + { name = "polymath-kinematics", extras = ["explorer"], marker = "extra == 'dev'" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "streamlit", marker = "extra == 'explorer'", specifier = ">=1.20.0" }, +] +provides-extras = ["explorer", "dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "matplotlib", specifier = ">=3.5.0" }, + { name = "pandas", specifier = ">=1.4.0" }, + { name = "pytest", specifier = ">=7.0.0" }, + { name = "streamlit", specifier = ">=1.20.0" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/bf/a34fee1d624152124fa8355c42f34195ad5fe5233ce5bb87946432047d52/pyarrow-24.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb", size = 35076681, upload-time = "2026-04-21T08:51:46.845Z" }, + { url = "https://files.pythonhosted.org/packages/1d/41/64180033d7027afce12dc96d0fe1f504c6fa112190582b458acea2399530/pyarrow-24.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147", size = 36684260, upload-time = "2026-04-21T08:51:53.642Z" }, + { url = "https://files.pythonhosted.org/packages/57/02/9b9320e673dd8a99411fac78690f3df92f6dd6f59754c750110bca66d64e/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c", size = 45698566, upload-time = "2026-04-21T10:46:02.133Z" }, + { url = "https://files.pythonhosted.org/packages/67/33/f75e91b9a64c3f33c787e263c93b871ad91b8a4a68c1d5cebddd9840e835/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041", size = 48835562, upload-time = "2026-04-21T10:46:10.278Z" }, + { url = "https://files.pythonhosted.org/packages/a5/63/097510448e47e4091faa41c43ba92f97cecaab8f4535b56a3d149578f634/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491", size = 49394997, upload-time = "2026-04-21T10:46:18.08Z" }, + { url = "https://files.pythonhosted.org/packages/60/6b/c047d6222ab279024a062742d1807e2fbaf27bba88a98637299ff47b9236/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1", size = 51911424, upload-time = "2026-04-21T10:46:25.347Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ba/464cc70761c2a525d97ebd84e21c31ebd47f3ef4bdcee117009f51c46f24/pyarrow-24.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591", size = 27251730, upload-time = "2026-04-21T10:46:30.913Z" }, + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + +[[package]] +name = "pydeck" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/df/4e9e7f20f8034a37c6571c93809f6d22388c39978c98d174d656c1a18fd2/pydeck-0.9.2.tar.gz", hash = "sha256:c10d9035e81ead6385264cac8d19402471f6866a15ca1f7df1400f52142bcf87", size = 5849672, upload-time = "2026-04-16T18:30:30.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/24/b30ee7d723100fd822de1bb4c0adea62f3419884a75a536f35f355d1e7c0/pydeck-0.9.2-py2.py3-none-any.whl", hash = "sha256:8213dfeacc5f6bfe6825f61c8ee34e3850e8a31fc43924379ec98edb34a75b25", size = 11305615, upload-time = "2026-04-16T18:30:28.133Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "streamlit" +version = "1.57.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "anyio" }, + { name = "blinker" }, + { name = "cachetools" }, + { name = "click" }, + { name = "gitpython" }, + { name = "httptools" }, + { name = "itsdangerous" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "starlette" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/f8/b2daf7a5f8ae15527daf94406e771bb6075e958a01c3dde9eba79dc3c9a3/streamlit-1.57.0.tar.gz", hash = "sha256:0b028d305c1a1a757071b2c9504966787602842fc8af6e873795ca58d2b4d12f", size = 8678859, upload-time = "2026-04-28T22:13:32.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/1a/3ca2293d8552bacea3e67e9600d2d1df7df4a325059769ad83d91c279595/streamlit-1.57.0-py3-none-any.whl", hash = "sha256:0d1d41972aeade5637dbb0e7f0eefa5312272f85304923d240a1b1f0475249c8", size = 9194216, upload-time = "2026-04-28T22:13:29.624Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] From 600f7ba03061fa4e42b777cdf7be200b33666735 Mon Sep 17 00:00:00 2001 From: Zeerek Date: Tue, 12 May 2026 05:56:14 +0000 Subject: [PATCH 3/9] Adding in differential drive projector and visualiztion code that was missing --- CMakeLists.txt | 2 + .../differential_drive_projector.hpp | 135 +++++++++++ polymath_kinematics/__init__.py | 4 + polymath_kinematics/explorer/__init__.py | 6 + polymath_kinematics/explorer/plotting.py | 62 ++++- polymath_kinematics/explorer/simulation.py | 175 ++++++++++++++ polymath_kinematics/explorer/types.py | 20 +- polymath_kinematics/kinematic_explorer_app.py | 219 ++++++++++++++++++ src/differential_drive_projector.cpp | 114 +++++++++ src/kinematics_pybind.cpp | 48 ++++ test/test_differential_drive_projector.cpp | 134 +++++++++++ test/test_explorer.py | 119 ++++++++++ test/test_python_bindings.py | 56 +++++ 13 files changed, 1089 insertions(+), 5 deletions(-) create mode 100644 include/polymath_kinematics/differential_drive_projector.hpp create mode 100644 src/differential_drive_projector.cpp create mode 100644 test/test_differential_drive_projector.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3546bdf..42864f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,7 @@ add_library(polymath_kinematics_core STATIC src/bicycle_model.cpp src/bicycle_projector.cpp src/differential_drive_model.cpp + src/differential_drive_projector.cpp ) target_include_directories(polymath_kinematics_core PUBLIC $ @@ -66,6 +67,7 @@ if(BUILD_TESTING) test_differential_drive test_bicycle_projector test_articulated_projector + test_differential_drive_projector ) foreach(tgt IN LISTS _pk_tests) add_executable(${tgt} test/${tgt}.cpp) diff --git a/include/polymath_kinematics/differential_drive_projector.hpp b/include/polymath_kinematics/differential_drive_projector.hpp new file mode 100644 index 0000000..f7a711f --- /dev/null +++ b/include/polymath_kinematics/differential_drive_projector.hpp @@ -0,0 +1,135 @@ +// 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. + +#ifndef POLYMATH_KINEMATICS__DIFFERENTIAL_DRIVE_PROJECTOR_HPP__ +#define POLYMATH_KINEMATICS__DIFFERENTIAL_DRIVE_PROJECTOR_HPP__ + +#include + +#include "polymath_kinematics/differential_drive_model.hpp" +#include "polymath_kinematics/pose2d.hpp" + +namespace polymath::kinematics +{ + +/// @brief One sample of a differential-drive-model projection. +struct DifferentialDriveProjectedState +{ + double time_s; ///< Elapsed time from the start of the projection + Pose2D pose; ///< Body pose at this sample + double linear_velocity_m_s; ///< Post-ramp body linear velocity in m/s + double angular_velocity_rad_s; ///< Post-ramp body angular velocity in rad/s + DifferentialDriveWheelVelocities wheel_velocities; ///< Wheel speeds derived from the body command +}; + +/// @brief Forward-projection wrapper around DifferentialDriveModel that ramps the body command +/// (linear_velocity, angular_velocity) toward targets at bounded accelerations, clamping each +/// to its [min, max] range before ramping. Pose is integrated with Euler. +class DifferentialDriveProjector +{ +public: + /// @brief Construct a projector with body-velocity limits. + /// @param model Differential drive kinematics model (stored by value) + /// @param min_linear_velocity_m_s Minimum allowed linear velocity (typically negative for reverse) + /// @param max_linear_velocity_m_s Maximum allowed linear velocity + /// @param min_angular_velocity_rad_s Minimum allowed angular velocity (typically negative) + /// @param max_angular_velocity_rad_s Maximum allowed angular velocity + DifferentialDriveProjector( + DifferentialDriveModel model, + double min_linear_velocity_m_s, + double max_linear_velocity_m_s, + double min_angular_velocity_rad_s, + double max_angular_velocity_rad_s) + : model_(model) + , min_linear_velocity_m_s_(min_linear_velocity_m_s) + , max_linear_velocity_m_s_(max_linear_velocity_m_s) + , min_angular_velocity_rad_s_(min_angular_velocity_rad_s) + , max_angular_velocity_rad_s_(max_angular_velocity_rad_s) + {} + + ~DifferentialDriveProjector() = default; + + /// @brief Advance the vehicle one time step. + /// Linear and angular velocities ramp from their current values toward + /// clamp(target, min, max) at the corresponding |acceleration| per second, + /// never overshooting. Pose is integrated with Euler using the post-ramp body command. + /// @param dt_s Step duration in seconds (must be > 0) + /// @param current_pose Pose at the start of the step + /// @param current_linear_velocity_m_s Linear velocity at the start of the step + /// @param current_angular_velocity_rad_s Angular velocity at the start of the step + /// @param target_linear_velocity_m_s Desired linear velocity (clamped internally) + /// @param target_angular_velocity_rad_s Desired angular velocity (clamped internally) + /// @param linear_acceleration_m_s2 Magnitude of the linear ramp rate (sign is ignored) + /// @param angular_acceleration_rad_s2 Magnitude of the angular ramp rate (sign is ignored) + /// @return Projected state at the end of the step (time_s = dt_s) + DifferentialDriveProjectedState step( + double dt_s, + const Pose2D & current_pose, + double current_linear_velocity_m_s, + double current_angular_velocity_rad_s, + double target_linear_velocity_m_s, + double target_angular_velocity_rad_s, + double linear_acceleration_m_s2, + double angular_acceleration_rad_s2); + + /// @brief Project a trajectory forward over `horizon_s` at `dt_s` steps. + /// Element 0 is the initial state (time_s=0); element N is the final state. + /// Trajectory length is ceil(horizon_s / dt_s) + 1. + std::vector project( + double horizon_s, + double dt_s, + const Pose2D & initial_pose, + double initial_linear_velocity_m_s, + double initial_angular_velocity_rad_s, + double target_linear_velocity_m_s, + double target_angular_velocity_rad_s, + double linear_acceleration_m_s2, + double angular_acceleration_rad_s2); + + const DifferentialDriveModel & get_model() const + { + return model_; + } + + double get_min_linear_velocity_m_s() const + { + return min_linear_velocity_m_s_; + } + + double get_max_linear_velocity_m_s() const + { + return max_linear_velocity_m_s_; + } + + double get_min_angular_velocity_rad_s() const + { + return min_angular_velocity_rad_s_; + } + + double get_max_angular_velocity_rad_s() const + { + return max_angular_velocity_rad_s_; + } + +private: + DifferentialDriveModel model_; + double min_linear_velocity_m_s_; + double max_linear_velocity_m_s_; + double min_angular_velocity_rad_s_; + double max_angular_velocity_rad_s_; +}; + +} // namespace polymath::kinematics + +#endif // POLYMATH_KINEMATICS__DIFFERENTIAL_DRIVE_PROJECTOR_HPP__ diff --git a/polymath_kinematics/__init__.py b/polymath_kinematics/__init__.py index c76a10f..5cc6ed6 100644 --- a/polymath_kinematics/__init__.py +++ b/polymath_kinematics/__init__.py @@ -27,6 +27,8 @@ BicycleSteeringState, DifferentialDriveBodyVelocity, DifferentialDriveModel, + DifferentialDriveProjectedState, + DifferentialDriveProjector, DifferentialDriveWheelVelocities, Pose2D, ) @@ -44,6 +46,8 @@ 'BicycleSteeringState', 'DifferentialDriveBodyVelocity', 'DifferentialDriveModel', + 'DifferentialDriveProjectedState', + 'DifferentialDriveProjector', 'DifferentialDriveWheelVelocities', 'Pose2D', 'Twist2D', diff --git a/polymath_kinematics/explorer/__init__.py b/polymath_kinematics/explorer/__init__.py index bbba8e4..1ed7508 100644 --- a/polymath_kinematics/explorer/__init__.py +++ b/polymath_kinematics/explorer/__init__.py @@ -40,6 +40,9 @@ simulate_trajectory, simulate_trajectory_euler, simulate_trajectory_rk4, + single_articulated_trajectory, + single_bicycle_trajectory, + single_differential_trajectory, ) from .types import ( AnyTrajectory, @@ -69,6 +72,9 @@ 'simulate_trajectory', 'simulate_trajectory_euler', 'simulate_trajectory_rk4', + 'single_articulated_trajectory', + 'single_bicycle_trajectory', + 'single_differential_trajectory', # Plotting 'plot_analysis', 'plot_articulated_footprint', diff --git a/polymath_kinematics/explorer/plotting.py b/polymath_kinematics/explorer/plotting.py index 0cfa021..e93a6fc 100644 --- a/polymath_kinematics/explorer/plotting.py +++ b/polymath_kinematics/explorer/plotting.py @@ -45,12 +45,20 @@ def plot_vehicle_footprint( width: float = 0.5, color: str = 'blue', alpha: float = 0.5, + steering_angle: float | None = None, + wheel_length_frac: float = 0.18, + wheel_width_frac: float = 0.08, ) -> None: """Draw a vehicle footprint (rectangle) at a given pose. The vehicle is centered on the rear axle position, with REAR_AXLE_POSITION controlling how far back the rear is, and FRONT_OVERHANG controlling how far forward the front extends. + + When ``steering_angle`` is provided (radians), two front-wheel indicators + are drawn at the front-axle position, rotated by the steering angle in the + body frame. Useful for bicycle/Ackermann visualisation; pass ``None`` for + models that don't have steered wheels (e.g. differential drive). """ corners_local = np.array([ [-length * REAR_AXLE_POSITION, -width / 2], @@ -79,6 +87,35 @@ def plot_vehicle_footprint( alpha=min(1.0, alpha + 0.3), ) + if steering_angle is not None: + # Front-wheel indicators: two filled rectangles at the front-axle (FRONT_OVERHANG) + # position, offset left/right by half the track width, each rotated by steering_angle + # in the body frame, then transformed to world by theta. + front_x_local = length * FRONT_OVERHANG + wheel_l = length * wheel_length_frac + wheel_w = 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 (-width / 2.0, +width / 2.0): + corners_body = wheel_corners_steered + np.array([front_x_local, 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, @@ -272,12 +309,16 @@ def plot_trajectory_with_footprints( if model_type == 'Articulated': articulated: ArticulatedTrajectory = trajectory # type: ignore[assignment] + if articulated.articulation_angle_series is not None: + sample_articulation_angle = float(articulated.articulation_angle_series[footprint_index]) + else: + sample_articulation_angle = articulated.articulation_angle plot_articulated_footprint( ax, trajectory.x[footprint_index], trajectory.y[footprint_index], trajectory.theta[footprint_index], - articulation_angle=articulated.articulation_angle, + articulation_angle=sample_articulation_angle, front_length=model_params['front_length'], rear_length=model_params['rear_length'], front_width=model_params['front_width'], @@ -285,7 +326,24 @@ def plot_trajectory_with_footprints( color=color, alpha=footprint_alpha, ) - else: + elif model_type == 'Bicycle': + bicycle: BicycleTrajectory = trajectory # type: ignore[assignment] + 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], + length=model_params['length'], + width=model_params['width'], + color=color, + alpha=footprint_alpha, + steering_angle=sample_steering_angle, + ) + else: # Differential Drive — no steered wheels. plot_vehicle_footprint( ax, trajectory.x[footprint_index], diff --git a/polymath_kinematics/explorer/simulation.py b/polymath_kinematics/explorer/simulation.py index 00633e7..bec2e8a 100644 --- a/polymath_kinematics/explorer/simulation.py +++ b/polymath_kinematics/explorer/simulation.py @@ -32,6 +32,7 @@ BicycleModel, BicycleProjector, DifferentialDriveModel, + DifferentialDriveProjector, Pose2D, ) @@ -322,3 +323,177 @@ def generate_lattice_articulated( ) 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, +) -> 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) + 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, + ) + + +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, +) -> 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) + 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, + ) + + +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, +) -> 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, + ) + 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, + ) diff --git a/polymath_kinematics/explorer/types.py b/polymath_kinematics/explorer/types.py index 0a18406..205e139 100644 --- a/polymath_kinematics/explorer/types.py +++ b/polymath_kinematics/explorer/types.py @@ -16,7 +16,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Union +from typing import Optional, Union import numpy as np @@ -47,20 +47,34 @@ class DifferentialTrajectory(Trajectory): @dataclass class BicycleTrajectory(Trajectory): - """Trajectory for bicycle model.""" + """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.""" + """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 # Type alias for any trajectory type diff --git a/polymath_kinematics/kinematic_explorer_app.py b/polymath_kinematics/kinematic_explorer_app.py index 626a10a..492ccfe 100644 --- a/polymath_kinematics/kinematic_explorer_app.py +++ b/polymath_kinematics/kinematic_explorer_app.py @@ -41,6 +41,9 @@ plot_analysis, plot_lattice, plot_trajectory_with_footprints, + single_articulated_trajectory, + single_bicycle_trajectory, + single_differential_trajectory, trajectories_to_dataframe, ) from polymath_kinematics.explorer.plotting import _get_traj_attr @@ -106,6 +109,83 @@ def _cached_lattice_articulated( ) +@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, +) -> 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, + ) + + +@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, +) -> 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, + ) + + +@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, +) -> 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, + ) + + def get_config_dict(model_type: str) -> dict: """Get current configuration as a dictionary (depends on session state).""" config = { @@ -525,6 +605,145 @@ def init_session_state(): 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.' +) + +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, + ) + single_fig = plot_trajectory_with_footprints([single_traj], model_type, model_params, num_footprints=5) + st.pyplot(single_fig) + plt.close(single_fig) + 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, + ) + single_fig = plot_trajectory_with_footprints([single_traj], model_type, model_params, num_footprints=5) + st.pyplot(single_fig) + plt.close(single_fig) + 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, + ) + single_fig = plot_trajectory_with_footprints([single_traj], model_type, model_params, num_footprints=5) + st.pyplot(single_fig) + plt.close(single_fig) + st.caption( + f'Horizon: {st.session_state.sim_duration:.1f}s · ' + f'dt: {st.session_state.sim_dt:.3f}s' + ) + # Parameter Table with st.expander('Current Configuration', expanded=False): config_dict = get_config_dict(model_type) diff --git a/src/differential_drive_projector.cpp b/src/differential_drive_projector.cpp new file mode 100644 index 0000000..9fea9ef --- /dev/null +++ b/src/differential_drive_projector.cpp @@ -0,0 +1,114 @@ +// 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. + +#include "polymath_kinematics/differential_drive_projector.hpp" + +#include +#include + +namespace polymath::kinematics +{ + +namespace +{ + +/// @brief Ramp `current` toward `clamped_target` by at most |rate| * dt, never overshooting. +double rampedAdvance(double current, double clamped_target, double rate, double dt_s) +{ + double max_delta = std::abs(rate) * dt_s; + double delta = clamped_target - current; + if (std::abs(delta) > max_delta) { + delta = std::copysign(max_delta, delta); + } + return current + delta; +} + +} // namespace + +DifferentialDriveProjectedState DifferentialDriveProjector::step( + double dt_s, + const Pose2D & current_pose, + double current_linear_velocity_m_s, + double current_angular_velocity_rad_s, + double target_linear_velocity_m_s, + double target_angular_velocity_rad_s, + double linear_acceleration_m_s2, + double angular_acceleration_rad_s2) +{ + // Clamp targets to actuator bounds before ramping. + double clamped_target_v = + std::clamp(target_linear_velocity_m_s, min_linear_velocity_m_s_, max_linear_velocity_m_s_); + double clamped_target_omega = + std::clamp(target_angular_velocity_rad_s, min_angular_velocity_rad_s_, max_angular_velocity_rad_s_); + + double new_linear_velocity_m_s = + rampedAdvance(current_linear_velocity_m_s, clamped_target_v, linear_acceleration_m_s2, dt_s); + double new_angular_velocity_rad_s = + rampedAdvance(current_angular_velocity_rad_s, clamped_target_omega, angular_acceleration_rad_s2, dt_s); + + DifferentialDriveWheelVelocities wheels = + model_.bodyVelocityToWheelVelocities(new_linear_velocity_m_s, new_angular_velocity_rad_s); + + // Euler pose update (heading taken at start of step, matches bicycle/articulated projectors). + Pose2D new_pose{ + current_pose.x + new_linear_velocity_m_s * std::cos(current_pose.theta) * dt_s, + current_pose.y + new_linear_velocity_m_s * std::sin(current_pose.theta) * dt_s, + normalizeAngle(current_pose.theta + new_angular_velocity_rad_s * dt_s)}; + + return DifferentialDriveProjectedState{ + dt_s, new_pose, new_linear_velocity_m_s, new_angular_velocity_rad_s, wheels}; +} + +std::vector DifferentialDriveProjector::project( + double horizon_s, + double dt_s, + const Pose2D & initial_pose, + double initial_linear_velocity_m_s, + double initial_angular_velocity_rad_s, + double target_linear_velocity_m_s, + double target_angular_velocity_rad_s, + double linear_acceleration_m_s2, + double angular_acceleration_rad_s2) +{ + std::vector trajectory; + if (dt_s <= 0.0 || horizon_s < 0.0) { + return trajectory; + } + + DifferentialDriveWheelVelocities initial_wheels = + model_.bodyVelocityToWheelVelocities(initial_linear_velocity_m_s, initial_angular_velocity_rad_s); + trajectory.push_back(DifferentialDriveProjectedState{ + 0.0, initial_pose, initial_linear_velocity_m_s, initial_angular_velocity_rad_s, initial_wheels}); + + std::size_t n_steps = static_cast(std::ceil(horizon_s / dt_s)); + trajectory.reserve(n_steps + 1); + + Pose2D pose = initial_pose; + double linear_velocity_m_s = initial_linear_velocity_m_s; + double angular_velocity_rad_s = initial_angular_velocity_rad_s; + for (std::size_t i = 0; i < n_steps; ++i) { + DifferentialDriveProjectedState s = step( + dt_s, pose, linear_velocity_m_s, angular_velocity_rad_s, + target_linear_velocity_m_s, target_angular_velocity_rad_s, + linear_acceleration_m_s2, angular_acceleration_rad_s2); + s.time_s = static_cast(i + 1) * dt_s; + pose = s.pose; + linear_velocity_m_s = s.linear_velocity_m_s; + angular_velocity_rad_s = s.angular_velocity_rad_s; + trajectory.push_back(s); + } + return trajectory; +} + +} // namespace polymath::kinematics diff --git a/src/kinematics_pybind.cpp b/src/kinematics_pybind.cpp index 5158b09..09861d8 100644 --- a/src/kinematics_pybind.cpp +++ b/src/kinematics_pybind.cpp @@ -17,6 +17,7 @@ #include "polymath_kinematics/bicycle_model.hpp" #include "polymath_kinematics/bicycle_projector.hpp" #include "polymath_kinematics/differential_drive_model.hpp" +#include "polymath_kinematics/differential_drive_projector.hpp" #include "polymath_kinematics/pose2d.hpp" #include "pybind11/pybind11.h" #include "pybind11/stl.h" @@ -226,6 +227,53 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) .def_property_readonly("model", &ArticulatedProjector::get_model, py::return_value_policy::reference_internal) .def_property_readonly("min_articulation_angle_rad", &ArticulatedProjector::get_min_articulation_angle_rad) .def_property_readonly("max_articulation_angle_rad", &ArticulatedProjector::get_max_articulation_angle_rad); + + // Differential drive projector bindings + py::class_(m, "DifferentialDriveProjectedState") + .def(py::init<>()) + .def_readwrite("time_s", &DifferentialDriveProjectedState::time_s) + .def_readwrite("pose", &DifferentialDriveProjectedState::pose) + .def_readwrite("linear_velocity_m_s", &DifferentialDriveProjectedState::linear_velocity_m_s) + .def_readwrite("angular_velocity_rad_s", &DifferentialDriveProjectedState::angular_velocity_rad_s) + .def_readwrite("wheel_velocities", &DifferentialDriveProjectedState::wheel_velocities); + + py::class_(m, "DifferentialDriveProjector") + .def( + py::init(), + py::arg("model"), + py::arg("min_linear_velocity_m_s"), + py::arg("max_linear_velocity_m_s"), + py::arg("min_angular_velocity_rad_s"), + py::arg("max_angular_velocity_rad_s")) + .def( + "step", + &DifferentialDriveProjector::step, + py::arg("dt_s"), + py::arg("current_pose"), + py::arg("current_linear_velocity_m_s"), + py::arg("current_angular_velocity_rad_s"), + py::arg("target_linear_velocity_m_s"), + py::arg("target_angular_velocity_rad_s"), + py::arg("linear_acceleration_m_s2"), + py::arg("angular_acceleration_rad_s2")) + .def( + "project", + &DifferentialDriveProjector::project, + py::arg("horizon_s"), + py::arg("dt_s"), + py::arg("initial_pose"), + py::arg("initial_linear_velocity_m_s"), + py::arg("initial_angular_velocity_rad_s"), + py::arg("target_linear_velocity_m_s"), + py::arg("target_angular_velocity_rad_s"), + py::arg("linear_acceleration_m_s2"), + py::arg("angular_acceleration_rad_s2")) + .def_property_readonly( + "model", &DifferentialDriveProjector::get_model, py::return_value_policy::reference_internal) + .def_property_readonly("min_linear_velocity_m_s", &DifferentialDriveProjector::get_min_linear_velocity_m_s) + .def_property_readonly("max_linear_velocity_m_s", &DifferentialDriveProjector::get_max_linear_velocity_m_s) + .def_property_readonly("min_angular_velocity_rad_s", &DifferentialDriveProjector::get_min_angular_velocity_rad_s) + .def_property_readonly("max_angular_velocity_rad_s", &DifferentialDriveProjector::get_max_angular_velocity_rad_s); } } // namespace polymath::kinematics diff --git a/test/test_differential_drive_projector.cpp b/test/test_differential_drive_projector.cpp new file mode 100644 index 0000000..62371c5 --- /dev/null +++ b/test/test_differential_drive_projector.cpp @@ -0,0 +1,134 @@ +// 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. + +#include + +#include "catch2_compat.hpp" +#include "polymath_kinematics/differential_drive_projector.hpp" + +namespace polymath::kinematics +{ + +namespace +{ +constexpr double kWheelRadius = 0.1; +constexpr double kTrackWidth = 0.5; +constexpr double kVMin = -2.0; +constexpr double kVMax = 2.0; +constexpr double kOmegaMin = -3.0; +constexpr double kOmegaMax = 3.0; + +DifferentialDriveProjector makeProjector() +{ + return DifferentialDriveProjector( + DifferentialDriveModel(kWheelRadius, kTrackWidth), + kVMin, kVMax, kOmegaMin, kOmegaMax); +} +} // namespace + +TEST_CASE("DifferentialDriveProjector construction stores model and limits") +{ + auto projector = makeProjector(); + CHECK(projector.get_min_linear_velocity_m_s() == Approx(kVMin)); + CHECK(projector.get_max_linear_velocity_m_s() == Approx(kVMax)); + CHECK(projector.get_min_angular_velocity_rad_s() == Approx(kOmegaMin)); + CHECK(projector.get_max_angular_velocity_rad_s() == Approx(kOmegaMax)); + CHECK(projector.get_model().get_wheel_radius_m() == Approx(kWheelRadius)); +} + +TEST_CASE("DifferentialDriveProjector step - zero acceleration freezes both velocities") +{ + auto projector = makeProjector(); + Pose2D pose{0.0, 0.0, 0.0}; + auto result = projector.step(0.1, pose, 0.5, 0.2, 1.5, 1.0, 0.0, 0.0); + CHECK(result.linear_velocity_m_s == Approx(0.5)); + CHECK(result.angular_velocity_rad_s == Approx(0.2)); +} + +TEST_CASE("DifferentialDriveProjector step - large acceleration snaps to target without overshoot") +{ + auto projector = makeProjector(); + Pose2D pose{0.0, 0.0, 0.0}; + // |delta_v| = 1.0, |delta_omega| = 0.4 — much smaller than 100 * 0.1 = 10.0. + auto result = projector.step(0.1, pose, 0.0, 0.0, 1.0, 0.4, 100.0, 100.0); + CHECK(result.linear_velocity_m_s == Approx(1.0)); + CHECK(result.angular_velocity_rad_s == Approx(0.4)); +} + +TEST_CASE("DifferentialDriveProjector step - rate-limited ramps advance by accel*dt") +{ + auto projector = makeProjector(); + Pose2D pose{0.0, 0.0, 0.0}; + // accel*dt = 0.5 * 0.1 = 0.05 for linear; 0.3 * 0.1 = 0.03 for angular. + auto result = projector.step(0.1, pose, 0.0, 0.0, 1.0, 0.5, 0.5, 0.3); + CHECK(result.linear_velocity_m_s == Approx(0.05)); + CHECK(result.angular_velocity_rad_s == Approx(0.03)); +} + +TEST_CASE("DifferentialDriveProjector step - target above linear max saturates at max") +{ + auto projector = makeProjector(); + Pose2D pose{0.0, 0.0, 0.0}; + auto result = projector.step(0.1, pose, 0.0, 0.0, 100.0, 100.0, 1000.0, 1000.0); + CHECK(result.linear_velocity_m_s == Approx(kVMax)); + CHECK(result.angular_velocity_rad_s == Approx(kOmegaMax)); +} + +TEST_CASE("DifferentialDriveProjector step - negative acceleration is treated as magnitude") +{ + auto projector = makeProjector(); + Pose2D pose{0.0, 0.0, 0.0}; + auto result = projector.step(0.1, pose, 0.0, 0.0, 1.0, 0.5, -0.5, -0.3); + CHECK(result.linear_velocity_m_s == Approx(0.05)); + CHECK(result.angular_velocity_rad_s == Approx(0.03)); +} + +TEST_CASE("DifferentialDriveProjector project - straight line traces +x at constant v") +{ + auto projector = makeProjector(); + Pose2D pose{0.0, 0.0, 0.0}; + // Constant linear=1.0, zero angular, large accels so the ramp is irrelevant. + auto trajectory = projector.project(1.0, 0.1, pose, 1.0, 0.0, 1.0, 0.0, 100.0, 100.0); + CHECK(trajectory.size() == 11); + CHECK(trajectory.front().time_s == Approx(0.0)); + CHECK(trajectory.back().time_s == Approx(1.0)); + CHECK(trajectory.back().pose.x == Approx(1.0)); + CHECK(trajectory.back().pose.y == Approx(0.0)); + CHECK(trajectory.back().pose.theta == Approx(0.0)); +} + +TEST_CASE("DifferentialDriveProjector project - initial state stored as element 0") +{ + auto projector = makeProjector(); + Pose2D pose{0.5, -1.0, 0.25}; + auto trajectory = projector.project(0.5, 0.1, pose, 0.2, 0.1, 0.5, 0.3, 0.5, 0.5); + CHECK(trajectory.front().time_s == Approx(0.0)); + CHECK(trajectory.front().pose.x == Approx(0.5)); + CHECK(trajectory.front().pose.y == Approx(-1.0)); + CHECK(trajectory.front().pose.theta == Approx(0.25)); + CHECK(trajectory.front().linear_velocity_m_s == Approx(0.2)); + CHECK(trajectory.front().angular_velocity_rad_s == Approx(0.1)); +} + +TEST_CASE("DifferentialDriveProjector step - wheel speeds derive from body command") +{ + auto projector = makeProjector(); + Pose2D pose{0.0, 0.0, 0.0}; + // After snap, body command is (1.0 m/s, 0.0 rad/s); wheel speeds should both be 1.0 / 0.1 = 10 rad/s. + auto result = projector.step(0.1, pose, 0.0, 0.0, 1.0, 0.0, 100.0, 100.0); + CHECK(result.wheel_velocities.left_wheel_velocity_rad_s == Approx(10.0)); + CHECK(result.wheel_velocities.right_wheel_velocity_rad_s == Approx(10.0)); +} + +} // namespace polymath::kinematics diff --git a/test/test_explorer.py b/test/test_explorer.py index 9e5231a..a95ca1e 100644 --- a/test/test_explorer.py +++ b/test/test_explorer.py @@ -27,6 +27,9 @@ plot_lattice, plot_trajectory_with_footprints, select_symmetric_trajectories, + single_articulated_trajectory, + single_bicycle_trajectory, + single_differential_trajectory, trajectories_to_dataframe, ) @@ -306,3 +309,119 @@ def test_kinematic_equations_present(self): 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) diff --git a/test/test_python_bindings.py b/test/test_python_bindings.py index d6733bd..7ec0e31 100644 --- a/test/test_python_bindings.py +++ b/test/test_python_bindings.py @@ -22,6 +22,7 @@ BicycleModel, BicycleProjector, DifferentialDriveModel, + DifferentialDriveProjector, Pose2D, ) @@ -266,3 +267,58 @@ def test_project_initial_state_anchored(self): assert traj[0].pose.y == pytest.approx(2.0) assert traj[0].pose.theta == pytest.approx(0.5) assert traj[0].articulation_angle_rad == pytest.approx(0.2) + + +class TestDifferentialDriveProjector: + def _make(self): + return DifferentialDriveProjector( + model=DifferentialDriveModel(0.1, 0.5), + min_linear_velocity_m_s=-2.0, + max_linear_velocity_m_s=2.0, + min_angular_velocity_rad_s=-3.0, + max_angular_velocity_rad_s=3.0, + ) + + def test_construction(self): + projector = self._make() + assert projector.min_linear_velocity_m_s == pytest.approx(-2.0) + assert projector.max_linear_velocity_m_s == pytest.approx(2.0) + assert projector.min_angular_velocity_rad_s == pytest.approx(-3.0) + assert projector.max_angular_velocity_rad_s == pytest.approx(3.0) + assert projector.model.wheel_radius == pytest.approx(0.1) + + def test_zero_accel_freezes_velocities(self): + projector = self._make() + result = projector.step( + dt_s=0.1, + current_pose=Pose2D(), + current_linear_velocity_m_s=0.5, + current_angular_velocity_rad_s=0.2, + target_linear_velocity_m_s=1.5, + target_angular_velocity_rad_s=1.0, + linear_acceleration_m_s2=0.0, + angular_acceleration_rad_s2=0.0, + ) + assert result.linear_velocity_m_s == pytest.approx(0.5) + assert result.angular_velocity_rad_s == pytest.approx(0.2) + + def test_clamping_saturates_at_max(self): + projector = self._make() + result = projector.step(0.1, Pose2D(), 0.0, 0.0, 100.0, 100.0, 1000.0, 1000.0) + assert result.linear_velocity_m_s == pytest.approx(2.0) + assert result.angular_velocity_rad_s == pytest.approx(3.0) + + def test_rate_limited_ramp(self): + projector = self._make() + result = projector.step(0.1, Pose2D(), 0.0, 0.0, 1.0, 0.5, 0.5, 0.3) + assert result.linear_velocity_m_s == pytest.approx(0.05) + assert result.angular_velocity_rad_s == pytest.approx(0.03) + + def test_project_straight_line(self): + projector = self._make() + # Constant linear=1.0, zero angular, large accels. + traj = projector.project(1.0, 0.1, Pose2D(), 1.0, 0.0, 1.0, 0.0, 100.0, 100.0) + assert len(traj) == 11 + assert traj[-1].pose.x == pytest.approx(1.0) + assert traj[-1].pose.y == pytest.approx(0.0) + assert traj[-1].pose.theta == pytest.approx(0.0) From 0d8d781036b5c92ff0215e2a933bc5eb313d03b2 Mon Sep 17 00:00:00 2001 From: Zeerek Date: Wed, 13 May 2026 03:34:08 +0000 Subject: [PATCH 4/9] Update nomenclature, testing and fix building Fix typo --- .cpplint.cfg | 3 + CMakeLists.txt | 102 ++++++++++++++--- README.md | 57 +++++++--- package.xml | 24 ++++ polymath_kinematics/explorer/simulation.py | 19 ++-- polymath_kinematics/kinematic_explorer_app.py | 105 +++++++++++++----- pyproject.toml | 5 +- src/articulated_model.cpp | 2 +- src/differential_drive_projector.cpp | 18 +-- src/kinematics_pybind.cpp | 3 +- test/test_differential_drive_projector.cpp | 3 +- 11 files changed, 264 insertions(+), 77 deletions(-) create mode 100644 package.xml diff --git a/.cpplint.cfg b/.cpplint.cfg index 70348dc..c0bdd3f 100644 --- a/.cpplint.cfg +++ b/.cpplint.cfg @@ -11,6 +11,9 @@ filter=-build/c++17 # Per our style guide, we want to allow the use of non-const reference passing for output parameters filter=-runtime/references +# Catch2 idiom is `CHECK(a == b)` with expression decomposition; CHECK_EQ is a gtest macro and not applicable here +filter=-readability/check + # Disable all formatting checks, this is handled by clang-format filter=-readability/braces filter=-whitespace/braces diff --git a/CMakeLists.txt b/CMakeLists.txt index 42864f4..0ffc727 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,11 +23,37 @@ if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic -Werror) endif() +# Ubuntu codename detection -> Catch2 v2 (Jammy) vs v3 (everything else). +# Mirrors socketcan_adapter so the Catch2 version is picked deterministically +# per distro instead of best-effort QUIET probing. +if(NOT DEFINED BUILD_JAMMY) + set(BUILD_JAMMY OFF) + if(EXISTS "/etc/os-release") + file(READ "/etc/os-release" _OS_RELEASE) + string(REGEX MATCH "VERSION_CODENAME=([^\n\r]+)" _match "${_OS_RELEASE}") + if(CMAKE_MATCH_1) + string(TOLOWER "${CMAKE_MATCH_1}" _UBUNTU_CODENAME) + if(_UBUNTU_CODENAME STREQUAL "jammy") + set(BUILD_JAMMY ON) + message(STATUS "Ubuntu=jammy (22.04) -> Catch2 v2") + else() + message(STATUS "Ubuntu codename='${_UBUNTU_CODENAME}' -> defaulting to Catch2 v3") + endif() + endif() + endif() +endif() + +# ament_cmake is only present under colcon. Absent under pip wheel builds, +# which is fine: the QUIET probe simply leaves ament_cmake_FOUND false. +find_package(ament_cmake QUIET) find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) find_package(pybind11 CONFIG REQUIRED) -# Core static library — the kinematic models and projectors live here. -add_library(polymath_kinematics_core STATIC +# Library target name must stay `polymath_kinematics`; downstream packages link +# against it by that name. SHARED under ROS2 so consumers pick up the .so via +# ament_export_targets; STATIC under wheel builds so the pybind11 module is +# self-contained and the wheel ships a single shared object. +set(_pk_lib_sources src/articulated_model.cpp src/articulated_projector.cpp src/bicycle_model.cpp @@ -35,31 +61,53 @@ add_library(polymath_kinematics_core STATIC src/differential_drive_model.cpp src/differential_drive_projector.cpp ) -target_include_directories(polymath_kinematics_core PUBLIC +if(ament_cmake_FOUND) + add_library(polymath_kinematics SHARED ${_pk_lib_sources}) +else() + add_library(polymath_kinematics STATIC ${_pk_lib_sources}) +endif() +target_include_directories(polymath_kinematics PUBLIC $ + $ ) -set_target_properties(polymath_kinematics_core PROPERTIES POSITION_INDEPENDENT_CODE ON) +set_target_properties(polymath_kinematics PROPERTIES POSITION_INDEPENDENT_CODE ON) -# Python extension — exposes everything as `polymath_kinematics_cpp`. pybind11_add_module(polymath_kinematics_cpp src/kinematics_pybind.cpp) -target_link_libraries(polymath_kinematics_cpp PRIVATE polymath_kinematics_core) +target_link_libraries(polymath_kinematics_cpp PRIVATE polymath_kinematics) -# Install the extension module at the wheel root so `from polymath_kinematics_cpp import ...` works. -install(TARGETS polymath_kinematics_cpp DESTINATION .) +if(ament_cmake_FOUND) + find_package(ament_cmake_python REQUIRED) -# ---------------------------------------------------------------------- -# Optional C++ tests (off by default; build with -DBUILD_TESTING=ON). -# Not part of the wheel build. Run with: cmake --build && ctest. -# ---------------------------------------------------------------------- -option(BUILD_TESTING "Build C++ Catch2 tests" OFF) + install(DIRECTORY include/ DESTINATION include) + install(TARGETS polymath_kinematics + EXPORT export_${PROJECT_NAME} + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin + ) + ament_export_targets(export_${PROJECT_NAME} HAS_LIBRARY_TARGET) + ament_export_include_directories(include) + + ament_python_install_package(polymath_kinematics) + # `polymath_kinematics_cpp` is a top-level module imported by polymath_kinematics/__init__.py, + # not a submodule. Install it as a sibling of the Python package, matching the wheel layout. + install(TARGETS polymath_kinematics_cpp + DESTINATION "${PYTHON_INSTALL_DIR}" + ) +else() + # Wheel build: scikit-build-core installs the extension at the wheel root so + # `from polymath_kinematics_cpp import ...` keeps working. + install(TARGETS polymath_kinematics_cpp DESTINATION .) +endif() if(BUILD_TESTING) include(CTest) - - find_package(Catch2 3 QUIET) - if(NOT Catch2_FOUND) + if(BUILD_JAMMY) find_package(Catch2 2 REQUIRED) + else() + find_package(Catch2 3 REQUIRED) endif() + include(Catch OPTIONAL) set(_pk_tests test_bicycle_model @@ -71,8 +119,26 @@ if(BUILD_TESTING) ) foreach(tgt IN LISTS _pk_tests) add_executable(${tgt} test/${tgt}.cpp) - target_link_libraries(${tgt} PRIVATE polymath_kinematics_core Catch2::Catch2WithMain) + target_link_libraries(${tgt} PRIVATE polymath_kinematics Catch2::Catch2WithMain) target_include_directories(${tgt} PRIVATE test) - add_test(NAME ${tgt} COMMAND ${tgt}) + if(COMMAND catch_discover_tests) + catch_discover_tests(${tgt}) + else() + add_test(NAME ${tgt} COMMAND ${tgt}) + endif() endforeach() + + if(ament_cmake_FOUND) + find_package(ament_cmake_pytest REQUIRED) + 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() + +if(ament_cmake_FOUND) + ament_package() endif() diff --git a/README.md b/README.md index 4d9e0af..5e6b6c6 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,18 @@ Includes an interactive **Kinematic Explorer** (Streamlit) for visualising traje ## Installation -**Prerequisites:** Python 3.10+, a C++17 compiler, CMake 3.15+, and [uv](https://github.com/astral-sh/uv). +**Prerequisites:** Python 3.10+, a C++17 compiler, CMake 3.15+, and [uv](https://github.com/astral-sh/uv) for the wheel path. + +The package supports three build paths. They share one `CMakeLists.txt`; `find_package(ament_cmake QUIET)` picks between the ROS2 and standalone branches at configure time. + +### 1. Python wheel (uv / pip) + +For Python-only consumers and the Streamlit explorer. Builds a static `polymath_kinematics` library and links it into the `polymath_kinematics_cpp` pybind11 module; ships the result as a single wheel. ```bash cd src/polymath_kinematics -# C++ bindings only +# Bindings only uv pip install -e . # Bindings + Kinematic Explorer (streamlit, matplotlib, pandas) @@ -21,23 +27,42 @@ uv pip install -e ".[explorer]" # Bindings + Explorer + pytest uv pip install -e ".[dev]" + +# Or, without activating a venv first: +uv run python -c "import polymath_kinematics; print(polymath_kinematics.BicycleModel(2.5, 1.5, 0.3))" ``` -The first install builds the pybind11 extension via CMake; subsequent installs are incremental. +The wheel build sets `-DCMAKE_DISABLE_FIND_PACKAGE_ament_cmake=ON` via `pyproject.toml`, so a host-installed `/opt/ros/humble` does not pull the build down the ROS2 branch. + +### 2. ROS2 / colcon + +For consumers inside a ROS2 workspace. Produces a shared `libpolymath_kinematics.so` exported through ament (`polymath_kinematics::polymath_kinematics`) plus the pybind11 module installed alongside the Python package. -### ROS2 / colcon +```bash +# From the colcon workspace root +colcon build --packages-select polymath_kinematics + +# Build + run C++ Catch2 tests and Python pytest suites +colcon build --packages-select polymath_kinematics --cmake-args -DBUILD_TESTING=ON +colcon test --packages-select polymath_kinematics +colcon test-result --verbose +``` -The package also builds inside a colcon workspace (it has a top-level `CMakeLists.txt`, no `package.xml` — colcon picks it up via its CMake detector): +### 3. Standalone CMake + +For working on the C++ code or running the Catch2 tests outside of ROS2 and outside of the wheel build (e.g. local IDE / ctest workflows). ```bash -# From the workspace root -colcon build --merge-install --packages-select polymath_kinematics +cd src/polymath_kinematics +cmake -S . -B build -DBUILD_TESTING=ON +cmake --build build -j +ctest --test-dir build --output-on-failure ``` -To also build the C++ tests: +If `/opt/ros/humble` (or any other ament-providing install) is on the host, force the standalone path with: ```bash -colcon build --merge-install --packages-select polymath_kinematics --cmake-args -DBUILD_TESTING=ON +cmake -S . -B build -DBUILD_TESTING=ON -DCMAKE_DISABLE_FIND_PACKAGE_ament_cmake=ON ``` ## Running the Kinematic Explorer @@ -64,12 +89,18 @@ The app opens at `http://localhost:8501` and exposes: ## Running tests ```bash -# Python (bindings + explorer) +# Python only (from the wheel install) uv run pytest -# C++ (via colcon) -colcon build --merge-install --packages-select polymath_kinematics --cmake-args -DBUILD_TESTING=ON -cd ../../build/polymath_kinematics && ctest --output-on-failure +# C++ Catch2 + Python pytest under ROS2 +colcon build --packages-select polymath_kinematics --cmake-args -DBUILD_TESTING=ON +colcon test --packages-select polymath_kinematics +colcon test-result --verbose + +# C++ Catch2 standalone (no ROS2) +cmake -S . -B build -DBUILD_TESTING=ON -DCMAKE_DISABLE_FIND_PACKAGE_ament_cmake=ON +cmake --build build -j +ctest --test-dir build --output-on-failure ``` ## Derivations diff --git a/package.xml b/package.xml new file mode 100644 index 0000000..d7c3a80 --- /dev/null +++ b/package.xml @@ -0,0 +1,24 @@ + + + + polymath_kinematics + 0.2.0 + Mixed C++/Python kinematics library for vehicle models + Polymath Engineering + Apache-2.0 + Zeerek Ahmad + + ament_cmake + ament_cmake_python + + pybind11_vendor + python3-numpy + + ament_cmake_pytest + ament_cmake_test + catch2 + + + ament_cmake + + diff --git a/polymath_kinematics/explorer/simulation.py b/polymath_kinematics/explorer/simulation.py index bec2e8a..8ff09d4 100644 --- a/polymath_kinematics/explorer/simulation.py +++ b/polymath_kinematics/explorer/simulation.py @@ -407,9 +407,12 @@ def single_articulated_trajectory( 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, + 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) states = projector.project( @@ -468,8 +471,10 @@ def single_differential_trajectory( model = DifferentialDriveModel(wheel_radius, track_width) projector = DifferentialDriveProjector( model, - min_linear_velocity, max_linear_velocity, - min_angular_velocity, max_angular_velocity, + min_linear_velocity, + max_linear_velocity, + min_angular_velocity, + max_angular_velocity, ) states = projector.project( horizon_s=duration, @@ -493,7 +498,5 @@ def single_differential_trajectory( 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, + base_wheel_velocity=(final_wheels.left_wheel_velocity_rad_s + final_wheels.right_wheel_velocity_rad_s) / 2.0, ) diff --git a/polymath_kinematics/kinematic_explorer_app.py b/polymath_kinematics/kinematic_explorer_app.py index 492ccfe..89dce2d 100644 --- a/polymath_kinematics/kinematic_explorer_app.py +++ b/polymath_kinematics/kinematic_explorer_app.py @@ -122,7 +122,9 @@ def _cached_single_bicycle( time_step: float, ) -> BicycleTrajectory: return single_bicycle_trajectory( - wheelbase, track_width, wheel_radius, + 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, @@ -148,9 +150,12 @@ def _cached_single_articulated( time_step: float, ) -> ArticulatedTrajectory: return single_articulated_trajectory( - articulation_to_front, articulation_to_rear, - front_track, rear_track, - front_wheel_radius, rear_wheel_radius, + 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, @@ -174,7 +179,8 @@ def _cached_single_differential( time_step: float, ) -> DifferentialTrajectory: return single_differential_trajectory( - wheel_radius, track_width, + wheel_radius, + track_width, initial_linear_velocity=initial_linear_velocity, initial_angular_velocity=initial_angular_velocity, target_linear_velocity=target_linear_velocity, @@ -618,18 +624,29 @@ def init_session_state(): 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', + -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', + -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', + 'Steering Rate (deg/s)', + 0.0, + 180.0, + 30.0, + 1.0, + key='bike_single_rate_deg_s', ) single_traj = _cached_single_bicycle( @@ -657,18 +674,29 @@ def init_session_state(): 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', + -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', + -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', + 'Articulation Rate (deg/s)', + 0.0, + 90.0, + 15.0, + 1.0, + key='art_single_rate_deg_s', ) single_traj = _cached_single_articulated( @@ -699,29 +727,59 @@ def init_session_state(): 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', + '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', + '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', + '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', + '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', + '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', + 'Angular Accel (rad/s²)', + 0.0, + 5.0, + 1.0, + 0.1, + key='diff_single_angular_accel', ) single_traj = _cached_single_differential( @@ -739,10 +797,7 @@ def init_session_state(): single_fig = plot_trajectory_with_footprints([single_traj], model_type, model_params, num_footprints=5) st.pyplot(single_fig) plt.close(single_fig) - st.caption( - f'Horizon: {st.session_state.sim_duration:.1f}s · ' - f'dt: {st.session_state.sim_dt:.3f}s' - ) + 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): diff --git a/pyproject.toml b/pyproject.toml index 90c9a7c..f06eab6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,10 @@ kinematic-explorer = "polymath_kinematics.kinematic_explorer_app:main" cmake.version = ">=3.15" build.verbose = false # C++ tests are built out-of-band via `cmake -DBUILD_TESTING=ON`, not as part of the wheel. -cmake.args = ["-DBUILD_TESTING=OFF"] +# CMAKE_DISABLE_FIND_PACKAGE_ament_cmake forces the wheel build down the non-ament path even +# when /opt/ros/humble is installed on the system — otherwise the ROS2 branch is taken and +# then fails on missing build-env deps like catkin_pkg. +cmake.args = ["-DBUILD_TESTING=OFF", "-DCMAKE_DISABLE_FIND_PACKAGE_ament_cmake=ON"] wheel.packages = ["polymath_kinematics"] [dependency-groups] diff --git a/src/articulated_model.cpp b/src/articulated_model.cpp index 8715c83..c446261 100644 --- a/src/articulated_model.cpp +++ b/src/articulated_model.cpp @@ -57,7 +57,7 @@ ArticulatedVehicleState ArticulatedModel::bodyVelocityToVehicleState( // Clamp keeps the arccos argument in [-1, 1] for over-tight commands; see the "Feasibility of // the arccos argument (clamping)" section in derivations/articulated_model.md. const double acos_argument = std::clamp( - articulation_to_rear_axle_m_ * (articulation_turning_velocity_rad_s_ - angular_velocity_rad_s) / + articulation_to_rear_axle_m_ * (articulation_turning_velocity_rad_s - angular_velocity_rad_s) / std::hypot(angular_velocity_rad_s * articulation_to_front_axle_m_, linear_velocity_m_s), -1.0, 1.0); diff --git a/src/differential_drive_projector.cpp b/src/differential_drive_projector.cpp index 9fea9ef..4abd588 100644 --- a/src/differential_drive_projector.cpp +++ b/src/differential_drive_projector.cpp @@ -16,6 +16,7 @@ #include #include +#include namespace polymath::kinematics { @@ -47,8 +48,7 @@ DifferentialDriveProjectedState DifferentialDriveProjector::step( double angular_acceleration_rad_s2) { // Clamp targets to actuator bounds before ramping. - double clamped_target_v = - std::clamp(target_linear_velocity_m_s, min_linear_velocity_m_s_, max_linear_velocity_m_s_); + double clamped_target_v = std::clamp(target_linear_velocity_m_s, min_linear_velocity_m_s_, max_linear_velocity_m_s_); double clamped_target_omega = std::clamp(target_angular_velocity_rad_s, min_angular_velocity_rad_s_, max_angular_velocity_rad_s_); @@ -66,8 +66,7 @@ DifferentialDriveProjectedState DifferentialDriveProjector::step( current_pose.y + new_linear_velocity_m_s * std::sin(current_pose.theta) * dt_s, normalizeAngle(current_pose.theta + new_angular_velocity_rad_s * dt_s)}; - return DifferentialDriveProjectedState{ - dt_s, new_pose, new_linear_velocity_m_s, new_angular_velocity_rad_s, wheels}; + return DifferentialDriveProjectedState{dt_s, new_pose, new_linear_velocity_m_s, new_angular_velocity_rad_s, wheels}; } std::vector DifferentialDriveProjector::project( @@ -99,9 +98,14 @@ std::vector DifferentialDriveProjector::project double angular_velocity_rad_s = initial_angular_velocity_rad_s; for (std::size_t i = 0; i < n_steps; ++i) { DifferentialDriveProjectedState s = step( - dt_s, pose, linear_velocity_m_s, angular_velocity_rad_s, - target_linear_velocity_m_s, target_angular_velocity_rad_s, - linear_acceleration_m_s2, angular_acceleration_rad_s2); + dt_s, + pose, + linear_velocity_m_s, + angular_velocity_rad_s, + target_linear_velocity_m_s, + target_angular_velocity_rad_s, + linear_acceleration_m_s2, + angular_acceleration_rad_s2); s.time_s = static_cast(i + 1) * dt_s; pose = s.pose; linear_velocity_m_s = s.linear_velocity_m_s; diff --git a/src/kinematics_pybind.cpp b/src/kinematics_pybind.cpp index 09861d8..900b96a 100644 --- a/src/kinematics_pybind.cpp +++ b/src/kinematics_pybind.cpp @@ -268,8 +268,7 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) py::arg("target_angular_velocity_rad_s"), py::arg("linear_acceleration_m_s2"), py::arg("angular_acceleration_rad_s2")) - .def_property_readonly( - "model", &DifferentialDriveProjector::get_model, py::return_value_policy::reference_internal) + .def_property_readonly("model", &DifferentialDriveProjector::get_model, py::return_value_policy::reference_internal) .def_property_readonly("min_linear_velocity_m_s", &DifferentialDriveProjector::get_min_linear_velocity_m_s) .def_property_readonly("max_linear_velocity_m_s", &DifferentialDriveProjector::get_max_linear_velocity_m_s) .def_property_readonly("min_angular_velocity_rad_s", &DifferentialDriveProjector::get_min_angular_velocity_rad_s) diff --git a/test/test_differential_drive_projector.cpp b/test/test_differential_drive_projector.cpp index 62371c5..e3acabe 100644 --- a/test/test_differential_drive_projector.cpp +++ b/test/test_differential_drive_projector.cpp @@ -32,8 +32,7 @@ constexpr double kOmegaMax = 3.0; DifferentialDriveProjector makeProjector() { return DifferentialDriveProjector( - DifferentialDriveModel(kWheelRadius, kTrackWidth), - kVMin, kVMax, kOmegaMin, kOmegaMax); + DifferentialDriveModel(kWheelRadius, kTrackWidth), kVMin, kVMax, kOmegaMin, kOmegaMax); } } // namespace From d4a5f3c269d43ba33e100c948a4c1d8a08ffbdb9 Mon Sep 17 00:00:00 2001 From: Zeerek Date: Wed, 13 May 2026 03:47:52 +0000 Subject: [PATCH 5/9] CMakeList simplification pass Try to fix builds --- CMakeLists.txt | 105 +++++++++++++++++++++++++------------------------ package.xml | 4 ++ 2 files changed, 58 insertions(+), 51 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ffc727..0f7cfcb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,6 @@ # Copyright (c) 2025-present Polymath Robotics, Inc. All rights reserved +# Proprietary. Any unauthorized copying, distribution, or modification of this software is strictly prohibited. +# 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. @@ -13,47 +15,50 @@ # limitations under the License. cmake_minimum_required(VERSION 3.15) -project(polymath_kinematics CXX) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) +project(polymath_kinematics) +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic -Werror) endif() -# Ubuntu codename detection -> Catch2 v2 (Jammy) vs v3 (everything else). -# Mirrors socketcan_adapter so the Catch2 version is picked deterministically -# per distro instead of best-effort QUIET probing. +# Ubuntu detection (override with -DBUILD_JAMMY=ON/OFF) if(NOT DEFINED BUILD_JAMMY) set(BUILD_JAMMY OFF) if(EXISTS "/etc/os-release") file(READ "/etc/os-release" _OS_RELEASE) string(REGEX MATCH "VERSION_CODENAME=([^\n\r]+)" _match "${_OS_RELEASE}") if(CMAKE_MATCH_1) - string(TOLOWER "${CMAKE_MATCH_1}" _UBUNTU_CODENAME) + set(_UBUNTU_CODENAME "${CMAKE_MATCH_1}") + string(TOLOWER "${_UBUNTU_CODENAME}" _UBUNTU_CODENAME) if(_UBUNTU_CODENAME STREQUAL "jammy") set(BUILD_JAMMY ON) message(STATUS "Ubuntu=jammy (22.04) -> Catch2 v2") else() + set(BUILD_JAMMY OFF) message(STATUS "Ubuntu codename='${_UBUNTU_CODENAME}' -> defaulting to Catch2 v3") endif() endif() endif() endif() -# ament_cmake is only present under colcon. Absent under pip wheel builds, -# which is fine: the QUIET probe simply leaves ament_cmake_FOUND false. +# Find packages. ament_cmake is QUIET so the wheel build (no ROS2) falls +# through to a plain CMake configuration. find_package(ament_cmake QUIET) -find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) +find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module) find_package(pybind11 CONFIG REQUIRED) -# Library target name must stay `polymath_kinematics`; downstream packages link -# against it by that name. SHARED under ROS2 so consumers pick up the .so via -# ament_export_targets; STATIC under wheel builds so the pybind11 module is -# self-contained and the wheel ships a single shared object. -set(_pk_lib_sources +# C++ library with all kinematic models. SHARED under ROS2 (ament exports a +# linkable .so to downstream packages); STATIC under wheel builds so the +# pybind11 module is self-contained and the wheel ships a single .so. +if(ament_cmake_FOUND) + set(_polymath_kinematics_lib_type SHARED) +else() + set(_polymath_kinematics_lib_type STATIC) +endif() +add_library(polymath_kinematics ${_polymath_kinematics_lib_type} src/articulated_model.cpp src/articulated_projector.cpp src/bicycle_model.cpp @@ -61,47 +66,43 @@ set(_pk_lib_sources src/differential_drive_model.cpp src/differential_drive_projector.cpp ) -if(ament_cmake_FOUND) - add_library(polymath_kinematics SHARED ${_pk_lib_sources}) -else() - add_library(polymath_kinematics STATIC ${_pk_lib_sources}) -endif() target_include_directories(polymath_kinematics PUBLIC $ $ ) set_target_properties(polymath_kinematics PROPERTIES POSITION_INDEPENDENT_CODE ON) +# Python bindings pybind11_add_module(polymath_kinematics_cpp src/kinematics_pybind.cpp) target_link_libraries(polymath_kinematics_cpp PRIVATE polymath_kinematics) if(ament_cmake_FOUND) find_package(ament_cmake_python REQUIRED) - install(DIRECTORY include/ DESTINATION include) - install(TARGETS polymath_kinematics - EXPORT export_${PROJECT_NAME} + install( + TARGETS polymath_kinematics + EXPORT ${PROJECT_NAME}_TARGETS ARCHIVE DESTINATION lib LIBRARY DESTINATION lib - RUNTIME DESTINATION bin + RUNTIME DESTINATION lib/${PROJECT_NAME} ) - ament_export_targets(export_${PROJECT_NAME} HAS_LIBRARY_TARGET) - ament_export_include_directories(include) - - ament_python_install_package(polymath_kinematics) - # `polymath_kinematics_cpp` is a top-level module imported by polymath_kinematics/__init__.py, - # not a submodule. Install it as a sibling of the Python package, matching the wheel layout. - install(TARGETS polymath_kinematics_cpp - DESTINATION "${PYTHON_INSTALL_DIR}" + install( + EXPORT ${PROJECT_NAME}_TARGETS + NAMESPACE ${PROJECT_NAME}:: + DESTINATION share/${PROJECT_NAME}/cmake ) + install(DIRECTORY include/ DESTINATION include/) + + ament_python_install_package(${PROJECT_NAME}) + install(TARGETS polymath_kinematics_cpp DESTINATION "${PYTHON_INSTALL_DIR}") else() - # Wheel build: scikit-build-core installs the extension at the wheel root so - # `from polymath_kinematics_cpp import ...` keeps working. + # Wheel build: scikit-build-core installs the extension at the wheel root. install(TARGETS polymath_kinematics_cpp DESTINATION .) endif() if(BUILD_TESTING) include(CTest) + if(BUILD_JAMMY) find_package(Catch2 2 REQUIRED) else() @@ -109,24 +110,23 @@ if(BUILD_TESTING) endif() include(Catch OPTIONAL) - set(_pk_tests - test_bicycle_model - test_articulated_model - test_differential_drive - test_bicycle_projector - test_articulated_projector - test_differential_drive_projector - ) - foreach(tgt IN LISTS _pk_tests) - add_executable(${tgt} test/${tgt}.cpp) - target_link_libraries(${tgt} PRIVATE polymath_kinematics Catch2::Catch2WithMain) - target_include_directories(${tgt} PRIVATE test) + function(add_kinematics_catch2_test TEST_NAME TEST_SOURCE) + add_executable(${TEST_NAME} ${TEST_SOURCE}) + target_link_libraries(${TEST_NAME} PRIVATE polymath_kinematics Catch2::Catch2WithMain) + target_include_directories(${TEST_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/test) if(COMMAND catch_discover_tests) - catch_discover_tests(${tgt}) + catch_discover_tests(${TEST_NAME}) else() - add_test(NAME ${tgt} COMMAND ${tgt}) + add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) endif() - endforeach() + endfunction() + + add_kinematics_catch2_test(test_bicycle_model test/test_bicycle_model.cpp) + add_kinematics_catch2_test(test_articulated_model test/test_articulated_model.cpp) + add_kinematics_catch2_test(test_differential_drive test/test_differential_drive.cpp) + add_kinematics_catch2_test(test_bicycle_projector test/test_bicycle_projector.cpp) + add_kinematics_catch2_test(test_articulated_projector test/test_articulated_projector.cpp) + add_kinematics_catch2_test(test_differential_drive_projector test/test_differential_drive_projector.cpp) if(ament_cmake_FOUND) find_package(ament_cmake_pytest REQUIRED) @@ -140,5 +140,8 @@ if(BUILD_TESTING) endif() if(ament_cmake_FOUND) + ament_export_targets(${PROJECT_NAME}_TARGETS HAS_LIBRARY_TARGET) + ament_export_include_directories(include) + ament_export_libraries(polymath_kinematics) ament_package() endif() diff --git a/package.xml b/package.xml index d7c3a80..f82293f 100644 --- a/package.xml +++ b/package.xml @@ -17,6 +17,10 @@ ament_cmake_pytest ament_cmake_test catch2 + + python3-pandas + python3-matplotlib ament_cmake From 7b656b0d6fb0628a27db0c4d96dab018d9e74e02 Mon Sep 17 00:00:00 2001 From: Zeerek Date: Wed, 1 Jul 2026 00:27:34 +0000 Subject: [PATCH 6/9] Update projector to also project footprints and fix up the querying interfaces --- .../articulated_projector.hpp | 59 +++++++- .../polymath_kinematics/bicycle_projector.hpp | 47 ++++++- .../differential_drive_projector.hpp | 42 +++++- include/polymath_kinematics/pose2d.hpp | 24 +++- polymath_kinematics/__init__.py | 2 + polymath_kinematics/explorer/config.py | 16 ++- polymath_kinematics/explorer/plotting.py | 101 ++++++++++++-- polymath_kinematics/explorer/simulation.py | 72 +++++++++- polymath_kinematics/explorer/types.py | 13 +- polymath_kinematics/kinematic_explorer_app.py | 29 +++- src/articulated_projector.cpp | 49 ++++++- src/bicycle_projector.cpp | 34 ++++- src/differential_drive_projector.cpp | 32 ++++- src/kinematics_pybind.cpp | 57 ++++++-- test/test_articulated_projector.cpp | 129 ++++++++++++++---- test/test_bicycle_projector.cpp | 69 +++++++--- test/test_differential_drive_projector.cpp | 66 +++++++-- test/test_explorer.py | 58 ++++++++ test/test_python_bindings.py | 71 ++++++++++ 19 files changed, 856 insertions(+), 114 deletions(-) diff --git a/include/polymath_kinematics/articulated_projector.hpp b/include/polymath_kinematics/articulated_projector.hpp index 9a1142b..346fae6 100644 --- a/include/polymath_kinematics/articulated_projector.hpp +++ b/include/polymath_kinematics/articulated_projector.hpp @@ -38,6 +38,8 @@ struct ArticulatedProjectedState double linear_velocity_m_s; ///< Commanded linear velocity in m/s double angular_velocity_rad_s; ///< Rear-axle turning rate used for theta integration ArticulatedVehicleState vehicle_state; ///< Full kinematic snapshot (wheel speeds + turning radii) + Footprint front_footprint; ///< World-frame front-body polygon; empty if no footprint dims set + Footprint rear_footprint; ///< World-frame rear-body polygon; empty if no footprint dims set }; /// @brief Forward-projection wrapper around ArticulatedModel. Ramps articulation angle (gamma) @@ -46,14 +48,36 @@ struct ArticulatedProjectedState class ArticulatedProjector { public: - /// @brief Construct a projector with articulation-angle limits. + /// @brief Construct a projector with articulation-angle limits and (optionally) footprint dims. + /// + /// Footprint dimensions describe the two vehicle bodies relative to the articulation joint and + /// are owned by the projector (the kinematic model stays dimension-free). Each body is a + /// rectangle: the front body extends `front_joint_to_bumper_m` ahead of the joint along the + /// front-body heading; the rear body extends `rear_joint_to_bumper_m` behind the joint along + /// the rear-body heading. A body width <= 0 means "unset/invalid": that body's footprint is + /// emitted empty and projection proceeds normally (never throws). /// @param model Articulated kinematics model (stored by value) /// @param min_articulation_angle_rad Minimum allowed articulation angle (typically negative) /// @param max_articulation_angle_rad Maximum allowed articulation angle (typically positive) - ArticulatedProjector(ArticulatedModel model, double min_articulation_angle_rad, double max_articulation_angle_rad) + /// @param front_joint_to_bumper_m Front-body length from joint to front bumper (m) + /// @param front_body_width_m Front-body width (m); <= 0 disables the front footprint + /// @param rear_joint_to_bumper_m Rear-body length from joint to rear bumper (m) + /// @param rear_body_width_m Rear-body width (m); <= 0 disables the rear footprint + ArticulatedProjector( + ArticulatedModel model, + double min_articulation_angle_rad, + double max_articulation_angle_rad, + double front_joint_to_bumper_m = 0.0, + double front_body_width_m = 0.0, + double rear_joint_to_bumper_m = 0.0, + double rear_body_width_m = 0.0) : model_(model) , min_articulation_angle_rad_(min_articulation_angle_rad) , max_articulation_angle_rad_(max_articulation_angle_rad) + , front_joint_to_bumper_m_(front_joint_to_bumper_m) + , front_body_width_m_(front_body_width_m) + , rear_joint_to_bumper_m_(rear_joint_to_bumper_m) + , rear_body_width_m_(rear_body_width_m) {} ~ArticulatedProjector() = default; @@ -105,10 +129,41 @@ class ArticulatedProjector return max_articulation_angle_rad_; } + double get_front_joint_to_bumper_m() const + { + return front_joint_to_bumper_m_; + } + + double get_front_body_width_m() const + { + return front_body_width_m_; + } + + double get_rear_joint_to_bumper_m() const + { + return rear_joint_to_bumper_m_; + } + + double get_rear_body_width_m() const + { + return rear_body_width_m_; + } + private: + /// @brief Fill state.front_footprint / state.rear_footprint (world frame) from the rear-segment + /// pose and articulation angle already populated on `state`. A body with width <= 0 yields an + /// empty footprint. + /// TODO: (zeerek) surface footprint-invalid to the user (e.g. a validity flag / status enum on + /// the projected state) instead of silently emitting an empty Footprint. + void fillFootprints(ArticulatedProjectedState & state) const; + ArticulatedModel model_; double min_articulation_angle_rad_; double max_articulation_angle_rad_; + double front_joint_to_bumper_m_; + double front_body_width_m_; + double rear_joint_to_bumper_m_; + double rear_body_width_m_; }; } // namespace polymath::kinematics diff --git a/include/polymath_kinematics/bicycle_projector.hpp b/include/polymath_kinematics/bicycle_projector.hpp index e3c4175..e749087 100644 --- a/include/polymath_kinematics/bicycle_projector.hpp +++ b/include/polymath_kinematics/bicycle_projector.hpp @@ -40,6 +40,7 @@ struct BicycleProjectedState double linear_velocity_m_s; ///< Commanded linear velocity in m/s double angular_velocity_rad_s; ///< Body angular velocity used for theta integration BicycleSteeringState steering_state; ///< Full kinematic snapshot (wheel speeds + turning radius) + Footprint footprint; ///< World-frame body polygon; empty if no footprint dims set }; /// @brief Forward-projection wrapper around BicycleModel that ramps steering toward a target at @@ -47,14 +48,32 @@ struct BicycleProjectedState class BicycleProjector { public: - /// @brief Construct a projector with steering-angle limits. + /// @brief Construct a projector with steering-angle limits and (optionally) footprint dims. + /// + /// Footprint dimensions describe the vehicle body relative to the pose reference and are owned + /// by the projector (the kinematic model stays dimension-free). The body is a rectangle + /// spanning [-rear_overhang_m, front_overhang_m] along the heading and `body_width_m` across. + /// A body width <= 0 means "unset/invalid": the footprint is emitted empty and projection + /// proceeds normally (never throws). /// @param model Bicycle kinematics model (stored by value) /// @param min_steering_angle_rad Minimum allowed steering angle (typically negative) /// @param max_steering_angle_rad Maximum allowed steering angle (typically positive) - BicycleProjector(BicycleModel model, double min_steering_angle_rad, double max_steering_angle_rad) + /// @param front_overhang_m Distance from the pose reference forward to the front bumper (m) + /// @param rear_overhang_m Distance from the pose reference back to the rear bumper (m) + /// @param body_width_m Body width (m); <= 0 disables the footprint + BicycleProjector( + BicycleModel model, + double min_steering_angle_rad, + double max_steering_angle_rad, + double front_overhang_m = 0.0, + double rear_overhang_m = 0.0, + double body_width_m = 0.0) : model_(model) , min_steering_angle_rad_(min_steering_angle_rad) , max_steering_angle_rad_(max_steering_angle_rad) + , front_overhang_m_(front_overhang_m) + , rear_overhang_m_(rear_overhang_m) + , body_width_m_(body_width_m) {} ~BicycleProjector() = default; @@ -106,10 +125,34 @@ class BicycleProjector return max_steering_angle_rad_; } + double get_front_overhang_m() const + { + return front_overhang_m_; + } + + double get_rear_overhang_m() const + { + return rear_overhang_m_; + } + + double get_body_width_m() const + { + return body_width_m_; + } + private: + /// @brief Fill state.footprint (world frame) from the pose already populated on `state`. + /// Emits an empty footprint when body_width_m_ <= 0. + /// TODO: (zeerek) surface footprint-invalid to the user (e.g. a validity flag / status enum on + /// the projected state) instead of silently emitting an empty Footprint. + void fillFootprint(BicycleProjectedState & state) const; + BicycleModel model_; double min_steering_angle_rad_; double max_steering_angle_rad_; + double front_overhang_m_; + double rear_overhang_m_; + double body_width_m_; }; } // namespace polymath::kinematics diff --git a/include/polymath_kinematics/differential_drive_projector.hpp b/include/polymath_kinematics/differential_drive_projector.hpp index f7a711f..7bfb1a7 100644 --- a/include/polymath_kinematics/differential_drive_projector.hpp +++ b/include/polymath_kinematics/differential_drive_projector.hpp @@ -31,6 +31,7 @@ struct DifferentialDriveProjectedState double linear_velocity_m_s; ///< Post-ramp body linear velocity in m/s double angular_velocity_rad_s; ///< Post-ramp body angular velocity in rad/s DifferentialDriveWheelVelocities wheel_velocities; ///< Wheel speeds derived from the body command + Footprint footprint; ///< World-frame body polygon; empty if no footprint dims set }; /// @brief Forward-projection wrapper around DifferentialDriveModel that ramps the body command @@ -45,17 +46,32 @@ class DifferentialDriveProjector /// @param max_linear_velocity_m_s Maximum allowed linear velocity /// @param min_angular_velocity_rad_s Minimum allowed angular velocity (typically negative) /// @param max_angular_velocity_rad_s Maximum allowed angular velocity + /// + /// Footprint dimensions describe the vehicle body relative to the pose reference and are owned + /// by the projector (the kinematic model stays dimension-free). The body is a rectangle + /// spanning [-rear_overhang_m, front_overhang_m] along the heading and `body_width_m` across. + /// A body width <= 0 means "unset/invalid": the footprint is emitted empty and projection + /// proceeds normally (never throws). + /// @param front_overhang_m Distance from the pose reference forward to the front bumper (m) + /// @param rear_overhang_m Distance from the pose reference back to the rear bumper (m) + /// @param body_width_m Body width (m); <= 0 disables the footprint DifferentialDriveProjector( DifferentialDriveModel model, double min_linear_velocity_m_s, double max_linear_velocity_m_s, double min_angular_velocity_rad_s, - double max_angular_velocity_rad_s) + double max_angular_velocity_rad_s, + double front_overhang_m = 0.0, + double rear_overhang_m = 0.0, + double body_width_m = 0.0) : model_(model) , min_linear_velocity_m_s_(min_linear_velocity_m_s) , max_linear_velocity_m_s_(max_linear_velocity_m_s) , min_angular_velocity_rad_s_(min_angular_velocity_rad_s) , max_angular_velocity_rad_s_(max_angular_velocity_rad_s) + , front_overhang_m_(front_overhang_m) + , rear_overhang_m_(rear_overhang_m) + , body_width_m_(body_width_m) {} ~DifferentialDriveProjector() = default; @@ -122,12 +138,36 @@ class DifferentialDriveProjector return max_angular_velocity_rad_s_; } + double get_front_overhang_m() const + { + return front_overhang_m_; + } + + double get_rear_overhang_m() const + { + return rear_overhang_m_; + } + + double get_body_width_m() const + { + return body_width_m_; + } + private: + /// @brief Fill state.footprint (world frame) from the pose already populated on `state`. + /// Emits an empty footprint when body_width_m_ <= 0. + /// TODO: (zeerek) surface footprint-invalid to the user (e.g. a validity flag / status enum on + /// the projected state) instead of silently emitting an empty Footprint. + void fillFootprint(DifferentialDriveProjectedState & state) const; + DifferentialDriveModel model_; double min_linear_velocity_m_s_; double max_linear_velocity_m_s_; double min_angular_velocity_rad_s_; double max_angular_velocity_rad_s_; + double front_overhang_m_; + double rear_overhang_m_; + double body_width_m_; }; } // namespace polymath::kinematics diff --git a/include/polymath_kinematics/pose2d.hpp b/include/polymath_kinematics/pose2d.hpp index 46dabf4..15841a9 100644 --- a/include/polymath_kinematics/pose2d.hpp +++ b/include/polymath_kinematics/pose2d.hpp @@ -16,6 +16,7 @@ #define POLYMATH_KINEMATICS__POSE2D_HPP__ #include +#include namespace polymath::kinematics { @@ -28,15 +29,24 @@ struct Pose2D double theta; }; -/// @brief Wrap an angle to [-pi, pi] +/// @brief A planar point (world frame, metres). +struct Point2D +{ + double x; + double y; +}; + +/// @brief A vehicle footprint as a world-frame polygon: 4 corners, counter-clockwise, +/// NOT closed (the first corner is not repeated; consumers close it if needed). An empty +/// footprint means "not computed / invalid" (e.g. when no footprint dimensions were given). +using Footprint = std::vector; + +/// @brief Wrap an angle to [-pi, pi]. +/// std::remainder(angle, 2*pi) maps to the [-pi, pi] interval directly (note: exactly +pi +/// maps to +pi, where the old fmod-based implementation returned -pi; both denote the same angle). inline double normalizeAngle(double angle) { - constexpr double kTwoPi = 2.0 * M_PI; - double a = std::fmod(angle + M_PI, kTwoPi); - if (a < 0.0) { - a += kTwoPi; - } - return a - M_PI; + return std::remainder(angle, 2.0 * M_PI); } } // namespace polymath::kinematics diff --git a/polymath_kinematics/__init__.py b/polymath_kinematics/__init__.py index 5cc6ed6..0d021ee 100644 --- a/polymath_kinematics/__init__.py +++ b/polymath_kinematics/__init__.py @@ -30,6 +30,7 @@ DifferentialDriveProjectedState, DifferentialDriveProjector, DifferentialDriveWheelVelocities, + Point2D, Pose2D, ) @@ -49,6 +50,7 @@ 'DifferentialDriveProjectedState', 'DifferentialDriveProjector', 'DifferentialDriveWheelVelocities', + 'Point2D', 'Pose2D', 'Twist2D', 'normalize_angle', diff --git a/polymath_kinematics/explorer/config.py b/polymath_kinematics/explorer/config.py index 91b2058..e5b2905 100644 --- a/polymath_kinematics/explorer/config.py +++ b/polymath_kinematics/explorer/config.py @@ -17,12 +17,26 @@ from dataclasses import dataclass -# Vehicle footprint positioning (fraction of length) +# Vehicle footprint positioning (fraction of length) — used only by the fallback footprint +# drawing path when a projector-computed footprint is not available. REAR_AXLE_POSITION = 0.3 # How far back the rear axle is from center FRONT_OVERHANG = 0.7 # How far forward the front extends from center HEADING_ARROW_LENGTH = 0.4 # Arrow length as fraction of vehicle length HEADING_ARROW_WIDTH = 0.3 # Arrow head width as fraction of vehicle width +# Explicit default footprint dimensions (metres) passed to the projectors so projection emits a +# real vehicle outline. These are the projector-owned body dimensions (independent of axle/track +# geometry); the explorer UI seeds its inputs from them. +# Single-body (differential / bicycle): overhangs measured from the pose reference. +DEFAULT_FRONT_OVERHANG_M = 1.0 +DEFAULT_REAR_OVERHANG_M = 0.5 +DEFAULT_BODY_WIDTH_M = 0.8 +# Articulated: each body measured from the articulation joint. +DEFAULT_FRONT_JOINT_TO_BUMPER_M = 2.2 +DEFAULT_FRONT_BODY_WIDTH_M = 2.0 +DEFAULT_REAR_JOINT_TO_BUMPER_M = 2.0 +DEFAULT_REAR_BODY_WIDTH_M = 2.0 + @dataclass class LatticeConfig: diff --git a/polymath_kinematics/explorer/plotting.py b/polymath_kinematics/explorer/plotting.py index e93a6fc..c62fccd 100644 --- a/polymath_kinematics/explorer/plotting.py +++ b/polymath_kinematics/explorer/plotting.py @@ -48,31 +48,36 @@ def plot_vehicle_footprint( steering_angle: float | None = None, wheel_length_frac: float = 0.18, wheel_width_frac: float = 0.08, + body_corners: np.ndarray | None = None, ) -> None: """Draw a vehicle footprint (rectangle) at a given pose. - The vehicle is centered on the rear axle position, with REAR_AXLE_POSITION - controlling how far back the rear is, and FRONT_OVERHANG controlling - how far forward the front extends. + When ``body_corners`` is provided (an (N, 2) array of world-frame polygon corners, as produced + by the C++ projector's ``footprint``), the body outline is drawn directly from it — the single + source of truth. Otherwise the body falls back to a rectangle derived from ``length``/``width`` + with REAR_AXLE_POSITION / FRONT_OVERHANG controlling the axle placement. When ``steering_angle`` is provided (radians), two front-wheel indicators are drawn at the front-axle position, rotated by the steering angle in the body frame. Useful for bicycle/Ackermann visualisation; pass ``None`` for models that don't have steered wheels (e.g. differential drive). """ - corners_local = np.array([ - [-length * REAR_AXLE_POSITION, -width / 2], - [length * FRONT_OVERHANG, -width / 2], - [length * FRONT_OVERHANG, width / 2], - [-length * REAR_AXLE_POSITION, width / 2], - [-length * REAR_AXLE_POSITION, -width / 2], - ]) - cos_theta, sin_theta = np.cos(theta), np.sin(theta) rotation_matrix = np.array([[cos_theta, -sin_theta], [sin_theta, cos_theta]]) - corners_world = (rotation_matrix @ corners_local.T).T + np.array([x, y]) - ax.fill(corners_world[:, 0], corners_world[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5) + if body_corners is not None: + body = np.asarray(body_corners, dtype=float) + ax.fill(body[:, 0], body[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5) + else: + corners_local = np.array([ + [-length * REAR_AXLE_POSITION, -width / 2], + [length * FRONT_OVERHANG, -width / 2], + [length * FRONT_OVERHANG, width / 2], + [-length * REAR_AXLE_POSITION, width / 2], + [-length * REAR_AXLE_POSITION, -width / 2], + ]) + corners_world = (rotation_matrix @ corners_local.T).T + np.array([x, y]) + ax.fill(corners_world[:, 0], corners_world[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5) arrow_length = length * HEADING_ARROW_LENGTH ax.arrow( @@ -129,12 +134,21 @@ def plot_articulated_footprint( rear_width: float, color: str = 'blue', alpha: float = 0.5, + front_corners: np.ndarray | None = None, + rear_corners: np.ndarray | None = None, ) -> None: """Draw an articulated vehicle footprint (two connected rectangles) at a given pose. + When ``front_corners`` / ``rear_corners`` are provided (each an (N, 2) array of world-frame + polygon corners, as produced by the C++ projector's ``front_footprint`` / ``rear_footprint``), + the two bodies are drawn directly from them — the single source of truth, with the correct + rear-segment anchoring. The joint marker and heading arrow are then derived from the corners. + Otherwise the bodies fall back to being computed from the axle lengths and track widths, + anchored at the front axle. + Args: - x_front_axle, y_front_axle: Position of the front axle center - theta: Heading of the front body + x_front_axle, y_front_axle: Position of the front axle center (fallback path) + theta: Heading of the front body (fallback path) articulation_angle: Angle between front and rear bodies (positive = left turn) front_length: Distance from articulation joint to front axle (L_f) rear_length: Distance from articulation joint to rear axle (L_r) @@ -142,6 +156,39 @@ def plot_articulated_footprint( color: Fill color alpha: Transparency """ + if front_corners is not None or rear_corners is not None: + joint_point = None + if front_corners is not None: + fc = np.asarray(front_corners, dtype=float) + ax.fill(fc[:, 0], fc[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5) + # C++ front body corners are [joint, front_bumper, front_bumper, joint]; joint is the + # midpoint of the two joint-side corners (indices 0 and 3). + joint_point = (fc[0] + fc[3]) / 2.0 + front_bumper_mid = (fc[1] + fc[2]) / 2.0 + if rear_corners is not None: + rc = np.asarray(rear_corners, dtype=float) + ax.fill(rc[:, 0], rc[:, 1], color=color, alpha=alpha * 0.8, edgecolor=color, linewidth=0.5) + if joint_point is None: + # Rear body corners are [rear_bumper, joint, joint, rear_bumper]; joint = mid(1, 2). + joint_point = (rc[1] + rc[2]) / 2.0 + if joint_point is not None: + ax.plot(joint_point[0], joint_point[1], 'o', color=color, markersize=4, alpha=min(1.0, alpha + 0.3)) + if front_corners is not None: + heading_vec = front_bumper_mid - joint_point + ax.arrow( + joint_point[0], + joint_point[1], + heading_vec[0], + heading_vec[1], + head_width=front_width * 0.2, + head_length=front_length * 0.08 if front_length else 0.05, + fc=color, + ec=color, + alpha=min(1.0, alpha + 0.3), + length_includes_head=True, + ) + return + # Compute articulation joint position joint_x = x_front_axle - front_length * np.cos(theta) joint_y = y_front_axle - front_length * np.sin(theta) @@ -313,6 +360,18 @@ def plot_trajectory_with_footprints( sample_articulation_angle = float(articulated.articulation_angle_series[footprint_index]) else: sample_articulation_angle = articulated.articulation_angle + # Prefer the projector-computed footprints (single source of truth); fall back to + # axle-length geometry when they are unavailable. + front_corners = ( + articulated.front_footprint_series[footprint_index] + if articulated.front_footprint_series is not None + else None + ) + rear_corners = ( + articulated.rear_footprint_series[footprint_index] + if articulated.rear_footprint_series is not None + else None + ) plot_articulated_footprint( ax, trajectory.x[footprint_index], @@ -325,6 +384,8 @@ def plot_trajectory_with_footprints( rear_width=model_params['rear_width'], color=color, alpha=footprint_alpha, + front_corners=front_corners, + rear_corners=rear_corners, ) elif model_type == 'Bicycle': bicycle: BicycleTrajectory = trajectory # type: ignore[assignment] @@ -332,6 +393,9 @@ def plot_trajectory_with_footprints( sample_steering_angle = float(bicycle.steering_angle_series[footprint_index]) else: sample_steering_angle = bicycle.steering_angle + body_corners = ( + bicycle.footprint_series[footprint_index] if bicycle.footprint_series is not None else None + ) plot_vehicle_footprint( ax, trajectory.x[footprint_index], @@ -342,8 +406,14 @@ def plot_trajectory_with_footprints( color=color, alpha=footprint_alpha, steering_angle=sample_steering_angle, + body_corners=body_corners, ) else: # Differential Drive — no steered wheels. + body_corners = ( + trajectory.footprint_series[footprint_index] + if trajectory.footprint_series is not None + else None + ) plot_vehicle_footprint( ax, trajectory.x[footprint_index], @@ -353,6 +423,7 @@ def plot_trajectory_with_footprints( width=model_params['width'], color=color, alpha=footprint_alpha, + body_corners=body_corners, ) ax.set_xlabel('X (m)') diff --git a/polymath_kinematics/explorer/simulation.py b/polymath_kinematics/explorer/simulation.py index 8ff09d4..5a1da19 100644 --- a/polymath_kinematics/explorer/simulation.py +++ b/polymath_kinematics/explorer/simulation.py @@ -146,6 +146,23 @@ def _states_to_arrays(states): 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, 4, 2) array. + + ``attr`` is the projected-state attribute holding the footprint (``footprint`` for single-body + models, ``front_footprint`` / ``rear_footprint`` for articulated). Returns ``None`` if any + sample has an empty footprint (i.e. the projector was given no footprint dimensions), 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 generate_lattice_differential( wheel_radius: float, track_width: float, @@ -201,6 +218,9 @@ def generate_lattice_bicycle( min_steering_angle_rad: float | None = None, max_steering_angle_rad: float | None = None, steering_rate_rad_s: float = 0.0, + front_overhang_m: float = 0.0, + rear_overhang_m: float = 0.0, + body_width_m: float = 0.0, ) -> list[BicycleTrajectory]: """Generate trajectory lattice by sweeping steering angles. @@ -223,7 +243,9 @@ def generate_lattice_bicycle( 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) + projector = BicycleProjector( + model, min_angle, max_angle, front_overhang_m, rear_overhang_m, body_width_m + ) trajectories: list[BicycleTrajectory] = [] for drive_velocity in drive_velocities: @@ -250,6 +272,7 @@ def generate_lattice_bicycle( drive_velocity=drive_velocity, steering_angle=steering_angle, turning_radius=states[0].steering_state.turning_radius_m, + footprint_series=_footprint_series(states, 'footprint'), ) ) @@ -271,6 +294,10 @@ def generate_lattice_articulated( min_articulation_angle_rad: float | None = None, max_articulation_angle_rad: float | None = None, articulation_rate_rad_s: float = 0.0, + front_joint_to_bumper_m: float = 0.0, + front_body_width_m: float = 0.0, + rear_joint_to_bumper_m: float = 0.0, + rear_body_width_m: float = 0.0, ) -> list[ArticulatedTrajectory]: """Generate trajectory lattice by sweeping articulation angles. @@ -292,7 +319,15 @@ def generate_lattice_articulated( front_wheel_radius, rear_wheel_radius, ) - projector = ArticulatedProjector(model, min_angle, max_angle) + projector = ArticulatedProjector( + model, + min_angle, + max_angle, + front_joint_to_bumper_m, + front_body_width_m, + rear_joint_to_bumper_m, + rear_body_width_m, + ) trajectories: list[ArticulatedTrajectory] = [] for drive_velocity in drive_velocities: @@ -319,6 +354,8 @@ def generate_lattice_articulated( 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'), ) ) @@ -345,6 +382,9 @@ def single_bicycle_trajectory( time_step: float = 0.02, min_steering_angle_rad: float | None = None, max_steering_angle_rad: float | None = None, + front_overhang_m: float = 0.0, + rear_overhang_m: float = 0.0, + body_width_m: float = 0.0, ) -> BicycleTrajectory: """Ramp a bicycle steering angle from `initial` toward `target` at `rate` rad/s over `duration` seconds, returning the resulting trajectory. @@ -358,7 +398,9 @@ def single_bicycle_trajectory( 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) + projector = BicycleProjector( + model, min_steering_angle_rad, max_steering_angle_rad, front_overhang_m, rear_overhang_m, body_width_m + ) states = projector.project( horizon_s=duration, dt_s=time_step, @@ -381,6 +423,7 @@ def single_bicycle_trajectory( 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'), ) @@ -399,6 +442,10 @@ def single_articulated_trajectory( time_step: float = 0.02, min_articulation_angle_rad: float | None = None, max_articulation_angle_rad: float | None = None, + front_joint_to_bumper_m: float = 0.0, + front_body_width_m: float = 0.0, + rear_joint_to_bumper_m: float = 0.0, + rear_body_width_m: float = 0.0, ) -> ArticulatedTrajectory: """Ramp an articulation angle from `initial` toward `target` at `rate` rad/s.""" if min_articulation_angle_rad is None: @@ -414,7 +461,15 @@ def single_articulated_trajectory( front_wheel_radius, rear_wheel_radius, ) - projector = ArticulatedProjector(model, min_articulation_angle_rad, max_articulation_angle_rad) + projector = ArticulatedProjector( + model, + min_articulation_angle_rad, + max_articulation_angle_rad, + front_joint_to_bumper_m, + front_body_width_m, + rear_joint_to_bumper_m, + rear_body_width_m, + ) states = projector.project( horizon_s=duration, dt_s=time_step, @@ -437,6 +492,8 @@ def single_articulated_trajectory( 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'), ) @@ -455,6 +512,9 @@ def single_differential_trajectory( max_linear_velocity: float | None = None, min_angular_velocity: float | None = None, max_angular_velocity: float | None = None, + front_overhang_m: float = 0.0, + rear_overhang_m: float = 0.0, + body_width_m: float = 0.0, ) -> DifferentialTrajectory: """Ramp diff-drive body command (v, omega) from initial toward target at the given accelerations, integrating pose forward over `duration`. @@ -475,6 +535,9 @@ def single_differential_trajectory( max_linear_velocity, min_angular_velocity, max_angular_velocity, + front_overhang_m, + rear_overhang_m, + body_width_m, ) states = projector.project( horizon_s=duration, @@ -499,4 +562,5 @@ def single_differential_trajectory( 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 index 205e139..1c9ad1f 100644 --- a/polymath_kinematics/explorer/types.py +++ b/polymath_kinematics/explorer/types.py @@ -23,7 +23,13 @@ @dataclass class Trajectory: - """Base trajectory data with common fields.""" + """Base trajectory data with common fields. + + ``footprint_series``, when present, is the per-sample single-body footprint produced by the + C++ projector: an ``(N, 4, 2)`` array of world-frame polygon corners (N samples, 4 corners, + xy). It is ``None`` when the projector was given no footprint dimensions (single-body models + only; the articulated model uses the front/rear series on ``ArticulatedTrajectory``). + """ time: np.ndarray x: np.ndarray @@ -31,6 +37,7 @@ class Trajectory: theta: np.ndarray linear_velocity: float angular_velocity: float + footprint_series: Optional[np.ndarray] = None def __len__(self) -> int: return len(self.time) @@ -75,6 +82,10 @@ class ArticulatedTrajectory(Trajectory): 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, 4, 2) array of + # world-frame polygon corners. None when the projector was given no footprint dimensions. + front_footprint_series: Optional[np.ndarray] = None + rear_footprint_series: Optional[np.ndarray] = None # Type alias for any trajectory type diff --git a/polymath_kinematics/kinematic_explorer_app.py b/polymath_kinematics/kinematic_explorer_app.py index 89dce2d..1457722 100644 --- a/polymath_kinematics/kinematic_explorer_app.py +++ b/polymath_kinematics/kinematic_explorer_app.py @@ -46,6 +46,7 @@ single_differential_trajectory, trajectories_to_dataframe, ) +from polymath_kinematics.explorer.config import FRONT_OVERHANG, REAR_AXLE_POSITION from polymath_kinematics.explorer.plotting import _get_traj_attr @@ -76,7 +77,17 @@ def _cached_lattice_bicycle( method: str, ) -> list[BicycleTrajectory]: return generate_lattice_bicycle( - wheelbase, track_width, wheel_radius, drive_velocities, steering_angles, duration, time_step, method + wheelbase, + track_width, + wheel_radius, + drive_velocities, + steering_angles, + duration, + time_step, + method, + front_overhang_m=wheelbase * FRONT_OVERHANG, + rear_overhang_m=wheelbase * REAR_AXLE_POSITION, + body_width_m=track_width, ) @@ -106,6 +117,10 @@ def _cached_lattice_articulated( duration, time_step, method, + front_joint_to_bumper_m=articulation_to_front, + front_body_width_m=front_track, + rear_joint_to_bumper_m=articulation_to_rear, + rear_body_width_m=rear_track, ) @@ -131,6 +146,9 @@ def _cached_single_bicycle( drive_velocity=drive_velocity, duration=duration, time_step=time_step, + front_overhang_m=wheelbase * FRONT_OVERHANG, + rear_overhang_m=wheelbase * REAR_AXLE_POSITION, + body_width_m=track_width, ) @@ -162,6 +180,10 @@ def _cached_single_articulated( drive_velocity=drive_velocity, duration=duration, time_step=time_step, + front_joint_to_bumper_m=articulation_to_front, + front_body_width_m=front_track, + rear_joint_to_bumper_m=articulation_to_rear, + rear_body_width_m=rear_track, ) @@ -178,6 +200,8 @@ def _cached_single_differential( duration: float, time_step: float, ) -> DifferentialTrajectory: + # Vehicle length is track_width * 1.5 to match the plotted footprint box. + length = track_width * 1.5 return single_differential_trajectory( wheel_radius, track_width, @@ -189,6 +213,9 @@ def _cached_single_differential( angular_acceleration=angular_acceleration, duration=duration, time_step=time_step, + front_overhang_m=length * FRONT_OVERHANG, + rear_overhang_m=length * REAR_AXLE_POSITION, + body_width_m=track_width, ) diff --git a/src/articulated_projector.cpp b/src/articulated_projector.cpp index 67d6cfd..3737a46 100644 --- a/src/articulated_projector.cpp +++ b/src/articulated_projector.cpp @@ -58,8 +58,47 @@ ArticulatedProjectedState ArticulatedProjector::step( current_pose.y + linear_velocity_m_s * std::sin(current_pose.theta) * dt_s, normalizeAngle(current_pose.theta + angular_velocity_rad_s * dt_s)}; - return ArticulatedProjectedState{ - dt_s, new_pose, new_articulation_angle_rad, linear_velocity_m_s, angular_velocity_rad_s, inner}; + ArticulatedProjectedState state{ + dt_s, new_pose, new_articulation_angle_rad, linear_velocity_m_s, angular_velocity_rad_s, inner, {}, {}}; + fillFootprints(state); + return state; +} + +void ArticulatedProjector::fillFootprints(ArticulatedProjectedState & state) const +{ + // Pose is the REAR axle; pose.theta is the rear-body heading. The articulation joint sits + // `articulation_to_rear_axle` ahead of the rear axle along the rear-body heading, and the + // front body is rotated relative to the rear by the articulation angle (theta_front = + // theta_rear + gamma; see derivations/articulated_model.md). + const double rear_theta = state.pose.theta; + const double front_theta = rear_theta + state.articulation_angle_rad; + const double rear_axle_to_joint = model_.get_articulation_to_rear_axle_m(); + const double joint_x = state.pose.x + rear_axle_to_joint * std::cos(rear_theta); + const double joint_y = state.pose.y + rear_axle_to_joint * std::sin(rear_theta); + + // Rectangle corners (CCW, open) given body-frame x extent [x_lo, x_hi] and width, placed at the + // joint and rotated by `heading`. Returns empty when the width is unset/invalid (<= 0). + auto make_body = [joint_x, joint_y](double heading, double x_lo, double x_hi, double width) { + Footprint corners; + if (width <= 0.0) { + return corners; + } + const double half_w = width / 2.0; + const double cos_h = std::cos(heading); + const double sin_h = std::sin(heading); + const double local_x[4] = {x_lo, x_hi, x_hi, x_lo}; + const double local_y[4] = {-half_w, -half_w, half_w, half_w}; + corners.reserve(4); + for (int i = 0; i < 4; ++i) { + corners.push_back(Point2D{ + joint_x + cos_h * local_x[i] - sin_h * local_y[i], + joint_y + sin_h * local_x[i] + cos_h * local_y[i]}); + } + return corners; + }; + + state.front_footprint = make_body(front_theta, 0.0, front_joint_to_bumper_m_, front_body_width_m_); + state.rear_footprint = make_body(rear_theta, -rear_joint_to_bumper_m_, 0.0, rear_body_width_m_); } std::vector ArticulatedProjector::project( @@ -81,8 +120,10 @@ std::vector ArticulatedProjector::project( model_.articulationToAxleVelocities(linear_velocity_m_s, initial_articulation_angle_rad); double initial_omega = initial_axle.rear_axle_turning_velocity_rad_s; ArticulatedVehicleState initial_inner = model_.bodyVelocityToVehicleState(linear_velocity_m_s, initial_omega); - trajectory.push_back(ArticulatedProjectedState{ - 0.0, initial_pose, initial_articulation_angle_rad, linear_velocity_m_s, initial_omega, initial_inner}); + ArticulatedProjectedState initial_state{ + 0.0, initial_pose, initial_articulation_angle_rad, linear_velocity_m_s, initial_omega, initial_inner, {}, {}}; + fillFootprints(initial_state); + trajectory.push_back(initial_state); std::size_t n_steps = static_cast(std::ceil(horizon_s / dt_s)); trajectory.reserve(n_steps + 1); diff --git a/src/bicycle_projector.cpp b/src/bicycle_projector.cpp index 19a59fd..82d6b4e 100644 --- a/src/bicycle_projector.cpp +++ b/src/bicycle_projector.cpp @@ -53,8 +53,31 @@ BicycleProjectedState BicycleProjector::step( current_pose.y + linear_velocity_m_s * std::sin(current_pose.theta) * dt_s, normalizeAngle(current_pose.theta + body_vel.angular_velocity_rad_s * dt_s)}; - return BicycleProjectedState{ - dt_s, new_pose, new_steering_angle_rad, linear_velocity_m_s, body_vel.angular_velocity_rad_s, inner}; + BicycleProjectedState state{ + dt_s, new_pose, new_steering_angle_rad, linear_velocity_m_s, body_vel.angular_velocity_rad_s, inner, {}}; + fillFootprint(state); + return state; +} + +void BicycleProjector::fillFootprint(BicycleProjectedState & state) const +{ + Footprint & corners = state.footprint; + corners.clear(); + if (body_width_m_ <= 0.0) { + return; + } + const double half_w = body_width_m_ / 2.0; + const double cos_t = std::cos(state.pose.theta); + const double sin_t = std::sin(state.pose.theta); + // CCW, open: rear-right, front-right, front-left, rear-left (body frame), then to world. + const double local_x[4] = {-rear_overhang_m_, front_overhang_m_, front_overhang_m_, -rear_overhang_m_}; + const double local_y[4] = {-half_w, -half_w, half_w, half_w}; + corners.reserve(4); + for (int i = 0; i < 4; ++i) { + corners.push_back(Point2D{ + state.pose.x + cos_t * local_x[i] - sin_t * local_y[i], + state.pose.y + sin_t * local_x[i] + cos_t * local_y[i]}); + } } std::vector BicycleProjector::project( @@ -76,13 +99,16 @@ std::vector BicycleProjector::project( BicycleBodyVelocity initial_body = model_.steeringToBodyVelocity(linear_velocity_m_s, initial_steering_angle_rad); BicycleSteeringState initial_inner = model_.bodyVelocityToSteering(linear_velocity_m_s, initial_body.angular_velocity_rad_s); - trajectory.push_back(BicycleProjectedState{ + BicycleProjectedState initial_state{ 0.0, initial_pose, initial_steering_angle_rad, linear_velocity_m_s, initial_body.angular_velocity_rad_s, - initial_inner}); + initial_inner, + {}}; + fillFootprint(initial_state); + trajectory.push_back(initial_state); std::size_t n_steps = static_cast(std::ceil(horizon_s / dt_s)); trajectory.reserve(n_steps + 1); diff --git a/src/differential_drive_projector.cpp b/src/differential_drive_projector.cpp index 4abd588..c57ba10 100644 --- a/src/differential_drive_projector.cpp +++ b/src/differential_drive_projector.cpp @@ -66,7 +66,31 @@ DifferentialDriveProjectedState DifferentialDriveProjector::step( current_pose.y + new_linear_velocity_m_s * std::sin(current_pose.theta) * dt_s, normalizeAngle(current_pose.theta + new_angular_velocity_rad_s * dt_s)}; - return DifferentialDriveProjectedState{dt_s, new_pose, new_linear_velocity_m_s, new_angular_velocity_rad_s, wheels}; + DifferentialDriveProjectedState state{ + dt_s, new_pose, new_linear_velocity_m_s, new_angular_velocity_rad_s, wheels, {}}; + fillFootprint(state); + return state; +} + +void DifferentialDriveProjector::fillFootprint(DifferentialDriveProjectedState & state) const +{ + Footprint & corners = state.footprint; + corners.clear(); + if (body_width_m_ <= 0.0) { + return; + } + const double half_w = body_width_m_ / 2.0; + const double cos_t = std::cos(state.pose.theta); + const double sin_t = std::sin(state.pose.theta); + // CCW, open: rear-right, front-right, front-left, rear-left (body frame), then to world. + const double local_x[4] = {-rear_overhang_m_, front_overhang_m_, front_overhang_m_, -rear_overhang_m_}; + const double local_y[4] = {-half_w, -half_w, half_w, half_w}; + corners.reserve(4); + for (int i = 0; i < 4; ++i) { + corners.push_back(Point2D{ + state.pose.x + cos_t * local_x[i] - sin_t * local_y[i], + state.pose.y + sin_t * local_x[i] + cos_t * local_y[i]}); + } } std::vector DifferentialDriveProjector::project( @@ -87,8 +111,10 @@ std::vector DifferentialDriveProjector::project DifferentialDriveWheelVelocities initial_wheels = model_.bodyVelocityToWheelVelocities(initial_linear_velocity_m_s, initial_angular_velocity_rad_s); - trajectory.push_back(DifferentialDriveProjectedState{ - 0.0, initial_pose, initial_linear_velocity_m_s, initial_angular_velocity_rad_s, initial_wheels}); + DifferentialDriveProjectedState initial_state{ + 0.0, initial_pose, initial_linear_velocity_m_s, initial_angular_velocity_rad_s, initial_wheels, {}}; + fillFootprint(initial_state); + trajectory.push_back(initial_state); std::size_t n_steps = static_cast(std::ceil(horizon_s / dt_s)); trajectory.reserve(n_steps + 1); diff --git a/src/kinematics_pybind.cpp b/src/kinematics_pybind.cpp index 900b96a..c0caa53 100644 --- a/src/kinematics_pybind.cpp +++ b/src/kinematics_pybind.cpp @@ -150,6 +150,15 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) .def_readwrite("y", &Pose2D::y) .def_readwrite("theta", &Pose2D::theta); + // Shared Point2D (footprint polygon vertex). Footprint = std::vector binds to a + // Python list of Point2D via pybind11/stl.h. + py::class_(m, "Point2D") + .def(py::init<>()) + .def( + py::init([](double x, double y) { return Point2D{x, y}; }), py::arg("x") = 0.0, py::arg("y") = 0.0) + .def_readwrite("x", &Point2D::x) + .def_readwrite("y", &Point2D::y); + // Bicycle projector bindings py::class_(m, "BicycleProjectedState") .def(py::init<>()) @@ -158,14 +167,18 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) .def_readwrite("steering_angle_rad", &BicycleProjectedState::steering_angle_rad) .def_readwrite("linear_velocity_m_s", &BicycleProjectedState::linear_velocity_m_s) .def_readwrite("angular_velocity_rad_s", &BicycleProjectedState::angular_velocity_rad_s) - .def_readwrite("steering_state", &BicycleProjectedState::steering_state); + .def_readwrite("steering_state", &BicycleProjectedState::steering_state) + .def_readwrite("footprint", &BicycleProjectedState::footprint); py::class_(m, "BicycleProjector") .def( - py::init(), + py::init(), py::arg("model"), py::arg("min_steering_angle_rad"), - py::arg("max_steering_angle_rad")) + py::arg("max_steering_angle_rad"), + py::arg("front_overhang_m") = 0.0, + py::arg("rear_overhang_m") = 0.0, + py::arg("body_width_m") = 0.0) .def( "step", &BicycleProjector::step, @@ -187,7 +200,10 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) py::arg("linear_velocity_m_s")) .def_property_readonly("model", &BicycleProjector::get_model, py::return_value_policy::reference_internal) .def_property_readonly("min_steering_angle_rad", &BicycleProjector::get_min_steering_angle_rad) - .def_property_readonly("max_steering_angle_rad", &BicycleProjector::get_max_steering_angle_rad); + .def_property_readonly("max_steering_angle_rad", &BicycleProjector::get_max_steering_angle_rad) + .def_property_readonly("front_overhang_m", &BicycleProjector::get_front_overhang_m) + .def_property_readonly("rear_overhang_m", &BicycleProjector::get_rear_overhang_m) + .def_property_readonly("body_width_m", &BicycleProjector::get_body_width_m); // Articulated projector bindings py::class_(m, "ArticulatedProjectedState") @@ -197,14 +213,20 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) .def_readwrite("articulation_angle_rad", &ArticulatedProjectedState::articulation_angle_rad) .def_readwrite("linear_velocity_m_s", &ArticulatedProjectedState::linear_velocity_m_s) .def_readwrite("angular_velocity_rad_s", &ArticulatedProjectedState::angular_velocity_rad_s) - .def_readwrite("vehicle_state", &ArticulatedProjectedState::vehicle_state); + .def_readwrite("vehicle_state", &ArticulatedProjectedState::vehicle_state) + .def_readwrite("front_footprint", &ArticulatedProjectedState::front_footprint) + .def_readwrite("rear_footprint", &ArticulatedProjectedState::rear_footprint); py::class_(m, "ArticulatedProjector") .def( - py::init(), + py::init(), py::arg("model"), py::arg("min_articulation_angle_rad"), - py::arg("max_articulation_angle_rad")) + py::arg("max_articulation_angle_rad"), + py::arg("front_joint_to_bumper_m") = 0.0, + py::arg("front_body_width_m") = 0.0, + py::arg("rear_joint_to_bumper_m") = 0.0, + py::arg("rear_body_width_m") = 0.0) .def( "step", &ArticulatedProjector::step, @@ -226,7 +248,11 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) py::arg("linear_velocity_m_s")) .def_property_readonly("model", &ArticulatedProjector::get_model, py::return_value_policy::reference_internal) .def_property_readonly("min_articulation_angle_rad", &ArticulatedProjector::get_min_articulation_angle_rad) - .def_property_readonly("max_articulation_angle_rad", &ArticulatedProjector::get_max_articulation_angle_rad); + .def_property_readonly("max_articulation_angle_rad", &ArticulatedProjector::get_max_articulation_angle_rad) + .def_property_readonly("front_joint_to_bumper_m", &ArticulatedProjector::get_front_joint_to_bumper_m) + .def_property_readonly("front_body_width_m", &ArticulatedProjector::get_front_body_width_m) + .def_property_readonly("rear_joint_to_bumper_m", &ArticulatedProjector::get_rear_joint_to_bumper_m) + .def_property_readonly("rear_body_width_m", &ArticulatedProjector::get_rear_body_width_m); // Differential drive projector bindings py::class_(m, "DifferentialDriveProjectedState") @@ -235,16 +261,20 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) .def_readwrite("pose", &DifferentialDriveProjectedState::pose) .def_readwrite("linear_velocity_m_s", &DifferentialDriveProjectedState::linear_velocity_m_s) .def_readwrite("angular_velocity_rad_s", &DifferentialDriveProjectedState::angular_velocity_rad_s) - .def_readwrite("wheel_velocities", &DifferentialDriveProjectedState::wheel_velocities); + .def_readwrite("wheel_velocities", &DifferentialDriveProjectedState::wheel_velocities) + .def_readwrite("footprint", &DifferentialDriveProjectedState::footprint); py::class_(m, "DifferentialDriveProjector") .def( - py::init(), + py::init(), py::arg("model"), py::arg("min_linear_velocity_m_s"), py::arg("max_linear_velocity_m_s"), py::arg("min_angular_velocity_rad_s"), - py::arg("max_angular_velocity_rad_s")) + py::arg("max_angular_velocity_rad_s"), + py::arg("front_overhang_m") = 0.0, + py::arg("rear_overhang_m") = 0.0, + py::arg("body_width_m") = 0.0) .def( "step", &DifferentialDriveProjector::step, @@ -272,7 +302,10 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) .def_property_readonly("min_linear_velocity_m_s", &DifferentialDriveProjector::get_min_linear_velocity_m_s) .def_property_readonly("max_linear_velocity_m_s", &DifferentialDriveProjector::get_max_linear_velocity_m_s) .def_property_readonly("min_angular_velocity_rad_s", &DifferentialDriveProjector::get_min_angular_velocity_rad_s) - .def_property_readonly("max_angular_velocity_rad_s", &DifferentialDriveProjector::get_max_angular_velocity_rad_s); + .def_property_readonly("max_angular_velocity_rad_s", &DifferentialDriveProjector::get_max_angular_velocity_rad_s) + .def_property_readonly("front_overhang_m", &DifferentialDriveProjector::get_front_overhang_m) + .def_property_readonly("rear_overhang_m", &DifferentialDriveProjector::get_rear_overhang_m) + .def_property_readonly("body_width_m", &DifferentialDriveProjector::get_body_width_m); } } // namespace polymath::kinematics diff --git a/test/test_articulated_projector.cpp b/test/test_articulated_projector.cpp index 5580a07..72e6d23 100644 --- a/test/test_articulated_projector.cpp +++ b/test/test_articulated_projector.cpp @@ -23,32 +23,32 @@ namespace polymath::kinematics namespace { // stueve-style parameters -constexpr double kFrontArm = 1.66; -constexpr double kRearArm = 1.44; -constexpr double kFrontTrack = 2.0; -constexpr double kRearTrack = 2.0; -constexpr double kFrontWheelRadius = 0.723; -constexpr double kRearWheelRadius = 0.723; -constexpr double kMinAngle = -0.785; -constexpr double kMaxAngle = 0.785; +constexpr double FRONT_ARM = 1.66; +constexpr double REAR_ARM = 1.44; +constexpr double FRONT_TRACK = 2.0; +constexpr double REAR_TRACK = 2.0; +constexpr double FRONT_WHEEL_RADIUS = 0.723; +constexpr double REAR_WHEEL_RADIUS = 0.723; +constexpr double MIN_ANGLE = -0.785; +constexpr double MAX_ANGLE = 0.785; ArticulatedModel make_model() { - return ArticulatedModel(kFrontArm, kRearArm, kFrontTrack, kRearTrack, kFrontWheelRadius, kRearWheelRadius); + return ArticulatedModel(FRONT_ARM, REAR_ARM, FRONT_TRACK, REAR_TRACK, FRONT_WHEEL_RADIUS, REAR_WHEEL_RADIUS); } } // namespace TEST_CASE("ArticulatedProjector construction stores model and limits") { - ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); - CHECK(projector.get_min_articulation_angle_rad() == Approx(kMinAngle)); - CHECK(projector.get_max_articulation_angle_rad() == Approx(kMaxAngle)); - CHECK(projector.get_model().get_articulation_to_front_axle_m() == Approx(kFrontArm)); + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); + CHECK(projector.get_min_articulation_angle_rad() == Approx(MIN_ANGLE)); + CHECK(projector.get_max_articulation_angle_rad() == Approx(MAX_ANGLE)); + CHECK(projector.get_model().get_articulation_to_front_axle_m() == Approx(FRONT_ARM)); } TEST_CASE("ArticulatedProjector step - zero rate freezes articulation angle") { - ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; auto result = projector.step(0.1, pose, 0.3, 0.6, 0.0, 1.0); @@ -57,7 +57,7 @@ TEST_CASE("ArticulatedProjector step - zero rate freezes articulation angle") TEST_CASE("ArticulatedProjector step - large rate snaps to target without overshoot") { - ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; auto result = projector.step(0.1, pose, 0.0, 0.4, 100.0, 1.0); @@ -66,7 +66,7 @@ TEST_CASE("ArticulatedProjector step - large rate snaps to target without oversh TEST_CASE("ArticulatedProjector step - rate-limited slew advances by rate*dt") { - ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; // rate*dt = 0.02, target far away @@ -76,16 +76,16 @@ TEST_CASE("ArticulatedProjector step - rate-limited slew advances by rate*dt") TEST_CASE("ArticulatedProjector step - target above max saturates at max") { - ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; auto result = projector.step(0.1, pose, 0.0, 2.0, 100.0, 1.0); - CHECK(result.articulation_angle_rad == Approx(kMaxAngle)); + CHECK(result.articulation_angle_rad == Approx(MAX_ANGLE)); } TEST_CASE("ArticulatedProjector step - negative articulation rate is treated as magnitude") { - ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; auto result = projector.step(0.1, pose, 0.0, 0.5, -0.2, 1.0); @@ -94,7 +94,7 @@ TEST_CASE("ArticulatedProjector step - negative articulation rate is treated as TEST_CASE("ArticulatedProjector project - straight line: zero articulation, theta unchanged") { - ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; auto trajectory = projector.project(1.0, 0.1, pose, 0.0, 0.0, 0.0, 1.5); @@ -107,7 +107,7 @@ TEST_CASE("ArticulatedProjector project - straight line: zero articulation, thet TEST_CASE("ArticulatedProjector project - articulation ramps then saturates at max") { - ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; // target = 0.785 (= max), rate = 0.2 rad/s, dt = 0.1 → step adds 0.02 @@ -120,7 +120,7 @@ TEST_CASE("ArticulatedProjector project - articulation ramps then saturates at m TEST_CASE("ArticulatedProjector project - initial state stored as element 0") { - ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); Pose2D pose{1.0, 2.0, 0.5}; auto trajectory = projector.project(0.5, 0.1, pose, 0.2, 0.6, 0.5, 1.0); @@ -133,7 +133,7 @@ TEST_CASE("ArticulatedProjector project - initial state stored as element 0") TEST_CASE("ArticulatedProjector step - heading rotates with non-zero articulation at steady state") { - ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; // With current == clamped target, the realized gamma-dot is zero (no joint motion this step), @@ -147,7 +147,7 @@ TEST_CASE("ArticulatedProjector step - heading rotates with non-zero articulatio TEST_CASE("ArticulatedProjector step - realized gamma-dot affects rear-axle omega") { - ArticulatedProjector projector(make_model(), kMinAngle, kMaxAngle); + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; // Same starting angle, same target, same v: stepping with a small rate (slow ramp) versus @@ -158,4 +158,85 @@ TEST_CASE("ArticulatedProjector step - realized gamma-dot affects rear-axle omeg CHECK(slow_ramp.angular_velocity_rad_s != Approx(snap.angular_velocity_rad_s)); } +// Footprint dimensions used by the footprint tests below. +namespace +{ +constexpr double FRONT_JOINT_TO_BUMPER = 2.2; +constexpr double FRONT_BODY_WIDTH = 2.0; +constexpr double REAR_JOINT_TO_BUMPER = 2.0; +constexpr double REAR_BODY_WIDTH = 2.0; + +ArticulatedProjector make_footprint_projector() +{ + return ArticulatedProjector( + make_model(), + MIN_ANGLE, + MAX_ANGLE, + FRONT_JOINT_TO_BUMPER, + FRONT_BODY_WIDTH, + REAR_JOINT_TO_BUMPER, + REAR_BODY_WIDTH); +} +} // namespace + +TEST_CASE("ArticulatedProjector - no footprint dims yields empty footprints (never throws)") +{ + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); + Pose2D pose{0.0, 0.0, 0.0}; + auto result = projector.step(0.1, pose, 0.0, 0.0, 0.0, 1.0); + CHECK(result.front_footprint.empty()); + CHECK(result.rear_footprint.empty()); + + // project() must also run and produce states with empty footprints. + auto states = projector.project(0.2, 0.1, pose, 0.0, 0.0, 0.0, 1.0); + REQUIRE(states.size() >= 1); + CHECK(states.front().front_footprint.empty()); + CHECK(states.front().rear_footprint.empty()); +} + +TEST_CASE("ArticulatedProjector footprint - zero articulation, both bodies aligned along +x") +{ + auto projector = make_footprint_projector(); + Pose2D pose{0.0, 0.0, 0.0}; // rear axle at origin, rear-body heading +x + auto result = projector.step(0.1, pose, 0.0, 0.0, 0.0, 0.0); // v=0 so pose stays; gamma=0 + + REQUIRE(result.front_footprint.size() == 4); + REQUIRE(result.rear_footprint.size() == 4); + + // Joint sits REAR_ARM ahead of the rear axle along +x. + const double joint_x = REAR_ARM; + // Front body spans [joint, joint + FRONT_JOINT_TO_BUMPER]; corners 0 and 3 are at the joint. + CHECK(result.front_footprint[0].x == Approx(joint_x)); + CHECK(result.front_footprint[0].y == Approx(-FRONT_BODY_WIDTH / 2.0)); + CHECK(result.front_footprint[1].x == Approx(joint_x + FRONT_JOINT_TO_BUMPER)); + CHECK(result.front_footprint[1].y == Approx(-FRONT_BODY_WIDTH / 2.0)); + // Rear body spans [joint - REAR_JOINT_TO_BUMPER, joint]. + CHECK(result.rear_footprint[0].x == Approx(joint_x - REAR_JOINT_TO_BUMPER)); + CHECK(result.rear_footprint[1].x == Approx(joint_x)); + CHECK(result.rear_footprint[1].y == Approx(-REAR_BODY_WIDTH / 2.0)); +} + +TEST_CASE("ArticulatedProjector footprint - nonzero articulation folds the front body about the joint") +{ + auto projector = make_footprint_projector(); + Pose2D pose{0.0, 0.0, 0.0}; + const double gamma = 0.3; + auto result = projector.step(0.1, pose, gamma, gamma, 0.0, 0.0); // hold gamma, v=0 + + REQUIRE(result.front_footprint.size() == 4); + const double joint_x = REAR_ARM; + const double joint_y = 0.0; + // The front-bumper midpoint (between corners 1 and 2) lies on the front-body axis at distance + // FRONT_JOINT_TO_BUMPER from the joint, along the front heading (rear theta + gamma). Using the + // midpoint avoids the body-width offset that each individual corner carries. + const double front_theta = 0.0 + gamma; + const double bumper_mid_x = (result.front_footprint[1].x + result.front_footprint[2].x) / 2.0; + const double bumper_mid_y = (result.front_footprint[1].y + result.front_footprint[2].y) / 2.0; + CHECK(bumper_mid_x == Approx(joint_x + FRONT_JOINT_TO_BUMPER * std::cos(front_theta))); + CHECK(bumper_mid_y == Approx(joint_y + FRONT_JOINT_TO_BUMPER * std::sin(front_theta))); + // Rear body stays aligned with the rear heading (unchanged from the zero case). + CHECK(result.rear_footprint[0].x == Approx(joint_x - REAR_JOINT_TO_BUMPER)); + CHECK(result.rear_footprint[0].y == Approx(0.0 - REAR_BODY_WIDTH / 2.0)); +} + } // namespace polymath::kinematics diff --git a/test/test_bicycle_projector.cpp b/test/test_bicycle_projector.cpp index 8262aac..edf3366 100644 --- a/test/test_bicycle_projector.cpp +++ b/test/test_bicycle_projector.cpp @@ -22,24 +22,24 @@ namespace polymath::kinematics namespace { -constexpr double kWheelbase = 2.5; -constexpr double kTrack = 1.5; -constexpr double kWheelRadius = 0.3; -constexpr double kMinAngle = -0.6; -constexpr double kMaxAngle = 0.6; +constexpr double WHEELBASE = 2.5; +constexpr double TRACK = 1.5; +constexpr double WHEEL_RADIUS = 0.3; +constexpr double MIN_ANGLE = -0.6; +constexpr double MAX_ANGLE = 0.6; } // namespace TEST_CASE("BicycleProjector construction stores model and limits") { - BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); - CHECK(projector.get_min_steering_angle_rad() == Approx(kMinAngle)); - CHECK(projector.get_max_steering_angle_rad() == Approx(kMaxAngle)); - CHECK(projector.get_model().get_wheelbase_m() == Approx(kWheelbase)); + BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); + CHECK(projector.get_min_steering_angle_rad() == Approx(MIN_ANGLE)); + CHECK(projector.get_max_steering_angle_rad() == Approx(MAX_ANGLE)); + CHECK(projector.get_model().get_wheelbase_m() == Approx(WHEELBASE)); } TEST_CASE("BicycleProjector step - zero rate freezes the steering angle") { - BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; auto result = projector.step(0.1, pose, 0.2, 0.5, 0.0, 1.0); @@ -48,7 +48,7 @@ TEST_CASE("BicycleProjector step - zero rate freezes the steering angle") TEST_CASE("BicycleProjector step - large rate reaches target in one step without overshoot") { - BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; // |delta| = 0.3, max_delta = rate * dt = 10.0 * 0.1 = 1.0 >> 0.3 → snaps exactly to target. @@ -58,7 +58,7 @@ TEST_CASE("BicycleProjector step - large rate reaches target in one step without TEST_CASE("BicycleProjector step - rate-limited slew advances by rate*dt toward target") { - BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; // rate*dt = 0.05, target far away → expect 0.05 increment. @@ -68,7 +68,7 @@ TEST_CASE("BicycleProjector step - rate-limited slew advances by rate*dt toward TEST_CASE("BicycleProjector step - negative steering rate is treated as magnitude") { - BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; auto result = projector.step(0.1, pose, 0.0, 0.5, -0.5, 1.0); @@ -77,17 +77,17 @@ TEST_CASE("BicycleProjector step - negative steering rate is treated as magnitud TEST_CASE("BicycleProjector step - target above max saturates at max") { - BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; // With large rate the angle snaps to clamped_target == max. auto result = projector.step(0.1, pose, 0.0, 5.0, 100.0, 1.0); - CHECK(result.steering_angle_rad == Approx(kMaxAngle)); + CHECK(result.steering_angle_rad == Approx(MAX_ANGLE)); } TEST_CASE("BicycleProjector project - straight line traces +x with theta unchanged") { - BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; auto trajectory = projector.project(1.0, 0.1, pose, 0.0, 0.0, 1.0, 2.0); @@ -103,7 +103,7 @@ TEST_CASE("BicycleProjector project - straight line traces +x with theta unchang TEST_CASE("BicycleProjector project - ramp reaches target in expected number of steps") { - BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; // target=0.5, rate=0.5, dt=0.1 → step adds 0.05; takes 10 steps to reach 0.5. @@ -114,7 +114,7 @@ TEST_CASE("BicycleProjector project - ramp reaches target in expected number of TEST_CASE("BicycleProjector project - sign symmetry: negating target mirrors trajectory across y=0") { - BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; auto left = projector.project(2.0, 0.05, pose, 0.0, 0.4, 1.0, 1.0); @@ -131,7 +131,7 @@ TEST_CASE("BicycleProjector project - sign symmetry: negating target mirrors tra TEST_CASE("BicycleProjector project - initial state stored as element 0") { - BicycleProjector projector(BicycleModel(kWheelbase, kTrack, kWheelRadius), kMinAngle, kMaxAngle); + BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); Pose2D pose{1.5, -2.0, 0.25}; auto trajectory = projector.project(0.5, 0.1, pose, 0.1, 0.3, 0.5, 1.0); @@ -142,4 +142,35 @@ TEST_CASE("BicycleProjector project - initial state stored as element 0") CHECK(trajectory.front().steering_angle_rad == Approx(0.1)); } +TEST_CASE("BicycleProjector - no footprint dims yields an empty footprint (never throws)") +{ + BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); + Pose2D pose{0.0, 0.0, 0.0}; + auto result = projector.step(0.1, pose, 0.0, 0.0, 0.0, 1.0); + CHECK(result.footprint.empty()); +} + +TEST_CASE("BicycleProjector footprint - rectangle corners at a known pose") +{ + constexpr double FRONT_OVERHANG = 3.0; + constexpr double REAR_OVERHANG = 1.0; + constexpr double BODY_WIDTH = 2.0; + BicycleProjector projector( + BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE, FRONT_OVERHANG, REAR_OVERHANG, BODY_WIDTH); + + // v=0, zero steering: pose stays at the origin with heading +x. + Pose2D pose{0.0, 0.0, 0.0}; + auto result = projector.step(0.1, pose, 0.0, 0.0, 0.0, 0.0); + REQUIRE(result.footprint.size() == 4); + // Body frame corners: rear-right, front-right, front-left, rear-left. + CHECK(result.footprint[0].x == Approx(-REAR_OVERHANG)); + CHECK(result.footprint[0].y == Approx(-BODY_WIDTH / 2.0)); + CHECK(result.footprint[1].x == Approx(FRONT_OVERHANG)); + CHECK(result.footprint[1].y == Approx(-BODY_WIDTH / 2.0)); + CHECK(result.footprint[2].x == Approx(FRONT_OVERHANG)); + CHECK(result.footprint[2].y == Approx(BODY_WIDTH / 2.0)); + CHECK(result.footprint[3].x == Approx(-REAR_OVERHANG)); + CHECK(result.footprint[3].y == Approx(BODY_WIDTH / 2.0)); +} + } // namespace polymath::kinematics diff --git a/test/test_differential_drive_projector.cpp b/test/test_differential_drive_projector.cpp index e3acabe..f3fa6a4 100644 --- a/test/test_differential_drive_projector.cpp +++ b/test/test_differential_drive_projector.cpp @@ -22,28 +22,28 @@ namespace polymath::kinematics namespace { -constexpr double kWheelRadius = 0.1; -constexpr double kTrackWidth = 0.5; -constexpr double kVMin = -2.0; -constexpr double kVMax = 2.0; -constexpr double kOmegaMin = -3.0; -constexpr double kOmegaMax = 3.0; +constexpr double WHEEL_RADIUS = 0.1; +constexpr double TRACK_WIDTH = 0.5; +constexpr double V_MIN = -2.0; +constexpr double V_MAX = 2.0; +constexpr double OMEGA_MIN = -3.0; +constexpr double OMEGA_MAX = 3.0; DifferentialDriveProjector makeProjector() { return DifferentialDriveProjector( - DifferentialDriveModel(kWheelRadius, kTrackWidth), kVMin, kVMax, kOmegaMin, kOmegaMax); + DifferentialDriveModel(WHEEL_RADIUS, TRACK_WIDTH), V_MIN, V_MAX, OMEGA_MIN, OMEGA_MAX); } } // namespace TEST_CASE("DifferentialDriveProjector construction stores model and limits") { auto projector = makeProjector(); - CHECK(projector.get_min_linear_velocity_m_s() == Approx(kVMin)); - CHECK(projector.get_max_linear_velocity_m_s() == Approx(kVMax)); - CHECK(projector.get_min_angular_velocity_rad_s() == Approx(kOmegaMin)); - CHECK(projector.get_max_angular_velocity_rad_s() == Approx(kOmegaMax)); - CHECK(projector.get_model().get_wheel_radius_m() == Approx(kWheelRadius)); + CHECK(projector.get_min_linear_velocity_m_s() == Approx(V_MIN)); + CHECK(projector.get_max_linear_velocity_m_s() == Approx(V_MAX)); + CHECK(projector.get_min_angular_velocity_rad_s() == Approx(OMEGA_MIN)); + CHECK(projector.get_max_angular_velocity_rad_s() == Approx(OMEGA_MAX)); + CHECK(projector.get_model().get_wheel_radius_m() == Approx(WHEEL_RADIUS)); } TEST_CASE("DifferentialDriveProjector step - zero acceleration freezes both velocities") @@ -80,8 +80,8 @@ TEST_CASE("DifferentialDriveProjector step - target above linear max saturates a auto projector = makeProjector(); Pose2D pose{0.0, 0.0, 0.0}; auto result = projector.step(0.1, pose, 0.0, 0.0, 100.0, 100.0, 1000.0, 1000.0); - CHECK(result.linear_velocity_m_s == Approx(kVMax)); - CHECK(result.angular_velocity_rad_s == Approx(kOmegaMax)); + CHECK(result.linear_velocity_m_s == Approx(V_MAX)); + CHECK(result.angular_velocity_rad_s == Approx(OMEGA_MAX)); } TEST_CASE("DifferentialDriveProjector step - negative acceleration is treated as magnitude") @@ -130,4 +130,42 @@ TEST_CASE("DifferentialDriveProjector step - wheel speeds derive from body comma CHECK(result.wheel_velocities.right_wheel_velocity_rad_s == Approx(10.0)); } +TEST_CASE("DifferentialDriveProjector - no footprint dims yields an empty footprint (never throws)") +{ + auto projector = makeProjector(); + Pose2D pose{0.0, 0.0, 0.0}; + auto result = projector.step(0.1, pose, 0.0, 0.0, 0.0, 0.0, 100.0, 100.0); + CHECK(result.footprint.empty()); +} + +TEST_CASE("DifferentialDriveProjector footprint - rectangle corners rotated by heading") +{ + constexpr double FRONT_OVERHANG = 1.5; + constexpr double REAR_OVERHANG = 0.5; + constexpr double BODY_WIDTH = 1.0; + DifferentialDriveProjector projector( + DifferentialDriveModel(WHEEL_RADIUS, TRACK_WIDTH), + V_MIN, + V_MAX, + OMEGA_MIN, + OMEGA_MAX, + FRONT_OVERHANG, + REAR_OVERHANG, + BODY_WIDTH); + + // Heading = +90 deg (theta = pi/2), v=0 so pose stays at the origin. + const double theta = M_PI / 2.0; + Pose2D pose{0.0, 0.0, theta}; + auto result = projector.step(0.1, pose, 0.0, 0.0, 0.0, 0.0, 100.0, 100.0); + REQUIRE(result.footprint.size() == 4); + // Body-frame front-right corner (FRONT_OVERHANG, -BODY_WIDTH/2) rotated by +90deg: + // world = (+BODY_WIDTH/2, FRONT_OVERHANG). + CHECK(result.footprint[1].x == Approx(BODY_WIDTH / 2.0)); + CHECK(result.footprint[1].y == Approx(FRONT_OVERHANG)); + // Body-frame rear-right corner (-REAR_OVERHANG, -BODY_WIDTH/2) rotated by +90deg: + // world = (+BODY_WIDTH/2, -REAR_OVERHANG). + CHECK(result.footprint[0].x == Approx(BODY_WIDTH / 2.0)); + CHECK(result.footprint[0].y == Approx(-REAR_OVERHANG)); +} + } // namespace polymath::kinematics diff --git a/test/test_explorer.py b/test/test_explorer.py index a95ca1e..33a31d6 100644 --- a/test/test_explorer.py +++ b/test/test_explorer.py @@ -425,3 +425,61 @@ def test_single_articulated_articulation_series_brackets_initial_and_target(self 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, + front_overhang_m=3.0, + rear_overhang_m=1.0, + body_width_m=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_joint_to_bumper_m=2.2, + front_body_width_m=2.0, + rear_joint_to_bumper_m=2.0, + rear_body_width_m=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) diff --git a/test/test_python_bindings.py b/test/test_python_bindings.py index 7ec0e31..137a4c9 100644 --- a/test/test_python_bindings.py +++ b/test/test_python_bindings.py @@ -322,3 +322,74 @@ def test_project_straight_line(self): assert traj[-1].pose.x == pytest.approx(1.0) assert traj[-1].pose.y == pytest.approx(0.0) assert traj[-1].pose.theta == pytest.approx(0.0) + + +class TestPoint2D: + def test_construction(self): + from polymath_kinematics import Point2D + + point = Point2D(1.5, -2.0) + assert point.x == pytest.approx(1.5) + assert point.y == pytest.approx(-2.0) + + +class TestProjectorFootprints: + def test_bicycle_no_dims_empty_footprint(self): + projector = BicycleProjector(BicycleModel(2.5, 1.5, 0.3), -0.6, 0.6) + result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 1.0) + assert list(result.footprint) == [] + + def test_bicycle_footprint_corners(self): + projector = BicycleProjector( + BicycleModel(2.5, 1.5, 0.3), + -0.6, + 0.6, + front_overhang_m=3.0, + rear_overhang_m=1.0, + body_width_m=2.0, + ) + # v=0, zero steering: pose stays at origin, heading +x. + result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 0.0) + corners = result.footprint + assert len(corners) == 4 + assert corners[0].x == pytest.approx(-1.0) + assert corners[0].y == pytest.approx(-1.0) + assert corners[1].x == pytest.approx(3.0) + assert corners[2].y == pytest.approx(1.0) + + def test_differential_footprint_corners(self): + projector = DifferentialDriveProjector( + DifferentialDriveModel(0.1, 0.5), + -2.0, + 2.0, + -3.0, + 3.0, + front_overhang_m=1.5, + rear_overhang_m=0.5, + body_width_m=1.0, + ) + result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 0.0, 100.0, 100.0) + assert len(result.footprint) == 4 + + def test_articulated_no_dims_empty_footprints(self): + projector = ArticulatedProjector(ArticulatedModel(1.66, 1.44, 2.0, 2.0, 0.723, 0.723), -0.785, 0.785) + result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 1.0) + assert list(result.front_footprint) == [] + assert list(result.rear_footprint) == [] + + def test_articulated_footprint_corners(self): + projector = ArticulatedProjector( + ArticulatedModel(1.66, 1.44, 2.0, 2.0, 0.723, 0.723), + -0.785, + 0.785, + front_joint_to_bumper_m=2.2, + front_body_width_m=2.0, + rear_joint_to_bumper_m=2.0, + rear_body_width_m=2.0, + ) + # Zero articulation, v=0: joint sits rear-arm (1.44) ahead of the rear axle along +x. + result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 0.0) + assert len(result.front_footprint) == 4 + assert len(result.rear_footprint) == 4 + assert result.front_footprint[0].x == pytest.approx(1.44) + assert result.front_footprint[1].x == pytest.approx(1.44 + 2.2) From 89ecdf138028e654a83401f6aac2562eae143a63 Mon Sep 17 00:00:00 2001 From: Zeerek Date: Tue, 7 Jul 2026 23:48:07 +0000 Subject: [PATCH 7/9] feat(polymath_kinematics): build vehicle footprints into projectors Footprint is now computed in the C++ projectors and exposed via pybind so projecting a trajectory yields the vehicle outline at each step (single body for bicycle/differential, front + rear for articulated, folding about the articulation angle). Dimensions live on the projectors; models stay pure kinematics. Unset/invalid dims -> empty footprint, never throws. - Articulated base_link/pose is now the articulation joint (integration still runs at the rear axle); footprints gain front/rear overhang beyond the axles - Rename k-style constants to ALL_CAPS; normalizeAngle via std::remainder - Explorer: consume projected footprints; show only the longest (highest-vel) lattice subplot; 16:9 landscape plots constrained to 2/3-width columns - Conservative test trim (bindings tests -> surface checks; C++ owns geometry) and small app cleanup (hoist vel_options, shared single-trajectory render) Co-Authored-By: Claude Opus 4.8 (1M context) Bugfixes, cleanup, corrections, simplification Switch to passing in footprints rather than something more specialized --- .github/workflows/build.yml | 43 +++ CHANGELOG.md | 68 ++++ CMakeLists.txt | 6 +- README.md | 121 +++++- derivations/articulated_model.md | 70 +++- derivations/bicycle_model.md | 53 +++ derivations/differential_drive.md | 43 +++ .../articulated_projector.hpp | 103 +++-- .../polymath_kinematics/bicycle_projector.hpp | 72 ++-- .../differential_drive_projector.hpp | 51 +-- include/polymath_kinematics/pose2d.hpp | 49 ++- polymath_kinematics/__init__.py | 41 +- polymath_kinematics/explorer/__init__.py | 10 +- polymath_kinematics/explorer/cli.py | 67 ++++ polymath_kinematics/explorer/config.py | 39 +- polymath_kinematics/explorer/plotting.py | 340 ++++++----------- polymath_kinematics/explorer/simulation.py | 258 +++++-------- polymath_kinematics/explorer/types.py | 13 +- polymath_kinematics/kinematic_explorer_app.py | 356 ++++++++++++------ pyproject.toml | 11 +- src/articulated_projector.cpp | 93 ++--- src/bicycle_projector.cpp | 68 ++-- src/differential_drive_projector.cpp | 43 +-- src/kinematics_pybind.cpp | 61 +-- test/test_articulated_projector.cpp | 158 ++++++-- test/test_bicycle_projector.cpp | 106 +++++- test/test_differential_drive_projector.cpp | 44 ++- test/test_explorer.py | 242 ++++++++++-- test/test_python_bindings.py | 128 +++++-- uv.lock | 33 +- 30 files changed, 1775 insertions(+), 1015 deletions(-) create mode 100644 polymath_kinematics/explorer/cli.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0bb56a4..08efecf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,3 +35,46 @@ jobs: with: name: colcon-logs-${{ matrix.ros }} path: ros_ws/log + + # 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 and explorer + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - 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 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 + name: Standalone CMake + ctest + steps: + - uses: actions/checkout@v4 + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake g++ catch2 pybind11-dev python3-dev + - name: Configure + # CMAKE_DISABLE_FIND_PACKAGE_ament_cmake forces the non-ament branch. + run: > + cmake -S . -B build -DBUILD_TESTING=ON + -DCMAKE_DISABLE_FIND_PACKAGE_ament_cmake=ON + - name: Build + run: cmake --build build -j + - name: Test + run: ctest --test-dir build --output-on-failure diff --git a/CHANGELOG.md b/CHANGELOG.md index ec20642..b9e6e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## v0.2.0 + +### Forward projection + +- `BicycleProjector` — ramps the steering angle toward a target at a bounded rate, clamped to + `[min, max]`, and integrates pose with Euler. Pose reference is the rear axle. +- `ArticulatedProjector` — same shape for the articulation angle γ and rate γ̇. Pose reference + (`base_link`) is the articulation joint; motion is integrated at the rear axle and converted + back to the joint each step. +- `DifferentialDriveProjector` — ramps the body command (v, ω) toward a target under separate + linear and angular acceleration limits. Pose reference is the body centre. +- All three expose `step()` for one time step and `project()` for a full horizon, returning a + timestamped state sequence whose element 0 is the initial state. + +### Footprints + +- `Pose2D`, `Point2D`, and `Footprint` (a counter-clockwise, unclosed polygon of any vertex count) + in a shared `pose2d.hpp`, along with `normalizeAngle`, `transformFootprint`, + `offsetAlongHeading`, and `rectangleFootprint` for the boxy common case. +- Each projector optionally takes an **arbitrary body-frame polygon** and emits it transformed into + the world frame per sample; the kinematic models stay dimension-free. An empty polygon means + "unset" and yields an empty footprint rather than throwing. The articulated projector takes one + polygon per body. + +### Axle reference + +- `AxleReference::FRONT` / `REAR` selects the axle that `BicycleProjector` and + `ArticulatedProjector` measure poses and footprints from. Motion is still integrated where each + model is naturally defined (the rear axle); the reference conversion is applied on input and + output, so no integration accuracy is traded for the choice. +- For the articulated model, `theta` follows the referenced axle's body — $\theta_r$ for REAR, + $\theta_f = \theta_r + \gamma$ for FRONT — and each body's polygon is anchored at **its own** + axle, the only anchoring that stays rigid as the joint articulates. +- `ArticulatedProjectedState` gained `joint_pose`, so the articulation joint (`base_link`) is + reported on every sample whichever axle is referenced. +- A differential drive has one axle, so `DifferentialDriveProjector` takes no reference; its + polygon is measured from the body centre. + +### Kinematics + +- `ArticulatedModel` now accepts an explicit articulation turning velocity (γ̇) on + `bodyVelocityToVehicleState` and `articulationToAxleVelocities`. The previous two-argument + forms remain as overloads that assume steady articulation (γ̇ = 0), so existing callers are + unaffected. γ̇ was previously a hardcoded zero constant. + +### Packaging + +- Wheel build via scikit-build-core, alongside the existing ROS2/ament path. One + `CMakeLists.txt` serves both; `find_package(ament_cmake QUIET)` selects the branch. The wheel + links the C++ library statically so it ships a single `.so`. +- Python bindings for all three projectors, their projected-state structs, `Pose2D`, and + `Point2D`. + +### Kinematic Explorer + +- New Streamlit app (`kinematic-explorer`) 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. +- Geometry sliders (including body overhangs), a front/rear axle reference selector, + single-projected-trajectory ramp controls, and CSV / JSON / PNG / SVG / PDF download. + +### Removed + +- `Twist2D`, `normalize_angle`, and `transform_pose` from the Python package — leftovers from + when `Pose2D` was a Python shim, now superseded by the bound C++ `Pose2D` and + `normalizeAngle`. + ## v0.1.0 Initial release. diff --git a/CMakeLists.txt b/CMakeLists.txt index 0f7cfcb..1569f7d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,4 @@ # Copyright (c) 2025-present Polymath Robotics, Inc. All rights reserved -# Proprietary. Any unauthorized copying, distribution, or modification of this software is strictly prohibited. -# 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. @@ -115,7 +113,9 @@ if(BUILD_TESTING) target_link_libraries(${TEST_NAME} PRIVATE polymath_kinematics Catch2::Catch2WithMain) target_include_directories(${TEST_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/test) if(COMMAND catch_discover_tests) - catch_discover_tests(${TEST_NAME}) + # PRE_TEST enumerates at ctest time. The default POST_BUILD runs each test binary during the + # build, where an older installed libpolymath_kinematics.so wins on LD_LIBRARY_PATH. + catch_discover_tests(${TEST_NAME} DISCOVERY_MODE PRE_TEST) else() add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) endif() diff --git a/README.md b/README.md index 5e6b6c6..6ee2c0c 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,9 @@ cmake -S . -B build -DBUILD_TESTING=ON -DCMAKE_DISABLE_FIND_PACKAGE_ament_cmake= After `uv pip install -e ".[explorer]"`: ```bash -# Console script (recommended) +# Console script (recommended). Extra arguments are forwarded to streamlit. kinematic-explorer +kinematic-explorer --server.port=8600 # Or invoke streamlit directly uv run python -m streamlit run polymath_kinematics/kinematic_explorer_app.py @@ -80,11 +81,18 @@ uv run python -m streamlit run polymath_kinematics/kinematic_explorer_app.py The app opens at `http://localhost:8501` and exposes: - Model selection (Differential Drive / Bicycle / Articulated) -- Geometry sliders (wheelbase, track width, wheel radii, etc.) +- Geometry sliders (wheelbase, track width, wheel radii, body overhangs) and a front/rear axle reference selector - Trajectory lattice visualisation across steering / articulation angles - Kinematic analysis plots (angle → angular velocity, turning radius vs angle) -- Vehicle-footprint overlays along trajectories -- CSV / JSON / PNG / SVG / PDF export +- Vehicle-footprint overlays along trajectories, drawn from the projector-computed polygons +- Single projected trajectory with initial → target ramp controls +- CSV / JSON / PNG / SVG / PDF download + +Every trajectory the explorer draws comes from the C++ projectors, so it exercises the same +forward-simulation code as production. Integration is Euler at the configured time step. + +The console script binds to `localhost` by default; pass `--server.address=0.0.0.0` to serve a +demo over the network. ## Running tests @@ -118,9 +126,13 @@ from polymath_kinematics import ( DifferentialDriveModel, BicycleModel, ArticulatedModel, + DifferentialDriveProjector, BicycleProjector, ArticulatedProjector, + AxleReference, Pose2D, + Point2D, + rectangle_footprint, ) ``` @@ -184,17 +196,56 @@ axles = model.articulation_to_axle_velocities( print(axles.front_axle_turning_velocity_rad_s, axles.rear_axle_turning_velocity_rad_s) ``` -### Projectors — forward simulation with steering-rate limits +### Projectors — forward simulation with actuator limits + +Each projector wraps its model and integrates pose forward in time under realistic actuator +constraints. For `BicycleProjector` and `ArticulatedProjector` the steering / articulation angle +ramps toward a target at a bounded rate, clamped to `[min, max]`. `DifferentialDriveProjector` +has no steered joint, so it instead ramps the body command `(v, ω)` under separate linear and +angular acceleration limits. + +Every projector optionally computes a per-sample vehicle footprint. Footprints are **arbitrary +polygons** given in the body frame, and they live on the projector rather than the model — the +kinematic models stay dimension-free. A polygon is counter-clockwise and not closed; an empty +polygon (the default) means "unset", so the footprint comes back empty and projection proceeds +normally. `rectangle_footprint(front_m, rear_m, width_m)` builds the boxy common case. + +For the bicycle and articulated models, poses and the body-frame footprint are both measured from +an axle you choose with `AxleReference.FRONT` or `AxleReference.REAR`. A differential drive has one +axle, so there is nothing to select. + +| Projector | Pose reference | Footprint | +|---|---|---| +| `DifferentialDriveProjector` | Body centre | One polygon in the body-centre frame | +| `BicycleProjector` | Selected axle (`AxleReference`) | One polygon in the selected axle's frame | +| `ArticulatedProjector` | Selected axle (`AxleReference`); θ is that axle's body heading | One polygon per body, **each in its own axle's frame** — the only anchoring that stays rigid as the joint articulates | -`BicycleProjector` and `ArticulatedProjector` wrap their respective models and integrate pose forward in time under realistic actuator constraints: the steering / articulation angle ramps toward a target at a bounded rate, clamped to `[min, max]`. +`ArticulatedProjector` also reports `joint_pose` on every sample, so the articulation joint +(`base_link` for a ROS articulated vehicle) is available regardless of which axle you reference. +See the [derivations](#derivations) for the per-model geometry and integration details. ```python projector = BicycleProjector( model=BicycleModel(wheelbase_m=2.7, track_width_m=1.6, wheel_radius_m=0.35), min_steering_angle_rad=-0.6, max_steering_angle_rad=0.6, + axle_reference=AxleReference.REAR, + # Measured from the rear axle: front bumper 0.9 m past the 2.7 m front axle, 0.8 m of tail. + footprint=rectangle_footprint(2.7 + 0.9, 0.8, 1.8), ) +# Or describe the same body from the front axle — the polygon moves with the reference: +front_referenced = BicycleProjector( + model=BicycleModel(wheelbase_m=2.7, track_width_m=1.6, wheel_radius_m=0.35), + min_steering_angle_rad=-0.6, + max_steering_angle_rad=0.6, + axle_reference=AxleReference.FRONT, + footprint=rectangle_footprint(0.9, 2.7 + 0.8, 1.8), +) + +# Any polygon works, not just rectangles — a tapered nose, for instance: +tapered = [Point2D(-0.8, -0.9), Point2D(3.2, -0.9), Point2D(3.6, 0.0), Point2D(3.2, 0.9), Point2D(-0.8, 0.9)] + # One-step advance result = projector.step( dt_s=0.1, @@ -205,6 +256,7 @@ result = projector.step( linear_velocity_m_s=1.0, ) print(result.pose.x, result.steering_angle_rad) +print([(p.x, p.y) for p in result.footprint]) # world-frame body polygon # Full trajectory trajectory = projector.project( @@ -226,6 +278,11 @@ projector = ArticulatedProjector( model=ArticulatedModel(1.66, 1.44, 2.0, 2.0, 0.723, 0.723), min_articulation_angle_rad=-0.785, max_articulation_angle_rad=0.785, + axle_reference=AxleReference.REAR, + # Front body about the FRONT axle: 1.0 m of bucket ahead, back to the joint 1.66 m behind. + front_footprint=rectangle_footprint(1.0, 1.66, 2.0), + # Rear body about the REAR axle: forward to the joint 1.44 m ahead, 0.8 m of counterweight. + rear_footprint=rectangle_footprint(1.44, 0.8, 2.0), ) trajectory = projector.project( horizon_s=5.0, dt_s=0.05, @@ -237,6 +294,29 @@ trajectory = projector.project( ) ``` +`DifferentialDriveProjector` ramps the body command instead of an angle: + +```python +projector = DifferentialDriveProjector( + model=DifferentialDriveModel(wheel_radius_m=0.15, track_width_m=0.5), + min_linear_velocity_m_s=-2.0, + max_linear_velocity_m_s=2.0, + min_angular_velocity_rad_s=-1.5, + max_angular_velocity_rad_s=1.5, +) +trajectory = projector.project( + horizon_s=5.0, dt_s=0.05, + initial_pose=Pose2D(), + initial_linear_velocity_m_s=0.0, + initial_angular_velocity_rad_s=0.0, + target_linear_velocity_m_s=1.0, + target_angular_velocity_rad_s=0.5, + linear_acceleration_m_s2=1.0, + angular_acceleration_rad_s2=1.0, +) +# Setting initial == target makes the ramp a no-op, giving a constant-command trajectory. +``` + ## C++ usage The C++ headers and library are installed under the package's CMake export when built via colcon: @@ -250,6 +330,7 @@ target_link_libraries(my_target PRIVATE polymath_kinematics::polymath_kinematics #include #include #include +#include #include #include #include @@ -299,7 +380,9 @@ auto axles_with_rate = model.articulationToAxleVelocities(1.5, 0.3, 0.2); ```cpp polymath::kinematics::BicycleProjector projector( polymath::kinematics::BicycleModel(2.7, 1.6, 0.35), - -0.6, 0.6); // min/max steering angle (rad) + -0.6, 0.6, // min/max steering angle (rad) + polymath::kinematics::AxleReference::REAR, // poses + footprint about the rear axle + polymath::kinematics::rectangleFootprint(3.6, 0.8, 1.8)); // front_m, rear_m, width_m polymath::kinematics::Pose2D pose{0.0, 0.0, 0.0}; @@ -319,7 +402,20 @@ auto trajectory = projector.project( // trajectory.front() is the initial state, trajectory.back() is the end of the horizon. ``` -`ArticulatedProjector` is shaped identically — the angle is the articulation angle (γ) and the rate is γ̇. +`ArticulatedProjector` is shaped identically — the angle is the articulation angle (γ) and the rate is γ̇. `DifferentialDriveProjector` takes a body command and acceleration limits instead: + +```cpp +polymath::kinematics::DifferentialDriveProjector diff_projector( + polymath::kinematics::DifferentialDriveModel(0.15, 0.5), + -2.0, 2.0, // min/max linear velocity (m/s) + -1.5, 1.5); // min/max angular velocity (rad/s) + +auto diff_trajectory = diff_projector.project( + /*horizon_s=*/5.0, /*dt_s=*/0.05, pose, + /*initial_v=*/0.0, /*initial_omega=*/0.0, + /*target_v=*/1.0, /*target_omega=*/0.5, + /*linear_accel=*/1.0, /*angular_accel=*/1.0); +``` ## Models reference @@ -370,7 +466,10 @@ auto trajectory = projector.project( | Class | Constructor | Notable methods | |---|---|---| -| `BicycleProjector` | `(BicycleModel, min_steering, max_steering)` | `step(dt, pose, current, target, rate, v)`, `project(horizon, dt, pose, initial, target, rate, v)` | -| `ArticulatedProjector` | `(ArticulatedModel, min_articulation, max_articulation)` | `step(dt, pose, current, target, rate, v)`, `project(horizon, dt, pose, initial, target, rate, v)` | +| `BicycleProjector` | `(BicycleModel, min_steering, max_steering, [axle_reference, footprint])` | `step(dt, pose, current, target, rate, v)`, `project(horizon, dt, pose, initial, target, rate, v)` | +| `ArticulatedProjector` | `(ArticulatedModel, min_articulation, max_articulation, [axle_reference, front_footprint, rear_footprint])` | `step(dt, pose, current, target, rate, v)`, `project(horizon, dt, pose, initial, target, rate, v)` | +| `DifferentialDriveProjector` | `(DifferentialDriveModel, min_v, max_v, min_omega, max_omega, [footprint])` | `step(dt, pose, current_v, current_omega, target_v, target_omega, accel, angular_accel)`, `project(horizon, dt, pose, initial_v, initial_omega, target_v, target_omega, accel, angular_accel)` | + +All three clamp the target to its `[min, max]` bounds before ramping, then advance toward the clamped target at the given rate or acceleration (never overshooting), and integrate pose with Euler using the heading at the start of each step. `project()` returns `ceil(horizon / dt) + 1` samples, with the initial state as element 0. -Both projectors clamp the target angle to `[min, max]` before ramping, then advance the current angle toward the clamped target at the given rate (never overshooting), and integrate pose with Euler. +`BicycleProjector` and `ArticulatedProjector` ramp an angle; `DifferentialDriveProjector` ramps the body command `(v, ω)` under separate linear and angular acceleration limits, since a differential drive has no steered joint. diff --git a/derivations/articulated_model.md b/derivations/articulated_model.md index 3c58382..af00d7c 100644 --- a/derivations/articulated_model.md +++ b/derivations/articulated_model.md @@ -18,7 +18,7 @@ A vehicle with two rigid sections connected by a central pivot (the articulation - $v$ — forward linear velocity of the vehicle (positive = forward) - $\omega$ — yaw rate of the body (positive = counter-clockwise / left turn) - $\gamma$ — articulation angle between front and rear sections (positive = left turn, counterclockwise) -- $\dot{\gamma}$ — articulation angle rate of change (currently fixed at $0$ in the implementation; placeholder for future estimation) +- $\dot{\gamma}$ — articulation angle rate of change. Supplied by the caller; the two-argument overloads of `bodyVelocityToVehicleState` / `articulationToAxleVelocities` assume steady articulation ($\dot{\gamma} = 0$) - $\theta_f$ — heading angle of the front body - $\theta_r$ — heading angle of the rear body @@ -206,9 +206,73 @@ Computing `bodyVelocityToVehicleState(v, omega)` to get $\gamma$, then feeding t --- -## Future work +## Forward projection (`ArticulatedProjector`) + +`ArticulatedProjector` wraps this model to roll a pose forward in time under a rate-limited +articulation joint. + +**Pose reference.** Poses are measured at whichever axle the caller selects +(`AxleReference::FRONT` or `REAR`), and $\theta$ is the heading of the body that axle belongs to — +$\theta_r$ for a REAR reference, $\theta_f = \theta_r + \gamma$ for a FRONT one. Motion is always +integrated at the rear axle. The conversions chain through the joint: + +$$\mathbf{p}^{\text{joint}} = \mathbf{p}^{\text{rear}} + L_r\begin{bmatrix}\cos\theta_r\\\sin\theta_r\end{bmatrix}, \qquad \mathbf{p}^{\text{front}} = \mathbf{p}^{\text{joint}} + L_f\begin{bmatrix}\cos\theta_f\\\sin\theta_f\end{bmatrix}$$ + +Every sample also reports `joint_pose` (position of the joint, $\theta = \theta_r$), so the +articulation joint — `base_link` for a ROS articulated vehicle — is available whichever axle is +referenced. + +**Per step**, given $\Delta t$, the current angle $\gamma_k$, a target $\gamma^\*$, a rate +limit $\dot{\gamma}_{\max}$, and a commanded $v$: + +1. **Clamp then ramp.** The target is first clamped to the joint's mechanical limits, then the + angle advances toward it without overshooting: + $$\gamma_{k+1} = \gamma_k + \operatorname{clamp}\!\left(\operatorname{clamp}(\gamma^\*,\, \gamma_{\min},\, \gamma_{\max}) - \gamma_k,\; -\dot{\gamma}_{\max}\Delta t,\; +\dot{\gamma}_{\max}\Delta t\right)$$ + Clamping before ramping means an out-of-range command saturates at the limit rather than + oscillating around it. +2. **Realized rate.** $\dot{\gamma}_k = (\gamma_{k+1} - \gamma_k)/\Delta t$, which falls to $0$ + once the angle pins at the target. This value — not the rate limit — is fed into the + kinematics above, so the $\dot{\gamma}$ term is exercised during the ramp and vanishes at + steady state. +3. **Integrate at the rear axle.** Because $\omega$ here is the rear-axle turning velocity, the + joint pose is converted back to the rear axle, Euler-stepped, and converted forward again: + $$\mathbf{p}^{\text{rear}}_k = \mathbf{p}_k - L_r\begin{bmatrix}\cos\theta_k\\\sin\theta_k\end{bmatrix}, \qquad \mathbf{p}^{\text{rear}}_{k+1} = \mathbf{p}^{\text{rear}}_k + v\begin{bmatrix}\cos\theta_k\\\sin\theta_k\end{bmatrix}\Delta t$$ + $$\theta_{k+1} = \operatorname{wrap}(\theta_k + \omega_k \Delta t), \qquad \mathbf{p}_{k+1} = \mathbf{p}^{\text{rear}}_{k+1} + L_r\begin{bmatrix}\cos\theta_{k+1}\\\sin\theta_{k+1}\end{bmatrix}$$ + The heading is taken at the *start* of the step (explicit Euler). Integrating at the joint + directly would trace the wrong arc, since the joint is not the point that moves along the + body's velocity vector. + +`project(horizon, dt, ...)` repeats this for $\lceil \text{horizon}/\Delta t \rceil$ steps and +returns $\lceil \text{horizon}/\Delta t \rceil + 1$ samples, with the initial state seeded as +element 0 so plots have a clean $t = 0$ anchor. + +### Footprints + +The projector optionally emits one polygon per body, keeping the kinematic model itself +dimension-free. Each is an **arbitrary polygon** supplied in the frame of **its own axle**: the front +polygon about the front axle with $+x$ along $\theta_f$, the rear polygon about the rear axle with +$+x$ along $\theta_r$. + +Anchoring each body at its own axle is the only choice that stays rigid as the joint articulates. A +polygon measured from the *other* body's axle would have to be re-measured for every value of +$\gamma$, since the two bodies rotate relative to one another about the joint — so a fixed polygon +in that frame would only be correct at one articulation angle. + +The joint sits $L_f$ behind the front axle and $L_r$ ahead of the rear axle, so a loader with $f$ of +bucket ahead of the front axle and $r$ of counterweight behind the rear axle is described as: + +| Body | Forward extent | Rearward extent | +|---|---|---| +| Front (about front axle) | $f$ | $L_f$ (back to the joint) | +| Rear (about rear axle) | $L_r$ (forward to the joint) | $r$ | + +`rectangleFootprint(front_m, rear_m, width_m)` builds the boxy case, ordered counter-clockwise and +open. An empty polygon means "unset": that body's footprint comes back empty and projection proceeds +normally rather than throwing. -The $\dot{\gamma}$ term is currently hardcoded to $0$. Once articulation angle rate estimation is available (e.g., from an IMU or joint encoder derivative), it will be incorporated to improve accuracy during transient steering maneuvers. +--- + +## Future work Add diagrams to the readme for visualization diff --git a/derivations/bicycle_model.md b/derivations/bicycle_model.md index 87fc16c..64d75f0 100644 --- a/derivations/bicycle_model.md +++ b/derivations/bicycle_model.md @@ -89,5 +89,58 @@ $$\omega_{\text{fr}} = \frac{\omega \cdot \operatorname{copysign}\!\left(\sqrt{( | $\omega \approx 0,\; v \neq 0$ | Straight line. All wheels spin at $v / r$ | | $\|R\| < W/2$ | ICR between rear wheels. Inner wheel reverses direction (handled by $\operatorname{copysign}$) | +## Forward projection (`BicycleProjector`) + +`BicycleProjector` wraps this model to roll a pose forward in time under a rate-limited steering +actuator. + +**Pose reference.** Poses are measured at whichever axle the caller selects +(`AxleReference::FRONT` or `REAR`). Motion is always integrated at the **rear axle**, the classic +bicycle reference and the point that travels along the body velocity vector. Because the chassis is +one rigid body, both axles share the heading $\theta$, so a FRONT reference is a pure longitudinal +offset applied on input and output: + +$$\mathbf{p}^{\text{front}} = \mathbf{p}^{\text{rear}} + L\begin{bmatrix}\cos\theta\\\sin\theta\end{bmatrix}$$ + +**Per step**, given $\Delta t$, the current angle $\delta_k$, a target $\delta^\*$, a rate limit +$\dot{\delta}_{\max}$, and a commanded $v$: + +1. **Clamp then ramp.** The target is clamped to the actuator's limits first, then the angle + advances toward it without overshooting: + $$\delta_{k+1} = \delta_k + \operatorname{clamp}\!\left(\operatorname{clamp}(\delta^\*,\, \delta_{\min},\, \delta_{\max}) - \delta_k,\; -\dot{\delta}_{\max}\Delta t,\; +\dot{\delta}_{\max}\Delta t\right)$$ + Clamping before ramping means an out-of-range command saturates at the limit rather than + oscillating around it. +2. **Forward kinematics** with the post-ramp angle gives the body yaw rate: + $\omega_k = v \tan(\delta_{k+1}) / L$. +3. **Euler pose update**, heading taken at the *start* of the step: + $$x_{k+1} = x_k + v\cos\theta_k\,\Delta t, \qquad y_{k+1} = y_k + v\sin\theta_k\,\Delta t, \qquad \theta_{k+1} = \operatorname{wrap}(\theta_k + \omega_k \Delta t)$$ + +Each sample also carries the full `BicycleSteeringState` (four wheel speeds and turning radius) +from running the inverse kinematics on $\omega_k$. + +`project(horizon, dt, ...)` repeats this for $\lceil \text{horizon}/\Delta t \rceil$ steps and +returns $\lceil \text{horizon}/\Delta t \rceil + 1$ samples, with the initial state seeded as +element 0 so plots have a clean $t = 0$ anchor. + +### Footprints + +The projector optionally emits a body polygon per sample, keeping the kinematic model itself +dimension-free. The footprint is an **arbitrary polygon** supplied in the body frame of the selected +axle: $+x$ along the chassis heading, $+y$ to the left, origin at that axle. Each sample carries the +polygon transformed into the world frame by that sample's pose. + +Because the reference axle sets the polygon's origin, the same physical body is described +differently depending on the choice. For a vehicle with $f$ of overhang past the front axle and $r$ +of tail behind the rear axle: + +| Reference | Forward extent | Rearward extent | +|---|---|---| +| `REAR` | $L + f$ | $r$ | +| `FRONT` | $f$ | $L + r$ | + +`rectangleFootprint(front_m, rear_m, width_m)` builds the boxy case, ordered counter-clockwise and +open: rear-right, front-right, front-left, rear-left. An empty polygon means "unset": the footprint +comes back empty and projection proceeds normally rather than throwing. + ## Future Work Add diagrams to the readme for visualization diff --git a/derivations/differential_drive.md b/derivations/differential_drive.md index 20172f3..521d69b 100644 --- a/derivations/differential_drive.md +++ b/derivations/differential_drive.md @@ -57,6 +57,49 @@ $$d_{\text{ICR}} = \frac{v}{\omega} = \frac{W}{2} \cdot \frac{\omega_R + \omega_ When the robot drives straight, the ICR is at infinity. When spinning in place, it is at the origin. +## Forward projection (`DifferentialDriveProjector`) + +`DifferentialDriveProjector` wraps this model to roll a pose forward in time under acceleration +limits. Unlike the bicycle and articulated projectors — which rate-limit a steering *angle* — a +differential drive has no steered joint, so what is limited here is the **body command itself**: +$v$ and $\omega$ each ramp toward their target under their own acceleration limit. + +**Pose reference.** The pose is the **body centre** (midway between the two wheels), the point +the ICR distance is measured from above. + +**Per step**, given $\Delta t$, current $(v_k, \omega_k)$, targets $(v^\*, \omega^\*)$, and +limits $(a_{\max}, \alpha_{\max})$: + +1. **Clamp then ramp**, independently per channel, neither overshooting: + $$v_{k+1} = v_k + \operatorname{clamp}\!\left(\operatorname{clamp}(v^\*,\, v_{\min},\, v_{\max}) - v_k,\; -a_{\max}\Delta t,\; +a_{\max}\Delta t\right)$$ + $$\omega_{k+1} = \omega_k + \operatorname{clamp}\!\left(\operatorname{clamp}(\omega^\*,\, \omega_{\min},\, \omega_{\max}) - \omega_k,\; -\alpha_{\max}\Delta t,\; +\alpha_{\max}\Delta t\right)$$ + Clamping before ramping means an out-of-range command saturates at the limit rather than + oscillating around it. +2. **Inverse kinematics** on the post-ramp command gives the wheel speeds recorded on the sample. +3. **Euler pose update**, using the post-ramp velocities with the heading taken at the *start* of + the step: + $$x_{k+1} = x_k + v_{k+1}\cos\theta_k\,\Delta t, \qquad y_{k+1} = y_k + v_{k+1}\sin\theta_k\,\Delta t, \qquad \theta_{k+1} = \operatorname{wrap}(\theta_k + \omega_{k+1} \Delta t)$$ + +`project(horizon, dt, ...)` repeats this for $\lceil \text{horizon}/\Delta t \rceil$ steps and +returns $\lceil \text{horizon}/\Delta t \rceil + 1$ samples, with the initial state seeded as +element 0 so plots have a clean $t = 0$ anchor. Setting initial $=$ target makes the ramp a +no-op, which is how a constant-command trajectory (e.g. one lattice cell) is generated. + +### Footprints + +The projector optionally emits a body polygon per sample, keeping the kinematic model itself +dimension-free. The footprint is an **arbitrary polygon** supplied in the body frame: $+x$ along the +heading, $+y$ to the left, origin at the body centre. Each sample carries it transformed into the +world frame by that sample's pose. + +A differential drive has a single axle, so unlike the bicycle and articulated projectors there is no +axle reference to select — the body centre is the only sensible origin, and polygon extents are the +bumper distances directly. + +`rectangleFootprint(front_m, rear_m, width_m)` builds the boxy case, ordered counter-clockwise and +open: rear-right, front-right, front-left, rear-left. An empty polygon means "unset": the footprint +comes back empty and projection proceeds normally rather than throwing. + ## Future Work Add diagrams to the readme for visualization diff --git a/include/polymath_kinematics/articulated_projector.hpp b/include/polymath_kinematics/articulated_projector.hpp index 346fae6..b2d5ca6 100644 --- a/include/polymath_kinematics/articulated_projector.hpp +++ b/include/polymath_kinematics/articulated_projector.hpp @@ -15,69 +15,68 @@ #ifndef POLYMATH_KINEMATICS__ARTICULATED_PROJECTOR_HPP__ #define POLYMATH_KINEMATICS__ARTICULATED_PROJECTOR_HPP__ +#include #include #include "polymath_kinematics/articulated_model.hpp" #include "polymath_kinematics/pose2d.hpp" -// TODO(naming): when this projector is in wide use, consider renaming the underlying -// ArticulatedModel methods for clarity: -// - bodyVelocityToVehicleState -> inverseKinematics -// - articulationToAxleVelocities -> forwardKinematics -// And dropping the _m / _rad / _rad_s unit suffixes from struct fields (units in docs). - namespace polymath::kinematics { -/// @brief One sample of an articulated-model projection. Pose is tracked at the rear segment. +/// @brief One sample of an articulated-model projection. `pose` sits at the projector's reference +/// axle, with theta the heading of the body that axle belongs to. Motion is integrated at the rear +/// axle internally. struct ArticulatedProjectedState { double time_s; ///< Elapsed time from the start of the projection - Pose2D pose; ///< Rear-segment pose at this sample + Pose2D pose; ///< Reference-axle pose; theta = heading of that axle's body double articulation_angle_rad; ///< Post-ramp articulation angle (gamma) in radians double linear_velocity_m_s; ///< Commanded linear velocity in m/s double angular_velocity_rad_s; ///< Rear-axle turning rate used for theta integration ArticulatedVehicleState vehicle_state; ///< Full kinematic snapshot (wheel speeds + turning radii) - Footprint front_footprint; ///< World-frame front-body polygon; empty if no footprint dims set - Footprint rear_footprint; ///< World-frame rear-body polygon; empty if no footprint dims set + Pose2D joint_pose; ///< Articulation-joint (base_link) pose; theta = rear-body heading + Footprint front_footprint; ///< World-frame front-body polygon; empty if none was set + Footprint rear_footprint; ///< World-frame rear-body polygon; empty if none was set }; /// @brief Forward-projection wrapper around ArticulatedModel. Ramps articulation angle (gamma) /// toward a target at a bounded rate (gamma-dot), clamping the target to [min, max] first. -/// Pose is integrated with Euler at the rear axle. +/// +/// Poses are measured at the axle named by `axle_reference`, with theta the heading of the body +/// that axle belongs to (rear-body heading for REAR, front-body heading = theta_rear + gamma for +/// FRONT). Motion is always integrated at the rear axle; the reference conversion is applied on +/// input and output. The articulation-joint pose is reported alongside on every sample. class ArticulatedProjector { public: - /// @brief Construct a projector with articulation-angle limits and (optionally) footprint dims. + /// @brief Construct a projector with articulation-angle limits and (optionally) body footprints. /// - /// Footprint dimensions describe the two vehicle bodies relative to the articulation joint and - /// are owned by the projector (the kinematic model stays dimension-free). Each body is a - /// rectangle: the front body extends `front_joint_to_bumper_m` ahead of the joint along the - /// front-body heading; the rear body extends `rear_joint_to_bumper_m` behind the joint along - /// the rear-body heading. A body width <= 0 means "unset/invalid": that body's footprint is - /// emitted empty and projection proceeds normally (never throws). + /// Each body carries its own arbitrary polygon, expressed in the frame of **its own** axle: the + /// front polygon about the front axle with +x along the front-body heading, the rear polygon + /// about the rear axle with +x along the rear-body heading. This is the only anchoring that stays + /// rigid as the joint articulates. Footprints are owned by the projector, so the kinematic model + /// stays dimension-free. An empty polygon means "unset": that body's footprint is emitted empty + /// and projection proceeds normally (never throws). Use `rectangleFootprint()` for boxy bodies. /// @param model Articulated kinematics model (stored by value) /// @param min_articulation_angle_rad Minimum allowed articulation angle (typically negative) /// @param max_articulation_angle_rad Maximum allowed articulation angle (typically positive) - /// @param front_joint_to_bumper_m Front-body length from joint to front bumper (m) - /// @param front_body_width_m Front-body width (m); <= 0 disables the front footprint - /// @param rear_joint_to_bumper_m Rear-body length from joint to rear bumper (m) - /// @param rear_body_width_m Rear-body width (m); <= 0 disables the rear footprint + /// @param axle_reference Axle that reported poses are measured from + /// @param front_footprint Front-body polygon in the front-axle frame + /// @param rear_footprint Rear-body polygon in the rear-axle frame ArticulatedProjector( ArticulatedModel model, double min_articulation_angle_rad, double max_articulation_angle_rad, - double front_joint_to_bumper_m = 0.0, - double front_body_width_m = 0.0, - double rear_joint_to_bumper_m = 0.0, - double rear_body_width_m = 0.0) + AxleReference axle_reference = AxleReference::REAR, + Footprint front_footprint = {}, + Footprint rear_footprint = {}) : model_(model) , min_articulation_angle_rad_(min_articulation_angle_rad) , max_articulation_angle_rad_(max_articulation_angle_rad) - , front_joint_to_bumper_m_(front_joint_to_bumper_m) - , front_body_width_m_(front_body_width_m) - , rear_joint_to_bumper_m_(rear_joint_to_bumper_m) - , rear_body_width_m_(rear_body_width_m) + , axle_reference_(axle_reference) + , front_footprint_(std::move(front_footprint)) + , rear_footprint_(std::move(rear_footprint)) {} ~ArticulatedProjector() = default; @@ -87,7 +86,7 @@ class ArticulatedProjector /// |articulation_rate_rad_s| per second, never overshooting. Pose is integrated with Euler /// using the post-ramp angle. /// @param dt_s Step duration in seconds (must be > 0) - /// @param current_pose Rear-segment pose at the start of the step + /// @param current_pose Reference-axle pose at the start of the step /// @param current_articulation_angle_rad Articulation angle at the start of the step /// @param target_articulation_angle_rad Desired articulation angle (clamped to [min, max] internally) /// @param articulation_rate_rad_s Magnitude of the ramp rate in rad/s (sign is ignored) @@ -129,41 +128,41 @@ class ArticulatedProjector return max_articulation_angle_rad_; } - double get_front_joint_to_bumper_m() const - { - return front_joint_to_bumper_m_; - } - - double get_front_body_width_m() const + AxleReference get_axle_reference() const { - return front_body_width_m_; + return axle_reference_; } - double get_rear_joint_to_bumper_m() const + const Footprint & get_front_footprint() const { - return rear_joint_to_bumper_m_; + return front_footprint_; } - double get_rear_body_width_m() const + const Footprint & get_rear_footprint() const { - return rear_body_width_m_; + return rear_footprint_; } private: - /// @brief Fill state.front_footprint / state.rear_footprint (world frame) from the rear-segment - /// pose and articulation angle already populated on `state`. A body with width <= 0 yields an - /// empty footprint. - /// TODO: (zeerek) surface footprint-invalid to the user (e.g. a validity flag / status enum on - /// the projected state) instead of silently emitting an empty Footprint. - void fillFootprints(ArticulatedProjectedState & state) const; + /// @brief Convert a pose at the reference axle to the rear axle, where motion is integrated. + Pose2D toRearAxle(const Pose2D & reference_pose, double articulation_angle_rad) const; + + /// @brief Rear-axle pose -> articulation-joint pose (theta unchanged, still the rear heading). + Pose2D jointFromRearAxle(const Pose2D & rear_axle_pose) const; + + /// @brief Rear-axle pose -> front-axle pose, whose theta is the front-body heading. + Pose2D frontAxleFromRearAxle(const Pose2D & rear_axle_pose, double articulation_angle_rad) const; + + /// @brief Populate pose / joint_pose / both footprints from a rear-axle pose and gamma. + void fillPosesAndFootprints( + ArticulatedProjectedState & state, const Pose2D & rear_axle_pose, double articulation_angle_rad) const; ArticulatedModel model_; double min_articulation_angle_rad_; double max_articulation_angle_rad_; - double front_joint_to_bumper_m_; - double front_body_width_m_; - double rear_joint_to_bumper_m_; - double rear_body_width_m_; + AxleReference axle_reference_; + Footprint front_footprint_; + Footprint rear_footprint_; }; } // namespace polymath::kinematics diff --git a/include/polymath_kinematics/bicycle_projector.hpp b/include/polymath_kinematics/bicycle_projector.hpp index e749087..e041cd8 100644 --- a/include/polymath_kinematics/bicycle_projector.hpp +++ b/include/polymath_kinematics/bicycle_projector.hpp @@ -15,19 +15,12 @@ #ifndef POLYMATH_KINEMATICS__BICYCLE_PROJECTOR_HPP__ #define POLYMATH_KINEMATICS__BICYCLE_PROJECTOR_HPP__ +#include #include #include "polymath_kinematics/bicycle_model.hpp" #include "polymath_kinematics/pose2d.hpp" -// TODO(naming): when this projector is in wide use, consider renaming the underlying -// BicycleModel methods for clarity: -// - bodyVelocityToSteering -> inverseKinematics -// - steeringToBodyVelocity -> forwardKinematics -// And dropping the _m / _rad / _rad_s unit suffixes from struct fields (units in docs). -// Also unify the Python Pose2D in polymath_kinematics/__init__.py with the C++ Pose2D -// bound here. - namespace polymath::kinematics { @@ -35,45 +28,46 @@ namespace polymath::kinematics struct BicycleProjectedState { double time_s; ///< Elapsed time from the start of the projection - Pose2D pose; ///< Body pose at this sample + Pose2D pose; ///< Body pose at the projector's reference axle; theta is the chassis heading double steering_angle_rad; ///< Post-ramp steering angle in radians double linear_velocity_m_s; ///< Commanded linear velocity in m/s double angular_velocity_rad_s; ///< Body angular velocity used for theta integration BicycleSteeringState steering_state; ///< Full kinematic snapshot (wheel speeds + turning radius) - Footprint footprint; ///< World-frame body polygon; empty if no footprint dims set + Footprint footprint; ///< World-frame body polygon; empty if no footprint was set }; /// @brief Forward-projection wrapper around BicycleModel that ramps steering toward a target at /// a bounded rate, clamping the target to [min, max] before ramping, and integrates pose with Euler. +/// +/// Poses and the body-frame footprint are both measured from the axle named by `axle_reference`. +/// Motion is always integrated at the rear axle (where the bicycle model is defined); a FRONT +/// reference is a rigid wheelbase offset along the chassis heading, applied on input and output. class BicycleProjector { public: - /// @brief Construct a projector with steering-angle limits and (optionally) footprint dims. + /// @brief Construct a projector with steering-angle limits and (optionally) a body footprint. /// - /// Footprint dimensions describe the vehicle body relative to the pose reference and are owned - /// by the projector (the kinematic model stays dimension-free). The body is a rectangle - /// spanning [-rear_overhang_m, front_overhang_m] along the heading and `body_width_m` across. - /// A body width <= 0 means "unset/invalid": the footprint is emitted empty and projection - /// proceeds normally (never throws). + /// The footprint is an arbitrary polygon in the body frame of `axle_reference`: +x along the + /// chassis heading, +y to the left, origin at that axle. It is owned by the projector, so the + /// kinematic model stays dimension-free. An empty footprint means "unset": projected states + /// carry an empty footprint and projection proceeds normally (never throws). Use + /// `rectangleFootprint()` for a boxy vehicle. /// @param model Bicycle kinematics model (stored by value) /// @param min_steering_angle_rad Minimum allowed steering angle (typically negative) /// @param max_steering_angle_rad Maximum allowed steering angle (typically positive) - /// @param front_overhang_m Distance from the pose reference forward to the front bumper (m) - /// @param rear_overhang_m Distance from the pose reference back to the rear bumper (m) - /// @param body_width_m Body width (m); <= 0 disables the footprint + /// @param axle_reference Axle that poses and the footprint are measured from + /// @param footprint Body-frame body polygon; empty disables footprint output BicycleProjector( BicycleModel model, double min_steering_angle_rad, double max_steering_angle_rad, - double front_overhang_m = 0.0, - double rear_overhang_m = 0.0, - double body_width_m = 0.0) + AxleReference axle_reference = AxleReference::REAR, + Footprint footprint = {}) : model_(model) , min_steering_angle_rad_(min_steering_angle_rad) , max_steering_angle_rad_(max_steering_angle_rad) - , front_overhang_m_(front_overhang_m) - , rear_overhang_m_(rear_overhang_m) - , body_width_m_(body_width_m) + , axle_reference_(axle_reference) + , footprint_(std::move(footprint)) {} ~BicycleProjector() = default; @@ -125,34 +119,28 @@ class BicycleProjector return max_steering_angle_rad_; } - double get_front_overhang_m() const + AxleReference get_axle_reference() const { - return front_overhang_m_; + return axle_reference_; } - double get_rear_overhang_m() const + const Footprint & get_footprint() const { - return rear_overhang_m_; - } - - double get_body_width_m() const - { - return body_width_m_; + return footprint_; } private: - /// @brief Fill state.footprint (world frame) from the pose already populated on `state`. - /// Emits an empty footprint when body_width_m_ <= 0. - /// TODO: (zeerek) surface footprint-invalid to the user (e.g. a validity flag / status enum on - /// the projected state) instead of silently emitting an empty Footprint. - void fillFootprint(BicycleProjectedState & state) const; + /// @brief Convert a pose at the reference axle to the rear axle, where motion is integrated. + Pose2D toRearAxle(const Pose2D & reference_pose) const; + + /// @brief Convert a rear-axle pose back out to the reference axle. + Pose2D fromRearAxle(const Pose2D & rear_axle_pose) const; BicycleModel model_; double min_steering_angle_rad_; double max_steering_angle_rad_; - double front_overhang_m_; - double rear_overhang_m_; - double body_width_m_; + AxleReference axle_reference_; + Footprint footprint_; }; } // namespace polymath::kinematics diff --git a/include/polymath_kinematics/differential_drive_projector.hpp b/include/polymath_kinematics/differential_drive_projector.hpp index 7bfb1a7..e1c221b 100644 --- a/include/polymath_kinematics/differential_drive_projector.hpp +++ b/include/polymath_kinematics/differential_drive_projector.hpp @@ -15,6 +15,7 @@ #ifndef POLYMATH_KINEMATICS__DIFFERENTIAL_DRIVE_PROJECTOR_HPP__ #define POLYMATH_KINEMATICS__DIFFERENTIAL_DRIVE_PROJECTOR_HPP__ +#include #include #include "polymath_kinematics/differential_drive_model.hpp" @@ -31,12 +32,15 @@ struct DifferentialDriveProjectedState double linear_velocity_m_s; ///< Post-ramp body linear velocity in m/s double angular_velocity_rad_s; ///< Post-ramp body angular velocity in rad/s DifferentialDriveWheelVelocities wheel_velocities; ///< Wheel speeds derived from the body command - Footprint footprint; ///< World-frame body polygon; empty if no footprint dims set + Footprint footprint; ///< World-frame body polygon; empty if no footprint was set }; /// @brief Forward-projection wrapper around DifferentialDriveModel that ramps the body command /// (linear_velocity, angular_velocity) toward targets at bounded accelerations, clamping each /// to its [min, max] range before ramping. Pose is integrated with Euler. +/// +/// A differential drive has a single axle, so there is no axle reference to choose: poses and the +/// body-frame footprint are both measured from the body centre. class DifferentialDriveProjector { public: @@ -47,31 +51,24 @@ class DifferentialDriveProjector /// @param min_angular_velocity_rad_s Minimum allowed angular velocity (typically negative) /// @param max_angular_velocity_rad_s Maximum allowed angular velocity /// - /// Footprint dimensions describe the vehicle body relative to the pose reference and are owned - /// by the projector (the kinematic model stays dimension-free). The body is a rectangle - /// spanning [-rear_overhang_m, front_overhang_m] along the heading and `body_width_m` across. - /// A body width <= 0 means "unset/invalid": the footprint is emitted empty and projection - /// proceeds normally (never throws). - /// @param front_overhang_m Distance from the pose reference forward to the front bumper (m) - /// @param rear_overhang_m Distance from the pose reference back to the rear bumper (m) - /// @param body_width_m Body width (m); <= 0 disables the footprint + /// The footprint is an arbitrary polygon in the body frame: +x along the heading, +y to the + /// left, origin at the body centre. It is owned by the projector, so the kinematic model stays + /// dimension-free. An empty footprint means "unset": projected states carry an empty footprint + /// and projection proceeds normally (never throws). Use `rectangleFootprint()` for a boxy body. + /// @param footprint Body-frame body polygon; empty disables footprint output DifferentialDriveProjector( DifferentialDriveModel model, double min_linear_velocity_m_s, double max_linear_velocity_m_s, double min_angular_velocity_rad_s, double max_angular_velocity_rad_s, - double front_overhang_m = 0.0, - double rear_overhang_m = 0.0, - double body_width_m = 0.0) + Footprint footprint = {}) : model_(model) , min_linear_velocity_m_s_(min_linear_velocity_m_s) , max_linear_velocity_m_s_(max_linear_velocity_m_s) , min_angular_velocity_rad_s_(min_angular_velocity_rad_s) , max_angular_velocity_rad_s_(max_angular_velocity_rad_s) - , front_overhang_m_(front_overhang_m) - , rear_overhang_m_(rear_overhang_m) - , body_width_m_(body_width_m) + , footprint_(std::move(footprint)) {} ~DifferentialDriveProjector() = default; @@ -138,36 +135,18 @@ class DifferentialDriveProjector return max_angular_velocity_rad_s_; } - double get_front_overhang_m() const + const Footprint & get_footprint() const { - return front_overhang_m_; - } - - double get_rear_overhang_m() const - { - return rear_overhang_m_; - } - - double get_body_width_m() const - { - return body_width_m_; + return footprint_; } private: - /// @brief Fill state.footprint (world frame) from the pose already populated on `state`. - /// Emits an empty footprint when body_width_m_ <= 0. - /// TODO: (zeerek) surface footprint-invalid to the user (e.g. a validity flag / status enum on - /// the projected state) instead of silently emitting an empty Footprint. - void fillFootprint(DifferentialDriveProjectedState & state) const; - DifferentialDriveModel model_; double min_linear_velocity_m_s_; double max_linear_velocity_m_s_; double min_angular_velocity_rad_s_; double max_angular_velocity_rad_s_; - double front_overhang_m_; - double rear_overhang_m_; - double body_width_m_; + Footprint footprint_; }; } // namespace polymath::kinematics diff --git a/include/polymath_kinematics/pose2d.hpp b/include/polymath_kinematics/pose2d.hpp index 15841a9..36e8e9c 100644 --- a/include/polymath_kinematics/pose2d.hpp +++ b/include/polymath_kinematics/pose2d.hpp @@ -36,11 +36,54 @@ struct Point2D double y; }; -/// @brief A vehicle footprint as a world-frame polygon: 4 corners, counter-clockwise, -/// NOT closed (the first corner is not repeated; consumers close it if needed). An empty -/// footprint means "not computed / invalid" (e.g. when no footprint dimensions were given). +/// @brief A vehicle footprint as a polygon of arbitrary vertex count, counter-clockwise and NOT +/// closed (the first vertex is not repeated; consumers close it if needed). Used both for the +/// body-frame outline handed to a projector and for the world-frame result it emits. An empty +/// footprint means "not set / not computed". using Footprint = std::vector; +/// @brief Which axle a projector's poses and body-frame footprint are measured from. +enum class AxleReference +{ + FRONT, + REAR +}; + +/// @brief Build a rectangular body-frame footprint, the common case for a boxy vehicle. +/// @param front_m Distance from the reference axle forward to the front edge (may be negative) +/// @param rear_m Distance from the reference axle back to the rear edge (positive = behind) +/// @param width_m Total body width; <= 0 returns an empty footprint +/// @return Corners CCW and open: rear-right, front-right, front-left, rear-left +inline Footprint rectangleFootprint(double front_m, double rear_m, double width_m) +{ + if (width_m <= 0.0) { + return Footprint{}; + } + const double half_w = width_m / 2.0; + return Footprint{ + Point2D{-rear_m, -half_w}, Point2D{front_m, -half_w}, Point2D{front_m, half_w}, Point2D{-rear_m, half_w}}; +} + +/// @brief Transform a body-frame footprint into the world frame by `pose`. +inline Footprint transformFootprint(const Footprint & body_frame, const Pose2D & pose) +{ + Footprint world; + world.reserve(body_frame.size()); + const double cos_t = std::cos(pose.theta); + const double sin_t = std::sin(pose.theta); + for (const Point2D & vertex : body_frame) { + world.push_back( + Point2D{pose.x + cos_t * vertex.x - sin_t * vertex.y, pose.y + sin_t * vertex.x + cos_t * vertex.y}); + } + return world; +} + +/// @brief Offset a pose along its own heading; negative `distance_m` moves backward. +inline Pose2D offsetAlongHeading(const Pose2D & pose, double distance_m) +{ + return Pose2D{pose.x + distance_m * std::cos(pose.theta), pose.y + distance_m * std::sin(pose.theta), pose.theta}; +} + /// @brief Wrap an angle to [-pi, pi]. /// std::remainder(angle, 2*pi) maps to the [-pi, pi] interval directly (note: exactly +pi /// maps to +pi, where the old fmod-based implementation returned -pi; both denote the same angle). diff --git a/polymath_kinematics/__init__.py b/polymath_kinematics/__init__.py index 0d021ee..54c7a19 100644 --- a/polymath_kinematics/__init__.py +++ b/polymath_kinematics/__init__.py @@ -12,14 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -import math - from polymath_kinematics_cpp import ( ArticulatedAxleVelocities, ArticulatedModel, ArticulatedProjectedState, ArticulatedProjector, ArticulatedVehicleState, + AxleReference, BicycleBodyVelocity, BicycleModel, BicycleProjectedState, @@ -32,14 +31,21 @@ DifferentialDriveWheelVelocities, Point2D, Pose2D, + rectangle_footprint, + transform_footprint, ) +# The C++ Footprint is std::vector, which pybind11 exposes as a plain list of Point2D. +# Alias it so annotations can name the concept. +Footprint = list[Point2D] + __all__ = [ 'ArticulatedAxleVelocities', 'ArticulatedModel', 'ArticulatedProjectedState', 'ArticulatedProjector', 'ArticulatedVehicleState', + 'AxleReference', 'BicycleBodyVelocity', 'BicycleModel', 'BicycleProjectedState', @@ -50,34 +56,9 @@ 'DifferentialDriveProjectedState', 'DifferentialDriveProjector', 'DifferentialDriveWheelVelocities', + 'Footprint', 'Point2D', 'Pose2D', - 'Twist2D', - 'normalize_angle', - 'transform_pose', + 'rectangle_footprint', + 'transform_footprint', ] - - -class Twist2D: - def __init__(self, linear_x=0.0, linear_y=0.0, angular_z=0.0): - self.linear_x = linear_x - self.linear_y = linear_y - self.angular_z = angular_z - - -def normalize_angle(angle): - """Normalize angle to [-pi, pi]""" - while angle > math.pi: - angle -= 2.0 * math.pi - while angle < -math.pi: - angle += 2.0 * math.pi - return angle - - -def transform_pose(pose, transform): - """Transform a pose by a relative transform""" - result = Pose2D() - result.x = pose.x + transform.x * math.cos(pose.theta) - transform.y * math.sin(pose.theta) - result.y = pose.y + transform.x * math.sin(pose.theta) + transform.y * math.cos(pose.theta) - result.theta = normalize_angle(pose.theta + transform.theta) - return result diff --git a/polymath_kinematics/explorer/__init__.py b/polymath_kinematics/explorer/__init__.py index 1ed7508..e7f73de 100644 --- a/polymath_kinematics/explorer/__init__.py +++ b/polymath_kinematics/explorer/__init__.py @@ -21,11 +21,11 @@ KINEMATIC_EQUATIONS, LATTICE_CONFIG, TRAJECTORY_EQUATIONS, - TRAJECTORY_EQUATIONS_RK4, LatticeConfig, ) from .export import trajectories_to_dataframe from .plotting import ( + get_traj_attr, plot_analysis, plot_articulated_footprint, plot_lattice, @@ -37,9 +37,6 @@ generate_lattice_articulated, generate_lattice_bicycle, generate_lattice_differential, - simulate_trajectory, - simulate_trajectory_euler, - simulate_trajectory_rk4, single_articulated_trajectory, single_bicycle_trajectory, single_differential_trajectory, @@ -64,18 +61,15 @@ 'LATTICE_CONFIG', 'LatticeConfig', 'TRAJECTORY_EQUATIONS', - 'TRAJECTORY_EQUATIONS_RK4', # Simulation 'generate_lattice_articulated', 'generate_lattice_bicycle', 'generate_lattice_differential', - 'simulate_trajectory', - 'simulate_trajectory_euler', - 'simulate_trajectory_rk4', 'single_articulated_trajectory', 'single_bicycle_trajectory', 'single_differential_trajectory', # Plotting + 'get_traj_attr', 'plot_analysis', 'plot_articulated_footprint', 'plot_lattice', 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 index e5b2905..2b25541 100644 --- a/polymath_kinematics/explorer/config.py +++ b/polymath_kinematics/explorer/config.py @@ -17,25 +17,21 @@ from dataclasses import dataclass -# Vehicle footprint positioning (fraction of length) — used only by the fallback footprint -# drawing path when a projector-computed footprint is not available. -REAR_AXLE_POSITION = 0.3 # How far back the rear axle is from center -FRONT_OVERHANG = 0.7 # How far forward the front extends from center -HEADING_ARROW_LENGTH = 0.4 # Arrow length as fraction of vehicle length -HEADING_ARROW_WIDTH = 0.3 # Arrow head width as fraction of vehicle width +# Footprint overhang slider defaults (metres, measured beyond the reference axle/centre). -# Explicit default footprint dimensions (metres) passed to the projectors so projection emits a -# real vehicle outline. These are the projector-owned body dimensions (independent of axle/track -# geometry); the explorer UI seeds its inputs from them. -# Single-body (differential / bicycle): overhangs measured from the pose reference. -DEFAULT_FRONT_OVERHANG_M = 1.0 -DEFAULT_REAR_OVERHANG_M = 0.5 -DEFAULT_BODY_WIDTH_M = 0.8 -# Articulated: each body measured from the articulation joint. -DEFAULT_FRONT_JOINT_TO_BUMPER_M = 2.2 -DEFAULT_FRONT_BODY_WIDTH_M = 2.0 -DEFAULT_REAR_JOINT_TO_BUMPER_M = 2.0 -DEFAULT_REAR_BODY_WIDTH_M = 2.0 +# 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 @@ -113,7 +109,7 @@ class LatticeConfig: 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 (currently 0)', + '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", }, } @@ -122,8 +118,3 @@ class LatticeConfig: **Trajectory Integration (Euler method):** $$\dot{x} = v \cos(\theta), \quad \dot{y} = v \sin(\theta), \quad \dot{\theta} = \omega$$ """ - -TRAJECTORY_EQUATIONS_RK4 = r""" -**Trajectory Integration (RK4 method):** -$$\dot{x} = v \cos(\theta), \quad \dot{y} = v \sin(\theta), \quad \dot{\theta} = \omega$$ -""" diff --git a/polymath_kinematics/explorer/plotting.py b/polymath_kinematics/explorer/plotting.py index c62fccd..eea164a 100644 --- a/polymath_kinematics/explorer/plotting.py +++ b/polymath_kinematics/explorer/plotting.py @@ -21,17 +21,11 @@ import matplotlib.pyplot as plt import numpy as np -from .config import ( - FRONT_OVERHANG, - HEADING_ARROW_LENGTH, - HEADING_ARROW_WIDTH, - LATTICE_CONFIG, - REAR_AXLE_POSITION, -) +from .config import LATTICE_CONFIG from .types import AnyTrajectory, ArticulatedTrajectory, BicycleTrajectory, DifferentialTrajectory -def _get_traj_attr(trajectory: AnyTrajectory, key: str) -> float: +def get_traj_attr(trajectory: AnyTrajectory, key: str) -> float: """Get trajectory attribute by key name.""" return getattr(trajectory, key) @@ -41,64 +35,59 @@ def plot_vehicle_footprint( x: float, y: float, theta: float, - length: float = 1.0, - width: float = 0.5, + 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, - body_corners: np.ndarray | None = None, ) -> None: - """Draw a vehicle footprint (rectangle) at a given pose. + """Draw a single-body vehicle footprint at a given pose. - When ``body_corners`` is provided (an (N, 2) array of world-frame polygon corners, as produced - by the C++ projector's ``footprint``), the body outline is drawn directly from it — the single - source of truth. Otherwise the body falls back to a rectangle derived from ``length``/``width`` - with REAR_AXLE_POSITION / FRONT_OVERHANG controlling the axle placement. + ``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. - When ``steering_angle`` is provided (radians), two front-wheel indicators - are drawn at the front-axle position, rotated by the steering angle in the - body frame. Useful for bicycle/Ackermann visualisation; pass ``None`` for - models that don't have steered wheels (e.g. differential drive). + ``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]]) - if body_corners is not None: - body = np.asarray(body_corners, dtype=float) - ax.fill(body[:, 0], body[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5) - else: - corners_local = np.array([ - [-length * REAR_AXLE_POSITION, -width / 2], - [length * FRONT_OVERHANG, -width / 2], - [length * FRONT_OVERHANG, width / 2], - [-length * REAR_AXLE_POSITION, width / 2], - [-length * REAR_AXLE_POSITION, -width / 2], - ]) - corners_world = (rotation_matrix @ corners_local.T).T + np.array([x, y]) - ax.fill(corners_world[:, 0], corners_world[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5) - - arrow_length = length * HEADING_ARROW_LENGTH + 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( - x, - y, - arrow_length * np.cos(theta), - arrow_length * np.sin(theta), - head_width=width * HEADING_ARROW_WIDTH, - head_length=length * 0.1, + 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: - # Front-wheel indicators: two filled rectangles at the front-axle (FRONT_OVERHANG) - # position, offset left/right by half the track width, each rotated by steering_angle - # in the body frame, then transformed to world by theta. - front_x_local = length * FRONT_OVERHANG - wheel_l = length * wheel_length_frac - wheel_w = width * wheel_width_frac + 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], @@ -109,8 +98,8 @@ def plot_vehicle_footprint( 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 (-width / 2.0, +width / 2.0): - corners_body = wheel_corners_steered + np.array([front_x_local, lateral_offset]) + 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], @@ -124,129 +113,47 @@ def plot_vehicle_footprint( def plot_articulated_footprint( ax: plt.Axes, - x_front_axle: float, - y_front_axle: float, - theta: float, - articulation_angle: float, - front_length: float, - rear_length: float, - front_width: float, - rear_width: float, + front_corners: np.ndarray, + rear_corners: np.ndarray, + joint_xy: np.ndarray, + front_theta: float, color: str = 'blue', alpha: float = 0.5, - front_corners: np.ndarray | None = None, - rear_corners: np.ndarray | None = None, ) -> None: - """Draw an articulated vehicle footprint (two connected rectangles) at a given pose. + """Draw an articulated vehicle footprint: two independently posed body polygons. - When ``front_corners`` / ``rear_corners`` are provided (each an (N, 2) array of world-frame - polygon corners, as produced by the C++ projector's ``front_footprint`` / ``rear_footprint``), - the two bodies are drawn directly from them — the single source of truth, with the correct - rear-segment anchoring. The joint marker and heading arrow are then derived from the corners. - Otherwise the bodies fall back to being computed from the axle lengths and track widths, - anchored at the front axle. - - Args: - x_front_axle, y_front_axle: Position of the front axle center (fallback path) - theta: Heading of the front body (fallback path) - articulation_angle: Angle between front and rear bodies (positive = left turn) - front_length: Distance from articulation joint to front axle (L_f) - rear_length: Distance from articulation joint to rear axle (L_r) - front_width, rear_width: Track widths - color: Fill color - alpha: Transparency + 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. """ - if front_corners is not None or rear_corners is not None: - joint_point = None - if front_corners is not None: - fc = np.asarray(front_corners, dtype=float) - ax.fill(fc[:, 0], fc[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5) - # C++ front body corners are [joint, front_bumper, front_bumper, joint]; joint is the - # midpoint of the two joint-side corners (indices 0 and 3). - joint_point = (fc[0] + fc[3]) / 2.0 - front_bumper_mid = (fc[1] + fc[2]) / 2.0 - if rear_corners is not None: - rc = np.asarray(rear_corners, dtype=float) - ax.fill(rc[:, 0], rc[:, 1], color=color, alpha=alpha * 0.8, edgecolor=color, linewidth=0.5) - if joint_point is None: - # Rear body corners are [rear_bumper, joint, joint, rear_bumper]; joint = mid(1, 2). - joint_point = (rc[1] + rc[2]) / 2.0 - if joint_point is not None: - ax.plot(joint_point[0], joint_point[1], 'o', color=color, markersize=4, alpha=min(1.0, alpha + 0.3)) - if front_corners is not None: - heading_vec = front_bumper_mid - joint_point - ax.arrow( - joint_point[0], - joint_point[1], - heading_vec[0], - heading_vec[1], - head_width=front_width * 0.2, - head_length=front_length * 0.08 if front_length else 0.05, - fc=color, - ec=color, - alpha=min(1.0, alpha + 0.3), - length_includes_head=True, - ) - return - - # Compute articulation joint position - joint_x = x_front_axle - front_length * np.cos(theta) - joint_y = y_front_axle - front_length * np.sin(theta) - - # Front body corners - front_corners_local = np.array([ - [0, -front_width / 2], - [front_length, -front_width / 2], - [front_length, front_width / 2], - [0, front_width / 2], - [0, -front_width / 2], - ]) - - cos_front, sin_front = np.cos(theta), np.sin(theta) - rotation_front = np.array([[cos_front, -sin_front], [sin_front, cos_front]]) - front_corners_world = (rotation_front @ front_corners_local.T).T + np.array([joint_x, joint_y]) - ax.fill( - front_corners_world[:, 0], front_corners_world[:, 1], color=color, alpha=alpha, edgecolor=color, linewidth=0.5 - ) - - # Rear body - rear_theta = theta - articulation_angle - rear_corners_local = np.array([ - [-rear_length, -rear_width / 2], - [0, -rear_width / 2], - [0, rear_width / 2], - [-rear_length, rear_width / 2], - [-rear_length, -rear_width / 2], - ]) - - cos_rear, sin_rear = np.cos(rear_theta), np.sin(rear_theta) - rotation_rear = np.array([[cos_rear, -sin_rear], [sin_rear, cos_rear]]) - rear_corners_world = (rotation_rear @ rear_corners_local.T).T + np.array([joint_x, joint_y]) - ax.fill( - rear_corners_world[:, 0], - rear_corners_world[:, 1], - color=color, - alpha=alpha * 0.8, - edgecolor=color, - linewidth=0.5, - ) - - # Articulation joint marker - ax.plot(joint_x, joint_y, 'o', color=color, markersize=4, alpha=min(1.0, alpha + 0.3)) - - # Heading arrow - arrow_length = front_length * 0.4 - ax.arrow( - x_front_axle, - y_front_axle, - arrow_length * np.cos(theta), - arrow_length * np.sin(theta), - head_width=front_width * 0.2, - head_length=front_length * 0.08, - fc=color, - ec=color, - alpha=min(1.0, alpha + 0.3), - ) + 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( @@ -276,8 +183,8 @@ def select_symmetric_trajectories( 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)) + 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: @@ -296,11 +203,11 @@ def select_symmetric_trajectories( 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) + 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)) + selected.sort(key=lambda trajectory: get_traj_attr(trajectory, angle_key)) return selected @@ -326,16 +233,20 @@ def plot_trajectory_with_footprints( ) -> 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: Dict with model dimensions + 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=(10, 8), layout='constrained') + fig, ax = plt.subplots(figsize=(16, 9), layout='constrained') colormap = plt.cm.tab10 legend_handles = [] @@ -356,80 +267,67 @@ def plot_trajectory_with_footprints( 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: - sample_articulation_angle = float(articulated.articulation_angle_series[footprint_index]) + gamma = float(articulated.articulation_angle_series[footprint_index]) else: - sample_articulation_angle = articulated.articulation_angle - # Prefer the projector-computed footprints (single source of truth); fall back to - # axle-length geometry when they are unavailable. - front_corners = ( - articulated.front_footprint_series[footprint_index] - if articulated.front_footprint_series is not None - else None - ) - rear_corners = ( - articulated.rear_footprint_series[footprint_index] - if articulated.rear_footprint_series is not None - else None - ) + gamma = articulated.articulation_angle plot_articulated_footprint( ax, - trajectory.x[footprint_index], - trajectory.y[footprint_index], - trajectory.theta[footprint_index], - articulation_angle=sample_articulation_angle, - front_length=model_params['front_length'], - rear_length=model_params['rear_length'], - front_width=model_params['front_width'], - rear_width=model_params['rear_width'], + 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, - front_corners=front_corners, - rear_corners=rear_corners, ) 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 - body_corners = ( - bicycle.footprint_series[footprint_index] if bicycle.footprint_series is not None else None - ) plot_vehicle_footprint( ax, trajectory.x[footprint_index], trajectory.y[footprint_index], trajectory.theta[footprint_index], - length=model_params['length'], - width=model_params['width'], + bicycle.footprint_series[footprint_index], color=color, alpha=footprint_alpha, steering_angle=sample_steering_angle, - body_corners=body_corners, + front_axle_offset_m=model_params['wheelbase'], + track_width_m=model_params['track_width'], ) else: # Differential Drive — no steered wheels. - body_corners = ( - trajectory.footprint_series[footprint_index] - if trajectory.footprint_series is not None - else None - ) + if trajectory.footprint_series is None: + continue plot_vehicle_footprint( ax, trajectory.x[footprint_index], trajectory.y[footprint_index], trajectory.theta[footprint_index], - length=model_params['length'], - width=model_params['width'], + trajectory.footprint_series[footprint_index], color=color, alpha=footprint_alpha, - body_corners=body_corners, ) 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) @@ -463,12 +361,14 @@ def plot_lattice( config = LATTICE_CONFIG[model_type] num_columns = len(group_values) - fig, axes = plt.subplots(1, num_columns, figsize=(5 * num_columns, 5), layout='constrained') + # 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] + 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: @@ -481,14 +381,16 @@ def plot_lattice( 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) + 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) @@ -551,7 +453,7 @@ def plot_analysis( ax_right.set_xlabel('|Articulation Angle| (deg)') ax_right.set_ylabel('Turning Radius (m)') ax_right.set_title('Turning Radius vs Articulation') - ax_right.legend() + # Single unlabeled series — no legend. ax_right.grid(True, alpha=0.3) ax_right.set_ylim(bottom=0) @@ -589,7 +491,7 @@ def plot_analysis( ax_right.set_xlabel('|Steering Angle| (deg)') ax_right.set_ylabel('Turning Radius (m)') ax_right.set_title('Turning Radius vs Steering') - ax_right.legend() + # Single unlabeled series — no legend. ax_right.grid(True, alpha=0.3) ax_right.set_ylim(bottom=0) diff --git a/polymath_kinematics/explorer/simulation.py b/polymath_kinematics/explorer/simulation.py index 5a1da19..66e513d 100644 --- a/polymath_kinematics/explorer/simulation.py +++ b/polymath_kinematics/explorer/simulation.py @@ -13,11 +13,9 @@ # limitations under the License. """Trajectory simulation and lattice generation functions. -Bicycle and articulated lattices are generated via the C++ BicycleProjector / -ArticulatedProjector forward-simulation classes (Euler integration with -steering-rate ramping support). The differential drive lattice keeps a -vectorized Euler integration here because there is no DifferentialDriveProjector -in the C++ library. +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. """ @@ -29,106 +27,18 @@ from polymath_kinematics import ( ArticulatedModel, ArticulatedProjector, + AxleReference, BicycleModel, BicycleProjector, DifferentialDriveModel, DifferentialDriveProjector, + Footprint, Pose2D, ) from .types import ArticulatedTrajectory, BicycleTrajectory, DifferentialTrajectory -def simulate_trajectory_euler( - linear_velocity: float, - angular_velocity: float, - duration: float = 5.0, - time_step: float = 0.02, -) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """Simulate trajectory using vectorized Euler integration. - - For constant angular velocity, theta is analytic: theta(t) = omega * t. - This allows fully vectorized computation of x and y via cumulative sum. - - Returns: - Tuple of (time, x, y, theta) arrays - """ - num_steps = int(duration / time_step) - time_array = np.linspace(0, duration, num_steps) - - # Theta is analytic for constant angular velocity - theta = angular_velocity * time_array - - # Compute velocities at each timestep (vectorized) - velocity_x = linear_velocity * np.cos(theta) - velocity_y = linear_velocity * np.sin(theta) - - # Integrate via cumulative sum (Euler method, vectorized) - x = np.concatenate([[0], np.cumsum(velocity_x[:-1]) * time_step]) - y = np.concatenate([[0], np.cumsum(velocity_y[:-1]) * time_step]) - - return time_array, x, y, theta - - -def simulate_trajectory_rk4( - linear_velocity: float, - angular_velocity: float, - duration: float = 5.0, - time_step: float = 0.02, -) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """Simulate trajectory using 4th-order Runge-Kutta integration. - - For constant angular velocity, we can still vectorize theta, then use - RK4-style weighted averaging for x and y positions. - - Returns: - Tuple of (time, x, y, theta) arrays - """ - num_steps = int(duration / time_step) - time_array = np.linspace(0, duration, num_steps) - - theta = angular_velocity * time_array - theta_prev = theta[:-1] - theta_mid = theta_prev + angular_velocity * time_step / 2 - theta_next = theta_prev + angular_velocity * time_step - - k1_x = linear_velocity * np.cos(theta_prev) - k1_y = linear_velocity * np.sin(theta_prev) - k2_x = linear_velocity * np.cos(theta_mid) - k2_y = linear_velocity * np.sin(theta_mid) - k4_x = linear_velocity * np.cos(theta_next) - k4_y = linear_velocity * np.sin(theta_next) - - delta_x = (k1_x + 4 * k2_x + k4_x) / 6 * time_step - delta_y = (k1_y + 4 * k2_y + k4_y) / 6 * time_step - - x = np.concatenate([[0], np.cumsum(delta_x)]) - y = np.concatenate([[0], np.cumsum(delta_y)]) - - return time_array, x, y, theta - - -def simulate_trajectory( - linear_velocity: float, - angular_velocity: float, - duration: float = 5.0, - time_step: float = 0.02, - method: str = 'euler', -) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """Simulate a constant-cmd_vel trajectory. - - Args: - linear_velocity: Linear velocity (m/s) - angular_velocity: Angular velocity (rad/s) - duration: Simulation duration (s) - time_step: Time step (s) - method: Integration method ("euler" or "rk4") - """ - if method == 'rk4': - return simulate_trajectory_rk4(linear_velocity, angular_velocity, duration, time_step) - return simulate_trajectory_euler(linear_velocity, angular_velocity, duration, time_step) - - def _states_to_arrays(states): """Pull (time, x, y, theta, omega) arrays out of a list of ProjectedState samples.""" n = len(states) @@ -147,12 +57,12 @@ def _states_to_arrays(states): def _footprint_series(states, attr: str): - """Stack the per-sample footprint polygons from a projection into an (N, 4, 2) array. + """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). Returns ``None`` if any - sample has an empty footprint (i.e. the projector was given no footprint dimensions), so - downstream consumers can fall back cleanly. + 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: @@ -163,6 +73,11 @@ def _footprint_series(states, attr: str): 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, @@ -170,38 +85,73 @@ def generate_lattice_differential( wheel_velocity_diffs: tuple[float, ...], duration: float, time_step: float = 0.02, - method: str = 'euler', + footprint: Footprint | None = None, ) -> list[DifferentialTrajectory]: - """Generate trajectory lattice by sweeping wheel velocity differences.""" + """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) - trajectories: list[DifferentialTrajectory] = [] + # 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) - linear_velocity = body_velocity.linear_velocity_m_s - angular_velocity = body_velocity.angular_velocity_rad_s + cells.append(( + base_velocity, + left_wheel, + right_wheel, + body_velocity.linear_velocity_m_s, + body_velocity.angular_velocity_rad_s, + )) + + if not cells: + return [] - time_array, x, y, theta = simulate_trajectory( - linear_velocity, angular_velocity, duration, time_step, method - ) + 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.append( - DifferentialTrajectory( - time=time_array, - x=x, - y=y, - theta=theta, - linear_velocity=linear_velocity, - angular_velocity=angular_velocity, - left_wheel=left_wheel, - right_wheel=right_wheel, - base_wheel_velocity=base_velocity, - ) + 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 @@ -214,13 +164,11 @@ def generate_lattice_bicycle( steering_angles: tuple[float, ...], duration: float, time_step: float = 0.02, - method: str = 'euler', # accepted for API compatibility; projector is Euler-only min_steering_angle_rad: float | None = None, max_steering_angle_rad: float | None = None, steering_rate_rad_s: float = 0.0, - front_overhang_m: float = 0.0, - rear_overhang_m: float = 0.0, - body_width_m: float = 0.0, + axle_reference: AxleReference = AxleReference.REAR, + footprint: Footprint | None = None, ) -> list[BicycleTrajectory]: """Generate trajectory lattice by sweeping steering angles. @@ -230,12 +178,7 @@ def generate_lattice_bicycle( 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. - - Note: ``method`` is accepted for backward compatibility but ignored. The - projector uses Euler integration only; RK4 trajectory generation has - been retired in favor of a single forward-simulation codepath. """ - del method # accepted for API compat; projector is Euler-only. angles = list(steering_angles) if not angles: return [] @@ -243,9 +186,7 @@ def generate_lattice_bicycle( 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, front_overhang_m, rear_overhang_m, body_width_m - ) + projector = BicycleProjector(model, min_angle, max_angle, axle_reference, footprint or []) trajectories: list[BicycleTrajectory] = [] for drive_velocity in drive_velocities: @@ -290,21 +231,17 @@ def generate_lattice_articulated( articulation_angles: tuple[float, ...], duration: float, time_step: float = 0.02, - method: str = 'euler', # accepted for API compatibility; projector is Euler-only min_articulation_angle_rad: float | None = None, max_articulation_angle_rad: float | None = None, articulation_rate_rad_s: float = 0.0, - front_joint_to_bumper_m: float = 0.0, - front_body_width_m: float = 0.0, - rear_joint_to_bumper_m: float = 0.0, - rear_body_width_m: 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 and - the meaning of ``method``. + See ``generate_lattice_bicycle`` for the projector-based rationale. """ - del method # accepted for API compat; projector is Euler-only. angles = list(articulation_angles) if not angles: return [] @@ -320,13 +257,7 @@ def generate_lattice_articulated( rear_wheel_radius, ) projector = ArticulatedProjector( - model, - min_angle, - max_angle, - front_joint_to_bumper_m, - front_body_width_m, - rear_joint_to_bumper_m, - rear_body_width_m, + model, min_angle, max_angle, axle_reference, front_footprint or [], rear_footprint or [] ) trajectories: list[ArticulatedTrajectory] = [] @@ -356,6 +287,7 @@ def generate_lattice_articulated( 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), ) ) @@ -382,9 +314,8 @@ def single_bicycle_trajectory( time_step: float = 0.02, min_steering_angle_rad: float | None = None, max_steering_angle_rad: float | None = None, - front_overhang_m: float = 0.0, - rear_overhang_m: float = 0.0, - body_width_m: float = 0.0, + 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. @@ -398,9 +329,7 @@ def single_bicycle_trajectory( 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, front_overhang_m, rear_overhang_m, body_width_m - ) + 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, @@ -442,10 +371,9 @@ def single_articulated_trajectory( time_step: float = 0.02, min_articulation_angle_rad: float | None = None, max_articulation_angle_rad: float | None = None, - front_joint_to_bumper_m: float = 0.0, - front_body_width_m: float = 0.0, - rear_joint_to_bumper_m: float = 0.0, - rear_body_width_m: float = 0.0, + 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: @@ -465,10 +393,9 @@ def single_articulated_trajectory( model, min_articulation_angle_rad, max_articulation_angle_rad, - front_joint_to_bumper_m, - front_body_width_m, - rear_joint_to_bumper_m, - rear_body_width_m, + axle_reference, + front_footprint or [], + rear_footprint or [], ) states = projector.project( horizon_s=duration, @@ -494,6 +421,7 @@ def single_articulated_trajectory( 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), ) @@ -512,9 +440,7 @@ def single_differential_trajectory( max_linear_velocity: float | None = None, min_angular_velocity: float | None = None, max_angular_velocity: float | None = None, - front_overhang_m: float = 0.0, - rear_overhang_m: float = 0.0, - body_width_m: float = 0.0, + 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`. @@ -535,9 +461,7 @@ def single_differential_trajectory( max_linear_velocity, min_angular_velocity, max_angular_velocity, - front_overhang_m, - rear_overhang_m, - body_width_m, + footprint or [], ) states = projector.project( horizon_s=duration, diff --git a/polymath_kinematics/explorer/types.py b/polymath_kinematics/explorer/types.py index 1c9ad1f..cb83450 100644 --- a/polymath_kinematics/explorer/types.py +++ b/polymath_kinematics/explorer/types.py @@ -26,9 +26,9 @@ 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, 4, 2)`` array of world-frame polygon corners (N samples, 4 corners, - xy). It is ``None`` when the projector was given no footprint dimensions (single-body models - only; the articulated model uses the front/rear series on ``ArticulatedTrajectory``). + 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 @@ -82,10 +82,13 @@ class ArticulatedTrajectory(Trajectory): 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, 4, 2) array of - # world-frame polygon corners. None when the projector was given no footprint dimensions. + # 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 diff --git a/polymath_kinematics/kinematic_explorer_app.py b/polymath_kinematics/kinematic_explorer_app.py index 1457722..67fdc47 100644 --- a/polymath_kinematics/kinematic_explorer_app.py +++ b/polymath_kinematics/kinematic_explorer_app.py @@ -26,11 +26,11 @@ 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, - TRAJECTORY_EQUATIONS_RK4, AnyTrajectory, ArticulatedTrajectory, BicycleTrajectory, @@ -38,16 +38,40 @@ 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 FRONT_OVERHANG, REAR_AXLE_POSITION -from polymath_kinematics.explorer.plotting import _get_traj_attr +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 @@ -58,10 +82,18 @@ def _cached_lattice_differential( wheel_velocity_diffs: tuple[float, ...], duration: float, time_step: float, - method: str, + 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, method + 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), ) @@ -74,7 +106,9 @@ def _cached_lattice_bicycle( steering_angles: tuple[float, ...], duration: float, time_step: float, - method: str, + front_overhang: float, + rear_overhang: float, + axle_reference: str, ) -> list[BicycleTrajectory]: return generate_lattice_bicycle( wheelbase, @@ -84,10 +118,8 @@ def _cached_lattice_bicycle( steering_angles, duration, time_step, - method, - front_overhang_m=wheelbase * FRONT_OVERHANG, - rear_overhang_m=wheelbase * REAR_AXLE_POSITION, - body_width_m=track_width, + axle_reference=AXLE_REFERENCES[axle_reference], + footprint=_bicycle_footprint(wheelbase, track_width, front_overhang, rear_overhang, axle_reference), ) @@ -103,7 +135,9 @@ def _cached_lattice_articulated( articulation_angles: tuple[float, ...], duration: float, time_step: float, - method: str, + front_overhang: float, + rear_overhang: float, + axle_reference: str, ) -> list[ArticulatedTrajectory]: return generate_lattice_articulated( articulation_to_front, @@ -116,11 +150,9 @@ def _cached_lattice_articulated( articulation_angles, duration, time_step, - method, - front_joint_to_bumper_m=articulation_to_front, - front_body_width_m=front_track, - rear_joint_to_bumper_m=articulation_to_rear, - rear_body_width_m=rear_track, + 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), ) @@ -135,6 +167,9 @@ def _cached_single_bicycle( drive_velocity: float, duration: float, time_step: float, + front_overhang: float, + rear_overhang: float, + axle_reference: str, ) -> BicycleTrajectory: return single_bicycle_trajectory( wheelbase, @@ -146,9 +181,8 @@ def _cached_single_bicycle( drive_velocity=drive_velocity, duration=duration, time_step=time_step, - front_overhang_m=wheelbase * FRONT_OVERHANG, - rear_overhang_m=wheelbase * REAR_AXLE_POSITION, - body_width_m=track_width, + axle_reference=AXLE_REFERENCES[axle_reference], + footprint=_bicycle_footprint(wheelbase, track_width, front_overhang, rear_overhang, axle_reference), ) @@ -166,6 +200,9 @@ def _cached_single_articulated( 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, @@ -180,10 +217,11 @@ def _cached_single_articulated( drive_velocity=drive_velocity, duration=duration, time_step=time_step, - front_joint_to_bumper_m=articulation_to_front, - front_body_width_m=front_track, - rear_joint_to_bumper_m=articulation_to_rear, - rear_body_width_m=rear_track, + 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), ) @@ -199,9 +237,9 @@ def _cached_single_differential( angular_acceleration: float, duration: float, time_step: float, + front_overhang: float, + rear_overhang: float, ) -> DifferentialTrajectory: - # Vehicle length is track_width * 1.5 to match the plotted footprint box. - length = track_width * 1.5 return single_differential_trajectory( wheel_radius, track_width, @@ -213,9 +251,8 @@ def _cached_single_differential( angular_acceleration=angular_acceleration, duration=duration, time_step=time_step, - front_overhang_m=length * FRONT_OVERHANG, - rear_overhang_m=length * REAR_AXLE_POSITION, - body_width_m=track_width, + # Pose reference is the body centre; see _cached_lattice_differential. + footprint=rectangle_footprint(front_overhang, rear_overhang, track_width), ) @@ -227,7 +264,6 @@ def get_config_dict(model_type: str) -> dict: 'simulation': { 'duration': st.session_state.sim_duration, 'dt': st.session_state.sim_dt, - 'method': st.session_state.get('sim_method', 'euler'), }, } @@ -235,6 +271,8 @@ def get_config_dict(model_type: str) -> dict: 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, @@ -248,6 +286,9 @@ def get_config_dict(model_type: str) -> dict: '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, @@ -264,6 +305,9 @@ def get_config_dict(model_type: str) -> dict: '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, @@ -282,6 +326,8 @@ def init_session_state(): # 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, @@ -291,6 +337,9 @@ def init_session_state(): '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, @@ -301,6 +350,9 @@ def init_session_state(): '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, @@ -311,7 +363,6 @@ def init_session_state(): # Simulation 'sim_duration': 3.0, 'sim_dt': 0.02, - 'sim_method': 'euler', 'export_dpi': 150, 'export_format': 'png', } @@ -339,9 +390,28 @@ def init_session_state(): 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) @@ -351,9 +421,36 @@ def init_session_state(): 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( @@ -368,6 +465,28 @@ def init_session_state(): 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 ) @@ -384,12 +503,7 @@ def init_session_state(): ) with st.sidebar.expander('Advanced Settings'): - st.session_state.sim_method = st.selectbox( - 'Integration Method', - ['euler', 'rk4'], - index=0 if st.session_state.sim_method == 'euler' else 1, - help='RK4 is more accurate, Euler is faster', - ) + 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, @@ -441,7 +555,8 @@ def init_session_state(): wheel_velocity_diffs=wheel_diffs, duration=st.session_state.sim_duration, time_step=st.session_state.sim_dt, - method=st.session_state.sim_method, + front_overhang=st.session_state.diff_front_overhang, + rear_overhang=st.session_state.diff_rear_overhang, ) group_values = list(base_wheel_velocities) @@ -481,7 +596,9 @@ def init_session_state(): steering_angles=steering_angles, duration=st.session_state.sim_duration, time_step=st.session_state.sim_dt, - method=st.session_state.sim_method, + 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) @@ -522,7 +639,9 @@ def init_session_state(): articulation_angles=articulation_angles, duration=st.session_state.sim_duration, time_step=st.session_state.sim_dt, - method=st.session_state.sim_method, + 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) @@ -534,54 +653,61 @@ def init_session_state(): st.latex(eq) st.markdown(eq_info['variables']) st.markdown('---') - if st.session_state.sim_method == 'rk4': - st.markdown(TRAJECTORY_EQUATIONS_RK4) - else: - st.markdown(TRAJECTORY_EQUATIONS) + st.markdown(TRAJECTORY_EQUATIONS) -# Trajectory Lattice +# 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, group_values) -st.pyplot(lattice_fig) +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) -st.pyplot(analysis_fig) +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': - model_params = { - 'length': st.session_state.diff_track_width * 1.5, - 'width': st.session_state.diff_track_width, - } - vel_options = list(group_values) vel_label = 'Base Wheel Velocity (rad/s)' - st.caption(f'Vehicle dimensions: {model_params["length"]:.2f}m x {model_params["width"]:.2f}m') + 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 = { - 'length': st.session_state.bike_wheelbase, - 'width': st.session_state.bike_track_width, + '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_options = list(group_values) vel_label = 'Drive Velocity (m/s)' - st.caption(f'Vehicle dimensions: wheelbase={model_params["length"]:.2f}m, track={model_params["width"]:.2f}m') + 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 - model_params = { - 'front_length': st.session_state.art_to_front, - 'rear_length': st.session_state.art_to_rear, - 'front_width': st.session_state.art_front_track, - 'rear_width': st.session_state.art_rear_track, - } - vel_options = list(group_values) 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={model_params["front_length"]:.2f}m x {model_params["front_width"]:.2f}m, ' - f'rear={model_params["rear_length"]:.2f}m x {model_params["rear_width"]:.2f}m' + 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]) @@ -613,23 +739,13 @@ def init_session_state(): 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 + trajectory for trajectory in trajectories if abs(get_traj_attr(trajectory, config.vel_key) - viz_vel) < 0.05 ] - - all_angles = sorted(set(_get_traj_attr(trajectory, config.angle_key) for trajectory in velocity_filtered)) - if len(all_angles) <= num_angles_viz: - selected_angles = set(all_angles) - else: - indices = np.linspace(0, len(all_angles) - 1, num_angles_viz, dtype=int) - selected_angles = {all_angles[index] for index in indices} - - viz_trajectories = [ - trajectory - for trajectory in velocity_filtered - if any(abs(_get_traj_attr(trajectory, config.angle_key) - angle) < 1e-6 for angle in selected_angles) - ] - viz_trajectories.sort(key=lambda trajectory: _get_traj_attr(trajectory, config.angle_key)) + 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) @@ -646,6 +762,15 @@ def init_session_state(): '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: @@ -686,10 +811,11 @@ def init_session_state(): 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, ) - single_fig = plot_trajectory_with_footprints([single_traj], model_type, model_params, num_footprints=5) - st.pyplot(single_fig) - plt.close(single_fig) + _render_single_trajectory(single_traj) st.caption( f'Drive velocity: {viz_vel:.2f} m/s · ' f'Horizon: {st.session_state.sim_duration:.1f}s · ' @@ -739,10 +865,11 @@ def init_session_state(): 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, ) - single_fig = plot_trajectory_with_footprints([single_traj], model_type, model_params, num_footprints=5) - st.pyplot(single_fig) - plt.close(single_fig) + _render_single_trajectory(single_traj) st.caption( f'Drive velocity: {viz_vel:.2f} m/s · ' f'Horizon: {st.session_state.sim_duration:.1f}s · ' @@ -820,10 +947,10 @@ def init_session_state(): 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, ) - single_fig = plot_trajectory_with_footprints([single_traj], model_type, model_params, num_footprints=5) - st.pyplot(single_fig) - plt.close(single_fig) + _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 @@ -842,11 +969,7 @@ def init_session_state(): st.markdown(f'- {key.replace("_", " ").title()}: `{value}`') st.markdown('**Simulation Settings:**') - st.markdown( - f'- Duration: `{config_dict["simulation"]["duration"]}s`, ' - f'dt: `{config_dict["simulation"]["dt"]}s`, ' - f'Method: `{config_dict["simulation"]["method"]}`' - ) + st.markdown(f'- Duration: `{config_dict["simulation"]["duration"]}s`, dt: `{config_dict["simulation"]["dt"]}s`') # Summary stats st.header('Summary') @@ -904,30 +1027,19 @@ def init_session_state(): mime='application/json', ) -# Image Export -if st.sidebar.button('Export Lattice Image'): - export_fig = plot_lattice(trajectories, model_type, group_values) - filename = f'lattice_export.{st.session_state.export_format}' - if st.session_state.export_format == 'png': - export_fig.savefig(filename, dpi=st.session_state.export_dpi, bbox_inches='tight') - else: - export_fig.savefig(filename, bbox_inches='tight') - plt.close(export_fig) - st.sidebar.success(f'Saved: {filename}') - - -def main(): - """Entry point for launching the Kinematic Explorer via ros2 run or CLI.""" - import subprocess - import sys - - cmd = [ - sys.executable, - '-m', - 'streamlit', - 'run', - __file__, - '--server.address=localhost', - '--browser.gatherUsageStats=false', - ] - sys.exit(subprocess.call(cmd)) +# 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/pyproject.toml b/pyproject.toml index f06eab6..54e2727 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dev = [ ] [project.scripts] -kinematic-explorer = "polymath_kinematics.kinematic_explorer_app:main" +kinematic-explorer = "polymath_kinematics.explorer.cli:main" [tool.scikit-build] cmake.version = ">=3.15" @@ -39,10 +39,7 @@ build.verbose = false cmake.args = ["-DBUILD_TESTING=OFF", "-DCMAKE_DISABLE_FIND_PACKAGE_ament_cmake=ON"] wheel.packages = ["polymath_kinematics"] +# uv reads this; defers to the extras above so the pins live in one place. [dependency-groups] -dev = [ - "pytest>=7.0.0", - "streamlit>=1.20.0", - "matplotlib>=3.5.0", - "pandas>=1.4.0", -] +dev = [{ include-group = "explorer" }, "pytest>=7.0.0"] +explorer = ["polymath_kinematics[explorer]"] diff --git a/src/articulated_projector.cpp b/src/articulated_projector.cpp index 3737a46..f8b2e3d 100644 --- a/src/articulated_projector.cpp +++ b/src/articulated_projector.cpp @@ -21,6 +21,42 @@ namespace polymath::kinematics { +Pose2D ArticulatedProjector::jointFromRearAxle(const Pose2D & rear_axle_pose) const +{ + return offsetAlongHeading(rear_axle_pose, model_.get_articulation_to_rear_axle_m()); +} + +Pose2D ArticulatedProjector::frontAxleFromRearAxle(const Pose2D & rear_axle_pose, double articulation_angle_rad) const +{ + // Walk rear axle -> joint along the rear heading, then joint -> front axle along the front + // heading (theta_front = theta_rear + gamma). + const Pose2D joint = jointFromRearAxle(rear_axle_pose); + const Pose2D front_heading{joint.x, joint.y, normalizeAngle(joint.theta + articulation_angle_rad)}; + return offsetAlongHeading(front_heading, model_.get_articulation_to_front_axle_m()); +} + +Pose2D ArticulatedProjector::toRearAxle(const Pose2D & reference_pose, double articulation_angle_rad) const +{ + if (axle_reference_ == AxleReference::REAR) { + return reference_pose; + } + // Invert frontAxleFromRearAxle: theta here is the front-body heading. + const Pose2D joint = offsetAlongHeading(reference_pose, -model_.get_articulation_to_front_axle_m()); + const Pose2D rear_heading{joint.x, joint.y, normalizeAngle(joint.theta - articulation_angle_rad)}; + return offsetAlongHeading(rear_heading, -model_.get_articulation_to_rear_axle_m()); +} + +void ArticulatedProjector::fillPosesAndFootprints( + ArticulatedProjectedState & state, const Pose2D & rear_axle_pose, double articulation_angle_rad) const +{ + const Pose2D front_axle_pose = frontAxleFromRearAxle(rear_axle_pose, articulation_angle_rad); + state.joint_pose = jointFromRearAxle(rear_axle_pose); + state.pose = axle_reference_ == AxleReference::REAR ? rear_axle_pose : front_axle_pose; + // Each body's polygon is anchored at its own axle, so it stays rigid as the joint articulates. + state.front_footprint = transformFootprint(front_footprint_, front_axle_pose); + state.rear_footprint = transformFootprint(rear_footprint_, rear_axle_pose); +} + ArticulatedProjectedState ArticulatedProjector::step( double dt_s, const Pose2D & current_pose, @@ -52,55 +88,19 @@ ArticulatedProjectedState ArticulatedProjector::step( ArticulatedVehicleState inner = model_.bodyVelocityToVehicleState(linear_velocity_m_s, angular_velocity_rad_s, actual_articulation_rate_rad_s); - // Euler pose update (heading taken at start of step). - Pose2D new_pose{ - current_pose.x + linear_velocity_m_s * std::cos(current_pose.theta) * dt_s, - current_pose.y + linear_velocity_m_s * std::sin(current_pose.theta) * dt_s, - normalizeAngle(current_pose.theta + angular_velocity_rad_s * dt_s)}; + // Motion is integrated at the rear axle; convert in from the reference axle and back out again. + const Pose2D rear_pose = toRearAxle(current_pose, current_articulation_angle_rad); + const Pose2D new_rear_pose{ + rear_pose.x + linear_velocity_m_s * std::cos(rear_pose.theta) * dt_s, + rear_pose.y + linear_velocity_m_s * std::sin(rear_pose.theta) * dt_s, + normalizeAngle(rear_pose.theta + angular_velocity_rad_s * dt_s)}; ArticulatedProjectedState state{ - dt_s, new_pose, new_articulation_angle_rad, linear_velocity_m_s, angular_velocity_rad_s, inner, {}, {}}; - fillFootprints(state); + dt_s, {}, new_articulation_angle_rad, linear_velocity_m_s, angular_velocity_rad_s, inner, {}, {}, {}}; + fillPosesAndFootprints(state, new_rear_pose, new_articulation_angle_rad); return state; } -void ArticulatedProjector::fillFootprints(ArticulatedProjectedState & state) const -{ - // Pose is the REAR axle; pose.theta is the rear-body heading. The articulation joint sits - // `articulation_to_rear_axle` ahead of the rear axle along the rear-body heading, and the - // front body is rotated relative to the rear by the articulation angle (theta_front = - // theta_rear + gamma; see derivations/articulated_model.md). - const double rear_theta = state.pose.theta; - const double front_theta = rear_theta + state.articulation_angle_rad; - const double rear_axle_to_joint = model_.get_articulation_to_rear_axle_m(); - const double joint_x = state.pose.x + rear_axle_to_joint * std::cos(rear_theta); - const double joint_y = state.pose.y + rear_axle_to_joint * std::sin(rear_theta); - - // Rectangle corners (CCW, open) given body-frame x extent [x_lo, x_hi] and width, placed at the - // joint and rotated by `heading`. Returns empty when the width is unset/invalid (<= 0). - auto make_body = [joint_x, joint_y](double heading, double x_lo, double x_hi, double width) { - Footprint corners; - if (width <= 0.0) { - return corners; - } - const double half_w = width / 2.0; - const double cos_h = std::cos(heading); - const double sin_h = std::sin(heading); - const double local_x[4] = {x_lo, x_hi, x_hi, x_lo}; - const double local_y[4] = {-half_w, -half_w, half_w, half_w}; - corners.reserve(4); - for (int i = 0; i < 4; ++i) { - corners.push_back(Point2D{ - joint_x + cos_h * local_x[i] - sin_h * local_y[i], - joint_y + sin_h * local_x[i] + cos_h * local_y[i]}); - } - return corners; - }; - - state.front_footprint = make_body(front_theta, 0.0, front_joint_to_bumper_m_, front_body_width_m_); - state.rear_footprint = make_body(rear_theta, -rear_joint_to_bumper_m_, 0.0, rear_body_width_m_); -} - std::vector ArticulatedProjector::project( double horizon_s, double dt_s, @@ -121,8 +121,9 @@ std::vector ArticulatedProjector::project( double initial_omega = initial_axle.rear_axle_turning_velocity_rad_s; ArticulatedVehicleState initial_inner = model_.bodyVelocityToVehicleState(linear_velocity_m_s, initial_omega); ArticulatedProjectedState initial_state{ - 0.0, initial_pose, initial_articulation_angle_rad, linear_velocity_m_s, initial_omega, initial_inner, {}, {}}; - fillFootprints(initial_state); + 0.0, {}, initial_articulation_angle_rad, linear_velocity_m_s, initial_omega, initial_inner, {}, {}, {}}; + fillPosesAndFootprints( + initial_state, toRearAxle(initial_pose, initial_articulation_angle_rad), initial_articulation_angle_rad); trajectory.push_back(initial_state); std::size_t n_steps = static_cast(std::ceil(horizon_s / dt_s)); diff --git a/src/bicycle_projector.cpp b/src/bicycle_projector.cpp index 82d6b4e..bf44ff6 100644 --- a/src/bicycle_projector.cpp +++ b/src/bicycle_projector.cpp @@ -21,6 +21,22 @@ namespace polymath::kinematics { +Pose2D BicycleProjector::toRearAxle(const Pose2D & reference_pose) const +{ + if (axle_reference_ == AxleReference::REAR) { + return reference_pose; + } + return offsetAlongHeading(reference_pose, -model_.get_wheelbase_m()); +} + +Pose2D BicycleProjector::fromRearAxle(const Pose2D & rear_axle_pose) const +{ + if (axle_reference_ == AxleReference::REAR) { + return rear_axle_pose; + } + return offsetAlongHeading(rear_axle_pose, model_.get_wheelbase_m()); +} + BicycleProjectedState BicycleProjector::step( double dt_s, const Pose2D & current_pose, @@ -47,37 +63,23 @@ BicycleProjectedState BicycleProjector::step( // Inverse kinematics populates wheel speeds + turning radius for the snapshot. BicycleSteeringState inner = model_.bodyVelocityToSteering(linear_velocity_m_s, body_vel.angular_velocity_rad_s); - // Euler pose update (heading taken at start of step, matching the plan's integration choice). - Pose2D new_pose{ - current_pose.x + linear_velocity_m_s * std::cos(current_pose.theta) * dt_s, - current_pose.y + linear_velocity_m_s * std::sin(current_pose.theta) * dt_s, - normalizeAngle(current_pose.theta + body_vel.angular_velocity_rad_s * dt_s)}; - - BicycleProjectedState state{ - dt_s, new_pose, new_steering_angle_rad, linear_velocity_m_s, body_vel.angular_velocity_rad_s, inner, {}}; - fillFootprint(state); - return state; -} + // Euler pose update at the rear axle (heading taken at start of step), then back out to the + // reference axle. + const Pose2D rear_pose = toRearAxle(current_pose); + Pose2D new_rear_pose{ + rear_pose.x + linear_velocity_m_s * std::cos(rear_pose.theta) * dt_s, + rear_pose.y + linear_velocity_m_s * std::sin(rear_pose.theta) * dt_s, + normalizeAngle(rear_pose.theta + body_vel.angular_velocity_rad_s * dt_s)}; + const Pose2D new_pose = fromRearAxle(new_rear_pose); -void BicycleProjector::fillFootprint(BicycleProjectedState & state) const -{ - Footprint & corners = state.footprint; - corners.clear(); - if (body_width_m_ <= 0.0) { - return; - } - const double half_w = body_width_m_ / 2.0; - const double cos_t = std::cos(state.pose.theta); - const double sin_t = std::sin(state.pose.theta); - // CCW, open: rear-right, front-right, front-left, rear-left (body frame), then to world. - const double local_x[4] = {-rear_overhang_m_, front_overhang_m_, front_overhang_m_, -rear_overhang_m_}; - const double local_y[4] = {-half_w, -half_w, half_w, half_w}; - corners.reserve(4); - for (int i = 0; i < 4; ++i) { - corners.push_back(Point2D{ - state.pose.x + cos_t * local_x[i] - sin_t * local_y[i], - state.pose.y + sin_t * local_x[i] + cos_t * local_y[i]}); - } + return BicycleProjectedState{ + dt_s, + new_pose, + new_steering_angle_rad, + linear_velocity_m_s, + body_vel.angular_velocity_rad_s, + inner, + transformFootprint(footprint_, new_pose)}; } std::vector BicycleProjector::project( @@ -99,16 +101,14 @@ std::vector BicycleProjector::project( BicycleBodyVelocity initial_body = model_.steeringToBodyVelocity(linear_velocity_m_s, initial_steering_angle_rad); BicycleSteeringState initial_inner = model_.bodyVelocityToSteering(linear_velocity_m_s, initial_body.angular_velocity_rad_s); - BicycleProjectedState initial_state{ + trajectory.push_back(BicycleProjectedState{ 0.0, initial_pose, initial_steering_angle_rad, linear_velocity_m_s, initial_body.angular_velocity_rad_s, initial_inner, - {}}; - fillFootprint(initial_state); - trajectory.push_back(initial_state); + transformFootprint(footprint_, initial_pose)}); std::size_t n_steps = static_cast(std::ceil(horizon_s / dt_s)); trajectory.reserve(n_steps + 1); diff --git a/src/differential_drive_projector.cpp b/src/differential_drive_projector.cpp index c57ba10..dd7b32b 100644 --- a/src/differential_drive_projector.cpp +++ b/src/differential_drive_projector.cpp @@ -66,31 +66,13 @@ DifferentialDriveProjectedState DifferentialDriveProjector::step( current_pose.y + new_linear_velocity_m_s * std::sin(current_pose.theta) * dt_s, normalizeAngle(current_pose.theta + new_angular_velocity_rad_s * dt_s)}; - DifferentialDriveProjectedState state{ - dt_s, new_pose, new_linear_velocity_m_s, new_angular_velocity_rad_s, wheels, {}}; - fillFootprint(state); - return state; -} - -void DifferentialDriveProjector::fillFootprint(DifferentialDriveProjectedState & state) const -{ - Footprint & corners = state.footprint; - corners.clear(); - if (body_width_m_ <= 0.0) { - return; - } - const double half_w = body_width_m_ / 2.0; - const double cos_t = std::cos(state.pose.theta); - const double sin_t = std::sin(state.pose.theta); - // CCW, open: rear-right, front-right, front-left, rear-left (body frame), then to world. - const double local_x[4] = {-rear_overhang_m_, front_overhang_m_, front_overhang_m_, -rear_overhang_m_}; - const double local_y[4] = {-half_w, -half_w, half_w, half_w}; - corners.reserve(4); - for (int i = 0; i < 4; ++i) { - corners.push_back(Point2D{ - state.pose.x + cos_t * local_x[i] - sin_t * local_y[i], - state.pose.y + sin_t * local_x[i] + cos_t * local_y[i]}); - } + return DifferentialDriveProjectedState{ + dt_s, + new_pose, + new_linear_velocity_m_s, + new_angular_velocity_rad_s, + wheels, + transformFootprint(footprint_, new_pose)}; } std::vector DifferentialDriveProjector::project( @@ -111,10 +93,13 @@ std::vector DifferentialDriveProjector::project DifferentialDriveWheelVelocities initial_wheels = model_.bodyVelocityToWheelVelocities(initial_linear_velocity_m_s, initial_angular_velocity_rad_s); - DifferentialDriveProjectedState initial_state{ - 0.0, initial_pose, initial_linear_velocity_m_s, initial_angular_velocity_rad_s, initial_wheels, {}}; - fillFootprint(initial_state); - trajectory.push_back(initial_state); + trajectory.push_back(DifferentialDriveProjectedState{ + 0.0, + initial_pose, + initial_linear_velocity_m_s, + initial_angular_velocity_rad_s, + initial_wheels, + transformFootprint(footprint_, initial_pose)}); std::size_t n_steps = static_cast(std::ceil(horizon_s / dt_s)); trajectory.reserve(n_steps + 1); diff --git a/src/kinematics_pybind.cpp b/src/kinematics_pybind.cpp index c0caa53..6d44e26 100644 --- a/src/kinematics_pybind.cpp +++ b/src/kinematics_pybind.cpp @@ -154,11 +154,29 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) // Python list of Point2D via pybind11/stl.h. py::class_(m, "Point2D") .def(py::init<>()) - .def( - py::init([](double x, double y) { return Point2D{x, y}; }), py::arg("x") = 0.0, py::arg("y") = 0.0) + .def(py::init([](double x, double y) { return Point2D{x, y}; }), py::arg("x") = 0.0, py::arg("y") = 0.0) .def_readwrite("x", &Point2D::x) .def_readwrite("y", &Point2D::y); + py::enum_(m, "AxleReference", "Which axle a projector's poses and footprint are measured from.") + .value("FRONT", AxleReference::FRONT) + .value("REAR", AxleReference::REAR); + + m.def( + "rectangle_footprint", + &rectangleFootprint, + py::arg("front_m"), + py::arg("rear_m"), + py::arg("width_m"), + "Build a rectangular body-frame footprint (CCW, open): rear-right, front-right, front-left, rear-left."); + + m.def( + "transform_footprint", + &transformFootprint, + py::arg("body_frame"), + py::arg("pose"), + "Transform a body-frame footprint into the world frame by `pose`."); + // Bicycle projector bindings py::class_(m, "BicycleProjectedState") .def(py::init<>()) @@ -172,13 +190,12 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) py::class_(m, "BicycleProjector") .def( - py::init(), + py::init(), py::arg("model"), py::arg("min_steering_angle_rad"), py::arg("max_steering_angle_rad"), - py::arg("front_overhang_m") = 0.0, - py::arg("rear_overhang_m") = 0.0, - py::arg("body_width_m") = 0.0) + py::arg("axle_reference") = AxleReference::REAR, + py::arg("footprint") = Footprint{}) .def( "step", &BicycleProjector::step, @@ -201,9 +218,8 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) .def_property_readonly("model", &BicycleProjector::get_model, py::return_value_policy::reference_internal) .def_property_readonly("min_steering_angle_rad", &BicycleProjector::get_min_steering_angle_rad) .def_property_readonly("max_steering_angle_rad", &BicycleProjector::get_max_steering_angle_rad) - .def_property_readonly("front_overhang_m", &BicycleProjector::get_front_overhang_m) - .def_property_readonly("rear_overhang_m", &BicycleProjector::get_rear_overhang_m) - .def_property_readonly("body_width_m", &BicycleProjector::get_body_width_m); + .def_property_readonly("axle_reference", &BicycleProjector::get_axle_reference) + .def_property_readonly("footprint", &BicycleProjector::get_footprint); // Articulated projector bindings py::class_(m, "ArticulatedProjectedState") @@ -214,19 +230,19 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) .def_readwrite("linear_velocity_m_s", &ArticulatedProjectedState::linear_velocity_m_s) .def_readwrite("angular_velocity_rad_s", &ArticulatedProjectedState::angular_velocity_rad_s) .def_readwrite("vehicle_state", &ArticulatedProjectedState::vehicle_state) + .def_readwrite("joint_pose", &ArticulatedProjectedState::joint_pose) .def_readwrite("front_footprint", &ArticulatedProjectedState::front_footprint) .def_readwrite("rear_footprint", &ArticulatedProjectedState::rear_footprint); py::class_(m, "ArticulatedProjector") .def( - py::init(), + py::init(), py::arg("model"), py::arg("min_articulation_angle_rad"), py::arg("max_articulation_angle_rad"), - py::arg("front_joint_to_bumper_m") = 0.0, - py::arg("front_body_width_m") = 0.0, - py::arg("rear_joint_to_bumper_m") = 0.0, - py::arg("rear_body_width_m") = 0.0) + py::arg("axle_reference") = AxleReference::REAR, + py::arg("front_footprint") = Footprint{}, + py::arg("rear_footprint") = Footprint{}) .def( "step", &ArticulatedProjector::step, @@ -249,10 +265,9 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) .def_property_readonly("model", &ArticulatedProjector::get_model, py::return_value_policy::reference_internal) .def_property_readonly("min_articulation_angle_rad", &ArticulatedProjector::get_min_articulation_angle_rad) .def_property_readonly("max_articulation_angle_rad", &ArticulatedProjector::get_max_articulation_angle_rad) - .def_property_readonly("front_joint_to_bumper_m", &ArticulatedProjector::get_front_joint_to_bumper_m) - .def_property_readonly("front_body_width_m", &ArticulatedProjector::get_front_body_width_m) - .def_property_readonly("rear_joint_to_bumper_m", &ArticulatedProjector::get_rear_joint_to_bumper_m) - .def_property_readonly("rear_body_width_m", &ArticulatedProjector::get_rear_body_width_m); + .def_property_readonly("axle_reference", &ArticulatedProjector::get_axle_reference) + .def_property_readonly("front_footprint", &ArticulatedProjector::get_front_footprint) + .def_property_readonly("rear_footprint", &ArticulatedProjector::get_rear_footprint); // Differential drive projector bindings py::class_(m, "DifferentialDriveProjectedState") @@ -266,15 +281,13 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) py::class_(m, "DifferentialDriveProjector") .def( - py::init(), + py::init(), py::arg("model"), py::arg("min_linear_velocity_m_s"), py::arg("max_linear_velocity_m_s"), py::arg("min_angular_velocity_rad_s"), py::arg("max_angular_velocity_rad_s"), - py::arg("front_overhang_m") = 0.0, - py::arg("rear_overhang_m") = 0.0, - py::arg("body_width_m") = 0.0) + py::arg("footprint") = Footprint{}) .def( "step", &DifferentialDriveProjector::step, @@ -303,9 +316,7 @@ PYBIND11_MODULE(polymath_kinematics_cpp, m) .def_property_readonly("max_linear_velocity_m_s", &DifferentialDriveProjector::get_max_linear_velocity_m_s) .def_property_readonly("min_angular_velocity_rad_s", &DifferentialDriveProjector::get_min_angular_velocity_rad_s) .def_property_readonly("max_angular_velocity_rad_s", &DifferentialDriveProjector::get_max_angular_velocity_rad_s) - .def_property_readonly("front_overhang_m", &DifferentialDriveProjector::get_front_overhang_m) - .def_property_readonly("rear_overhang_m", &DifferentialDriveProjector::get_rear_overhang_m) - .def_property_readonly("body_width_m", &DifferentialDriveProjector::get_body_width_m); + .def_property_readonly("footprint", &DifferentialDriveProjector::get_footprint); } } // namespace polymath::kinematics diff --git a/test/test_articulated_projector.cpp b/test/test_articulated_projector.cpp index 72e6d23..ee41f6c 100644 --- a/test/test_articulated_projector.cpp +++ b/test/test_articulated_projector.cpp @@ -158,28 +158,31 @@ TEST_CASE("ArticulatedProjector step - realized gamma-dot affects rear-axle omeg CHECK(slow_ramp.angular_velocity_rad_s != Approx(snap.angular_velocity_rad_s)); } -// Footprint dimensions used by the footprint tests below. +// Body footprints used by the footprint tests below. Each polygon is measured from its OWN axle. namespace { -constexpr double FRONT_JOINT_TO_BUMPER = 2.2; +// Front body: 2.2 m ahead of the front axle to the bucket, 0.4 m behind it. +constexpr double FRONT_AHEAD = 2.2; +constexpr double FRONT_BEHIND = 0.4; constexpr double FRONT_BODY_WIDTH = 2.0; -constexpr double REAR_JOINT_TO_BUMPER = 2.0; +// Rear body: 0.3 m ahead of the rear axle, 2.0 m behind it to the counterweight. +constexpr double REAR_AHEAD = 0.3; +constexpr double REAR_BEHIND = 2.0; constexpr double REAR_BODY_WIDTH = 2.0; -ArticulatedProjector make_footprint_projector() +ArticulatedProjector make_footprint_projector(AxleReference reference = AxleReference::REAR) { return ArticulatedProjector( make_model(), MIN_ANGLE, MAX_ANGLE, - FRONT_JOINT_TO_BUMPER, - FRONT_BODY_WIDTH, - REAR_JOINT_TO_BUMPER, - REAR_BODY_WIDTH); + reference, + rectangleFootprint(FRONT_AHEAD, FRONT_BEHIND, FRONT_BODY_WIDTH), + rectangleFootprint(REAR_AHEAD, REAR_BEHIND, REAR_BODY_WIDTH)); } } // namespace -TEST_CASE("ArticulatedProjector - no footprint dims yields empty footprints (never throws)") +TEST_CASE("ArticulatedProjector - no footprint set yields empty footprints (never throws)") { ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; @@ -194,49 +197,132 @@ TEST_CASE("ArticulatedProjector - no footprint dims yields empty footprints (nev CHECK(states.front().rear_footprint.empty()); } -TEST_CASE("ArticulatedProjector footprint - zero articulation, both bodies aligned along +x") +TEST_CASE("ArticulatedProjector - joint pose is reported alongside the reference-axle pose") { auto projector = make_footprint_projector(); - Pose2D pose{0.0, 0.0, 0.0}; // rear axle at origin, rear-body heading +x - auto result = projector.step(0.1, pose, 0.0, 0.0, 0.0, 0.0); // v=0 so pose stays; gamma=0 + // REAR reference: pose is the rear axle at the origin, so the joint sits REAR_ARM ahead. + auto result = projector.step(0.1, Pose2D{0.0, 0.0, 0.0}, 0.0, 0.0, 0.0, 0.0); + CHECK(result.pose.x == Approx(0.0)); + CHECK(result.joint_pose.x == Approx(REAR_ARM)); + CHECK(result.joint_pose.y == Approx(0.0)); + CHECK(result.joint_pose.theta == Approx(0.0)); +} + +TEST_CASE("ArticulatedProjector footprint - zero articulation, each body about its own axle") +{ + auto projector = make_footprint_projector(); + // REAR reference: rear axle at the origin, rear-body heading +x. gamma=0 so both bodies align. + auto result = projector.step(0.1, Pose2D{0.0, 0.0, 0.0}, 0.0, 0.0, 0.0, 0.0); // v=0 so pose stays REQUIRE(result.front_footprint.size() == 4); REQUIRE(result.rear_footprint.size() == 4); - // Joint sits REAR_ARM ahead of the rear axle along +x. - const double joint_x = REAR_ARM; - // Front body spans [joint, joint + FRONT_JOINT_TO_BUMPER]; corners 0 and 3 are at the joint. - CHECK(result.front_footprint[0].x == Approx(joint_x)); + // Rear body is measured from the rear axle (the origin here). + CHECK(result.rear_footprint[0].x == Approx(-REAR_BEHIND)); + CHECK(result.rear_footprint[0].y == Approx(-REAR_BODY_WIDTH / 2.0)); + CHECK(result.rear_footprint[1].x == Approx(REAR_AHEAD)); + + // Front axle is a joint arm plus a front arm ahead of the rear axle when gamma = 0. + const double front_axle_x = REAR_ARM + FRONT_ARM; + CHECK(result.front_footprint[0].x == Approx(front_axle_x - FRONT_BEHIND)); CHECK(result.front_footprint[0].y == Approx(-FRONT_BODY_WIDTH / 2.0)); - CHECK(result.front_footprint[1].x == Approx(joint_x + FRONT_JOINT_TO_BUMPER)); - CHECK(result.front_footprint[1].y == Approx(-FRONT_BODY_WIDTH / 2.0)); - // Rear body spans [joint - REAR_JOINT_TO_BUMPER, joint]. - CHECK(result.rear_footprint[0].x == Approx(joint_x - REAR_JOINT_TO_BUMPER)); - CHECK(result.rear_footprint[1].x == Approx(joint_x)); - CHECK(result.rear_footprint[1].y == Approx(-REAR_BODY_WIDTH / 2.0)); + CHECK(result.front_footprint[1].x == Approx(front_axle_x + FRONT_AHEAD)); } -TEST_CASE("ArticulatedProjector footprint - nonzero articulation folds the front body about the joint") +TEST_CASE("ArticulatedProjector footprint - nonzero articulation swings the front body about the joint") { auto projector = make_footprint_projector(); - Pose2D pose{0.0, 0.0, 0.0}; const double gamma = 0.3; - auto result = projector.step(0.1, pose, gamma, gamma, 0.0, 0.0); // hold gamma, v=0 + auto result = projector.step(0.1, Pose2D{0.0, 0.0, 0.0}, gamma, gamma, 0.0, 0.0); // hold gamma, v=0 REQUIRE(result.front_footprint.size() == 4); - const double joint_x = REAR_ARM; - const double joint_y = 0.0; - // The front-bumper midpoint (between corners 1 and 2) lies on the front-body axis at distance - // FRONT_JOINT_TO_BUMPER from the joint, along the front heading (rear theta + gamma). Using the - // midpoint avoids the body-width offset that each individual corner carries. - const double front_theta = 0.0 + gamma; + // Joint sits REAR_ARM ahead of the rear axle; the front axle is FRONT_ARM further along the + // front heading (theta_rear + gamma). + const double front_theta = gamma; + const double front_axle_x = REAR_ARM + FRONT_ARM * std::cos(front_theta); + const double front_axle_y = FRONT_ARM * std::sin(front_theta); + // Compare bumper midpoints, which lie on the body axis and carry no width offset. const double bumper_mid_x = (result.front_footprint[1].x + result.front_footprint[2].x) / 2.0; const double bumper_mid_y = (result.front_footprint[1].y + result.front_footprint[2].y) / 2.0; - CHECK(bumper_mid_x == Approx(joint_x + FRONT_JOINT_TO_BUMPER * std::cos(front_theta))); - CHECK(bumper_mid_y == Approx(joint_y + FRONT_JOINT_TO_BUMPER * std::sin(front_theta))); - // Rear body stays aligned with the rear heading (unchanged from the zero case). - CHECK(result.rear_footprint[0].x == Approx(joint_x - REAR_JOINT_TO_BUMPER)); - CHECK(result.rear_footprint[0].y == Approx(0.0 - REAR_BODY_WIDTH / 2.0)); + CHECK(bumper_mid_x == Approx(front_axle_x + FRONT_AHEAD * std::cos(front_theta))); + CHECK(bumper_mid_y == Approx(front_axle_y + FRONT_AHEAD * std::sin(front_theta))); + // Rear body is unaffected by gamma: still measured straight back from the rear axle. + CHECK(result.rear_footprint[0].x == Approx(-REAR_BEHIND)); + CHECK(result.rear_footprint[0].y == Approx(-REAR_BODY_WIDTH / 2.0)); +} + +TEST_CASE("ArticulatedProjector - FRONT reference reports the front axle and its body heading") +{ + const double gamma = 0.3; + auto projector = make_footprint_projector(AxleReference::FRONT); + // Seed with the front-axle pose that corresponds to a rear axle at the origin heading +x. + const double front_theta = gamma; + const Pose2D front_start{ + REAR_ARM + FRONT_ARM * std::cos(front_theta), FRONT_ARM * std::sin(front_theta), front_theta}; + auto result = projector.step(0.1, front_start, gamma, gamma, 0.0, 0.0); // hold gamma, v=0 + + // Pose comes back at the front axle with the FRONT-body heading, and the joint is unchanged. + CHECK(result.pose.x == Approx(front_start.x)); + CHECK(result.pose.y == Approx(front_start.y)); + CHECK(result.pose.theta == Approx(front_theta)); + CHECK(result.joint_pose.x == Approx(REAR_ARM)); + CHECK(result.joint_pose.y == Approx(0.0)); + CHECK(result.joint_pose.theta == Approx(0.0)); +} + +TEST_CASE("ArticulatedProjector - FRONT and REAR references describe the same physical motion") +{ + const double gamma = 0.25; + auto rear_projector = make_footprint_projector(AxleReference::REAR); + auto front_projector = make_footprint_projector(AxleReference::FRONT); + + const double front_theta = gamma; + const Pose2D rear_start{0.0, 0.0, 0.0}; + const Pose2D front_start{ + REAR_ARM + FRONT_ARM * std::cos(front_theta), FRONT_ARM * std::sin(front_theta), front_theta}; + + auto rear_traj = rear_projector.project(1.0, 0.05, rear_start, gamma, gamma, 0.0, 1.0); + auto front_traj = front_projector.project(1.0, 0.05, front_start, gamma, gamma, 0.0, 1.0); + REQUIRE(rear_traj.size() == front_traj.size()); + + // Same vehicle: the joint pose and both world-frame footprints must agree sample for sample, + // regardless of which axle the caller chose to reference. + for (std::size_t i = 0; i < rear_traj.size(); ++i) { + CHECK(front_traj[i].joint_pose.x == Approx(rear_traj[i].joint_pose.x)); + CHECK(front_traj[i].joint_pose.y == Approx(rear_traj[i].joint_pose.y)); + CHECK(front_traj[i].joint_pose.theta == Approx(rear_traj[i].joint_pose.theta)); + REQUIRE(front_traj[i].front_footprint.size() == rear_traj[i].front_footprint.size()); + for (std::size_t k = 0; k < rear_traj[i].front_footprint.size(); ++k) { + CHECK(front_traj[i].front_footprint[k].x == Approx(rear_traj[i].front_footprint[k].x)); + CHECK(front_traj[i].front_footprint[k].y == Approx(rear_traj[i].front_footprint[k].y)); + CHECK(front_traj[i].rear_footprint[k].x == Approx(rear_traj[i].rear_footprint[k].x)); + CHECK(front_traj[i].rear_footprint[k].y == Approx(rear_traj[i].rear_footprint[k].y)); + } + } +} + +TEST_CASE("ArticulatedProjector footprint - arbitrary polygons are carried through vertex for vertex") +{ + // Five-vertex front body, three-vertex rear body: nothing may assume 4 corners. + const Footprint front_body{ + Point2D{-0.4, -1.0}, Point2D{1.8, -1.0}, Point2D{2.4, 0.0}, Point2D{1.8, 1.0}, Point2D{-0.4, 1.0}}; + const Footprint rear_body{Point2D{0.3, -0.9}, Point2D{-1.9, 0.0}, Point2D{0.3, 0.9}}; + ArticulatedProjector projector(make_model(), MIN_ANGLE, MAX_ANGLE, AxleReference::REAR, front_body, rear_body); + + auto result = projector.step(0.1, Pose2D{0.0, 0.0, 0.0}, 0.0, 0.0, 0.0, 0.0); + REQUIRE(result.front_footprint.size() == front_body.size()); + REQUIRE(result.rear_footprint.size() == rear_body.size()); + // gamma = 0 and rear axle at the origin: rear body passes through unchanged, front body shifts + // by the full wheelbase with no rotation. + const double front_axle_x = REAR_ARM + FRONT_ARM; + for (std::size_t i = 0; i < rear_body.size(); ++i) { + CHECK(result.rear_footprint[i].x == Approx(rear_body[i].x)); + CHECK(result.rear_footprint[i].y == Approx(rear_body[i].y)); + } + for (std::size_t i = 0; i < front_body.size(); ++i) { + CHECK(result.front_footprint[i].x == Approx(front_axle_x + front_body[i].x)); + CHECK(result.front_footprint[i].y == Approx(front_body[i].y)); + } } } // namespace polymath::kinematics diff --git a/test/test_bicycle_projector.cpp b/test/test_bicycle_projector.cpp index edf3366..2a6d201 100644 --- a/test/test_bicycle_projector.cpp +++ b/test/test_bicycle_projector.cpp @@ -142,7 +142,7 @@ TEST_CASE("BicycleProjector project - initial state stored as element 0") CHECK(trajectory.front().steering_angle_rad == Approx(0.1)); } -TEST_CASE("BicycleProjector - no footprint dims yields an empty footprint (never throws)") +TEST_CASE("BicycleProjector - no footprint set yields an empty footprint (never throws)") { BicycleProjector projector(BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE); Pose2D pose{0.0, 0.0, 0.0}; @@ -152,25 +152,105 @@ TEST_CASE("BicycleProjector - no footprint dims yields an empty footprint (never TEST_CASE("BicycleProjector footprint - rectangle corners at a known pose") { - constexpr double FRONT_OVERHANG = 3.0; - constexpr double REAR_OVERHANG = 1.0; - constexpr double BODY_WIDTH = 2.0; + constexpr double FRONT = 3.0; + constexpr double REAR = 1.0; + constexpr double WIDTH = 2.0; BicycleProjector projector( - BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE, FRONT_OVERHANG, REAR_OVERHANG, BODY_WIDTH); + BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), + MIN_ANGLE, + MAX_ANGLE, + AxleReference::REAR, + rectangleFootprint(FRONT, REAR, WIDTH)); // v=0, zero steering: pose stays at the origin with heading +x. Pose2D pose{0.0, 0.0, 0.0}; auto result = projector.step(0.1, pose, 0.0, 0.0, 0.0, 0.0); REQUIRE(result.footprint.size() == 4); // Body frame corners: rear-right, front-right, front-left, rear-left. - CHECK(result.footprint[0].x == Approx(-REAR_OVERHANG)); - CHECK(result.footprint[0].y == Approx(-BODY_WIDTH / 2.0)); - CHECK(result.footprint[1].x == Approx(FRONT_OVERHANG)); - CHECK(result.footprint[1].y == Approx(-BODY_WIDTH / 2.0)); - CHECK(result.footprint[2].x == Approx(FRONT_OVERHANG)); - CHECK(result.footprint[2].y == Approx(BODY_WIDTH / 2.0)); - CHECK(result.footprint[3].x == Approx(-REAR_OVERHANG)); - CHECK(result.footprint[3].y == Approx(BODY_WIDTH / 2.0)); + CHECK(result.footprint[0].x == Approx(-REAR)); + CHECK(result.footprint[0].y == Approx(-WIDTH / 2.0)); + CHECK(result.footprint[1].x == Approx(FRONT)); + CHECK(result.footprint[1].y == Approx(-WIDTH / 2.0)); + CHECK(result.footprint[2].x == Approx(FRONT)); + CHECK(result.footprint[2].y == Approx(WIDTH / 2.0)); + CHECK(result.footprint[3].x == Approx(-REAR)); + CHECK(result.footprint[3].y == Approx(WIDTH / 2.0)); +} + +TEST_CASE("BicycleProjector footprint - arbitrary polygon is carried through vertex for vertex") +{ + // A 5-vertex tapered nose, deliberately not a rectangle. + const Footprint body{ + Point2D{-1.0, -0.9}, Point2D{2.0, -0.9}, Point2D{2.8, 0.0}, Point2D{2.0, 0.9}, Point2D{-1.0, 0.9}}; + BicycleProjector projector( + BicycleModel(WHEELBASE, TRACK, WHEEL_RADIUS), MIN_ANGLE, MAX_ANGLE, AxleReference::REAR, body); + + Pose2D pose{0.0, 0.0, 0.0}; + auto result = projector.step(0.1, pose, 0.0, 0.0, 0.0, 0.0); + REQUIRE(result.footprint.size() == body.size()); + for (std::size_t i = 0; i < body.size(); ++i) { + CHECK(result.footprint[i].x == Approx(body[i].x)); + CHECK(result.footprint[i].y == Approx(body[i].y)); + } +} + +TEST_CASE("BicycleProjector - FRONT reference offsets the pose by a wheelbase") +{ + const BicycleModel model(WHEELBASE, TRACK, WHEEL_RADIUS); + BicycleProjector rear(model, MIN_ANGLE, MAX_ANGLE, AxleReference::REAR); + BicycleProjector front(model, MIN_ANGLE, MAX_ANGLE, AxleReference::FRONT); + + // Straight ahead from the origin: the front-axle pose leads the rear-axle pose by the wheelbase. + auto rear_traj = rear.project(1.0, 0.1, Pose2D{0.0, 0.0, 0.0}, 0.0, 0.0, 0.0, 1.0); + auto front_traj = front.project(1.0, 0.1, Pose2D{WHEELBASE, 0.0, 0.0}, 0.0, 0.0, 0.0, 1.0); + REQUIRE(rear_traj.size() == front_traj.size()); + for (std::size_t i = 0; i < rear_traj.size(); ++i) { + CHECK(front_traj[i].pose.x == Approx(rear_traj[i].pose.x + WHEELBASE)); + CHECK(front_traj[i].pose.y == Approx(rear_traj[i].pose.y)); + CHECK(front_traj[i].pose.theta == Approx(rear_traj[i].pose.theta)); + } +} + +TEST_CASE("BicycleProjector - FRONT reference traces the same curve as REAR, offset forward") +{ + const BicycleModel model(WHEELBASE, TRACK, WHEEL_RADIUS); + const double steering = 0.3; + BicycleProjector rear(model, MIN_ANGLE, MAX_ANGLE, AxleReference::REAR); + BicycleProjector front(model, MIN_ANGLE, MAX_ANGLE, AxleReference::FRONT); + + // Same physical vehicle, same turn: seed each with its own axle's start pose. + auto rear_traj = rear.project(2.0, 0.05, Pose2D{0.0, 0.0, 0.0}, steering, steering, 0.0, 1.5); + auto front_traj = front.project(2.0, 0.05, Pose2D{WHEELBASE, 0.0, 0.0}, steering, steering, 0.0, 1.5); + REQUIRE(rear_traj.size() == front_traj.size()); + for (std::size_t i = 0; i < rear_traj.size(); ++i) { + // Headings match (one rigid chassis) and the front axle sits a wheelbase ahead along it. + const double theta = rear_traj[i].pose.theta; + CHECK(front_traj[i].pose.theta == Approx(theta)); + CHECK(front_traj[i].pose.x == Approx(rear_traj[i].pose.x + WHEELBASE * std::cos(theta))); + CHECK(front_traj[i].pose.y == Approx(rear_traj[i].pose.y + WHEELBASE * std::sin(theta))); + } +} + +TEST_CASE("BicycleProjector - footprint is anchored at whichever axle is the reference") +{ + const BicycleModel model(WHEELBASE, TRACK, WHEEL_RADIUS); + // Same physical body described from each axle: bumper 0.5 m past the front axle, 1.0 m behind + // the rear axle. From the rear axle that is (WHEELBASE + 0.5) forward; from the front axle it is + // 0.5 forward and (WHEELBASE + 1.0) back. + BicycleProjector rear( + model, MIN_ANGLE, MAX_ANGLE, AxleReference::REAR, rectangleFootprint(WHEELBASE + 0.5, 1.0, 2.0)); + BicycleProjector front( + model, MIN_ANGLE, MAX_ANGLE, AxleReference::FRONT, rectangleFootprint(0.5, WHEELBASE + 1.0, 2.0)); + + auto rear_state = rear.step(0.1, Pose2D{0.0, 0.0, 0.0}, 0.0, 0.0, 0.0, 0.0); + auto front_state = front.step(0.1, Pose2D{WHEELBASE, 0.0, 0.0}, 0.0, 0.0, 0.0, 0.0); + REQUIRE(rear_state.footprint.size() == 4); + REQUIRE(front_state.footprint.size() == 4); + // Both descriptions must land the body in the same place in the world. + for (std::size_t i = 0; i < 4; ++i) { + CHECK(front_state.footprint[i].x == Approx(rear_state.footprint[i].x)); + CHECK(front_state.footprint[i].y == Approx(rear_state.footprint[i].y)); + } } } // namespace polymath::kinematics diff --git a/test/test_differential_drive_projector.cpp b/test/test_differential_drive_projector.cpp index f3fa6a4..138062e 100644 --- a/test/test_differential_drive_projector.cpp +++ b/test/test_differential_drive_projector.cpp @@ -130,7 +130,7 @@ TEST_CASE("DifferentialDriveProjector step - wheel speeds derive from body comma CHECK(result.wheel_velocities.right_wheel_velocity_rad_s == Approx(10.0)); } -TEST_CASE("DifferentialDriveProjector - no footprint dims yields an empty footprint (never throws)") +TEST_CASE("DifferentialDriveProjector - no footprint set yields an empty footprint (never throws)") { auto projector = makeProjector(); Pose2D pose{0.0, 0.0, 0.0}; @@ -140,32 +140,46 @@ TEST_CASE("DifferentialDriveProjector - no footprint dims yields an empty footpr TEST_CASE("DifferentialDriveProjector footprint - rectangle corners rotated by heading") { - constexpr double FRONT_OVERHANG = 1.5; - constexpr double REAR_OVERHANG = 0.5; - constexpr double BODY_WIDTH = 1.0; + constexpr double FRONT = 1.5; + constexpr double REAR = 0.5; + constexpr double WIDTH = 1.0; DifferentialDriveProjector projector( DifferentialDriveModel(WHEEL_RADIUS, TRACK_WIDTH), V_MIN, V_MAX, OMEGA_MIN, OMEGA_MAX, - FRONT_OVERHANG, - REAR_OVERHANG, - BODY_WIDTH); + rectangleFootprint(FRONT, REAR, WIDTH)); // Heading = +90 deg (theta = pi/2), v=0 so pose stays at the origin. const double theta = M_PI / 2.0; Pose2D pose{0.0, 0.0, theta}; auto result = projector.step(0.1, pose, 0.0, 0.0, 0.0, 0.0, 100.0, 100.0); REQUIRE(result.footprint.size() == 4); - // Body-frame front-right corner (FRONT_OVERHANG, -BODY_WIDTH/2) rotated by +90deg: - // world = (+BODY_WIDTH/2, FRONT_OVERHANG). - CHECK(result.footprint[1].x == Approx(BODY_WIDTH / 2.0)); - CHECK(result.footprint[1].y == Approx(FRONT_OVERHANG)); - // Body-frame rear-right corner (-REAR_OVERHANG, -BODY_WIDTH/2) rotated by +90deg: - // world = (+BODY_WIDTH/2, -REAR_OVERHANG). - CHECK(result.footprint[0].x == Approx(BODY_WIDTH / 2.0)); - CHECK(result.footprint[0].y == Approx(-REAR_OVERHANG)); + // Body-frame front-right corner (FRONT, -WIDTH/2) rotated by +90deg: world = (+WIDTH/2, FRONT). + CHECK(result.footprint[1].x == Approx(WIDTH / 2.0)); + CHECK(result.footprint[1].y == Approx(FRONT)); + // Body-frame rear-right corner (-REAR, -WIDTH/2) rotated by +90deg: world = (+WIDTH/2, -REAR). + CHECK(result.footprint[0].x == Approx(WIDTH / 2.0)); + CHECK(result.footprint[0].y == Approx(-REAR)); +} + +TEST_CASE("DifferentialDriveProjector footprint - arbitrary polygon is rotated vertex for vertex") +{ + // A 3-vertex body, to prove nothing assumes 4 corners. No vertex sits on an axis, so the + // expected values stay clear of zero where Approx has no relative epsilon to work with. + const Footprint body{Point2D{1.0, 0.2}, Point2D{-0.5, 0.6}, Point2D{-0.5, -0.6}}; + DifferentialDriveProjector projector( + DifferentialDriveModel(WHEEL_RADIUS, TRACK_WIDTH), V_MIN, V_MAX, OMEGA_MIN, OMEGA_MAX, body); + + const double theta = M_PI / 2.0; + auto result = projector.step(0.1, Pose2D{0.0, 0.0, theta}, 0.0, 0.0, 0.0, 0.0, 100.0, 100.0); + REQUIRE(result.footprint.size() == body.size()); + for (std::size_t i = 0; i < body.size(); ++i) { + // +90 deg rotation maps (x, y) -> (-y, x). + CHECK(result.footprint[i].x == Approx(-body[i].y)); + CHECK(result.footprint[i].y == Approx(body[i].x)); + } } } // namespace polymath::kinematics diff --git a/test/test_explorer.py b/test/test_explorer.py index 33a31d6..242d57c 100644 --- a/test/test_explorer.py +++ b/test/test_explorer.py @@ -15,8 +15,10 @@ 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, @@ -45,11 +47,54 @@ def test_generate_lattice_differential(self): time_step=0.1, ) - # Differential drive still uses manual Euler: num_steps = int(duration/time_step). + # ceil(duration/dt) + 1 samples, same as the bicycle and articulated lattices. assert len(trajectories) == 3 - assert len(trajectories[0].time) == 10 + 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, @@ -93,20 +138,6 @@ def test_generate_lattice_articulated(self): assert len(trajectories[0].time) == 11 assert trajectories[0].drive_velocity == pytest.approx(2.0) - def test_method_param_accepted_for_compatibility(self): - # method='rk4' is accepted but the projector path is Euler-only — the call must still succeed. - trajectories = 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, - method='rk4', - ) - assert len(trajectories) == 1 - class TestDataframeExport: def test_differential_drive_export(self): @@ -133,7 +164,7 @@ def test_differential_drive_export(self): 'base_wheel_velocity', ] assert list(dataframe.columns) == expected_columns - assert len(dataframe) == 30 # 3 trajectories * 10 points each + assert len(dataframe) == 33 # 3 trajectories * 11 samples each def test_bicycle_export(self): trajectories = generate_lattice_bicycle( @@ -209,6 +240,7 @@ def test_empty_trajectories_selection(self): 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, @@ -216,6 +248,7 @@ def differential_trajectories(self): 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 @@ -228,6 +261,7 @@ def bicycle_trajectories(self): 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 @@ -243,6 +277,10 @@ def articulated_trajectories(self): 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): @@ -267,27 +305,36 @@ def test_plot_trajectory_with_footprints_differential(self, differential_traject selected = select_symmetric_trajectories( differential_trajectories, 'Differential Drive', num_angles=3, num_velocities=1 ) - figure = plot_trajectory_with_footprints( - selected, 'Differential Drive', {'length': 0.75, 'width': 0.5}, num_footprints=3 - ) + 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', {'length': 2.5, 'width': 1.5}, num_footprints=3) + 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 ) - model_params = { - 'front_length': 1.5, - 'rear_length': 1.2, - 'front_width': 1.8, - 'rear_width': 1.6, - } - figure = plot_trajectory_with_footprints(selected, 'Articulated', model_params, num_footprints=3) + 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 @@ -452,9 +499,7 @@ def test_single_bicycle_footprint_series_present_with_dims(self): drive_velocity=1.0, duration=1.0, time_step=0.1, - front_overhang_m=3.0, - rear_overhang_m=1.0, - body_width_m=1.5, + footprint=rectangle_footprint(3.0, 1.0, 1.5), ) assert traj.footprint_series is not None # (N samples, 4 corners, xy) @@ -474,12 +519,139 @@ def test_single_articulated_footprint_series_present_with_dims(self): drive_velocity=1.0, duration=1.0, time_step=0.1, - front_joint_to_bumper_m=2.2, - front_body_width_m=2.0, - rear_joint_to_bumper_m=2.0, - rear_body_width_m=2.0, + 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) diff --git a/test/test_python_bindings.py b/test/test_python_bindings.py index 137a4c9..3514c38 100644 --- a/test/test_python_bindings.py +++ b/test/test_python_bindings.py @@ -19,11 +19,15 @@ from polymath_kinematics import ( ArticulatedModel, ArticulatedProjector, + AxleReference, BicycleModel, BicycleProjector, DifferentialDriveModel, DifferentialDriveProjector, + Point2D, Pose2D, + rectangle_footprint, + transform_footprint, ) @@ -334,62 +338,120 @@ def test_construction(self): class TestProjectorFootprints: - def test_bicycle_no_dims_empty_footprint(self): + def test_bicycle_no_footprint_is_empty(self): projector = BicycleProjector(BicycleModel(2.5, 1.5, 0.3), -0.6, 0.6) result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 1.0) assert list(result.footprint) == [] - def test_bicycle_footprint_corners(self): + def test_bicycle_footprint_exposed(self): + # Binding-surface check only: exact geometry is covered by the C++ projector tests. projector = BicycleProjector( - BicycleModel(2.5, 1.5, 0.3), - -0.6, - 0.6, - front_overhang_m=3.0, - rear_overhang_m=1.0, - body_width_m=2.0, + BicycleModel(2.5, 1.5, 0.3), -0.6, 0.6, footprint=rectangle_footprint(3.0, 1.0, 2.0) ) - # v=0, zero steering: pose stays at origin, heading +x. result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 0.0) - corners = result.footprint - assert len(corners) == 4 - assert corners[0].x == pytest.approx(-1.0) - assert corners[0].y == pytest.approx(-1.0) - assert corners[1].x == pytest.approx(3.0) - assert corners[2].y == pytest.approx(1.0) + assert len(result.footprint) == 4 + assert hasattr(result.footprint[0], 'x') and hasattr(result.footprint[0], 'y') - def test_differential_footprint_corners(self): + def test_bicycle_arbitrary_polygon_vertex_count_is_preserved(self): + body = [Point2D(-1.0, -0.9), Point2D(2.0, -0.9), Point2D(2.8, 0.1), Point2D(2.0, 0.9), Point2D(-1.0, 0.9)] + projector = BicycleProjector(BicycleModel(2.5, 1.5, 0.3), -0.6, 0.6, footprint=body) + result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 0.0) + assert len(result.footprint) == 5 + assert result.footprint[2].x == pytest.approx(2.8) + assert result.footprint[2].y == pytest.approx(0.1) + + def test_differential_footprint_exposed(self): projector = DifferentialDriveProjector( - DifferentialDriveModel(0.1, 0.5), - -2.0, - 2.0, - -3.0, - 3.0, - front_overhang_m=1.5, - rear_overhang_m=0.5, - body_width_m=1.0, + DifferentialDriveModel(0.1, 0.5), -2.0, 2.0, -3.0, 3.0, footprint=rectangle_footprint(1.5, 0.5, 1.0) ) result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 0.0, 100.0, 100.0) assert len(result.footprint) == 4 - def test_articulated_no_dims_empty_footprints(self): + def test_articulated_no_footprint_is_empty(self): projector = ArticulatedProjector(ArticulatedModel(1.66, 1.44, 2.0, 2.0, 0.723, 0.723), -0.785, 0.785) result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 1.0) assert list(result.front_footprint) == [] assert list(result.rear_footprint) == [] - def test_articulated_footprint_corners(self): + def test_articulated_footprint_exposed(self): + # Binding-surface check only: exact geometry is covered by the C++ projector tests. projector = ArticulatedProjector( ArticulatedModel(1.66, 1.44, 2.0, 2.0, 0.723, 0.723), -0.785, 0.785, - front_joint_to_bumper_m=2.2, - front_body_width_m=2.0, - rear_joint_to_bumper_m=2.0, - rear_body_width_m=2.0, + front_footprint=rectangle_footprint(2.2, 0.4, 2.0), + rear_footprint=rectangle_footprint(0.3, 2.0, 2.0), ) - # Zero articulation, v=0: joint sits rear-arm (1.44) ahead of the rear axle along +x. result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 0.0) assert len(result.front_footprint) == 4 assert len(result.rear_footprint) == 4 - assert result.front_footprint[0].x == pytest.approx(1.44) - assert result.front_footprint[1].x == pytest.approx(1.44 + 2.2) + assert hasattr(result.front_footprint[0], 'x') and hasattr(result.front_footprint[0], 'y') + + def test_articulated_reports_joint_pose(self): + articulation_to_rear = 1.44 + projector = ArticulatedProjector( + ArticulatedModel(1.66, articulation_to_rear, 2.0, 2.0, 0.723, 0.723), -0.785, 0.785 + ) + # REAR reference (the default): the rear axle is the pose, so the joint leads it by L_r. + result = projector.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 0.0) + assert result.joint_pose.x == pytest.approx(articulation_to_rear) + assert result.joint_pose.y == pytest.approx(0.0) + + +class TestAxleReference: + def test_enum_values_exposed(self): + assert AxleReference.FRONT != AxleReference.REAR + + def test_bicycle_default_is_rear(self): + projector = BicycleProjector(BicycleModel(2.5, 1.5, 0.3), -0.6, 0.6) + assert projector.axle_reference == AxleReference.REAR + + def test_bicycle_front_reference_leads_by_a_wheelbase(self): + wheelbase = 2.5 + model = BicycleModel(wheelbase, 1.5, 0.3) + rear = BicycleProjector(model, -0.6, 0.6, axle_reference=AxleReference.REAR) + front = BicycleProjector(model, -0.6, 0.6, axle_reference=AxleReference.FRONT) + # Straight ahead: the front-axle pose stays a wheelbase ahead of the rear-axle pose. + rear_state = rear.step(0.1, Pose2D(), 0.0, 0.0, 0.0, 1.0) + front_state = front.step(0.1, Pose2D(x=wheelbase), 0.0, 0.0, 0.0, 1.0) + assert front_state.pose.x == pytest.approx(rear_state.pose.x + wheelbase) + assert front_state.pose.y == pytest.approx(rear_state.pose.y) + + def test_articulated_front_reference_uses_the_front_body_heading(self): + articulation_to_front, articulation_to_rear = 1.66, 1.44 + gamma = 0.3 + projector = ArticulatedProjector( + ArticulatedModel(articulation_to_front, articulation_to_rear, 2.0, 2.0, 0.723, 0.723), + -0.785, + 0.785, + axle_reference=AxleReference.FRONT, + ) + # Front-axle pose corresponding to a rear axle at the origin heading +x, held at gamma. + start = Pose2D( + x=articulation_to_rear + articulation_to_front * math.cos(gamma), + y=articulation_to_front * math.sin(gamma), + theta=gamma, + ) + result = projector.step(0.1, start, gamma, gamma, 0.0, 0.0) + assert result.pose.theta == pytest.approx(gamma) + assert result.joint_pose.x == pytest.approx(articulation_to_rear) + assert result.joint_pose.theta == pytest.approx(0.0) + + +class TestRectangleFootprint: + def test_corner_order_and_extents(self): + corners = rectangle_footprint(3.0, 1.0, 2.0) + assert len(corners) == 4 + # CCW, open: rear-right, front-right, front-left, rear-left. + assert (corners[0].x, corners[0].y) == pytest.approx((-1.0, -1.0)) + assert (corners[1].x, corners[1].y) == pytest.approx((3.0, -1.0)) + assert (corners[2].x, corners[2].y) == pytest.approx((3.0, 1.0)) + assert (corners[3].x, corners[3].y) == pytest.approx((-1.0, 1.0)) + + def test_non_positive_width_is_empty(self): + assert list(rectangle_footprint(3.0, 1.0, 0.0)) == [] + + def test_transform_footprint_rotates_and_translates(self): + corners = transform_footprint([Point2D(1.0, 0.0)], Pose2D(x=2.0, y=3.0, theta=math.pi / 2)) + assert corners[0].x == pytest.approx(2.0) + assert corners[0].y == pytest.approx(4.0) diff --git a/uv.lock b/uv.lock index 8b9dd6b..e56a7de 100644 --- a/uv.lock +++ b/uv.lock @@ -211,7 +211,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -286,7 +286,7 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -377,7 +377,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1045,10 +1045,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -1114,9 +1114,9 @@ resolution-markers = [ "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [ @@ -1302,11 +1302,11 @@ explorer = [ [package.dev-dependencies] dev = [ - { name = "matplotlib" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "polymath-kinematics", extra = ["explorer"] }, { name = "pytest" }, - { name = "streamlit" }, +] +explorer = [ + { name = "polymath-kinematics", extra = ["explorer"] }, ] [package.metadata] @@ -1322,11 +1322,10 @@ provides-extras = ["explorer", "dev"] [package.metadata.requires-dev] dev = [ - { name = "matplotlib", specifier = ">=3.5.0" }, - { name = "pandas", specifier = ">=1.4.0" }, + { name = "polymath-kinematics", extras = ["explorer"] }, { name = "pytest", specifier = ">=7.0.0" }, - { name = "streamlit", specifier = ">=1.20.0" }, ] +explorer = [{ name = "polymath-kinematics", extras = ["explorer"] }] [[package]] name = "protobuf" From 12e41c93ae1c227c82f882a12828505d3aa17cc2 Mon Sep 17 00:00:00 2001 From: Zeerek Date: Wed, 12 Aug 2026 18:52:36 +0000 Subject: [PATCH 8/9] Update ci for wheels to work --- .github/workflows/build.yml | 10 +++++----- .github/workflows/pre-commit.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 08efecf..719fed0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,11 +27,11 @@ jobs: ROS_DISTRO: ${{ matrix.ros }} PIP_BREAK_SYSTEM_PACKAGES: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: ros-tooling/action-ros-ci@v0.4 with: target-ros2-distro: ${{ matrix.ros }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: colcon-logs-${{ matrix.ros }} path: ros_ws/log @@ -41,8 +41,8 @@ jobs: runs-on: ubuntu-latest name: Wheel and explorer steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 + - 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 @@ -64,7 +64,7 @@ jobs: runs-on: ubuntu-latest name: Standalone CMake + ctest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install build dependencies run: | sudo apt-get update diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 093bf31..bff844c 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -10,8 +10,8 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: "3.10" - run: sudo apt-get update && sudo apt-get install libxml2-utils From eff0f44741d5be4173ad7f8980232e6424f58753 Mon Sep 17 00:00:00 2001 From: Zeerek Date: Wed, 12 Aug 2026 22:08:35 +0000 Subject: [PATCH 9/9] README update --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6ee2c0c..b9291e9 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ from polymath_kinematics import ( Pose2D, Point2D, rectangle_footprint, + transform_footprint, ) ``` @@ -208,7 +209,9 @@ Every projector optionally computes a per-sample vehicle footprint. Footprints a polygons** given in the body frame, and they live on the projector rather than the model — the kinematic models stay dimension-free. A polygon is counter-clockwise and not closed; an empty polygon (the default) means "unset", so the footprint comes back empty and projection proceeds -normally. `rectangle_footprint(front_m, rear_m, width_m)` builds the boxy common case. +normally. `rectangle_footprint(front_m, rear_m, width_m)` builds the boxy common case, and +`transform_footprint(polygon, pose)` maps a body-frame polygon into another frame if you need to +transform one yourself. For the bicycle and articulated models, poses and the body-frame footprint are both measured from an axle you choose with `AxleReference.FRONT` or `AxleReference.REAR`. A differential drive has one @@ -256,7 +259,7 @@ result = projector.step( linear_velocity_m_s=1.0, ) print(result.pose.x, result.steering_angle_rad) -print([(p.x, p.y) for p in result.footprint]) # world-frame body polygon +print([(p.x, p.y) for p in result.footprint]) # world-frame body polygon; empty if no footprint was given # Full trajectory trajectory = projector.project( @@ -470,6 +473,6 @@ auto diff_trajectory = diff_projector.project( | `ArticulatedProjector` | `(ArticulatedModel, min_articulation, max_articulation, [axle_reference, front_footprint, rear_footprint])` | `step(dt, pose, current, target, rate, v)`, `project(horizon, dt, pose, initial, target, rate, v)` | | `DifferentialDriveProjector` | `(DifferentialDriveModel, min_v, max_v, min_omega, max_omega, [footprint])` | `step(dt, pose, current_v, current_omega, target_v, target_omega, accel, angular_accel)`, `project(horizon, dt, pose, initial_v, initial_omega, target_v, target_omega, accel, angular_accel)` | -All three clamp the target to its `[min, max]` bounds before ramping, then advance toward the clamped target at the given rate or acceleration (never overshooting), and integrate pose with Euler using the heading at the start of each step. `project()` returns `ceil(horizon / dt) + 1` samples, with the initial state as element 0. +All three clamp the target to its `[min, max]` bounds before ramping, then advance toward the clamped target at the given rate or acceleration (never overshooting), and integrate pose with Euler. Each step advances position using the heading `θ` at the *start* of the step, while the angular rate `ω` comes from the *post-ramp* steering/articulation angle — the model headers phrase this as integrating with the "post-ramp angle", referring to that rate, not the heading used for position. `project()` returns `ceil(horizon / dt) + 1` samples, with the initial state as element 0; it returns an empty sequence for degenerate inputs (`dt_s <= 0` or `horizon_s < 0`). `BicycleProjector` and `ArticulatedProjector` ramp an angle; `DifferentialDriveProjector` ramps the body command `(v, ω)` under separate linear and angular acceleration limits, since a differential drive has no steered joint.