interpn/
lib.rs

1//! N-dimensional interpolation/extrapolation methods, no-std and no-alloc compatible,
2//! prioritizing correctness, performance, and compatiblity with memory-constrained environments.
3//!
4//! # Performance Scalings
5//! Note that for a self-consistent multidimensional linear interpolation, there are 2^ndims grid values that contribute
6//! to each observation point, and as such, that is the theoretical floor for performance scaling. That said,
7//! depending on the implementation, the constant term can vary by more than an order of magnitude.
8//!
9//! Cubic interpolations require two more degrees of freedom per dimension, and have a minimal runtime scaling of 4^ndims.
10//! Similar to the linear methods, depending on implementation, the constant term can vary by orders of magnitude,
11//! as can the RAM usage.
12//!
13//! Rectilinear methods perform a bisection search to find the relevant grid cell, which takes
14//! a worst-case number of iterations of log2(number of grid elements).
15//!
16//! | Method                        | RAM       | Interp. / Extrap. Cost       |
17//! |-------------------------------|-----------|------------------------------|
18//! | multilinear::regular          | O(ndims)  | O(2^ndims)                   |
19//! | multilinear::rectilinear      | O(ndims)  | O(2^ndims) + log2(gridsize)  |
20//! | multicubic::regular           | O(ndims)  | O(4^ndims)                   |
21//! | multicubic::rectilinear       | O(ndims)  | O(4^ndims) + log2(gridsize)  |
22//!
23//! # Example: Multilinear and Multicubic w/ Regular Grid
24//! ```rust
25//! use interpn::{multilinear, multicubic};
26//!
27//! // Define a grid
28//! let x = [1.0_f64, 2.0, 3.0, 4.0];
29//! let y = [0.0_f64, 1.0, 2.0, 3.0];
30//!
31//! // Grid input for rectilinear method
32//! let grids = &[&x[..], &y[..]];
33//!
34//! // Grid input for regular grid method
35//! let dims = [x.len(), y.len()];
36//! let starts = [x[0], y[0]];
37//! let steps = [x[1] - x[0], y[1] - y[0]];
38//!
39//! // Values at grid points
40//! let z = [2.0; 16];
41//!
42//! // Observation points to interpolate/extrapolate
43//! let xobs = [0.0_f64, 5.0];
44//! let yobs = [-1.0, 3.0];
45//! let obs = [&xobs[..], &yobs[..]];
46//!
47//! // Storage for output
48//! let mut out = [0.0; 2];
49//!
50//! // Do interpolation
51//! multilinear::regular::interpn(&dims, &starts, &steps, &z, &obs, &mut out);
52//! multicubic::regular::interpn(&dims, &starts, &steps, &z, false, &obs, &mut out);
53//! ```
54//!
55//! # Example: Multilinear and Multicubic w/ Rectilinear Grid
56//! ```rust
57//! use interpn::{multilinear, multicubic};
58//!
59//! // Define a grid
60//! let x = [1.0_f64, 2.0, 3.0, 4.0];
61//! let y = [0.0_f64, 1.0, 2.0, 3.0];
62//!
63//! // Grid input for rectilinear method
64//! let grids = &[&x[..], &y[..]];
65//!
66//! // Values at grid points
67//! let z = [2.0; 16];
68//!
69//! // Points to interpolate/extrapolate
70//! let xobs = [0.0_f64, 5.0];
71//! let yobs = [-1.0, 3.0];
72//! let obs = [&xobs[..], &yobs[..]];
73//!
74//! // Storage for output
75//! let mut out = [0.0; 2];
76//!
77//! // Do interpolation
78//! multilinear::rectilinear::interpn(grids, &z, &obs, &mut out).unwrap();
79//! multicubic::rectilinear::interpn(grids, &z, false, &obs, &mut out).unwrap();
80//! ```
81//!
82//! # Development Roadmap
83//! * Methods for unstructured triangular and tetrahedral meshes
84#![cfg_attr(not(feature = "std"), no_std)]
85// These "needless" range loops are a significant speedup
86#![allow(clippy::needless_range_loop)]
87// Some const loops produce flattened code with unresolvable lints on
88// expanded code that is entirely in const.
89#![allow(clippy::absurd_extreme_comparisons)]
90
91use num_traits::Float;
92
93pub mod multilinear;
94pub use multilinear::{MultilinearRectilinear, MultilinearRegular};
95
96pub mod multicubic;
97pub use multicubic::{MulticubicRectilinear, MulticubicRegular};
98
99pub mod linear {
100    pub use crate::multilinear::rectilinear;
101    pub use crate::multilinear::regular;
102}
103
104pub mod cubic {
105    pub use crate::multicubic::rectilinear;
106    pub use crate::multicubic::regular;
107}
108
109pub mod nearest;
110pub use nearest::{NearestRectilinear, NearestRegular};
111
112pub mod one_dim;
113pub use one_dim::{
114    RectilinearGrid1D, RegularGrid1D, hold::Left1D, hold::Nearest1D, hold::Right1D,
115    linear::Linear1D, linear::LinearHoldLast1D,
116};
117
118#[cfg(feature = "par")]
119use rayon::{
120    iter::{IndexedParallelIterator, ParallelIterator},
121    slice::ParallelSliceMut,
122};
123
124#[cfg(feature = "par")]
125use std::sync::{LazyLock, Mutex};
126
127#[cfg(feature = "std")]
128pub mod utils;
129
130#[cfg(all(test, feature = "std"))]
131pub(crate) mod testing;
132
133#[cfg(feature = "python")]
134pub mod python;
135
136/// Interpolant function for multi-dimensional methods.
137#[derive(Clone, Copy)]
138pub enum GridInterpMethod {
139    /// Multi-linear interpolation.
140    Linear,
141    /// Cubic Hermite spline interpolation.
142    Cubic,
143    /// Nearest-neighbor interpolation.
144    Nearest,
145}
146
147/// Grid spacing category for multi-dimensional methods.
148#[derive(Clone, Copy)]
149pub enum GridKind {
150    /// Evenly-spaced points along each axis.
151    Regular,
152    /// Un-evenly spaced points along each axis.
153    Rectilinear,
154}
155
156const MAXDIMS: usize = 8;
157const MAXDIMS_ERR: &str =
158    "Dimension exceeds maximum (8). Use interpolator struct directly for higher dimensions.";
159const MIN_CHUNK_SIZE: usize = 1024;
160
161/// The number of physical cores present on the machine;
162/// initialized once, then never again, because each call involves some file I/O
163/// and allocations that can be slower than the function call that they support.
164///
165/// On subsequent accesses, each access is an atomic load without any waiting paths.
166///
167/// This lock can only be contended if multiple threads attempt access
168/// before it is initialized; in that case, the waiting threads may park
169/// until initialization is complete, which can cause ~20us delays
170/// on first access only.
171#[cfg(feature = "par")]
172static PHYSICAL_CORES: LazyLock<usize> = LazyLock::new(num_cpus::get_physical);
173
174/// Evaluate multidimensional interpolation on a regular grid in up to 8 dimensions.
175/// Assumes C-style ordering of vals (z(x0, y0), z(x0, y1), ..., z(x0, yn), z(x1, y0), ...).
176///
177/// For lower dimensions, a fast flattened method is used. For higher dimensions, where that flattening
178/// becomes impractical due to compile times and instruction size, evaluation defers to a run-time
179/// loop.
180/// The linear method uses the flattening for 1-6 dimensions, while
181/// flattened cubic methods are available up to 3 dimensions by default and up to 4 dimensions
182/// with the `deep_unroll` feature enabled.
183///
184/// This is a convenience function; best performance will be achieved by using the exact right
185/// number for the N parameter, as this will slightly reduce compute and storage overhead,
186/// and the underlying method can be extended to more than this function's limit of 8 dimensions.
187/// The limit of 8 dimensions was chosen for no more specific reason than to reduce unit test times.
188///
189/// While this method initializes the interpolator struct on every call, the overhead of doing this
190/// is minimal even when using it to evaluate one observation point at a time.
191///
192/// Like most grid search algorithms (including in the standard library), the uniqueness and
193/// monotonicity of the grid is the responsibility of the user, because checking it is often much
194/// more expensive than the algorithm that we will perform on it. Behavior with ill-posed grids
195/// is undefined.
196///
197/// #### Args:
198///
199/// * `grids`: `N` slices of each axis' grid coordinates. Must be unique and monotonically increasing.
200/// * `vals`:  Flattened `N`-dimensional array of data values at each grid point in C-style order.
201///          Must be the same length as the cartesian product of the grids, (n_x * n_y * ...).
202/// * `obs`:   `N` slices of Observation points where the interpolant should be evaluated.
203///          Must be of equal length.
204/// * `out`:   Pre-allocated output buffer to place the resulting values.
205///          Must  be the same length as each of the `obs` slices.
206/// * `method`: Choice of interpolant function.
207/// * `assume_grid_kind`: Whether to assume the grid is regular (evenly-spaced),
208///                     rectilinear (un-evenly spaced), or make no assumption.
209///                     If an assumption is provided, this bypasses a check of each
210///                     grid, which can be a major speedup in some cases.
211/// * `linearize_extrapolation`: Whether cubic methods should extrapolate linearly instead of the default
212///                            quadratic extrapolation. Linearization is recommended to prevent
213///                            the interpolant from diverging to extremes outside the grid.
214/// * `check_bounds_with_atol`: If provided, return an error if any observation points are outside the grid
215///                           by an amount exceeding the provided tolerance.
216/// * `max_threads`: If provided, limit number of threads used to at most this number. Otherwise,
217///                use a heuristic to choose the number that will provide the best throughput.
218#[cfg(feature = "par")]
219pub fn interpn<T: Float + Send + Sync>(
220    grids: &[&[T]],
221    vals: &[T],
222    obs: &[&[T]],
223    out: &mut [T],
224    method: GridInterpMethod,
225    assume_grid_kind: Option<GridKind>,
226    linearize_extrapolation: bool,
227    check_bounds_with_atol: Option<T>,
228    max_threads: Option<usize>,
229) -> Result<(), &'static str> {
230    let ndims = grids.len();
231    if ndims > MAXDIMS {
232        return Err(MAXDIMS_ERR);
233    }
234    let n = out.len();
235
236    // Resolve grid kind, checking the grid if the kind is not provided by the user.
237    // We do this once at the top level so that the work is not repeated by each thread.
238    let kind = resolve_grid_kind(assume_grid_kind, grids)?;
239
240    // If there are enough points to justify it, run parallel
241    if 2 * MIN_CHUNK_SIZE <= n {
242        // Chunk for parallelism.
243        //
244        // By default, use only physical cores, because on most machines as of
245        // 2026, only half the available cores represent real compute capability due to
246        // the widespread adoption of hyperthreading. If a larger number is requested for
247        // max_threads, that value is clamped to the total available threads so that we don't
248        // queue chunks unnecessarily.
249        //
250        // We also use a minimum chunk size of 1024 as a heuristic, because below that limit,
251        // single-threaded performance is usually faster due to a combination of thread spawning overhead,
252        // memory page sizing, and improved vectorization over larger inputs.
253        let num_cores_physical = *PHYSICAL_CORES; // Real cores, populated on first access
254        let num_cores_pool = rayon::current_num_threads(); // Available cores from rayon thread pool
255        let num_cores_available = num_cores_physical.min(num_cores_pool).max(1); // Real max
256        let num_cores = match max_threads {
257            Some(num_cores_requested) => num_cores_requested.min(num_cores_available),
258            None => num_cores_available,
259        };
260        let chunk = MIN_CHUNK_SIZE.max(n / num_cores);
261
262        // Make a shared error indicator
263        let result: Mutex<Option<&'static str>> = Mutex::new(None);
264        let write_err = |msg: &'static str| {
265            let mut guard = result.lock().unwrap();
266            if guard.is_none() {
267                *guard = Some(msg);
268            }
269        };
270
271        // Run threaded
272        out.par_chunks_mut(chunk).enumerate().for_each(|(i, outc)| {
273            // Calculate the start and end of observation point chunks
274            let start = chunk * i;
275            let end = start + outc.len();
276
277            // Chunk observation points
278            let mut obs_slices: [&[T]; 8] = [&[]; 8];
279            for (j, o) in obs.iter().enumerate() {
280                let s = &o.get(start..end);
281                match s {
282                    Some(s) => obs_slices[j] = s,
283                    None => {
284                        write_err("Dimension mismatch");
285                        return;
286                    }
287                };
288            }
289
290            // Do interpolations
291            let res_inner = interpn_serial(
292                grids,
293                vals,
294                &obs_slices[..ndims],
295                outc,
296                method,
297                Some(kind),
298                linearize_extrapolation,
299                check_bounds_with_atol,
300            );
301
302            match res_inner {
303                Ok(()) => {}
304                Err(msg) => write_err(msg),
305            }
306        });
307
308        // Handle errors from threads
309        match *result.lock().unwrap() {
310            Some(msg) => Err(msg),
311            None => Ok(()),
312        }
313    } else {
314        // If there are not enough points to justify parallelism, run serial
315        interpn_serial(
316            grids,
317            vals,
318            obs,
319            out,
320            method,
321            Some(kind),
322            linearize_extrapolation,
323            check_bounds_with_atol,
324        )
325    }
326}
327
328/// Allocating variant of [interpn].
329/// It is recommended to pre-allocate outputs and use the non-allocating variant
330/// whenever possible.
331#[cfg(feature = "par")]
332pub fn interpn_alloc<T: Float + Send + Sync>(
333    grids: &[&[T]],
334    vals: &[T],
335    obs: &[&[T]],
336    out: Option<Vec<T>>,
337    method: GridInterpMethod,
338    assume_grid_kind: Option<GridKind>,
339    linearize_extrapolation: bool,
340    check_bounds_with_atol: Option<T>,
341    max_threads: Option<usize>,
342) -> Result<Vec<T>, &'static str> {
343    // Empty input -> empty output
344    if obs.len() == 0 {
345        return Ok(Vec::with_capacity(0));
346    }
347
348    // If output storage was not provided, build it now
349    let mut out = out.unwrap_or_else(|| vec![T::zero(); obs[0].len()]);
350
351    interpn(
352        grids,
353        vals,
354        obs,
355        &mut out,
356        method,
357        assume_grid_kind,
358        linearize_extrapolation,
359        check_bounds_with_atol,
360        max_threads,
361    )?;
362
363    Ok(out)
364}
365
366/// Single-threaded, non-allocating variant of [interpn] available without `par` feature.
367pub fn interpn_serial<T: Float>(
368    grids: &[&[T]],
369    vals: &[T],
370    obs: &[&[T]],
371    out: &mut [T],
372    method: GridInterpMethod,
373    assume_grid_kind: Option<GridKind>,
374    linearize_extrapolation: bool,
375    check_bounds_with_atol: Option<T>,
376) -> Result<(), &'static str> {
377    let ndims = grids.len();
378    if ndims > MAXDIMS {
379        return Err(MAXDIMS_ERR);
380    }
381
382    // Resolve grid kind, checking the grid if the kind is not provided by the user.
383    let kind = resolve_grid_kind(assume_grid_kind, grids)?;
384
385    // Extract regular grid params
386    let get_regular_grid = || {
387        let mut dims = [0_usize; MAXDIMS];
388        let mut starts = [T::zero(); MAXDIMS];
389        let mut steps = [T::zero(); MAXDIMS];
390
391        for (i, grid) in grids.iter().enumerate() {
392            if grid.len() < 2 {
393                return Err("All grids must have at least two entries");
394            }
395            dims[i] = grid.len();
396            starts[i] = grid[0];
397            steps[i] = grid[1] - grid[0];
398        }
399
400        Ok((dims, starts, steps))
401    };
402
403    // Bounds checks for regular grid, if requested
404    let maybe_check_bounds_regular = |dims: &[usize], starts: &[T], steps: &[T], obs: &[&[T]]| {
405        if let Some(atol) = check_bounds_with_atol {
406            let mut bounds = [false; MAXDIMS];
407            let out = &mut bounds[..ndims];
408            multilinear::regular::check_bounds(
409                &dims[..ndims],
410                &starts[..ndims],
411                &steps[..ndims],
412                obs,
413                atol,
414                out,
415            )?;
416            if bounds.iter().any(|x| *x) {
417                return Err("At least one observation point is outside the grid.");
418            }
419        }
420        Ok(())
421    };
422
423    // Bounds checks for rectilinear grid, if requested
424    let maybe_check_bounds_rectilinear = |grids, obs| {
425        if let Some(atol) = check_bounds_with_atol {
426            let mut bounds = [false; MAXDIMS];
427            let out = &mut bounds[..ndims];
428            multilinear::rectilinear::check_bounds(grids, obs, atol, out)?;
429            if bounds.iter().any(|x| *x) {
430                return Err("At least one observation point is outside the grid.");
431            }
432        }
433        Ok(())
434    };
435
436    // Select lower-level method
437    match (method, kind) {
438        (GridInterpMethod::Linear, GridKind::Regular) => {
439            let (dims, starts, steps) = get_regular_grid()?;
440            maybe_check_bounds_regular(&dims, &starts, &steps, obs)?;
441            linear::regular::interpn(
442                &dims[..ndims],
443                &starts[..ndims],
444                &steps[..ndims],
445                vals,
446                obs,
447                out,
448            )
449        }
450        (GridInterpMethod::Linear, GridKind::Rectilinear) => {
451            maybe_check_bounds_rectilinear(grids, obs)?;
452            linear::rectilinear::interpn(grids, vals, obs, out)
453        }
454        (GridInterpMethod::Cubic, GridKind::Regular) => {
455            let (dims, starts, steps) = get_regular_grid()?;
456            maybe_check_bounds_regular(&dims, &starts, &steps, obs)?;
457            cubic::regular::interpn(
458                &dims[..ndims],
459                &starts[..ndims],
460                &steps[..ndims],
461                vals,
462                linearize_extrapolation,
463                obs,
464                out,
465            )
466        }
467        (GridInterpMethod::Cubic, GridKind::Rectilinear) => {
468            maybe_check_bounds_rectilinear(grids, obs)?;
469            cubic::rectilinear::interpn(grids, vals, linearize_extrapolation, obs, out)
470        }
471        (GridInterpMethod::Nearest, GridKind::Regular) => {
472            let (dims, starts, steps) = get_regular_grid()?;
473            maybe_check_bounds_regular(&dims, &starts, &steps, obs)?;
474            nearest::regular::interpn(
475                &dims[..ndims],
476                &starts[..ndims],
477                &steps[..ndims],
478                vals,
479                obs,
480                out,
481            )
482        }
483        (GridInterpMethod::Nearest, GridKind::Rectilinear) => {
484            maybe_check_bounds_rectilinear(grids, obs)?;
485            nearest::rectilinear::interpn(grids, vals, obs, out)
486        }
487    }
488}
489
490/// Figure out whether a grid is regular or rectilinear.
491fn resolve_grid_kind<T: Float>(
492    assume_grid_kind: Option<GridKind>,
493    grids: &[&[T]],
494) -> Result<GridKind, &'static str> {
495    let kind = match assume_grid_kind {
496        Some(GridKind::Regular) => GridKind::Regular,
497        Some(GridKind::Rectilinear) => GridKind::Rectilinear,
498        None => {
499            // Check whether grid is regular
500            let mut is_regular = true;
501
502            for grid in grids.iter() {
503                if grid.len() < 2 {
504                    return Err("All grids must have at least two entries");
505                }
506                let step = grid[1] - grid[0];
507
508                if !grid.windows(2).all(|pair| pair[1] - pair[0] == step) {
509                    is_regular = false;
510                    break;
511                }
512            }
513
514            if is_regular {
515                GridKind::Regular
516            } else {
517                GridKind::Rectilinear
518            }
519        }
520    };
521
522    Ok(kind)
523}
524
525/// Index a single value from an array with a known fixed number of dimensions
526#[inline]
527pub(crate) fn index_arr_fixed_dims<T: Copy, const N: usize>(
528    loc: [usize; N],
529    dimprod: [usize; N],
530    data: &[T],
531) -> T {
532    let mut i = 0;
533
534    for j in 0..N {
535        i += loc[j] * dimprod[j];
536    }
537
538    data[i]
539}
540
541/// Light-weight regular grid checks. Doing a more complete validation
542/// would break asymptotic scaling for evaluation.
543#[inline(always)]
544pub(crate) fn validate_regular_grid<T: Float, const N: usize>(
545    dims: &[usize; N],
546    steps: &[T; N],
547    vals: &[T],
548) -> Result<(), &'static str> {
549    let nvals: usize = dims.iter().product();
550    if vals.len() != nvals {
551        return Err("Dimension mismatch");
552    }
553    let degenerate = dims.iter().any(|&x| x < 2);
554    if degenerate {
555        return Err("All grids must have at least two entries");
556    }
557    let steps_are_positive = steps.iter().all(|&x| x > T::zero());
558    if !steps_are_positive {
559        return Err("All grids must be monotonically increasing");
560    }
561    Ok(())
562}
563
564/// Light-weight rectilinear grid checks. Doing a more complete validation
565/// would break asymptotic scaling for evaluation.
566#[inline(always)]
567pub(crate) fn validate_rectilinear_grid<T: Float, const N: usize>(
568    grids: &[&[T]; N],
569    vals: &[T],
570) -> Result<[usize; N], &'static str> {
571    let mut dims = [1_usize; N];
572    (0..N).for_each(|i| dims[i] = grids[i].len());
573    let nvals: usize = dims.iter().product();
574    if vals.len() != nvals {
575        return Err("Dimension mismatch");
576    }
577    let degenerate = dims.iter().any(|&x| x < 2);
578    if degenerate {
579        return Err("All grids must have at least 2 entries");
580    }
581    let monotonic_maybe = grids.iter().all(|&g| g[1] > g[0]);
582    if !monotonic_maybe {
583        return Err("All grids must be monotonically increasing");
584    }
585    Ok(dims)
586}
587
588/// Helper for dispatching to a generic method matching the input dimensionality.
589#[doc(hidden)]
590#[macro_export]
591macro_rules! dispatch_ndims {
592    ($ndims:expr, $err:expr, [$($n:literal),+ $(,)?], |$N:ident| $body:expr $(,)?) => {{
593        match $ndims {
594            $(
595                $n => {
596                    const $N: usize = $n;
597                    $body
598                }
599            )+
600            _ => Err($err),
601        }
602    }};
603}