predictor/docs/numerics.tex
2026-08-03 22:11:10 +09:00

474 lines
21 KiB
TeX

\documentclass[a4paper,11pt]{article}
\usepackage[margin=1in]{geometry}
\usepackage{amsmath, amssymb}
\usepackage{algorithm, algpseudocode}
\usepackage{hyperref}
\title{stratoflights-predictor: Mathematical Reference}
\author{stratoflights-predictor}
\date{}
\begin{document}
\maketitle
\noindent This document is the end-to-end mathematical specification of the
trajectory calculation performed by stratoflights-predictor. It is meant
to be detailed enough to permit hand verification of the numerical
output. Section~\ref{sec:numerics} covers the small numerics library
(\verb|internal/numerics|); the remaining sections describe the engine
(\verb|internal/engine|) and the data plane.
\tableofcontents
% =========================================================================
\section{State vector and equations of motion}
\label{sec:state}
\paragraph{State vector.} The balloon state is the four-tuple
\[
\mathbf{s}(t) \;=\; \bigl(t,\; \varphi(t),\; \lambda(t),\; h(t)\bigr),
\]
where $t$ is UNIX seconds, $\varphi \in [-90, 90]$ is geographic latitude
in degrees, $\lambda \in [0, 360)$ is geographic longitude in degrees,
and $h$ is altitude above mean sea level in metres. Inside the
implementation, the spatial part $(\varphi, \lambda, h)$ is the
\verb|engine.State| struct; $t$ is tracked separately by the integrator.
\paragraph{Equations of motion.} The time derivative of state is the
direction-agnostic vector
\[
\dot{\mathbf{s}}(t) \;=\; \bigl(\dot \varphi,\; \dot \lambda,\; \dot h\bigr)
\;=\; \sum_{m \in \text{Models}(t)} \mathbf{F}_m(t, \mathbf{s}),
\]
i.e.\ the sum of the active stage's models evaluated at the current
state. The supported per-model contributions are:
\paragraph{Constant rate.} A purely vertical model with no horizontal
component, used for the standard balloon ascent profile:
\[
\mathbf{F}_{\text{const}}(t, \mathbf{s}) = (0, 0, r), \qquad r \in \mathbb{R}.
\]
Positive $r$ is upward; a negative $r$ may be combined with reverse-time
integration to model an ascent backwards from a known apex.
\paragraph{Parachute descent.} The vertical contribution under a constant
drag coefficient with the NASA atmosphere model density $\rho(h)$:
\[
\mathbf{F}_{\text{drag}}(t, \mathbf{s}) = \Bigl(0, 0, -\frac{k}{\sqrt{\rho(h)}}\Bigr),
\qquad k = r_0 \cdot 1{.}1045,
\]
where $r_0$ is the descent rate at sea level. Density is computed
piecewise from the layered model described in
\href{https://www.grc.nasa.gov/WWW/K-12/airplane/atmosmet.html}{NASA's
atmosphere page}.
\paragraph{Piecewise rate.} A schedule $\{(\tau_i, r_i)\}_{i=1}^N$
parameterised in either absolute UNIX time, or seconds since
profile start, or seconds since the propagator's own start. Resolution
happens lazily through the \verb|Propagator.BuildModel| hook so the same
spec can be reused across profiles with different launch times.
The contribution at time $t$ is
\[
\mathbf{F}_{\text{pwc}}(t, \mathbf{s}) = (0, 0, r_{i^\star}),
\qquad i^\star = \min\{i : \tau_i > t\}.
\]
\paragraph{Wind transport.} The horizontal contribution from sampling the
loaded wind field $W$ is the wind itself:
\[
\mathbf{F}_{\text{wind}}(t, \mathbf{s}) = (u,\; v,\; 0),
\qquad (u, v) = W(t, \varphi, \lambda, h),
\]
in metres per second east and north. No conversion is performed.
Earlier revisions converted this to degrees per second, which introduced a
$1/\cos\varphi$ factor in longitude. That factor diverges at the poles: for
$u = 10$~m/s it grows from $8.95\times10^{-5}$~deg/s at the equator to
$0.513$~deg/s at $\varphi = 89.99^\circ$ and $1.46\times10^{12}$~deg/s at
$\varphi = 90^\circ$ (large but finite, since $\cos(\pi/2)$ evaluates to
$6.12\times10^{-17}$ in double precision rather than to zero). Against a fixed
step this made the integrator meaningless near the poles. The factor is now
absent from the formulation rather than guarded against; see
section~\ref{sec:geostep}. The implementation lives in
\verb|engine.WindTransport| (\verb|engine/models.go|).
\paragraph{Coordinate system.} The model is a spherical Earth. State is
still carried as $(\varphi, \lambda, h)$ in degrees and metres, because
constraints, path recording and the REST API all speak latitude and
longitude --- but motion is \emph{not} integrated in those coordinates.
Displacement is applied by rotating the position vector along a great
circle (section~\ref{sec:geostep}), so no longitude derivative is ever
formed and there is no coordinate singularity at the poles. Latitude also
cannot leave $[-90, 90]$, since it is read back from a unit vector instead
of accumulated.
This is a deliberate departure from the reference Tawhiri predictor, which
integrates in plate-carrée coordinates. Outputs are therefore no longer
bit-identical to it. The measured cost is small: on GFS
\verb|2026-08-03T00:00:00Z|, launch $89^\circ$N $68^\circ$E, burst
25~km --- a 114~km flight --- the two integrators differ by at most
$1.608$~m along the track and $0.058$~m at the landing point. That is the
order of the truncation error, and far below the representation error of
the wind data itself. Back-to-back testing against Tawhiri is retained as
an agreement-within-tolerance check rather than an equality check, run with
\verb|cmd/compare-tawhiri| and its \verb|-align-dataset| flag (on by default),
which asks the hosted service to use the local predictor's GFS run --- without
it the two sides silently compare different weather. Note the hosted service
retains only recent runs, so alignment fails once the local dataset ages out.
Measured on GFS \verb|2026-08-03T06:00:00Z|, burst 25~km:
\begin{center}
\begin{tabular}{lrrrr}
launch & burst $\Delta$ & landing $\Delta$ & apex alt $\Delta$ & land alt $\Delta$ \\
\hline
$52.2^\circ$N $0.1^\circ$E & 1~m & 60~m & 0~m & 47~m \\
$89^\circ$N $68^\circ$E & 2~m & 0~m & 0~m & 0~m \\
\end{tabular}
\end{center}
The polar launch agrees exactly. The 60~m at mid-latitude is not integrator
error: it follows from the 47~m difference in termination altitude, because the
reference has the ruaumoko elevation dataset and terminates on terrain while
this deployment has none and terminates at sea level. At $89^\circ$N the
surface is sea ice, so sea level is the terrain and the difference vanishes.
A WGS84 ellipsoid variant remains deferred: it would require converting
U/V wind components from the GFS sphere model to the ellipsoid, which is
not a trivial coordinate transform.
% =========================================================================
\section{Profiles and propagators}
\label{sec:profile}
\paragraph{Propagator.} A propagator owns one Model and a list of
Constraints; it produces a sequence of trajectory points via classical
Runge--Kutta--4 integration with step $\Delta t$ (positive for forward,
negative for reverse propagation):
\[
\Pi : (t_0, \mathbf{s}_0) \;\longmapsto\; \bigl[(t_k, \mathbf{s}_k)\bigr]_{k=0}^{K}.
\]
The sequence ends at the first $k$ where any constraint is violated;
the violation point is refined by binary search (see
\S\ref{sec:numerics}).
\paragraph{Profile.} A profile is an ordered chain of propagators
$[\Pi_1, \Pi_2, \ldots, \Pi_N]$. Stage $i$ starts where stage $i-1$
ended; the time direction (sign of $\Delta t$) is shared.
\paragraph{Constraint actions.} When a constraint $c$ is violated at the
refined point $(t^\star, \mathbf{s}^\star)$, $c.\text{Action}$ controls
the dispatch:
\begin{itemize}
\item \texttt{stop} — the profile ends at $(t^\star, \mathbf{s}^\star)$.
\item \texttt{fallback} — the current propagator hands off to its
\texttt{Fallback} propagator (chains supported).
\item \texttt{clip} — the violated coordinate is clipped to the
constraint's boundary and integration continues. Useful for soft
constraints such as ``hold altitude above 500~m''.
\end{itemize}
Constraints fire on full RK4 steps only, never on intermediate
sub-evaluations. This matches the reference Tawhiri behaviour
bit-for-bit.
\paragraph{Reverse propagation.} A profile with \verb|Direction = Reverse|
runs every propagator with $\Delta t = -\Delta t$. Models are
direction-agnostic: their derivative formulas above hold unchanged. The
typical use is to start from a known landing point and recover the
launch position by integrating backwards in time.
% =========================================================================
\section{Constraint geometry}
\label{sec:constraints}
The engine ships four constraint primitives.
\paragraph{Scalar comparison: altitude.}
\(
c_{\text{alt}}(t, \mathbf{s}) \;=\; h \,\mathrel{\bigotimes}\, h_0,
\)
where $\bigotimes \in \{<, \le, >, \ge, =\}$ is the configured operator
and $h_0$ is the limit (metres). The implementation is
\verb|engine.Altitude| (\verb|engine/constraints.go|).
\paragraph{Scalar comparison: time.} Same shape as altitude, but acting
on $t$ in UNIX seconds. Implementation: \verb|engine.Time|.
\paragraph{Terrain contact.}
\(
c_{\text{terr}}(t, \mathbf{s}) = \bigl(z(\varphi, \lambda) > h\bigr),
\)
with $z$ provided by the ruaumoko-compatible elevation dataset.
\paragraph{Polygon.} For a polygon $P$ with vertices
$(\varphi_i, \lambda_i)_{i=1}^N$ and mode $\mu \in
\{\text{inside}, \text{outside}\}$, the constraint is
$c_{\text{poly}}(\mathbf{s}) = \bigl(\mathbf{s} \in P\bigr) \oplus
[\mu = \text{outside}]$. Containment is tested by ray casting in
plate-carrée after normalising every longitude to within 180\textdegree{}
of the first vertex; this handles antimeridian-crossing edges so long
as the polygon spans no more than 180\textdegree{} in longitude.
% =========================================================================
\section{Numerics library}
\label{sec:numerics}
The numerics package (\verb|internal/numerics|) provides four primitives:
regular-grid bracketing, multilinear interpolation, monotone bisection,
and classical RK4 with termination-point refinement.
\subsection{Regular-grid bracketing}
\paragraph{Definition.} An \emph{axis} is the regularly-spaced sequence
$x_i = \ell + i \cdot s$ for $i = 0, 1, \ldots, N - 1$, parameterised by
the left edge $\ell$, the step $s > 0$, and the point count $N$.
Given a query $v$, the \emph{bracket} is the pair $(i_0, i_1)$ with
$x_{i_0} \le v \le x_{i_1}$ and the dimensionless position
\[
f = \frac{v - x_{i_0}}{s} \in [0, 1].
\]
Implemented as \verb|Axis.Locate| in \verb|internal/numerics/grid.go|.
\paragraph{Both ends are closed.} The accepted range is
$[\ell, \ell + (N-1)s]$, and the upper end resolves to the last cell at
$f = 1$ rather than opening a cell with no neighbour above it. This matters
on the latitude axis, where $\ell + (N-1)s = 90^\circ$ is the north pole:
that row carries real data --- NCEP resolves the GFS pole row per longitude
--- so it is a usable grid row like any other. While the upper end was open,
sampling exactly $90^\circ$ returned an error; the wind model discarded that
error and returned a zero rate, so a prediction launched at the pole froze in
place, and the wind-field endpoint reported a row of calm where a 29~m/s flow
was blowing. The same argument applies to the last forecast hour and the
topmost pressure level.
Bound-checking is done on $p = (v - \ell)/s$ before truncation. Testing the
truncated index instead admitted values just below $\ell$, because Go's
\verb|int()| truncates toward zero: $p = -0{.}002$ became index $0$ and
extrapolated off the end of the axis.
\paragraph{Wrapping axes.} For periodic axes (e.g.\ longitude), the
sequence is extended by the convention $x_N = x_0$ so a value approaching
$x_N$ from below brackets $(N{-}1, 0)$ with fraction
$f = (v - x_{N-1})/s$.
\paragraph{Worked example.} Latitude axis with $\ell = -90$, $s = 0{.}5$,
$N = 361$. Query $v = -89{.}75$ yields $p = 0{.}5$, so $i_0 = 0$,
$i_1 = 1$, $f = 0{.}5$. Query $v = 90$ yields $p = 360$, which clamps to
$i_0 = 359$, $i_1 = 360$, $f = 1$ --- the north pole row at full weight.
\subsection{Multilinear interpolation}
\paragraph{Definition.} For a scalar field $u$ defined at the grid nodes
of three axes, the trilinear interpolant at brackets
$b_a, b_b, b_c$ is
\[
\tilde u = \sum_{i, j, k \in \{0, 1\}} w_{a,i} \, w_{b,j} \, w_{c,k}
\; u\bigl(b_a^i, b_b^j, b_c^k\bigr),
\]
where $w_{\bullet, 0} = 1 - f_\bullet$ and $w_{\bullet, 1} = f_\bullet$.
The corner terms are accumulated in the order
$(0,0,0), (0,0,1), \ldots, (1,1,1)$, matching the reference Cython
implementation so that double-precision results agree byte for byte.
\paragraph{Linear exactness.} For any affine field
$u(i, j, k) = \alpha i + \beta j + \gamma k + \delta$, the formula returns
$\alpha p_a + \beta p_b + \gamma p_c + \delta$ exactly (modulo
floating-point rounding), where $p_\bullet = b_\bullet^0 + f_\bullet$.
\subsection{Monotone bisection}
For an integer-indexed monotone non-decreasing sequence
$f : \{i_{\min}, \ldots, i_{\max}\} \to \mathbb{R}$ and a target $t$,
\verb|Bisect| returns the largest index $i^\star$ with $f(i^\star) < t$.
Used by the wind sampler to locate the pressure level bracketing the
query altitude. Time complexity:
$\mathcal{O}(\log(i_{\max} - i_{\min}))$.
\subsection{Classical RK4}
\paragraph{Definition.} For a state $y$, derivative $\dot y = f(t, y)$,
and step $\Delta t$, \verb|RK4Step| applies
\[
\begin{aligned}
k_1 &= f(t, y), \\
k_2 &= f\bigl(t + \tfrac{\Delta t}{2}, \; y + \tfrac{\Delta t}{2} k_1\bigr), \\
k_3 &= f\bigl(t + \tfrac{\Delta t}{2}, \; y + \tfrac{\Delta t}{2} k_2\bigr), \\
k_4 &= f\bigl(t + \Delta t, \; y + \Delta t \cdot k_3\bigr), \\
y(t + \Delta t) &= y + \tfrac{\Delta t}{6}\bigl(k_1 + 2 k_2 + 2 k_3 + k_4\bigr).
\end{aligned}
\]
Reverse-time integration uses $\Delta t < 0$ unchanged; the implementation
contains no branch on the sign of $\Delta t$.
\paragraph{Stage combination on the sphere.} The additions above are not
performed in $(\varphi, \lambda, h)$. Each stage rate is a velocity in the
local horizontal frame at \emph{its own} evaluation point, and that frame
rotates from one stage to the next --- near a pole, fast enough that
averaging east/north components directly would reintroduce the error this
formulation exists to remove. The stages are therefore converted to
earth-centred vectors, combined with the usual $\tfrac16(1,2,2,1)$
weights, read back in the frame at the starting point (which also discards
the small radial component the averaging introduces), and applied as a
single great-circle step:
\[
\bar{\mathbf V} = \sum_i w_i \bigl(k_i^{E}\,\hat{\mathbf e}_i + k_i^{N}\,\hat{\mathbf n}_i\bigr),
\qquad
y(t + \Delta t) = \mathrm{GeoStep}\bigl(y,\; \bar{\mathbf V},\; \Delta t\bigr).
\]
\subsection{Great-circle stepping}
\label{sec:geostep}
\verb|GeoStep| advances a position by a horizontal velocity
$(u, v)$ and a vertical rate $w$ over $\Delta t$. With
$\hat{\mathbf r}, \hat{\mathbf e}, \hat{\mathbf n}$ the outward radial,
east and north unit vectors at $(\varphi, \lambda)$, speed
$V = \sqrt{u^2 + v^2}$ and unit travel direction
$\hat{\mathbf t} = (u\,\hat{\mathbf e} + v\,\hat{\mathbf n})/V$:
\[
\delta = \frac{V \Delta t}{R + h},
\qquad
\hat{\mathbf r}' = \hat{\mathbf r}\cos\delta + \hat{\mathbf t}\sin\delta,
\qquad
h' = h + w\,\Delta t,
\]
and $(\varphi', \lambda')$ are read back from $\hat{\mathbf r}'$ via
$\varphi' = \arcsin r'_z$, $\lambda' = \operatorname{atan2}(r'_y, r'_x)$.
The step is exact for a constant rate. Because $\hat{\mathbf t}$ is
orthogonal to $\hat{\mathbf r}$ by construction, $\hat{\mathbf r}'$ stays
on the unit sphere without renormalisation. All three basis vectors are
unit length at every latitude including the poles; what happens at a pole
is not a degeneracy but a genuine ambiguity, since east and north there
depend on which meridian $\lambda$ names. That matches the data --- NCEP
resolves the GFS pole row per longitude for exactly this reason, so any
choice yields the same physical vector.
A step that reaches a pole simply continues down the far side and
$\lambda$ picks up $180^\circ$ on its own, with no special case and no
threshold latitude. This is asserted directly in
\verb|numerics/spherical_test.go|.
\subsection{Termination refinement}
After each integration step the propagator checks one or more
constraints. When a constraint reports a violation between $(t_1, y_1)$
(not violated) and $(t_2, y_2)$ (violated), \verb|RefineTrigger|
locates the crossing within tolerance $\tau \in (0, 1)$ by binary
search in the linear-interpolation parameter $\lambda \in [0, 1]$:
\begin{algorithm}[H]
\caption{RefineTrigger}\label{alg:refine}
\begin{algorithmic}[1]
\State $L \gets 0,\; R \gets 1$
\State $t_3 \gets t_2,\; y_3 \gets y_2$
\While{$R - L > \tau$}
\State $m \gets (L + R)/2$
\State $t_3 \gets (1 - m)\,t_1 + m\,t_2$
\State $y_3 \gets \mathrm{lerp}(y_1, y_2, m)$
\If{constraint violated at $(t_3, y_3)$}
\State $R \gets m$
\Else
\State $L \gets m$
\EndIf
\EndWhile
\State \Return $(t_3, y_3)$
\end{algorithmic}
\end{algorithm}
After $\lceil \log_2 \tau^{-1} \rceil$ iterations, $R - L \le \tau$.
With $\tau = 0{.}01$ and $\Delta t = 60$~s, the returned point is within
$0{.}6$~s of the true crossing in parameter space; the corresponding
altitude error is bounded by $0{.}6\,|\dot y|$, which for typical
balloon ascent and parachute descent rates is at most $\sim 3$~m.
The returned point is the \emph{last midpoint sampled} rather than
guaranteed to lie on the triggered side; this matches the reference
Tawhiri implementation byte for byte.
% =========================================================================
\section{Wind data pipeline}
\label{sec:winddata}
\paragraph{Data source.} NOAA GFS 0.5\textdegree{} (default) or 0.25\textdegree{}
forecasts, optionally subset by region or hour range. GEFS ensemble
runs are supported by selecting one of the 21 members; each member is a
separate dataset (\verb|DatasetID.Subset.Members = \{m\}|).
\paragraph{Cube layout.} A flat C-order row-major float32 array, shape
$(N_{\text{hours}}, N_{\text{levels}}, 3, N_{\text{lat}}, N_{\text{lng}})$,
where the variable axis is fixed to (HGT, UGRD, VGRD). Per-variant sizes
live in \verb|internal/weather/gfs/variant.go|.
\paragraph{Sampling.} Given a query $(t, \varphi, \lambda, h)$, the
sampler computes the time-in-hours offset
$\tau = (t - t_0)/3600$ from the dataset epoch $t_0$, brackets
$(\tau, \varphi, \lambda)$ on the three horizontal axes, then bisects
the pressure-level axis to find the largest level $\ell$ whose
trilinearly-interpolated HGT is below $h$. Wind components are
extracted via two more trilinear evaluations (at levels $\ell$ and
$\ell + 1$) and linearly interpolated in altitude:
\[
W(t, \varphi, \lambda, h) = \alpha \cdot W_\ell + (1 - \alpha) \cdot W_{\ell+1},
\qquad
\alpha = \frac{H_{\ell+1} - h}{H_{\ell+1} - H_\ell}.
\]
% =========================================================================
\section{Coverage and dataset selection}
\label{sec:coverage}
A loaded dataset $\mathcal{D}$ exposes its \emph{coverage}
$C_\mathcal{D} = (R_\mathcal{D}, [t_0, t_1])$ where $R_\mathcal{D}$ is a
geographic bounding box (possibly antimeridian-spanning) and
$[t_0, t_1]$ is the temporal extent. When more than one dataset is
loaded simultaneously, the predictor selects the first one whose
$C_\mathcal{D}$ contains the launch query. Regional / sub-range
datasets thus complement the global default.
% =========================================================================
\section{Deferrals and design notes}
\label{sec:deferrals}
\paragraph{Mass-aware drift.} The current model assumes the payload moves
horizontally at exactly the local wind velocity. A heavier payload
exhibits a velocity defect proportional to inertial coupling. A
plausible extension is the Stokes-style first-order lag model
\[
\dot{\mathbf{v}}_p = \frac{1}{\tau}\bigl(\mathbf{v}_{\text{wind}}(t,\mathbf{s}) - \mathbf{v}_p\bigr),
\]
introduced as an additional state variable $\mathbf{v}_p$ alongside the
existing $\mathbf{s}$. The Propagator interface already accepts
arbitrary State types via generics in numerics; the engine could lift
its State to $(\mathbf{s}, \mathbf{v}_p)$ for a future mass-aware
propagator without breaking the existing models.
\paragraph{Coordinate system upgrades.} The cosine factor is no longer a
deferral: horizontal motion is integrated by great-circle rotation and the
$1/\cos\varphi$ term is gone from the formulation entirely. What remains
deferred is the \emph{ellipsoid}: migrating from a spherical Earth to
WGS84 would make distances metric directly, but GFS itself uses a
spherical Earth and its wind components are not directly portable to the
ellipsoid. A clean implementation would provide a coordinate-system
parameter on the profile request; for now the spherical model is used
uniformly.
\paragraph{Monte Carlo.} GEFS already provides 21 ensemble members per
epoch. A Monte Carlo prediction would sample $K$ trajectories per
request, each picking a (member, parameter perturbation) pair. The
recommended architecture is to keep the perturbation inside the
predictor (so the same wind sample can serve many members and any
piecewise rate noise is correlated with the wind step), exposed as a
\verb|POST /api/v1/montecarlo| endpoint that returns one job per
sample and aggregates outcomes.
% =========================================================================
\section{Implementation notes}
\label{sec:impl}
The numerics library is intentionally small (under 300 lines of Go) and
uses no allocations on the hot path. The generic \verb|RK4Step| and
\verb|RefineTrigger| compile to per-type specialisations under Go's
generics, so a future C or Rust port can mirror the implementation
verbatim without changing the call sites in the trajectory engine.
\end{document}