This package enables index to value mapping both in a forward and backward way.
Inverse retrieval of indices from given values can be done with different matching
rules (None, nearest, forward-fill, backward-fill). Results are exact even when
using big integer values (e.g., nanosecond datetime64), and dtypes that are
integer, floating, datetime64 or timedelta64 are all supported for fp.
method="nearest" clamps without limit: a value beyond the first or last point
resolves to that boundary's index, however far outside the range it falls. Where
rounding to an integer is needed, ties round to even (banker's rounding), matching
the behaviour of round() and numpy.round.
An axis can be described in two ways, and the package mirrors the same operations across both.
Points -- the knots are listed, and each piece implies its own slope. Use
forward_points / inverse_points when the axis is irregular and every knot
matters.
Step -- the knots are generated from a starting value at a fixed exact rate
num / den. Use forward_step / inverse_step when the axis is regular: a long
run of samples costs two integers rather than a knot each, and the rate stays
exact because it is never collapsed to a float.
| points | step | |
|---|---|---|
| index to value | forward_points |
forward_step |
| value to index | inverse_points |
inverse_step |
| drop redundant knots | simplify_points |
simplify_step |
| fit a rate | -- | infer_step |
| residual against a rate | -- | deviation_step |
Values live on the tick grid of their dtype and every result is the exactly rounded one, so the following hold at every sample rather than on average. They hold identically for the points and the step family.
Round trip, index to value and back. inverse(forward(x)) == x whenever the
mapping is injective -- that is, whenever values advance by at least one tick per
index step (|num| >= den for the step family). Below that rate several indices
share a value, and inverse returns one of them.
Round trip, value to index and back. forward(inverse(f)) == f whenever
inverse returns. Under method=None a value the curve never attains raises
KeyError rather than resolving to a neighbour; the other methods resolve to a
neighbouring index by design, so the law does not apply to them.
Simplification is lossless at zero. simplify(x, 0) reproduces every sample
exactly. The walk is conservative there: it drops a knot only when it can prove
the drop costs nothing, so at zero it may well keep everything.
Simplification is bounded elsewhere. No sample moves by more than tol.
simplify_step takes tol in ticks and applies it directly. simplify_points
measures its budget against the unrounded curve, so pass en / ed = tol + 1/2
to get the same bound on the reconstruction.
pip install xinterp
import numpy as np
from xinterp import forward_points, inverse_points
xp = np.array([0, 10, 20])
fp = np.array([0, 1000, 2000], dtype="datetime64[s]")
x = np.array([0, 5, 10, 15, 20])
result = forward_points(x, xp, fp)
expected = np.array([0, 500, 1000, 1500, 2000], dtype="datetime64[s]")
assert np.array_equal(result, expected)
x = np.array([1, 499, 1001, 1503, 1997], dtype="datetime64[s]")
result = inverse_points(x, xp, fp, method="nearest")
expected = np.array([0, 5, 10, 15, 20])
assert np.array_equal(result, expected)The rate is a ratio, so a 2.5 s sampling interval is num, den = 5, 2 and stays
exact. Values landing on a half tick round to even:
import numpy as np
from xinterp import forward_step, inverse_step
tie_indices = np.array([0, 4])
tie_values = np.array([0, 10], dtype="datetime64[s]")
num, den = 5, 2
x = np.array([0, 1, 2, 3, 4])
result = forward_step(x, tie_indices, tie_values, num, den)
expected = np.array([0, 2, 5, 8, 10], dtype="datetime64[s]")
assert np.array_equal(result, expected)
f = np.array([0, 2, 5, 8, 10], dtype="datetime64[s]")
result = inverse_step(f, tie_indices, tie_values, num, den)
assert np.array_equal(result, x)infer_step recovers that ratio from an axis, and reports the worst per-segment
deviation from it, so you can tell whether a single rate is honest before
adopting one. deviation_step gives the residual segment by segment:
import numpy as np
from xinterp import deviation_step, infer_step
x = np.array([0, 10, 20, 30])
f = np.array([0, 25, 50, 75], dtype="datetime64[s]")
assert infer_step(x, f) == (5, 2, 0) # 2.5 s per index, fitting every segment
assert np.array_equal(deviation_step(x, f, 5, 2), [0, 0, 0])simplify_points returns a mask of the knots worth keeping. Tolerance is the
exact ratio en / ed, measured against the unrounded curve -- to bound the
reconstructed value by tol ticks, pass tol + 1/2:
import numpy as np
from xinterp import forward_points, simplify_points
xp = np.array([0, 10, 20, 30, 40])
fp = np.array([0, 1001, 1999, 3002, 4000], dtype="datetime64[s]")
keep = simplify_points(xp, fp, 5, 2) # 5/2 == tol 2 ticks + 1/2
assert np.array_equal(keep, [True, False, False, False, True])
recon = forward_points(xp, xp[keep], fp[keep])
assert np.abs((recon - fp).astype("i8")).max() <= 2 # the promised boundsimplify_step is the twin for rate-described segments: it fuses consecutive
runs whose declared step agrees, and re-anchors each survivor. It returns the
keep mask alongside the re-anchored tie values:
import numpy as np
from xinterp import simplify_step
tie_values = np.array([0, 1000, 2000])
tie_lengths = np.array([10, 10, 10])
keep, fused = simplify_step(tie_values, tie_lengths, [100], [1], 0)
assert np.array_equal(keep, [True, False, False]) # one run, not three
assert np.array_equal(fused, [0])forward and inverse are now forward_points and inverse_points. The old
names still work as aliases but emit a DeprecationWarning, and are scheduled
for removal in 0.5:
from xinterp import forward_points as forward # was: from xinterp import forwardNothing else in the 0.1 surface changed. See CHANGELOG.md for the full list.
The project is developed with uv and maturin:
uv sync # build the extension and install the dev dependencies
uv run pytest # Python suite
cargo test # Rust suiteSee CONTRIBUTING.md for the full toolchain.