polar-math
v1.1.1
Published
Prepared polar-table interpolation and target calculations.
Maintainers
Readme
polar-math
Pure calculations for canonical polar-format polar tables. The package has no Signal K dependency.
Polar.fromTable(table) validates and prepares an immutable polar table once. All query methods accept
their dynamic inputs explicitly and return { value, state }. Measurements use SI units: m/s and radians.
const { Polar } = require('polar-math')
const polar = Polar.fromTable(table)
const speed = polar.speedAt({ tws, twa, performanceFactor: 1.0 })
const vmg = polar.vmgAt({ tws, twa, performanceFactor: 1.0 })
const targets = polar.targetsAt({ tws, performanceFactor: 1.0 })
const range = polar.rangeAt({ tws })state always includes available. For valid queries it also includes the TWS range state and, for
angle-dependent calls, the TWA range state. Invalid numeric input (including a twa outside [-π, π])
returns value: null and state.reason: 'invalid_input'.
state.tws is one of:
in_range— within the table's TWS axisbelow_range— below the lowest TWS row (result is clamped to it)above_range— above the highest TWS row (result is clamped to it)
state.twa (angle-dependent calls only) is one of:
in_range— within the sailable TWA rangepinching— between the pinch boundary and the beat angle; a reduced positive speed is returnedin_irons— below the pinch boundary;speedAt/vmgAtreturnvalue: nullextrapolated— beyond the table's last real TWA point but within the modeled run extensionabove_range— beyond the modeled range;speedAt/vmgAtreturnvalue: null
The performance factor is deliberately a per-query input and defaults to 1.0, meaning the polar's
unadjusted performance. Values below 1.0 scale the calculated speed and target values down.
Interpolation and extrapolation
Within a TWS row, a query angle that falls between two real points (measured axis columns, plus any
derived.rows beat/run target inserted into the same list) is interpolated using a monotone cubic
Hermite spline (PCHIP) — smooth (continuous slope) and passing exactly through the input and synthetic
curve points, but with
a built-in constraint that it can never overshoot or oscillate between two points the way a plain cubic
spline can. Between two TWS rows, results are still linearly interpolated (TWS rows are few and widely
spaced, and are never rendered as a continuous curve themselves, so the simpler method is preferred
there). Angles outside the real data span (below the beat angle, or beyond the last real point)
fall back to the modeled extrapolation described below.
Beat-side (pinching)
Below the beat angle, speed is modeled down to a fixed 25° endpoint. A synthetic zero-speed point is
prepended at 25°, and the same PCHIP fit above interpolates through it like any other curve point. This
endpoint is part of the curve construction and is independent of the pinch cutoff. rangeAt's reported
minTwa is the endpoint plus PINCH_FACTOR (currently 0.8) of the angular distance from that endpoint
to the beat angle:
minTwa = 25° + 0.8 × (beatAngle - 25°)Queries below this boundary are classified as in_irons and return value: null; the boundary does not
alter the PCHIP curve. The 25° endpoint is only added when a valid beat target exists and the row's first
point is above 25°.
Run-side (gybe)
The deepest real angle in a row is rarely 180° (dead downwind), but the polar plot is mirrored
port/starboard at 180°, so a query (or a rendered curve) needs something defined all the way there.
Unlike the beat side, real sailing behaviour genuinely continues past the last known angle — so this side
does need an extrapolated model, in two steps, both operating on VMG (speed × |cos(twa)|) rather
than raw speed, because VMG — not speed — is the quantity that peaks at the run angle and is expected to
behave predictably beyond it:
- Mirror point. The run angle is a peak: VMG rises up to it and falls beyond it. As a first
extrapolated point, the model assumes the fall mirrors the rise — the VMG at
runAngle + dis assumed equal to the VMG already measured atrunAngle - d, wheredis the distance back to the nearest real axis point below the run angle. This adds at most one synthetic point, and only when it would land beyond the last real point and at or before 180°; if real data already reaches (or the mirror would overshoot) 180°, no mirror point is added. This point also becomes part of the PCHIP fit above, like any other point. - Taper to zero slope at 180°. From the deepest known angle (the mirror point, or the last real
point if no mirror was added), the VMG slope is assumed to decrease linearly to exactly zero at 180° —
the point where the boat gybes and the polar mirrors onto itself, so VMG must be momentarily flat
there. The starting slope for this taper is taken directly from the PCHIP tangent at that point
(converted from
dSpeed/dTWAtodVMG/dTWA), so the taper connects smoothly to the interior curve rather than starting from an independent estimate. Integrating that linear slope gives a quadratic VMG curve out to 180°, converted back to boat speed at query time. Two safety clamps apply: speed is never negative, and VMG is never allowed to increase past its value at the deepest known angle.
Both the mirror point and the taper are skipped if a row has no run target, too few points to establish a slope, or is already complete out to 180°.
Opting out: extrapolate: false
Every query method accepts extrapolate (default true). Pass extrapolate: false to restrict results to
real data only — no beat-side 25° endpoint, no run-side mirror/taper. rangeAt then reports the TWA span
actually covered by measured axis columns and derived targets, and speedAt/vmgAt return value: null
(state.twa: 'below_range'/'above_range') for anything outside it. Use this when extrapolated data
would be misleading for the caller's purpose (e.g. feeding a live performance calculation) rather than
just for rendering a continuous curve.
Symmetry
Only port/starboard-symmetric tables are supported: polar-format requires
symmetry.portStarboardSymmetric: true, and all lookups here always mirror speed across the beam
(Math.abs(twa)). There is no asymmetric-table support yet.
