// Copyright ©2020 The Gonum Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package interp import ( "math" "gonum.org/v1/gonum/mat" ) // PiecewiseCubic is a piecewise cubic 1-dimensional interpolator with // continuous value and first derivative. type PiecewiseCubic struct { // Interpolated X values. xs []float64 // Coefficients of interpolating cubic polynomials, with // len(xs) - 1 rows and 4 columns. The interpolated value // for xs[i] <= x < xs[i + 1] is defined as // sum_{k = 0}^3 coeffs.At(i, k) * (x - xs[i])^k // To guarantee left-continuity, coeffs.At(i, 0) == ys[i]. coeffs mat.Dense // Last interpolated Y value, corresponding to xs[len(xs) - 1]. lastY float64 // Last interpolated dY/dX value, corresponding to xs[len(xs) - 1]. lastDyDx float64 } // Predict returns the interpolation value at x. func (pc *PiecewiseCubic) Predict(x float64) float64 { i := findSegment(pc.xs, x) if i < 0 { return pc.coeffs.At(0, 0) } m := len(pc.xs) - 1 if x == pc.xs[i] { if i < m { return pc.coeffs.At(i, 0) } return pc.lastY } if i == m { return pc.lastY } dx := x - pc.xs[i] a := pc.coeffs.RawRowView(i) return ((a[3]*dx+a[2])*dx+a[1])*dx + a[0] } // PredictDerivative returns the predicted derivative at x. func (pc *PiecewiseCubic) PredictDerivative(x float64) float64 { i := findSegment(pc.xs, x) if i < 0 { return pc.coeffs.At(0, 1) } m := len(pc.xs) - 1 if x == pc.xs[i] { if i < m { return pc.coeffs.At(i, 1) } return pc.lastDyDx } if i == m { return pc.lastDyDx } dx := x - pc.xs[i] a := pc.coeffs.RawRowView(i) return (3*a[3]*dx+2*a[2])*dx + a[1] } // FitWithDerivatives fits a piecewise cubic predictor to (X, Y, dY/dX) value // triples provided as three slices. // It panics if len(xs) < 2, elements of xs are not strictly increasing, // len(xs) != len(ys) or len(xs) != len(dydxs). func (pc *PiecewiseCubic) FitWithDerivatives(xs, ys, dydxs []float64) { n := len(xs) if len(ys) != n { panic(differentLengths) } if len(dydxs) != n { panic(differentLengths) } if n < 2 { panic(tooFewPoints) } m := n - 1 pc.coeffs.Reset() pc.coeffs.ReuseAs(m, 4) for i := 0; i < m; i++ { dx := xs[i+1] - xs[i] if dx <= 0 { panic(xsNotStrictlyIncreasing) } dy := ys[i+1] - ys[i] // a_0 pc.coeffs.Set(i, 0, ys[i]) // a_1 pc.coeffs.Set(i, 1, dydxs[i]) // Solve a linear equation system for a_2 and a_3. pc.coeffs.Set(i, 2, (3*dy-(2*dydxs[i]+dydxs[i+1])*dx)/dx/dx) pc.coeffs.Set(i, 3, (-2*dy+(dydxs[i]+dydxs[i+1])*dx)/dx/dx/dx) } pc.xs = append(pc.xs[:0], xs...) pc.lastY = ys[m] pc.lastDyDx = dydxs[m] } // AkimaSpline is a piecewise cubic 1-dimensional interpolator with // continuous value and first derivative, which can be fitted to (X, Y) // value pairs without providing derivatives. // See https://www.iue.tuwien.ac.at/phd/rottinger/node60.html for more details. type AkimaSpline struct { cubic PiecewiseCubic } // Predict returns the interpolation value at x. func (as *AkimaSpline) Predict(x float64) float64 { return as.cubic.Predict(x) } // PredictDerivative returns the predicted derivative at x. func (as *AkimaSpline) PredictDerivative(x float64) float64 { return as.cubic.PredictDerivative(x) } // Fit fits a predictor to (X, Y) value pairs provided as two slices. // It panics if len(xs) < 2, elements of xs are not strictly increasing // or len(xs) != len(ys). Always returns nil. func (as *AkimaSpline) Fit(xs, ys []float64) error { n := len(xs) if len(ys) != n { panic(differentLengths) } dydxs := make([]float64, n) if n == 2 { dx := xs[1] - xs[0] slope := (ys[1] - ys[0]) / dx dydxs[0] = slope dydxs[1] = slope as.cubic.FitWithDerivatives(xs, ys, dydxs) return nil } slopes := akimaSlopes(xs, ys) for i := 0; i < n; i++ { wLeft, wRight := akimaWeights(slopes, i) dydxs[i] = akimaWeightedAverage(slopes[i+1], slopes[i+2], wLeft, wRight) } as.cubic.FitWithDerivatives(xs, ys, dydxs) return nil } // akimaSlopes returns slopes for Akima spline method, including the approximations // of slopes outside the data range (two on each side). // It panics if len(xs) <= 2, elements of xs are not strictly increasing // or len(xs) != len(ys). func akimaSlopes(xs, ys []float64) []float64 { n := len(xs) if n <= 2 { panic(tooFewPoints) } if len(ys) != n { panic(differentLengths) } m := n + 3 slopes := make([]float64, m) for i := 2; i < m-2; i++ { dx := xs[i-1] - xs[i-2] if dx <= 0 { panic(xsNotStrictlyIncreasing) } slopes[i] = (ys[i-1] - ys[i-2]) / dx } slopes[0] = 3*slopes[2] - 2*slopes[3] slopes[1] = 2*slopes[2] - slopes[3] slopes[m-2] = 2*slopes[m-3] - slopes[m-4] slopes[m-1] = 3*slopes[m-3] - 2*slopes[m-4] return slopes } // akimaWeightedAverage returns (v1 * w1 + v2 * w2) / (w1 + w2) for w1, w2 >= 0 (not checked). // If w1 == w2 == 0, it returns a simple average of v1 and v2. func akimaWeightedAverage(v1, v2, w1, w2 float64) float64 { w := w1 + w2 if w > 0 { return (v1*w1 + v2*w2) / w } return 0.5*v1 + 0.5*v2 } // akimaWeights returns the left and right weight for approximating // the i-th derivative with neighbouring slopes. func akimaWeights(slopes []float64, i int) (float64, float64) { wLeft := math.Abs(slopes[i+2] - slopes[i+3]) wRight := math.Abs(slopes[i+1] - slopes[i]) return wLeft, wRight } // FritschButland is a piecewise cubic 1-dimensional interpolator with // continuous value and first derivative, which can be fitted to (X, Y) // value pairs without providing derivatives. // It is monotone, local and produces a C^1 curve. Its downside is that // exhibits high tension, flattening out unnaturally the interpolated // curve between the nodes. // See Fritsch, F. N. and Butland, J., "A method for constructing local // monotone piecewise cubic interpolants" (1984), SIAM J. Sci. Statist. // Comput., 5(2), pp. 300-304. type FritschButland struct { cubic PiecewiseCubic } // Predict returns the interpolation value at x. func (fb *FritschButland) Predict(x float64) float64 { return fb.cubic.Predict(x) } // PredictDerivative returns the predicted derivative at x. func (fb *FritschButland) PredictDerivative(x float64) float64 { return fb.cubic.PredictDerivative(x) } // Fit fits a predictor to (X, Y) value pairs provided as two slices. // It panics if len(xs) < 2, elements of xs are not strictly increasing // or len(xs) != len(ys). Always returns nil. func (fb *FritschButland) Fit(xs, ys []float64) error { n := len(xs) if n < 2 { panic(tooFewPoints) } if len(ys) != n { panic(differentLengths) } dydxs := make([]float64, n) if n == 2 { dx := xs[1] - xs[0] slope := (ys[1] - ys[0]) / dx dydxs[0] = slope dydxs[1] = slope fb.cubic.FitWithDerivatives(xs, ys, dydxs) return nil } slopes := calculateSlopes(xs, ys) m := len(slopes) prevSlope := slopes[0] for i := 1; i < m; i++ { slope := slopes[i] if slope*prevSlope > 0 { dydxs[i] = 3 * (xs[i+1] - xs[i-1]) / ((2*xs[i+1]-xs[i-1]-xs[i])/slopes[i-1] + (xs[i+1]+xs[i]-2*xs[i-1])/slopes[i]) } else { dydxs[i] = 0 } prevSlope = slope } dydxs[0] = fritschButlandEdgeDerivative(xs, ys, slopes, true) dydxs[m] = fritschButlandEdgeDerivative(xs, ys, slopes, false) fb.cubic.FitWithDerivatives(xs, ys, dydxs) return nil } // fritschButlandEdgeDerivative calculates dy/dx approximation for the // Fritsch-Butland method for the left or right edge node. func fritschButlandEdgeDerivative(xs, ys, slopes []float64, leftEdge bool) float64 { n := len(xs) var dE, dI, h, hE, f float64 if leftEdge { dE = slopes[0] dI = slopes[1] xE := xs[0] xM := xs[1] xI := xs[2] hE = xM - xE h = xI - xE f = xM + xI - 2*xE } else { dE = slopes[n-2] dI = slopes[n-3] xE := xs[n-1] xM := xs[n-2] xI := xs[n-3] hE = xE - xM h = xE - xI f = 2*xE - xI - xM } g := (f*dE - hE*dI) / h if g*dE <= 0 { return 0 } if dE*dI <= 0 && math.Abs(g) > 3*math.Abs(dE) { return 3 * dE } return g } // fitWithSecondDerivatives fits a piecewise cubic predictor to (X, Y, d^2Y/dX^2) value // triples provided as three slices. // It panics if any of these is true: // - len(xs) < 2, // - elements of xs are not strictly increasing, // - len(xs) != len(ys), // - len(xs) != len(d2ydx2s). // Note that this method does not guarantee on its own the continuity of first derivatives. func (pc *PiecewiseCubic) fitWithSecondDerivatives(xs, ys, d2ydx2s []float64) { n := len(xs) switch { case len(ys) != n, len(d2ydx2s) != n: panic(differentLengths) case n < 2: panic(tooFewPoints) } m := n - 1 pc.coeffs.Reset() pc.coeffs.ReuseAs(m, 4) for i := 0; i < m; i++ { dx := xs[i+1] - xs[i] if dx <= 0 { panic(xsNotStrictlyIncreasing) } dy := ys[i+1] - ys[i] dm := d2ydx2s[i+1] - d2ydx2s[i] pc.coeffs.Set(i, 0, ys[i]) // a_0 pc.coeffs.Set(i, 1, (dy-(d2ydx2s[i]+dm/3)*dx*dx/2)/dx) // a_1 pc.coeffs.Set(i, 2, d2ydx2s[i]/2) // a_2 pc.coeffs.Set(i, 3, dm/6/dx) // a_3 } pc.xs = append(pc.xs[:0], xs...) pc.lastY = ys[m] lastDx := xs[m] - xs[m-1] pc.lastDyDx = pc.coeffs.At(m-1, 1) + 2*pc.coeffs.At(m-1, 2)*lastDx + 3*pc.coeffs.At(m-1, 3)*lastDx*lastDx } // makeCubicSplineSecondDerivativeEquations generates the basic system of linear equations // which have to be satisfied by the second derivatives to make the first derivatives of a // cubic spline continuous. It panics if elements of xs are not strictly increasing, or // len(xs) != len(ys). // makeCubicSplineSecondDerivativeEquations fills a banded matrix a and a vector b // defining a system of linear equations a*m = b for second derivatives vector m. // Parameters a and b are assumed to have correct dimensions and initialised to zero. func makeCubicSplineSecondDerivativeEquations(a mat.MutableBanded, b mat.MutableVector, xs, ys []float64) { n := len(xs) if len(ys) != n { panic(differentLengths) } m := n - 1 if n > 2 { for i := 0; i < m; i++ { dx := xs[i+1] - xs[i] if dx <= 0 { panic(xsNotStrictlyIncreasing) } slope := (ys[i+1] - ys[i]) / dx if i > 0 { b.SetVec(i, b.AtVec(i)+slope) a.SetBand(i, i, a.At(i, i)+dx/3) a.SetBand(i, i+1, dx/6) } if i < m-1 { b.SetVec(i+1, b.AtVec(i+1)-slope) a.SetBand(i+1, i+1, a.At(i+1, i+1)+dx/3) a.SetBand(i+1, i, dx/6) } } } } // NaturalCubic is a piecewise cubic 1-dimensional interpolator with // continuous value, first and second derivatives, which can be fitted to (X, Y) // value pairs without providing derivatives. It uses the boundary conditions // Y′′(left end ) = Y′′(right end) = 0. // See e.g. https://www.math.drexel.edu/~tolya/cubicspline.pdf for details. type NaturalCubic struct { cubic PiecewiseCubic } // Predict returns the interpolation value at x. func (nc *NaturalCubic) Predict(x float64) float64 { return nc.cubic.Predict(x) } // PredictDerivative returns the predicted derivative at x. func (nc *NaturalCubic) PredictDerivative(x float64) float64 { return nc.cubic.PredictDerivative(x) } // Fit fits a predictor to (X, Y) value pairs provided as two slices. // It panics if len(xs) < 2, elements of xs are not strictly increasing // or len(xs) != len(ys). It returns an error if solving the required system // of linear equations fails. func (nc *NaturalCubic) Fit(xs, ys []float64) error { n := len(xs) a := mat.NewTridiag(n, nil, nil, nil) b := mat.NewVecDense(n, nil) makeCubicSplineSecondDerivativeEquations(a, b, xs, ys) // Add boundary conditions y′′(left) = y′′(right) = 0: b.SetVec(0, 0) b.SetVec(n-1, 0) a.SetBand(0, 0, 1) a.SetBand(n-1, n-1, 1) x := mat.NewVecDense(n, nil) err := a.SolveVecTo(x, false, b) if err == nil { nc.cubic.fitWithSecondDerivatives(xs, ys, x.RawVector().Data) } return err } // ClampedCubic is a piecewise cubic 1-dimensional interpolator with // continuous value, first and second derivatives, which can be fitted to (X, Y) // value pairs without providing derivatives. It uses the boundary conditions // Y′(left end ) = Y′(right end) = 0. type ClampedCubic struct { cubic PiecewiseCubic } // Predict returns the interpolation value at x. func (cc *ClampedCubic) Predict(x float64) float64 { return cc.cubic.Predict(x) } // PredictDerivative returns the predicted derivative at x. func (cc *ClampedCubic) PredictDerivative(x float64) float64 { return cc.cubic.PredictDerivative(x) } // Fit fits a predictor to (X, Y) value pairs provided as two slices. // It panics if len(xs) < 2, elements of xs are not strictly increasing // or len(xs) != len(ys). It returns an error if solving the required system // of linear equations fails. func (cc *ClampedCubic) Fit(xs, ys []float64) error { n := len(xs) a := mat.NewTridiag(n, nil, nil, nil) b := mat.NewVecDense(n, nil) makeCubicSplineSecondDerivativeEquations(a, b, xs, ys) // Add boundary conditions y′′(left) = y′′(right) = 0: // Condition Y′(left end) = 0: dxL := xs[1] - xs[0] b.SetVec(0, (ys[1]-ys[0])/dxL) a.SetBand(0, 0, dxL/3) a.SetBand(0, 1, dxL/6) // Condition Y′(right end) = 0: m := n - 1 dxR := xs[m] - xs[m-1] b.SetVec(m, (ys[m]-ys[m-1])/dxR) a.SetBand(m, m, -dxR/3) a.SetBand(m, m-1, -dxR/6) x := mat.NewVecDense(n, nil) err := a.SolveVecTo(x, false, b) if err == nil { cc.cubic.fitWithSecondDerivatives(xs, ys, x.RawVector().Data) } return err } // NotAKnotCubic is a piecewise cubic 1-dimensional interpolator with // continuous value, first and second derivatives, which can be fitted to (X, Y) // value pairs without providing derivatives. It imposes the condition that // the third derivative of the interpolant is continuous in the first and // last interior node. // See http://www.cs.tau.ac.il/~turkel/notes/numeng/spline_note.pdf for details. type NotAKnotCubic struct { cubic PiecewiseCubic } // Predict returns the interpolation value at x. func (nak *NotAKnotCubic) Predict(x float64) float64 { return nak.cubic.Predict(x) } // PredictDerivative returns the predicted derivative at x. func (nak *NotAKnotCubic) PredictDerivative(x float64) float64 { return nak.cubic.PredictDerivative(x) } // Fit fits a predictor to (X, Y) value pairs provided as two slices. // It panics if len(xs) < 3 (because at least one interior node is required), // elements of xs are not strictly increasing or len(xs) != len(ys). // It returns an error if solving the required system of linear equations fails. func (nak *NotAKnotCubic) Fit(xs, ys []float64) error { n := len(xs) if n < 3 { panic(tooFewPoints) } a := mat.NewBandDense(n, n, 2, 2, nil) b := mat.NewVecDense(n, nil) makeCubicSplineSecondDerivativeEquations(a, b, xs, ys) // Add boundary conditions. // First interior node: dxOuter := xs[1] - xs[0] dxInner := xs[2] - xs[1] a.SetBand(0, 0, 1/dxOuter) a.SetBand(0, 1, -1/dxOuter-1/dxInner) a.SetBand(0, 2, 1/dxInner) if n > 3 { // Last interior node: m := n - 1 dxOuter = xs[m] - xs[m-1] dxInner = xs[m-1] - xs[m-2] a.SetBand(m, m, 1/dxOuter) a.SetBand(m, m-1, -1/dxOuter-1/dxInner) a.SetBand(m, m-2, 1/dxInner) } x := mat.NewVecDense(n, nil) err := x.SolveVec(a, b) if err == nil { nak.cubic.fitWithSecondDerivatives(xs, ys, x.RawVector().Data) } return err }