Skip to main content

oxirs_physics/
vibration_analysis.rs

1//! # Vibration Analysis
2//!
3//! Structural vibration and modal analysis for mass-spring-damper systems.
4//!
5//! Implements:
6//! - Natural frequency calculation: f = (1/2π) * sqrt(k/m)
7//! - Damping ratio: ζ = c / (2 * sqrt(k*m))
8//! - Damped natural frequency: f_d = f_n * sqrt(1 - ζ²)
9//! - Free vibration response: x(t) = A * exp(-ζ * ω_n * t) * cos(ω_d * t + φ)
10//! - Harmonic forcing response and resonance detection
11//! - Frequency response functions (FRF)
12//! - Logarithmic decrement and critical damping
13
14use std::f64::consts::PI;
15
16/// A mass-spring-damper system
17#[derive(Debug, Clone, PartialEq)]
18pub struct MassSpringSystem {
19    /// Mass in kilograms
20    pub mass_kg: f64,
21    /// Stiffness in N/m
22    pub stiffness_nm: f64,
23    /// Damping coefficient in N·s/m
24    pub damping_ns_m: f64,
25}
26
27impl MassSpringSystem {
28    /// Create a new undamped mass-spring system
29    pub fn new(mass_kg: f64, stiffness_nm: f64) -> Self {
30        Self {
31            mass_kg,
32            stiffness_nm,
33            damping_ns_m: 0.0,
34        }
35    }
36
37    /// Add viscous damping to the system
38    pub fn with_damping(self, damping_ns_m: f64) -> Self {
39        Self {
40            damping_ns_m,
41            ..self
42        }
43    }
44
45    /// Returns true if the system is underdamped (ζ < 1)
46    pub fn is_underdamped(&self) -> bool {
47        VibrationAnalyzer::damping_ratio(self) < 1.0
48    }
49
50    /// Returns true if the system is overdamped (ζ > 1)
51    pub fn is_overdamped(&self) -> bool {
52        VibrationAnalyzer::damping_ratio(self) > 1.0
53    }
54
55    /// Returns true if the system is critically damped (ζ ≈ 1, within 1e-9)
56    pub fn is_critically_damped(&self) -> bool {
57        (VibrationAnalyzer::damping_ratio(self) - 1.0).abs() < 1e-9
58    }
59}
60
61/// Modal shape and frequency of a vibration mode
62#[derive(Debug, Clone)]
63pub struct VibrationMode {
64    /// Natural frequency in Hz
65    pub frequency_hz: f64,
66    /// Damping ratio (dimensionless)
67    pub damping_ratio: f64,
68    /// Mode shape vector (relative displacements)
69    pub mode_shape: Vec<f64>,
70}
71
72/// Frequency response function data
73#[derive(Debug, Clone)]
74pub struct FrequencyResponse {
75    /// Excitation frequencies in Hz
76    pub frequencies_hz: Vec<f64>,
77    /// Response amplitude (displacement / static displacement)
78    pub amplitudes: Vec<f64>,
79    /// Phase angle in radians
80    pub phase_rad: Vec<f64>,
81}
82
83impl FrequencyResponse {
84    /// Number of frequency points
85    pub fn len(&self) -> usize {
86        self.frequencies_hz.len()
87    }
88
89    /// Returns true if the frequency response has no data points
90    pub fn is_empty(&self) -> bool {
91        self.frequencies_hz.is_empty()
92    }
93}
94
95/// Structural vibration and modal analysis engine
96pub struct VibrationAnalyzer;
97
98impl VibrationAnalyzer {
99    /// Compute the undamped natural frequency in Hz.
100    ///
101    /// f_n = (1 / 2π) * sqrt(k / m)
102    pub fn natural_frequency(system: &MassSpringSystem) -> f64 {
103        (1.0 / (2.0 * PI)) * (system.stiffness_nm / system.mass_kg).sqrt()
104    }
105
106    /// Compute the viscous damping ratio ζ.
107    ///
108    /// ζ = c / (2 * sqrt(k * m))
109    pub fn damping_ratio(system: &MassSpringSystem) -> f64 {
110        let critical = Self::critical_damping(system);
111        if critical == 0.0 {
112            return 0.0;
113        }
114        system.damping_ns_m / critical
115    }
116
117    /// Compute the damped natural frequency in Hz.
118    ///
119    /// f_d = f_n * sqrt(1 - ζ²)   (only meaningful for underdamped systems)
120    pub fn damped_frequency(system: &MassSpringSystem) -> f64 {
121        let zeta = Self::damping_ratio(system);
122        let fn_hz = Self::natural_frequency(system);
123        if zeta >= 1.0 {
124            return 0.0; // overdamped / critically damped: no oscillation
125        }
126        fn_hz * (1.0 - zeta * zeta).sqrt()
127    }
128
129    /// Compute the free vibration response at time t.
130    ///
131    /// x(t) = A · exp(−ζ · ω_n · t) · cos(ω_d · t)
132    ///
133    /// Phase is taken as zero (initial velocity = 0, initial displacement = amplitude).
134    pub fn free_vibration(system: &MassSpringSystem, amplitude: f64, t: f64) -> f64 {
135        let zeta = Self::damping_ratio(system);
136        let omega_n = 2.0 * PI * Self::natural_frequency(system);
137        let omega_d = 2.0 * PI * Self::damped_frequency(system);
138
139        if zeta >= 1.0 {
140            // Overdamped or critically damped — no oscillation, pure exponential decay
141            amplitude * (-zeta * omega_n * t).exp()
142        } else {
143            amplitude * (-zeta * omega_n * t).exp() * (omega_d * t).cos()
144        }
145    }
146
147    /// Compute the steady-state amplitude under harmonic forcing F(t) = F0 * cos(ω t).
148    ///
149    /// X = (F0 / k) / sqrt((1 − r²)² + (2ζr)²)
150    /// where r = ω / ω_n (frequency ratio)
151    pub fn harmonic_response_amplitude(
152        system: &MassSpringSystem,
153        force_n: f64,
154        forcing_freq_hz: f64,
155    ) -> f64 {
156        let omega_n_hz = Self::natural_frequency(system);
157        let zeta = Self::damping_ratio(system);
158        let r = forcing_freq_hz / omega_n_hz;
159
160        let static_deflection = force_n / system.stiffness_nm;
161        let denom = ((1.0 - r * r) * (1.0 - r * r) + (2.0 * zeta * r) * (2.0 * zeta * r)).sqrt();
162
163        if denom == 0.0 {
164            return f64::INFINITY;
165        }
166        static_deflection / denom
167    }
168
169    /// Compute the steady-state phase angle (in radians) of the response relative to force.
170    ///
171    /// φ = atan2(2ζr, 1 − r²)   (phase lag)
172    pub fn harmonic_response_phase(system: &MassSpringSystem, forcing_freq_hz: f64) -> f64 {
173        let omega_n_hz = Self::natural_frequency(system);
174        let zeta = Self::damping_ratio(system);
175        let r = forcing_freq_hz / omega_n_hz;
176        (2.0 * zeta * r).atan2(1.0 - r * r)
177    }
178
179    /// Check whether the forcing frequency is in resonance with the natural frequency.
180    ///
181    /// Resonance when |f_forcing − f_n| / f_n < tolerance
182    pub fn is_resonant(system: &MassSpringSystem, forcing_freq_hz: f64, tolerance: f64) -> bool {
183        let fn_hz = Self::natural_frequency(system);
184        if fn_hz == 0.0 {
185            return false;
186        }
187        ((forcing_freq_hz - fn_hz) / fn_hz).abs() < tolerance
188    }
189
190    /// Compute the logarithmic decrement δ.
191    ///
192    /// δ = 2π · ζ / sqrt(1 − ζ²)
193    pub fn log_decrement(damping_ratio: f64) -> f64 {
194        if damping_ratio >= 1.0 {
195            return f64::INFINITY;
196        }
197        2.0 * PI * damping_ratio / (1.0 - damping_ratio * damping_ratio).sqrt()
198    }
199
200    /// Compute the critical damping coefficient c_crit.
201    ///
202    /// c_crit = 2 * sqrt(k * m)
203    pub fn critical_damping(system: &MassSpringSystem) -> f64 {
204        2.0 * (system.stiffness_nm * system.mass_kg).sqrt()
205    }
206
207    /// Compute the frequency response function over a specified frequency range.
208    ///
209    /// Returns amplitude and phase at `n_points` logarithmically spaced frequencies.
210    pub fn frequency_response(
211        system: &MassSpringSystem,
212        force_n: f64,
213        freq_min: f64,
214        freq_max: f64,
215        n_points: usize,
216    ) -> FrequencyResponse {
217        if n_points == 0 {
218            return FrequencyResponse {
219                frequencies_hz: vec![],
220                amplitudes: vec![],
221                phase_rad: vec![],
222            };
223        }
224
225        let mut frequencies_hz = Vec::with_capacity(n_points);
226        let mut amplitudes = Vec::with_capacity(n_points);
227        let mut phase_rad = Vec::with_capacity(n_points);
228
229        let log_min = freq_min.ln();
230        let log_max = freq_max.ln();
231
232        for i in 0..n_points {
233            let freq = if n_points == 1 {
234                freq_min
235            } else {
236                (log_min + (log_max - log_min) * i as f64 / (n_points - 1) as f64).exp()
237            };
238
239            let amp = Self::harmonic_response_amplitude(system, force_n, freq);
240            let phase = Self::harmonic_response_phase(system, freq);
241
242            frequencies_hz.push(freq);
243            amplitudes.push(amp);
244            phase_rad.push(phase);
245        }
246
247        FrequencyResponse {
248            frequencies_hz,
249            amplitudes,
250            phase_rad,
251        }
252    }
253
254    /// Estimate damping ratio from logarithmic decrement.
255    ///
256    /// ζ = δ / sqrt(4π² + δ²)
257    pub fn damping_ratio_from_log_decrement(log_dec: f64) -> f64 {
258        log_dec / (4.0 * PI * PI + log_dec * log_dec).sqrt()
259    }
260
261    /// Compute the dynamic magnification factor (DMF) at a given frequency ratio r = ω/ω_n.
262    pub fn dynamic_magnification_factor(r: f64, zeta: f64) -> f64 {
263        let denom = ((1.0 - r * r) * (1.0 - r * r) + (2.0 * zeta * r) * (2.0 * zeta * r)).sqrt();
264        if denom == 0.0 {
265            return f64::INFINITY;
266        }
267        1.0 / denom
268    }
269
270    /// Compute peak frequency ratio at resonance for damped system.
271    ///
272    /// r_peak = sqrt(1 − 2ζ²)   (valid for ζ < 1/sqrt(2))
273    pub fn peak_frequency_ratio(zeta: f64) -> Option<f64> {
274        let arg = 1.0 - 2.0 * zeta * zeta;
275        if arg <= 0.0 {
276            None
277        } else {
278            Some(arg.sqrt())
279        }
280    }
281
282    /// Compute half-power bandwidth in Hz.
283    ///
284    /// Δf ≈ 2ζ * f_n
285    pub fn half_power_bandwidth(system: &MassSpringSystem) -> f64 {
286        let zeta = Self::damping_ratio(system);
287        let fn_hz = Self::natural_frequency(system);
288        2.0 * zeta * fn_hz
289    }
290
291    /// Compute the quality factor Q = 1/(2ζ).
292    pub fn quality_factor(zeta: f64) -> f64 {
293        if zeta == 0.0 {
294            return f64::INFINITY;
295        }
296        1.0 / (2.0 * zeta)
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    // ─── helpers ─────────────────────────────────────────────────────────────
305
306    fn assert_approx(actual: f64, expected: f64, tol: f64, msg: &str) {
307        let err = (actual - expected).abs();
308        assert!(
309            err < tol,
310            "{msg}: got {actual}, expected {expected}, err {err}"
311        );
312    }
313
314    /// Simple 1 kg / 100 N/m undamped system  →  f_n = (1/2π)*sqrt(100) ≈ 1.5915 Hz
315    fn simple_system() -> MassSpringSystem {
316        MassSpringSystem::new(1.0, 100.0)
317    }
318
319    /// System with 20% damping ratio: c = 2*ζ*sqrt(k*m) = 2*0.2*sqrt(100*1) = 4
320    fn damped_system() -> MassSpringSystem {
321        MassSpringSystem::new(1.0, 100.0).with_damping(4.0)
322    }
323
324    // ─── natural frequency ────────────────────────────────────────────────────
325
326    #[test]
327    fn test_natural_frequency_formula() {
328        let sys = simple_system();
329        let expected = (1.0 / (2.0 * PI)) * (100.0_f64).sqrt();
330        assert_approx(
331            VibrationAnalyzer::natural_frequency(&sys),
332            expected,
333            1e-10,
334            "natural frequency",
335        );
336    }
337
338    #[test]
339    fn test_natural_frequency_unit_mass_unit_stiffness() {
340        let sys = MassSpringSystem::new(1.0, 1.0);
341        let expected = 1.0 / (2.0 * PI);
342        assert_approx(
343            VibrationAnalyzer::natural_frequency(&sys),
344            expected,
345            1e-10,
346            "f_n for k=1, m=1",
347        );
348    }
349
350    #[test]
351    fn test_natural_frequency_scales_with_stiffness() {
352        let sys1 = MassSpringSystem::new(1.0, 100.0);
353        let sys2 = MassSpringSystem::new(1.0, 400.0);
354        // f scales as sqrt(k) → f2 = 2 * f1
355        let ratio = VibrationAnalyzer::natural_frequency(&sys2)
356            / VibrationAnalyzer::natural_frequency(&sys1);
357        assert_approx(ratio, 2.0, 1e-10, "frequency ratio sqrt(4)=2");
358    }
359
360    #[test]
361    fn test_natural_frequency_scales_inverse_with_mass() {
362        let sys1 = MassSpringSystem::new(1.0, 100.0);
363        let sys2 = MassSpringSystem::new(4.0, 100.0);
364        let ratio = VibrationAnalyzer::natural_frequency(&sys1)
365            / VibrationAnalyzer::natural_frequency(&sys2);
366        assert_approx(ratio, 2.0, 1e-10, "frequency ratio 1/sqrt(m)");
367    }
368
369    // ─── damping ratio ────────────────────────────────────────────────────────
370
371    #[test]
372    fn test_damping_ratio_undamped() {
373        let sys = simple_system();
374        assert_approx(
375            VibrationAnalyzer::damping_ratio(&sys),
376            0.0,
377            1e-15,
378            "undamped",
379        );
380    }
381
382    #[test]
383    fn test_damping_ratio_twenty_percent() {
384        let sys = damped_system(); // c = 4 = 0.2 * 2 * sqrt(100)
385        assert_approx(
386            VibrationAnalyzer::damping_ratio(&sys),
387            0.2,
388            1e-10,
389            "20% damping",
390        );
391    }
392
393    #[test]
394    fn test_damping_ratio_critical() {
395        // c_crit = 2*sqrt(k*m) = 2*sqrt(100*1) = 20
396        let sys = MassSpringSystem::new(1.0, 100.0).with_damping(20.0);
397        assert_approx(
398            VibrationAnalyzer::damping_ratio(&sys),
399            1.0,
400            1e-10,
401            "critical damping ratio",
402        );
403    }
404
405    #[test]
406    fn test_damping_ratio_formula_manual() {
407        let sys = MassSpringSystem::new(2.0, 200.0).with_damping(5.0);
408        // c_crit = 2*sqrt(200*2) = 2*20 = 40
409        // zeta = 5/40 = 0.125
410        assert_approx(
411            VibrationAnalyzer::damping_ratio(&sys),
412            0.125,
413            1e-10,
414            "manual formula check",
415        );
416    }
417
418    // ─── damped frequency ─────────────────────────────────────────────────────
419
420    #[test]
421    fn test_damped_frequency_less_than_natural() {
422        let sys = damped_system();
423        let fn_hz = VibrationAnalyzer::natural_frequency(&sys);
424        let fd_hz = VibrationAnalyzer::damped_frequency(&sys);
425        assert!(
426            fd_hz < fn_hz,
427            "damped freq {fd_hz} must be < natural {fn_hz}"
428        );
429    }
430
431    #[test]
432    fn test_damped_frequency_undamped_equals_natural() {
433        let sys = simple_system();
434        let fn_hz = VibrationAnalyzer::natural_frequency(&sys);
435        let fd_hz = VibrationAnalyzer::damped_frequency(&sys);
436        assert_approx(fd_hz, fn_hz, 1e-10, "undamped: f_d == f_n");
437    }
438
439    #[test]
440    fn test_damped_frequency_formula() {
441        let sys = damped_system(); // zeta = 0.2
442        let fn_hz = VibrationAnalyzer::natural_frequency(&sys);
443        let expected = fn_hz * (1.0 - 0.04_f64).sqrt();
444        assert_approx(
445            VibrationAnalyzer::damped_frequency(&sys),
446            expected,
447            1e-10,
448            "f_d formula",
449        );
450    }
451
452    #[test]
453    fn test_damped_frequency_overdamped_is_zero() {
454        let sys = MassSpringSystem::new(1.0, 100.0).with_damping(30.0); // zeta > 1
455        assert_approx(
456            VibrationAnalyzer::damped_frequency(&sys),
457            0.0,
458            1e-15,
459            "overdamped f_d = 0",
460        );
461    }
462
463    // ─── free vibration ───────────────────────────────────────────────────────
464
465    #[test]
466    fn test_free_vibration_at_t0_equals_amplitude() {
467        let sys = damped_system();
468        let amp = 0.05; // 5 cm
469        let x0 = VibrationAnalyzer::free_vibration(&sys, amp, 0.0);
470        assert_approx(x0, amp, 1e-10, "x(0) = amplitude");
471    }
472
473    #[test]
474    fn test_free_vibration_decays() {
475        let sys = damped_system();
476        let amp = 1.0;
477        let x0 = VibrationAnalyzer::free_vibration(&sys, amp, 0.0).abs();
478        let x1 = VibrationAnalyzer::free_vibration(&sys, amp, 0.5).abs();
479        let x2 = VibrationAnalyzer::free_vibration(&sys, amp, 1.0).abs();
480        // Envelope should decay over longer time spans
481        // x0 = 1.0, x2 is at most exp(-ζ ω_n t) ≈ exp(-0.2*10*1) = 0.135
482        assert!(x2 < x0, "vibration should decay: x(1) {x2} < x(0) {x0}");
483        let _ = x1; // used for compilation
484    }
485
486    #[test]
487    fn test_free_vibration_undamped_at_t0() {
488        let sys = simple_system();
489        let x0 = VibrationAnalyzer::free_vibration(&sys, 2.0, 0.0);
490        assert_approx(x0, 2.0, 1e-10, "undamped x(0)=2");
491    }
492
493    #[test]
494    fn test_free_vibration_negative_amplitude() {
495        let sys = damped_system();
496        let x0 = VibrationAnalyzer::free_vibration(&sys, -1.0, 0.0);
497        assert_approx(x0, -1.0, 1e-10, "negative amplitude at t=0");
498    }
499
500    // ─── harmonic response ────────────────────────────────────────────────────
501
502    #[test]
503    fn test_harmonic_response_static_limit() {
504        // At very low frequency (r→0), amplitude → F0/k (static deflection)
505        let sys = damped_system();
506        let force = 10.0;
507        let static_def = force / sys.stiffness_nm;
508        let amp = VibrationAnalyzer::harmonic_response_amplitude(&sys, force, 0.001);
509        assert_approx(amp, static_def, 1e-4, "static limit");
510    }
511
512    #[test]
513    fn test_harmonic_response_at_resonance_large() {
514        // At resonance (r=1), amplitude = F0/(k * 2ζ), which is large for small ζ
515        let sys = damped_system(); // zeta = 0.2
516        let force = 10.0;
517        let fn_hz = VibrationAnalyzer::natural_frequency(&sys);
518        let amp_at_resonance = VibrationAnalyzer::harmonic_response_amplitude(&sys, force, fn_hz);
519        let static_def = force / sys.stiffness_nm;
520        // DMF at resonance = 1/(2ζ) = 1/0.4 = 2.5
521        let expected = static_def * 2.5;
522        assert_approx(amp_at_resonance, expected, 1e-10, "resonance amplitude");
523    }
524
525    #[test]
526    fn test_harmonic_response_amplitude_positive() {
527        let sys = damped_system();
528        let amp = VibrationAnalyzer::harmonic_response_amplitude(&sys, 5.0, 2.0);
529        assert!(amp > 0.0, "amplitude must be positive");
530    }
531
532    #[test]
533    fn test_harmonic_response_high_frequency_decreases() {
534        // At very high frequency r >> 1, amplitude → 0
535        let sys = damped_system();
536        let amp_low = VibrationAnalyzer::harmonic_response_amplitude(&sys, 1.0, 0.01);
537        let amp_high = VibrationAnalyzer::harmonic_response_amplitude(&sys, 1.0, 1000.0);
538        assert!(
539            amp_high < amp_low,
540            "high-freq amplitude {amp_high} < low-freq {amp_low}"
541        );
542    }
543
544    // ─── is_resonant ─────────────────────────────────────────────────────────
545
546    #[test]
547    fn test_is_resonant_true_at_natural_freq() {
548        let sys = simple_system();
549        let fn_hz = VibrationAnalyzer::natural_frequency(&sys);
550        assert!(VibrationAnalyzer::is_resonant(&sys, fn_hz, 0.01));
551    }
552
553    #[test]
554    fn test_is_resonant_false_far_from_natural() {
555        let sys = simple_system();
556        let fn_hz = VibrationAnalyzer::natural_frequency(&sys);
557        assert!(!VibrationAnalyzer::is_resonant(&sys, fn_hz * 2.0, 0.01));
558    }
559
560    #[test]
561    fn test_is_resonant_within_tolerance() {
562        let sys = simple_system();
563        let fn_hz = VibrationAnalyzer::natural_frequency(&sys);
564        // 5% off natural frequency
565        let forcing = fn_hz * 1.04;
566        assert!(VibrationAnalyzer::is_resonant(&sys, forcing, 0.05));
567    }
568
569    #[test]
570    fn test_is_resonant_outside_tolerance() {
571        let sys = simple_system();
572        let fn_hz = VibrationAnalyzer::natural_frequency(&sys);
573        let forcing = fn_hz * 1.2; // 20% off
574        assert!(!VibrationAnalyzer::is_resonant(&sys, forcing, 0.05));
575    }
576
577    // ─── log decrement ────────────────────────────────────────────────────────
578
579    #[test]
580    fn test_log_decrement_undamped_is_zero() {
581        assert_approx(VibrationAnalyzer::log_decrement(0.0), 0.0, 1e-15, "δ(0)=0");
582    }
583
584    #[test]
585    fn test_log_decrement_formula() {
586        let zeta = 0.1;
587        let expected = 2.0 * PI * zeta / (1.0 - zeta * zeta).sqrt();
588        assert_approx(
589            VibrationAnalyzer::log_decrement(zeta),
590            expected,
591            1e-10,
592            "log decrement formula",
593        );
594    }
595
596    #[test]
597    fn test_log_decrement_increases_with_damping() {
598        let d1 = VibrationAnalyzer::log_decrement(0.1);
599        let d2 = VibrationAnalyzer::log_decrement(0.3);
600        assert!(d2 > d1, "higher damping → larger log decrement");
601    }
602
603    #[test]
604    fn test_log_decrement_round_trip() {
605        let zeta = 0.15;
606        let delta = VibrationAnalyzer::log_decrement(zeta);
607        let zeta_back = VibrationAnalyzer::damping_ratio_from_log_decrement(delta);
608        assert_approx(zeta_back, zeta, 1e-10, "round trip zeta→δ→zeta");
609    }
610
611    // ─── critical damping ─────────────────────────────────────────────────────
612
613    #[test]
614    fn test_critical_damping_formula() {
615        let sys = simple_system(); // k=100, m=1
616        let expected = 2.0 * (100.0_f64).sqrt(); // = 20
617        assert_approx(
618            VibrationAnalyzer::critical_damping(&sys),
619            expected,
620            1e-10,
621            "c_crit formula",
622        );
623    }
624
625    #[test]
626    fn test_critical_damping_equals_20_for_simple_system() {
627        let sys = simple_system();
628        assert_approx(
629            VibrationAnalyzer::critical_damping(&sys),
630            20.0,
631            1e-10,
632            "c_crit = 20",
633        );
634    }
635
636    #[test]
637    fn test_critical_damping_scales_with_stiffness() {
638        let sys1 = MassSpringSystem::new(1.0, 100.0);
639        let sys2 = MassSpringSystem::new(1.0, 400.0);
640        let ratio =
641            VibrationAnalyzer::critical_damping(&sys2) / VibrationAnalyzer::critical_damping(&sys1);
642        assert_approx(ratio, 2.0, 1e-10, "c_crit scales sqrt(k)");
643    }
644
645    // ─── frequency response ───────────────────────────────────────────────────
646
647    #[test]
648    fn test_frequency_response_n_points_length() {
649        let sys = damped_system();
650        let frf = VibrationAnalyzer::frequency_response(&sys, 1.0, 0.1, 100.0, 50);
651        assert_eq!(frf.len(), 50, "FRF must have 50 points");
652        assert_eq!(frf.frequencies_hz.len(), 50);
653        assert_eq!(frf.amplitudes.len(), 50);
654        assert_eq!(frf.phase_rad.len(), 50);
655    }
656
657    #[test]
658    fn test_frequency_response_zero_points() {
659        let sys = damped_system();
660        let frf = VibrationAnalyzer::frequency_response(&sys, 1.0, 0.1, 100.0, 0);
661        assert!(frf.is_empty());
662    }
663
664    #[test]
665    fn test_frequency_response_single_point() {
666        let sys = damped_system();
667        let frf = VibrationAnalyzer::frequency_response(&sys, 1.0, 1.0, 1.0, 1);
668        assert_eq!(frf.len(), 1);
669        assert_approx(frf.frequencies_hz[0], 1.0, 1e-10, "single point freq");
670    }
671
672    #[test]
673    fn test_frequency_response_amplitudes_positive() {
674        let sys = damped_system();
675        let frf = VibrationAnalyzer::frequency_response(&sys, 10.0, 0.1, 10.0, 20);
676        for amp in &frf.amplitudes {
677            assert!(*amp > 0.0, "amplitude must be positive");
678        }
679    }
680
681    #[test]
682    fn test_frequency_response_covers_range() {
683        let sys = damped_system();
684        let frf = VibrationAnalyzer::frequency_response(&sys, 1.0, 1.0, 100.0, 10);
685        assert_approx(frf.frequencies_hz[0], 1.0, 1e-6, "first frequency");
686        assert_approx(frf.frequencies_hz[9], 100.0, 1e-4, "last frequency");
687    }
688
689    // ─── system classification ────────────────────────────────────────────────
690
691    #[test]
692    fn test_is_underdamped() {
693        let sys = damped_system(); // zeta = 0.2
694        assert!(sys.is_underdamped());
695        assert!(!sys.is_overdamped());
696        assert!(!sys.is_critically_damped());
697    }
698
699    #[test]
700    fn test_is_overdamped() {
701        let sys = MassSpringSystem::new(1.0, 100.0).with_damping(30.0); // zeta > 1
702        assert!(sys.is_overdamped());
703        assert!(!sys.is_underdamped());
704        assert!(!sys.is_critically_damped());
705    }
706
707    #[test]
708    fn test_is_critically_damped() {
709        let sys = MassSpringSystem::new(1.0, 100.0).with_damping(20.0); // zeta = 1 exactly
710        assert!(sys.is_critically_damped());
711        assert!(!sys.is_underdamped());
712        assert!(!sys.is_overdamped());
713    }
714
715    #[test]
716    fn test_undamped_is_underdamped() {
717        let sys = simple_system(); // zeta = 0
718        assert!(sys.is_underdamped());
719    }
720
721    // ─── additional helper methods ────────────────────────────────────────────
722
723    #[test]
724    fn test_dynamic_magnification_factor_at_resonance() {
725        // DMF at r=1 = 1/(2ζ)
726        let zeta = 0.25;
727        let dmf = VibrationAnalyzer::dynamic_magnification_factor(1.0, zeta);
728        assert_approx(dmf, 1.0 / (2.0 * zeta), 1e-10, "DMF at r=1");
729    }
730
731    #[test]
732    fn test_quality_factor_inverse_damping() {
733        let zeta = 0.1;
734        let q = VibrationAnalyzer::quality_factor(zeta);
735        assert_approx(q, 5.0, 1e-10, "Q = 1/(2*0.1) = 5");
736    }
737
738    #[test]
739    fn test_half_power_bandwidth() {
740        let sys = damped_system(); // zeta=0.2, fn ≈ 1.5915 Hz
741        let bw = VibrationAnalyzer::half_power_bandwidth(&sys);
742        let expected = 2.0 * 0.2 * VibrationAnalyzer::natural_frequency(&sys);
743        assert_approx(bw, expected, 1e-10, "half-power bandwidth");
744    }
745
746    #[test]
747    fn test_peak_frequency_ratio_low_damping() {
748        let zeta = 0.1;
749        let r_peak = VibrationAnalyzer::peak_frequency_ratio(zeta)
750            .expect("should have peak for low damping");
751        assert_approx(r_peak, (1.0 - 2.0 * 0.01_f64).sqrt(), 1e-10, "r_peak");
752    }
753
754    #[test]
755    fn test_peak_frequency_ratio_high_damping_none() {
756        // For zeta >= 1/sqrt(2) ≈ 0.707, no peak
757        let result = VibrationAnalyzer::peak_frequency_ratio(0.8);
758        assert!(result.is_none(), "no peak for overdamped");
759    }
760
761    #[test]
762    fn test_mass_spring_system_builder() {
763        let sys = MassSpringSystem::new(5.0, 200.0).with_damping(12.0);
764        assert_eq!(sys.mass_kg, 5.0);
765        assert_eq!(sys.stiffness_nm, 200.0);
766        assert_eq!(sys.damping_ns_m, 12.0);
767    }
768
769    #[test]
770    fn test_frequency_response_is_not_empty() {
771        let sys = damped_system();
772        let frf = VibrationAnalyzer::frequency_response(&sys, 1.0, 0.5, 10.0, 100);
773        assert!(!frf.is_empty());
774    }
775
776    #[test]
777    fn test_harmonic_response_phase_at_low_freq() {
778        // At very low forcing, r→0, phase → atan2(0,1) = 0
779        let sys = damped_system();
780        let phase = VibrationAnalyzer::harmonic_response_phase(&sys, 0.001);
781        assert!(phase.abs() < 0.01, "phase near zero at low frequency");
782    }
783
784    #[test]
785    fn test_damping_ratio_from_log_decrement_roundtrip_2() {
786        let zeta_in = 0.05;
787        let delta = VibrationAnalyzer::log_decrement(zeta_in);
788        let zeta_out = VibrationAnalyzer::damping_ratio_from_log_decrement(delta);
789        assert_approx(zeta_out, zeta_in, 1e-10, "log-dec round trip 2");
790    }
791}