Skip to content

Commit 7e104c0

Browse files
authored
Merge pull request #375 from fslaborg/repo-assist/improve-interpolation-integrate-20260413-a4be8a75855ed951
[Repo Assist] feat: add `integrate` to LinearSpline, Step, and CubicSpline
2 parents 087c66b + 4fad869 commit 7e104c0

2 files changed

Lines changed: 267 additions & 0 deletions

File tree

‎src/FSharp.Stats/Interpolation.fs‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,36 @@ module Interpolation =
567567
let k = leftSegmentIdx lsc.XValues x
568568
lsc.C1.[k]
569569

570+
/// <summary>
571+
/// Returns the definite integral of the linear spline from xVal1 to xVal2.
572+
/// </summary>
573+
/// <param name="lsc">Linear spline coefficients given as input x values, intersects, and slopes.</param>
574+
/// <param name="xVal1">Lower bound of integration.</param>
575+
/// <param name="xVal2">Upper bound of integration.</param>
576+
/// <returns>Definite integral (signed area under the curve) from xVal1 to xVal2.</returns>
577+
/// <remarks>xVal1 and xVal2 should lie within the range of the input x values; values outside the range are extrapolated using the nearest segment.</remarks>
578+
let rec getIntegralBetween (lsc: LinearSplineCoef) xVal1 xVal2 =
579+
if xVal1 > xVal2 then
580+
- getIntegralBetween lsc xVal2 xVal1
581+
elif xVal1 = xVal2 then 0.
582+
else
583+
// Integral of segment k from x1 to x2:
584+
// C0[k]*(x2-x1) + C1[k]*((x2-xk)^2 - (x1-xk)^2)/2
585+
let segmentIntegral k x1 x2 =
586+
let xk = lsc.XValues.[k]
587+
lsc.C0.[k] * (x2 - x1) + lsc.C1.[k] * ((x2 - xk) * (x2 - xk) - (x1 - xk) * (x1 - xk)) / 2.
588+
let k1 = leftSegmentIdx lsc.XValues xVal1
589+
let k2 = leftSegmentIdx lsc.XValues xVal2
590+
if k1 = k2 then
591+
segmentIntegral k1 xVal1 xVal2
592+
else
593+
let firstPart = segmentIntegral k1 xVal1 lsc.XValues.[k1 + 1]
594+
let lastPart = segmentIntegral k2 lsc.XValues.[k2] xVal2
595+
let middleParts =
596+
[ k1 + 1 .. k2 - 1 ]
597+
|> List.sumBy (fun k -> segmentIntegral k lsc.XValues.[k] lsc.XValues.[k + 1])
598+
firstPart + middleParts + lastPart
599+
570600
/// <summary>
571601
/// Module to create linear splines from x,y coordinates. x,y coordinates are interpolated by straight lines between two knots.
572602
/// </summary>
@@ -741,6 +771,38 @@ module Interpolation =
741771
let differentiate (lsc: StepCoef) x =
742772
0.
743773

774+
/// <summary>
775+
/// Returns the definite integral of the step function from xVal1 to xVal2.
776+
/// </summary>
777+
/// <param name="lsc">Step function coefficients given as input x values and intersects.</param>
778+
/// <param name="xVal1">Lower bound of integration.</param>
779+
/// <param name="xVal2">Upper bound of integration.</param>
780+
/// <returns>Definite integral (signed area under the step function) from xVal1 to xVal2.</returns>
781+
/// <remarks>xVal1 and xVal2 should lie within the range of the input x values.</remarks>
782+
let rec getIntegralBetween (lsc: StepCoef) xVal1 xVal2 =
783+
if xVal1 > xVal2 then
784+
- getIntegralBetween lsc xVal2 xVal1
785+
elif xVal1 = xVal2 then 0.
786+
else
787+
let n = lsc.XValues.Length
788+
// Find interval index k such that XValues[k] <= x < XValues[k+1], clamped to [0, n-2]
789+
let getInterval x =
790+
if x >= lsc.XValues.[n - 1] then n - 2
791+
elif x <= lsc.XValues.[0] then 0
792+
else
793+
lsc.XValues |> Array.findIndex (fun xk -> xk > x) |> fun idx -> idx - 1
794+
let k1 = getInterval xVal1
795+
let k2 = getInterval xVal2
796+
if k1 = k2 then
797+
lsc.C0.[k1] * (xVal2 - xVal1)
798+
else
799+
let firstPart = lsc.C0.[k1] * (lsc.XValues.[k1 + 1] - xVal1)
800+
let lastPart = lsc.C0.[k2] * (xVal2 - lsc.XValues.[k2])
801+
let middleParts =
802+
[ k1 + 1 .. k2 - 1 ]
803+
|> List.sumBy (fun k -> lsc.C0.[k] * (lsc.XValues.[k + 1] - lsc.XValues.[k]))
804+
firstPart + middleParts + lastPart
805+
744806

