ninterp
The ninterp crate provides multivariate interpolation over rectilinear grids of any dimensionality.
It is built on ndarray and uses ndarray arrays/views throughout its API.
ndarray and num_traits are re-exposed as ninterp::ndarray and
ninterp::num_traits for convenience.
Hard-coded interpolators are provided for N = 1, 2, and 3, based on the observed runtime tradeoff versus a general N-D implementation.
For higher dimensionalities (N >= 4), use InterpND.
All interpolators work with both owned and borrowed arrays (array views), and are generic over various Num types.
A variety of interpolation strategies are implemented and exposed in the prelude module.
Custom interpolation strategies can be defined in downstream crates.
Quick Start
cargo add ninterp
Bring common API types into scope:
use *;
Minimal end-to-end interpolation example, using the hard-coded 2-D interpolator:
use *;
use *;
let interp = new
.unwrap;
let z = interp.interpolate.unwrap;
assert_eq!;
The same example, generalized to InterpND (grid axes become a Vec, values become
dynamically-dimensioned via .into_dyn()):
use *;
use *;
let interp_nd = new
.unwrap;
let z = interp_nd.interpolate.unwrap;
assert_eq!;
Instantiation is done by calling an interpolator's new method.
For dimensionalities N >= 1, this executes a validation step that prevents runtime panics.
Choosing an Interpolator
The prelude exposes these interpolators:
Interp0D: constant-value interpolatorInterp1D: hard-coded 1-D interpolatorInterp2D: hard-coded 2-D interpolatorInterp3D: hard-coded 3-D interpolatorInterpND: general N-D interpolator
Use Interp0D when working with heterogeneous collections such as an InterpolatorEnum or Box<dyn Interpolator>.
Compiled vs. Runtime Flexibility
This crate is designed to maximize both performance and runtime flexibility. There are multiple ways to specify interpolators and strategies; You can pin a concrete dimensionality and strategy at compile-time for best performance, or swap either via provided enums or dynamic dispatch. Each approach has trade-offs:
| Approach | Runtime cost | Runtime swapping | Custom strategies | serde |
|---|---|---|---|---|
Interp*<_, ConcreteStrategy> |
Lowest | No | Yes | Yes |
Interp*<_, strategy::enums::Strategy*Enum> |
Low | Strategy only | No | Yes |
InterpolatorEnum |
Low | Interpolator + strategy | No | Yes |
Interp*<_, Box<dyn Strategy*>> |
Medium | Strategy only | Yes | No |
Box<dyn Interpolator<_>> |
Highest | Interpolator + strategy | Yes | No |
For heterogeneous storage that also needs to recover the concrete interpolator type later,
AnyInterpolator
(ninterp::interpolator::AnyInterpolator, not in the prelude) adds Send + Sync and an
as_any(&self) -> &dyn Any method on top of Interpolator<T>, so
boxed.as_any().downcast_ref::<Interp2D<f64, MyStrategy>>() recovers the concrete type.
It's implemented for owned Interp1D/2D/3D/ND only, since downcasting requires
Self: 'static.
Core Concepts
Data Shape Contract
Grid and values shapes must match by axis order:
for every dimension n, grid[n].len() == values.shape()[n].
Grid coordinates in each dimension must be strictly increasing (no repeated adjacent
coordinates), with at least 2 points per dimension (ValidateError::InsufficientGridPoints
otherwise).
Strategies
An interpolation strategy must be specified. Provided strategies:
| Strategy | Description |
|---|---|
Nearest |
Nearest-neighbor interpolation |
Step |
Step interpolation to the previous/next grid value, broadcast or per-dimension |
Linear |
Linear interpolation |
LinearUniform |
Linear interpolation for uniformly spaced grids |
CubicC2 |
C²-continuous cubic spline with boundary conditions (CubicC2BoundaryConditions: not-a-knot, first derivative ("clamped"), second derivative (zero is "natural"), or periodic, with per-endpoint, per-axis configuration flexibility) |
More strategies will be added in the future, see Issue #24.
To change the interpolation strategy, supply a Strategy*DEnum or Box<dyn Strategy*D> at instantiation and call set_strategy.
Custom strategies can be defined, see examples/custom_strategy.rs.
Extrapolation
An Extrapolate
setting must be provided in new.
This controls behavior when points are beyond the supplied coordinate range.
Available for all interpolation strategies:
Extrapolate::Fill(T)Extrapolate::ClampExtrapolate::WrapExtrapolate::Error
Extrapolate::Enable is valid for Linear and LinearUniform for all dimensionalities.
If you are unsure which variant to choose, Extrapolate::Error is a good default.
To change extrapolation behavior after construction, call set_extrapolate.
Interpolation Calls
Call interpolate with one coordinate per dimension:
- 1-D interpolator:
interp.interpolate(&[x]) - 2-D interpolator:
interp.interpolate(&[x, y]) - 3-D interpolator:
interp.interpolate(&[x, y, z]) - N-D interpolator:
interp.interpolate(&[x0, x1, ..., x_{N-1}])
For Interp1D/Interp2D/Interp3D, a wrong-length point is a compile error. For
InterpND, InterpolatorEnum, and Box<dyn Interpolator<T>>, it's instead a runtime
InterpolateError::PointLength. Retrieve dimensionality using
Interpolator::ndim
if necessary.
For hot loops where the point is already known to be in-bounds and extrapolation isn't
needed, interpolate_fast
skips the bounds check and extrapolation handling that interpolate does, returning
D::Elem directly instead of a Result (panicking, instead of erroring, on an invalid
point). It takes the same point argument as interpolate above.
Batch Interpolation
To interpolate several points against one interpolator,
batch_interpolate
(and its interpolate_fast-style counterpart batch_interpolate_fast) resolve
self.extrapolate once for the whole batch and funnel every point into at most one call
to the strategy, instead of calling interpolate per point. For a Box<dyn Interpolator<T>> or Box<dyn Strategy*D>, this also collapses one virtual dispatch
per point into a single dispatch for the whole batch:
use *;
use *;
let interp = new
.unwrap;
let ys = interp.batch_interpolate.unwrap;
assert_eq!;
batch_interpolate_into/batch_interpolate_fast_into write into a caller-supplied output
slice instead of allocating a Vec. batch_interpolate_into returns
InterpolateError::OutputLength if the slice length doesn't match the point count;
batch_interpolate_fast_into instead panics on that mismatch, matching interpolate_fast's
panic-instead-of-Result convention.
Validation Lifecycle
After editing interpolator data, call
Interpolator::validate
to rerun data, extrapolate, and strategy validation checks together, or interp.data.validate()
directly for just the narrower check that grid/value shapes match and grid coordinates are
strictly increasing.
validate checks the data (shapes, strictly increasing grid coordinates), the extrapolate
setting, and runs the strategy's own validate, a pure check for invariants that don't
need precomputed state (for example LinearUniform's uniform-grid requirement, or
Step's per-dimension direction count). It does not re-run the strategy's init, the
mutating counterpart that only strategies caching derived state (such as precomputed
spline coefficients) need to override.
new and set_strategy call both validate and init on the strategy. If you mutate
the public data/strategy fields directly instead, call validate_strategy/
init_strategy to re-run just those steps.
Deserialization does not call validate or init. Call validate afterward regardless:
deserialized data isn't guaranteed to satisfy grid/strategy invariants (hand-edited JSON,
a foreign writer, etc.), and the check is cheap. Whether you also need init_strategy
depends on the strategy: if its cached state is stored in its own serialized fields,
deserialize restores it as-is and init doesn't need to rerun; if that state is instead
skipped from serialization (e.g. via #[serde(skip)], to avoid bloating the wire format
with a large derived array), call init_strategy to rebuild it.
Errors
Validation-time (new / validate):
| Error | Meaning |
|---|---|
ValidateError::InsufficientGridPoints |
Fewer than 2 grid coordinates in a dimension |
ValidateError::NotStrictlyIncreasing |
Grid coordinates in a dimension aren't strictly increasing (a repeat or a decrease) |
ValidateError::NonUniform |
Non-uniformly-spaced grid, required by LinearUniform and any strategy calling validate_uniform_grid/validate_uniform_grid_epsilon |
ValidateError::IncompatibleShapes |
Grid/value shape mismatch |
ValidateError::GridAxisCount |
InterpDataND grid axis count doesn't match the dimensionality of values |
ValidateError::ExtrapolateUnsupported |
Extrapolate::Enable on a strategy that can't extrapolate |
Interpolation-time (interpolate / batch interpolation):
| Error | Meaning |
|---|---|
InterpolateError::PointLength |
Query point has wrong dimensionality; carries a WrongLengthAt per offending point |
InterpolateError::OutOfBounds |
Query point is out of bounds while using Extrapolate::Error; carries an OutOfBoundsAt per offending coordinate |
InterpolateError::OutputLength |
batch_interpolate_into output slice length doesn't match the point count |
Using Owned and Borrowed (Viewed) Data
All interpolators support both owned and borrowed data via the generic D bound on
ndarray::Data.
Type aliases in the prelude
follow Rust idioms (like String vs &str and Vec vs &[T]), making ownership intent explicit.
For example, in 1-D:
-
Interp1D(owned data)- Default type for owned arrays
- Examples: struct fields, general use
use *; use *; let interp = new .unwrap; -
Interp1DView(borrowed data)- For viewed arrays (data borrowed from elsewhere)
- Examples: data lives in a larger struct, data is shared without copying
use *; use *; let x = array!; let f_x = array!; let interp = new .unwrap;
The same pattern applies to 2-D, 3-D, and N-D interpolators (Interp2D/Interp2DView, etc.).
Cargo Features
-
serde: support forserde1.xcargo add ninterp --features serdeBy default, arrays are written in
ndarray's built-in format, which is performant to parse and works with every serialization format (text and binary):You can also serialize interpolators using the nested-array format from
serde-ndim, which is far easier to read and hand-edit. This works for anyis_human_readableserde format (serializing to binary formats necessarily uses the standardndarraystyle).-
On fields, using the
serialize_nestedhelper function fromninterp::prelude:use *; -
Directly, using the
Nestedwrapper:use *; let json = to_string.unwrap; // {"grid":[[0.0,1.0],[0.0,1.0,2.0]],"values":[[0.0,1.0,2.0],[3.0,4.0,5.0]]}
Deserialization accepts either format, so this is purely a choice about what you write:
-
Prefer the default when deserialization is on a hot path: nested arrays cost roughly 20% more to read, since
ndarray's format carries the shape up front and can allocate exactly once, whileserde-ndimmust parse the shape from the nested array every read. -
Prefer
Nested/serialize_with = "serialize_nested"for config files and anything a human will look at, so long as the array read cost is worth it.
-
Examples
See examples in new method documentation:
Also see the examples directory for advanced examples:
- Swapping strategies at runtime:
swap_strategy.rs- Strategy enums (
strategy::enums::Strategy1DEnum/etc.):serde-compatible, custom strategies not supported Box<dyn Strategy1D>/etc. (dynamic dispatch): custom strategies supported, notserde-compatible, runtime cost
- Strategy enums (
- Swapping interpolators at runtime:
swap_interpolator.rsInterpolatorEnum:serde-compatible, custom strategies not supportedBox<dyn Interpolator>(dynamic dispatch): custom strategies supported, notserde-compatible, runtime cost
- Defining custom strategies:
custom_strategy.rs - Using transmutable (transparent) types such as
uom::si::Quantity:uom.rs