noobase 0.0.4

Foundational pure-function utilities for astronomy analysis
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
//! Line-spread-function broadening of a noise-free template
//! ([`Spectrum::convolve_lsf`]).
//!
//! Both supported specs reduce to a single dimensionless quantity, the
//! Gaussian sigma **in `ln λ`**:
//!
//! - `ConstantR(R)`: a constant resolving power `R = λ / Δλ_FWHM`, so
//!   `σ_λ = λ / (R · 2√(2 ln 2))` and `σ_lnλ = σ_λ / λ = 1 / (R · 2√(2 ln 2))`.
//! - `ConstantVelocitySigma { sigma, speed_of_light }`: a constant
//!   velocity dispersion, so `σ_λ / λ = sigma / c` and
//!   `σ_lnλ = sigma / c`.
//!
//! In both cases `σ_λ(λ) = σ_lnλ · λ` — the kernel widens linearly with
//! wavelength. This is the single fact the whole module is built on.
//!
//! Two evaluation paths (ROADMAP D7), selected automatically; **any**
//! wavelength grid is accepted:
//!
//! - **Fast path** — the grid is `Log`-spaced *and* actually uniform in
//!   `ln λ` (`Grid::is_uniform`). Then `σ` is a constant number of
//!   pixels and the broadening collapses to a single fixed
//!   [`gaussian1d`] kernel applied once with edge renormalization.
//! - **General path** — anything else (linear, irregular, non-uniform
//!   `Log`). Each output pixel integrates an erf Gaussian of its own
//!   `σ_λ(λ_i)` over the actual (possibly non-uniform) bins and divides
//!   by the in-window weight. This preserves a constant template and is
//!   flux-conserving for well-contained features; at the spectrum edges,
//!   the per-output renormalization avoids edge darkening but does not
//!   preserve the total integrated flux of a truncated feature. It
//!   deliberately does *not* go through a `linear → log → linear` rebin
//!   round trip (which would irreversibly smear the red end before
//!   convolving).
//!
//! `is_uniform` is only the fast-path *discriminant*, never a rejection
//! gate: `Spacing::Log` is a midpoint convention and does not by itself
//! guarantee `ln λ` uniformity, so dispatching on `spacing == Log` alone
//! would silently miscompute a non-uniform log grid.
//!
//! The input is treated as a noise-free template (ROADMAP D8): only
//! `flux` is broadened. A spectrum carrying `error` or `mask` is
//! *rejected* ([`LsfError::NotATemplate`]), not silently stripped — an
//! observed spectrum already contains its instrumental LSF, so
//! re-convolving one is a scientific misuse, and silently dropping its
//! error/mask would be the crate's only silent data loss.

use ndarray::Array1;
use thiserror::Error;

use crate::axis::{GridKind, Spacing};
use crate::convolve::{Boundary, GaussianSampling, Normalization, conv1d, gaussian1d, normal_cdf};
use crate::float::Float;
use crate::spectroscopy::Spectrum;

/// Gaussian kernel truncation radius, in units of σ (matches
/// [`gaussian1d`]'s default and the general path's window).
const TRUNCATE: f64 = 4.0;

/// Relative per-step tolerance for accepting a `Log` grid as uniform in
/// `ln λ` (the fast-path discriminant). Tight enough that the
/// single-kernel approximation error is negligible, loose enough to
/// admit a genuinely log-uniform grid carrying float noise.
const UNIFORM_REL_TOL: f64 = 1e-6;

/// FWHM-to-sigma factor `2√(2 ln 2) ≈ 2.354820045` for a Gaussian.
#[inline]
fn fwhm_per_sigma() -> f64 {
    2.0 * (2.0 * std::f64::consts::LN_2).sqrt()
}

