Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d2b75e9
add: configure testing dependencies for control module and XML file u…
zachwaffle4 Dec 16, 2025
80a0020
add: implement AngularState and LinearState classes for kinematic mot…
zachwaffle4 Dec 16, 2025
47696ee
add: introduce InchesPerSecond and inchesPerSecond extension for line…
zachwaffle4 Dec 16, 2025
ae55d98
remove state classes
zachwaffle4 Dec 17, 2025
a9b543d
add: implement constructor for SizedVector from SizedMatrix
zachwaffle4 Dec 17, 2025
25d23c4
reformat copyright again
zachwaffle4 Dec 18, 2025
4605e5c
add: override toString() method to return short string representation…
zachwaffle4 Dec 19, 2025
faf8b9a
add: implement PID and SquID controller classes
zachwaffle4 Dec 19, 2025
e899509
add: implement MotionState data class for tracking position, velocity…
zachwaffle4 Dec 19, 2025
43ebbc0
refactor: rename SizedMatrix to Matrix and SizedVector to Vector to m…
zachwaffle4 Dec 19, 2025
43260e3
add: implement GravityFeedforward and SimpleFeedforward controllers w…
zachwaffle4 Dec 19, 2025
540084b
add: implement TrapezoidProfile class and associated tests for motion…
zachwaffle4 Dec 19, 2025
1bc72ab
add: implement LinearModel class and Model interface for state-space …
zachwaffle4 Dec 19, 2025
618926b
refactor: rename SizedVector and SizedMatrix to Vector and Matrix for…
zachwaffle4 Dec 19, 2025
0ab72cf
add: introduce natural number representations for rows and columns in…
zachwaffle4 Dec 19, 2025
ec34141
refactor: adjust line length limit to 120 and reformat
zachwaffle4 Dec 19, 2025
96ce0ba
add: implement LQRController control using state-space representation
zachwaffle4 Dec 19, 2025
3054663
add: move utility functions for state-space representation to StateSp…
zachwaffle4 Dec 19, 2025
1103676
add: implement matrix exponential function using Padé approximant and…
zachwaffle4 Dec 19, 2025
a3bad82
add: move discretizeAB to new file and add discretizeAQ + tests
zachwaffle4 Dec 19, 2025
d770adf
add: enhance matrix operations to support division by scalar
zachwaffle4 Dec 19, 2025
8843a92
add: implement discretizeR function and improve formatting in Discret…
zachwaffle4 Dec 19, 2025
81a08cc
add: update scalar multiplication to accept Number type in DynamicVec…
zachwaffle4 Dec 19, 2025
b97f458
add: integrate discretization of matrices in LinearModel using discre…
zachwaffle4 Dec 19, 2025
dbc918a
add: implement Kalman filter for linear state estimation
zachwaffle4 Dec 19, 2025
19fdbfd
Merge remote-tracking branch 'origin/main' into develop
zachwaffle4 Dec 20, 2025
0b516aa
add: update version to 0.0.1-alpha.1 in gradle.properties
zachwaffle4 Dec 20, 2025
f517eec
run spotless oopsies
zachwaffle4 Dec 20, 2025
0a93373
add: update group ID to 'dev.nextftc.control' in build.gradle.kts
zachwaffle4 Dec 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .idea/codeStyles/codeStyleConfig.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .idea/copyright/BSD_3_License_header.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions .idea/copyright/profiles_settings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {

allprojects {
version = property("version") as String
group = "dev.nextftc"
group = "dev.nextftc.control"
}

subprojects {
Expand Down
6 changes: 5 additions & 1 deletion control/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ description = "A WPIMath inspired library for controls and other math classes an
dependencies {
api(project(":units"))
api(project(":linalg"))

testImplementation(libs.bundles.kotest)
}

nextFTCPublishing {
Expand All @@ -22,6 +24,8 @@ kotlin {
}
}

tasks.withType<Test>().configureEach { useJUnitPlatform() }

spotless {
kotlin {
ktlint().editorConfigOverride(
Expand All @@ -30,7 +34,7 @@ spotless {
"indent_size" to "4",
"continuation_indent_size" to "4",
"ktlint_standard_no-wildcard-imports" to "disabled",
"max_line_length" to "100",
"max_line_length" to "120",
),
)
}
Expand Down
121 changes: 121 additions & 0 deletions control/src/main/kotlin/dev/nextftc/control/feedback/LQRController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 NextFTC Team
*
* Use of this source code is governed by an BSD-3-clause
* license that can be found in the LICENSE.md file at the root of this repository or at
* https://opensource.org/license/bsd-3-clause.
*/

@file:Suppress("ktlint:standard:property-naming")

package dev.nextftc.control.feedback

import dev.nextftc.control.model.LinearModel
import dev.nextftc.control.util.discretizeAB
import dev.nextftc.control.util.makeBrysonMatrix
import dev.nextftc.control.util.solveDARE
import dev.nextftc.linalg.Matrix
import dev.nextftc.linalg.N1
import dev.nextftc.linalg.Nat
import dev.nextftc.linalg.Vector

/**
* A Linear Quadratic Regulator (LQR) for controlling a system modeled by state-space equations.
*
* LQR is a form of optimal control that finds the best control input to apply to a system
* by minimizing a quadratic cost function. The cost function balances two competing goals:
* 1. **State Error**: How far the system is from its desired target state (penalized by the `Q` matrix).
* 2. **Control Effort**: How much energy or effort is used to control the system (penalized by the `R` matrix).
*
* The controller computes the optimal control input `u` using a simple state-feedback law: `u = -Kx`,
* where `x` is the system's state error and `K` is the optimal gain matrix.
**
* Thank you to Tyler Veness and WPILib!
*
* @param A The state matrix.
* @param B The input matrix.
* @param Q The state cost matrix.
* @param R The control cost matrix.
* @param dt The time step for the discrete-time model (your loop time)
*
* @see <a href="https://en.wikipedia.org/wiki/Linear%E2%80%93quadratic_regulator">LQR on Wikipedia</a>
* @see <a href="https://docs.wpilib.org/en/stable/docs/software/advanced-controls/state-space/state-space-intro.html#the-linear-quadratic-regulator">LQR in WPILib</a>
*/
class LQRController<States : Nat, Inputs : Nat, Outputs : Nat> @JvmOverloads constructor(
A: Matrix<States, States>,
B: Matrix<States, Inputs>,
Q: Matrix<States, States>,
R: Matrix<Inputs, Inputs>,
private val dt: Double = 0.05,
) {
private val K: Matrix<Inputs, States>

init {
require(dt > 0) { "Time step (dt) must be positive" }
val (Ad, Bd) = discretizeAB(A, B, dt)
val (_, K) = computeLQRGain(Ad, Bd, Q, R)
this.K = K
}

/**
* Constructs a controller with the given coefficient matrices.
*
* @param A the state matrix
* @param B the input matrix
* @param Qelems the maximum state error for each state dimension
* @param Relems the maximum control effort for each control input dimension
* @param dt the time step for the discrete-time model (your loop time)
*/
@JvmOverloads constructor(
A: Matrix<States, States>,
B: Matrix<States, Inputs>,
Qelems: Vector<States>,
Relems: Vector<Inputs>,
dt: Double = 0.05,
) : this(A, B, makeBrysonMatrix(Qelems), makeBrysonMatrix(Relems), dt)

/**
* Constructs a controller with the given plant model and cost matrices.
*
* @param plant the plant model
* @param Qelems the maximum state error for each state dimension
* @param Relems the maximum control effort for each control input dimension
* @param dt the time step for the discrete-time model (your loop time)
*/
@JvmOverloads constructor(
plant: LinearModel<States, Inputs, Outputs>,
Qelems: Vector<States>,
Relems: Vector<Inputs>,
dt: Double = 0.05,
) : this(plant.A, plant.B, Qelems, Relems, dt)

/**
* Calculates the optimal control input to correct for the given state error.
*
* @param error The current state error of the system, represented as a Matrix.
* @return The calculated optimal control input as a Matrix.
* */
fun update(error: Matrix<States, N1>): Matrix<Inputs, N1> = -K * error
}

/**
* Computes the optimal gain matrix K using [dev.nextftc.control.util.solveDARE].
*
* @return Pair of DARE solution X and K.
*/
internal fun <States : Nat, Inputs : Nat> computeLQRGain(
Ad: Matrix<States, States>,
Bd: Matrix<States, Inputs>,
Q: Matrix<States, States>,
R: Matrix<Inputs, Inputs>,
maxIter: Int = -1,
epsilon: Double = 1e-6,
): Pair<Matrix<States, States>, Matrix<Inputs, States>> {
val X = solveDARE(Ad, Bd, Q, R, maxIter, epsilon)

val btx = Bd.transpose * X
val btxb = btx * Bd
val K = (R + btxb).inverse * btx * Ad

return X to K
}
153 changes: 153 additions & 0 deletions control/src/main/kotlin/dev/nextftc/control/feedback/PIDController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025 NextFTC Team
*
* Use of this source code is governed by an BSD-3-clause
* license that can be found in the LICENSE.md file at the root of this repository or at
* https://opensource.org/license/bsd-3-clause.
*/

package dev.nextftc.control.feedback

import kotlin.math.sign
import kotlin.time.ComparableTimeMark
import kotlin.time.DurationUnit

/**
* Coefficients for a PID Controller/
*
* @param kP proportional gain, multiplied by the error
* @param kI integral gain, multiplied by the integral of the error over time
* @param kD derivative gain, multiplied by the derivative of the error
*/
data class PIDCoefficients @JvmOverloads constructor(
@JvmField var kP: Double,
@JvmField var kI: Double = 0.0,
@JvmField var kD: Double = 0.0,
)

/**
* Traditional proportional-integral-derivative controller.
*
* @param coefficients the [PIDCoefficients] that contains the PID gains
* @param resetIntegralOnZeroCrossover whether to reset the integral term when the error crosses
*/
class PIDController @JvmOverloads constructor(
val coefficients: PIDCoefficients,
val resetIntegralOnZeroCrossover: Boolean = true,
) {
private var lastError: Double = 0.0
private var errorSum = 0.0
private var lastTimestamp: ComparableTimeMark? = null

/**
* Creates a PIDController with the given coefficients.
*
* @param kP proportional gain, multiplied by the error
* @param kI integral gain, multiplied by the integral of the error over time
* @param kD derivative gain, multiplied by the derivative of the error
*/
@JvmOverloads constructor(
kP: Double,
kI: Double = 0.0,
kD: Double = 0.0,
resetIntegralOnZeroCrossover: Boolean = true,
) : this(
PIDCoefficients(kP, kI, kD),
resetIntegralOnZeroCrossover,
)

/**
* Calculates the PID output
*
* @param timestamp the current time
* @param error the error in the target state; the difference between the desired
* state and the current state
* @param errorDerivative the derivative of the error, or `null` to compute it automatically
* from the change in error over time. This is typically the difference between the desired
* and current velocity when controlling position, or the difference in acceleration when
* controlling velocity.
*
* @return the PID output
*/
fun calculate(timestamp: ComparableTimeMark, error: Double, errorDerivative: Double?): Double {
if (lastTimestamp == null) {
lastError = error
lastTimestamp = timestamp
// On first call with no derivative provided, ignore D term
val derivative = errorDerivative ?: 0.0
return coefficients.kP * error + coefficients.kD * derivative
}

if (resetIntegralOnZeroCrossover && lastError.sign != error.sign) {
errorSum = 0.0
}

val deltaT = (timestamp - lastTimestamp!!).toDouble(DurationUnit.NANOSECONDS)
errorSum += error * deltaT

val derivative = errorDerivative ?: ((error - lastError) / deltaT)

lastError = error
lastTimestamp = timestamp

return coefficients.kP * error + coefficients.kI * errorSum + coefficients.kD *
derivative
}

/**
* Calculates the PID output from a reference (setpoint) and measured value.
*
* This overload assumes the reference derivative is zero (i.e., the setpoint is constant).
*
* @param timestamp the current time
* @param reference the desired/target value (setpoint)
* @param measured the current measured value
* @param measuredDerivative the derivative of the measured value, or `null` to compute the
* error derivative automatically from the change in error over time.
*
* @return the PID output
*/
fun calculate(
timestamp: ComparableTimeMark,
reference: Double,
measured: Double,
measuredDerivative: Double?,
): Double = calculate(
timestamp,
reference - measured,
measuredDerivative?.let { -it },
)

/**
* Calculates the PID output from a reference (setpoint) and measured value, with their
* respective derivatives.
*
* @param timestamp the current time
* @param reference the desired/target value (setpoint)
* @param measured the current measured value
* @param referenceDerivative the derivative of the reference value (e.g., desired velocity)
* @param measuredDerivative the derivative of the measured value (e.g., current velocity)
*
* @return the PID output
*/
fun calculate(
timestamp: ComparableTimeMark,
reference: Double,
measured: Double,
referenceDerivative: Double,
measuredDerivative: Double,
): Double = calculate(
timestamp,
reference - measured,
referenceDerivative - measuredDerivative,
)

/**
* Resets the PID controller
*/
fun reset() {
errorSum = 0.0
lastError = 0.0
lastTimestamp = null
}
}
Loading