diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..a55e7a1
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copyright/BSD_3_License_header.xml b/.idea/copyright/BSD_3_License_header.xml
index 6862309..550881b 100644
--- a/.idea/copyright/BSD_3_License_header.xml
+++ b/.idea/copyright/BSD_3_License_header.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml
index eb08c84..aee8f3f 100644
--- a/.idea/copyright/profiles_settings.xml
+++ b/.idea/copyright/profiles_settings.xml
@@ -1,7 +1,7 @@
-
+
-
+
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index dc1f6d2..1313446 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,7 +8,7 @@ plugins {
allprojects {
version = property("version") as String
- group = "dev.nextftc"
+ group = "dev.nextftc.control"
}
subprojects {
diff --git a/control/build.gradle.kts b/control/build.gradle.kts
index 189b634..df235e4 100644
--- a/control/build.gradle.kts
+++ b/control/build.gradle.kts
@@ -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 {
@@ -22,6 +24,8 @@ kotlin {
}
}
+tasks.withType().configureEach { useJUnitPlatform() }
+
spotless {
kotlin {
ktlint().editorConfigOverride(
@@ -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",
),
)
}
diff --git a/control/src/main/kotlin/dev/nextftc/control/feedback/LQRController.kt b/control/src/main/kotlin/dev/nextftc/control/feedback/LQRController.kt
new file mode 100644
index 0000000..8d0154a
--- /dev/null
+++ b/control/src/main/kotlin/dev/nextftc/control/feedback/LQRController.kt
@@ -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 LQR on Wikipedia
+ * @see LQR in WPILib
+ */
+class LQRController @JvmOverloads constructor(
+ A: Matrix,
+ B: Matrix,
+ Q: Matrix,
+ R: Matrix,
+ private val dt: Double = 0.05,
+) {
+ private val K: Matrix
+
+ 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,
+ B: Matrix,
+ Qelems: Vector,
+ Relems: Vector,
+ 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,
+ Qelems: Vector,
+ Relems: Vector,
+ 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): Matrix = -K * error
+}
+
+/**
+ * Computes the optimal gain matrix K using [dev.nextftc.control.util.solveDARE].
+ *
+ * @return Pair of DARE solution X and K.
+ */
+internal fun computeLQRGain(
+ Ad: Matrix,
+ Bd: Matrix,
+ Q: Matrix,
+ R: Matrix,
+ maxIter: Int = -1,
+ epsilon: Double = 1e-6,
+): Pair, Matrix> {
+ 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
+}
diff --git a/control/src/main/kotlin/dev/nextftc/control/feedback/PIDController.kt b/control/src/main/kotlin/dev/nextftc/control/feedback/PIDController.kt
new file mode 100644
index 0000000..c476678
--- /dev/null
+++ b/control/src/main/kotlin/dev/nextftc/control/feedback/PIDController.kt
@@ -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
+ }
+}
diff --git a/control/src/main/kotlin/dev/nextftc/control/feedback/SquIDController.kt b/control/src/main/kotlin/dev/nextftc/control/feedback/SquIDController.kt
new file mode 100644
index 0000000..4ff4d5a
--- /dev/null
+++ b/control/src/main/kotlin/dev/nextftc/control/feedback/SquIDController.kt
@@ -0,0 +1,146 @@
+/*
+ * 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.abs
+import kotlin.math.sign
+import kotlin.math.sqrt
+import kotlin.time.ComparableTimeMark
+import kotlin.time.DurationUnit
+
+/**
+ * Square-root P + ID (aka SquID) controller.
+ *
+ * @param coefficients the [PIDCoefficients] that contains the PID gains
+ * @param resetIntegralOnZeroCrossover whether to reset the integral term when the error crosses zero
+ */
+class SquIDController @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 SquIDController with the given coefficients.
+ *
+ * @param kP proportional gain, multiplied by the square root of 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
+ * @param resetIntegralOnZeroCrossover whether to reset the integral term when the error crosses zero
+ */
+ @JvmOverloads constructor(
+ kP: Double,
+ kI: Double = 0.0,
+ kD: Double = 0.0,
+ resetIntegralOnZeroCrossover: Boolean = true,
+ ) : this(
+ PIDCoefficients(kP, kI, kD),
+ resetIntegralOnZeroCrossover,
+ )
+
+ /**
+ * Calculates the SquID 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 SquID 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 * sqrt(abs(error)) * error.sign +
+ 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 * sqrt(abs(error)) * error.sign +
+ coefficients.kI * errorSum +
+ coefficients.kD * derivative
+ }
+
+ /**
+ * Calculates the SquID 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 SquID output
+ */
+ fun calculate(
+ timestamp: ComparableTimeMark,
+ reference: Double,
+ measured: Double,
+ measuredDerivative: Double?,
+ ): Double = calculate(
+ timestamp,
+ reference - measured,
+ measuredDerivative?.let { -it },
+ )
+
+ /**
+ * Calculates the SquID 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 SquID output
+ */
+ fun calculate(
+ timestamp: ComparableTimeMark,
+ reference: Double,
+ measured: Double,
+ referenceDerivative: Double,
+ measuredDerivative: Double,
+ ): Double = calculate(
+ timestamp,
+ reference - measured,
+ referenceDerivative - measuredDerivative,
+ )
+
+ /**
+ * Resets the SquID controller
+ */
+ fun reset() {
+ errorSum = 0.0
+ lastError = 0.0
+ lastTimestamp = null
+ }
+}
diff --git a/control/src/main/kotlin/dev/nextftc/control/feedforward/GravityFeedforward.kt b/control/src/main/kotlin/dev/nextftc/control/feedforward/GravityFeedforward.kt
new file mode 100644
index 0000000..d7ed6f8
--- /dev/null
+++ b/control/src/main/kotlin/dev/nextftc/control/feedforward/GravityFeedforward.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.feedforward
+
+import dev.nextftc.control.model.MotionState
+import dev.nextftc.units.unittypes.Inches
+import dev.nextftc.units.unittypes.InchesPerSecond
+import dev.nextftc.units.unittypes.InchesPerSecondSquared
+import kotlin.math.cos
+import kotlin.math.sign
+
+/**
+ * Parameters for [ElevatorFeedforward] and [ArmFeedforward]
+ *
+ * @param kG gravity value, added to overcome gravity
+ * @param kS static gain, used to overcome static friction (multiplied by the sign of velocity)
+ * @param kV velocity gain, multiplied by the target velocity
+ * @param kA acceleration gain, multiplied by the target acceleration
+ */
+data class GravityFeedforwardParameters @JvmOverloads constructor(
+ @JvmField var kG: Double = 0.0,
+ @JvmField var kS: Double = 0.0,
+ @JvmField var kV: Double = 0.0,
+ @JvmField var kA: Double = 0.0,
+)
+
+/**
+ * Feedforward controller for elevator mechanisms.
+ *
+ * This feedforward applies a constant gravity compensation term plus velocity and acceleration
+ * feedforward terms. It is suitable for linear mechanisms like elevators where gravity exerts
+ * a constant force regardless of position.
+ *
+ * @param coefficients the [GravityFeedforwardParameters] containing the feedforward gains
+ */
+class ElevatorFeedforward(val coefficients: GravityFeedforwardParameters) {
+ /**
+ * Calculates the feedforward output for the given velocity and acceleration.
+ *
+ * @param velocity the target velocity
+ * @param acceleration the target acceleration
+ * @return the feedforward output: `kG + kS * sign(velocity) + kV * velocity + kA * acceleration`
+ */
+ fun calculate(velocity: Double, acceleration: Double): Double = coefficients.kG +
+ coefficients.kS * velocity.sign +
+ coefficients.kV * velocity +
+ coefficients.kA * acceleration
+
+ /**
+ * Calculates the feedforward output from a [MotionState].
+ *
+ * @param state the target motion state containing velocity and acceleration
+ * @return the feedforward output
+ */
+ fun calculate(state: MotionState) = calculate(
+ state.velocity.into(InchesPerSecond),
+ state.acceleration.into(InchesPerSecondSquared),
+ )
+}
+
+/**
+ * Feedforward controller for arm mechanisms.
+ *
+ * This feedforward applies a gravity compensation term that varies with arm angle (using cosine),
+ * plus velocity and acceleration feedforward terms. It is suitable for rotational mechanisms
+ * like arms where the gravity torque depends on the arm's angular position.
+ *
+ * @param coefficients the [GravityFeedforwardParameters] containing the feedforward gains
+ */
+class ArmFeedforward(val coefficients: GravityFeedforwardParameters) {
+ /**
+ * Calculates the feedforward output for the given position, velocity, and acceleration.
+ *
+ * @param position the arm position in radians (0 = horizontal, π/2 = vertical up)
+ * @param velocity the target angular velocity
+ * @param acceleration the target angular acceleration
+ * @return the feedforward output: `kG * cos(position) + kS * sign(velocity) + kV * velocity + kA * acceleration`
+ */
+ fun calculate(position: Double, velocity: Double, acceleration: Double) = coefficients.kG * cos(position) +
+ coefficients.kS * velocity.sign +
+ coefficients.kV * velocity +
+ coefficients.kA * acceleration
+
+ /**
+ * Calculates the feedforward output from a [MotionState].
+ *
+ * @param state the target motion state containing position, velocity, and acceleration
+ * @return the feedforward output
+ */
+ fun calculate(state: MotionState) = calculate(
+ state.position.into(Inches),
+ state.velocity.into(InchesPerSecond),
+ state.acceleration.into(InchesPerSecondSquared),
+ )
+}
diff --git a/control/src/main/kotlin/dev/nextftc/control/feedforward/SimpleFeedforward.kt b/control/src/main/kotlin/dev/nextftc/control/feedforward/SimpleFeedforward.kt
new file mode 100644
index 0000000..2592ffd
--- /dev/null
+++ b/control/src/main/kotlin/dev/nextftc/control/feedforward/SimpleFeedforward.kt
@@ -0,0 +1,113 @@
+/*
+ * 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.feedforward
+
+import dev.nextftc.control.model.MotionState
+import dev.nextftc.units.unittypes.InchesPerSecond
+import dev.nextftc.units.unittypes.InchesPerSecondSquared
+import kotlin.math.sign
+
+/**
+ * Coefficients for a simple feedforward controller.
+ *
+ * These coefficients model the relationship between desired motion and motor output:
+ * `output = kS * sign(velocity) + kV * velocity + kA * acceleration`
+ *
+ * @property kS Static friction gain. The minimum output needed to overcome static friction
+ * and start moving. Applied in the direction of desired velocity.
+ * @property kV Velocity gain. Multiplied by the desired velocity to produce the output
+ * needed to maintain that velocity against back-EMF and viscous friction.
+ * @property kA Acceleration gain. Multiplied by the desired acceleration to produce the
+ * additional output needed to accelerate the system (overcomes inertia).
+ */
+data class SimpleFFCoefficients @JvmOverloads constructor(
+ @JvmField var kS: Double,
+ @JvmField var kV: Double,
+ @JvmField var kA: Double = 0.0,
+)
+
+/**
+ * A simple feedforward controller for velocity control.
+ *
+ * Feedforward control predicts the motor output needed to achieve a desired motion,
+ * without waiting for error to accumulate (unlike feedback/PID control). This is
+ * typically used in combination with a feedback controller for best results.
+ *
+ * The feedforward equation is:
+ * ```
+ * output = kS * sign(velocity) + kV * velocity + kA * acceleration
+ * ```
+ *
+ * Where:
+ * - `kS` compensates for static friction
+ * - `kV` compensates for back-EMF and viscous friction
+ * - `kA` compensates for inertia during acceleration
+ *
+ * @param coefficients The [SimpleFFCoefficients] containing kS, kV, and kA gains.
+ */
+class SimpleFeedforward(val coefficients: SimpleFFCoefficients) {
+
+ /**
+ * Calculates the feedforward output for a desired velocity and acceleration.
+ *
+ * @param velocity The desired velocity.
+ * @param acceleration The desired acceleration (defaults to 0.0 for constant velocity).
+ * @return The feedforward output value.
+ */
+ @JvmOverloads
+ fun calculate(velocity: Double, acceleration: Double = 0.0): Double =
+ coefficients.kS * velocity.sign + coefficients.kV * velocity +
+ coefficients.kA * acceleration
+
+ fun calculate(state: MotionState) = calculate(
+ state.velocity.into(InchesPerSecond),
+ state.acceleration.into(InchesPerSecondSquared),
+ )
+
+ /**
+ * Calculates the maximum achievable velocity given voltage and acceleration constraints.
+ *
+ * @param maxVoltage The maximum voltage (or output) available.
+ * @param acceleration The current or desired acceleration.
+ * @return The maximum velocity achievable under these constraints.
+ */
+ fun maxAchievableVelocity(maxVoltage: Double, acceleration: Double): Double =
+ (maxVoltage - coefficients.kS - acceleration * coefficients.kA) / coefficients.kV
+
+ /**
+ * Calculates the minimum achievable velocity given voltage and acceleration constraints.
+ *
+ * @param maxVoltage The maximum voltage (or output) available.
+ * @param acceleration The current or desired acceleration.
+ * @return The minimum (most negative) velocity achievable under these constraints.
+ */
+ fun minAchievableVelocity(maxVoltage: Double, acceleration: Double): Double =
+ (-maxVoltage + coefficients.kS - acceleration * coefficients.kA) / coefficients.kV
+
+ /**
+ * Calculates the maximum achievable acceleration given voltage and velocity constraints.
+ *
+ * @param maxVoltage The maximum voltage (or output) available.
+ * @param velocity The current or desired velocity.
+ * @return The maximum acceleration achievable under these constraints.
+ */
+ fun maxAchievableAcceleration(maxVoltage: Double, velocity: Double): Double =
+ (maxVoltage - coefficients.kS * velocity.sign - velocity * coefficients.kV) /
+ coefficients.kA
+
+ /**
+ * Calculates the minimum achievable acceleration given voltage and velocity constraints.
+ *
+ * @param maxVoltage The maximum voltage (or output) available.
+ * @param velocity The current or desired velocity.
+ * @return The minimum (most negative) acceleration achievable under these constraints.
+ */
+ fun minAchievableAcceleration(maxVoltage: Double, velocity: Double): Double =
+ maxAchievableAcceleration(-maxVoltage, velocity)
+}
diff --git a/control/src/main/kotlin/dev/nextftc/control/filters/KalmanFilter.kt b/control/src/main/kotlin/dev/nextftc/control/filters/KalmanFilter.kt
new file mode 100644
index 0000000..f639ab7
--- /dev/null
+++ b/control/src/main/kotlin/dev/nextftc/control/filters/KalmanFilter.kt
@@ -0,0 +1,141 @@
+/*
+ * 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.filters
+
+import dev.nextftc.control.model.LinearModel
+import dev.nextftc.control.util.discretizeAQ
+import dev.nextftc.control.util.discretizeR
+import dev.nextftc.control.util.makeCovarianceMatrix
+import dev.nextftc.control.util.solveDARE
+import dev.nextftc.linalg.Matrix
+import dev.nextftc.linalg.Nat
+import dev.nextftc.linalg.Vector
+
+/**
+ * A Kalman filter for linear state estimation.
+ *
+ * The Kalman filter is an optimal state estimator for linear systems with Gaussian noise.
+ * It combines predictions from a system model with noisy measurements to produce an
+ * optimal estimate of the system state.
+ *
+ * The filter operates on a linear system of the form:
+ * ```
+ * x(k+1) = A * x(k) + B * u(k) + w(k) (state equation)
+ * y(k) = C * x(k) + D * u(k) + v(k) (measurement equation)
+ * ```
+ * where:
+ * - `x` is the state vector
+ * - `u` is the input vector
+ * - `y` is the measurement vector
+ * - `w` is process noise with covariance Q
+ * - `v` is measurement noise with covariance R
+ *
+ * @param States The number of states in the system
+ * @param Inputs The number of inputs to the system
+ * @param Outputs The number of outputs (measurements) from the system
+ * @param plant The linear system model describing the plant dynamics
+ * @param stateStdDevs Standard deviations of the state model (process noise)
+ * @param measurementStdDevs Standard deviations of the measurements (measurement noise)
+ * @param dt The discretization timestep in seconds (default 0.05s / 50ms)
+ */
+class KalmanFilter @JvmOverloads constructor(
+ val plant: LinearModel,
+ val stateStdDevs: Vector,
+ val measurementStdDevs: Vector,
+ dt: Double = 0.05,
+) {
+ private val A: Matrix
+ private val Q: Matrix
+ private val R: Matrix
+ private val initialP: Matrix
+
+ private var P: Matrix
+
+ private var Xhat = Vector.zero(plant.A.natRows)
+
+ init {
+ require(dt > 0) { "Time step (dt) must be positive" }
+
+ val Qcont = makeCovarianceMatrix(stateStdDevs)
+ val Rcont = makeCovarianceMatrix(measurementStdDevs)
+
+ val AQ = discretizeAQ(plant.A, Qcont, dt)
+ A = AQ.first
+ Q = AQ.second
+
+ R = discretizeR(Rcont, dt)
+
+ initialP = solveDARE(A.transpose, plant.C.transpose, Q, R)
+ P = initialP
+ }
+
+ /**
+ * Resets the filter to its initial state.
+ *
+ * Sets the state estimate to zero and resets the error covariance matrix
+ * to its initial value computed from the DARE solution.
+ */
+ fun reset() {
+ Xhat = Vector.zero(plant.A.natRows)
+ P = initialP
+ }
+
+ /**
+ * Performs the prediction step of the Kalman filter.
+ *
+ * This propagates the state estimate forward in time using the system model:
+ * ```
+ * x̂(k+1|k) = A * x̂(k|k) + B * u(k)
+ * P(k+1|k) = A * P(k|k) * Aᵀ + Q
+ * ```
+ *
+ * @param inputs The current input vector u(k)
+ * @return The predicted state estimate x̂(k+1|k)
+ */
+ fun predict(inputs: Vector): Vector {
+ Xhat = plant.derivative(Xhat, inputs)
+
+ P = A * P * A.transpose + Q
+
+ return Xhat
+ }
+
+ /**
+ * Performs the correction (update) step of the Kalman filter.
+ *
+ * This incorporates a new measurement to refine the state estimate using
+ * the Joseph form of the covariance update for numerical stability:
+ * ```
+ * S = C * P * Cᵀ + R (innovation covariance)
+ * K = P * Cᵀ * S⁻¹ (Kalman gain)
+ * x̂(k|k) = x̂(k|k-1) + K * (y - D * u) (state update)
+ * P(k|k) = (I - K*C) * P * (I - K*C)ᵀ + K*R*Kᵀ (covariance update)
+ * ```
+ *
+ * @param inputs The current input vector u(k)
+ * @param outputs The measurement vector y(k)
+ * @return The corrected state estimate x̂(k|k)
+ */
+ fun correct(inputs: Vector, outputs: Vector): Vector {
+ val S = plant.C * P * plant.C.transpose + R
+
+ val K = S.solve(plant.C * P).transpose
+
+ Xhat += Vector(K * (outputs - (plant.D * inputs)))
+
+ // (I−Kₖ₊₁C)Pₖ₊₁⁻(I−Kₖ₊₁C)ᵀ + Kₖ₊₁RKₖ₊₁ᵀ
+ P = (Matrix.identity(K.natRows) - K * plant.C) * P *
+ (Matrix.identity(K.natRows) - K * plant.C).transpose +
+ K * R * K.transpose
+
+ return Xhat
+ }
+}
diff --git a/control/src/main/kotlin/dev/nextftc/control/model/Model.kt b/control/src/main/kotlin/dev/nextftc/control/model/Model.kt
new file mode 100644
index 0000000..4922d5b
--- /dev/null
+++ b/control/src/main/kotlin/dev/nextftc/control/model/Model.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.model
+
+import dev.nextftc.control.util.discretizeAB
+import dev.nextftc.linalg.Matrix
+import dev.nextftc.linalg.Nat
+import dev.nextftc.linalg.Vector
+
+interface Model {
+ fun derivative(state: Vector, input: Vector): Vector
+
+ fun output(state: Vector, input: Vector): Vector