/// How the line-spread function widens with wavelength. Both variants
/// describe a Gaussian whose `σ / λ` ratio is constant.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LsfSpec {
    /// Constant resolving power `R = λ / Δλ_FWHM`.
    ConstantR(f64),
    /// Constant velocity dispersion. `sigma` and `speed_of_light` must
    /// share units (noobase does not track units; consistency is the
    /// caller's responsibility).
    ConstantVelocitySigma { sigma: f64, speed_of_light: f64 },
}

/// Errors from [`Spectrum::convolve_lsf`].
///
/// Algorithmic edge cases (grid spacing, non-uniformity, edge
/// truncation) are *not* errors — they are handled by automatic path
/// selection and per-pixel renormalization.
#[derive(Debug, Error, PartialEq)]
pub enum LsfError {
    #[error(
        "convolve_lsf input carries error/mask; the LSF applies only to noise-free templates — strip error and mask first"
    )]
    NotATemplate,
    #[error("LSF resolution must be positive (R > 0, or sigma > 0 with positive speed of light)")]
    InvalidResolution,
}

impl<T: Float> Spectrum<T> {
    /// Broaden the template with a Gaussian line-spread function.
    ///
    /// The input is treated as a noise-free model: only `flux` is
    /// convolved and the output carries no `error`/`mask`. The
    /// wavelength grid is returned unchanged (same values, spacing, and
    /// kind). Constant templates remain constant, including at the
    /// spectrum edges, because each output pixel is renormalized by the
    /// in-window kernel weight. Total integrated flux is conserved for
    /// well-contained features; a feature truncated by the spectrum edge is
    /// edge-renormalized locally rather than globally flux-conserved.
    ///
    /// Works for *any* wavelength grid (see the module docs for the
    /// fast/general path selection). Precondition: wavelengths are
    /// physical (strictly positive); `Grid` already guarantees strict
    /// monotonicity.
    ///
    /// # Errors
    ///
    /// - [`LsfError::NotATemplate`] if the spectrum carries `error` or
    ///   `mask` (ROADMAP D8).
    /// - [`LsfError::InvalidResolution`] if `R <= 0`, or `sigma <= 0`,
    ///   or `speed_of_light <= 0`.
    pub fn convolve_lsf(&self, spec: LsfSpec) -> Result<Spectrum<T>, LsfError> {
        // D8: an LSF target is noise-free by construction. Reject (do
        // not silently drop) error/mask.
        if self.error().is_some() || self.mask().is_some() {
            return Err(LsfError::NotATemplate);
        }

        let sigma_ln_lambda = match spec {
            LsfSpec::ConstantR(resolving_power) => {
                if resolving_power <= 0.0 {
                    return Err(LsfError::InvalidResolution);
                }
                1.0 / (resolving_power * fwhm_per_sigma())
            }
            LsfSpec::ConstantVelocitySigma {
                sigma,
                speed_of_light,
            } => {
                if sigma <= 0.0 || speed_of_light <= 0.0 {
                    return Err(LsfError::InvalidResolution);
                }
                sigma / speed_of_light
            }
        };

        let bin_count = self.n_bins();
        let wavelength = self.wavelength();

        // Bin-center wavelengths (the convolution axis) and bin edges
        // (the integration limits for the general path).
        let centers_grid = match wavelength.kind() {
            GridKind::Centers => None,
            GridKind::Edges => Some(wavelength.to_centers()),
        };
        let centers = match &centers_grid {
            Some(grid) => grid.values(),
            None => wavelength.values(),
        };

        let use_fast_path = wavelength.spacing() == Spacing::Log
            && wavelength
                .is_uniform(T::from_f64(UNIFORM_REL_TOL).expect("uniform tolerance fits in T"))
            && bin_count >= 2;

        let new_flux = if use_fast_path {
            self.lsf_fast_path(sigma_ln_lambda, centers)
        } else {
            self.lsf_general_path(sigma_ln_lambda, centers)
        };

        // Lengths are guaranteed to match (built with `bin_count`
        // entries); `Spectrum::new` would only re-validate.
        Ok(Spectrum::new(wavelength.clone(), new_flux, None, None)
            .expect("convolved flux length equals wavelength bin count"))
    }

