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