745807
/// <summary>
746808
/// Module to create piecewise cubic polynomials (cubic subsplines) from x,y coordinates.
@@ -1741,6 +1803,51 @@ module Interpolation =
17411803
let getThirdDerivative (coefficients: CubicSplineCoef) x =
17421804
getDerivative 3 coefficients x
17431805

1806+
/// <summary>
1807+
/// Returns the definite integral of the cubic spline from xVal1 to xVal2.
1808+
/// </summary>
1809+
/// <param name="coefficients">Interpolation functions coefficients.</param>
1810+
/// <param name="xVal1">Lower bound of integration.</param>
1811+
/// <param name="xVal2">Upper bound of integration.</param>
1812+
/// <returns>Definite integral (signed area under the curve) from xVal1 to xVal2.</returns>
1813+
/// <remarks>xVal1 and xVal2 should lie within the range of the input x values; values outside are handled by extrapolating the nearest segment's polynomial.</remarks>
1814+
let rec getIntegralBetween (coefficients: CubicSplineCoef) xVal1 xVal2 =
1815+
if xVal1 > xVal2 then
1816+
- getIntegralBetween coefficients xVal2 xVal1
1817+
elif xVal1 = xVal2 then 0.
1818+
else
1819+
let sortedX = coefficients.XData |> Seq.sort |> Array.ofSeq
1820+
let n = sortedX.Length - 1 // number of intervals
1821+
1822+
// Find interval index k such that sortedX[k] <= x < sortedX[k+1], clamped to [0, n-1]
1823+
let getInterval x =
1824+
if x >= sortedX.[n] then n - 1
1825+
elif x < sortedX.[0] then 0
1826+
else
1827+
sortedX |> Array.findIndex (fun xk -> xk > x) |> fun idx -> idx - 1
1828+
1829+
// Antiderivative of the polynomial for interval k, evaluated at x:
1830+
// F_k(x) = a*x^4/4 + b*x^3/3 + c*x^2/2 + d*x
1831+
let antideriv k x =
1832+
let a = coefficients.C0_3.[4 * k + 0]
1833+
let b = coefficients.C0_3.[4 * k + 1]
1834+
let c = coefficients.C0_3.[4 * k + 2]
1835+
let d = coefficients.C0_3.[4 * k + 3]
1836+
a * x * x * x * x / 4. + b * x * x * x / 3. + c * x * x / 2. + d * x
1837+
1838+
let i1 = getInterval xVal1
1839+
let i2 = getInterval xVal2
1840+
1841+
if i1 = i2 then
1842+
antideriv i1 xVal2 - antideriv i1 xVal1
1843+
else
1844+
let firstPart = antideriv i1 sortedX.[i1 + 1] - antideriv i1 xVal1
1845+
let lastPart = antideriv i2 xVal2 - antideriv i2 sortedX.[i2]
1846+
let middleParts =
1847+
[ i1 + 1 .. i2 - 1 ]
1848+
|> List.sumBy (fun k -> antideriv k sortedX.[k + 1] - antideriv k sortedX.[k])
1849+
firstPart + middleParts + lastPart
1850+
17441851
/// <summary>
17451852
/// Hermite cubic splines are defined by the function values and their slopes (first derivatives). If the slopws are unknown, they must be estimated.
17461853
/// </summary>