    /// `Log`-uniform fast path: one fixed Gaussian kernel (σ constant in
    /// pixels because the grid is uniform in `ln λ`), applied with edge
    /// renormalization so flux is conserved at the borders too.
    fn lsf_fast_path(&self, sigma_ln_lambda: f64, centers: ndarray::ArrayView1<T>) -> Array1<T> {
        let bin_count = centers.len();
        // Uniform step in ln λ between adjacent bins.
        let ln_first = centers[0].to_f64().expect("wavelength fits in f64").ln();
        let ln_last = centers[bin_count - 1]
            .to_f64()
            .expect("wavelength fits in f64")
            .ln();
        let ln_step = (ln_last - ln_first) / (bin_count as f64 - 1.0);
        let sigma_pixels = sigma_ln_lambda / ln_step;

        let kernel = gaussian1d::<T>(
            sigma_pixels,
            TRUNCATE,
            GaussianSampling::ErfIntegrated,
            Normalization::Sum,
        );
        // out = correlate(flux) / correlate(ones): interior pixels see a
        // unit-sum kernel (== plain correlation); edge pixels are
        // renormalized by the in-bounds weight, conserving flux. No NaN
        // is possible (template), so this never divides by zero — the
        // center tap alone keeps the denominator positive everywhere.
        let flux = self.flux();
        let numerator = conv1d(flux, kernel.view(), Boundary::Zero);
        let ones = Array1::<T>::from_elem(bin_count, T::one());
        let denominator = conv1d(ones.view(), kernel.view(), Boundary::Zero);
        let mut output = Array1::<T>::zeros(bin_count);
        for index in 0..bin_count {
            output[index] = numerator[index] / denominator[index];
        }
        output
    }

    /// General path: per output pixel, integrate an erf Gaussian of that
    /// pixel's own `σ_λ(λ_i) = σ_lnλ · λ_i` over the actual bins and
    /// normalize by the in-window weight. Exact for any (non-uniform)
    /// grid, constant-preserving at the edges, and flux-conserving for
    /// well-contained features.
    fn lsf_general_path(&self, sigma_ln_lambda: f64, centers: ndarray::ArrayView1<T>) -> Array1<T> {
        let bin_count = centers.len();
        let wavelength = self.wavelength();
        let edges_grid = wavelength.to_edges();
        let edges = edges_grid.values();
        let flux = self.flux();

        let mut output = Array1::<T>::zeros(bin_count);
        for output_index in 0..bin_count {
            let lambda = centers[output_index]
                .to_f64()
                .expect("wavelength fits in f64");
            let sigma = sigma_ln_lambda * lambda;
            let half_window = TRUNCATE * sigma;
            let low_wave = lambda - half_window;
            let high_wave = lambda + half_window;

            // Window = bins whose center is within ±truncate·σ of λ_i
            // (same truncation convention as gaussian1d's tap radius).
            let mut numerator = 0.0_f64;
            let mut denominator = 0.0_f64;
            // `centers` is `Grid::values()` over an owned `Array1`, so
            // the underlying storage is always contiguous.
            let mut bin = centers
                .as_slice()
                .expect("centers is contiguous")
                .partition_point(|c| c.to_f64().expect("wavelength fits in f64") < low_wave);
            while bin < bin_count {
                let center = centers[bin].to_f64().expect("wavelength fits in f64");
                if center > high_wave {
                    break;
                }
                let edge_lo = edges[bin].to_f64().expect("edge fits in f64");
                let edge_hi = edges[bin + 1].to_f64().expect("edge fits in f64");
                // Integral of the unit Gaussian (mean λ_i, sigma σ) over
                // this bin: the same erf source as gaussian1d.
                let weight =
                    normal_cdf((edge_hi - lambda) / sigma) - normal_cdf((edge_lo - lambda) / sigma);
                numerator += weight * flux[bin].to_f64().expect("flux fits in f64");
                denominator += weight;
                bin += 1;
            }

            // The bin containing λ_i always carries positive Gaussian
            // mass, so the denominator is positive in practice; the
            // guard only defends a pathologically degenerate window.
            let value = if denominator > 0.0 {
                numerator / denominator
            } else {
                flux[output_index].to_f64().expect("flux fits in f64")
            };
            output[output_index] = T::from_f64(value).expect("convolved flux fits in T");
        }
        output
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::axis::{Grid, GridKind, Spacing};
    use ndarray::Array1;

    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
        (a - b).abs() <= tol * a.abs().max(b.abs()).max(1.0)
    }

