Skip to content

Commit f32f5fd

Browse files
committed
feat: add trapezoidal motion profile generator and constraints
feat: add arithmetic operators and companion object with zero constant to KineticState fix: remove internal goal variable and update goal handling in TrapezoidProfile refactor calculate method for clarity fix: update timestamp handling and improve reference calculation in TrapezoidProfile feat: update TrapezoidProfile to use KineticState.ZERO and enhance documentation chore: update license information in TrapezoidProfile.kt test: add unit tests for TrapezoidProfile functionality and constraints chore: add WPILib license information and attribution in TrapezoidProfile
1 parent 803a132 commit f32f5fd

4 files changed

Lines changed: 783 additions & 5 deletions

File tree

LICENSES/WPILib.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Some code in this project is based on [WPILib](https://wpilib.org/);
2+
WPILib is licensed under the following terms:
3+
4+
Copyright (c) 2009-2025 FIRST and other WPILib contributors
5+
All rights reserved.
6+
7+
Redistribution and use in source and binary forms, with or without
8+
modification, are permitted provided that the following conditions are met:
9+
* Redistributions of source code must retain the above copyright
10+
notice, this list of conditions and the following disclaimer.
11+
* Redistributions in binary form must reproduce the above copyright
12+
notice, this list of conditions and the following disclaimer in the
13+
documentation and/or other materials provided with the distribution.
14+
* Neither the name of FIRST, WPILib, nor the names of other WPILib
15+
contributors may be used to endorse or promote products derived from
16+
this software without specific prior written permission.
17+
18+
THIS SOFTWARE IS PROVIDED BY FIRST AND OTHER WPILIB CONTRIBUTORS "AS IS" AND
19+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20+
WARRANTIES OF MERCHANTABILITY NONINFRINGEMENT AND FITNESS FOR A PARTICULAR
21+
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL FIRST OR CONTRIBUTORS BE LIABLE FOR
22+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
25+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

src/main/kotlin/dev/nextftc/control/KineticState.kt

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,18 @@ package dev.nextftc.control
2525
* @param velocity the state's velocity
2626
* @param acceleration the state's acceleration
2727
*
28-
* @author BeepBot99
28+
* @author BeepBot99, zachwaffle4
2929
*/
3030
data class KineticState @JvmOverloads constructor(
3131
val position: Double = 0.0,
3232
val velocity: Double = 0.0,
3333
val acceleration: Double = 0.0
3434
) {
35+
operator fun plus(other: KineticState): KineticState = KineticState(
36+
position + other.position,
37+
velocity + other.velocity,
38+
acceleration + other.acceleration
39+
)
3540

3641
operator fun minus(other: KineticState): KineticState = KineticState(
3742
position - other.position,
@@ -45,9 +50,15 @@ data class KineticState @JvmOverloads constructor(
4550
acceleration * scalar
4651
)
4752

48-
operator fun plus(other: KineticState): KineticState = KineticState(
49-
position + other.position,
50-
velocity + other.velocity,
51-
acceleration + other.acceleration
53+
operator fun div(scalar: Double): KineticState = KineticState(
54+
position / scalar,
55+
velocity / scalar,
56+
acceleration / scalar
5257
)
58+
59+
operator fun unaryMinus(): KineticState = KineticState(-position, -velocity, -acceleration)
60+
61+
companion object {
62+
@JvmField val ZERO = KineticState()
63+
}
5364
}
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
/*
2+
* NextFTC: a user-friendly control library for FIRST Tech Challenge
3+
* Copyright (C) 2025 Rowan McAlpin
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*
18+
* This file also contains code from WPILib.
19+
* WPILib code is used under the BSD-3 license,
20+
* found in the LICENSES/WPILib.md file.
21+
*/
22+
package dev.nextftc.control.interpolators
23+
24+
import dev.nextftc.control.KineticState
25+
import kotlin.math.abs
26+
import kotlin.math.max
27+
import kotlin.math.sqrt
28+
import kotlin.math.withSign
29+
import kotlin.time.ComparableTimeMark
30+
import kotlin.time.Duration
31+
import kotlin.time.DurationUnit
32+
import kotlin.time.TimeSource
33+
34+
/**
35+
* Constraints for a trapezoidal motion profile.
36+
*
37+
* @property maxVelocity The maximum velocity of the profile.
38+
* @property maxAcceleration The maximum acceleration of the profile.
39+
*/
40+
data class TrapezoidProfileConstraints(val maxVelocity: Double, val maxAcceleration: Double) {
41+
init {
42+
require(maxVelocity >= 0.0) { "Constraints must be non-negative" }
43+
require(maxAcceleration >= 0.0) { "Constraints must be non-negative" }
44+
}
45+
}
46+
47+
/**
48+
* A trapezoidal motion profile generator.
49+
*
50+
* A trapezoidal motion profile is a velocity profile that accelerates at a constant rate,
51+
* maintains a constant velocity, then decelerates at a constant rate. This creates a
52+
* trapezoid shape when velocity is plotted over time.
53+
*
54+
* The profile handles truncated motion profiles (with nonzero initial or final velocity)
55+
* and profiles that never reach maximum velocity (triangular profiles).
56+
*
57+
* @param constraints The [TrapezoidProfileConstraints] that define the maximum velocity and
58+
* acceleration for the profile.
59+
*
60+
* @author WPILib contributors, Zach Harel
61+
*/
62+
class TrapezoidProfile(private val constraints: TrapezoidProfileConstraints) {
63+
private var direction = 0
64+
65+
private var currentState = KineticState.ZERO
66+
67+
private var endAccel = 0.0
68+
private var endVel = 0.0
69+
private var endDecel = 0.0
70+
71+
/**
72+
* The total time required to complete the motion profile.
73+
*/
74+
val totalTime: Double
75+
get() = endDecel
76+
77+
/**
78+
* Calculates the state of the profile at a given time.
79+
*
80+
* @param t The time since the beginning of the profile
81+
* @param current The current state of the system.
82+
* @param goal The desired goal state.
83+
*
84+
* @return The state of the profile at time [t].
85+
*/
86+
fun calculate(t: Duration, current: KineticState, goal: KineticState): KineticState {
87+
direction = if (shouldFlipAcceleration(current, goal)) -1 else 1
88+
currentState = direct(current)
89+
val directGoal = direct(goal)
90+
91+
val timeSeconds = t.toDouble(DurationUnit.SECONDS)
92+
93+
if (abs(currentState.velocity) > constraints.maxVelocity) {
94+
currentState = currentState.copy(velocity = constraints.maxVelocity.withSign(currentState.velocity))
95+
}
96+
97+
// Deal with a possibly truncated motion profile (with nonzero initial or
98+
// final velocity) by calculating the parameters as if the profile began and
99+
// ended at zero velocity
100+
val cutoffBegin = currentState.velocity / constraints.maxAcceleration
101+
val cutoffDistBegin = cutoffBegin * cutoffBegin * constraints.maxAcceleration / 2.0
102+
103+
val cutoffEnd = directGoal.velocity / constraints.maxAcceleration
104+
val cutoffDistEnd = cutoffEnd * cutoffEnd * constraints.maxAcceleration / 2.0
105+
106+
// Now we can calculate the parameters as if it was a full trapezoid instead
107+
// of a truncated one
108+
val fullTrapezoidDist =
109+
cutoffDistBegin + (directGoal.position - currentState.position) + cutoffDistEnd
110+
var accelerationTime = constraints.maxVelocity / constraints.maxAcceleration
111+
112+
var fullSpeedDist =
113+
fullTrapezoidDist - accelerationTime * accelerationTime * constraints.maxAcceleration
114+
115+
// Handle the case where the profile never reaches full speed
116+
if (fullSpeedDist < 0) {
117+
accelerationTime = sqrt(fullTrapezoidDist / constraints.maxAcceleration)
118+
fullSpeedDist = 0.0
119+
}
120+
121+
endAccel = accelerationTime - cutoffBegin
122+
endVel = endAccel + fullSpeedDist / constraints.maxVelocity
123+
endDecel = endVel + accelerationTime - cutoffEnd
124+
125+
val position: Double
126+
val velocity: Double
127+
val accel: Double
128+
129+
if (timeSeconds < endAccel) {
130+
velocity = currentState.velocity + timeSeconds * constraints.maxAcceleration
131+
position = currentState.position + (currentState.velocity + timeSeconds * constraints.maxAcceleration / 2.0) * timeSeconds
132+
accel = constraints.maxAcceleration
133+
} else if (timeSeconds < endVel) {
134+
velocity = constraints.maxVelocity
135+
position = currentState.position +
136+
((currentState.velocity + endAccel * constraints.maxAcceleration / 2.0) * endAccel
137+
+ constraints.maxVelocity * (timeSeconds - endAccel))
138+
accel = 0.0
139+
} else if (timeSeconds <= endDecel) {
140+
velocity = directGoal.velocity + (endDecel - timeSeconds) * constraints.maxAcceleration
141+
val timeLeft = endDecel - timeSeconds
142+
position = directGoal.position - (directGoal.velocity + timeLeft * constraints.maxAcceleration / 2.0) * timeLeft
143+
accel = -constraints.maxAcceleration
144+
} else {
145+
velocity = directGoal.velocity
146+
position = directGoal.position
147+
accel = 0.0
148+
}
149+
150+
return direct(KineticState(position, velocity, accel))
151+
}
152+
153+
/**
154+
* Calculates the time remaining until the profile reaches a target position.
155+
*
156+
* @param target The target position to reach.
157+
*
158+
* @return The time remaining until the target is reached, in seconds.
159+
*/
160+
fun timeLeftUntil(target: Double): Double {
161+
val position = currentState.position * direction
162+
var velocity = currentState.velocity * direction
163+
164+
var endAccel = endAccel * direction
165+
var endFullSpeed = endVel * direction - endAccel
166+
167+
if (target < position) {
168+
endAccel = -endAccel
169+
endFullSpeed = -endFullSpeed
170+
velocity = -velocity
171+
}
172+
173+
endAccel = max(endAccel, 0.0)
174+
endFullSpeed = max(endFullSpeed, 0.0)
175+
176+
val acceleration = constraints.maxAcceleration
177+
val deceleration = -constraints.maxAcceleration
178+
179+
val distToTarget = abs(target - position)
180+
if (distToTarget < 1e-6) {
181+
return 0.0
182+
}
183+
184+
var accelDist = velocity * endAccel + 0.5 * acceleration * endAccel * endAccel
185+
186+
val decelVelocity: Double = if (endAccel > 0) {
187+
sqrt(abs(velocity * velocity + 2 * acceleration * accelDist))
188+
} else {
189+
velocity
190+
}
191+
192+
var fullSpeedDist = constraints.maxVelocity * endFullSpeed
193+
val decelDist: Double
194+
195+
if (accelDist > distToTarget) {
196+
accelDist = distToTarget
197+
fullSpeedDist = 0.0
198+
decelDist = 0.0
199+
} else if (accelDist + fullSpeedDist > distToTarget) {
200+
fullSpeedDist = distToTarget - accelDist
201+
decelDist = 0.0
202+
} else {
203+
decelDist = distToTarget - fullSpeedDist - accelDist
204+
}
205+
206+
val accelTime =
207+
((-velocity + sqrt(abs(velocity * velocity + 2 * acceleration * accelDist)))
208+
/ acceleration)
209+
210+
val decelTime =
211+
((-decelVelocity
212+
+ sqrt(abs(decelVelocity * decelVelocity + 2 * deceleration * decelDist)))
213+
/ deceleration)
214+
215+
val fullSpeedTime = fullSpeedDist / constraints.maxVelocity
216+
217+
return accelTime + fullSpeedTime + decelTime
218+
}
219+
220+
/**
221+
* Checks if the profile has finished at the given time.
222+
*
223+
* @param t The time since the beginning of the profile, in seconds.
224+
*
225+
* @return true if the profile has finished, false otherwise.
226+
*/
227+
fun isFinished(t: Double): Boolean {
228+
return t >= totalTime
229+
}
230+
231+
/**
232+
* Flips the sign of the velocity and position if the profile is inverted.
233+
* Used internally to handle backward motion.
234+
*
235+
* @param state The state to transform.
236+
*
237+
* @return The transformed state with signs adjusted based on [direction].
238+
*/
239+
private fun direct(state: KineticState): KineticState {
240+
val position = state.position * direction
241+
val velocity = state.velocity * direction
242+
val acceleration = state.acceleration * direction
243+
return KineticState(position, velocity, acceleration)
244+
}
245+
246+
companion object {
247+
/**
248+
* Determines if the acceleration should be flipped based on the initial and goal states.
249+
*
250+
* @param initial The initial state.
251+
* @param goal The goal state.
252+
*
253+
* @return true if the goal position is less than the initial position, false otherwise.
254+
*/
255+
private fun shouldFlipAcceleration(initial: KineticState, goal: KineticState): Boolean {
256+
return initial.position > goal.position
257+
}
258+
}
259+
}
260+
261+
/**
262+
* An [InterpolatorElement] that generates smooth trapezoidal motion profiles.
263+
*
264+
* This element uses a [TrapezoidProfile] to interpolate from the current reference to the goal
265+
* state while respecting velocity and acceleration constraints. The profile generates a smooth
266+
* trajectory that accelerates, maintains constant velocity, and decelerates.
267+
*
268+
* @param constraints The [TrapezoidProfileConstraints] that define the maximum velocity and
269+
* acceleration for the profile.
270+
* @param timeSource The time source used to track elapsed time. Defaults to the system's
271+
* monotonic time source.
272+
*
273+
* @author WPILib contributors, Zach Harel
274+
*/
275+
class TrapezoidProfileElement @JvmOverloads constructor(
276+
constraints: TrapezoidProfileConstraints,
277+
val timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic
278+
) : InterpolatorElement {
279+
/**
280+
* The underlying trapezoidal profile generator.
281+
*/
282+
internal val profile = TrapezoidProfile(constraints)
283+
284+
/**
285+
* The goal that the interpolator is trying to reach. Setting this resets the profile's
286+
* start time.
287+
*/
288+
override var goal = KineticState()
289+
set(value) {
290+
field = value
291+
lastTimestamp = timeSource.markNow()
292+
}
293+
294+
internal lateinit var lastTimestamp: ComparableTimeMark
295+
296+
/**
297+
* The previous reference state, used as the starting point for the next profile calculation.
298+
*/
299+
internal var previousReference = KineticState.ZERO
300+
301+
override val currentReference: KineticState
302+
get() {
303+
val nextTimestamp = timeSource.markNow()
304+
305+
if (!::lastTimestamp.isInitialized) {
306+
lastTimestamp = nextTimestamp
307+
return KineticState()
308+
}
309+
310+
val dt = nextTimestamp - lastTimestamp
311+
312+
previousReference = profile.calculate(dt, previousReference, goal)
313+
return previousReference
314+
}
315+
316+
override fun reset() {
317+
lastTimestamp = timeSource.markNow()
318+
previousReference = KineticState.ZERO
319+
}
320+
}

0 commit comments

Comments
 (0)