Skip to content

Commit 270e6e8

Browse files
committed
Hankel-SVD fitter matching WEC-Sim/BEMIO approach
Implement full A,B,C,D state-space realization from Hankel-SVD
1 parent 63ecd31 commit 270e6e8

4 files changed

Lines changed: 927 additions & 4 deletions

File tree

include/hydroc/radiation/radiation_types.h

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,24 +73,55 @@ struct TaperedDirectOptions {
7373
*
7474
* These options control the fitting process that determines the number of
7575
* exponential modes and their parameters (H_m, α_m) from the RIRF data.
76+
*
77+
* TUNING GUIDE:
78+
* - For faster fitting: reduce max_hankel_size (e.g., 100)
79+
* - For better accuracy: increase max_hankel_size (e.g., 500) and r2_threshold (e.g., 0.99)
80+
* - For faster simulations: increase max_order (more modes = better fit = lower residual)
7681
*/
7782
struct StateSpaceOptions {
7883
/**
79-
* @brief Maximum number of exponential modes to use per DOF pair.
84+
* @brief Maximum number of state-space modes to use per DOF pair.
8085
*
81-
* Higher values improve fit quality but increase computation time.
86+
* Higher values improve fit quality but increase per-step computation.
8287
* The fitter may use fewer modes if a good fit is achieved earlier.
83-
* Typical values: 2-10. Default: 10.
88+
* Typical values: 4-15. Default: 10.
8489
*/
8590
int max_order = 10;
8691

8792
/**
8893
* @brief Minimum R² (coefficient of determination) threshold for fit acceptance.
8994
*
9095
* The fitter adds modes until either R² >= r2_threshold or max_order is reached.
91-
* Values closer to 1.0 require better fits. Default: 0.95.
96+
* Values closer to 1.0 require better fits but may need more modes.
97+
* Typical values: 0.90-0.99. Default: 0.95.
9298
*/
9399
double r2_threshold = 0.95;
100+
101+
/**
102+
* @brief Maximum size of Hankel matrix for SVD decomposition.
103+
*
104+
* CRITICAL PERFORMANCE PARAMETER: SVD is O(n³), so this directly controls
105+
* fitting time. Larger values give better accuracy but slower fitting.
106+
*
107+
* Guidelines:
108+
* - 100: Very fast fitting (~50ms), may reduce accuracy for complex kernels
109+
* - 200: Good balance (default), ~300ms fitting, suitable for most cases
110+
* - 500: High accuracy, ~2-5s fitting, for demanding applications
111+
* - 1000: Maximum accuracy, ~20-60s fitting, rarely needed
112+
*
113+
* If RIRF has fewer samples than this value, all samples are used.
114+
* Default: 200.
115+
*/
116+
int max_hankel_size = 200;
117+
118+
/**
119+
* @brief Number of subsamples used for R² quality check during fitting.
120+
*
121+
* Lower values speed up fitting; higher values give more accurate R² estimates.
122+
* Typical values: 30-100. Default: 50.
123+
*/
124+
int r2_num_samples = 50;
94125
};
95126

96127
} // namespace hydrochrono::hydro
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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

Comments
 (0)