Skip to main content

numra_core/
signal.rs

1//! Signal generators for forcing functions.
2//!
3//! This module provides time-dependent signals commonly used as forcing terms
4//! in differential equations (earthquake records, wind loads, control inputs, etc.)
5//!
6//! # Signal Types
7//!
8//! ## Deterministic Signals
9//! - [`Harmonic`] - Sinusoidal signal: A·sin(2πft + φ)
10//! - [`Step`] - Step function with optional smoothing
11//! - [`Ramp`] - Linear ramp between start and end times
12//! - [`Pulse`] - Rectangular pulse with given duration
13//! - [`Chirp`] - Frequency-swept sine wave
14//!
15//! ## Data-Driven Signals
16//! - [`Tabulated`] - Interpolated from time-value pairs
17//! - [`FromFile`] - Loaded from CSV file (requires `std` feature)
18//!
19//! ## Composite Signals
20//! - [`Piecewise`] - Different signals in different time ranges
21//! - [`Sum`] - Sum of two signals
22//! - [`Product`] - Product of two signals
23//!
24//! # Example
25//!
26//! ```rust
27//! use numra_core::signal::{Signal, Harmonic, Step, Sum};
28//!
29//! // Create a forcing function: harmonic + step
30//! let harmonic = Harmonic::new(1.0, 2.0, 0.0);  // A=1, f=2Hz, φ=0
31//! let step = Step::new(0.5, 1.0);                // magnitude=0.5 at t=1
32//! let forcing = Sum::new(harmonic, step);
33//!
34//! // Evaluate at t = 1.5s
35//! let value = forcing.eval(1.5);
36//! ```
37//!
38//! Author: Moussa Leblouba
39//! Date: 2 February 2026
40//! Modified: 2 May 2026
41
42#[cfg(not(feature = "std"))]
43use alloc::{boxed::Box, vec::Vec};
44
45use crate::Scalar;
46
47/// A time-dependent signal for forcing functions.
48///
49/// Signals are evaluated at time `t` to produce a scalar value.
50/// They are used as forcing terms in ODEs/SDEs.
51pub trait Signal<S: Scalar>: Send + Sync {
52    /// Evaluate the signal at time t.
53    fn eval(&self, t: S) -> S;
54
55    /// Evaluate derivative of signal at time t (if available).
56    /// Default implementation uses finite differences.
57    fn eval_derivative(&self, t: S) -> S {
58        let h = S::from_f64(1e-8);
59        (self.eval(t + h) - self.eval(t - h)) / (S::TWO * h)
60    }
61}
62
63// ============================================================================
64// Harmonic Signal: A·sin(2πft + φ)
65// ============================================================================
66
67/// Harmonic (sinusoidal) signal.
68///
69/// Evaluates to: `amplitude * sin(2π * frequency * t + phase)`
70///
71/// # Example
72///
73/// ```rust
74/// use numra_core::signal::{Signal, Harmonic};
75///
76/// let signal: Harmonic<f64> = Harmonic::new(2.0, 1.0, 0.0);  // 2*sin(2πt)
77/// let value = signal.eval(0.25);  // sin(π/2) = 1, so result is 2.0
78/// assert!((value - 2.0).abs() < 1e-10);
79/// ```
80#[derive(Clone, Debug)]
81pub struct Harmonic<S: Scalar> {
82    pub amplitude: S,
83    pub frequency: S,
84    pub phase: S,
85}
86
87impl<S: Scalar> Harmonic<S> {
88    /// Create a new harmonic signal.
89    ///
90    /// # Arguments
91    /// - `amplitude` - Peak amplitude
92    /// - `frequency` - Frequency in Hz
93    /// - `phase` - Phase offset in radians
94    pub fn new(amplitude: S, frequency: S, phase: S) -> Self {
95        Self {
96            amplitude,
97            frequency,
98            phase,
99        }
100    }
101}
102
103impl<S: Scalar> Signal<S> for Harmonic<S> {
104    #[inline]
105    fn eval(&self, t: S) -> S {
106        let omega_t = S::TWO * S::PI * self.frequency * t + self.phase;
107        self.amplitude * omega_t.sin()
108    }
109
110    #[inline]
111    fn eval_derivative(&self, t: S) -> S {
112        let omega = S::TWO * S::PI * self.frequency;
113        let omega_t = omega * t + self.phase;
114        self.amplitude * omega * omega_t.cos()
115    }
116}
117
118// ============================================================================
119// Step Signal
120// ============================================================================
121
122/// Step function signal.
123///
124/// Returns 0 for t < time, and magnitude for t >= time.
125/// Optionally applies smooth transition using tanh.
126///
127/// # Example
128///
129/// ```rust
130/// use numra_core::signal::{Signal, Step};
131///
132/// let step: Step<f64> = Step::new(1.0, 2.0);  // Unit step at t=2
133/// assert!(step.eval(1.0).abs() < 1e-10);   // Before step
134/// assert!((step.eval(3.0) - 1.0).abs() < 1e-10);  // After step
135/// ```
136#[derive(Clone, Debug)]
137pub struct Step<S: Scalar> {
138    pub magnitude: S,
139    pub time: S,
140    /// Smoothing parameter (None = sharp step)
141    pub smoothing: Option<S>,
142}
143
144impl<S: Scalar> Step<S> {
145    /// Create a sharp step function.
146    pub fn new(magnitude: S, time: S) -> Self {
147        Self {
148            magnitude,
149            time,
150            smoothing: None,
151        }
152    }
153
154    /// Create a smooth step function with tanh transition.
155    ///
156    /// The `smoothing` parameter controls the transition width.
157    /// Smaller values = sharper transition.
158    pub fn smooth(magnitude: S, time: S, smoothing: S) -> Self {
159        Self {
160            magnitude,
161            time,
162            smoothing: Some(smoothing),
163        }
164    }
165}
166
167impl<S: Scalar> Signal<S> for Step<S> {
168    fn eval(&self, t: S) -> S {
169        match self.smoothing {
170            None => {
171                if t >= self.time {
172                    self.magnitude
173                } else {
174                    S::ZERO
175                }
176            }
177            Some(k) => {
178                // Smooth transition using tanh
179                let x = (t - self.time) / k;
180                self.magnitude * S::HALF * (S::ONE + x.tanh())
181            }
182        }
183    }
184}
185
186// ============================================================================
187// Ramp Signal
188// ============================================================================
189
190/// Linear ramp signal.
191///
192/// Returns 0 before start, ramps linearly from start to end,
193/// then returns final value after end.
194///
195/// # Example
196///
197/// ```rust
198/// use numra_core::signal::{Signal, Ramp};
199///
200/// let ramp: Ramp<f64> = Ramp::new(0.0, 2.0, 1.0, 3.0);  // from 0 to 2 between t=1 and t=3
201/// assert!(ramp.eval(0.5).abs() < 1e-10);      // Before ramp
202/// assert!((ramp.eval(2.0) - 1.0).abs() < 1e-10);  // Midpoint
203/// assert!((ramp.eval(4.0) - 2.0).abs() < 1e-10);  // After ramp
204/// ```
205#[derive(Clone, Debug)]
206pub struct Ramp<S: Scalar> {
207    pub start_value: S,
208    pub end_value: S,
209    pub start_time: S,
210    pub end_time: S,
211}
212
213impl<S: Scalar> Ramp<S> {
214    /// Create a new ramp signal.
215    pub fn new(start_value: S, end_value: S, start_time: S, end_time: S) -> Self {
216        Self {
217            start_value,
218            end_value,
219            start_time,
220            end_time,
221        }
222    }
223
224    /// Create a ramp from zero with given rate.
225    pub fn from_rate(rate: S, start_time: S, end_time: S) -> Self {
226        let duration = end_time - start_time;
227        Self {
228            start_value: S::ZERO,
229            end_value: rate * duration,
230            start_time,
231            end_time,
232        }
233    }
234}
235
236impl<S: Scalar> Signal<S> for Ramp<S> {
237    fn eval(&self, t: S) -> S {
238        if t <= self.start_time {
239            self.start_value
240        } else if t >= self.end_time {
241            self.end_value
242        } else {
243            let alpha = (t - self.start_time) / (self.end_time - self.start_time);
244            self.start_value + alpha * (self.end_value - self.start_value)
245        }
246    }
247
248    fn eval_derivative(&self, t: S) -> S {
249        if t > self.start_time && t < self.end_time {
250            (self.end_value - self.start_value) / (self.end_time - self.start_time)
251        } else {
252            S::ZERO
253        }
254    }
255}
256
257// ============================================================================
258// Pulse Signal
259// ============================================================================
260
261/// Rectangular pulse signal.
262///
263/// Returns magnitude during [start, start + duration], zero otherwise.
264///
265/// # Example
266///
267/// ```rust
268/// use numra_core::signal::{Signal, Pulse};
269///
270/// let pulse: Pulse<f64> = Pulse::new(5.0, 1.0, 2.0);  // magnitude=5, starts at t=1, duration=2
271/// assert!(pulse.eval(0.5).abs() < 1e-10);     // Before pulse
272/// assert!((pulse.eval(2.0) - 5.0).abs() < 1e-10);  // During pulse
273/// assert!(pulse.eval(4.0).abs() < 1e-10);     // After pulse
274/// ```
275#[derive(Clone, Debug)]
276pub struct Pulse<S: Scalar> {
277    pub magnitude: S,
278    pub start: S,
279    pub duration: S,
280}
281
282impl<S: Scalar> Pulse<S> {
283    /// Create a new pulse signal.
284    pub fn new(magnitude: S, start: S, duration: S) -> Self {
285        Self {
286            magnitude,
287            start,
288            duration,
289        }
290    }
291}
292
293impl<S: Scalar> Signal<S> for Pulse<S> {
294    fn eval(&self, t: S) -> S {
295        if t >= self.start && t <= self.start + self.duration {
296            self.magnitude
297        } else {
298            S::ZERO
299        }
300    }
301}
302
303// ============================================================================
304// Chirp Signal (Frequency Sweep)
305// ============================================================================
306
307/// Chirp (frequency-swept sinusoid) signal.
308///
309/// Linear frequency sweep from f0 to f1 over duration t1.
310/// Useful for structural dynamics testing.
311///
312/// # Example
313///
314/// ```rust
315/// use numra_core::signal::{Signal, Chirp};
316///
317/// let chirp: Chirp<f64> = Chirp::new(1.0, 1.0, 10.0, 5.0);  // A=1, f: 1→10 Hz over 5s
318/// let value: f64 = chirp.eval(2.5);  // Somewhere in the middle
319/// assert!(value.abs() <= 1.0);  // Bounded by amplitude
320/// ```
321#[derive(Clone, Debug)]
322pub struct Chirp<S: Scalar> {
323    pub amplitude: S,
324    pub f0: S, // Start frequency (Hz)
325    pub f1: S, // End frequency (Hz)
326    pub t1: S, // Duration of sweep
327}
328
329impl<S: Scalar> Chirp<S> {
330    /// Create a new linear chirp.
331    pub fn new(amplitude: S, f0: S, f1: S, t1: S) -> Self {
332        Self {
333            amplitude,
334            f0,
335            f1,
336            t1,
337        }
338    }
339}
340
341impl<S: Scalar> Signal<S> for Chirp<S> {
342    fn eval(&self, t: S) -> S {
343        if t < S::ZERO || t > self.t1 {
344            return S::ZERO;
345        }
346        // Linear chirp: f(t) = f0 + (f1 - f0) * t / t1
347        // Phase: φ(t) = 2π ∫ f(τ) dτ = 2π (f0*t + (f1-f0)*t²/(2*t1))
348        let k = (self.f1 - self.f0) / self.t1;
349        let phase = S::TWO * S::PI * (self.f0 * t + S::HALF * k * t * t);
350        self.amplitude * phase.sin()
351    }
352}
353
354// ============================================================================
355// Tabulated Signal (Interpolated Data)
356// ============================================================================
357
358/// Interpolation method for tabulated data.
359#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
360pub enum Interpolation {
361    /// Linear interpolation between points.
362    #[default]
363    Linear,
364    /// Nearest neighbor (zero-order hold).
365    Nearest,
366    /// Cubic spline (not yet implemented).
367    Cubic,
368}
369
370/// Signal from tabulated time-value data.
371///
372/// Interpolates between given data points using linear, nearest-neighbor,
373/// or cubic spline interpolation.
374///
375/// # Example
376///
377/// ```rust
378/// use numra_core::signal::{Signal, Tabulated, Interpolation};
379///
380/// let times: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0];
381/// let values: Vec<f64> = vec![0.0, 1.0, 0.0, 2.0];
382/// let signal = Tabulated::new(times, values, Interpolation::Linear);
383///
384/// assert!((signal.value_at(0.5) - 0.5).abs() < 1e-10);  // Midpoint interpolation
385/// assert!((signal.value_at(1.0) - 1.0).abs() < 1e-10);  // Exact point
386/// ```
387///
388/// # Cubic Spline Interpolation
389///
390/// When using `Interpolation::Cubic`, natural cubic splines provide
391/// smooth C² continuous interpolation between data points.
392#[derive(Clone, Debug)]
393pub struct Tabulated<S: Scalar> {
394    pub times: Vec<S>,
395    pub values: Vec<S>,
396    pub interp: Interpolation,
397    /// Second derivatives at each point (for cubic spline interpolation).
398    /// Computed once during construction when interp == Cubic.
399    spline_d2: Option<Vec<S>>,
400}
401
402impl<S: Scalar> Tabulated<S> {
403    /// Create a new tabulated signal.
404    ///
405    /// # Panics
406    /// Panics if times and values have different lengths, or if times is empty.
407    pub fn new(times: Vec<S>, values: Vec<S>, interp: Interpolation) -> Self {
408        assert_eq!(
409            times.len(),
410            values.len(),
411            "times and values must have same length"
412        );
413        assert!(!times.is_empty(), "times must not be empty");
414
415        // Compute spline coefficients if cubic interpolation is requested
416        let spline_d2 = if interp == Interpolation::Cubic && times.len() >= 2 {
417            Some(Self::compute_spline_coefficients(&times, &values))
418        } else {
419            None
420        };
421
422        Self {
423            times,
424            values,
425            interp,
426            spline_d2,
427        }
428    }
429
430    /// Create from slice pairs.
431    pub fn from_pairs(pairs: &[(S, S)], interp: Interpolation) -> Self {
432        let (times, values): (Vec<_>, Vec<_>) = pairs.iter().cloned().unzip();
433        Self::new(times, values, interp)
434    }
435
436    /// Compute natural cubic spline second derivatives.
437    ///
438    /// Uses the Thomas algorithm to solve the tridiagonal system for
439    /// natural spline boundary conditions (second derivative = 0 at endpoints).
440    fn compute_spline_coefficients(times: &[S], values: &[S]) -> Vec<S> {
441        let n = times.len();
442        if n < 2 {
443            return vec![S::ZERO; n];
444        }
445        if n == 2 {
446            // Linear case - second derivatives are zero
447            return vec![S::ZERO, S::ZERO];
448        }
449
450        // Set up the tridiagonal system for natural spline
451        // For interior points i = 1, ..., n-2:
452        // h_{i-1} * d2[i-1] + 2*(h_{i-1} + h_i) * d2[i] + h_i * d2[i+1] = 6 * delta
453        // where delta = (y[i+1] - y[i])/h_i - (y[i] - y[i-1])/h_{i-1}
454
455        let mut d2 = vec![S::ZERO; n];
456
457        // Allocate arrays for Thomas algorithm
458        let mut c_prime = vec![S::ZERO; n - 2]; // Modified upper diagonal
459        let mut d_prime = vec![S::ZERO; n - 2]; // Modified RHS
460
461        // Compute h_i = t[i+1] - t[i]
462        let mut h = Vec::with_capacity(n - 1);
463        for i in 0..n - 1 {
464            h.push(times[i + 1] - times[i]);
465        }
466
467        // Forward sweep of Thomas algorithm
468        for i in 1..n - 1 {
469            let h_prev = h[i - 1];
470            let h_curr = h[i];
471
472            // Diagonal element: 2 * (h_{i-1} + h_i)
473            let diag = S::TWO * (h_prev + h_curr);
474
475            // RHS: 6 * ((y[i+1] - y[i])/h_i - (y[i] - y[i-1])/h_{i-1})
476            let slope_curr = (values[i + 1] - values[i]) / h_curr;
477            let slope_prev = (values[i] - values[i - 1]) / h_prev;
478            let rhs = S::from_f64(6.0) * (slope_curr - slope_prev);
479
480            let idx = i - 1;
481            if idx == 0 {
482                // First equation (natural BC: d2[0] = 0)
483                c_prime[idx] = h_curr / diag;
484                d_prime[idx] = rhs / diag;
485            } else {
486                let m = diag - h_prev * c_prime[idx - 1];
487                c_prime[idx] = h_curr / m;
488                d_prime[idx] = (rhs - h_prev * d_prime[idx - 1]) / m;
489            }
490        }
491
492        // Back substitution
493        // Natural BC: d2[n-1] = 0, so we only solve for d2[1..n-1]
494        let last_idx = n - 3;
495        d2[n - 2] = d_prime[last_idx];
496
497        for i in (1..n - 2).rev() {
498            let idx = i - 1;
499            d2[i] = d_prime[idx] - c_prime[idx] * d2[i + 1];
500        }
501
502        // d2[0] = 0 and d2[n-1] = 0 (natural boundary conditions)
503        d2[0] = S::ZERO;
504        d2[n - 1] = S::ZERO;
505
506        d2
507    }
508
509    /// Get the value at time t (convenience method).
510    pub fn value_at(&self, t: S) -> S {
511        self.interpolate_at(t)
512    }
513
514    /// Internal interpolation method.
515    fn interpolate_at(&self, t: S) -> S {
516        let n = self.times.len();
517
518        // Extrapolation: hold constant
519        if t <= self.times[0] {
520            return self.values[0];
521        }
522        if t >= self.times[n - 1] {
523            return self.values[n - 1];
524        }
525
526        let i = self.find_interval(t);
527        let t0 = self.times[i];
528        let t1 = self.times[i + 1];
529        let v0 = self.values[i];
530        let v1 = self.values[i + 1];
531
532        match self.interp {
533            Interpolation::Nearest => {
534                if t - t0 < t1 - t {
535                    v0
536                } else {
537                    v1
538                }
539            }
540            Interpolation::Linear => {
541                let alpha = (t - t0) / (t1 - t0);
542                v0 + alpha * (v1 - v0)
543            }
544            Interpolation::Cubic => self.cubic_spline_interp(t, i),
545        }
546    }
547
548    /// Cubic spline interpolation using precomputed second derivatives.
549    fn cubic_spline_interp(&self, t: S, i: usize) -> S {
550        let d2 = match &self.spline_d2 {
551            Some(d2) => d2,
552            None => {
553                // Fallback to linear if spline coefficients not available
554                let t0 = self.times[i];
555                let t1 = self.times[i + 1];
556                let alpha = (t - t0) / (t1 - t0);
557                return self.values[i] + alpha * (self.values[i + 1] - self.values[i]);
558            }
559        };
560
561        let t0 = self.times[i];
562        let t1 = self.times[i + 1];
563        let h = t1 - t0;
564        let y0 = self.values[i];
565        let y1 = self.values[i + 1];
566        let d2_0 = d2[i];
567        let d2_1 = d2[i + 1];
568
569        // Cubic spline formula:
570        // S(t) = (1-u)*y0 + u*y1 + h²/6 * [(u³-u)*d2_1 + ((1-u)³-(1-u))*d2_0]
571        // where u = (t - t0) / h
572
573        let u = (t - t0) / h;
574        let one_minus_u = S::ONE - u;
575
576        // Cubic terms
577        let u3 = u * u * u;
578        let omu3 = one_minus_u * one_minus_u * one_minus_u;
579
580        // h² / 6
581        let h2_6 = h * h / S::from_f64(6.0);
582
583        // S(t) = (1-u)*y0 + u*y1 + h²/6 * [(u³-u)*d2_1 + ((1-u)³-(1-u))*d2_0]
584        one_minus_u * y0 + u * y1 + h2_6 * ((u3 - u) * d2_1 + (omu3 - one_minus_u) * d2_0)
585    }
586
587    /// Binary search for interval containing t.
588    fn find_interval(&self, t: S) -> usize {
589        let n = self.times.len();
590        if t <= self.times[0] {
591            return 0;
592        }
593        if t >= self.times[n - 1] {
594            return n - 2;
595        }
596        // Binary search
597        let mut lo = 0;
598        let mut hi = n - 1;
599        while hi - lo > 1 {
600            let mid = (lo + hi) / 2;
601            if t < self.times[mid] {
602                hi = mid;
603            } else {
604                lo = mid;
605            }
606        }
607        lo
608    }
609}
610
611impl<S: Scalar> Signal<S> for Tabulated<S> {
612    fn eval(&self, t: S) -> S {
613        self.interpolate_at(t)
614    }
615}
616
617// ============================================================================
618// FromFile Signal (std only)
619// ============================================================================
620
621/// Signal loaded from a CSV file.
622///
623/// Reads time-value pairs from a file and interpolates.
624/// This is useful for loading earthquake records, experimental data, etc.
625///
626/// # File Format
627///
628/// The file should be in CSV format with two columns: time and value.
629/// Lines starting with '#' are treated as comments.
630///
631/// ```text
632/// # Time, Value
633/// 0.0, 0.0
634/// 0.1, 0.5
635/// 0.2, 1.0
636/// ```
637///
638/// # Example
639///
640/// ```
641/// use numra_core::signal::{Signal, FromFile};
642///
643/// // Load a CSV file with time,value columns
644/// let signal: FromFile<f64> = FromFile::load("test_data/earthquake.csv").unwrap();
645/// let accel = signal.eval(0.15);  // Interpolated value at t=0.15
646/// assert!(accel > 0.0 && accel < 1.0);  // Interpolated between 0.5 and 1.0
647/// ```
648#[cfg(feature = "std")]
649#[derive(Clone, Debug)]
650pub struct FromFile<S: Scalar> {
651    /// Underlying tabulated data
652    tabulated: Tabulated<S>,
653    /// Original file path (for debugging)
654    path: std::string::String,
655}
656
657#[cfg(feature = "std")]
658impl<S: Scalar + std::str::FromStr> FromFile<S> {
659    /// Load signal data from a CSV file.
660    ///
661    /// # Arguments
662    /// - `path` - Path to the CSV file
663    ///
664    /// # Returns
665    /// - `Ok(FromFile)` if the file was loaded successfully
666    /// - `Err(String)` if there was an error reading or parsing the file
667    pub fn load<P: AsRef<std::path::Path>>(path: P) -> Result<Self, std::string::String> {
668        Self::load_with_interpolation(path, Interpolation::Linear)
669    }
670
671    /// Load signal data with specified interpolation method.
672    pub fn load_with_interpolation<P: AsRef<std::path::Path>>(
673        path: P,
674        interp: Interpolation,
675    ) -> Result<Self, std::string::String> {
676        use std::fs::File;
677        use std::io::{BufRead, BufReader};
678
679        let path_ref = path.as_ref();
680        let file = File::open(path_ref)
681            .map_err(|e| format!("Failed to open file '{}': {}", path_ref.display(), e))?;
682
683        let reader = BufReader::new(file);
684        let mut times = Vec::new();
685        let mut values = Vec::new();
686
687        for (line_num, line_result) in reader.lines().enumerate() {
688            let line =
689                line_result.map_err(|e| format!("Failed to read line {}: {}", line_num + 1, e))?;
690
691            let line = line.trim();
692
693            // Skip empty lines and comments
694            if line.is_empty() || line.starts_with('#') {
695                continue;
696            }
697
698            // Parse CSV line
699            let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
700            if parts.len() < 2 {
701                return Err(format!(
702                    "Line {} has fewer than 2 columns: '{}'",
703                    line_num + 1,
704                    line
705                ));
706            }
707
708            let t: S = parts[0].parse().map_err(|_| {
709                format!(
710                    "Failed to parse time on line {}: '{}'",
711                    line_num + 1,
712                    parts[0]
713                )
714            })?;
715            let v: S = parts[1].parse().map_err(|_| {
716                format!(
717                    "Failed to parse value on line {}: '{}'",
718                    line_num + 1,
719                    parts[1]
720                )
721            })?;
722
723            times.push(t);
724            values.push(v);
725        }
726
727        if times.is_empty() {
728            return Err("No data found in file".to_string());
729        }
730
731        let tabulated = Tabulated::new(times, values, interp);
732        let path_string = path_ref.to_string_lossy().into_owned();
733
734        Ok(Self {
735            tabulated,
736            path: path_string,
737        })
738    }
739
740    /// Load from raw CSV string content.
741    pub fn from_csv_string(
742        content: &str,
743        interp: Interpolation,
744    ) -> Result<Self, std::string::String> {
745        let mut times = Vec::new();
746        let mut values = Vec::new();
747
748        for (line_num, line) in content.lines().enumerate() {
749            let line = line.trim();
750
751            // Skip empty lines and comments
752            if line.is_empty() || line.starts_with('#') {
753                continue;
754            }
755
756            // Parse CSV line
757            let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
758            if parts.len() < 2 {
759                return Err(format!(
760                    "Line {} has fewer than 2 columns: '{}'",
761                    line_num + 1,
762                    line
763                ));
764            }
765
766            let t: S = parts[0].parse().map_err(|_| {
767                format!(
768                    "Failed to parse time on line {}: '{}'",
769                    line_num + 1,
770                    parts[0]
771                )
772            })?;
773            let v: S = parts[1].parse().map_err(|_| {
774                format!(
775                    "Failed to parse value on line {}: '{}'",
776                    line_num + 1,
777                    parts[1]
778                )
779            })?;
780
781            times.push(t);
782            values.push(v);
783        }
784
785        if times.is_empty() {
786            return Err("No data found in content".to_string());
787        }
788
789        let tabulated = Tabulated::new(times, values, interp);
790
791        Ok(Self {
792            tabulated,
793            path: "<from_string>".to_string(),
794        })
795    }
796
797    /// Get the number of data points.
798    pub fn len(&self) -> usize {
799        self.tabulated.times.len()
800    }
801
802    /// Check if the signal is empty.
803    pub fn is_empty(&self) -> bool {
804        self.tabulated.times.is_empty()
805    }
806
807    /// Get the time range.
808    pub fn time_range(&self) -> (S, S) {
809        let times = &self.tabulated.times;
810        (times[0], times[times.len() - 1])
811    }
812
813    /// Get the file path.
814    pub fn path(&self) -> &str {
815        &self.path
816    }
817
818    /// Get the underlying tabulated data.
819    pub fn as_tabulated(&self) -> &Tabulated<S> {
820        &self.tabulated
821    }
822}
823
824#[cfg(feature = "std")]
825impl<S: Scalar + std::str::FromStr> Signal<S> for FromFile<S> {
826    fn eval(&self, t: S) -> S {
827        self.tabulated.eval(t)
828    }
829
830    fn eval_derivative(&self, t: S) -> S {
831        self.tabulated.eval_derivative(t)
832    }
833}
834
835// ============================================================================
836// Piecewise Signal
837// ============================================================================
838
839/// Piecewise signal with different values in different time ranges.
840///
841/// # Example
842///
843/// ```rust
844/// use numra_core::signal::{Signal, Piecewise};
845///
846/// // Value 1.0 for t < 1, value 2.0 for 1 ≤ t < 3, value 0.0 for t ≥ 3
847/// let signal: Piecewise<f64> = Piecewise::new(vec![
848///     (1.0, 1.0),   // until t=1, value=1.0
849///     (3.0, 2.0),   // until t=3, value=2.0
850/// ], 0.0);          // default value after all segments
851///
852/// assert!((signal.eval(0.5) - 1.0).abs() < 1e-10);
853/// assert!((signal.eval(2.0) - 2.0).abs() < 1e-10);
854/// assert!(signal.eval(4.0).abs() < 1e-10);
855/// ```
856#[derive(Clone, Debug)]
857pub struct Piecewise<S: Scalar> {
858    /// Segments as (end_time, value) pairs, sorted by end_time.
859    segments: Vec<(S, S)>,
860    /// Default value after all segments.
861    default: S,
862}
863
864impl<S: Scalar> Piecewise<S> {
865    /// Create a new piecewise constant signal.
866    ///
867    /// # Arguments
868    /// - `segments` - List of (end_time, value) pairs. The value applies until end_time.
869    /// - `default` - Value after all segments end.
870    pub fn new(segments: Vec<(S, S)>, default: S) -> Self {
871        Self { segments, default }
872    }
873}
874
875impl<S: Scalar> Signal<S> for Piecewise<S> {
876    fn eval(&self, t: S) -> S {
877        for &(end_time, value) in &self.segments {
878            if t < end_time {
879                return value;
880            }
881        }
882        self.default
883    }
884}
885
886// ============================================================================
887// Composite Signals: Sum and Product
888// ============================================================================
889
890/// Sum of two signals: f(t) = a(t) + b(t)
891#[derive(Clone, Debug)]
892pub struct Sum<S: Scalar, A: Signal<S>, B: Signal<S>> {
893    pub a: A,
894    pub b: B,
895    _marker: core::marker::PhantomData<S>,
896}
897
898impl<S: Scalar, A: Signal<S>, B: Signal<S>> Sum<S, A, B> {
899    pub fn new(a: A, b: B) -> Self {
900        Self {
901            a,
902            b,
903            _marker: core::marker::PhantomData,
904        }
905    }
906}
907
908impl<S: Scalar, A: Signal<S>, B: Signal<S>> Signal<S> for Sum<S, A, B> {
909    #[inline]
910    fn eval(&self, t: S) -> S {
911        self.a.eval(t) + self.b.eval(t)
912    }
913
914    #[inline]
915    fn eval_derivative(&self, t: S) -> S {
916        self.a.eval_derivative(t) + self.b.eval_derivative(t)
917    }
918}
919
920/// Product of two signals: f(t) = a(t) * b(t)
921#[derive(Clone, Debug)]
922pub struct Product<S: Scalar, A: Signal<S>, B: Signal<S>> {
923    pub a: A,
924    pub b: B,
925    _marker: core::marker::PhantomData<S>,
926}
927
928impl<S: Scalar, A: Signal<S>, B: Signal<S>> Product<S, A, B> {
929    pub fn new(a: A, b: B) -> Self {
930        Self {
931            a,
932            b,
933            _marker: core::marker::PhantomData,
934        }
935    }
936}
937
938impl<S: Scalar, A: Signal<S>, B: Signal<S>> Signal<S> for Product<S, A, B> {
939    #[inline]
940    fn eval(&self, t: S) -> S {
941        self.a.eval(t) * self.b.eval(t)
942    }
943
944    #[inline]
945    fn eval_derivative(&self, t: S) -> S {
946        // Product rule: (a*b)' = a'*b + a*b'
947        self.a.eval_derivative(t) * self.b.eval(t) + self.a.eval(t) * self.b.eval_derivative(t)
948    }
949}
950
951/// Scaled signal: f(t) = scale * inner(t)
952#[derive(Clone, Debug)]
953pub struct Scaled<S: Scalar, Inner: Signal<S>> {
954    pub scale: S,
955    pub inner: Inner,
956}
957
958impl<S: Scalar, Inner: Signal<S>> Scaled<S, Inner> {
959    pub fn new(scale: S, inner: Inner) -> Self {
960        Self { scale, inner }
961    }
962}
963
964impl<S: Scalar, Inner: Signal<S>> Signal<S> for Scaled<S, Inner> {
965    #[inline]
966    fn eval(&self, t: S) -> S {
967        self.scale * self.inner.eval(t)
968    }
969
970    #[inline]
971    fn eval_derivative(&self, t: S) -> S {
972        self.scale * self.inner.eval_derivative(t)
973    }
974}
975
976// ============================================================================
977// Constant Signal
978// ============================================================================
979
980/// Constant signal: f(t) = value
981#[derive(Clone, Debug)]
982pub struct Constant<S: Scalar> {
983    pub value: S,
984}
985
986impl<S: Scalar> Constant<S> {
987    pub fn new(value: S) -> Self {
988        Self { value }
989    }
990}
991
992impl<S: Scalar> Signal<S> for Constant<S> {
993    #[inline]
994    fn eval(&self, _t: S) -> S {
995        self.value
996    }
997
998    #[inline]
999    fn eval_derivative(&self, _t: S) -> S {
1000        S::ZERO
1001    }
1002}
1003
1004// ============================================================================
1005// Zero Signal
1006// ============================================================================
1007
1008/// Zero signal: f(t) = 0
1009#[derive(Clone, Debug, Default)]
1010pub struct Zero<S: Scalar> {
1011    _marker: core::marker::PhantomData<S>,
1012}
1013
1014impl<S: Scalar> Zero<S> {
1015    pub fn new() -> Self {
1016        Self {
1017            _marker: core::marker::PhantomData,
1018        }
1019    }
1020}
1021
1022impl<S: Scalar> Signal<S> for Zero<S> {
1023    #[inline]
1024    fn eval(&self, _t: S) -> S {
1025        S::ZERO
1026    }
1027
1028    #[inline]
1029    fn eval_derivative(&self, _t: S) -> S {
1030        S::ZERO
1031    }
1032}
1033
1034// ============================================================================
1035// Boxed Signal for Dynamic Dispatch
1036// ============================================================================
1037
1038impl<S: Scalar> Signal<S> for Box<dyn Signal<S>> {
1039    fn eval(&self, t: S) -> S {
1040        (**self).eval(t)
1041    }
1042
1043    fn eval_derivative(&self, t: S) -> S {
1044        (**self).eval_derivative(t)
1045    }
1046}
1047
1048// ============================================================================
1049// Tests
1050// ============================================================================
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    const TOL: f64 = 1e-10;
1057
1058    #[test]
1059    fn test_harmonic() {
1060        let h = Harmonic::new(2.0, 1.0, 0.0); // 2*sin(2πt)
1061
1062        // sin(0) = 0
1063        assert!(h.eval(0.0).abs() < TOL);
1064
1065        // sin(π/2) = 1 at t=0.25
1066        assert!((h.eval(0.25) - 2.0).abs() < TOL);
1067
1068        // sin(π) = 0 at t=0.5
1069        assert!(h.eval(0.5).abs() < TOL);
1070
1071        // sin(3π/2) = -1 at t=0.75
1072        assert!((h.eval(0.75) + 2.0).abs() < TOL);
1073    }
1074
1075    #[test]
1076    fn test_harmonic_derivative() {
1077        let h = Harmonic::new(1.0, 1.0, 0.0); // sin(2πt)
1078
1079        // d/dt sin(2πt) = 2π cos(2πt)
1080        // At t=0: 2π*cos(0) = 2π
1081        let deriv = h.eval_derivative(0.0);
1082        assert!((deriv - 2.0 * core::f64::consts::PI).abs() < TOL);
1083    }
1084
1085    #[test]
1086    fn test_step_sharp() {
1087        let s = Step::new(1.0, 2.0);
1088
1089        assert!(s.eval(1.0).abs() < TOL);
1090        assert!(s.eval(1.999).abs() < TOL);
1091        assert!((s.eval(2.0) - 1.0).abs() < TOL);
1092        assert!((s.eval(3.0) - 1.0).abs() < TOL);
1093    }
1094
1095    #[test]
1096    fn test_step_smooth() {
1097        let s = Step::smooth(1.0, 2.0, 0.1);
1098
1099        // Far before step
1100        assert!(s.eval(0.0).abs() < 0.01);
1101
1102        // At step time: should be 0.5
1103        assert!((s.eval(2.0) - 0.5).abs() < TOL);
1104
1105        // Far after step
1106        assert!((s.eval(4.0) - 1.0).abs() < 0.01);
1107    }
1108
1109    #[test]
1110    fn test_ramp() {
1111        let r = Ramp::new(0.0, 2.0, 1.0, 3.0);
1112
1113        // Before ramp
1114        assert!(r.eval(0.5).abs() < TOL);
1115
1116        // Start of ramp
1117        assert!(r.eval(1.0).abs() < TOL);
1118
1119        // Midpoint
1120        assert!((r.eval(2.0) - 1.0).abs() < TOL);
1121
1122        // End of ramp
1123        assert!((r.eval(3.0) - 2.0).abs() < TOL);
1124
1125        // After ramp
1126        assert!((r.eval(4.0) - 2.0).abs() < TOL);
1127    }
1128
1129    #[test]
1130    fn test_ramp_derivative() {
1131        let r = Ramp::new(0.0, 4.0, 1.0, 3.0); // 4/(3-1) = 2 slope
1132
1133        // Derivative should be 2 during ramp
1134        assert!((r.eval_derivative(2.0) - 2.0).abs() < TOL);
1135
1136        // Derivative should be 0 outside ramp
1137        assert!(r.eval_derivative(0.5).abs() < TOL);
1138        assert!(r.eval_derivative(4.0).abs() < TOL);
1139    }
1140
1141    #[test]
1142    fn test_pulse() {
1143        let p = Pulse::new(5.0, 1.0, 2.0);
1144
1145        // Before pulse
1146        assert!(p.eval(0.5).abs() < TOL);
1147
1148        // During pulse
1149        assert!((p.eval(1.0) - 5.0).abs() < TOL);
1150        assert!((p.eval(2.0) - 5.0).abs() < TOL);
1151        assert!((p.eval(3.0) - 5.0).abs() < TOL);
1152
1153        // After pulse
1154        assert!(p.eval(3.5).abs() < TOL);
1155    }
1156
1157    #[test]
1158    fn test_chirp() {
1159        let c = Chirp::new(1.0, 1.0, 10.0, 5.0);
1160
1161        // Should be zero outside [0, t1]
1162        assert!(c.eval(-1.0).abs() < TOL);
1163        assert!(c.eval(6.0).abs() < TOL);
1164
1165        // Should be bounded by amplitude inside
1166        assert!(c.eval(2.5).abs() <= 1.0 + TOL);
1167
1168        // At t=0, phase=0, so sin(0)=0
1169        assert!(c.eval(0.0).abs() < TOL);
1170    }
1171
1172    #[test]
1173    fn test_tabulated_linear() {
1174        let times = vec![0.0, 1.0, 2.0, 3.0];
1175        let values = vec![0.0, 2.0, 1.0, 3.0];
1176        let t = Tabulated::new(times, values, Interpolation::Linear);
1177
1178        // Exact points
1179        assert!(t.eval(0.0).abs() < TOL);
1180        assert!((t.eval(1.0) - 2.0).abs() < TOL);
1181        assert!((t.eval(2.0) - 1.0).abs() < TOL);
1182        assert!((t.eval(3.0) - 3.0).abs() < TOL);
1183
1184        // Interpolated
1185        assert!((t.eval(0.5) - 1.0).abs() < TOL); // Midpoint of 0→2
1186        assert!((t.eval(1.5) - 1.5).abs() < TOL); // Midpoint of 2→1
1187
1188        // Extrapolated (hold constant)
1189        assert!(t.eval(-1.0).abs() < TOL);
1190        assert!((t.eval(5.0) - 3.0).abs() < TOL);
1191    }
1192
1193    #[test]
1194    fn test_tabulated_nearest() {
1195        let times = vec![0.0, 1.0, 2.0];
1196        let values = vec![0.0, 1.0, 2.0];
1197        let t = Tabulated::new(times, values, Interpolation::Nearest);
1198
1199        // Near 0
1200        assert!(t.eval(0.3).abs() < TOL);
1201
1202        // Near 1
1203        assert!((t.eval(0.6) - 1.0).abs() < TOL);
1204        assert!((t.eval(1.4) - 1.0).abs() < TOL);
1205
1206        // Near 2
1207        assert!((t.eval(1.6) - 2.0).abs() < TOL);
1208    }
1209
1210    #[test]
1211    fn test_tabulated_cubic_spline() {
1212        // Test cubic spline interpolation with a simple dataset
1213        let times = vec![0.0, 1.0, 2.0, 3.0, 4.0];
1214        let values = vec![0.0, 1.0, 0.0, 1.0, 0.0];
1215        let t = Tabulated::new(times.clone(), values.clone(), Interpolation::Cubic);
1216
1217        // Exact points should match
1218        for (i, (&ti, &vi)) in times.iter().zip(values.iter()).enumerate() {
1219            let y = t.value_at(ti);
1220            assert!(
1221                (y - vi).abs() < 1e-10,
1222                "Failed at point {}: expected {}, got {}",
1223                i,
1224                vi,
1225                y
1226            );
1227        }
1228
1229        // Cubic spline should be smoother than linear at midpoints
1230        // For the oscillating data, cubic spline should give values between the neighbors
1231        let mid_12 = t.value_at(1.5);
1232        assert!(
1233            mid_12 < 1.0 && mid_12 > -0.5,
1234            "Cubic spline midpoint 1.5 out of range: {}",
1235            mid_12
1236        );
1237
1238        // The derivative should be continuous (C² property of natural cubic splines)
1239        // Check that the second derivative exists by verifying smoothness
1240        let eps = 0.001;
1241        let deriv_left = (t.value_at(1.5) - t.value_at(1.5 - eps)) / eps;
1242        let deriv_right = (t.value_at(1.5 + eps) - t.value_at(1.5)) / eps;
1243        assert!(
1244            (deriv_left - deriv_right).abs() < 0.1,
1245            "Derivative discontinuity at 1.5: left={}, right={}",
1246            deriv_left,
1247            deriv_right
1248        );
1249    }
1250
1251    #[test]
1252    fn test_tabulated_cubic_vs_linear() {
1253        // Cubic spline should give smoother interpolation than linear
1254        let times = vec![0.0, 1.0, 2.0, 3.0];
1255        let values = vec![0.0, 1.0, 0.5, 1.5];
1256
1257        let linear = Tabulated::new(times.clone(), values.clone(), Interpolation::Linear);
1258        let cubic = Tabulated::new(times.clone(), values.clone(), Interpolation::Cubic);
1259
1260        // At exact points, both should give same values
1261        for (&ti, &vi) in times.iter().zip(values.iter()) {
1262            assert!((linear.value_at(ti) - vi).abs() < 1e-10);
1263            assert!((cubic.value_at(ti) - vi).abs() < 1e-10);
1264        }
1265
1266        // At midpoint, cubic will generally differ from linear
1267        // Both should be reasonable interpolations
1268        let lin_mid = linear.value_at(1.5);
1269        let cub_mid = cubic.value_at(1.5);
1270
1271        // Linear at 1.5: (1.0 + 0.5) / 2 = 0.75
1272        assert!((lin_mid - 0.75).abs() < 1e-10);
1273
1274        // Cubic will be different (but still reasonable)
1275        assert!(
1276            cub_mid > 0.0 && cub_mid < 2.0,
1277            "Cubic value {} out of reasonable range",
1278            cub_mid
1279        );
1280    }
1281
1282    #[test]
1283    fn test_piecewise() {
1284        let p = Piecewise::new(vec![(1.0, 10.0), (3.0, 20.0), (5.0, 30.0)], 0.0);
1285
1286        assert!((p.eval(0.5) - 10.0).abs() < TOL);
1287        assert!((p.eval(2.0) - 20.0).abs() < TOL);
1288        assert!((p.eval(4.0) - 30.0).abs() < TOL);
1289        assert!(p.eval(6.0).abs() < TOL);
1290    }
1291
1292    #[test]
1293    fn test_sum() {
1294        let a = Constant::new(3.0);
1295        let b = Constant::new(5.0);
1296        let sum = Sum::new(a, b);
1297
1298        assert!((sum.eval(0.0) - 8.0).abs() < TOL);
1299        assert!((sum.eval(100.0) - 8.0).abs() < TOL);
1300    }
1301
1302    #[test]
1303    fn test_product() {
1304        let a = Constant::new(3.0);
1305        let b = Constant::new(5.0);
1306        let prod = Product::new(a, b);
1307
1308        assert!((prod.eval(0.0) - 15.0).abs() < TOL);
1309    }
1310
1311    #[test]
1312    fn test_scaled() {
1313        let h = Harmonic::new(1.0, 1.0, 0.0);
1314        let scaled = Scaled::new(2.0, h);
1315
1316        // At t=0.25, sin(π/2) = 1, so scaled = 2
1317        assert!((scaled.eval(0.25) - 2.0).abs() < TOL);
1318    }
1319
1320    #[test]
1321    fn test_constant_and_zero() {
1322        let c = Constant::new(42.0);
1323        let z: Zero<f64> = Zero::new();
1324
1325        assert!((c.eval(0.0) - 42.0).abs() < TOL);
1326        assert!((c.eval(1000.0) - 42.0).abs() < TOL);
1327        assert!(c.eval_derivative(0.0).abs() < TOL);
1328
1329        assert!(z.eval(0.0).abs() < TOL);
1330        assert!(z.eval(1000.0).abs() < TOL);
1331    }
1332
1333    #[test]
1334    fn test_composite_signals() {
1335        // Build: 2*sin(2πt) + step(1, at t=0.5)
1336        let harmonic = Harmonic::new(2.0, 1.0, 0.0);
1337        let step = Step::new(1.0, 0.5);
1338        let composite = Sum::new(harmonic, step);
1339
1340        // At t=0: sin(0) + 0 = 0
1341        assert!(composite.eval(0.0).abs() < TOL);
1342
1343        // At t=0.25: sin(π/2)*2 + 0 = 2
1344        assert!((composite.eval(0.25) - 2.0).abs() < TOL);
1345
1346        // At t=0.75: sin(3π/2)*2 + 1 = -2 + 1 = -1
1347        assert!((composite.eval(0.75) + 1.0).abs() < TOL);
1348    }
1349
1350    #[cfg(feature = "std")]
1351    #[test]
1352    fn test_from_file_csv_string() {
1353        let csv = r#"
1354# Test data
13550.0, 0.0
13561.0, 2.0
13572.0, 1.0
13583.0, 3.0
1359"#;
1360        let signal: super::FromFile<f64> =
1361            super::FromFile::from_csv_string(csv, Interpolation::Linear).unwrap();
1362
1363        assert_eq!(signal.len(), 4);
1364        assert!(!signal.is_empty());
1365
1366        let (t_min, t_max) = signal.time_range();
1367        assert!(t_min.abs() < TOL);
1368        assert!((t_max - 3.0).abs() < TOL);
1369
1370        // Test interpolation
1371        assert!(signal.eval(0.0).abs() < TOL);
1372        assert!((signal.eval(1.0) - 2.0).abs() < TOL);
1373        assert!((signal.eval(0.5) - 1.0).abs() < TOL); // Midpoint interpolation
1374    }
1375
1376    #[cfg(feature = "std")]
1377    #[test]
1378    fn test_from_file_empty_error() {
1379        let csv = "# Only comments\n# No data\n";
1380        let result: Result<super::FromFile<f64>, _> =
1381            super::FromFile::from_csv_string(csv, Interpolation::Linear);
1382        assert!(result.is_err());
1383    }
1384
1385    #[cfg(feature = "std")]
1386    #[test]
1387    fn test_from_file_parse_error() {
1388        let csv = "0.0, abc\n"; // Invalid value
1389        let result: Result<super::FromFile<f64>, _> =
1390            super::FromFile::from_csv_string(csv, Interpolation::Linear);
1391        assert!(result.is_err());
1392    }
1393
1394    #[cfg(feature = "std")]
1395    #[test]
1396    fn test_from_file_too_few_columns() {
1397        let csv = "0.0\n"; // Only one column
1398        let result: Result<super::FromFile<f64>, _> =
1399            super::FromFile::from_csv_string(csv, Interpolation::Linear);
1400        assert!(result.is_err());
1401    }
1402}