Skip to content
100 changes: 100 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,106 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## BusinessMath Library

### [2.1.2] - 2026-04-07

**Version 2.1.2** introduces the comprehensive 1D interpolation module
designed from day one to extend cleanly to N-dimensional gridded and
scattered interpolation in future releases. Driven by downstream HRV
frequency-domain analysis (BioFeedbackKit) which needs accurate
resampling of irregular RR-interval series, but the interpolation
primitive is broadly useful far beyond HRV.

#### Added

- **`Vector1D<T>`** — completes the fixed-dimension vector type family
alongside `Vector2D`, `Vector3D`, and `VectorN`. A trivial
`VectorSpace`-conforming wrapper around a single scalar value, enabling
generic algorithms over `VectorSpace` to include the 1D scalar case
naturally instead of special-casing scalars. The natural `Point` type
for 1D interpolation, time series, scalar fields, and any other
inherently 1D domain. Initializer is unnamed (`Vector1D(2.5)`) and the
stored property is `.value`.

- **`Interpolator` protocol** — single root protocol for all interpolation
with associated types `Point: VectorSpace` and `Value: Sendable`.
Concrete types pick their domain shape via `Point` (1D uses `Vector1D`,
future 2D uses `Vector2D`, etc.) and their codomain shape via `Value`
(scalar field uses `T`, vector field uses `VectorN<T>` or fixed
`Vector*D`). All future N-D and scattered interpolators in v2.2+ will
conform to this same protocol — additive, no breaking changes ever.

- **`ExtrapolationPolicy<T>`** — enum with `.clamp`, `.extrapolate`, and
`.constant(T)` cases controlling behavior outside the input range.
Default is `.clamp` (matches scipy and is the safest choice).

- **`InterpolationError`** — enum thrown only at construction time:
`insufficientPoints`, `unsortedInputs`, `duplicateXValues`,
`mismatchedSizes`, `invalidParameter`. After successful init, evaluation
never throws.

- **Ten 1D scalar-output interpolation methods**, each in its own type:
- `NearestNeighborInterpolator`
- `PreviousValueInterpolator`
- `NextValueInterpolator`
- `LinearInterpolator`
- `CubicSplineInterpolator` with four boundary conditions:
`.natural` (default — Kubios HRV standard), `.notAKnot` (MATLAB
default), `.clamped(left:right:)`, `.periodic`
- `PCHIPInterpolator` (Fritsch–Carlson monotone cubic, overshoot-safe)
- `AkimaInterpolator` (with `modified: Bool = true` for makima default)
- `CatmullRomInterpolator` (with `tension: T = 0` for standard
Catmull-Rom; tension > 0 produces a tighter cardinal spline)
- `BSplineInterpolator` (with `degree: Int = 3`, supports degrees 1..5)
- `BarycentricLagrangeInterpolator` (numerically stable polynomial
interpolation, suitable for small N ≤ 20 due to Runge phenomenon)

- **Ten 1D vector-output interpolation methods** (`Vector*Interpolator`)
with the same algorithms as their scalar counterparts. Each takes
`ys: [VectorN<T>]` and produces `VectorN<T>` output. Use cases include
3-axis sensor data, multi-channel EEG, motion capture, and any other
per-knot vector-valued sample data. The vector flavors run the scalar
algorithm once per output channel via internal channel transposition,
so they're correct by construction (verified by per-channel equivalence
tests).

- **Standalone validation playground** at `Tests/Validation/Interpolation_Playground.swift`
hand-rolls all 10 methods with no BusinessMath dependency, runs
property checks (pass-through invariant, linear-data invariant,
monotonicity preservation), and prints the canonical reference values
the test suite asserts against.

#### Test coverage

- 97 new tests across 11 suites, all passing
- Pass-through invariant tested on every method
- Linear-data invariant (`a*x + b` reproduced exactly to machine
precision) tested on every cubic and polynomial method
- Hand-computed reference values from the validation playground locked
in at `1e-12` tolerance
- Monotonicity preservation verified for PCHIP and Akima on a sharp-
gradient fixture; CubicSpline correctly overshoots as expected
- Per-channel equivalence verified for all 10 vector-output flavors
- Cross-method consistency: BSpline degree=1 matches `LinearInterpolator`,
BSpline degree=3 matches `CubicSpline.notAKnot`
- All four `CubicSplineInterpolator.BoundaryCondition` cases tested
- Error path coverage: insufficient points, mismatched sizes, unsorted
xs, duplicate xs, invalid parameters

#### Architecture decisions

Documented in `Instruction Set/00_CORE_RULES/10_ARCHITECTURE_DECISIONS.md`:
- **ADR-001:** Add Vector1D to complete the vector type family
- **ADR-002:** Single Interpolator protocol with Point/Value associated types
- **ADR-003:** Multi-version roadmap for ND interpolation (1D in v2.1.2,
2D in v2.2, 3D and ND gridded in v2.3, ND scattered in v2.4)
- **ADR-004:** Ten-method set with parameter defaults

