TinyRobotics
Loading...
Searching...
No Matches
MotionController2D.h
1// For dynamic dt measurement (simplified)
2#pragma once
3#include "Arduino.h" // for millis
6#include "TinyRobotics/coordinates/Coordinate.h"
7#include "TinyRobotics/imu/IMU2D.h"
8#include "TinyRobotics/planning/Path.h"
9
10namespace tinyrobotics {
11
12/**
13 * @brief Throttle control strategy selection.
14 * @ingroup control
15 */
16enum class ThrottleMode {
17 FeedforwardOnly, ///< Model-based (open-loop) throttle
18 PIDOnly, ///< Feedback (closed-loop) throttle
19 Combined ///< Feedforward + PID feedback
20};
21
22/**
23 * @class MotionController2D
24 * @ingroup control
25 * @brief 2D motion controller for path following and vehicle control.
26 *
27 * This class implements a flexible 2D motion controller for robotic vehicles,
28 * supporting both model-based (feedforward) and feedback (PID) control for
29 * speed (throttle) and steering. It is designed for path following, smooth
30 * acceleration/deceleration, and robust integration with IMU and odometry
31 * sensors.
32 *
33 * **Features:**
34 * - Configurable PID controllers for both speed (throttle) and steering angle
35 * correction.
36 * - Multiple throttle control strategies: Feedforward (model-based), PID
37 * (feedback), or Combined (feedforward + feedback).
38 * - Path following with a sequence of waypoints, including automatic waypoint
39 * advancement and goal detection.
40 * - Smooth acceleration and deceleration profiles based on distance to target
41 * and from start.
42 * - Dynamic time step (dt) initialization for PID controllers based on update
43 * frequency.
44 * - Modular design for use with different vehicle types, IMU sensors, and
45 * coordinate systems.
46 * - Integration with the TinyRobotics message system for sending throttle and
47 * steering commands.
48 * - Customizable goal callback for user-defined actions upon reaching the final
49 * waypoint.
50 *
51 * **Usage Example:**
52 * @code
53 * MotionController2D<> controller(motionState, 10.0f, 2.0f);
54 * controller.setPath(path);
55 * controller.setThrottleMode(ThrottleMode::Combined);
56 * controller.begin();
57 * // In main loop:
58 * controller.update();
59 * @endcode
60 *
61 * **Integration:**
62 * - Use as a standalone controller for path following and vehicle control.
63 * - Integrate with a message bus to receive and send control messages.
64 * - Extend or customize by overriding protected methods or providing custom
65 * callbacks.
66 *
67 * @tparam T Numeric type for calculations (default: float)
68 *
69 * @author Phil Schatzmann
70 */
71template <typename T = float>
72class MotionController2D : public MessageSource {
73 public:
74 MotionController2D(IMotionState2D& motionState, float maxSpeedKmh = 10,
75 float maxSteeringAngleDeg = 30.0f,
76 float accelDistanceM = 2.0f)
77 : motionStateSource(motionState),
78 accelDistanceM(accelDistanceM),
79 maxSpeedKmh(maxSpeedKmh) {
80 // Conservative initial PID values for easier tuning
81 // kp=0.1, ki=0, kd=0
82 configureSteeringPID(-maxSteeringAngleDeg, maxSteeringAngleDeg, 1.0f, 0.0f,
83 0.0f);
84 // kp=2.0, ki=0, kd=0
85 configureSpeedPID(0.0f, 100.0f, 2.0f, 0.0f, 0.0f);
86 }
87
88 MotionController2D(IMotionState2D& motionState, Speed maxSpeedKmh,
89 Angle maxSteeringAngle, Distance accelDistanceM)
90 : MotionController2D(motionState, maxSpeedKmh.getValue(SpeedUnit::KPH),
91 maxSteeringAngle.getValue(AngleUnit::DEG),
92 accelDistanceM.getValue(DistanceUnit::M)) {}
93
94 /**
95 * @brief Configure the PID controller for speed-to-throttle mapping.
96 * @param dt Time step (seconds)
97 * @param maxOut Maximum output (throttle)
98 * @param minOut Minimum output (throttle)
99 * @param kp Proportional gain
100 * @param ki Integral gain
101 * @param kd Derivative gain
102 */
103 void configureSpeedPID(float minOut, float maxOut, float kp, float ki,
104 float kd) {
105 pidSpeed_.begin(0.0f, minOut, maxOut, kp, ki, kd);
106 }
107
108 /**
109 * @brief Configure the PID controller for steering.
110 * @param minOut Minimum output
111 * @param maxOut Maximum output
112 * @param kp Proportional gain
113 * @param ki Integral gain
114 * @param kd Derivative gain
115 */
116 void configureSteeringPID(float minOut, float maxOut, float kp, float ki,
117 float kd) {
118 pidSteering_.begin(0.0f, minOut, maxOut, kp, ki, kd);
119 }
120
121 /**
122 * @brief Defines the path to follow as a sequence of waypoints
123 */
124 void setPath(Path<Coordinate<DistanceM>> path) { this->path = path; }
125
126 /**
127 * @brief Adds a single waypoint to the path (appended to the end)
128 */
129 void addWaypoint(Coordinate<DistanceM> target) {
130 this->path.addWaypoint(target);
131 }
132
133 /**
134 * @brief Set the target accuracy (meters) for reaching waypoints.
135 * @param accuracyM The new target accuracy in meters.
136 */
137 void setTargetAccuracy(float accuracyM) { targetAccuracyM = accuracyM; }
138
139 /**
140 * @brief Start the controller and initialize state
141 */
142 bool begin() {
143 is_active = true;
144 has_distance = false;
145 updateCount = 0;
146 updateStartTimeMs = 0;
147 updateEndTimeMs = 0;
148 dtSetFromUpdates = false;
149
150 if (!hasStartCoordinate) {
151 startCoordinate = motionStateSource.getPosition();
152 hasStartCoordinate = true;
153 }
154 return true;
155 }
156
157 /**
158 * @brief Stop the controller and the vehicle
159 */
160 void end() { is_active = false; }
161
162 /**
163 * @brief Main control loop to be called periodically (e.g., in a timer or
164 * main loop)
165 */
166 void update() {
167 if (!is_active) return;
169 return;
170 }
171 Coordinate<DistanceM> currentPos = motionStateSource.getPosition();
172 Coordinate<DistanceM> targetPos = path[0];
173 float currentHeading =
174 motionStateSource.getHeading().getValue(AngleUnit::DEG);
175 float desiredHeading = 0;
176 if (!handleWaypoint(currentPos, targetPos, distanceToTargetM,
177 desiredHeading)) {
178 // new waypoint
179 return;
180 }
181 float headingError = computeHeadingError(desiredHeading, currentHeading);
182 float currentSpeedKmh =
183 motionStateSource.getSpeed().getValue(SpeedUnit::KPH);
184 float distanceFromStartM =
185 startCoordinate.distance(currentPos, DistanceUnit::M);
186
187 desiredSpeedKmh =
188 getDesiredSpeed(distanceFromStartM, distanceToTargetM, currentSpeedKmh);
189
190 // Compute actuator commands and update class state
191 resultThrottlePercent = getThrottlePercent(currentSpeedKmh);
192 // ROS convention: positive steering = left turn. Invert heading error sign
193 // so negative error (target left) yields negative steering (right turn).
194 float pidInput = !isInverted ? -headingError : headingError;
195 resultSteeringAngleDeg = pidSteering_.calculate(0.0f, pidInput);
196
197 // Log summary (moved from sendControlMessages)
198 TRLogger.info(
199 "MotionController2D: distanceToTarget=%.2f m, desiredHeading=%.1f deg, "
200 "currentHeading=%.1f deg, headingError=%.1f deg, steeringAngle=%.1f "
201 "deg, desiredSpeed=%.2f km/h, currentSpeed=%.2f km/h, throttle=%.1f%%",
202 distanceToTargetM, desiredHeading, currentHeading, headingError,
203 resultSteeringAngleDeg, desiredSpeedKmh, currentSpeedKmh,
204 resultThrottlePercent);
205
206 // Send messages with computed values
207 sendControlMessages(resultThrottlePercent, resultSteeringAngleDeg);
208
209 // We have a valid distance to target, so we can compute control commands
210 has_distance = true;
211 }
212
213 /**
214 * @brief Set a custom callback to be called when reaching the goal. The
215 * callback should return true if it handled the goal action, or false to
216 * allow default handling.
217 */
218 void setOnGoalCallback(bool (*callback)(void*), void* ref = nullptr) {
219 onGoalCallback = callback;
220 if (ref != nullptr) onGoalRef = ref;
221 }
222
223 /**
224 * @brief Get the last computed throttle percent.
225 */
226 float getThrottlePercent() const { return resultThrottlePercent; }
227 /**
228 * @brief Get the last computed steering angle in degrees.
229 */
230 float getSteeringAngleDeg() const { return resultSteeringAngleDeg; }
231 /**
232 * @brief Get the last computed steering angle as an Angle object.
233 */
234 Angle getSteeringAngle() const {
235 return Angle(resultSteeringAngleDeg, AngleUnit::DEG);
236 }
237
238 /**
239 * @brief Get the Target Accuracy object
240 *
241 * @param unit
242 * @return float
243 */
244 float getTargetAccuracy(DistanceUnit unit) const {
245 return Distance(targetAccuracyM, DistanceUnit::M).getValue(unit);
246 }
247
248 /**
249 * @brief Returns true if the goal has been reached
250 */
251 bool isGoalReached() const {
252 if (!has_distance) return false;
253 return distanceToTargetM < targetAccuracyM;
254 }
255
256 /**
257 * @brief Set the throttle control mode.
258 * @param mode The throttle control strategy to use (FeedforwardOnly, PIDOnly,
259 * Combined).
260 */
261 void setThrottleMode(ThrottleMode mode) { throttleMode = mode; }
262
263 /**
264 * @brief Set the Inverted Steering logic: by default we use ROS logic for
265 * steering correction (positive angle = turn left), but some vehicles may
266 * require the opposite. Set to true to invert the steering direction.
267 * @param inverted
268 */
269 void setInvertedSteering(bool inverted) { isInverted = inverted; }
270
271 protected:
272 IMotionState2D& motionStateSource;
273 Path<Coordinate<DistanceM>> path;
274 bool is_active = false;
275 bool has_distance = false;
276 float accelDistanceM = 2.0f;
277 float maxSpeedKmh = 10.0f;
278 float desiredSpeedKmh = 0.0f;
279 float maxSteeringAngleDeg = 30.0f;
280 PIDController<float> pidSteering_;
281 PIDController<float> pidSpeed_;
282 Coordinate<DistanceM> startCoordinate;
283 bool hasStartCoordinate = false;
284 float targetAccuracyM = 0.10f;
285 float distanceToTargetM = 0;
286 bool (*onGoalCallback)(void*) = nullptr;
287 void* onGoalRef = this;
288 float resultThrottlePercent = 0.0f;
289 float resultSteeringAngleDeg = 0.0f;
290 /// Throttle control mode
292 int updateCount = 0;
293 unsigned long updateStartTimeMs = 0;
294 unsigned long updateEndTimeMs = 0;
295 bool dtSetFromUpdates = false;
296 bool isInverted = false; // For steering direction inversion if needed
297
298 /// Calculate desired speed based on distance to target and start
299 float getDesiredSpeed(float distanceFromStartM, float distanceToTarget,
300 float currentSpeedKmh) {
301 // Compute a desired speed profile: slow down as you approach the target
302 float minSpeedKmh = 0.0f;
303 float desiredSpeedKmh =
304 maxSpeedKmh; // * (distanceToTarget / accelDistanceM);
305 if (distanceToTarget < accelDistanceM) {
306 if (distanceToTarget < targetAccuracyM) {
307 desiredSpeedKmh = 0.0f; // Stop at the target
308 } else {
309 desiredSpeedKmh = maxSpeedKmh * (distanceToTarget / accelDistanceM);
310 }
311 }
312 if (distanceFromStartM < accelDistanceM) {
313 // avoid 0 start speed!
314 if (distanceFromStartM == 0)
315 distanceFromStartM = 0.1f; // avoid division by zero
316 desiredSpeedKmh = maxSpeedKmh * (distanceFromStartM / accelDistanceM);
317 }
318 if (desiredSpeedKmh > maxSpeedKmh) desiredSpeedKmh = maxSpeedKmh;
319 if (desiredSpeedKmh < minSpeedKmh) desiredSpeedKmh = minSpeedKmh;
320 return desiredSpeedKmh;
321 }
322
323 /// Handle the current waypoint: calculate distance and desired heading
324 bool handleWaypoint(const Coordinate<DistanceM>& currentPos,
325 Coordinate<DistanceM>& targetPos,
326 float& distanceToTargetM, float& desiredHeadingDeg) {
327 // Use class variable targetAccuracyM
328 distanceToTargetM = 0.0f;
329 while (distanceToTargetM < targetAccuracyM) {
330 desiredHeadingDeg = currentPos.bearing(targetPos, AngleUnit::DEG);
331 distanceToTargetM = currentPos.distance(targetPos, DistanceUnit::M);
332 if (distanceToTargetM < targetAccuracyM) {
333 TRLogger.info(
334 "Reached waypoint at (%.2f m, %.2f m), distance to target: %.2f m",
335 targetPos.x, targetPos.y, distanceToTargetM);
336 path.removeHead();
337 if (path.isEmpty()) {
338 TRLogger.info("Reached final goal!");
339 if (onGoalCallback) {
340 onGoalCallback(onGoalRef);
341 }
342 return false;
343 }
344 targetPos = path[0];
345 return false;
346 }
347 }
348 return true;
349 }
350
351 /// Compute angle difference range [-180, 180]
352 float computeHeadingError(float desiredHeading, float currentHeading) {
353 float headingError = desiredHeading - currentHeading;
354 while (headingError > 180) headingError -= 360;
355 while (headingError < -180) headingError += 360;
356 return headingError;
357 }
358
359 /// Model-based feedforward throttle estimate: 100% throttle = maxSpeedKmh
360 float feedforwardThrottle(float desiredSpeedKmh) const {
361 if (maxSpeedKmh <= 0.0f) return 0.0f;
362 return 100.0f * desiredSpeedKmh / maxSpeedKmh;
363 }
364
365 /// Compute the throttle percent based on the selected mode.
366 float getThrottlePercent(float currentSpeedKmh) {
367 float speedError = desiredSpeedKmh - currentSpeedKmh;
368 float ff = feedforwardThrottle(desiredSpeedKmh);
369 float fb = pidSpeed_.calculate(0.0f, -speedError);
370 float throttlePercent = 0.0f;
371 switch (throttleMode) {
373 throttlePercent = ff;
374 break;
376 throttlePercent = fb;
377 break;
379 default:
380 throttlePercent = ff + fb;
381 break;
382 }
383 // Clamp throttle to [0, 100]
384 if (throttlePercent > 100.0f) throttlePercent = 100.0f;
385 if (throttlePercent < 0.0f) throttlePercent = 0.0f;
386 if (throttlePercent > 0.0f && throttlePercent < 1.0f)
387 throttlePercent = 1.0f; // avoid too low throttle
388 return throttlePercent;
389 }
390
391 /// Send control messages for throttle and steering angle
392 void sendControlMessages(float throttlePercent, float steeringAngleDeg) {
393 Message<float> msg;
394 msg.origin = MessageOrigin::Autonomy;
395 msg.content = MessageContent::Throttle;
396 msg.unit = Unit::Percent;
397 msg.value = throttlePercent;
398 sendMessage(msg);
399
400 msg.content = MessageContent::SteeringAngle;
401 msg.unit = Unit::AngleDegree;
402 msg.value = steeringAngleDeg;
403 sendMessage(msg);
404 }
405
406 /// Handles dt initialization from first 10 updates
408 unsigned long nowMs = millis();
409 if (dtSetFromUpdates) return true;
410 if (updateCount == 0) {
411 updateStartTimeMs = nowMs;
412 }
413 updateCount++;
414 if (updateCount == 11) {
415 updateEndTimeMs = nowMs;
416 float avgMs = (updateEndTimeMs - updateStartTimeMs) / 10.0f;
417 float dt = avgMs / 1000.0f; // convert ms to seconds
418 // Reconfigure both PIDs with new dt, keep other params
419 pidSpeed_.setDt(dt);
420 pidSteering_.setDt(dt);
421 dtSetFromUpdates = true;
422 TRLogger.info("Initialized PID dt from updates: %.3f seconds (%.1f ms)",
423 dt, avgMs);
424 }
425 return dtSetFromUpdates;
426 }
427};
428
429} // namespace tinyrobotics
Represents an angle with unit conversion and wrap-around support.
Definition: Angle.h:42
A generic 3D coordinate class for robotics, navigation, and spatial calculations.
Definition: Coordinate.h:57
Represents a distance measurement with unit conversion support.
Definition: Distance.h:40
Interface for representing the navigation state of a robot in 2D space.
Definition: MotionState2D.h:35
Base class for message sources in the TinyRobotics communication framework.
Definition: MessageSource.h:35
2D motion controller for path following and vehicle control.
Definition: MotionController2D.h:72
void setThrottleMode(ThrottleMode mode)
Set the throttle control mode.
Definition: MotionController2D.h:261
void addWaypoint(Coordinate< DistanceM > target)
Adds a single waypoint to the path (appended to the end)
Definition: MotionController2D.h:129
void setInvertedSteering(bool inverted)
Set the Inverted Steering logic: by default we use ROS logic for steering correction (positive angle ...
Definition: MotionController2D.h:269
void configureSteeringPID(float minOut, float maxOut, float kp, float ki, float kd)
Configure the PID controller for steering.
Definition: MotionController2D.h:116
float getSteeringAngleDeg() const
Get the last computed steering angle in degrees.
Definition: MotionController2D.h:230
Angle getSteeringAngle() const
Get the last computed steering angle as an Angle object.
Definition: MotionController2D.h:234
bool isGoalReached() const
Returns true if the goal has been reached.
Definition: MotionController2D.h:251
void setTargetAccuracy(float accuracyM)
Set the target accuracy (meters) for reaching waypoints.
Definition: MotionController2D.h:137
bool begin()
Start the controller and initialize state.
Definition: MotionController2D.h:142
ThrottleMode throttleMode
Throttle control mode.
Definition: MotionController2D.h:291
float getDesiredSpeed(float distanceFromStartM, float distanceToTarget, float currentSpeedKmh)
Calculate desired speed based on distance to target and start.
Definition: MotionController2D.h:299
void configureSpeedPID(float minOut, float maxOut, float kp, float ki, float kd)
Configure the PID controller for speed-to-throttle mapping.
Definition: MotionController2D.h:103
float getThrottlePercent(float currentSpeedKmh)
Compute the throttle percent based on the selected mode.
Definition: MotionController2D.h:366
bool initializeDtFromUpdates()
Handles dt initialization from first 10 updates.
Definition: MotionController2D.h:407
bool handleWaypoint(const Coordinate< DistanceM > &currentPos, Coordinate< DistanceM > &targetPos, float &distanceToTargetM, float &desiredHeadingDeg)
Handle the current waypoint: calculate distance and desired heading.
Definition: MotionController2D.h:324
void end()
Stop the controller and the vehicle.
Definition: MotionController2D.h:160
float getThrottlePercent() const
Get the last computed throttle percent.
Definition: MotionController2D.h:226
void update()
Main control loop to be called periodically (e.g., in a timer or main loop)
Definition: MotionController2D.h:166
void sendControlMessages(float throttlePercent, float steeringAngleDeg)
Send control messages for throttle and steering angle.
Definition: MotionController2D.h:392
float getTargetAccuracy(DistanceUnit unit) const
Get the Target Accuracy object.
Definition: MotionController2D.h:244
float computeHeadingError(float desiredHeading, float currentHeading)
Compute angle difference range [-180, 180].
Definition: MotionController2D.h:352
void setOnGoalCallback(bool(*callback)(void *), void *ref=nullptr)
Set a custom callback to be called when reaching the goal. The callback should return true if it hand...
Definition: MotionController2D.h:218
float feedforwardThrottle(float desiredSpeedKmh) const
Model-based feedforward throttle estimate: 100% throttle = maxSpeedKmh.
Definition: MotionController2D.h:360
void setPath(Path< Coordinate< DistanceM > > path)
Defines the path to follow as a sequence of waypoints.
Definition: MotionController2D.h:124
Implements a simple, header-only Proportional-Integral-Derivative (PID) controller.
Definition: PIDController.h:56
Represents a speed measurement with unit conversion support.
Definition: Speed.h:40
ThrottleMode
Throttle control strategy selection.
Definition: MotionController2D.h:16
@ FeedforwardOnly
Model-based (open-loop) throttle.
@ Combined
Feedforward + PID feedback.
@ PIDOnly
Feedback (closed-loop) throttle.
DistanceUnit
Supported distance units for conversion and representation.
Definition: Distance.h:10
AngleUnit
Supported angle units for conversion and representation.
Definition: Angle.h:11
SpeedUnit
Supported speed units for conversion and representation.
Definition: Speed.h:10
Generic message structure for communication, parameterized by value type.
Definition: Message.h:72