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