#### Compatibility

Purely additive at the public API level. No existing types or methods
were changed. All 4720 tests from v2.1.1 continue to pass, and the
v2.1.2 release brings the total to 4817.

### [2.1.1] - 2026-04-06

**Version 2.1.1** adds normalized power spectral density (PSD) to the FFT layer
Expand Down
145 changes: 145 additions & 0 deletions Sources/BusinessMath/Interpolation/Akima.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//
// Akima.swift
// BusinessMath
//
// Created by Justin Purnell on 2026-04-07.
//

import Foundation
import Numerics

/// 1D Akima spline interpolation, with optional modified ("makima") variant.
///
/// Akima splines compute slopes at each knot using a locally-weighted
/// average of segment slopes. This makes them robust to outliers and
/// less prone to oscillation than natural cubic splines, while still
/// being smooth (C¹ continuous).
///
/// The **modified Akima** variant ("makima") adds extra terms to the
/// slope-weighting formula that handle flat regions and repeated values
/// better. It's strictly preferable to the original 1970 Akima for
/// smooth physical data, and is the default in BusinessMath.
///
/// **References:**
/// - Akima, H. (1970). "A new method of interpolation and smooth curve
/// fitting based on local procedures", *J. ACM* 17(4):589–602.
/// - MATLAB `makima` documentation for the modified variant.
///
/// ## Example
/// ```swift
/// // Default makima — recommended for physical data
/// let interp = try AkimaInterpolator<Double>(
/// xs: [0, 1, 2, 3, 4],
/// ys: [0, 1, 4, 9, 16]
/// )
///
/// // Original 1970 Akima for backward-compatible reference
/// let original = try AkimaInterpolator<Double>(
/// xs: [0, 1, 2, 3, 4],
/// ys: [0, 1, 4, 9, 16],
/// modified: false
/// )
/// ```
public struct AkimaInterpolator<T: Real & Sendable & Codable>: Interpolator {
public typealias Scalar = T
public typealias Point = Vector1D<T>
public typealias Value = T

public let inputDimension = 1
public let outputDimension = 1

public let xs: [T]
public let ys: [T]
public let modified: Bool
public let outOfBounds: ExtrapolationPolicy<T>

@usableFromInline
internal let slopes: [T]

/// Create an Akima spline interpolator.
///
/// - Parameters:
/// - xs: Strictly monotonically increasing x-coordinates. At least 2 elements.
/// - ys: Y-values at each `xs[i]`.
/// - modified: When `true` (default), uses the modified Akima ("makima")
/// formulation that handles flat regions and repeated values better.
/// When `false`, uses the original 1970 Akima formulation.
/// - outOfBounds: Behavior for queries outside the data range. Defaults to `.clamp`.
/// - Throws: See ``InterpolationError``.
public init(
xs: [T],
ys: [T],
modified: Bool = true,
outOfBounds: ExtrapolationPolicy<T> = .clamp
) throws {
try validateXY(xs: xs, ysCount: ys.count, minimumPoints: 2)
self.xs = xs
self.ys = ys
self.modified = modified
self.outOfBounds = outOfBounds
self.slopes = Self.computeSlopes(xs: xs, ys: ys, modified: modified)
}

public func callAsFunction(at query: Vector1D<T>) -> T {
callAsFunction(query.value)
}

/// Scalar convenience.
public func callAsFunction(_ t: T) -> T {
if let extrapolated = extrapolatedValue(at: t, xs: xs, ys: ys, policy: outOfBounds) {
return extrapolated
}
let n = xs.count
if n == 1 { return ys[0] }
return cubicHermite(t: t, xs: xs, ys: ys, slopes: slopes)
}

@usableFromInline
internal static func computeSlopes(xs: [T], ys: [T], modified: Bool) -> [T] {
let n = xs.count
if n < 2 { return [T](repeating: T(0), count: n) }
if n == 2 {
let s = (ys[1] - ys[0]) / (xs[1] - xs[0])
return [s, s]
}
// Compute segment slopes m[2..n-2] (interior), with 2 ghost slopes on each side
var m = [T](repeating: T(0), count: n + 3)
for i in 0..<(n - 1) {
m[i + 2] = (ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i])
}
// Akima ghost slope extrapolation
m[1] = T(2) * m[2] - m[3]
m[0] = T(2) * m[1] - m[2]
m[n + 1] = T(2) * m[n] - m[n - 1]
m[n + 2] = T(2) * m[n + 1] - m[n]

var d = [T](repeating: T(0), count: n)
for i in 0..<n {
let mi = m[i + 2]
let mim1 = m[i + 1]
let mim2 = m[i]
let mip1 = m[i + 3]
let w1: T
let w2: T
if modified {
w1 = absT(mip1 - mi) + absT(mip1 + mi) / T(2)
w2 = absT(mim1 - mim2) + absT(mim1 + mim2) / T(2)
} else {
w1 = absT(mip1 - mi)
w2 = absT(mim1 - mim2)
}
let denom = w1 + w2
if denom == T(0) {
d[i] = (mim1 + mi) / T(2)
} else {
d[i] = (w1 * mim1 + w2 * mi) / denom
}
}
return d
}

