From 94fc711d5a2790266c28c5993083ea664093a42c Mon Sep 17 00:00:00 2001 From: Kyle Carow Date: Thu, 6 Aug 2026 23:53:27 -0600 Subject: [PATCH 1/2] Replace serde_ndim feature with per-call-site Nested wrapper (#32) serde_ndim switched the write format for arrays globally via a Cargo feature, which silently affects every other ninterp consumer in the same binary since features are additive. It was also broken for non-self-describing formats (bincode, postcard): deserialize_any is unconditional, and fixed-size grids serialize as tuples, which desyncs the byte stream on read. Replace it with a Nested wrapper / serialize_nested helper (exposed via prelude) that opts into the nested-array format at the point of serialization. The ndarray format stays the derive default: fastest, and the only format that works with binary serializers. Reading already accepted either format and continues to. Gate the tolerant reader on is_human_readable() so non-self-describing formats fall back to the ndarray format instead of failing, and fix the fixed-size grid tuple/seq desync. tests/serde_formats.rs round-trips every serializable type through both formats (catches the hand-written SerializeNested impls drifting from derived Deserialize) plus a bincode round-trip regression test. --- CHANGELOG.md | 21 +++ Cargo.toml | 2 +- README.md | 134 +++++++++++++------- src/interpolator/data.rs | 32 +++-- src/interpolator/enums.rs | 21 +++ src/interpolator/mod.rs | 26 ++++ src/interpolator/n/mod.rs | 32 +++-- src/interpolator/n/tests.rs | 16 ++- src/interpolator/one/mod.rs | 2 + src/interpolator/one/tests.rs | 16 ++- src/interpolator/three/mod.rs | 2 + src/interpolator/two/mod.rs | 2 + src/interpolator/zero/mod.rs | 15 +++ src/lib.rs | 11 +- src/{serde.rs => serde/de.rs} | 166 +++++++++++------------- src/serde/mod.rs | 32 +++++ src/serde/ser.rs | 229 +++++++++++++++++++++++++++++++++ tests/serde_formats.rs | 232 ++++++++++++++++++++++++++++++++++ 18 files changed, 815 insertions(+), 176 deletions(-) rename src/{serde.rs => serde/de.rs} (53%) create mode 100644 src/serde/mod.rs create mode 100644 src/serde/ser.rs create mode 100644 tests/serde_formats.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c626dda..41ac60c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ Everything below is merged to `main` but not yet tagged/released. runtime-selected direction. - `strategy::LinearUniform`, `Step`, `StepLower`, and `StepUpper` are all available for every dimensionality and included in the corresponding `Strategy*Enum` types. +- `Nested` wrapper / `serialize_nested` helper / `SerializeNested` trait (`prelude`, behind + the `serde` feature): opt into the nested-array format at a specific serialization call + site, e.g. `serde_json::to_string(&Nested(&interp))` or + `#[serde(serialize_with = "serialize_nested")]` on a field. Falls back to the `ndarray` + format on non-`is_human_readable` (binary) serializers, since there's nothing to nest + there and those formats can't read it back anyway. ### Changed - **Breaking:** `find_nearest_index` is renamed to `locate_lower_index` and, along with @@ -47,6 +53,13 @@ Everything below is merged to `main` but not yet tagged/released. `NonMonotonic`. `EmptyGrid` is removed outright; a grid dimension with 0 or 1 points is now rejected by the same `InsufficientGridPoints`, since a single point can't bracket a query either. +- **Breaking:** the `serde_ndim` Cargo feature is removed. It switched the nested-array + write format on for every array field crate-wide, and because Cargo features are + additive and unify across the dependency graph, enabling it anywhere in a binary + silently flipped the wire format for every other `ninterp` consumer in that binary too. + Migrate to wrapping values in `Nested` (or `serialize_with = "serialize_nested"` on a + field) at the specific call site that wants it. Reading already accepted either format + and continues to, so data written by prior versions still loads fine. - Significant ND performance work: `Linear`/`Nearest` no longer build coordinate permutation tables via `itertools::multi_cartesian_product` (removing the `itertools` dependency); corner values are now gathered into a flat buffer and reduced with an @@ -75,6 +88,14 @@ Everything below is merged to `main` but not yet tagged/released. `Extrapolate::Enable` was selected, since the "at least 2 points" check only ran for that one setting. It's now checked unconditionally at construction, so this is a `ValidateError::InsufficientGridPoints` instead of a panic. +- Serde: non-self-describing formats (bincode, postcard, ...) could never actually read + back an interpolator they had just written, in either feature configuration. + `deserialize_any` was called unconditionally, which those formats don't support at all + (`Bincode does not support the serde::Deserializer::deserialize_any method`); separately, + a fixed-size grid (`[ArrayBase; N]`) serializes as a tuple, which those formats + encode without a length prefix, so reading it back as a seq desynchronized the byte + stream (`unknown array version: 0`). Both are now gated on `is_human_readable()`, falling + back to `ndarray`'s own (de)serialization for non-human-readable formats. ## [0.9.1] - 2026-08-03 diff --git a/Cargo.toml b/Cargo.toml index ba63932..356a21a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ ndarray-rand = "0.16.0" approx = "0.5.1" uom = "0.36.0" serde_json = "1.0.140" +bincode = "1.3" [[bench]] name = "benchmark" @@ -41,4 +42,3 @@ serde = [ "dep:serde_unit_struct", "dep:serde-ndim", ] -serde_ndim = ["serde"] diff --git a/README.md b/README.md index 8e5e38d..925e551 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ The `ninterp` crate provides [multivariate interpolation](https://en.wikipedia.org/wiki/Multivariate_interpolation#Regular_grid) over rectilinear grids of any dimensionality. It is built on [`ndarray`](https://crates.io/crates/ndarray) and uses ndarray arrays/views throughout its API. +`ndarray` is re-exposed as `ninterp::ndarray` 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`. @@ -75,15 +76,49 @@ Instantiation is done by calling an interpolator's `new` method. For dimensionalities N >= 1, this executes a validation step that prevents runtime panics. ## Cargo Features -- `serde`: support for [`serde`](https://crates.io/crates/serde) 1.x using ndarray's built-in array format +- `serde`: support for [`serde`](https://crates.io/crates/serde) 1.x ```text cargo add ninterp --features serde ``` -- `serde_ndim`: enable `serde` feature and switch output to the column-major nested array format from [`serde-ndim`](https://crates.io/crates/serde-ndim) - ```text - cargo add ninterp --features serde_ndim + + By default, arrays are written in `ndarray`'s built-in format, which is performant to parse and works with every serialization format (text and binary): + ```json + {"grid":[{"v":1,"dim":[2],"data":[0.0,1.0]},{"v":1,"dim":[3],"data":[0.0,1.0,2.0]}],"values":{"v":1,"dim":[2,3],"data":[0.0,1.0,2.0,3.0,4.0,5.0]}} ``` + You can also serialize interpolators using the nested-array format from + [`serde-ndim`](https://crates.io/crates/serde-ndim), which is far easier to read and hand-edit. This works for any `is_human_readable` serde format (serializing to binary formats necessarily uses the standard `ndarray` style). + + - On fields, using the `serialize_nested` helper function from [`ninterp::prelude`](https://docs.rs/ninterp/latest/ninterp/prelude/index.html): + + ```rust,ignore + use ninterp::prelude::*; + + #[derive(serde::Serialize)] + struct MyConfig { + #[serde(serialize_with = "serialize_nested")] + surface: Interp2DOwned, + } + ``` + + - Directly, using the `Nested` wrapper: + + ```rust,ignore + use ninterp::prelude::*; + + let json = serde_json::to_string(&Nested(&interp.data)).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, + while `serde-ndim` must 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. + ## Choosing an Interpolator The [`prelude`](https://docs.rs/ninterp/latest/ninterp/prelude/index.html) exposes these interpolators: - [`Interp0D`](https://docs.rs/ninterp/latest/ninterp/interpolator/struct.Interp0D.html): constant-value interpolator @@ -94,14 +129,21 @@ The [`prelude`](https://docs.rs/ninterp/latest/ninterp/prelude/index.html) expos Use `Interp0D` when working with heterogeneous collections such as an `InterpolatorEnum` or `Box`. -### Flexibility Model -| Approach | Runtime swapping | `serde` | Custom strategies | Runtime cost | +### 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>` | No | Yes | N/A | Lowest | -| `Interp*<_, strategy::enums::Strategy*Enum>` | Strategy only | Yes | No | Low | -| `Interp*<_, Box>` | Strategy only | No | Yes | Medium | -| `InterpolatorEnum` | Interpolator + strategy | Yes | No | Low | -| `Box>` | Interpolator + strategy | No | Yes | Highest | +| `Interp*<_, ConcreteStrategy>` | Lowest | No | Yes | Yes | +| `Interp*<_, strategy::enums::Strategy*Enum>` | Low | Strategy only | No | Yes | +| `InterpolatorEnum` | Low | Interpolator + strategy | No | Yes | +| `Interp*<_, Box>` | Medium | Strategy only | Yes | No | +| `Box>` | Highest | Interpolator + strategy | Yes | No | ## Core Concepts ### Validation Lifecycle @@ -116,28 +158,26 @@ per-dimension direction count), or after deserializing an interpolator with a st custom strategy, call `init_strategy` to re-run it. ### Data Shape Contract -Grid and values shapes must match by axis order. - -Examples: -- 1-D: `x.len() == f_x.len()` -- 2-D: `x.len() == f_xy.shape()[0]` and `y.len() == f_xy.shape()[1]` -- 3-D: `x.len() == f_xyz.shape()[0]`, `y.len() == f_xyz.shape()[1]`, `z.len() == f_xyz.shape()[2]` -- N-D: for every dimension `n`, `grid[n].len() == values.shape()[n]` +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 monotonically increasing. +Grid coordinates in each dimension must be monotonically increasing, with at least 2 points per +dimension (`ValidateError::InsufficientGridPoints` otherwise). ### Strategies -An interpolation strategy (for example -[`Linear`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.Linear.html), -[`LinearUniform`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.LinearUniform.html), -[`Nearest`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.Nearest.html), -[`Step`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.Step.html), -[`StepLower`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.StepLower.html), -[`StepUpper`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.StepUpper.html), -must be specified. - -To change the interpolation strategy, supply a `Strategy1DEnum`/etc. or `Box`/etc. at instantiation and call `set_strategy`. -Custom strategies can be defined. See [`examples/custom_strategy.rs`](https://github.com/NatLabRockies/ninterp/blob/main/examples/custom_strategy.rs). +An interpolation strategy must be specified. Provided strategies: + +| Strategy | Description | +| --- | --- | +| [`Linear`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.Linear.html) | Linear interpolation | +| [`LinearUniform`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.LinearUniform.html) | Linear interpolation for uniformly spaced grids | +| [`Nearest`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.Nearest.html) | Nearest-neighbor interpolation | +| [`Step`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.Step.html) | Step interpolation with per-dimension directions or a direction chosen at runtime | +| [`StepLower`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.StepLower.html) | Step interpolation to the previous grid value in each dimension | +| [`StepUpper`](https://docs.rs/ninterp/latest/ninterp/strategy/struct.StepUpper.html) | Step interpolation to the next grid value in each dimension | + +To change the interpolation strategy, supply a `Strategy*DEnum` or `Box` at instantiation and call `set_strategy`. +Custom strategies can be defined, see [`examples/custom_strategy.rs`](https://github.com/NatLabRockies/ninterp/blob/main/examples/custom_strategy.rs). ### Extrapolation An [`Extrapolate`](https://docs.rs/ninterp/latest/ninterp/interpolator/enum.Extrapolate.html) @@ -166,25 +206,33 @@ For example: - N-D interpolator: `&[x0, x1, ..., x_{N-1}]` If the number of coordinates does not match dimensionality, interpolation returns an error. -Retrieve dimensionality using [`Interpolator::ndim`](https://docs.rs/ninterp/latest/ninterp/interpolator/trait.Interpolator.html#tymethod.ndim). +Retrieve dimensionality using +[`Interpolator::ndim`](https://docs.rs/ninterp/latest/ninterp/interpolator/trait.Interpolator.html#tymethod.ndim) +if necessary. -### Common Errors +### Errors Validation-time (`new` / `validate`): -- Fewer than 2 grid coordinates in a dimension (`ValidateError::InsufficientGridPoints`) -- Non-monotonic coordinates (`ValidateError::NonMonotonic`) -- Grid/value shape mismatch (`ValidateError::IncompatibleShapes`) -- Inapplicable extrapolation setting (`ValidateError::InvalidExtrapolate`) + +| Error | Meaning | +| --- | --- | +| `ValidateError::InsufficientGridPoints` | Fewer than 2 grid coordinates in a dimension | +| `ValidateError::NonMonotonic` | Non-monotonic coordinates | +| `ValidateError::IncompatibleShapes` | Grid/value shape mismatch | +| `ValidateError::InvalidExtrapolate` | Inapplicable extrapolation setting | Interpolation-time (`interpolate`): -- Query point has wrong dimensionality (`InterpolateError::PointLength`) -- Query point is out of bounds while using `Extrapolate::Error` (`InterpolateError::ExtrapolateError`) + +| Error | Meaning | +| --- | --- | +| `InterpolateError::PointLength` | Query point has wrong dimensionality | +| `InterpolateError::ExtrapolateError` | Query point is out of bounds while using `Extrapolate::Error` | ## Using Owned and Borrowed (Viewed) Data All interpolators support both owned and borrowed data via the generic `D` bound on [`ndarray::Data`](https://docs.rs/ndarray/latest/ndarray/trait.Data.html). The crate also re-exports [`ndarray`](https://docs.rs/ninterp/latest/ninterp/ndarray/index.html) -and [`num_traits`](https://docs.rs/ninterp/latest/ninterp/num_traits/index.html), +(and [`num_traits`](https://docs.rs/ninterp/latest/ninterp/num_traits/index.html)), so either of these import styles are valid: ```rust @@ -196,8 +244,8 @@ use ndarray::prelude::*; Type aliases in the [`prelude`](https://docs.rs/ninterp/latest/ninterp/prelude/index.html) make ownership intent explicit, for example in 1-D: - [`Interp1DOwned`](https://docs.rs/ninterp/latest/ninterp/interpolator/type.Interp1DOwned.html) - - Data is owned by the interpolator object - - Useful for struct fields + - Data is owned by the interpolator + - Examples: struct fields, general use ```rust use ndarray::prelude::*; use ninterp::prelude::*; @@ -210,8 +258,8 @@ make ownership intent explicit, for example in 1-D: .unwrap(); ``` - [`Interp1DViewed`](https://docs.rs/ninterp/latest/ninterp/interpolator/type.Interp1DViewed.html) - - Data is borrowed by the interpolator object - - Use when interpolator data should be owned by another object + - Data is borrowed by the interpolator + - Examples: data lives in a larger struct, data is shared without copying ```rust use ndarray::prelude::*; use ninterp::prelude::*; diff --git a/src/interpolator/data.rs b/src/interpolator/data.rs index 5c17797..e1265e8 100644 --- a/src/interpolator/data.rs +++ b/src/interpolator/data.rs @@ -39,20 +39,9 @@ where /// - 1-D: `[x]` /// - 2-D: `[x, y]` /// - 3-D: `[x, y, z]` - #[cfg_attr( - feature = "serde", - serde(deserialize_with = "serde_arr_array::deserialize") - )] - #[cfg_attr( - feature = "serde_ndim", - serde(serialize_with = "serde_arr_array::serialize") - )] + #[cfg_attr(feature = "serde", serde(deserialize_with = "deserialize_grid_arr"))] pub grid: [ArrayBase; N], /// Function values at coordinates: a single `N`-dimensional [`ArrayBase`]. - #[cfg_attr( - feature = "serde_ndim", - serde(serialize_with = "serde_ndim::serialize") - )] #[cfg_attr(feature = "serde", serde(deserialize_with = "deserialize_fixed"))] pub values: ArrayBase>, } @@ -61,6 +50,25 @@ pub type InterpDataViewed = InterpData, N>; /// [`InterpData`] that owns data. pub type InterpDataOwned = InterpData, N>; +#[cfg(feature = "serde")] +impl SerializeNested for InterpData +where + Dim<[Ix; N]>: Dimension, + D: Data + RawDataClone + Clone, + D::Elem: PartialEq + Debug + Serialize, + ArrayBase>: Serialize, +{ + fn serialize_nested(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut s = serializer.serialize_struct("InterpData", 2)?; + s.serialize_field("grid", &GridArrWrapper(&self.grid))?; + s.serialize_field("values", &ArrayWrapper(&self.values))?; + s.end() + } +} + impl PartialEq for InterpData where Dim<[Ix; N]>: Dimension, diff --git a/src/interpolator/enums.rs b/src/interpolator/enums.rs index c95cdee..da69716 100644 --- a/src/interpolator/enums.rs +++ b/src/interpolator/enums.rs @@ -91,6 +91,27 @@ pub type InterpolatorEnumViewed = InterpolatorEnum>; /// [`InterpolatorEnum`] that owns data. pub type InterpolatorEnumOwned = InterpolatorEnum>; +#[cfg(feature = "serde")] +impl SerializeNested for InterpolatorEnum +where + D: Data + RawDataClone + Clone, + D::Elem: Float + Debug + Serialize, +{ + /// `#[serde(untagged)]`, so each variant serializes as its inner value. + fn serialize_nested(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Interp0D(interp) => Nested(interp).serialize(serializer), + Self::Interp1D(interp) => Nested(interp).serialize(serializer), + Self::Interp2D(interp) => Nested(interp).serialize(serializer), + Self::Interp3D(interp) => Nested(interp).serialize(serializer), + Self::InterpND(interp) => Nested(interp).serialize(serializer), + } + } +} + impl PartialEq for InterpolatorEnum where D: Data + RawDataClone + Clone, diff --git a/src/interpolator/mod.rs b/src/interpolator/mod.rs index c661f48..e739122 100644 --- a/src/interpolator/mod.rs +++ b/src/interpolator/mod.rs @@ -117,3 +117,29 @@ macro_rules! partialeq_impl { }; } pub(crate) use partialeq_impl; + +#[cfg(feature = "serde")] +macro_rules! serialize_nested_impl { + ($InterpType:ident, $Data:ident, $Strategy:ident) => { + impl SerializeNested for $InterpType + where + D: Data + RawDataClone + Clone, + D::Elem: PartialEq + Debug + Serialize, + S: $Strategy + Clone + Serialize, + $Data: SerializeNested + Serialize, + { + fn serialize_nested(&self, serializer: Ser) -> Result + where + Ser: Serializer, + { + let mut s = serializer.serialize_struct(stringify!($InterpType), 3)?; + s.serialize_field("data", &Nested(&self.data))?; + s.serialize_field("strategy", &self.strategy)?; + s.serialize_field("extrapolate", &self.extrapolate)?; + s.end() + } + } + }; +} +#[cfg(feature = "serde")] +pub(crate) use serialize_nested_impl; diff --git a/src/interpolator/n/mod.rs b/src/interpolator/n/mod.rs index b08abf9..6cba8e3 100644 --- a/src/interpolator/n/mod.rs +++ b/src/interpolator/n/mod.rs @@ -29,20 +29,9 @@ where D::Elem: PartialEq + Debug, { /// Coordinate grid: a vector of 1-dimensional [`ArrayBase`]. - #[cfg_attr( - feature = "serde", - serde(deserialize_with = "serde_vec_array::deserialize") - )] - #[cfg_attr( - feature = "serde_ndim", - serde(serialize_with = "serde_vec_array::serialize") - )] + #[cfg_attr(feature = "serde", serde(deserialize_with = "deserialize_grid_vec"))] pub grid: Vec>, /// Function values at coordinates: a single dynamic-dimensional [`ArrayBase`]. - #[cfg_attr( - feature = "serde_ndim", - serde(serialize_with = "serde_ndim::serialize") - )] #[cfg_attr(feature = "serde", serde(deserialize_with = "deserialize_dyn"))] pub values: ArrayBase, } @@ -51,6 +40,23 @@ pub type InterpDataNDViewed = InterpDataND>; /// [`InterpDataND`] that owns data. pub type InterpDataNDOwned = InterpDataND>; +#[cfg(feature = "serde")] +impl SerializeNested for InterpDataND +where + D: Data + RawDataClone + Clone, + D::Elem: PartialEq + Debug + Serialize, +{ + fn serialize_nested(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut s = serializer.serialize_struct("InterpDataND", 2)?; + s.serialize_field("grid", &GridVecWrapper(&self.grid))?; + s.serialize_field("values", &ArrayWrapper(&self.values))?; + s.end() + } +} + impl PartialEq for InterpDataND where D: Data + RawDataClone + Clone, @@ -179,6 +185,8 @@ pub type InterpNDOwned = InterpND, S>; extrapolate_impl!(InterpND, StrategyND); partialeq_impl!(InterpND, InterpDataND, StrategyND); +#[cfg(feature = "serde")] +serialize_nested_impl!(InterpND, InterpDataND, StrategyND); impl InterpND where diff --git a/src/interpolator/n/tests.rs b/src/interpolator/n/tests.rs index 4347503..68a4094 100644 --- a/src/interpolator/n/tests.rs +++ b/src/interpolator/n/tests.rs @@ -520,17 +520,23 @@ fn test_serde() { let de: InterpNDOwned = serde_json::from_str(&ser).unwrap(); assert_eq!(interp, de); + // `ndarray` format by default let data_ser = serde_json::to_string(&interp.data).unwrap(); - #[cfg(feature = "serde_ndim")] assert_eq!( data_ser, - "{\"grid\":[[0.1,1.1],[0.2,1.2],[0.3,1.3]],\"values\":[[[0.0,1.0],[2.0,3.0]],[[4.0,5.0],[6.0,7.0]]]}" + "{\"grid\":[{\"v\":1,\"dim\":[2],\"data\":[0.1,1.1]},{\"v\":1,\"dim\":[2],\"data\":[0.2,1.2]},{\"v\":1,\"dim\":[2],\"data\":[0.3,1.3]}],\"values\":{\"v\":1,\"dim\":[2,2,2],\"data\":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0]}}" ); - #[cfg(not(feature = "serde_ndim"))] + // nested-array format on request + let data_ser_nested = serde_json::to_string(&crate::prelude::Nested(&interp.data)).unwrap(); assert_eq!( - data_ser, - "{\"grid\":[{\"v\":1,\"dim\":[2],\"data\":[0.1,1.1]},{\"v\":1,\"dim\":[2],\"data\":[0.2,1.2]},{\"v\":1,\"dim\":[2],\"data\":[0.3,1.3]}],\"values\":{\"v\":1,\"dim\":[2,2,2],\"data\":[0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0]}}" + data_ser_nested, + "{\"grid\":[[0.1,1.1],[0.2,1.2],[0.3,1.3]],\"values\":[[[0.0,1.0],[2.0,3.0]],[[4.0,5.0],[6.0,7.0]]]}" ); + // ...and the whole interpolator nests too + let interp_ser_nested = serde_json::to_string(&crate::prelude::Nested(&interp)).unwrap(); + let de_nested: InterpNDOwned = + serde_json::from_str(&interp_ser_nested).unwrap(); + assert_eq!(interp, de_nested); // simple format (new serialization output) let ser0 = "{\"grid\":[[0.1,1.1],[0.2,1.2],[0.3,1.3]],\"values\":[[[0.0,1.0],[2.0,3.0]],[[4.0,5.0],[6.0,7.0]]]}"; diff --git a/src/interpolator/one/mod.rs b/src/interpolator/one/mod.rs index ec0a178..057cb66 100644 --- a/src/interpolator/one/mod.rs +++ b/src/interpolator/one/mod.rs @@ -69,6 +69,8 @@ pub type Interp1DOwned = Interp1D, S>; extrapolate_impl!(Interp1D, Strategy1D); partialeq_impl!(Interp1D, InterpData1D, Strategy1D); +#[cfg(feature = "serde")] +serialize_nested_impl!(Interp1D, InterpData1D, Strategy1D); impl Interp1D where diff --git a/src/interpolator/one/tests.rs b/src/interpolator/one/tests.rs index fcc1586..b7d2d6a 100644 --- a/src/interpolator/one/tests.rs +++ b/src/interpolator/one/tests.rs @@ -331,17 +331,23 @@ fn test_serde() { let de: Interp1DOwned = serde_json::from_str(&ser).unwrap(); assert_eq!(interp, de); + // `ndarray` format by default let data_ser = serde_json::to_string(&interp.data).unwrap(); - #[cfg(feature = "serde_ndim")] assert_eq!( data_ser, - "{\"grid\":[[0.0,1.0,2.0,3.0,4.0]],\"values\":[0.2,0.4,0.6,0.8,1.0]}" + "{\"grid\":[{\"v\":1,\"dim\":[5],\"data\":[0.0,1.0,2.0,3.0,4.0]}],\"values\":{\"v\":1,\"dim\":[5],\"data\":[0.2,0.4,0.6,0.8,1.0]}}" ); - #[cfg(not(feature = "serde_ndim"))] + // nested-array format on request + let data_ser_nested = serde_json::to_string(&crate::prelude::Nested(&interp.data)).unwrap(); assert_eq!( - data_ser, - "{\"grid\":[{\"v\":1,\"dim\":[5],\"data\":[0.0,1.0,2.0,3.0,4.0]}],\"values\":{\"v\":1,\"dim\":[5],\"data\":[0.2,0.4,0.6,0.8,1.0]}}" + data_ser_nested, + "{\"grid\":[[0.0,1.0,2.0,3.0,4.0]],\"values\":[0.2,0.4,0.6,0.8,1.0]}" ); + // ...and the whole interpolator nests too + let interp_ser_nested = serde_json::to_string(&crate::prelude::Nested(&interp)).unwrap(); + let de_nested: Interp1DOwned = + serde_json::from_str(&interp_ser_nested).unwrap(); + assert_eq!(interp, de_nested); // simple format (new serialization output) let ser0 = "{\"grid\":[[0.0,1.0,2.0,3.0,4.0]],\"values\":[0.2,0.4,0.6,0.8,1.0]}"; diff --git a/src/interpolator/three/mod.rs b/src/interpolator/three/mod.rs index 2087a78..9ce798e 100644 --- a/src/interpolator/three/mod.rs +++ b/src/interpolator/three/mod.rs @@ -74,6 +74,8 @@ pub type Interp3DOwned = Interp3D, S>; extrapolate_impl!(Interp3D, Strategy3D); partialeq_impl!(Interp3D, InterpData3D, Strategy3D); +#[cfg(feature = "serde")] +serialize_nested_impl!(Interp3D, InterpData3D, Strategy3D); impl Interp3D where diff --git a/src/interpolator/two/mod.rs b/src/interpolator/two/mod.rs index 6edc981..979b163 100644 --- a/src/interpolator/two/mod.rs +++ b/src/interpolator/two/mod.rs @@ -73,6 +73,8 @@ pub type Interp2DOwned = Interp2D, S>; extrapolate_impl!(Interp2D, Strategy2D); partialeq_impl!(Interp2D, InterpData2D, Strategy2D); +#[cfg(feature = "serde")] +serialize_nested_impl!(Interp2D, InterpData2D, Strategy2D); impl Interp2D where diff --git a/src/interpolator/zero/mod.rs b/src/interpolator/zero/mod.rs index ac311df..7b44fd1 100644 --- a/src/interpolator/zero/mod.rs +++ b/src/interpolator/zero/mod.rs @@ -9,6 +9,21 @@ const N: usize = 0; #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] pub struct Interp0D(pub T); + +#[cfg(feature = "serde")] +impl SerializeNested for Interp0D +where + T: Serialize, +{ + /// 0-D interpolators hold no arrays, so there is nothing to nest. + fn serialize_nested(&self, serializer: S) -> Result + where + S: Serializer, + { + self.serialize(serializer) + } +} + impl Interp0D where T: PartialEq + Debug, diff --git a/src/lib.rs b/src/lib.rs index 264081d..b6c8461 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,8 @@ /// - [`strategy::StepLower`] / [`strategy::StepUpper`] /// - `serde`-compatible strategy enums: [`strategy::enums::Strategy1DEnum`]/etc. /// - The extrapolation setting enum: [`Extrapolate`](`interpolator::Extrapolate`) +/// - With the `serde` feature: `Nested`, `serialize_nested`, and `SerializeNested`, for opting +/// into the nested-array serialization format at a specific call site pub mod prelude { pub use crate::strategy; @@ -33,6 +35,9 @@ pub mod prelude { pub use crate::interpolator::enums::{ InterpolatorEnum, InterpolatorEnumOwned, InterpolatorEnumViewed, }; + + #[cfg(feature = "serde")] + pub use crate::serde_support::{serialize_nested, Nested, SerializeNested}; } pub mod error; @@ -60,10 +65,10 @@ pub(crate) use core::ops::Sub; pub(crate) use dyn_clone::*; #[cfg(feature = "serde")] -#[path = "serde.rs"] -mod serde_mod; +#[path = "serde/mod.rs"] +mod serde_support; #[cfg(feature = "serde")] -pub(crate) use serde_mod::*; +pub(crate) use serde_support::*; #[cfg(test)] /// Alias for [`approx::assert_abs_diff_eq`] with `epsilon = 1e-6` diff --git a/src/serde.rs b/src/serde/de.rs similarity index 53% rename from src/serde.rs rename to src/serde/de.rs index 156393b..f44c362 100644 --- a/src/serde.rs +++ b/src/serde/de.rs @@ -1,26 +1,20 @@ -use super::*; +//! Deserialization. +//! +//! Both array formats are accepted, whichever was written. Self-describing formats dispatch on +//! the first token: a sequence is the nested format, a map is the [`ndarray`] format. Formats +//! that are not self-describing cannot support [`Deserializer::deserialize_any`] at all, so for +//! those the [`ndarray`] format is assumed, as it is the only one they can produce. -pub(crate) use ndarray::{DataOwned, IntoDimension}; -pub(crate) use serde::{Deserialize, Serialize}; -pub(crate) use serde_unit_struct::{Deserialize_unit_struct, Serialize_unit_struct}; +use super::*; use core::marker::PhantomData; + use serde::de::{ value::{MapAccessDeserializer, SeqAccessDeserializer}, DeserializeSeed, Deserializer, Error, MapAccess, SeqAccess, Visitor, }; -use serde::ser::{SerializeSeq, Serializer}; use serde_ndim::de::MakeNDim; -#[allow(dead_code)] -#[derive(Serialize)] -struct ArrayWrapper<'a, D>( - #[serde(serialize_with = "serde_ndim::serialize")] &'a ArrayBase, -) -where - D: Data, - D::Elem: Serialize; - struct ArrayFormatVisitor(PhantomData A>); impl ArrayFormatVisitor { @@ -37,7 +31,7 @@ where type Value = A; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a legacy ndarray object or a nested array sequence") + formatter.write_str("an ndarray object or a nested array sequence") } fn visit_seq(self, seq: S) -> Result @@ -61,7 +55,14 @@ where A::Item: Deserialize<'de>, D: Deserializer<'de>, { - deserializer.deserialize_any(ArrayFormatVisitor::::new()) + // Accepting either format requires `deserialize_any`, which non-self-describing formats + // (bincode, postcard, ...) cannot support. Those only ever produce the `ndarray` format, + // so defer to its own impl rather than failing outright. + if deserializer.is_human_readable() { + deserializer.deserialize_any(ArrayFormatVisitor::::new()) + } else { + A::deserialize(deserializer) + } } struct ArraySeed(PhantomData D>); @@ -126,100 +127,74 @@ where } } -pub(crate) mod serde_arr_array { - use super::*; +struct GridVecVisitor(PhantomData D>); - #[allow(dead_code)] - pub fn serialize( - grid: &[ArrayBase; N], - serializer: Ser, - ) -> Result - where - D: Data, - D::Elem: Serialize, - Ser: Serializer, - { - let mut seq = serializer.serialize_seq(Some(N))?; - for arr in grid { - seq.serialize_element(&ArrayWrapper(arr))?; - } - seq.end() - } - - pub fn deserialize<'de, D, const N: usize, De>( - deserializer: De, - ) -> Result<[ArrayBase; N], De::Error> - where - D: DataOwned, - D::Elem: Deserialize<'de> + Debug, - De: Deserializer<'de>, - { - deserializer.deserialize_seq(GridVisitor::::new()) +impl GridVecVisitor { + const fn new() -> Self { + Self(PhantomData) } } -pub(crate) mod serde_vec_array { - use super::*; +impl<'de, D> Visitor<'de> for GridVecVisitor +where + D: DataOwned, + D::Elem: Deserialize<'de>, + ArrayBase: Deserialize<'de> + MakeNDim, +{ + type Value = Vec>; - #[allow(dead_code)] - pub fn serialize( - grid: &[ArrayBase], - serializer: Ser, - ) -> Result - where - D: Data, - D::Elem: Serialize, - Ser: Serializer, - { - let mut seq = serializer.serialize_seq(Some(grid.len()))?; - for arr in grid { - seq.serialize_element(&ArrayWrapper(arr))?; - } - seq.end() + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a sequence of arrays") } - pub fn deserialize<'de, D, De>(deserializer: De) -> Result>, De::Error> + fn visit_seq(self, mut seq: S) -> Result where - D: DataOwned, - D::Elem: Deserialize<'de>, - De: Deserializer<'de>, + S: SeqAccess<'de>, { - struct VecGridVisitor(PhantomData D>); - - impl VecGridVisitor { - const fn new() -> Self { - Self(PhantomData) - } + let mut grid = Vec::new(); + while let Some(array) = seq.next_element_seed(ArraySeed::::new())? { + grid.push(array); } + Ok(grid) + } +} - impl<'de, D> Visitor<'de> for VecGridVisitor - where - D: DataOwned, - D::Elem: Deserialize<'de>, - ArrayBase: Deserialize<'de> + MakeNDim, - { - type Value = Vec>; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a sequence of arrays") - } - - fn visit_seq(self, mut seq: S) -> Result - where - S: SeqAccess<'de>, - { - let mut grid = Vec::new(); - while let Some(array) = seq.next_element_seed(ArraySeed::::new())? { - grid.push(array); - } - Ok(grid) - } - } +/// Read a fixed-length coordinate grid, in either array format. +pub fn deserialize_grid_arr<'de, D, const N: usize, De>( + deserializer: De, +) -> Result<[ArrayBase; N], De::Error> +where + D: DataOwned, + D::Elem: Deserialize<'de> + Debug, + [ArrayBase; N]: Deserialize<'de>, + De: Deserializer<'de>, +{ + // A fixed-size array is written as a tuple, which non-self-describing formats encode without + // a length prefix, so reading it back as a seq would desynchronize the stream. + if deserializer.is_human_readable() { + deserializer.deserialize_seq(GridVisitor::::new()) + } else { + <[ArrayBase; N]>::deserialize(deserializer) + } +} - deserializer.deserialize_seq(VecGridVisitor::::new()) +/// Read a variable-length coordinate grid, in either array format. +pub fn deserialize_grid_vec<'de, D, De>( + deserializer: De, +) -> Result>, De::Error> +where + D: DataOwned, + D::Elem: Deserialize<'de>, + De: Deserializer<'de>, +{ + if deserializer.is_human_readable() { + deserializer.deserialize_seq(GridVecVisitor::::new()) + } else { + Vec::>::deserialize(deserializer) } } +/// Read a fixed-dimensionality values array, in either array format. pub fn deserialize_fixed<'de, D, const N: usize, De>( deserializer: De, ) -> Result>, De::Error> @@ -233,6 +208,7 @@ where deserialize_array_format(deserializer) } +/// Read a dynamic-dimensionality values array, in either array format. pub fn deserialize_dyn<'de, D, De>(deserializer: De) -> Result, De::Error> where D: DataOwned, diff --git a/src/serde/mod.rs b/src/serde/mod.rs new file mode 100644 index 0000000..58ab3a8 --- /dev/null +++ b/src/serde/mod.rs @@ -0,0 +1,32 @@ +//! `serde` support. +//! +//! Interpolators write their arrays in the [`ndarray`] format by default: +//! ```json +//! {"v":1,"dim":[3],"data":[0.0,1.0,2.0]} +//! ``` +//! Wrap a value in [`Nested`](crate::prelude::Nested) to write the more readable nested-array +//! format: +//! ```json +//! [0.0,1.0,2.0] +//! ``` +//! Reading accepts either format, so [`Nested`](crate::prelude::Nested) only affects what is +//! written. +//! +//! Split by direction: [`ser`] writes, [`de`] reads. Note that neither is the `serde` crate; +//! refer to that as `::serde` from inside this module. + +use super::*; + +pub(crate) mod de; +pub(crate) mod ser; + +pub(crate) use de::*; +pub(crate) use ser::*; + +/// The public surface, re-exported from [`crate::prelude`]. +pub use ser::{serialize_nested, Nested, SerializeNested}; + +pub(crate) use ndarray::{DataOwned, IntoDimension}; +pub(crate) use serde::ser::{SerializeSeq, SerializeStruct, Serializer}; +pub(crate) use serde::{Deserialize, Serialize}; +pub(crate) use serde_unit_struct::{Deserialize_unit_struct, Serialize_unit_struct}; diff --git a/src/serde/ser.rs b/src/serde/ser.rs new file mode 100644 index 0000000..ed1059f --- /dev/null +++ b/src/serde/ser.rs @@ -0,0 +1,229 @@ +//! Serialization. +//! +//! Arrays are written in the [`ndarray`] format unless wrapped in [`Nested`]. + +use super::*; + +/// Serialization in the nested-array format. +/// +/// Implemented for every ninterp type that contains an [`ArrayBase`]. Each implementation +/// re-wraps its children, so the format choice propagates all the way down the value. +/// +/// Prefer [`Nested`] or [`serialize_nested`] over calling this directly. This trait is not +/// sealed: implement it for your own types that contain interpolators to extend the recursion. +pub trait SerializeNested { + /// Serialize `self`, writing any contained [`ArrayBase`] as nested sequences. + fn serialize_nested(&self, serializer: S) -> Result + where + S: Serializer; +} + +/// Serialize a value using the nested-array format. +/// +/// Use this when serializing a value directly. For a field of your own type, use +/// [`serialize_nested`] with serde's `serialize_with` attribute: +/// ``` +/// use ninterp::prelude::*; +/// +/// #[derive(serde::Serialize)] +/// struct Config { +/// #[serde(serialize_with = "serialize_nested")] +/// curve: Interp1DOwned, +/// } +/// ``` +/// +/// Non-self-describing formats (bincode, postcard, ...) cannot read the nested format back, +/// so for those this is a no-op and the [`ndarray`] format is written instead. +/// +/// # Example +/// ``` +/// # use ndarray::array; +/// # use ninterp::prelude::*; +/// # use ninterp::data::InterpData1DOwned; +/// let interp = Interp1D::new( +/// array![0., 1., 2.], +/// array![0.0, 0.4, 0.8], +/// strategy::Linear, +/// Extrapolate::Error, +/// ) +/// .unwrap(); +/// +/// let json = serde_json::to_string(&Nested(&interp.data)).unwrap(); +/// assert_eq!(json, r#"{"grid":[[0.0,1.0,2.0]],"values":[0.0,0.4,0.8]}"#); +/// +/// // ...and reads back regardless of which format it was written in +/// let de: InterpData1DOwned = serde_json::from_str(&json).unwrap(); +/// assert_eq!(de, interp.data); +/// ``` +pub struct Nested<'a, T: ?Sized>(pub &'a T); + +impl Serialize for Nested<'_, T> +where + T: SerializeNested + Serialize + ?Sized, +{ + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if serializer.is_human_readable() { + self.0.serialize_nested(serializer) + } else { + self.0.serialize(serializer) + } + } +} + +/// Serialize a value using the nested-array format, for use with `serialize_with`. +/// +/// Equivalent to wrapping the value in [`Nested`]. +/// +/// # Example +/// ``` +/// # use ninterp::prelude::*; +/// #[derive(serde::Serialize)] +/// struct Config { +/// #[serde(serialize_with = "serialize_nested")] +/// curve: Interp1DOwned, +/// } +/// ``` +pub fn serialize_nested(value: &T, serializer: S) -> Result +where + T: SerializeNested + Serialize + ?Sized, + S: Serializer, +{ + Nested(value).serialize(serializer) +} + +impl SerializeNested for [T] +where + T: SerializeNested + Serialize, +{ + fn serialize_nested(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut seq = serializer.serialize_seq(Some(self.len()))?; + for item in self { + seq.serialize_element(&Nested(item))?; + } + seq.end() + } +} + +impl SerializeNested for Vec +where + T: SerializeNested + Serialize, +{ + fn serialize_nested(&self, serializer: S) -> Result + where + S: Serializer, + { + self.as_slice().serialize_nested(serializer) + } +} + +impl SerializeNested for [T; N] +where + T: SerializeNested + Serialize, +{ + fn serialize_nested(&self, serializer: S) -> Result + where + S: Serializer, + { + self.as_slice().serialize_nested(serializer) + } +} + +impl SerializeNested for Option +where + T: SerializeNested + Serialize, +{ + fn serialize_nested(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Some(value) => serializer.serialize_some(&Nested(value)), + None => serializer.serialize_none(), + } + } +} + +/// Writes a single array in the nested format, at any dimensionality. +pub(crate) struct ArrayWrapper<'a, D, Dm>(pub &'a ArrayBase) +where + D: Data, + Dm: Dimension; + +impl Serialize for ArrayWrapper<'_, D, Dm> +where + D: Data, + D::Elem: Serialize, + Dm: Dimension, + ArrayBase: Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // `serde_ndim` requires at least 1 dimension, and a 0-D array has no nested form that + // could be read back anyway, so fall back to the `ndarray` format for that case. + if self.0.ndim() == 0 { + self.0.serialize(serializer) + } else { + serde_ndim::serialize(self.0, serializer) + } + } +} + +/// Writes a fixed-length grid in the nested format. +#[derive(Serialize)] +pub(crate) struct GridArrWrapper<'a, D, const N: usize>( + #[serde(serialize_with = "serialize_grid_arr")] pub &'a [ArrayBase; N], +) +where + D: Data, + D::Elem: Serialize; + +/// Writes a variable-length grid in the nested format. +#[derive(Serialize)] +pub(crate) struct GridVecWrapper<'a, D>( + #[serde(serialize_with = "serialize_grid_vec")] pub &'a [ArrayBase], +) +where + D: Data, + D::Elem: Serialize; + +/// Write a fixed-length coordinate grid as a sequence of nested arrays. +pub fn serialize_grid_arr( + grid: &[ArrayBase; N], + serializer: S, +) -> Result +where + D: Data, + D::Elem: Serialize, + S: Serializer, +{ + let mut seq = serializer.serialize_seq(Some(N))?; + for arr in grid { + seq.serialize_element(&ArrayWrapper(arr))?; + } + seq.end() +} + +/// Write a variable-length coordinate grid as a sequence of nested arrays. +pub fn serialize_grid_vec( + grid: &[ArrayBase], + serializer: S, +) -> Result +where + D: Data, + D::Elem: Serialize, + S: Serializer, +{ + let mut seq = serializer.serialize_seq(Some(grid.len()))?; + for arr in grid { + seq.serialize_element(&ArrayWrapper(arr))?; + } + seq.end() +} diff --git a/tests/serde_formats.rs b/tests/serde_formats.rs new file mode 100644 index 0000000..af9e909 --- /dev/null +++ b/tests/serde_formats.rs @@ -0,0 +1,232 @@ +//! Round-trip coverage for both array formats, across every serializable type. +//! +//! The `Nested` impls are written by hand, so they can drift out of sync with the derived +//! `Deserialize` (a renamed field, a field added to one but not the other). Round-tripping every +//! type through both formats is what catches that. +#![cfg(feature = "serde")] + +use ndarray::prelude::*; +use ninterp::data::{InterpData3DOwned, InterpDataND, InterpDataNDOwned}; +use ninterp::prelude::*; + +/// Assert that a value survives a round trip in both the `ndarray` and nested-array formats, +/// and that the two formats really are different on the wire. +fn both_formats(value: &T) +where + T: serde::Serialize + serde::de::DeserializeOwned + SerializeNested + PartialEq, +{ + let plain = serde_json::to_string(value).unwrap(); + let nested = serde_json::to_string(&Nested(value)).unwrap(); + + let from_plain: T = serde_json::from_str(&plain).unwrap(); + assert!( + from_plain == *value, + "`ndarray` format failed to round-trip" + ); + + let from_nested: T = serde_json::from_str(&nested).unwrap(); + assert!( + from_nested == *value, + "nested format failed to round-trip: {nested}" + ); + + // Each format must also read the other's output, since the reader is format-agnostic. + let cross: T = serde_json::from_str(&plain).unwrap(); + assert!(cross == *value); +} + +fn interp_1d() -> Interp1DOwned { + Interp1D::new( + array![0., 1., 2., 3.], + array![0.2, 0.4, 0.6, 0.8], + strategy::Linear, + Extrapolate::Error, + ) + .unwrap() +} + +fn interp_2d() -> Interp2DOwned { + Interp2D::new( + array![0., 1.], + array![0., 1.], + array![[0., 1.], [2., 3.]], + strategy::Linear, + Extrapolate::Error, + ) + .unwrap() +} + +fn interp_3d() -> Interp3DOwned { + Interp3D::new( + array![0., 1.], + array![0., 1.], + array![0., 1.], + array![[[0., 1.], [2., 3.]], [[4., 5.], [6., 7.]]], + strategy::Linear, + Extrapolate::Error, + ) + .unwrap() +} + +fn interp_nd() -> InterpNDOwned { + InterpND::new( + vec![array![0., 1.], array![0., 1.]], + array![[0., 1.], [2., 3.]].into_dyn(), + strategy::Linear, + Extrapolate::Error, + ) + .unwrap() +} + +#[test] +fn round_trip_interpolators() { + both_formats(&Interp0D::new(0.5)); + both_formats(&interp_1d()); + both_formats(&interp_2d()); + both_formats(&interp_3d()); + both_formats(&interp_nd()); +} + +#[test] +fn round_trip_data() { + both_formats(&interp_1d().data); + both_formats(&interp_2d().data); + both_formats(&interp_3d().data); + both_formats(&interp_nd().data); +} + +/// Untagged enums fail confusingly when a variant's field names shift, so cover every variant. +#[test] +fn round_trip_interpolator_enum() { + let variants: Vec> = vec![ + InterpolatorEnum::new_0d(0.5), + InterpolatorEnum::new_1d( + array![0., 1., 2., 3.], + array![0.2, 0.4, 0.6, 0.8], + strategy::Linear, + Extrapolate::Error, + ) + .unwrap(), + InterpolatorEnum::new_2d( + array![0., 1.], + array![0., 1.], + array![[0., 1.], [2., 3.]], + strategy::Linear, + Extrapolate::Error, + ) + .unwrap(), + InterpolatorEnum::new_3d( + array![0., 1.], + array![0., 1.], + array![0., 1.], + array![[[0., 1.], [2., 3.]], [[4., 5.], [6., 7.]]], + strategy::Linear, + Extrapolate::Error, + ) + .unwrap(), + // 4-D: `InterpolatorEnum` is `#[serde(untagged)]`, so an N-D interpolator whose + // dimensionality also fits a concrete variant deserializes as that concrete variant. + // Use a dimensionality no concrete variant can claim. + InterpolatorEnum::new_nd( + vec![array![0., 1.]; 4], + Array4::from_shape_fn((2, 2, 2, 2), |(i, j, k, l)| (i + j + k + l) as f64).into_dyn(), + strategy::Linear, + Extrapolate::Error, + ) + .unwrap(), + ]; + for variant in &variants { + both_formats(variant); + } +} + +/// `Nested` must reach through containers, not just bare values. +#[test] +fn round_trip_through_containers() { + let curves = vec![interp_1d(), interp_1d()]; + let nested = serde_json::to_string(&Nested(&curves)).unwrap(); + assert!(nested.starts_with("[{\"data\":{\"grid\":[[0.0,1.0,2.0,3.0]]")); + + let de: Vec> = serde_json::from_str(&nested).unwrap(); + assert_eq!(de, curves); + + let some = Some(interp_1d()); + let de: Option> = + serde_json::from_str(&serde_json::to_string(&Nested(&some)).unwrap()).unwrap(); + assert_eq!(de, some); +} + +/// `serialize_with` on a field of someone else's struct. +#[test] +fn serialize_with_on_a_field() { + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] + struct Config { + name: String, + #[serde(serialize_with = "serialize_nested")] + curve: Interp1DOwned, + } + + let config = Config { + name: "efficiency".into(), + curve: interp_1d(), + }; + let ser = serde_json::to_string(&config).unwrap(); + assert!( + ser.contains("\"grid\":[[0.0,1.0,2.0,3.0]]"), + "field was not nested: {ser}" + ); + assert_eq!(serde_json::from_str::(&ser).unwrap(), config); +} + +/// Non-self-describing formats cannot support `deserialize_any`, so the reader must fall back to +/// the `ndarray` format for them rather than failing outright. +#[test] +fn binary_formats_round_trip() { + let data = interp_3d().data; + let bytes = bincode::serialize(&data).unwrap(); + assert_eq!( + bincode::deserialize::>(&bytes).unwrap(), + data + ); + + let nd = interp_nd().data; + let bytes = bincode::serialize(&nd).unwrap(); + assert_eq!( + bincode::deserialize::>(&bytes).unwrap(), + nd + ); + + let interp = interp_1d(); + let bytes = bincode::serialize(&interp).unwrap(); + assert_eq!( + bincode::deserialize::>(&bytes).unwrap(), + interp + ); + + // `Nested` degrades to the `ndarray` format here, so it must still round-trip. + let bytes = bincode::serialize(&Nested(&interp)).unwrap(); + assert_eq!( + bincode::deserialize::>(&bytes).unwrap(), + interp + ); +} + +/// `InterpDataND` permits zero dimensions, which has no nested representation, so the values +/// array must fall back to the `ndarray` format for the value to still round-trip. +#[test] +fn zero_dimensional_nd_data() { + let data = InterpDataND::new(vec![], Array0::from_elem((), 0.5).into_dyn()).unwrap(); + + let plain = serde_json::to_string(&data).unwrap(); + assert_eq!( + serde_json::from_str::>(&plain).unwrap(), + data + ); + + let nested = serde_json::to_string(&Nested(&data)).unwrap(); + assert_eq!( + serde_json::from_str::>(&nested).unwrap(), + data, + "0-D values failed to round-trip in nested format: {nested}" + ); +} From 87ecc042c76f9c0d0866d0bfbc8aed9e428ee27e Mon Sep 17 00:00:00 2001 From: Kyle Carow Date: Fri, 7 Aug 2026 00:16:38 -0600 Subject: [PATCH 2/2] Fix ambiguous-import-visibilities clippy error on newer rustc (rust-lang/rust#149145) --- src/serde/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/serde/mod.rs b/src/serde/mod.rs index 58ab3a8..78cd897 100644 --- a/src/serde/mod.rs +++ b/src/serde/mod.rs @@ -20,8 +20,10 @@ use super::*; pub(crate) mod de; pub(crate) mod ser; -pub(crate) use de::*; -pub(crate) use ser::*; +pub(crate) use de::{ + deserialize_dyn, deserialize_fixed, deserialize_grid_arr, deserialize_grid_vec, +}; +pub(crate) use ser::{ArrayWrapper, GridArrWrapper, GridVecWrapper}; /// The public surface, re-exported from [`crate::prelude`]. pub use ser::{serialize_nested, Nested, SerializeNested};