From 89e19751bf4b4bac7bfabe37ab9f9a8e86d3f03e Mon Sep 17 00:00:00 2001 From: Kyle Carow Date: Sat, 22 Aug 2026 11:12:35 -0600 Subject: [PATCH 1/8] create ninterp-uom --- Cargo.toml | 11 ++- ninterp-uom/Cargo.toml | 42 +++++++++ ninterp-uom/examples/uom.rs | 43 +++++++++ ninterp-uom/src/base_unit.rs | 28 ++++++ ninterp-uom/src/interpolator/mod.rs | 10 ++ ninterp-uom/src/interpolator/one/mod.rs | 108 ++++++++++++++++++++++ ninterp-uom/src/interpolator/three/mod.rs | 1 + ninterp-uom/src/interpolator/two/mod.rs | 1 + ninterp-uom/src/lib.rs | 37 ++++++++ 9 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 ninterp-uom/Cargo.toml create mode 100644 ninterp-uom/examples/uom.rs create mode 100644 ninterp-uom/src/base_unit.rs create mode 100644 ninterp-uom/src/interpolator/mod.rs create mode 100644 ninterp-uom/src/interpolator/one/mod.rs create mode 100644 ninterp-uom/src/interpolator/three/mod.rs create mode 100644 ninterp-uom/src/interpolator/two/mod.rs create mode 100644 ninterp-uom/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index ea02fa0..a421901 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,12 @@ +[workspace] +members = ["ninterp-uom"] + +[workspace.dependencies] +ninterp = { path = ".", version = "0.11.0" } +num-traits = { version = "0.2.15", default-features = false, features = [ + "libm", +] } + [package] name = "ninterp" version = "0.11.0" @@ -17,7 +26,7 @@ categories = ["mathematics"] [dependencies] dyn-clone = "1" ndarray = "0.17" -num-traits = "0.2.15" +num-traits = { workspace = true, version = "0.2.15" } serde = { version = "1.0.103", optional = true, features = ["derive"] } serde_unit_struct = { version = "0.1.3", optional = true } serde-ndim = { version = "2.2.1", optional = true, features = ["ndarray"] } diff --git a/ninterp-uom/Cargo.toml b/ninterp-uom/Cargo.toml new file mode 100644 index 0000000..a0fa419 --- /dev/null +++ b/ninterp-uom/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "ninterp-uom" +version = "0.1.0" +edition = "2024" + +[dependencies] +ninterp = { workspace = true, version = "0.11.0" } +uom = { version = "0.38.0", default-features = false, features = ["si"] } +num-traits = { workspace = true, version = "0.2.15" } + +[features] +default = ["autoconvert", "std", "f64"] + +autoconvert = ["uom/autoconvert"] +std = ["uom/std"] +serde = ["uom/serde", "ninterp/serde"] + +f32 = ["uom/f32"] +f64 = ["uom/f64"] + +usize = ["uom/usize"] +u8 = ["uom/u8"] +u16 = ["uom/u16"] +u32 = ["uom/u32"] +u64 = ["uom/u64"] +u128 = ["uom/u128"] +isize = ["uom/isize"] +i8 = ["uom/i8"] +i16 = ["uom/i16"] +i32 = ["uom/i32"] +i64 = ["uom/i64"] +i128 = ["uom/i128"] + +bigint = ["uom/bigint"] +biguint = ["uom/biguint"] +rational = ["uom/rational"] +rational32 = ["uom/rational32"] +rational64 = ["uom/rational64"] +bigrational = ["uom/bigrational"] + +complex32 = ["uom/complex32"] +complex64 = ["uom/complex64"] diff --git a/ninterp-uom/examples/uom.rs b/ninterp-uom/examples/uom.rs new file mode 100644 index 0000000..b18fbdb --- /dev/null +++ b/ninterp-uom/examples/uom.rs @@ -0,0 +1,43 @@ +use ninterp_uom::ndarray::prelude::*; +use ninterp_uom::prelude::*; + +use uom::si::power::kilowatt; +use uom::si::ratio::ratio; + +fn main() { + // f(x) = 0.25 kW + 0.5 kW * x, in `f64`, viewed (borrowed, zero-copy). + // Requires the `f64` feature (on by default). + #[cfg(feature = "f64")] + { + use uom::si::f64::{Power, Ratio}; + + let x = array![Ratio::new::(0.), Ratio::new::(1.)]; + let f_x = array![Power::new::(0.25), Power::new::(0.75)]; + + let interp: UomInterp1DView = + UomInterp1DView::new(x.view(), f_x.view(), strategy::Linear, Extrapolate::Error) + .unwrap(); + + // No `unsafe` at the call site, and the output comes back as a `Power`, not a + // bare `f64`. + let output = interp.interpolate(Ratio::new::(0.5)).unwrap(); + assert_eq!(output, Power::new::(0.5)); + } + + // The same wrapper, unmodified, over `f32` storage instead - proving `V` is generic, + // not just `Qx`/`Qv`. Requires the `f32` feature (`cargo run --example uom --features f32`). + #[cfg(feature = "f32")] + { + use uom::si::f32::{Power, Ratio}; + + let x = array![Ratio::new::(0.), Ratio::new::(1.)]; + let f_x = array![Power::new::(0.25), Power::new::(0.75)]; + + let interp: UomInterp1DView = + UomInterp1DView::new(x.view(), f_x.view(), strategy::Linear, Extrapolate::Error) + .unwrap(); + + let output = interp.interpolate(Ratio::new::(0.5)).unwrap(); + assert_eq!(output, Power::new::(0.5)); + } +} diff --git a/ninterp-uom/src/base_unit.rs b/ninterp-uom/src/base_unit.rs new file mode 100644 index 0000000..c565704 --- /dev/null +++ b/ninterp-uom/src/base_unit.rs @@ -0,0 +1,28 @@ +use super::*; + +/// A `uom` quantity backed by storage type `V`, convertible to/from its dimension's base +/// unit. Implemented for every `Quantity`, i.e. every `uom` quantity of every +/// unit system: `Length`, `Power`, `Ratio`, in `f32` or `f64`, all alike. +pub trait BaseUnit: Copy { + fn to_base(self) -> V; + fn from_base(value: V) -> Self; +} + +impl BaseUnit for Quantity +where + D: Dimension + ?Sized, + U: Units + ?Sized, + V: Num + Conversion + Copy, +{ + fn to_base(self) -> V { + self.value + } + + fn from_base(value: V) -> Self { + Quantity { + dimension: PhantomData, + units: PhantomData, + value, + } + } +} diff --git a/ninterp-uom/src/interpolator/mod.rs b/ninterp-uom/src/interpolator/mod.rs new file mode 100644 index 0000000..e15632a --- /dev/null +++ b/ninterp-uom/src/interpolator/mod.rs @@ -0,0 +1,10 @@ +//! Interpolator types, one module per dimensionality (mirroring `ninterp`'s own +//! `interpolator::{one, two, three}` layout). + +use super::*; + +pub mod one; +pub mod three; +pub mod two; + +pub use one::{UomInterp1D, UomInterp1DBase, UomInterp1DView}; diff --git a/ninterp-uom/src/interpolator/one/mod.rs b/ninterp-uom/src/interpolator/one/mod.rs new file mode 100644 index 0000000..d96a0fe --- /dev/null +++ b/ninterp-uom/src/interpolator/one/mod.rs @@ -0,0 +1,108 @@ +//! Zero-copy 1-D interpolation over `uom` quantities: generic over any dimension/unit, +//! any storage type (`f32`, `f64`, ...), and both owned and borrowed data. +//! +//! `Quantity` is `#[repr(transparent)]` over `V`; `dimension`/`units` are +//! `PhantomData` (zero-sized regardless of `D`/`U`), so `V` is the only field that +//! contributes to layout. `ArrayView1`/`Array1`'s own size doesn't depend on `A` +//! either (a view holds a thin pointer plus shape/strides; an owned array is +//! `Vec`-shaped), which is why `mem::transmute` type-checks even with `Qx`/`Qv`/`V` left +//! generic - unlike a transmute of a bare scalar type parameter, which fails to compile +//! (rustc can't prove two abstract types have equal size, only that *this particular* +//! wrapper struct's size is element-type-independent). That reasoning only goes through +//! when the concrete container (`OwnedRepr` or `ViewRepr`) is spelled out on both sides +//! of the transmute, not left as a fully abstract `D: Data` bound - so unlike the rest +//! of this module (struct definition, `interpolate`, all written once over generic `D`), +//! `new` itself needs two small `impl` blocks, one per concrete `D`. + +use super::*; + +/// 1-D interpolator over `uom` quantities: grid points of unit `Qx`, values of unit `Qv`, +/// both backed by storage representation `D` (`OwnedRepr` or `ViewRepr<&'a V>` - see +/// the [`UomInterp1D`]/[`UomInterp1DView`] aliases below). +#[derive(Clone)] +pub struct UomInterp1DBase +where + D: Data + RawDataClone + Clone, + D::Elem: PartialEq + Debug + Clone, + Qx: BaseUnit, + Qv: BaseUnit, + S: Clone, +{ + interp: Interp1DBase, + _units: PhantomData (Qx, Qv)>, +} + +/// Owned variant (see [`UomInterp1DBase`] for the generic form). +pub type UomInterp1D = UomInterp1DBase, Qx, Qv, S>; +/// Viewed variant (see [`UomInterp1DBase`] for the generic form). +pub type UomInterp1DView<'a, Qx, Qv, V, S> = UomInterp1DBase, Qx, Qv, S>; + +impl<'a, Qx, Qv, V, S> UomInterp1DView<'a, Qx, Qv, V, S> +where + Qx: BaseUnit, + Qv: BaseUnit, + V: Num + PartialOrd + Euclid + Copy + Debug + 'a, + S: Strategy1D> + Clone, +{ + /// Construct a viewed (borrowed, zero-copy) interpolator over `uom` quantity arrays. + pub fn new( + x: ArrayView1<'a, Qx>, + f_x: ArrayView1<'a, Qv>, + strategy: S, + extrapolate: Extrapolate, + ) -> Result { + // SAFETY: see module docs - `Qx`/`Qv` are `uom` quantities backed by `V`, + // `#[repr(transparent)]` over it, so reinterpreting the view's element type as + // `V` is sound and the view's shape/strides are unaffected. + let x: ArrayView1<'a, V> = unsafe { mem::transmute::, _>(x) }; + let f_x: ArrayView1<'a, V> = unsafe { mem::transmute::, _>(f_x) }; + Ok(Self { + interp: Interp1DView::new(x, f_x, strategy, extrapolate)?, + _units: PhantomData, + }) + } +} + +impl UomInterp1D +where + Qx: BaseUnit, + Qv: BaseUnit, + V: Num + PartialOrd + Euclid + Copy + Debug, + S: Strategy1D> + Clone, +{ + /// Construct an owned interpolator over `uom` quantity arrays. + pub fn new( + x: Array1, + f_x: Array1, + strategy: S, + extrapolate: Extrapolate, + ) -> Result { + // SAFETY: same reasoning as the view constructor above, applied to owned storage: + // `Array1` is `Vec`-shaped regardless of `A`, so the transmute only changes the + // element type, not the container's own layout. Dropping is unaffected too - + // `Quantity` has no `Drop` of its own, so dropping it is exactly dropping + // the wrapped `V`. + let x: Array1 = unsafe { mem::transmute::, _>(x) }; + let f_x: Array1 = unsafe { mem::transmute::, _>(f_x) }; + Ok(Self { + interp: Interp1D::new(x, f_x, strategy, extrapolate)?, + _units: PhantomData, + }) + } +} + +impl UomInterp1DBase +where + D: Data + RawDataClone + Clone, + D::Elem: Num + PartialOrd + Euclid + Copy + Debug, + Qx: BaseUnit, + Qv: BaseUnit, + S: Strategy1D + Clone, +{ + /// Interpolate at `point`, returning a value in `Qv`. + pub fn interpolate(&self, point: Qx) -> Result { + self.interp + .interpolate(&[point.to_base()]) + .map(Qv::from_base) + } +} diff --git a/ninterp-uom/src/interpolator/three/mod.rs b/ninterp-uom/src/interpolator/three/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ninterp-uom/src/interpolator/three/mod.rs @@ -0,0 +1 @@ + diff --git a/ninterp-uom/src/interpolator/two/mod.rs b/ninterp-uom/src/interpolator/two/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ninterp-uom/src/interpolator/two/mod.rs @@ -0,0 +1 @@ + diff --git a/ninterp-uom/src/lib.rs b/ninterp-uom/src/lib.rs new file mode 100644 index 0000000..7c7417a --- /dev/null +++ b/ninterp-uom/src/lib.rs @@ -0,0 +1,37 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +/// Re-exports [`ninterp::prelude`] alongside this crate's own types, so downstream +/// crates need only `use ninterp_uom::prelude::*;`. +pub mod prelude { + pub use ninterp::prelude::*; + + pub use crate::base_unit::BaseUnit; + pub use crate::interpolator::{UomInterp1D, UomInterp1DBase, UomInterp1DView}; +} + +mod base_unit; +pub mod interpolator; + +pub use base_unit::BaseUnit; +pub use interpolator::{UomInterp1D, UomInterp1DBase, UomInterp1DView}; + +pub(crate) use ninterp::error::{InterpolateError, ValidateError}; +pub(crate) use ninterp::prelude::*; +pub(crate) use ninterp::strategy::traits::Strategy1D; + +pub(crate) use core::fmt::Debug; +pub(crate) use core::marker::PhantomData; +pub(crate) use core::mem; + +pub use ninterp::ndarray; +pub(crate) use ninterp::ndarray::prelude::*; +pub(crate) use ninterp::ndarray::{Data, OwnedRepr, RawDataClone, ViewRepr}; + +pub use uom; +pub(crate) use uom::{ + si::{Dimension, Quantity, Units}, + Conversion, +}; + +pub use num_traits; +pub(crate) use num_traits::{Euclid, Num}; From ad7f72bc248c1bef7599e7cef41eb4c1661dc753 Mon Sep 17 00:00:00 2001 From: Kyle Carow Date: Sat, 22 Aug 2026 11:19:12 -0600 Subject: [PATCH 2/8] cargo fmt From 3a305871b591063018f17f8292063678a59d530e Mon Sep 17 00:00:00 2001 From: Kyle Carow Date: Sat, 22 Aug 2026 13:32:16 -0600 Subject: [PATCH 3/8] cargo fmt From 05379baf76fed3bd69b49c6fe57bb9d1df9ef3e0 Mon Sep 17 00:00:00 2001 From: Kyle Carow Date: Sat, 22 Aug 2026 13:32:59 -0600 Subject: [PATCH 4/8] cargo fmt again From 0276df13eceb6a5bd7085a5a0a40a2a27677d7b8 Mon Sep 17 00:00:00 2001 From: Kyle Carow Date: Sat, 22 Aug 2026 13:36:59 -0600 Subject: [PATCH 5/8] pre-commit hook fix --- ninterp-uom/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ninterp-uom/src/lib.rs b/ninterp-uom/src/lib.rs index 7c7417a..9db3b22 100644 --- a/ninterp-uom/src/lib.rs +++ b/ninterp-uom/src/lib.rs @@ -29,8 +29,8 @@ pub(crate) use ninterp::ndarray::{Data, OwnedRepr, RawDataClone, ViewRepr}; pub use uom; pub(crate) use uom::{ - si::{Dimension, Quantity, Units}, Conversion, + si::{Dimension, Quantity, Units}, }; pub use num_traits; From b23f3c5bdb112aeec2c8de50380675f72ecee27a Mon Sep 17 00:00:00 2001 From: Kyle Carow Date: Sat, 22 Aug 2026 14:02:14 -0600 Subject: [PATCH 6/8] rename for consistency --- ninterp-uom/src/interpolator/one/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ninterp-uom/src/interpolator/one/mod.rs b/ninterp-uom/src/interpolator/one/mod.rs index d96a0fe..df7120b 100644 --- a/ninterp-uom/src/interpolator/one/mod.rs +++ b/ninterp-uom/src/interpolator/one/mod.rs @@ -28,7 +28,7 @@ where Qv: BaseUnit, S: Clone, { - interp: Interp1DBase, + inner: Interp1DBase, _units: PhantomData (Qx, Qv)>, } @@ -57,7 +57,7 @@ where let x: ArrayView1<'a, V> = unsafe { mem::transmute::, _>(x) }; let f_x: ArrayView1<'a, V> = unsafe { mem::transmute::, _>(f_x) }; Ok(Self { - interp: Interp1DView::new(x, f_x, strategy, extrapolate)?, + inner: Interp1DView::new(x, f_x, strategy, extrapolate)?, _units: PhantomData, }) } @@ -85,7 +85,7 @@ where let x: Array1 = unsafe { mem::transmute::, _>(x) }; let f_x: Array1 = unsafe { mem::transmute::, _>(f_x) }; Ok(Self { - interp: Interp1D::new(x, f_x, strategy, extrapolate)?, + inner: Interp1D::new(x, f_x, strategy, extrapolate)?, _units: PhantomData, }) } @@ -101,7 +101,7 @@ where { /// Interpolate at `point`, returning a value in `Qv`. pub fn interpolate(&self, point: Qx) -> Result { - self.interp + self.inner .interpolate(&[point.to_base()]) .map(Qv::from_base) } From 3ba96a1f3c5490ff023b654eb649976f3dd36021 Mon Sep 17 00:00:00 2001 From: Kyle Carow Date: Sat, 22 Aug 2026 14:05:40 -0600 Subject: [PATCH 7/8] remove unnecessary deps --- Cargo.toml | 2 +- ninterp-uom/Cargo.toml | 3 +-- ninterp-uom/src/lib.rs | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1e11ee1..1f9e580 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = ["ninterp-uom"] [workspace.dependencies] -ninterp = { path = ".", version = "0.11.0" } +ninterp = { path = ".", version = "0.11.1" } num-traits = { version = "0.2.15", default-features = false, features = [ "libm", ] } diff --git a/ninterp-uom/Cargo.toml b/ninterp-uom/Cargo.toml index a0fa419..116905e 100644 --- a/ninterp-uom/Cargo.toml +++ b/ninterp-uom/Cargo.toml @@ -4,9 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] -ninterp = { workspace = true, version = "0.11.0" } +ninterp = { workspace = true, version = "0.11.1" } uom = { version = "0.38.0", default-features = false, features = ["si"] } -num-traits = { workspace = true, version = "0.2.15" } [features] default = ["autoconvert", "std", "f64"] diff --git a/ninterp-uom/src/lib.rs b/ninterp-uom/src/lib.rs index 9db3b22..360c1ca 100644 --- a/ninterp-uom/src/lib.rs +++ b/ninterp-uom/src/lib.rs @@ -33,5 +33,5 @@ pub(crate) use uom::{ si::{Dimension, Quantity, Units}, }; -pub use num_traits; -pub(crate) use num_traits::{Euclid, Num}; +pub use ninterp::num_traits; +pub(crate) use ninterp::num_traits::{Euclid, Num}; From 75ff13e7ab63464cce451153a3addbeaf47a24f0 Mon Sep 17 00:00:00 2001 From: Kyle Carow Date: Sat, 22 Aug 2026 17:34:42 -0600 Subject: [PATCH 8/8] wip uom wrappers --- .github/workflows/test.yaml | 11 +- Cargo.toml | 6 +- ninterp-uom/Cargo.toml | 6 +- ninterp-uom/src/interpolator/mod.rs | 300 ++++++++++++++++++++ ninterp-uom/src/interpolator/one/mod.rs | 33 ++- ninterp-uom/src/interpolator/one/tests.rs | 154 ++++++++++ ninterp-uom/src/interpolator/three/mod.rs | 130 +++++++++ ninterp-uom/src/interpolator/three/tests.rs | 160 +++++++++++ ninterp-uom/src/interpolator/two/mod.rs | 122 ++++++++ ninterp-uom/src/interpolator/two/tests.rs | 132 +++++++++ ninterp-uom/src/interpolator/zero/mod.rs | 74 +++++ ninterp-uom/src/interpolator/zero/tests.rs | 34 +++ ninterp-uom/src/lib.rs | 27 +- 13 files changed, 1175 insertions(+), 14 deletions(-) create mode 100644 ninterp-uom/src/interpolator/one/tests.rs create mode 100644 ninterp-uom/src/interpolator/three/tests.rs create mode 100644 ninterp-uom/src/interpolator/two/tests.rs create mode 100644 ninterp-uom/src/interpolator/zero/mod.rs create mode 100644 ninterp-uom/src/interpolator/zero/tests.rs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 67f4ba9..04e73cf 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -24,17 +24,22 @@ jobs: run: cargo fmt --check - name: clippy - run: cargo clippy --all-features --all-targets -- -D warnings + run: cargo clippy --workspace --all-features --all-targets -- -D warnings - name: doc - run: cargo doc --all-features --no-deps + run: cargo doc --workspace --all-features --no-deps env: RUSTDOCFLAGS: -D warnings - name: test - # Note: this will be unwieldy if there are many features, but for now it is fine. + # ninterp crate has few enough features that this isn't too hefty run: cargo hack test --feature-powerset --verbose + - name: test ninterp-uom + # Powerset over autoconvert/serde/std only; f64 pinned as the required storage + # type, other ~21 storage-type alternatives excluded (uom passthrough, not this crate's logic). + run: cargo hack test -p ninterp-uom --feature-powerset --features f64 --exclude-features f32,usize,u8,u16,u32,u64,u128,isize,i8,i16,i32,i64,i128,bigint,biguint,rational,rational32,rational64,bigrational,complex32,complex64 --verbose + - name: install no_std target run: rustup target add thumbv7m-none-eabi diff --git a/Cargo.toml b/Cargo.toml index 1f9e580..4469e94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ ninterp = { path = ".", version = "0.11.1" } num-traits = { version = "0.2.15", default-features = false, features = [ "libm", ] } +serde = { version = "1.0.103", default-features = false } [package] name = "ninterp" @@ -29,10 +30,7 @@ categories = ["mathematics"] dyn-clone = "1" ndarray = { version = "0.17", default-features = false } num-traits = { workspace = true, version = "0.2.15" } -serde = { version = "1.0.103", optional = true, default-features = false, features = [ - "derive", - "alloc", -] } +serde = { workspace = true, optional = true, features = ["derive", "alloc"] } serde_unit_struct = { version = "0.1.3", optional = true } serde-ndim = { version = "2.2.1", optional = true, default-features = false, features = [ "ndarray", diff --git a/ninterp-uom/Cargo.toml b/ninterp-uom/Cargo.toml index 116905e..ca1be30 100644 --- a/ninterp-uom/Cargo.toml +++ b/ninterp-uom/Cargo.toml @@ -6,13 +6,17 @@ edition = "2024" [dependencies] ninterp = { workspace = true, version = "0.11.1" } uom = { version = "0.38.0", default-features = false, features = ["si"] } +serde = { workspace = true, optional = true } + +[dev-dependencies] +serde_json = "1.0.140" [features] default = ["autoconvert", "std", "f64"] autoconvert = ["uom/autoconvert"] std = ["uom/std"] -serde = ["uom/serde", "ninterp/serde"] +serde = ["dep:serde", "uom/serde", "ninterp/serde"] f32 = ["uom/f32"] f64 = ["uom/f64"] diff --git a/ninterp-uom/src/interpolator/mod.rs b/ninterp-uom/src/interpolator/mod.rs index e15632a..5ff768b 100644 --- a/ninterp-uom/src/interpolator/mod.rs +++ b/ninterp-uom/src/interpolator/mod.rs @@ -1,10 +1,310 @@ //! Interpolator types, one module per dimensionality (mirroring `ninterp`'s own //! `interpolator::{one, two, three}` layout). +//! +//! Covers **0-D through 3-D**. Three things core `ninterp` has are intentionally *not* +//! mirrored here, all for the same underlying reason: each needs one element type shared +//! across every grid axis (and, for the last one, across every dimensionality too), which +//! per-axis `uom` units don't provide: +//! - **`N`-D** (`ninterp::interpolator::InterpND`): a runtime-variable axis count can't +//! carry a per-axis Rust type, and the only fallback (one shared unit for every axis) +//! wasn't judged worth shipping. +//! - **The dyn-compatible `Interpolator` trait and `AnyInterpolator`**: both are +//! built on `interpolate(&[T]) -> T`, one `T` for every axis *and* the output. +//! - **`InterpolatorEnumBase`** (`ninterp`'s runtime-swappable `Interp0D|1D|2D|3D|ND` +//! enum): same shape problem, one level up. use super::*; pub mod one; pub mod three; pub mod two; +pub mod zero; pub use one::{UomInterp1D, UomInterp1DBase, UomInterp1DView}; +pub use three::{UomInterp3D, UomInterp3DBase, UomInterp3DView}; +pub use two::{UomInterp2D, UomInterp2DBase, UomInterp2DView}; +pub use zero::UomInterp0D; + +/// Generates the inherent methods shared by every `UomInterp{1,2,3}DBase`, beyond `new` +/// and `interpolate` (which stay hand-written per dimensionality: grid construction and +/// the point signature genuinely differ in arity). Invoked from inside the same +/// `impl ... where ...` block that hosts `interpolate`, so it inherits +/// that block's bounds. +/// +/// `$Qi`/`$qi` are parallel axis type/value identifier lists (`Qx` / `x` for 1-D; `Qx, Qy` +/// / `x, y` for 2-D; etc). This falls out correctly for 1-D too, without a separate case: +/// `($($Qi),+)` with one identifier is just `Qx` (Rust parens around a single +/// type/pattern are grouping, not a 1-tuple), matching the existing bare-`Qx` point type +/// rather than an awkward `(Qx,)`. +macro_rules! uom_interp_common_methods { + ( + $Base:ident, + $Strategy:ident, + $Viewed:ty, + $Owned:ty, + ($($Qi:ident),+ $(,)?), + ($($qi:ident),+ $(,)?) + ) => { + /// Interpolate without bounds/extrapolation checks, for use in hot loops where + /// the caller has already checked bounds or knows that extrapolation handling + /// is not needed. + pub fn interpolate_fast(&self, $($qi: $Qi),+) -> Qv { + Qv::from_base(self.inner.interpolate_fast(&[$($qi.to_base()),+])) + } + + /// Interpolate at each of several points, sharing one grid across all of them. + #[allow(unused_parens)] + pub fn batch_interpolate( + &self, + points: &[($($Qi),+)], + ) -> Result, InterpolateError> { + let raw_points: Vec<_> = points + .iter() + .map(|&($($qi),+)| [$($qi.to_base()),+]) + .collect(); + let out = self.inner.batch_interpolate(&raw_points)?; + // SAFETY: `Qv` is `#[repr(transparent)]` over `D::Elem` (same size/align, + // no `Drop` of its own - see `one` module docs for the full argument), so a + // `Vec` is layout-identical to a `Vec`; re-tagging the element + // type in place is sound and avoids a second allocation/copy. + Ok(unsafe { mem::transmute::, Vec>(out) }) + } + + /// Interpolate at each of several points, writing results into `out` instead of + /// allocating. + #[allow(unused_parens)] + pub fn batch_interpolate_into( + &self, + points: &[($($Qi),+)], + out: &mut [Qv], + ) -> Result<(), InterpolateError> { + let raw_points: Vec<_> = points + .iter() + .map(|&($($qi),+)| [$($qi.to_base()),+]) + .collect(); + // SAFETY: same reasoning as `batch_interpolate` above, applied to a + // borrowed mutable slice instead of an owned `Vec`. + let raw_out: &mut [D::Elem] = unsafe { mem::transmute::<&mut [Qv], _>(out) }; + self.inner.batch_interpolate_into(&raw_points, raw_out) + } + + /// Unchecked batched [`Self::interpolate_fast`], assuming every point is valid. + #[allow(unused_parens)] + pub fn batch_interpolate_fast(&self, points: &[($($Qi),+)]) -> Vec { + let raw_points: Vec<_> = points + .iter() + .map(|&($($qi),+)| [$($qi.to_base()),+]) + .collect(); + let out = self.inner.batch_interpolate_fast(&raw_points); + // SAFETY: see `batch_interpolate`. + unsafe { mem::transmute::, Vec>(out) } + } + + /// Unchecked [`Self::batch_interpolate_into`], assuming every point is valid. + #[allow(unused_parens)] + pub fn batch_interpolate_fast_into(&self, points: &[($($Qi),+)], out: &mut [Qv]) { + let raw_points: Vec<_> = points + .iter() + .map(|&($($qi),+)| [$($qi.to_base()),+]) + .collect(); + // SAFETY: see `batch_interpolate_into`. + let raw_out: &mut [D::Elem] = unsafe { mem::transmute::<&mut [Qv], _>(out) }; + self.inner.batch_interpolate_fast_into(&raw_points, raw_out) + } + + /// Re-run the strategy's `validate` against the current data. + /// + /// `new`, `set_strategy`, and [`Self::validate`] already call this internally, + /// so this is only needed after mutating `self.inner`'s `data`/`strategy` + /// fields directly. + pub fn validate_strategy(&self) -> Result<(), ValidateError> { + self.inner.validate_strategy() + } + + /// Re-run the strategy's `init` against the current data. + /// + /// `new` and `set_strategy` already call this internally, so this is only + /// needed after bypassing them via `self.inner`. + pub fn init_strategy(&mut self) -> Result<(), ValidateError> { + self.inner.init_strategy() + } + + /// Check that `extrapolate` is applicable to the current strategy. Takes the + /// setting as an argument (rather than reading `self`) so a candidate can be + /// vetted before storing it. + /// + /// Stays raw `D::Elem`, not uom-typed: core's own `Extrapolate` doesn't yet + /// distinguish grid-coordinate values from interpolated-output values, so + /// there's no single `Qx`/`Qv` this could soundly be. + pub fn validate_extrapolate( + &self, + extrapolate: &Extrapolate, + ) -> Result<(), ValidateError> { + self.inner.validate_extrapolate(extrapolate) + } + + /// Update `extrapolate` at runtime, checking it's applicable to the current + /// strategy first. + pub fn set_extrapolate( + &mut self, + extrapolate: Extrapolate, + ) -> Result<(), ValidateError> { + self.inner.set_extrapolate(extrapolate) + } + + /// Interpolator dimensionality. + #[inline] + pub fn ndim(&self) -> usize { + self.inner.ndim() + } + + /// Validate interpolator data. + pub fn validate(&self) -> Result<(), ValidateError> { + self.inner.validate() + } + + /// Return an interpolator with viewed data. + pub fn view(&self) -> $Viewed + where + S: for<'a> $Strategy>, + D::Elem: Clone, + { + $Base { + inner: self.inner.view(), + _units: PhantomData, + } + } + + /// Turn the interpolator into an owned variant, cloning the array elements if + /// necessary. + pub fn into_owned(self) -> $Owned + where + S: $Strategy>, + D::Elem: Clone, + { + $Base { + inner: self.inner.into_owned(), + _units: PhantomData, + } + } + }; +} +pub(crate) use uom_interp_common_methods; + +/// Generates a hand-written `PartialEq` (not derived: see [`uom_interp_common_methods`]'s +/// neighbors below for why) forwarding straight to `self.inner`. +macro_rules! uom_interp_partial_eq { + ($Base:ident, $Inner:ident, ($($Qi:ident),+ $(,)?)) => { + impl PartialEq for $Base + where + D: Data + RawDataClone + Clone, + D::Elem: PartialEq + Debug + Clone, + $($Qi: BaseUnit,)* + Qv: BaseUnit, + S: Clone, + $Inner: PartialEq, + { + fn eq(&self, other: &Self) -> bool { + self.inner == other.inner + } + } + }; +} +pub(crate) use uom_interp_partial_eq; + +/// Generates the `Box>`-backed inherent `set_strategy`. +macro_rules! uom_interp_set_strategy_box { + ($Base:ident, $Strategy:ident, ($($Qi:ident),+ $(,)?)) => { + impl $Base>> + where + D: Data + RawDataClone + Clone, + D::Elem: PartialEq + Debug + Clone, + $($Qi: BaseUnit,)* + Qv: BaseUnit, + { + /// Update strategy at runtime, calling `init` on the new strategy against + /// the current data. + /// + /// To swap in a strategy without re-running `init`, mutate `self.inner`'s + /// `strategy` field directly instead. + pub fn set_strategy( + &mut self, + strategy: Box>, + ) -> Result<(), ValidateError> { + self.inner.set_strategy(strategy) + } + } + }; +} +pub(crate) use uom_interp_set_strategy_box; + +/// Generates the `$StrategyEnum`-backed inherent `set_strategy`. +macro_rules! uom_interp_set_strategy_enum { + ($Base:ident, $StrategyEnum:ident, ($($Qi:ident),+ $(,)?)) => { + impl $Base> + where + D: Data + RawDataClone + Clone, + D::Elem: Float + Debug + Clone, + $($Qi: BaseUnit,)* + Qv: BaseUnit, + { + /// Update strategy at runtime, calling `init` on the new strategy against + /// the current data. + pub fn set_strategy( + &mut self, + strategy: impl Into<$StrategyEnum>, + ) -> Result<(), ValidateError> { + self.inner.set_strategy(strategy) + } + } + }; +} +pub(crate) use uom_interp_set_strategy_enum; + +/// Generates `Serialize`/`Deserialize`, gated on `feature = "serde"`, that delegate +/// straight to `self.inner`'s existing (core-provided) serde impl. The wire format is +/// identical to a bare `ninterp` interpolator - units are compile-time-only via +/// `Qx`/`Qy`/`Qz`/`Qv`, never serialized. +macro_rules! uom_interp_serde { + ($Base:ident, $Inner:ident, ($($Qi:ident),+ $(,)?)) => { + #[cfg(feature = "serde")] + impl Serialize for $Base + where + D: Data + RawDataClone + Clone, + D::Elem: PartialEq + Debug + Clone, + $($Qi: BaseUnit,)* + Qv: BaseUnit, + S: Clone, + $Inner: Serialize, + { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: Serializer, + { + self.inner.serialize(serializer) + } + } + + #[cfg(feature = "serde")] + impl<'de, D, $($Qi,)* Qv, S> Deserialize<'de> for $Base + where + D: Data + RawDataClone + Clone, + D::Elem: PartialEq + Debug + Clone, + $($Qi: BaseUnit,)* + Qv: BaseUnit, + S: Clone, + $Inner: Deserialize<'de>, + { + fn deserialize(deserializer: De) -> Result + where + De: Deserializer<'de>, + { + Ok(Self { + inner: $Inner::deserialize(deserializer)?, + _units: PhantomData, + }) + } + } + }; +} +pub(crate) use uom_interp_serde; diff --git a/ninterp-uom/src/interpolator/one/mod.rs b/ninterp-uom/src/interpolator/one/mod.rs index df7120b..1b2708c 100644 --- a/ninterp-uom/src/interpolator/one/mod.rs +++ b/ninterp-uom/src/interpolator/one/mod.rs @@ -16,10 +16,13 @@ use super::*; +#[cfg(all(test, feature = "f64"))] +mod tests; + /// 1-D interpolator over `uom` quantities: grid points of unit `Qx`, values of unit `Qv`, /// both backed by storage representation `D` (`OwnedRepr` or `ViewRepr<&'a V>` - see /// the [`UomInterp1D`]/[`UomInterp1DView`] aliases below). -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct UomInterp1DBase where D: Data + RawDataClone + Clone, @@ -28,7 +31,19 @@ where Qv: BaseUnit, S: Clone, { - inner: Interp1DBase, + /// The wrapped, unit-erased `ninterp` interpolator - an escape hatch, mirroring + /// core's own public `data`/`strategy`/`extrapolate` fields on `Interp1DBase`. The + /// validated path (`new`, `set_strategy`, `set_extrapolate`, ...) is the default; use + /// this directly only when you explicitly want to bypass it (e.g. mutating + /// `strategy`/`extrapolate` without re-validating, or reaching `data` for `Debug` + /// output). + /// + /// **Caveat**: `inner.data.grid`/`values` are stored in each dimension's `uom` *base* + /// unit (e.g. meters for `Length`), not necessarily the unit originally used to + /// construct the array - `Quantity::new::(3.0)` stores `0.9144`. Reading raw + /// numbers here gives you that base-unit value with no `Qx`/`Qv` left to say which + /// unit it is. + pub inner: Interp1DBase, _units: PhantomData (Qx, Qv)>, } @@ -105,4 +120,18 @@ where .interpolate(&[point.to_base()]) .map(Qv::from_base) } + + uom_interp_common_methods!( + UomInterp1DBase, + Strategy1D, + UomInterp1DView<'_, Qx, Qv, D::Elem, S>, + UomInterp1D, + (Qx), + (point) + ); } + +uom_interp_partial_eq!(UomInterp1DBase, Interp1DBase, (Qx)); +uom_interp_set_strategy_box!(UomInterp1DBase, Strategy1D, (Qx)); +uom_interp_set_strategy_enum!(UomInterp1DBase, Strategy1DEnum, (Qx)); +uom_interp_serde!(UomInterp1DBase, Interp1DBase, (Qx)); diff --git a/ninterp-uom/src/interpolator/one/tests.rs b/ninterp-uom/src/interpolator/one/tests.rs new file mode 100644 index 0000000..eb92b4a --- /dev/null +++ b/ninterp-uom/src/interpolator/one/tests.rs @@ -0,0 +1,154 @@ +use super::*; +use uom::si::f64::{Power, Ratio}; +use uom::si::power::kilowatt; +use uom::si::ratio::ratio; + +fn build() -> UomInterp1D { + UomInterp1D::new( + array![ + Ratio::new::(0.), + Ratio::new::(1.), + Ratio::new::(2.), + ], + array![ + Power::new::(0.), + Power::new::(1.), + Power::new::(2.), + ], + strategy::Linear, + Extrapolate::Error, + ) + .unwrap() +} + +#[test] +fn interpolate_matches_linear() { + let interp = build(); + assert_eq!( + interp.interpolate(Ratio::new::(0.5)).unwrap(), + Power::new::(0.5) + ); + assert_eq!( + interp.interpolate_fast(Ratio::new::(0.5)), + Power::new::(0.5) + ); +} + +#[test] +fn view_and_into_owned_round_trip() { + let owned = build(); + let point = Ratio::new::(1.5); + let viewed = owned.view(); + assert_eq!(viewed.interpolate(point), owned.interpolate(point)); + let back = viewed.into_owned(); + assert_eq!(back.interpolate(point), owned.interpolate(point)); +} + +#[test] +fn batch_interpolate_matches_loop() { + let interp = build(); + let points = [ + Ratio::new::(0.25), + Ratio::new::(1.5), + Ratio::new::(1.75), + ]; + let batched = interp.batch_interpolate(&points).unwrap(); + let looped: Vec = points + .iter() + .map(|&p| interp.interpolate(p).unwrap()) + .collect(); + assert_eq!(batched, looped); + assert_eq!(interp.batch_interpolate_fast(&points), looped); + + let mut into = vec![Power::new::(0.); points.len()]; + interp.batch_interpolate_into(&points, &mut into).unwrap(); + assert_eq!(into, looped); + interp.batch_interpolate_fast_into(&points, &mut into); + assert_eq!(into, looped); +} + +#[test] +fn set_strategy_box_changes_result() { + let mut interp: UomInterp1D>>> = + UomInterp1D::new( + array![Ratio::new::(0.), Ratio::new::(1.)], + array![Power::new::(0.), Power::new::(1.)], + Box::new(strategy::Linear) as Box>>, + Extrapolate::Error, + ) + .unwrap(); + let point = Ratio::new::(0.25); + assert_eq!( + interp.interpolate(point).unwrap(), + Power::new::(0.25) + ); + interp.set_strategy(Box::new(strategy::Nearest)).unwrap(); + assert_eq!( + interp.interpolate(point).unwrap(), + Power::new::(0.) + ); +} + +#[test] +fn set_strategy_enum_changes_result() { + let mut interp: UomInterp1D> = UomInterp1D::new( + array![Ratio::new::(0.), Ratio::new::(1.)], + array![Power::new::(0.), Power::new::(1.)], + strategy::Linear.into(), + Extrapolate::Error, + ) + .unwrap(); + let point = Ratio::new::(0.25); + assert_eq!( + interp.interpolate(point).unwrap(), + Power::new::(0.25) + ); + interp.set_strategy(strategy::Nearest).unwrap(); + assert_eq!( + interp.interpolate(point).unwrap(), + Power::new::(0.) + ); +} + +#[test] +fn extrapolate_and_validate() { + let mut interp = build(); + assert_eq!(interp.ndim(), 1); + assert!(interp.validate().is_ok()); + assert!(interp.validate_extrapolate(&Extrapolate::Clamp).is_ok()); + assert!(interp.interpolate(Ratio::new::(5.)).is_err()); + interp.set_extrapolate(Extrapolate::Clamp).unwrap(); + assert_eq!( + interp.interpolate(Ratio::new::(5.)).unwrap(), + Power::new::(2.) + ); + interp.validate_strategy().unwrap(); + interp.init_strategy().unwrap(); +} + +#[test] +fn inner_escape_hatch_is_reachable() { + let mut interp = build(); + // Mutate the raw, unit-erased interpolator directly rather than going through + // `set_strategy`, then re-run `init_strategy` (mirrors core's own documented usage + // of its public `strategy` field). + interp.inner.strategy = strategy::Linear; + interp.init_strategy().unwrap(); + // Base-unit caveat: the grid was built from dimensionless `Ratio`s, so the stored + // raw value equals what was typed in here (not always true in general). + assert_eq!(interp.inner.data.grid[0][0], 0.); +} + +#[test] +fn partial_eq() { + assert_eq!(build(), build()); +} + +#[test] +#[cfg(feature = "serde")] +fn serde_round_trip() { + let interp = build(); + let json = serde_json::to_string(&interp).unwrap(); + let de: UomInterp1D = serde_json::from_str(&json).unwrap(); + assert_eq!(interp, de); +} diff --git a/ninterp-uom/src/interpolator/three/mod.rs b/ninterp-uom/src/interpolator/three/mod.rs index 8b13789..95ecfc7 100644 --- a/ninterp-uom/src/interpolator/three/mod.rs +++ b/ninterp-uom/src/interpolator/three/mod.rs @@ -1 +1,131 @@ +//! Zero-copy 3-D interpolation over `uom` quantities: `Qx`/`Qy`/`Qz` are independent +//! per-axis units. See the [`one`] module docs for the transmute-soundness +//! argument every dimensionality here relies on. +use super::*; + +#[cfg(all(test, feature = "f64"))] +mod tests; + +/// 3-D interpolator over `uom` quantities: grid points of unit `Qx`/`Qy`/`Qz`, values of +/// unit `Qv`, all backed by storage representation `D` (`OwnedRepr` or +/// `ViewRepr<&'a V>` - see the [`UomInterp3D`]/[`UomInterp3DView`] aliases below). +#[derive(Clone, Debug)] +pub struct UomInterp3DBase +where + D: Data + RawDataClone + Clone, + D::Elem: PartialEq + Debug + Clone, + Qx: BaseUnit, + Qy: BaseUnit, + Qz: BaseUnit, + Qv: BaseUnit, + S: Clone, +{ + /// The wrapped, unit-erased `ninterp` interpolator - see the `inner` field on + /// [`UomInterp1DBase`] for the escape-hatch rationale + /// and the base-unit caveat. + pub inner: Interp3DBase, + #[allow(clippy::type_complexity)] + _units: PhantomData (Qx, Qy, Qz, Qv)>, +} + +/// Owned variant (see [`UomInterp3DBase`] for the generic form). +pub type UomInterp3D = UomInterp3DBase, Qx, Qy, Qz, Qv, S>; +/// Viewed variant (see [`UomInterp3DBase`] for the generic form). +pub type UomInterp3DView<'a, Qx, Qy, Qz, Qv, V, S> = + UomInterp3DBase, Qx, Qy, Qz, Qv, S>; + +impl<'a, Qx, Qy, Qz, Qv, V, S> UomInterp3DView<'a, Qx, Qy, Qz, Qv, V, S> +where + Qx: BaseUnit, + Qy: BaseUnit, + Qz: BaseUnit, + Qv: BaseUnit, + V: Num + PartialOrd + Euclid + Copy + Debug + 'a, + S: Strategy3D> + Clone, +{ + /// Construct a viewed (borrowed, zero-copy) interpolator over `uom` quantity arrays. + pub fn new( + x: ArrayView1<'a, Qx>, + y: ArrayView1<'a, Qy>, + z: ArrayView1<'a, Qz>, + f_xyz: ArrayView3<'a, Qv>, + strategy: S, + extrapolate: Extrapolate, + ) -> Result { + // SAFETY: see `one` module docs - `Qx`/`Qy`/`Qz`/`Qv` are `uom` quantities + // backed by `V`, `#[repr(transparent)]` over it, so reinterpreting each view's + // element type as `V` is sound and each view's shape/strides are unaffected. + let x: ArrayView1<'a, V> = unsafe { mem::transmute::, _>(x) }; + let y: ArrayView1<'a, V> = unsafe { mem::transmute::, _>(y) }; + let z: ArrayView1<'a, V> = unsafe { mem::transmute::, _>(z) }; + let f_xyz: ArrayView3<'a, V> = unsafe { mem::transmute::, _>(f_xyz) }; + Ok(Self { + inner: Interp3DView::new(x, y, z, f_xyz, strategy, extrapolate)?, + _units: PhantomData, + }) + } +} + +impl UomInterp3D +where + Qx: BaseUnit, + Qy: BaseUnit, + Qz: BaseUnit, + Qv: BaseUnit, + V: Num + PartialOrd + Euclid + Copy + Debug, + S: Strategy3D> + Clone, +{ + /// Construct an owned interpolator over `uom` quantity arrays. + pub fn new( + x: Array1, + y: Array1, + z: Array1, + f_xyz: Array3, + strategy: S, + extrapolate: Extrapolate, + ) -> Result { + // SAFETY: same reasoning as the view constructor above, applied to owned + // storage. + let x: Array1 = unsafe { mem::transmute::, _>(x) }; + let y: Array1 = unsafe { mem::transmute::, _>(y) }; + let z: Array1 = unsafe { mem::transmute::, _>(z) }; + let f_xyz: Array3 = unsafe { mem::transmute::, _>(f_xyz) }; + Ok(Self { + inner: Interp3D::new(x, y, z, f_xyz, strategy, extrapolate)?, + _units: PhantomData, + }) + } +} + +impl UomInterp3DBase +where + D: Data + RawDataClone + Clone, + D::Elem: Num + PartialOrd + Euclid + Copy + Debug, + Qx: BaseUnit, + Qy: BaseUnit, + Qz: BaseUnit, + Qv: BaseUnit, + S: Strategy3D + Clone, +{ + /// Interpolate at `(x, y, z)`, returning a value in `Qv`. + pub fn interpolate(&self, x: Qx, y: Qy, z: Qz) -> Result { + self.inner + .interpolate(&[x.to_base(), y.to_base(), z.to_base()]) + .map(Qv::from_base) + } + + uom_interp_common_methods!( + UomInterp3DBase, + Strategy3D, + UomInterp3DView<'_, Qx, Qy, Qz, Qv, D::Elem, S>, + UomInterp3D, + (Qx, Qy, Qz), + (x, y, z) + ); +} + +uom_interp_partial_eq!(UomInterp3DBase, Interp3DBase, (Qx, Qy, Qz)); +uom_interp_set_strategy_box!(UomInterp3DBase, Strategy3D, (Qx, Qy, Qz)); +uom_interp_set_strategy_enum!(UomInterp3DBase, Strategy3DEnum, (Qx, Qy, Qz)); +uom_interp_serde!(UomInterp3DBase, Interp3DBase, (Qx, Qy, Qz)); diff --git a/ninterp-uom/src/interpolator/three/tests.rs b/ninterp-uom/src/interpolator/three/tests.rs new file mode 100644 index 0000000..a08f2d7 --- /dev/null +++ b/ninterp-uom/src/interpolator/three/tests.rs @@ -0,0 +1,160 @@ +use super::*; +use uom::si::f64::{Length, Power, Ratio, Time}; +use uom::si::length::meter; +use uom::si::power::kilowatt; +use uom::si::ratio::ratio; +use uom::si::time::second; + +fn build() -> UomInterp3D { + UomInterp3D::new( + array![Ratio::new::(0.), Ratio::new::(1.)], + array![Time::new::(0.), Time::new::(1.)], + array![Length::new::(0.), Length::new::(1.)], + array![ + [ + [Power::new::(0.), Power::new::(1.)], + [Power::new::(1.), Power::new::(2.)], + ], + [ + [Power::new::(1.), Power::new::(2.)], + [Power::new::(2.), Power::new::(3.)], + ], + ], + strategy::Linear, + Extrapolate::Error, + ) + .unwrap() +} + +#[test] +fn interpolate_matches_linear() { + let interp = build(); + let (x, y, z) = ( + Ratio::new::(0.5), + Time::new::(0.5), + Length::new::(0.5), + ); + assert_eq!( + interp.interpolate(x, y, z).unwrap(), + Power::new::(1.5) + ); + assert_eq!( + interp.interpolate_fast(x, y, z), + Power::new::(1.5) + ); +} + +#[test] +fn view_and_into_owned_round_trip() { + let owned = build(); + let (x, y, z) = ( + Ratio::new::(0.25), + Time::new::(0.75), + Length::new::(0.5), + ); + let viewed = owned.view(); + assert_eq!(viewed.interpolate(x, y, z), owned.interpolate(x, y, z)); + let back = viewed.into_owned(); + assert_eq!(back.interpolate(x, y, z), owned.interpolate(x, y, z)); +} + +#[test] +fn batch_interpolate_matches_loop() { + let interp = build(); + let points = [ + ( + Ratio::new::(0.25), + Time::new::(0.25), + Length::new::(0.25), + ), + ( + Ratio::new::(0.5), + Time::new::(0.75), + Length::new::(1.), + ), + ]; + let batched = interp.batch_interpolate(&points).unwrap(); + let looped: Vec = points + .iter() + .map(|&(x, y, z)| interp.interpolate(x, y, z).unwrap()) + .collect(); + assert_eq!(batched, looped); + assert_eq!(interp.batch_interpolate_fast(&points), looped); + + let mut into = vec![Power::new::(0.); points.len()]; + interp.batch_interpolate_into(&points, &mut into).unwrap(); + assert_eq!(into, looped); + interp.batch_interpolate_fast_into(&points, &mut into); + assert_eq!(into, looped); +} + +#[test] +fn set_strategy_enum_changes_result() { + let mut interp: UomInterp3D> = + UomInterp3D::new( + array![Ratio::new::(0.), Ratio::new::(1.)], + array![Time::new::(0.), Time::new::(1.)], + array![Length::new::(0.), Length::new::(1.)], + array![ + [ + [Power::new::(0.), Power::new::(1.)], + [Power::new::(1.), Power::new::(2.)], + ], + [ + [Power::new::(1.), Power::new::(2.)], + [Power::new::(2.), Power::new::(3.)], + ], + ], + strategy::Linear.into(), + Extrapolate::Error, + ) + .unwrap(); + let (x, y, z) = ( + Ratio::new::(0.25), + Time::new::(0.), + Length::new::(0.), + ); + assert_eq!( + interp.interpolate(x, y, z).unwrap(), + Power::new::(0.25) + ); + interp.set_strategy(strategy::Nearest).unwrap(); + assert_eq!( + interp.interpolate(x, y, z).unwrap(), + Power::new::(0.) + ); +} + +#[test] +fn extrapolate_and_validate() { + let mut interp = build(); + assert_eq!(interp.ndim(), 3); + assert!(interp.validate().is_ok()); + let oob = ( + Ratio::new::(5.), + Time::new::(0.), + Length::new::(0.), + ); + assert!(interp.interpolate(oob.0, oob.1, oob.2).is_err()); + interp.set_extrapolate(Extrapolate::Clamp).unwrap(); + // Clamped to (x=1, y=0, z=0): f(1, 0, 0) = 1 kW. + assert_eq!( + interp.interpolate(oob.0, oob.1, oob.2).unwrap(), + Power::new::(1.) + ); +} + +#[test] +fn partial_eq() { + assert_eq!(build(), build()); +} + +#[test] +#[cfg(feature = "serde")] +fn serde_round_trip() { + let interp = build(); + let json = serde_json::to_string(&interp).unwrap(); + let de: UomInterp3D = + serde_json::from_str(&json).unwrap(); + assert_eq!(interp, de); +} diff --git a/ninterp-uom/src/interpolator/two/mod.rs b/ninterp-uom/src/interpolator/two/mod.rs index 8b13789..6257b8a 100644 --- a/ninterp-uom/src/interpolator/two/mod.rs +++ b/ninterp-uom/src/interpolator/two/mod.rs @@ -1 +1,123 @@ +//! Zero-copy 2-D interpolation over `uom` quantities: `Qx`/`Qy` are independent per-axis +//! units, so e.g. an RPM x Torque grid producing a Power value is one type. See the +//! [`one`] module docs for the transmute-soundness argument every +//! dimensionality here relies on. +use super::*; + +#[cfg(all(test, feature = "f64"))] +mod tests; + +/// 2-D interpolator over `uom` quantities: grid points of unit `Qx`/`Qy`, values of unit +/// `Qv`, all backed by storage representation `D` (`OwnedRepr` or `ViewRepr<&'a V>` - +/// see the [`UomInterp2D`]/[`UomInterp2DView`] aliases below). +#[derive(Clone, Debug)] +pub struct UomInterp2DBase +where + D: Data + RawDataClone + Clone, + D::Elem: PartialEq + Debug + Clone, + Qx: BaseUnit, + Qy: BaseUnit, + Qv: BaseUnit, + S: Clone, +{ + /// The wrapped, unit-erased `ninterp` interpolator - see the `inner` field on + /// [`UomInterp1DBase`] for the escape-hatch rationale + /// and the base-unit caveat. + pub inner: Interp2DBase, + #[allow(clippy::type_complexity)] + _units: PhantomData (Qx, Qy, Qv)>, +} + +/// Owned variant (see [`UomInterp2DBase`] for the generic form). +pub type UomInterp2D = UomInterp2DBase, Qx, Qy, Qv, S>; +/// Viewed variant (see [`UomInterp2DBase`] for the generic form). +pub type UomInterp2DView<'a, Qx, Qy, Qv, V, S> = UomInterp2DBase, Qx, Qy, Qv, S>; + +impl<'a, Qx, Qy, Qv, V, S> UomInterp2DView<'a, Qx, Qy, Qv, V, S> +where + Qx: BaseUnit, + Qy: BaseUnit, + Qv: BaseUnit, + V: Num + PartialOrd + Euclid + Copy + Debug + 'a, + S: Strategy2D> + Clone, +{ + /// Construct a viewed (borrowed, zero-copy) interpolator over `uom` quantity arrays. + pub fn new( + x: ArrayView1<'a, Qx>, + y: ArrayView1<'a, Qy>, + f_xy: ArrayView2<'a, Qv>, + strategy: S, + extrapolate: Extrapolate, + ) -> Result { + // SAFETY: see `one` module docs - `Qx`/`Qy`/`Qv` are `uom` quantities backed by + // `V`, `#[repr(transparent)]` over it, so reinterpreting each view's element + // type as `V` is sound and each view's shape/strides are unaffected. + let x: ArrayView1<'a, V> = unsafe { mem::transmute::, _>(x) }; + let y: ArrayView1<'a, V> = unsafe { mem::transmute::, _>(y) }; + let f_xy: ArrayView2<'a, V> = unsafe { mem::transmute::, _>(f_xy) }; + Ok(Self { + inner: Interp2DView::new(x, y, f_xy, strategy, extrapolate)?, + _units: PhantomData, + }) + } +} + +impl UomInterp2D +where + Qx: BaseUnit, + Qy: BaseUnit, + Qv: BaseUnit, + V: Num + PartialOrd + Euclid + Copy + Debug, + S: Strategy2D> + Clone, +{ + /// Construct an owned interpolator over `uom` quantity arrays. + pub fn new( + x: Array1, + y: Array1, + f_xy: Array2, + strategy: S, + extrapolate: Extrapolate, + ) -> Result { + // SAFETY: same reasoning as the view constructor above, applied to owned + // storage. + let x: Array1 = unsafe { mem::transmute::, _>(x) }; + let y: Array1 = unsafe { mem::transmute::, _>(y) }; + let f_xy: Array2 = unsafe { mem::transmute::, _>(f_xy) }; + Ok(Self { + inner: Interp2D::new(x, y, f_xy, strategy, extrapolate)?, + _units: PhantomData, + }) + } +} + +impl UomInterp2DBase +where + D: Data + RawDataClone + Clone, + D::Elem: Num + PartialOrd + Euclid + Copy + Debug, + Qx: BaseUnit, + Qy: BaseUnit, + Qv: BaseUnit, + S: Strategy2D + Clone, +{ + /// Interpolate at `(x, y)`, returning a value in `Qv`. + pub fn interpolate(&self, x: Qx, y: Qy) -> Result { + self.inner + .interpolate(&[x.to_base(), y.to_base()]) + .map(Qv::from_base) + } + + uom_interp_common_methods!( + UomInterp2DBase, + Strategy2D, + UomInterp2DView<'_, Qx, Qy, Qv, D::Elem, S>, + UomInterp2D, + (Qx, Qy), + (x, y) + ); +} + +uom_interp_partial_eq!(UomInterp2DBase, Interp2DBase, (Qx, Qy)); +uom_interp_set_strategy_box!(UomInterp2DBase, Strategy2D, (Qx, Qy)); +uom_interp_set_strategy_enum!(UomInterp2DBase, Strategy2DEnum, (Qx, Qy)); +uom_interp_serde!(UomInterp2DBase, Interp2DBase, (Qx, Qy)); diff --git a/ninterp-uom/src/interpolator/two/tests.rs b/ninterp-uom/src/interpolator/two/tests.rs new file mode 100644 index 0000000..336d45f --- /dev/null +++ b/ninterp-uom/src/interpolator/two/tests.rs @@ -0,0 +1,132 @@ +use super::*; +use uom::si::f64::{Power, Ratio, Time}; +use uom::si::power::kilowatt; +use uom::si::ratio::ratio; +use uom::si::time::second; + +fn build() -> UomInterp2D { + UomInterp2D::new( + array![Ratio::new::(0.), Ratio::new::(1.)], + array![Time::new::(0.), Time::new::(1.)], + array![ + [Power::new::(0.), Power::new::(1.)], + [Power::new::(1.), Power::new::(2.)], + ], + strategy::Linear, + Extrapolate::Error, + ) + .unwrap() +} + +#[test] +fn interpolate_matches_linear() { + let interp = build(); + assert_eq!( + interp + .interpolate(Ratio::new::(0.5), Time::new::(0.5)) + .unwrap(), + Power::new::(1.) + ); + assert_eq!( + interp.interpolate_fast(Ratio::new::(0.5), Time::new::(0.5)), + Power::new::(1.) + ); +} + +#[test] +fn view_and_into_owned_round_trip() { + let owned = build(); + let point = (Ratio::new::(0.25), Time::new::(0.75)); + let viewed = owned.view(); + assert_eq!( + viewed.interpolate(point.0, point.1), + owned.interpolate(point.0, point.1) + ); + let back = viewed.into_owned(); + assert_eq!( + back.interpolate(point.0, point.1), + owned.interpolate(point.0, point.1) + ); +} + +#[test] +fn batch_interpolate_matches_loop() { + let interp = build(); + let points = [ + (Ratio::new::(0.25), Time::new::(0.25)), + (Ratio::new::(0.5), Time::new::(0.75)), + ]; + let batched = interp.batch_interpolate(&points).unwrap(); + let looped: Vec = points + .iter() + .map(|&(x, y)| interp.interpolate(x, y).unwrap()) + .collect(); + assert_eq!(batched, looped); + assert_eq!(interp.batch_interpolate_fast(&points), looped); + + let mut into = vec![Power::new::(0.); points.len()]; + interp.batch_interpolate_into(&points, &mut into).unwrap(); + assert_eq!(into, looped); + interp.batch_interpolate_fast_into(&points, &mut into); + assert_eq!(into, looped); +} + +#[test] +fn set_strategy_enum_changes_result() { + let mut interp: UomInterp2D> = UomInterp2D::new( + array![Ratio::new::(0.), Ratio::new::(1.)], + array![Time::new::(0.), Time::new::(1.)], + array![ + [Power::new::(0.), Power::new::(1.)], + [Power::new::(1.), Power::new::(2.)], + ], + strategy::Linear.into(), + Extrapolate::Error, + ) + .unwrap(); + let (x, y) = (Ratio::new::(0.25), Time::new::(0.)); + assert_eq!( + interp.interpolate(x, y).unwrap(), + Power::new::(0.25) + ); + interp.set_strategy(strategy::Nearest).unwrap(); + assert_eq!( + interp.interpolate(x, y).unwrap(), + Power::new::(0.) + ); +} + +#[test] +fn extrapolate_and_validate() { + let mut interp = build(); + assert_eq!(interp.ndim(), 2); + assert!(interp.validate().is_ok()); + assert!( + interp + .interpolate(Ratio::new::(5.), Time::new::(0.)) + .is_err() + ); + interp.set_extrapolate(Extrapolate::Clamp).unwrap(); + // Clamped to (x=1, y=0), the grid's max-x/min-y corner: f(1, 0) = 1 kW. + assert_eq!( + interp + .interpolate(Ratio::new::(5.), Time::new::(0.)) + .unwrap(), + Power::new::(1.) + ); +} + +#[test] +fn partial_eq() { + assert_eq!(build(), build()); +} + +#[test] +#[cfg(feature = "serde")] +fn serde_round_trip() { + let interp = build(); + let json = serde_json::to_string(&interp).unwrap(); + let de: UomInterp2D = + serde_json::from_str(&json).unwrap(); + assert_eq!(interp, de); +} diff --git a/ninterp-uom/src/interpolator/zero/mod.rs b/ninterp-uom/src/interpolator/zero/mod.rs new file mode 100644 index 0000000..dd40f9c --- /dev/null +++ b/ninterp-uom/src/interpolator/zero/mod.rs @@ -0,0 +1,74 @@ +//! 0-D "interpolation": a constant value, still uom-typed. + +use super::*; + +#[cfg(all(test, feature = "f64"))] +mod tests; + +/// 0-D 'interpolator': wraps a constant value of unit `Q`, backed by storage type `V`. +/// +/// Has no axes, so the per-axis heterogeneous-unit machinery the other dimensionalities +/// need doesn't apply here; kept minimal (`new`/`interpolate`/serde only) rather than +/// pulled through the batch/`view`/`set_strategy` surface those share. +#[derive(Clone, Debug, PartialEq)] +pub struct UomInterp0D { + /// The wrapped, unit-erased `ninterp` interpolator - see the `inner` field on + /// [`UomInterp1DBase`] for the escape-hatch rationale + /// and the base-unit caveat. + pub inner: Interp0D, + _unit: PhantomData Q>, +} + +impl UomInterp0D +where + Q: BaseUnit, + V: PartialEq + Debug, +{ + /// Construct a constant-value 'interpolator'. + pub fn new(value: Q) -> Self { + Self { + inner: Interp0D::new(value.to_base()), + _unit: PhantomData, + } + } + + /// Returns the contained value. Infallible: 0-D has no point argument to get wrong + /// (core's `InterpolateError::PointLength` case can't occur here). + pub fn interpolate(&self) -> Q + where + V: Clone, + { + Q::from_base(self.inner.0.clone()) + } +} + +#[cfg(feature = "serde")] +impl Serialize for UomInterp0D +where + Q: BaseUnit, + V: PartialEq + Debug + Serialize, +{ + fn serialize(&self, serializer: Ser) -> Result + where + Ser: Serializer, + { + self.inner.serialize(serializer) + } +} + +#[cfg(feature = "serde")] +impl<'de, Q, V> Deserialize<'de> for UomInterp0D +where + Q: BaseUnit, + V: PartialEq + Debug + Deserialize<'de>, +{ + fn deserialize(deserializer: De) -> Result + where + De: Deserializer<'de>, + { + Ok(Self { + inner: Interp0D::deserialize(deserializer)?, + _unit: PhantomData, + }) + } +} diff --git a/ninterp-uom/src/interpolator/zero/tests.rs b/ninterp-uom/src/interpolator/zero/tests.rs new file mode 100644 index 0000000..927e471 --- /dev/null +++ b/ninterp-uom/src/interpolator/zero/tests.rs @@ -0,0 +1,34 @@ +use super::*; +use uom::si::f64::Power; +use uom::si::power::kilowatt; + +#[test] +fn interpolate_returns_constant() { + let interp = UomInterp0D::new(Power::new::(0.5)); + assert_eq!(interp.interpolate(), Power::new::(0.5)); +} + +#[test] +fn partial_eq() { + assert_eq!( + UomInterp0D::new(Power::new::(0.5)), + UomInterp0D::new(Power::new::(0.5)) + ); +} + +#[test] +fn inner_escape_hatch_is_reachable() { + let interp = UomInterp0D::new(Power::new::(0.5)); + // Base-unit caveat: `inner.0` is watts (`uom`'s SI base unit for power), not + // kilowatts. + assert_eq!(interp.inner.0, 500.); +} + +#[test] +#[cfg(feature = "serde")] +fn serde_round_trip() { + let interp = UomInterp0D::new(Power::new::(0.5)); + let json = serde_json::to_string(&interp).unwrap(); + let de: UomInterp0D = serde_json::from_str(&json).unwrap(); + assert_eq!(interp, de); +} diff --git a/ninterp-uom/src/lib.rs b/ninterp-uom/src/lib.rs index 360c1ca..4e925eb 100644 --- a/ninterp-uom/src/lib.rs +++ b/ninterp-uom/src/lib.rs @@ -1,23 +1,39 @@ #![cfg_attr(not(feature = "std"), no_std)] +extern crate alloc; + /// Re-exports [`ninterp::prelude`] alongside this crate's own types, so downstream /// crates need only `use ninterp_uom::prelude::*;`. pub mod prelude { pub use ninterp::prelude::*; pub use crate::base_unit::BaseUnit; - pub use crate::interpolator::{UomInterp1D, UomInterp1DBase, UomInterp1DView}; + pub use crate::interpolator::{ + UomInterp0D, UomInterp1D, UomInterp1DBase, UomInterp1DView, UomInterp2D, UomInterp2DBase, + UomInterp2DView, UomInterp3D, UomInterp3DBase, UomInterp3DView, + }; } mod base_unit; pub mod interpolator; pub use base_unit::BaseUnit; -pub use interpolator::{UomInterp1D, UomInterp1DBase, UomInterp1DView}; +pub use interpolator::{ + UomInterp0D, UomInterp1D, UomInterp1DBase, UomInterp1DView, UomInterp2D, UomInterp2DBase, + UomInterp2DView, UomInterp3D, UomInterp3DBase, UomInterp3DView, +}; pub(crate) use ninterp::error::{InterpolateError, ValidateError}; pub(crate) use ninterp::prelude::*; -pub(crate) use ninterp::strategy::traits::Strategy1D; +pub(crate) use ninterp::strategy::enums::{Strategy1DEnum, Strategy2DEnum, Strategy3DEnum}; +pub(crate) use ninterp::strategy::traits::{Strategy1D, Strategy2D, Strategy3D}; + +pub(crate) use alloc::boxed::Box; +// Only reached through test modules' `use super::*;` chains, which the unused-import +// lint doesn't track through a macro-namespace re-export. +#[allow(unused_imports)] +pub(crate) use alloc::vec; +pub(crate) use alloc::vec::Vec; pub(crate) use core::fmt::Debug; pub(crate) use core::marker::PhantomData; @@ -34,4 +50,7 @@ pub(crate) use uom::{ }; pub use ninterp::num_traits; -pub(crate) use ninterp::num_traits::{Euclid, Num}; +pub(crate) use ninterp::num_traits::{Euclid, Float, Num}; + +#[cfg(feature = "serde")] +pub(crate) use serde::{Deserialize, Deserializer, Serialize, Serializer};