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}