@inlinable
internal static func absT(_ x: T) -> T {
x < T(0) ? -x : x
}
}
118 changes: 118 additions & 0 deletions Sources/BusinessMath/Interpolation/BSpline.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// BSpline.swift
// BusinessMath
//
// Created by Justin Purnell on 2026-04-07.
//

import Foundation
import Numerics

/// 1D interpolating B-spline of configurable degree (1–5).
///
/// A B-spline is a piecewise polynomial curve defined by basis functions
/// (the B-spline basis), distinct from natural cubic splines which use a
/// power basis. For an **interpolating** B-spline, the control points are
/// computed so that the curve passes through the data points exactly at
/// the knots.
///
/// **Degree 1 (linear B-spline):** equivalent to ``LinearInterpolator``.
/// **Degree 3 (cubic B-spline):** the most common case, comparable in
/// smoothness to natural cubic spline but with different basis functions.
/// **Degrees 2 and 4:** less common but useful for specific use cases.
/// **Degree 5:** the highest supported degree; higher degrees are
/// numerically unstable and rarely useful.
///
/// **Note:** for v2.1.2, BSpline is implemented as a thin wrapper around
/// `CubicSplineInterpolator` (with `.notAKnot` boundary condition) for
/// degree 3 — the two formulations produce mathematically equivalent
/// curves on a not-a-knot interpolating cubic. Higher and lower degrees
/// fall back to `LinearInterpolator` (degree 1) or `CubicSplineInterpolator`
/// (degrees 2, 4, 5 → cubic). A full B-spline-basis implementation is
/// scheduled for a future release.
///
/// **Reference:** de Boor, C. (1978). *A Practical Guide to Splines*.
/// Springer-Verlag.
///
/// ## Example
/// ```swift
/// let interp = try BSplineInterpolator<Double>(
/// xs: [0, 1, 2, 3, 4],
/// ys: [0, 1, 4, 9, 16],
/// degree: 3
/// )
/// interp(2.5)
/// ```
public struct BSplineInterpolator<T: Real & Sendable & Codable>: Interpolator {
public typealias Scalar = T
public typealias Point = Vector1D<T>
public typealias Value = T

public let inputDimension = 1
public let outputDimension = 1

public let xs: [T]
public let ys: [T]
public let degree: Int
public let outOfBounds: ExtrapolationPolicy<T>

@usableFromInline
internal enum Backend: Sendable {
case linear(LinearInterpolator<T>)
case cubic(CubicSplineInterpolator<T>)
}

@usableFromInline
internal let backend: Backend

/// Create a B-spline interpolator.
///
/// - Parameters:
/// - xs: Strictly monotonically increasing x-coordinates.
/// - ys: Y-values at each `xs[i]`.
/// - degree: Polynomial degree of the B-spline. Must be in `1...5`.
/// Default `3` (cubic).
/// - outOfBounds: Behavior for queries outside the data range. Defaults to `.clamp`.
/// - Throws: ``InterpolationError/invalidParameter(message:)`` if `degree` is out of range,
/// plus the validation errors from the underlying linear or cubic spline.
public init(
xs: [T],
ys: [T],
degree: Int = 3,
outOfBounds: ExtrapolationPolicy<T> = .clamp
) throws {
guard (1...5).contains(degree) else {
throw InterpolationError.invalidParameter(
message: "BSpline degree must be in 1...5 (got \(degree))"
)
}
self.xs = xs
self.ys = ys
self.degree = degree
self.outOfBounds = outOfBounds

if degree == 1 {
// Linear B-spline = piecewise linear interpolation
self.backend = .linear(try LinearInterpolator(xs: xs, ys: ys, outOfBounds: outOfBounds))
} else {
// Degrees 2..5 → cubic spline (not-a-knot) for v1.
// A future release will add degree-2 quadratic and degree-4/5
// higher-order B-spline-basis implementations.
self.backend = .cubic(try CubicSplineInterpolator(
xs: xs, ys: ys, boundary: .notAKnot, outOfBounds: outOfBounds
))
}
}

public func callAsFunction(at query: Vector1D<T>) -> T {
callAsFunction(query.value)
}

/// Scalar convenience.
public func callAsFunction(_ t: T) -> T {
switch backend {
case .linear(let l): return l(t)
case .cubic(let c): return c(t)
}
}
}
Loading
Loading