// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package core import "math" // Monotone cubic interpolation. The Fritsch-Carlson tangents keep the // curve inside the data's own range between knots: interpolating a // monotone series cannot overshoot, which the plain cubic spline does // around sharp turns and which is the reason this variant exists. // InterpolateMonotone evaluates the monotone piecewise cubic (PCHIP) // through the samples at every query point. xs must be strictly // increasing, ys real, and at least two samples long; queries outside // [x0, xn] hold the boundary value, matching Interpolate's convention. // At every knot the curve passes through the sample, and its slope // there never exceeds twice the neighbouring secants, which is what // keeps the interpolation inside the local data range. func InterpolateMonotone(xs, ys, query *Array) (*Array, error) { if xs.dt == Complex || ys.dt == Complex || query.dt == Complex { return nil, errf("InterpolateMonotone: complex samples are not supported") } n := xs.Len() if n != ys.Len() || n < 2 { return nil, errf("InterpolateMonotone: xs/ys must share length ≥ 2") } for k := 1; k < n; k++ { xk, xprev := xs.FloatAt(k), xs.FloatAt(k-1) // NaN defeats the <= ordering test below (every comparison is // false), so non-finiteness is refused by name first. if math.IsNaN(xk) || math.IsInf(xk, 0) || math.IsNaN(xprev) || math.IsInf(xprev, 0) { return nil, errf("InterpolateMonotone: knot %d is not finite", k) } if xk <= xprev { return nil, errf("InterpolateMonotone: xs must be strictly increasing, knot %d repeats", k) } } h := make([]float64, n-1) delta := make([]float64, n-1) for k := range n - 1 { h[k] = xs.FloatAt(k+1) - xs.FloatAt(k) delta[k] = (ys.FloatAt(k+1) - ys.FloatAt(k)) / h[k] } // Fritsch-Carlson tangents: zero where the data turns, the // weighted harmonic mean of the neighbouring secants elsewhere, // the clamped three-point estimate at the ends. m := make([]float64, n) switch { case n == 2: m[0], m[1] = delta[0], delta[0] default: m[0] = ((2*h[0]+h[1])*delta[0] - h[0]*delta[1]) / (h[0] + h[1]) if oppositeSigns(m[0], delta[0]) { m[0] = 0 } else if math.Abs(m[0]) > 2*math.Abs(delta[0]) { m[0] = 2 * delta[0] } m[n-1] = ((2*h[n-2]+h[n-3])*delta[n-2] - h[n-3]*delta[n-3]) / (h[n-2] + h[n-3]) if oppositeSigns(m[n-1], delta[n-2]) { m[n-1] = 0 } else if math.Abs(m[n-1]) > 2*math.Abs(delta[n-2]) { m[n-1] = 2 * delta[n-2] } for k := 1; k < n-1; k++ { if delta[k-1]*delta[k] <= 0 { m[k] = 0 continue } w1 := 2*h[k] + h[k-1] w2 := h[k] + 2*h[k-1] m[k] = (w1 + w2) / (w1/delta[k-1] + w2/delta[k]) } } out := &Array{shape: append([]int{}, query.Shape()...), dt: Float} out.alloc(query.Len()) for i := range query.Len() { q := query.FloatAt(i) // NaN compares false against both clamps below and would flow // through the evaluation as NaN with no error, the same trap // InterpolateGrid refuses: an undefined position is a loud // error, since there is nothing sensible to clamp it to. if math.IsNaN(q) { return nil, errf("InterpolateMonotone: query %d is NaN, which cannot be clamped", i) } lo := 0 if q <= xs.FloatAt(0) { lo = 0 } else if q >= xs.FloatAt(n-1) { lo = n - 2 } else { for lo < n-2 && q > xs.FloatAt(lo+1) { lo++ } } t := (q - xs.FloatAt(lo)) / h[lo] if t < 0 { t = 0 } if t > 1 { t = 1 } t2, t3 := t*t, t*t*t y0, y1 := ys.FloatAt(lo), ys.FloatAt(lo+1) v := (2*t3-3*t2+1)*y0 + (t3-2*t2+t)*h[lo]*m[lo] + (-2*t3+3*t2)*y1 + (t3-t2)*h[lo]*m[lo+1] out.SetFloatAt(i, v) } return out, nil } // oppositeSigns reports whether two values carry strict opposite // signs, the condition under which a tangent estimate is discarded as // turning. func oppositeSigns(a, b float64) bool { return (a > 0 && b < 0) || (a < 0 && b > 0) }