‎tests/FSharp.Stats.Tests/Interpolation.fs‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,166 @@ let BezierInterpolationTests =
142142

143143

144144

145+
[<Tests>]
146+
let integrationTests =
147+
testList "Interpolation.integrate" [
148+
149+
testCase "LinearSpline.getIntegralBetween positive linear function" <| fun () ->
150+
// y = 2x at {0,2,4} => integral [0,4] = [x^2]_0^4 = 16
151+
let coefs = LinearSpline.interpolate [|0.;2.;4.|] [|0.;4.;8.|]
152+
Expect.floatClose Accuracy.high (LinearSpline.getIntegralBetween coefs 0. 4.) 16.0
153+
"integral of y=2x from 0 to 4 should be 16"
154+
// [1,3]: segments [0,2] and [2,4], so partial cross-segment
155+
// ∫[1,3] 2x dx = [x^2]_1^3 = 9 - 1 = 8
156+
Expect.floatClose Accuracy.high (LinearSpline.getIntegralBetween coefs 1. 3.) 8.0
157+
"integral of y=2x from 1 to 3 should be 8"
158+
// ∫[0.5,1.5] 2x dx = 1.5^2 - 0.5^2 = 2.25 - 0.25 = 2
159+
Expect.floatClose Accuracy.high (LinearSpline.getIntegralBetween coefs 0.5 1.5) 2.0
160+
"integral of y=2x from 0.5 to 1.5 should be 2"
161+
162+
testCase "LinearSpline.getIntegralBetween reversed limits" <| fun () ->
163+
let coefs = LinearSpline.interpolate [|0.;2.;4.|] [|0.;4.;8.|]
164+
let forward = LinearSpline.getIntegralBetween coefs 1. 3.
165+
let reverse = LinearSpline.getIntegralBetween coefs 3. 1.
166+
167+
Expect.floatClose Accuracy.high forward (-reverse)
168+
"integrating with reversed limits should negate the result"
169+
170+
testCase "LinearSpline.getIntegralBetween is additive" <| fun () ->
171+
let coefs = LinearSpline.interpolate [|0.;2.;4.|] [|0.;4.;8.|]
172+
let whole = LinearSpline.getIntegralBetween coefs 1. 3.
173+
let partials = LinearSpline.getIntegralBetween coefs 1. 2. + LinearSpline.getIntegralBetween coefs 2. 3.
174+
Expect.floatClose Accuracy.high whole partials
175+
"integral should be additive across knot boundaries"
176+
177+
testCase "LinearSpline.getIntegralBetween returns zero for equal bounds" <| fun () ->
178+
let coefs = LinearSpline.interpolate [|0.;1.;2.|] [|1.;2.;3.|]
179+
Expect.floatClose Accuracy.high (LinearSpline.getIntegralBetween coefs 1. 1.) 0.0
180+
"integral with equal bounds should be 0"
181+
182+
testCase "LinearSpline.getIntegralBetween negative linear function" <| fun () ->
183+
let coefs = LinearSpline.interpolate [|0.;1.|] [|-1.;0.|]
184+
// ∫[0,1] (x-1) dx = -1/2
185+
Expect.floatClose Accuracy.high (LinearSpline.getIntegralBetween coefs 0. 1.) -0.5
186+
"integral of negative linear function should be negative"
187+
188+
testCase "LinearSpline.getIntegralBetween across zero crossing" <| fun () ->
189+
let coefs = LinearSpline.interpolate [|0.;1.;2.|] [|-1.;0.;1.|]
190+
// ∫[0,2] (x-1) dx = 0
191+
Expect.floatClose Accuracy.high (LinearSpline.getIntegralBetween coefs 0. 2.) 0.0
192+
"equivalent positive and negative areas should cancel"
193+
194+
testCase "Step.getIntegralBetween constant positive segments" <| fun () ->
195+
// y = 2 on [0,1), y = 3 on [1,2), y = 4 on [2,3)
196+
let coefs = Step.interpolate [|0.;1.;2.;3.|] [|2.;3.;4.;5.|]
197+
// ∫[0,3] = 2*1 + 3*1 + 4*1 = 9
198+
Expect.floatClose Accuracy.high (Step.getIntegralBetween coefs 0. 3.) 9.0
199+
"integral of step function from 0 to 3 should be 9"
200+
// ∫[0.5,2.5] = 2*0.5 + 3*1 + 4*0.5 = 1 + 3 + 2 = 6
201+
Expect.floatClose Accuracy.high (Step.getIntegralBetween coefs 0.5 2.5) 6.0
202+
"partial integral of step function from 0.5 to 2.5 should be 6"
203+
// ∫[1,2] = 3 * 1 = 3
204+
Expect.floatClose Accuracy.high (Step.getIntegralBetween coefs 1. 2.) 3.0
205+
"integral within exact segment boundaries should be 3"
206+
Expect.floatClose Accuracy.high (Step.getIntegralBetween coefs 1.5 1.5) 0.0
207+
"integral over zero-width interval should be zero"
208+
// Uses first interval value for values below x = 0
209+
// ∫[-1,0] = 2 * 1 = 2
210+
Expect.floatClose Accuracy.high (Step.getIntegralBetween coefs -1 0) 2.0
211+
"integral before the first breakpoint should use first segment value"
212+
213+
testCase "Step.getIntegralBetween is additive" <| fun () ->
214+
let coefs = Step.interpolate [|0.;1.;2.;3.|] [|2.;3.;4.;5.|]
215+
let whole = Step.getIntegralBetween coefs 0.5 2.5
216+
let partials = (Step.getIntegralBetween coefs 0.5 1.5) + (Step.getIntegralBetween coefs 1.5 2.5)
217+
Expect.floatClose Accuracy.high whole partials
218+
"integral should be additive acreoss sub-intervals"
219+
220+
testCase "Step.getIntegralBetween reversed limits" <| fun () ->
221+
let coefs = Step.interpolate [|0.;1.;2.;3.|] [|2.;3.;4.;5.|]
222+
223+
let forward = Step.getIntegralBetween coefs 0.5 2.5
224+
let reverse = Step.getIntegralBetween coefs 2.5 0.5
225+
226+
Expect.floatClose Accuracy.high reverse (-forward)
227+
"integrating with reversed limits should negate the result"
228+
229+
testCase "Step.getIntegralBetween constant negative segments" <| fun () ->
230+
// y = -2 on [0,1), y = -3 on [1,2)
231+
let coefs = Step.interpolate [|0.;1.;2.|] [|-2.;-3.;-3.|]
232+
//∫[0,2] = (-2)*1 + (-3)*1 = -5
233+
Expect.floatClose Accuracy.high (Step.getIntegralBetween coefs 0. 2.) -5.0
234+
"Integral of negative step function should be negative"
235+
236+
testCase "Step.getIntegralBetween across zero crossing" <| fun () ->
237+
// y= -2 on [0,1), y = 2 on [1,2)
238+
let coefs = Step.interpolate [|0.;1.;2.|] [|-2.; 2.; 2.|]
239+
// ∫[0,2] = (-2)*1 + 2*1 = 0
240+
Expect.floatClose Accuracy.high (Step.getIntegralBetween coefs 0. 2.) 0.0
241+
"equivalent positive and negative areas should cancel"
242+
243+
testCase "CubicSpline.getIntegralBetween positive quadratic function" <| fun () ->
244+
// Quadratic boundary condition reproduces y=x^2 exactly
245+
let t = vector [| 1.; 2.; 3.; 4. |]
246+
let u = vector [| 1.; 4.; 9.; 16. |] // y = x^2
247+
let coefs = CubicSpline.interpolate CubicSpline.Quadratic t u
248+
// ∫[1,4] x^2 dx = [x^3/3]_1^4 = 64/3 - 1/3 = 21
249+
Expect.floatClose Accuracy.high (CubicSpline.getIntegralBetween coefs 1. 4.) 21.0
250+
"integral of y=x^2 from 1 to 4 should be 21"
251+
// ∫[1,2] x^2 dx = 8/3 - 1/3 = 7/3
252+
Expect.floatClose Accuracy.high (CubicSpline.getIntegralBetween coefs 1. 2.) (7. / 3.)
253+
"integral of y=x^2 from 1 to 2 should be 7/3"
254+
// ∫[1.5,3.5] x² dx = (3.5³ - 1.5³)/3
255+
Expect.floatClose Accuracy.high (CubicSpline.getIntegralBetween coefs 1.5 3.5) ((3.5 ** 3. - 1.5 ** 3.) / 3.)
256+
"integral across several spline intervals should be correct"
257+
258+
testCase "CubicSpline.getIntegralBetween is additive" <| fun () ->
259+
let t = vector [| 1.; 2.; 3.; 4. |]
260+
let u = vector [| 1.; 4.; 9.; 16. |] // y = x^2
261+
let coefs = CubicSpline.interpolate CubicSpline.Quadratic t u
262+
let whole= CubicSpline.getIntegralBetween coefs 1. 4.
263+
let partials = CubicSpline.getIntegralBetween coefs 1. 2 + CubicSpline.getIntegralBetween coefs 2. 3. + CubicSpline.getIntegralBetween coefs 3. 4.
264+
Expect.floatClose Accuracy.high whole partials
265+
"integral should be additive across sub-intervals"
266+
267+
testCase "CubicSpline.getIntegralBetween returns zero for equal bounds" <| fun () ->
268+
let t = vector [| 0.; 1.; 2. |]
269+
let u = vector [| 0.; 1.; 4. |]
270+
let coefs = CubicSpline.interpolate CubicSpline.Natural t u
271+
Expect.floatClose Accuracy.high (CubicSpline.getIntegralBetween coefs 1. 1.) 0.0
272+
"integral with equal bounds should be 0"
273+
274+
testCase "CubicSpline.getIntegralBetween reversed limits" <| fun () ->
275+
let t = vector [| 1.; 2.; 3.; 4. |]
276+
let u = vector [| 1.; 4.; 9.; 16. |]
277+
let coefs = CubicSpline.interpolate CubicSpline.Quadratic t u
278+
279+
let forward = CubicSpline.getIntegralBetween coefs 1. 4.
280+
let reverse = CubicSpline.getIntegralBetween coefs 4. 1.
281+
282+
Expect.floatClose Accuracy.high forward (-reverse)
283+
"integrating with reversed limits should negate the result"
284+
285+
testCase "CubicSpline.getIntegralBetween negative values" <| fun () ->
286+
let t = vector [|1.;2.;3.;4.|]
287+
let u = vector [|-1.;-4.;-9.;-16.|]
288+
let coefs = CubicSpline.interpolate CubicSpline.Quadratic t u
289+
// ∫[1,4] -x² dx = -21
290+
Expect.floatClose Accuracy.high (CubicSpline.getIntegralBetween coefs 1. 4.) -21.0
291+
"integral of negative quadratic should be negative"
292+
293+
testCase "CubicSpline.getIntegralBetween positive and negative values" <| fun () ->
294+
let t = vector [|-2.;-1.;0.;1.;2.|]
295+
let u = vector [|-2.;-1.;0.;1.;2.|]
296+
let coefs = CubicSpline.interpolate CubicSpline.Quadratic t u
297+
// ∫[-2,2] x dx = 0
298+
Expect.floatClose Accuracy.high (CubicSpline.getIntegralBetween coefs -2. 2.) 0.0
299+
"positive and negative contributions should cancel"
300+
]
301+
302+
303+
304+
145305

146306

147307

0 commit comments

Comments
 (0)