|
| 1 | +/********************************************************************* |
| 2 | + * @file radiation_ss_fitter.cpp |
| 3 | + * @brief Implementation of RadiationStateSpaceFitter. |
| 4 | + * |
| 5 | + * ALGORITHM DETAILS (matches WEC-Sim/BEMIO radiationIRFSS): |
| 6 | + * |
| 7 | + * This implementation uses a Hankel-SVD approach to identify state-space |
| 8 | + * parameters from an impulse response, following the WEC-Sim algorithm. |
| 9 | + * |
| 10 | + * 1. SCALE AND BUILD HANKEL MATRIX |
| 11 | + * y = dt * K |
| 12 | + * H = hankel(y[1:]) (Hankel matrix from scaled samples) |
| 13 | + * |
| 14 | + * 2. SVD AND ORDER SELECTION |
| 15 | + * [U, S, V] = svd(H) |
| 16 | + * Truncate to order O based on R² threshold |
| 17 | + * |
| 18 | + * 3. DISCRETE-TIME REALIZATION (balanced form) |
| 19 | + * u1 = U[0:end-1, 0:O] |
| 20 | + * u2 = U[1:end, 0:O] |
| 21 | + * v1 = V[0:end-1, 0:O] |
| 22 | + * sqs = sqrt(S[0:O]) |
| 23 | + * |
| 24 | + * ubar = u1' * u2 |
| 25 | + * a = ubar .* ((1/sqs) * sqs') (element-wise) |
| 26 | + * b = v1[0,:] .* sqs |
| 27 | + * c = u1[0,:] .* sqs' |
| 28 | + * d = y[0] |
| 29 | + * |
| 30 | + * 4. BILINEAR (TUSTIN) TRANSFORM TO CONTINUOUS |
| 31 | + * iidd = inv((dt/2) * (I + a)) |
| 32 | + * Ac = (a - I) * iidd |
| 33 | + * Bc = dt * iidd * b |
| 34 | + * Cc = c * iidd |
| 35 | + * Dc = d - (dt/2) * c * iidd * b |
| 36 | + * |
| 37 | + * 5. KERNEL RECONSTRUCTION |
| 38 | + * K_fit[k] = Cc * expm(Ac * dt * k) * Bc |
| 39 | + * |
| 40 | + *********************************************************************/ |
| 41 | + |
| 42 | +#include "radiation_ss_fitter.h" |
| 43 | + |
| 44 | +#include <unsupported/Eigen/MatrixFunctions> |
| 45 | +#include <algorithm> |
| 46 | +#include <cmath> |
| 47 | + |
| 48 | +namespace hydrochrono::hydro { |
| 49 | + |
| 50 | +RadiationStateSpaceFitter::RadiationStateSpaceFitter(const StateSpaceOptions& opts) |
| 51 | + : opts_(opts) { |
| 52 | + // Validate options |
| 53 | + if (opts_.max_order <= 0) { |
| 54 | + opts_.max_order = 10; // Sensible default |
| 55 | + } |
| 56 | + if (opts_.r2_threshold <= 0.0 || opts_.r2_threshold > 1.0) { |
| 57 | + opts_.r2_threshold = 0.95; // Sensible default |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +StateSpaceFitResult RadiationStateSpaceFitter::FitKernel( |
| 62 | + const Eigen::VectorXd& K, |
| 63 | + double dt) const { |
| 64 | + |
| 65 | + StateSpaceFitResult result; |
| 66 | + result.order = 0; |
| 67 | + result.r2 = 0.0; |
| 68 | + |
| 69 | + const int N = static_cast<int>(K.size()); |
| 70 | + |
| 71 | + // Need at least 4 samples for meaningful fit |
| 72 | + if (N < 4) { |
| 73 | + return result; |
| 74 | + } |
| 75 | + |
| 76 | + // Check for trivial (zero) kernel |
| 77 | + const double K_norm = K.norm(); |
| 78 | + if (K_norm < 1e-14) { |
| 79 | + return result; |
| 80 | + } |
| 81 | + |
| 82 | + // Validate time step |
| 83 | + if (dt <= 0.0) { |
| 84 | + return result; |
| 85 | + } |
| 86 | + |
| 87 | + // ========================================================================= |
| 88 | + // OPTIMIZATION: Limit Hankel matrix size for faster SVD |
| 89 | + // The Hankel matrix only needs to capture system dynamics, not all samples. |
| 90 | + // User can tune via opts_.max_hankel_size (default 200). |
| 91 | + // ========================================================================= |
| 92 | + const int hankel_size = std::min(N - 1, opts_.max_hankel_size); |
| 93 | + |
| 94 | + // Scale kernel |
| 95 | + Eigen::VectorXd y = dt * K; |
| 96 | + |
| 97 | + // Build reduced-size Hankel matrix |
| 98 | + Eigen::MatrixXd H(hankel_size, hankel_size); |
| 99 | + for (int i = 0; i < hankel_size; ++i) { |
| 100 | + for (int j = 0; j < hankel_size; ++j) { |
| 101 | + int idx = i + j + 1; |
| 102 | + H(i, j) = (idx < N) ? y(idx) : 0.0; |
| 103 | + } |
| 104 | + } |
| 105 | + |
| 106 | + // ========================================================================= |
| 107 | + // OPTIMIZATION: Single SVD, compute only needed singular vectors |
| 108 | + // ========================================================================= |
| 109 | + const int max_possible_order = std::min(opts_.max_order, hankel_size / 2); |
| 110 | + Eigen::JacobiSVD<Eigen::MatrixXd> svd(H, Eigen::ComputeThinU | Eigen::ComputeThinV); |
| 111 | + const Eigen::MatrixXd& U = svd.matrixU(); |
| 112 | + const Eigen::MatrixXd& V = svd.matrixV(); |
| 113 | + const Eigen::VectorXd& svh = svd.singularValues(); |
| 114 | + |
| 115 | + // ========================================================================= |
| 116 | + // Try increasing orders, reusing the same SVD decomposition |
| 117 | + // ========================================================================= |
| 118 | + for (int order = 2; order <= max_possible_order; ++order) { |
| 119 | + StateSpaceFitResult candidate = FitFromSVD(K, dt, order, y, U, V, svh, hankel_size); |
| 120 | + |
| 121 | + if (candidate.IsValid()) { |
| 122 | + result = candidate; |
| 123 | + |
| 124 | + if (result.r2 >= opts_.r2_threshold) { |
| 125 | + break; |
| 126 | + } |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + return result; |
| 131 | +} |
| 132 | + |
| 133 | +// Optimized method - reuses pre-computed SVD |
| 134 | +StateSpaceFitResult RadiationStateSpaceFitter::FitFromSVD( |
| 135 | + const Eigen::VectorXd& K, |
| 136 | + double dt, |
| 137 | + int order, |
| 138 | + const Eigen::VectorXd& y, |
| 139 | + const Eigen::MatrixXd& U, |
| 140 | + const Eigen::MatrixXd& V, |
| 141 | + const Eigen::VectorXd& svh, |
| 142 | + int hankel_size) const { |
| 143 | + |
| 144 | + StateSpaceFitResult result; |
| 145 | + result.order = 0; |
| 146 | + result.r2 = 0.0; |
| 147 | + |
| 148 | + const int N = static_cast<int>(K.size()); |
| 149 | + const int O = order; |
| 150 | + |
| 151 | + if (svh.size() < O || O < 2) { |
| 152 | + return result; |
| 153 | + } |
| 154 | + |
| 155 | + // Check that singular values are significant |
| 156 | + const double sv_threshold = 1e-10 * svh(0); |
| 157 | + if (svh(O - 1) < sv_threshold) { |
| 158 | + return result; |
| 159 | + } |
| 160 | + |
| 161 | + // ───────────────────────────────────────────────────────────────────────── |
| 162 | + // Form discrete-time state-space realization (WEC-Sim style) |
| 163 | + // ───────────────────────────────────────────────────────────────────────── |
| 164 | + const int len_k = hankel_size; |
| 165 | + |
| 166 | + // u1 = U[0:end-1, 0:O], u2 = U[1:end, 0:O] |
| 167 | + Eigen::MatrixXd u1 = U.block(0, 0, len_k - 1, O); |
| 168 | + Eigen::MatrixXd u2 = U.block(1, 0, len_k - 1, O); |
| 169 | + Eigen::MatrixXd v1 = V.block(0, 0, len_k - 1, O); |
| 170 | + |
| 171 | + // sqs = sqrt(svh[0:O]) |
| 172 | + Eigen::VectorXd sqs = svh.head(O).array().sqrt(); |
| 173 | + |
| 174 | + // ubar = u1' * u2 |
| 175 | + Eigen::MatrixXd ubar = u1.transpose() * u2; |
| 176 | + |
| 177 | + // a = ubar .* ((1/sqs) * sqs') |
| 178 | + Eigen::MatrixXd a(O, O); |
| 179 | + for (int i = 0; i < O; ++i) { |
| 180 | + for (int j = 0; j < O; ++j) { |
| 181 | + a(i, j) = ubar(i, j) * sqs(j) / sqs(i); |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + // b = v1[0,:] .* sqs |
| 186 | + Eigen::VectorXd b = v1.row(0).transpose().array() * sqs.array(); |
| 187 | + |
| 188 | + // c = u1[0,:] .* sqs' |
| 189 | + Eigen::RowVectorXd c = u1.row(0).array() * sqs.transpose().array(); |
| 190 | + |
| 191 | + // d = y[0] |
| 192 | + double d = y(0); |
| 193 | + |
| 194 | + // ───────────────────────────────────────────────────────────────────────── |
| 195 | + // Bilinear (Tustin) transform: discrete to continuous |
| 196 | + // ───────────────────────────────────────────────────────────────────────── |
| 197 | + Eigen::MatrixXd I_O = Eigen::MatrixXd::Identity(O, O); |
| 198 | + Eigen::MatrixXd iidd = ((dt / 2.0) * (I_O + a)).inverse(); |
| 199 | + |
| 200 | + Eigen::MatrixXd Ac = (a - I_O) * iidd; |
| 201 | + Eigen::VectorXd Bc = dt * (iidd * b); |
| 202 | + Eigen::RowVectorXd Cc = c * iidd; |
| 203 | + double Dc = d - (dt / 2.0) * (c * iidd * b)(0, 0); |
| 204 | + |
| 205 | + result.A = Ac; |
| 206 | + result.B = Bc; |
| 207 | + result.C = Cc; |
| 208 | + result.D = Dc; |
| 209 | + result.order = O; |
| 210 | + |
| 211 | + // ───────────────────────────────────────────────────────────────────────── |
| 212 | + // OPTIMIZATION: Subsample for R² computation |
| 213 | + // User can tune via StateSpaceOptions::r2_num_samples (default 50) |
| 214 | + // ───────────────────────────────────────────────────────────────────────── |
| 215 | + const int r2_samples = std::min(N, opts_.r2_num_samples); |
| 216 | + const int step = std::max(1, N / r2_samples); |
| 217 | + |
| 218 | + Eigen::VectorXd K_sub(r2_samples); |
| 219 | + Eigen::VectorXd K_fit_sub(r2_samples); |
| 220 | + |
| 221 | + for (int i = 0; i < r2_samples; ++i) { |
| 222 | + int idx = i * step; |
| 223 | + if (idx >= N) idx = N - 1; |
| 224 | + K_sub(i) = K(idx); |
| 225 | + |
| 226 | + double t = idx * dt; |
| 227 | + Eigen::MatrixXd expAt = (Ac * t).exp(); |
| 228 | + K_fit_sub(i) = Cc * expAt * Bc; |
| 229 | + } |
| 230 | + |
| 231 | + result.r2 = ComputeR2(K_sub, K_fit_sub); |
| 232 | + |
| 233 | + return result; |
| 234 | +} |
| 235 | + |
| 236 | +Eigen::VectorXd RadiationStateSpaceFitter::ReconstructKernel( |
| 237 | + const StateSpaceFitResult& result, |
| 238 | + double dt, |
| 239 | + int num_samples) { |
| 240 | + |
| 241 | + if (!result.IsValid() || num_samples <= 0) { |
| 242 | + return Eigen::VectorXd::Zero(num_samples); |
| 243 | + } |
| 244 | + |
| 245 | + const int O = result.order; |
| 246 | + Eigen::VectorXd K(num_samples); |
| 247 | + |
| 248 | + // K[k] = C * expm(A * dt * k) * B |
| 249 | + // Note: For k=0, expm(0) = I, so K[0] = C * B |
| 250 | + for (int k = 0; k < num_samples; ++k) { |
| 251 | + double t = k * dt; |
| 252 | + Eigen::MatrixXd expAt = (result.A * t).exp(); // Matrix exponential |
| 253 | + K(k) = result.C * expAt * result.B; |
| 254 | + } |
| 255 | + |
| 256 | + return K; |
| 257 | +} |
| 258 | + |
| 259 | +double RadiationStateSpaceFitter::ComputeR2( |
| 260 | + const Eigen::VectorXd& K_actual, |
| 261 | + const Eigen::VectorXd& K_fit) { |
| 262 | + |
| 263 | + if (K_actual.size() != K_fit.size() || K_actual.size() == 0) { |
| 264 | + return 0.0; |
| 265 | + } |
| 266 | + |
| 267 | + // SS_res = Σ(K_actual - K_fit)² |
| 268 | + const double ss_res = (K_actual - K_fit).squaredNorm(); |
| 269 | + |
| 270 | + // SS_tot = Σ(K_actual - mean(K_actual))² |
| 271 | + const double mean = K_actual.mean(); |
| 272 | + const double ss_tot = (K_actual.array() - mean).matrix().squaredNorm(); |
| 273 | + |
| 274 | + // R² = 1 - SS_res / SS_tot |
| 275 | + // Handle edge case where SS_tot ≈ 0 (constant kernel) |
| 276 | + if (ss_tot < 1e-20) { |
| 277 | + return (ss_res < 1e-20) ? 1.0 : 0.0; |
| 278 | + } |
| 279 | + |
| 280 | + const double r2 = 1.0 - ss_res / ss_tot; |
| 281 | + |
| 282 | + // Clamp to [0, 1] for numerical robustness |
| 283 | + return std::max(0.0, std::min(1.0, r2)); |
| 284 | +} |
| 285 | + |
| 286 | +} // namespace hydrochrono::hydro |
0 commit comments