    fn centers_log(start: f64, end: f64, n: usize) -> Grid<f64> {
        Grid::<f64>::logspace(start, end, n, GridKind::Centers)
    }

    fn centers_linear(start: f64, end: f64, n: usize) -> Grid<f64> {
        Grid::<f64>::linspace(start, end, n, GridKind::Centers)
    }

    #[test]
    fn rejects_input_with_error() {
        let wavelength = centers_log(4000.0, 7000.0, 50);
        let flux = Array1::<f64>::from_elem(50, 1.0);
        let error = Array1::<f64>::from_elem(50, 0.1);
        let spectrum = Spectrum::new(wavelength, flux, Some(error), None).unwrap();
        let err = spectrum
            .convolve_lsf(LsfSpec::ConstantR(2000.0))
            .unwrap_err();
        assert_eq!(err, LsfError::NotATemplate);
    }

    #[test]
    fn rejects_input_with_mask() {
        let wavelength = centers_log(4000.0, 7000.0, 50);
        let flux = Array1::<f64>::from_elem(50, 1.0);
        let mask = Array1::<bool>::from_elem(50, false);
        let spectrum = Spectrum::new(wavelength, flux, None, Some(mask)).unwrap();
        let err = spectrum
            .convolve_lsf(LsfSpec::ConstantR(2000.0))
            .unwrap_err();
        assert_eq!(err, LsfError::NotATemplate);
    }

    #[test]
    fn rejects_invalid_resolution() {
        let wavelength = centers_log(4000.0, 7000.0, 20);
        let flux = Array1::<f64>::from_elem(20, 1.0);
        let spectrum = Spectrum::new(wavelength, flux, None, None).unwrap();
        for spec in [
            LsfSpec::ConstantR(0.0),
            LsfSpec::ConstantR(-100.0),
            LsfSpec::ConstantVelocitySigma {
                sigma: 0.0,
                speed_of_light: 299792.458,
            },
            LsfSpec::ConstantVelocitySigma {
                sigma: 50.0,
                speed_of_light: 0.0,
            },
            LsfSpec::ConstantVelocitySigma {
                sigma: 50.0,
                speed_of_light: -1.0,
            },
        ] {
            assert_eq!(
                spectrum.convolve_lsf(spec).unwrap_err(),
                LsfError::InvalidResolution
            );
        }
    }

    #[test]
    fn flux_only_input_is_accepted() {
        let wavelength = centers_log(4000.0, 7000.0, 30);
        let flux = Array1::<f64>::from_elem(30, 2.0);
        let spectrum = Spectrum::new(wavelength, flux, None, None).unwrap();
        assert!(spectrum.convolve_lsf(LsfSpec::ConstantR(3000.0)).is_ok());
    }

    #[test]
    fn constant_template_stays_constant_fast_path_f64() {
        // Log-uniform grid -> fast path. Constant template must stay
        // constant everywhere, edges included (flux conservation).
        let wavelength = centers_log(4000.0, 7000.0, 256);
        let flux = Array1::<f64>::from_elem(256, 3.7);
        let spectrum = Spectrum::new(wavelength, flux, None, None).unwrap();
        let out = spectrum.convolve_lsf(LsfSpec::ConstantR(2000.0)).unwrap();
        for value in out.flux().iter() {
            assert!(approx_eq(*value, 3.7, 1e-9));
        }
    }

