2023-10-01 15:09:09 -07:00
|
|
|
|
// Copyright (c) FIRST and other WPILib contributors.
|
|
|
|
|
|
// Open Source Software; you can modify and/or share it under the terms of
|
|
|
|
|
|
// the WPILib BSD license file in the root directory of this project.
|
|
|
|
|
|
|
|
|
|
|
|
#include "sysid/analysis/FeedforwardAnalysis.h"
|
|
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
#include <array>
|
|
|
|
|
|
#include <bitset>
|
2023-10-01 15:09:09 -07:00
|
|
|
|
#include <cmath>
|
|
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
#include <Eigen/Eigenvalues>
|
|
|
|
|
|
#include <fmt/format.h>
|
|
|
|
|
|
#include <fmt/ranges.h>
|
2023-10-01 15:09:09 -07:00
|
|
|
|
#include <units/math.h>
|
|
|
|
|
|
#include <units/time.h>
|
|
|
|
|
|
|
|
|
|
|
|
#include "sysid/analysis/AnalysisManager.h"
|
|
|
|
|
|
#include "sysid/analysis/FilteringUtils.h"
|
|
|
|
|
|
#include "sysid/analysis/OLS.h"
|
|
|
|
|
|
|
2023-12-26 20:06:10 -08:00
|
|
|
|
namespace sysid {
|
2023-10-01 15:09:09 -07:00
|
|
|
|
|
|
|
|
|
|
/**
|
2023-12-29 21:31:27 -08:00
|
|
|
|
* Populates OLS data for the following models:
|
|
|
|
|
|
*
|
|
|
|
|
|
* Simple, Drivetrain, DrivetrainAngular:
|
|
|
|
|
|
*
|
|
|
|
|
|
* (xₖ₊₁ − xₖ)/τ = αxₖ + βuₖ + γ sgn(xₖ)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Elevator:
|
|
|
|
|
|
*
|
|
|
|
|
|
* (xₖ₊₁ − xₖ)/τ = αxₖ + βuₖ + γ sgn(xₖ) + δ
|
|
|
|
|
|
*
|
|
|
|
|
|
* Arm:
|
|
|
|
|
|
*
|
|
|
|
|
|
* (xₖ₊₁ − xₖ)/τ = αxₖ + βuₖ + γ sgn(xₖ) + δ cos(angle) + ε sin(angle)
|
|
|
|
|
|
*
|
|
|
|
|
|
* OLS performs best with the noisiest variable as the dependent variable, so we
|
|
|
|
|
|
* regress acceleration in terms of the other variables.
|
2023-10-01 15:09:09 -07:00
|
|
|
|
*
|
|
|
|
|
|
* @param d List of characterization data.
|
|
|
|
|
|
* @param type Type of system being identified.
|
|
|
|
|
|
* @param X Vector representation of X in y = Xβ.
|
|
|
|
|
|
* @param y Vector representation of y in y = Xβ.
|
|
|
|
|
|
*/
|
|
|
|
|
|
static void PopulateOLSData(const std::vector<PreparedData>& d,
|
|
|
|
|
|
const AnalysisType& type,
|
|
|
|
|
|
Eigen::Block<Eigen::MatrixXd> X,
|
|
|
|
|
|
Eigen::VectorBlock<Eigen::VectorXd> y) {
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// Fill in X and y row-wise
|
2023-10-01 15:09:09 -07:00
|
|
|
|
for (size_t sample = 0; sample < d.size(); ++sample) {
|
|
|
|
|
|
const auto& pt = d[sample];
|
|
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// Set the velocity term (for α)
|
2023-10-01 15:09:09 -07:00
|
|
|
|
X(sample, 0) = pt.velocity;
|
|
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// Set the voltage term (for β)
|
2023-10-01 15:09:09 -07:00
|
|
|
|
X(sample, 1) = pt.voltage;
|
|
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// Set the intercept term (for γ)
|
2023-10-01 15:09:09 -07:00
|
|
|
|
X(sample, 2) = std::copysign(1, pt.velocity);
|
|
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// Set test-specific variables
|
2023-10-01 15:09:09 -07:00
|
|
|
|
if (type == analysis::kElevator) {
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// Set the gravity term (for δ)
|
2023-10-01 15:09:09 -07:00
|
|
|
|
X(sample, 3) = 1.0;
|
|
|
|
|
|
} else if (type == analysis::kArm) {
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// Set the cosine and sine terms (for δ and ε)
|
2023-10-01 15:09:09 -07:00
|
|
|
|
X(sample, 3) = pt.cos;
|
|
|
|
|
|
X(sample, 4) = pt.sin;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// Set the dependent variable (acceleration)
|
2023-10-01 15:09:09 -07:00
|
|
|
|
y(sample) = pt.acceleration;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Throws an InsufficientSamplesError if the collected data is poor for OLS.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param X The collected data in matrix form for OLS.
|
|
|
|
|
|
* @param type The analysis type.
|
|
|
|
|
|
*/
|
|
|
|
|
|
static void CheckOLSDataQuality(const Eigen::MatrixXd& X,
|
|
|
|
|
|
const AnalysisType& type) {
|
|
|
|
|
|
Eigen::SelfAdjointEigenSolver<Eigen::MatrixXd> eigSolver{X.transpose() * X};
|
|
|
|
|
|
const Eigen::VectorXd& eigvals = eigSolver.eigenvalues();
|
|
|
|
|
|
const Eigen::MatrixXd& eigvecs = eigSolver.eigenvectors();
|
|
|
|
|
|
|
|
|
|
|
|
// Bits are Ks, Kv, Ka, Kg, offset
|
|
|
|
|
|
std::bitset<5> badGains;
|
|
|
|
|
|
|
|
|
|
|
|
constexpr double threshold = 10.0;
|
|
|
|
|
|
|
2024-01-01 21:22:34 -08:00
|
|
|
|
// For n x n matrix XᵀX, need n nonzero eigenvalues for good fit
|
2023-12-29 21:31:27 -08:00
|
|
|
|
for (int row = 0; row < eigvals.rows(); ++row) {
|
2024-01-01 21:22:34 -08:00
|
|
|
|
// Find row of eigenvector with largest magnitude. This determines the
|
|
|
|
|
|
// primary regression variable that corresponds to the eigenvalue.
|
|
|
|
|
|
int maxIndex;
|
|
|
|
|
|
double maxCoeff = eigvecs.col(row).cwiseAbs().maxCoeff(&maxIndex);
|
|
|
|
|
|
|
|
|
|
|
|
// Check whether the eigenvector component along the regression variable's
|
|
|
|
|
|
// direction is below the threshold. If it is, the regression variable's fit
|
|
|
|
|
|
// is bad.
|
|
|
|
|
|
if (std::abs(eigvals(row) * maxCoeff) <= threshold) {
|
|
|
|
|
|
// Fit for α is bad
|
2023-12-29 21:31:27 -08:00
|
|
|
|
if (maxIndex == 0) {
|
2024-01-01 21:22:34 -08:00
|
|
|
|
// Affects Kv
|
2023-12-29 21:31:27 -08:00
|
|
|
|
badGains.set(1);
|
|
|
|
|
|
}
|
2024-01-01 21:22:34 -08:00
|
|
|
|
|
|
|
|
|
|
// Fit for β is bad
|
2023-12-29 21:31:27 -08:00
|
|
|
|
if (maxIndex == 1) {
|
2024-01-01 21:22:34 -08:00
|
|
|
|
// Affects all gains
|
2023-12-29 21:31:27 -08:00
|
|
|
|
badGains.set();
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2024-01-01 21:22:34 -08:00
|
|
|
|
|
|
|
|
|
|
// Fit for γ is bad
|
2023-12-29 21:31:27 -08:00
|
|
|
|
if (maxIndex == 2) {
|
2024-01-01 21:22:34 -08:00
|
|
|
|
// Affects Ks
|
2023-12-29 21:31:27 -08:00
|
|
|
|
badGains.set(0);
|
|
|
|
|
|
}
|
2024-01-01 21:22:34 -08:00
|
|
|
|
|
|
|
|
|
|
// Fit for δ is bad
|
2023-12-29 21:31:27 -08:00
|
|
|
|
if (maxIndex == 3) {
|
|
|
|
|
|
if (type == analysis::kElevator) {
|
2024-01-01 21:22:34 -08:00
|
|
|
|
// Affects Kg
|
2023-12-29 21:31:27 -08:00
|
|
|
|
badGains.set(3);
|
|
|
|
|
|
} else if (type == analysis::kArm) {
|
2024-01-01 21:22:34 -08:00
|
|
|
|
// Affects Kg and offset
|
2023-12-29 21:31:27 -08:00
|
|
|
|
badGains.set(3);
|
|
|
|
|
|
badGains.set(4);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-01-01 21:22:34 -08:00
|
|
|
|
|
|
|
|
|
|
// Fit for ε is bad
|
2023-12-29 21:31:27 -08:00
|
|
|
|
if (maxIndex == 4) {
|
2024-01-01 21:22:34 -08:00
|
|
|
|
// Affects Kg and offset
|
2023-12-29 21:31:27 -08:00
|
|
|
|
badGains.set(3);
|
|
|
|
|
|
badGains.set(4);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If any gains are bad, throw an error
|
|
|
|
|
|
if (badGains.any()) {
|
|
|
|
|
|
// Create list of bad gain names
|
|
|
|
|
|
constexpr std::array gainNames{"Ks", "Kv", "Ka", "Kg", "offset"};
|
|
|
|
|
|
std::vector<std::string_view> badGainsList;
|
|
|
|
|
|
for (size_t i = 0; i < badGains.size(); ++i) {
|
|
|
|
|
|
if (badGains.test(i)) {
|
|
|
|
|
|
badGainsList.emplace_back(gainNames[i]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string error = fmt::format("Insufficient samples to compute {}.\n\n",
|
|
|
|
|
|
fmt::join(badGainsList, ", "));
|
|
|
|
|
|
|
|
|
|
|
|
// If all gains are bad, the robot may not have moved
|
|
|
|
|
|
if (badGains.all()) {
|
|
|
|
|
|
error += "Either no data was collected or the robot didn't move.\n\n";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Append guidance for fixing the data
|
|
|
|
|
|
error +=
|
|
|
|
|
|
"Ensure the data has:\n\n"
|
|
|
|
|
|
" * at least 2 steady-state velocity events to separate Ks from Kv\n"
|
|
|
|
|
|
" * at least 1 acceleration event to find Ka\n"
|
|
|
|
|
|
" * for elevators, enough vertical motion to measure gravity\n"
|
|
|
|
|
|
" * for arms, enough range of motion to measure gravity and encoder "
|
|
|
|
|
|
"offset\n";
|
|
|
|
|
|
throw InsufficientSamplesError{error};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-12-26 20:06:10 -08:00
|
|
|
|
OLSResult CalculateFeedforwardGains(const Storage& data,
|
2023-12-29 21:31:27 -08:00
|
|
|
|
const AnalysisType& type,
|
|
|
|
|
|
bool throwOnBadData) {
|
2023-10-01 15:09:09 -07:00
|
|
|
|
// Iterate through the data and add it to our raw vector.
|
|
|
|
|
|
const auto& [slowForward, slowBackward, fastForward, fastBackward] = data;
|
|
|
|
|
|
|
|
|
|
|
|
const auto size = slowForward.size() + slowBackward.size() +
|
|
|
|
|
|
fastForward.size() + fastBackward.size();
|
|
|
|
|
|
|
|
|
|
|
|
// Create a raw vector of doubles with our data in it.
|
|
|
|
|
|
Eigen::MatrixXd X{size, type.independentVariables};
|
|
|
|
|
|
Eigen::VectorXd y{size};
|
|
|
|
|
|
|
|
|
|
|
|
int rowOffset = 0;
|
|
|
|
|
|
PopulateOLSData(slowForward, type,
|
|
|
|
|
|
X.block(rowOffset, 0, slowForward.size(), X.cols()),
|
|
|
|
|
|
y.segment(rowOffset, slowForward.size()));
|
|
|
|
|
|
|
|
|
|
|
|
rowOffset += slowForward.size();
|
|
|
|
|
|
PopulateOLSData(slowBackward, type,
|
|
|
|
|
|
X.block(rowOffset, 0, slowBackward.size(), X.cols()),
|
|
|
|
|
|
y.segment(rowOffset, slowBackward.size()));
|
|
|
|
|
|
|
|
|
|
|
|
rowOffset += slowBackward.size();
|
|
|
|
|
|
PopulateOLSData(fastForward, type,
|
|
|
|
|
|
X.block(rowOffset, 0, fastForward.size(), X.cols()),
|
|
|
|
|
|
y.segment(rowOffset, fastForward.size()));
|
|
|
|
|
|
|
|
|
|
|
|
rowOffset += fastForward.size();
|
|
|
|
|
|
PopulateOLSData(fastBackward, type,
|
|
|
|
|
|
X.block(rowOffset, 0, fastBackward.size(), X.cols()),
|
|
|
|
|
|
y.segment(rowOffset, fastBackward.size()));
|
|
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// Check quality of collected data
|
|
|
|
|
|
if (throwOnBadData) {
|
|
|
|
|
|
CheckOLSDataQuality(X, type);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::vector<double> gains;
|
|
|
|
|
|
gains.reserve(X.rows());
|
|
|
|
|
|
|
2023-12-26 20:06:10 -08:00
|
|
|
|
auto ols = OLS(X, y);
|
2023-10-01 15:09:09 -07:00
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// Calculate feedforward gains
|
|
|
|
|
|
//
|
|
|
|
|
|
// See docs/ols-derivations.md for more details.
|
|
|
|
|
|
{
|
|
|
|
|
|
// dx/dt = -Kv/Ka x + 1/Ka u - Ks/Ka sgn(x)
|
|
|
|
|
|
// dx/dt = αx + βu + γ sgn(x)
|
2023-10-01 15:09:09 -07:00
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// α = -Kv/Ka
|
|
|
|
|
|
// β = 1/Ka
|
|
|
|
|
|
// γ = -Ks/Ka
|
|
|
|
|
|
double α = ols.coeffs[0];
|
|
|
|
|
|
double β = ols.coeffs[1];
|
|
|
|
|
|
double γ = ols.coeffs[2];
|
2023-10-01 15:09:09 -07:00
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// Ks = -γ/β
|
|
|
|
|
|
// Kv = -α/β
|
|
|
|
|
|
// Ka = 1/β
|
|
|
|
|
|
gains.emplace_back(-γ / β);
|
|
|
|
|
|
gains.emplace_back(-α / β);
|
|
|
|
|
|
gains.emplace_back(1 / β);
|
2023-10-01 15:09:09 -07:00
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
if (type == analysis::kElevator) {
|
|
|
|
|
|
// dx/dt = -Kv/Ka x + 1/Ka u - Ks/Ka sgn(x) - Kg/Ka
|
|
|
|
|
|
// dx/dt = αx + βu + γ sgn(x) + δ
|
2023-10-01 15:09:09 -07:00
|
|
|
|
|
2023-12-29 21:31:27 -08:00
|
|
|
|
// δ = -Kg/Ka
|
|
|
|
|
|
double δ = ols.coeffs[3];
|
|
|
|
|
|
|
|
|
|
|
|
// Kg = -δ/β
|
|
|
|
|
|
gains.emplace_back(-δ / β);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (type == analysis::kArm) {
|
|
|
|
|
|
// dx/dt = -Kv/Ka x + 1/Ka u - Ks/Ka sgn(x)
|
|
|
|
|
|
// - Kg/Ka cos(offset) cos(angle) NOLINT
|
|
|
|
|
|
// + Kg/Ka sin(offset) sin(angle) NOLINT
|
|
|
|
|
|
// dx/dt = αx + βu + γ sgn(x) + δ cos(angle) + ε sin(angle) NOLINT
|
|
|
|
|
|
|
|
|
|
|
|
// δ = -Kg/Ka cos(offset)
|
|
|
|
|
|
// ε = Kg/Ka sin(offset)
|
|
|
|
|
|
double δ = ols.coeffs[3];
|
|
|
|
|
|
double ε = ols.coeffs[4];
|
|
|
|
|
|
|
|
|
|
|
|
// Kg = hypot(δ, ε)/β NOLINT
|
|
|
|
|
|
// offset = atan2(ε, -δ) NOLINT
|
|
|
|
|
|
gains.emplace_back(std::hypot(δ, ε) / β);
|
|
|
|
|
|
gains.emplace_back(std::atan2(ε, -δ));
|
|
|
|
|
|
}
|
2023-10-01 15:09:09 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Gains are Ks, Kv, Ka, Kg (elevator/arm only), offset (arm only)
|
2023-12-26 20:06:10 -08:00
|
|
|
|
return OLSResult{gains, ols.rSquared, ols.rmse};
|
2023-10-01 15:09:09 -07:00
|
|
|
|
}
|
2023-12-26 20:06:10 -08:00
|
|
|
|
|
|
|
|
|
|
} // namespace sysid
|