splimes 1.1.0

Resample irregularly sampled time series onto a regular grid with local spline interpolation, on CPU (rayon) or GPU (wgpu), with BigDecimal or f64 values.
Documentation
<p align="center">
  <img src="https://raw.githubusercontent.com/basic-automation/splimes/main/assets/logo.svg" alt="splimes" width="420">
</p>

<p align="center">
  <a href="https://crates.io/crates/splimes"><img src="https://img.shields.io/crates/v/splimes.svg" alt="crates.io"></a>
  <a href="https://docs.rs/splimes"><img src="https://img.shields.io/docsrs/splimes" alt="docs.rs"></a>
  <a href="https://github.com/basic-automation/splimes/actions/workflows/ci.yml"><img src="https://github.com/basic-automation/splimes/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  <img src="https://img.shields.io/badge/MSRV-1.95-blue.svg" alt="MSRV 1.95">
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
</p>

Resample **irregularly sampled** time series onto a regular grid.

You have points that arrived whenever they arrived; you want a value every second,
minute or day. `splimes` fits a local spline through the points, evaluates it on the
grid, and labels every output point as an observation, an interpolation or an
extrapolation.

- **Methods:** linear, quadratic, cubic and polynomial (degree 1 to 8). Each is
  precisely defined, inside the data and outside it, and every backend computes the
  same thing.
- **Backends:** one CPU thread, rayon's thread pool, or the GPU via
  [`wgpu`]https://wgpu.rs (Vulkan, Metal, DX12), picked per call by size or chosen
  explicitly. A GPU failure never panics: automatic selection reruns on the CPU and
  tells you why.
- **Values:** `BigDecimal` or `f64`. Computation is in `f64` (or `f32` on the GPU, if
  you ask), against a published, tested error bound. Points that land on an input
  return that input exactly, every digit intact.
- **Provenance:** each output point is `Raw`, `Interpolated` or `Extrapolated`.
- **Honest reporting:** the result says which method and backend actually ran. If
  there are too few points for the method you asked for, it steps down (cubic →
  quadratic → linear) and says so, or refuses if you'd rather it didn't.

## Quick start

```toml
[dependencies]
splimes = "1"
bigdecimal = "0.4"
chrono = "0.4"
```

```rust
use bigdecimal::BigDecimal;
use chrono::{TimeZone, Utc};
use splimes::{Point, PointKind, Resolution, Spline};

fn main() -> Result<(), splimes::Error> {
    let at = |secs: i64| Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap();

    // Four readings, unevenly spaced.
    let readings = [
        Point::new(at(0), BigDecimal::from(10)),
        Point::new(at(7), BigDecimal::from(14)),
        Point::new(at(19), BigDecimal::from(11)),
        Point::new(at(30), BigDecimal::from(20)),
    ];

    // A value every second, with a cubic spline, a little past the last reading.
    let series = splimes::interpolate(&readings, at(0), at(33), Resolution::Seconds, Spline::Cubic)?;

    for (timestamp, value, kind) in series.iter().take(3) {
        println!("{timestamp} {value} {kind}");
    }
    assert_eq!(series.kinds()[7], PointKind::Raw);
    assert_eq!(series.kinds()[32], PointKind::Extrapolated);
    Ok(())
}
```

For more control, configure an `Interpolator`:

```rust
use chrono::{TimeDelta, TimeZone, Utc};
use splimes::{Backend, Interpolator, Resolution, Spline};

let t0 = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
let timestamps = [t0, t0 + TimeDelta::seconds(4), t0 + TimeDelta::seconds(9)];
let values = [1.0, 3.5, 2.0];

let series = Interpolator::new(Spline::Quadratic, Resolution::Milliseconds)
    .backend(Backend::Parallel) // or Auto (the default), Cpu, Gpu
    .exact(true)                // error instead of stepping down to a simpler method
    .run_f64(&timestamps, &values, t0, t0 + TimeDelta::seconds(9))?; // f64 in, f64 out

assert_eq!(series.len(), 9_001);
assert_eq!(series.spline(), Spline::Quadratic);
# Ok::<(), splimes::Error>(())
```

From async code, `spawn` (or `spawn_f64`) runs the work on rayon's pool and returns a
future that any executor can await, with no feature flag:
`interpolator.spawn(points, start, end).await?`. Dropping the future cancels work that
hasn't started, and with the GPU it waits for work that has, so the process can exit
safely; `spawn`'s docs say when the GPU is used and the cases that don't wait. With the
`tokio` feature, `run_async` uses tokio's blocking pool instead.

## Accuracy

Every backend computes from the same normalised inputs, with every time difference
taken exactly, and `tests/contract.rs` checks each one against an exact 60-digit
reference. Each result is as accurate as perturbing every input value by **10⁻¹³** of
the value range allows in `f64` (CPU or GPU), or **10⁻⁵** in GPU `f32` — for every
method, inside the data and out, however long the series. Where the problem is
ill-conditioned (very irregular spacing, high degree, far extrapolation) the error grows
exactly as much as the method amplifies such a perturbation, and no more. The
[crate documentation](https://docs.rs/splimes/latest/splimes/#numerical-contract)
has the precise statement. It is part of the semver promise.

## Performance

On a 16-core Ryzen 9 7950X3D with an RTX 4070 Ti SUPER, a cubic resample of 4,096
irregular points onto a 16.7-million-point grid takes about **100 ms** on rayon's pool,
against 670 ms on one thread, and a million points about 7–12 ms. Building the output
(timestamps, values, provenance) costs the same whichever backend computed the values,
and it dominates, so the GPU gains little: here it at best ties rayon in `f64` and wins
modestly in `f32` at a million points and more. `Backend::Auto` therefore uses the GPU
only once `splimes::calibrate()` has started it and measured that it pays off on your
machine. [BENCHMARKS.md](BENCHMARKS.md) has the numbers and how to reproduce them.

## Features

| Feature | Default | Effect |
|---------|---------|--------|
| `gpu` | yes | The wgpu backend. Without it, `Backend::Gpu` returns `Error::GpuUnavailable`, and the build drops wgpu entirely. |
| `serde` | yes | `Serialize`/`Deserialize` for `Point`, `PointKind`, `Resolution` and `Spline`. |
| `tokio` | no | `Interpolator::run_async` and `run_f64_async`. |

## Stability

`splimes` 1.x follows [semantic versioning](https://semver.org). Covered: the public
API, what each method computes (beyond rounding within the published bound), and the
serde formats. The minimum supported Rust version is **1.95**; raising it is a
minor-version change, announced in the [changelog](CHANGELOG.md), and only to a
version at least six months old. Upgrading from 0.1? See [MIGRATING.md](MIGRATING.md).

`splimes` is the interpolation engine behind [WeftDB](https://github.com/basic-automation/weftdb),
but it has no dependency on the database and works on its own.

## Contributing

`cargo test` runs anywhere. Without a GPU, the GPU tests skip themselves; set
`SPLIMES_REQUIRE_GPU=1` to make a missing GPU a failure instead, or
`SPLIMES_REQUIRE_GPU_F64=1` to require one with `f64` support too (CI sets that, on a
software Vulkan driver). Formatting uses nightly rustfmt: `cargo +nightly fmt`.

## License

MIT; see [LICENSE](LICENSE).