    #[test]
    fn constant_template_stays_constant_general_path_f64() {
        // Linear grid -> general path. Same conservation requirement.
        let wavelength = centers_linear(4000.0, 7000.0, 200);
        let flux = Array1::<f64>::from_elem(200, -1.5);
        let spectrum = Spectrum::new(wavelength, flux, None, None).unwrap();
        let out = spectrum
            .convolve_lsf(LsfSpec::ConstantVelocitySigma {
                sigma: 60.0,
                speed_of_light: 299792.458,
            })
            .unwrap();
        for value in out.flux().iter() {
            assert!(approx_eq(*value, -1.5, 1e-9));
        }
    }

    #[test]
    fn output_preserves_grid_and_drops_error_mask() {
        let wavelength = centers_linear(5000.0, 6000.0, 40);
        let flux: Array1<f64> = (0..40).map(|i| (i as f64).sin() + 2.0).collect();
        let spectrum = Spectrum::new(wavelength.clone(), flux, None, None).unwrap();
        let out = spectrum.convolve_lsf(LsfSpec::ConstantR(5000.0)).unwrap();
        assert_eq!(out.wavelength().kind(), wavelength.kind());
        assert_eq!(out.wavelength().spacing(), wavelength.spacing());
        assert_eq!(out.wavelength().len(), wavelength.len());
        for i in 0..wavelength.len() {
            assert_eq!(out.wavelength().values()[i], wavelength.values()[i]);
        }
        assert!(out.error().is_none());
        assert!(out.mask().is_none());
    }

    #[test]
    fn line_flux_is_conserved_for_interior_line_general_path() {
        // A Gaussian emission line well inside a uniform linear grid:
        // the LSF must conserve the integrated flux (constant bin width,
        // so Σ flux is invariant). Tight bound because the line and all
        // its broadened mass stay far from the edges.
        let n = 1200;
        let wavelength = centers_linear(5000.0, 6200.0, n);
        let step = (6200.0 - 5000.0) / (n as f64 - 1.0);
        let line_center = 5600.0;
        let line_sigma = 4.0;
        let flux: Array1<f64> = (0..n)
            .map(|i| {
                let lambda = 5000.0 + step * i as f64;
                let z = (lambda - line_center) / line_sigma;
                (-0.5 * z * z).exp()
            })
            .collect();
        let total_in: f64 = flux.iter().sum();
        let spectrum = Spectrum::new(wavelength, flux, None, None).unwrap();
        let out = spectrum.convolve_lsf(LsfSpec::ConstantR(2000.0)).unwrap();
        let total_out: f64 = out.flux().iter().sum();
        assert!(
            approx_eq(total_out, total_in, 2e-3),
            "flux not conserved: in={total_in} out={total_out}"
        );
    }

    #[test]
    fn broadening_adds_in_quadrature_general_path() {
        // Gaussian line convolved by a Gaussian LSF -> Gaussian with
        // σ_out² = σ_line² + σ_lsf², σ_lsf = λ0 / (R · 2√(2ln2)).
        // Measured via the flux-weighted second moment.
        let n = 2000;
        let (lo, hi) = (5200.0_f64, 5800.0_f64);
        let wavelength = centers_linear(lo, hi, n);
        let step = (hi - lo) / (n as f64 - 1.0);
        let line_center = 5500.0;
        let line_sigma = 5.0;
        let flux: Array1<f64> = (0..n)
            .map(|i| {
                let lambda = lo + step * i as f64;
                let z = (lambda - line_center) / line_sigma;
                (-0.5 * z * z).exp()
            })
            .collect();
        let resolving_power = 1500.0;
        let spectrum = Spectrum::new(wavelength, flux, None, None).unwrap();
        let out = spectrum
            .convolve_lsf(LsfSpec::ConstantR(resolving_power))
            .unwrap();

        let out_flux = out.flux();
        let mut sum_w = 0.0;
        let mut sum_wx = 0.0;
        for i in 0..n {
            let lambda = lo + step * i as f64;
            sum_w += out_flux[i];
            sum_wx += out_flux[i] * lambda;
        }
        let mean = sum_wx / sum_w;
        let mut sum_wxx = 0.0;
        for i in 0..n {
            let lambda = lo + step * i as f64;
            sum_wxx += out_flux[i] * (lambda - mean).powi(2);
        }
        let measured_sigma = (sum_wxx / sum_w).sqrt();
        let sigma_lsf = line_center / (resolving_power * fwhm_per_sigma());
        let expected_sigma = (line_sigma * line_sigma + sigma_lsf * sigma_lsf).sqrt();
        assert!(
            approx_eq(measured_sigma, expected_sigma, 1e-2),
            "measured σ {measured_sigma} vs expected {expected_sigma}"
        );
    }

