moon-sighting/src/math/index.ts

339 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* math — Core numerical utilities.
*
* All computation in this module is pure (no I/O, no state).
* Uses Float64Array for coefficient storage to match JS engine optimization paths.
*/
import type { Vec3 } from '../types.js'
// ─── Vector operations ────────────────────────────────────────────────────────
/** Add two 3-vectors */
export function vadd(a: Vec3, b: Vec3): Vec3 {
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
/** Subtract b from a */
export function vsub(a: Vec3, b: Vec3): Vec3 {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
/** Scale a 3-vector */
export function vscale(a: Vec3, s: number): Vec3 {
return [a[0] * s, a[1] * s, a[2] * s]
}
/** Dot product */
export function vdot(a: Vec3, b: Vec3): number {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/** Euclidean norm */
export function vnorm(a: Vec3): number {
return Math.sqrt(vdot(a, a))
}
/** Cross product */
export function vcross(a: Vec3, b: Vec3): Vec3 {
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/** Unit vector (normalized) */
export function vunit(a: Vec3): Vec3 {
const n = vnorm(a)
if (n === 0) throw new RangeError('Cannot normalize a zero vector')
return vscale(a, 1 / n)
}
/** Angular separation between two direction vectors in radians */
export function angularSep(a: Vec3, b: Vec3): number {
const cosAngle = Math.max(-1, Math.min(1, vdot(vunit(a), vunit(b))))
return Math.acos(cosAngle)
}
// ─── 3×3 matrix operations ────────────────────────────────────────────────────
/** 3×3 matrix stored row-major as a 9-element tuple */
export type Mat3 = [number, number, number, number, number, number, number, number, number]
/** Multiply 3×3 matrix by 3-vector */
export function mvmul(m: Mat3, v: Vec3): Vec3 {
return [
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
]
}
/** Multiply two 3×3 matrices */
export function mmmul(a: Mat3, b: Mat3): Mat3 {
return [
a[0] * b[0] + a[1] * b[3] + a[2] * b[6],
a[0] * b[1] + a[1] * b[4] + a[2] * b[7],
a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
a[3] * b[0] + a[4] * b[3] + a[5] * b[6],
a[3] * b[1] + a[4] * b[4] + a[5] * b[7],
a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
]
}
/** Transpose a 3×3 matrix */
export function mtranspose(m: Mat3): Mat3 {
return [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]]
}
/**
* Rotation matrix around the X axis by angle θ (radians).
* Follows right-hand rule.
*/
export function rotX(theta: number): Mat3 {
const c = Math.cos(theta)
const s = Math.sin(theta)
return [1, 0, 0, 0, c, s, 0, -s, c]
}
/**
* Rotation matrix around the Y axis by angle θ (radians).
*/
export function rotY(theta: number): Mat3 {
const c = Math.cos(theta)
const s = Math.sin(theta)
return [c, 0, -s, 0, 1, 0, s, 0, c]
}
/**
* Rotation matrix around the Z axis by angle θ (radians).
*/
export function rotZ(theta: number): Mat3 {
const c = Math.cos(theta)
const s = Math.sin(theta)
return [c, s, 0, -s, c, 0, 0, 0, 1]
}
// ─── Chebyshev polynomial evaluation ─────────────────────────────────────────
/**
* Evaluate a Chebyshev polynomial at a normalized point x ∈ [-1, 1]
* using the Clenshaw recurrence algorithm.
*
* The Clenshaw algorithm is numerically superior to Horner's method for
* Chebyshev series and is the standard approach in SPICE's SPKE02.
*
* @param coeffs - Chebyshev coefficients c[0..n] (degree n polynomial)
* @param x - Evaluation point, must be in [-1, 1]
* @returns Polynomial value at x
*/
export function chebyshevEval(coeffs: Float64Array, x: number): number {
const n = coeffs.length
if (n === 0) return 0
if (n === 1) return coeffs[0]! // Float64Array length checked above; index 0 is valid
// Double-x for Clenshaw efficiency
const x2 = 2 * x
let b2 = 0
let b1 = 0
for (let k = n - 1; k >= 1; k--) {
const b0 = coeffs[k]! + x2 * b1 - b2 // k in [1, n-1]; all valid indices
b2 = b1
b1 = b0
}
return coeffs[0]! + x * b1 - b2 // index 0 valid; n >= 2 at this point
}
/**
* Evaluate a Chebyshev polynomial and its derivative simultaneously.
* Uses the extended Clenshaw recurrence (more efficient than two separate evaluations).
*
* @returns [value, derivative] where derivative is with respect to the original time variable
* @param coeffs - Chebyshev coefficients
* @param x - Evaluation point in [-1, 1]
* @param radius - Half-interval width in seconds (for scaling derivative to original time)
*/
export function chebyshevEvalWithDerivative(
coeffs: Float64Array,
x: number,
radius: number,
): [number, number] {
const n = coeffs.length
if (n === 0) return [0, 0]
if (n === 1) return [coeffs[0]!, 0] // length checked; index 0 valid
const x2 = 2 * x
let b2 = 0
let b1 = 0
let db2 = 0
let db1 = 0
for (let k = n - 1; k >= 1; k--) {
const b0 = coeffs[k]! + x2 * b1 - b2 // k in [1, n-1]; all valid indices
const db0 = 2 * b1 + x2 * db1 - db2
b2 = b1
b1 = b0
db2 = db1
db1 = db0
}
const value = coeffs[0]! + x * b1 - b2 // index 0 valid; n >= 2 at this point
const dvalue = b1 + x * db1 - db2
// Scale derivative from normalized domain back to seconds
return [value, dvalue / radius]
}
// ─── Root finding ─────────────────────────────────────────────────────────────
/**
* Find a root of f(t) in [a, b] using Brent's method.
* Requires f(a) and f(b) to have opposite signs.
*
* Brent's method combines bisection, secant, and inverse quadratic interpolation
* for guaranteed convergence with superlinear speed in practice.
*
* @param f - Function to find root of
* @param a - Left bracket
* @param b - Right bracket
* @param tol - Tolerance (default 1e-9 seconds for astronomical work)
* @param maxIter - Maximum iterations (default 64)
* @returns Root location, or null if bracket does not contain a sign change
*/
export function brentRoot(
f: (t: number) => number,
a: number,
b: number,
tol = 1e-9,
maxIter = 64,
): number | null {
let fa = f(a)
let fb = f(b)
// No sign change in bracket
if (fa * fb > 0) return null
// Swap so |f(b)| <= |f(a)|
if (Math.abs(fa) < Math.abs(fb)) {
;[a, b] = [b, a]
;[fa, fb] = [fb, fa]
}
let c = a
let fc = fa
let mflag = true
let s: number
let d = 0
for (let i = 0; i < maxIter; i++) {
if (Math.abs(b - a) < tol) return b
if (fa !== fc && fb !== fc) {
// Inverse quadratic interpolation
s =
(a * fb * fc) / ((fa - fb) * (fa - fc)) +
(b * fa * fc) / ((fb - fa) * (fb - fc)) +
(c * fa * fb) / ((fc - fa) * (fc - fb))
} else {
// Secant method
s = b - fb * ((b - a) / (fb - fa))
}
const cond1 = s < (3 * a + b) / 4 || s > b
const cond2 = mflag && Math.abs(s - b) >= Math.abs(b - c) / 2
const cond3 = !mflag && Math.abs(s - b) >= Math.abs(c - d) / 2
const cond4 = mflag && Math.abs(b - c) < tol
const cond5 = !mflag && Math.abs(c - d) < tol
if (cond1 || cond2 || cond3 || cond4 || cond5) {
s = (a + b) / 2
mflag = true
} else {
mflag = false
}
const fs = f(s)
d = c
c = b
fc = fb
if (fa * fs < 0) {
b = s
fb = fs
} else {
a = s
fa = fs
}
if (Math.abs(fa) < Math.abs(fb)) {
;[a, b] = [b, a]
;[fa, fb] = [fb, fa]
}
}
return b
}
/**
* Find all roots of f(t) in [a, b] by adaptive subdivision then Brent.
* Used for finding rise/set events where multiple crossings may occur in a day.
*
* @param f - Function to find roots of
* @param a - Start of search interval
* @param b - End of search interval
* @param steps - Number of initial subdivision steps (default 48 for 30-min resolution over a day)
* @returns Array of root locations
*/
export function findRoots(f: (t: number) => number, a: number, b: number, steps = 48): number[] {
const dt = (b - a) / steps
const roots: number[] = []
let tPrev = a
let fPrev = f(a)
for (let i = 1; i <= steps; i++) {
const t = a + i * dt
const ft = f(t)
if (fPrev * ft <= 0) {
// Sign change in [tPrev, t] — apply Brent's method
const root = brentRoot(f, tPrev, t, 1e-9, 64)
if (root !== null) {
// Deduplicate roots that are too close together
if (roots.length === 0 || Math.abs(root - roots[roots.length - 1]!) > 1e-6) { // length > 0 checked via ||
roots.push(root)
}
}
}
tPrev = t
fPrev = ft
}
return roots
}
// ─── Angle utilities ─────────────────────────────────────────────────────────
/** Convert degrees to radians */
export const DEG2RAD = Math.PI / 180
/** Convert radians to degrees */
export const RAD2DEG = 180 / Math.PI
/** Normalize an angle to [0, 2π) */
export function mod2pi(angle: number): number {
const twoPi = 2 * Math.PI
return ((angle % twoPi) + twoPi) % twoPi
}
/** Normalize an angle in degrees to [0, 360) */
export function mod360(deg: number): number {
return ((deg % 360) + 360) % 360
}
/** Normalize an angle in degrees to [-180, 180) */
export function normalizeDeg180(deg: number): number {
deg = mod360(deg)
return deg >= 180 ? deg - 360 : deg
}