    #[test]
    fn fast_path_and_general_path_agree_on_same_sigma() {
        // ROADMAP §7: identical wavelength values, one tagged Log
        // (fast path) and one tagged Linear (general path). Under the
        // same physical σ_λ ∝ λ the two paths must agree closely (they
        // differ only by ln-space vs λ-space discretization).
        let n = 400;
        let values: Array1<f64> = {
            let log_lo = 4500.0_f64.ln();
            let log_hi = 6500.0_f64.ln();
            (0..n)
                .map(|i| (log_lo + (log_hi - log_lo) * i as f64 / (n as f64 - 1.0)).exp())
                .collect()
        };
        let template: Array1<f64> = values
            .iter()
            .map(|&lambda| 1.0 + (-0.5 * ((lambda - 5500.0) / 80.0).powi(2)).exp())
            .collect();

        let grid_log = Grid::new(values.clone(), Spacing::Log, GridKind::Centers).unwrap();
        let grid_lin = Grid::new(values.clone(), Spacing::Linear, GridKind::Centers).unwrap();
        let spec = LsfSpec::ConstantR(3000.0);
        let out_fast = Spectrum::new(grid_log, template.clone(), None, None)
            .unwrap()
            .convolve_lsf(spec)
            .unwrap();
        let out_general = Spectrum::new(grid_lin, template.clone(), None, None)
            .unwrap()
            .convolve_lsf(spec)
            .unwrap();

        let fast = out_fast.flux();
        let general = out_general.flux();
        // Compare the interior (away from edge truncation differences).
        for i in 20..(n - 20) {
            assert!(
                approx_eq(fast[i], general[i], 1e-3),
                "path mismatch at {i}: fast {} general {}",
                fast[i],
                general[i]
            );
        }
    }

    #[test]
    fn single_bin_spectrum_is_identity() {
        // n_bins = 1 (Edges grid of length 2): the only bin is its own
        // window, so flux is returned unchanged.
        let wavelength = Grid::new(
            Array1::from(vec![5000.0_f64, 5010.0]),
            Spacing::Linear,
            GridKind::Edges,
        )
        .unwrap();
        let flux = Array1::from(vec![42.0_f64]);
        let spectrum = Spectrum::new(wavelength, flux, None, None).unwrap();
        let out = spectrum.convolve_lsf(LsfSpec::ConstantR(2000.0)).unwrap();
        assert_eq!(out.n_bins(), 1);
        assert!(approx_eq(out.flux()[0], 42.0, 1e-12));
    }

    #[test]
    fn works_with_f32() {
        let wavelength = Grid::<f32>::logspace(4000.0, 7000.0, 128, GridKind::Centers);
        let flux = Array1::<f32>::from_elem(128, 5.0);
        let spectrum = Spectrum::new(wavelength, flux, None, None).unwrap();
        let out = spectrum
            .convolve_lsf(LsfSpec::ConstantVelocitySigma {
                sigma: 70.0,
                speed_of_light: 299792.458,
            })
            .unwrap();
        for value in out.flux().iter() {
            assert!((*value - 5.0).abs() <= 1e-3);
        }
    }
}