\documentclass[11pt,a4paper]{article} \usepackage{siunitx} \usepackage{amsmath,amssymb,amsthm} \usepackage{geometry} \usepackage{hyperref} \usepackage{booktabs} \geometry{margin=2.5cm} \title{Wind Visualisation -- Mathematical Reference\\ \large stratoflights-predictor frontend} \author{stratoflights} \date{} \begin{document} \maketitle \tableofcontents \bigskip %--------------------------------------------------------------------------- \section{Wind Field Grid Format} %--------------------------------------------------------------------------- The predictor's \texttt{GET /api/v1/wind/field} endpoint returns a two-element JSON array $[C_U,\,C_V]$, each element having the shape \begin{verbatim} { "header": { "nx", "ny", "lo1", "la1", "lo2", "la2", "dx", "dy", "refTime", ... }, "data": [ float, ... ] // flat row-major array, length = nx * ny } \end{verbatim} \noindent where $C_U$ contains the \emph{eastward} (zonal) component $u$ and $C_V$ contains the \emph{northward} (meridional) component $v$, both in \si{m\,s^{-1}}. \subsection{Grid coordinates} Let $n_x$ and $n_y$ be the number of grid points along the longitude and latitude axes respectively. The coordinate of grid cell $(i,\,j)$ ($i = 0, \ldots, n_x-1$;\; $j = 0, \ldots, n_y-1$) is \begin{align} \lambda_{i} &= \operatorname{wrap}\!\left(\lambda_1 + i\,\Delta\lambda\right), \label{eq:lng}\\ \varphi_{j} &= \varphi_1 + j\,\Delta\varphi, \label{eq:lat} \end{align} \noindent where $\lambda_1 = \texttt{lo1}$ and $\varphi_1 = \texttt{la1}$. \paragraph{Increments from the extent, not \texttt{dx}/\texttt{dy}.} The predictor reports \texttt{dx} and \texttt{dy} as positive magnitudes \emph{regardless of scan direction}, and emits longitudes in the $[0,360)$ convention. The per-step increments are therefore derived from the grid extent so the last row/column lands exactly on $(\texttt{la2},\texttt{lo2})$ and the scan direction follows automatically: \begin{equation} \Delta\lambda = \frac{(\texttt{lo2}-\texttt{lo1}) \bmod 360}{n_x-1}, \qquad \Delta\varphi = \frac{\texttt{la2}-\texttt{la1}}{n_y-1}. \label{eq:increments} \end{equation} For the standard GFS global grid $\varphi_1 = 90^\circ$, $\varphi_2 = -90^\circ$, so $\Delta\varphi = -10^\circ$ (rows scan southward) without any case distinction. (When $n_x=1$ or $n_y=1$ the magnitude \texttt{dx}/\texttt{dy} is used as a fallback.) \paragraph{Longitude wrapping.} Because longitudes are emitted in $[0,360)$ but the map renders in $(-180,180]$, each longitude is wrapped: \begin{equation} \operatorname{wrap}(\lambda) = \big((\lambda + 180) \bmod 360\big) - 180. \label{eq:wrap} \end{equation} Without \eqref{eq:wrap} a grid sampled near the prime meridian (e.g.\ $\texttt{lo1}=358$ for a query at $-2^\circ$) would be rendered a full $360^\circ$ away from the trajectory. The flat data index for cell $(i,\,j)$ is \begin{equation} k = j\,n_x + i. \label{eq:index} \end{equation} %--------------------------------------------------------------------------- \section{Bilinear Interpolation of Wind Components} %--------------------------------------------------------------------------- The particle renderer samples the wind at arbitrary points (the unprojected pixel positions of individual particles), so bilinear interpolation of the $[u,v]$ grid is performed at run time for every particle, every frame. Given a query point $(\lambda,\,\varphi)$ inside the grid, locate the surrounding four cells \begin{align*} i_0 &= \left\lfloor \frac{\lambda - \lambda_1}{\Delta\lambda} \right\rfloor, & j_0 &= \left\lfloor \frac{\varphi - \varphi_1}{\Delta\varphi} \right\rfloor, \end{align*} and define the fractional offsets \begin{equation*} s = \frac{\lambda - \lambda_{i_0}}{\Delta\lambda}, \qquad t = \frac{\varphi - \varphi_{j_0}}{\Delta\varphi}, \qquad s,t \in [0,1]. \end{equation*} The bilinearly interpolated value of any scalar field $f$ at $(\lambda,\varphi)$ is \begin{equation} f(\lambda,\varphi) = (1-s)(1-t)\,f_{i_0,j_0} + s(1-t)\,f_{i_0+1,j_0} + (1-s)t\,f_{i_0,j_0+1} + st\,f_{i_0+1,j_0+1}. \label{eq:bilinear} \end{equation} Applied independently to $u$ and $v$, equation~\eqref{eq:bilinear} gives the interpolated wind vector at any interior point. %--------------------------------------------------------------------------- \section{Particle Advection and Rendering} %--------------------------------------------------------------------------- The wind is visualised as a dense field of particles that flow with the wind, in the style of \texttt{leaflet-velocity} / cambecc's \emph{earth}. Particles live in screen (CSS-pixel) space; each animation frame they are advected by the local wind and drawn as short fading trails. \subsection{Speed (magnitude)} The wind speed used for colouring is the Euclidean norm of the horizontal wind vector returned by the interpolator~\eqref{eq:bilinear}: \begin{equation} \lVert\mathbf{w}\rVert = \sqrt{u^2 + v^2}. \label{eq:speed} \end{equation} \subsection{Advection through the map projection} A particle at pixel position $\mathbf{x} = (x,y)$ is unprojected to geographic coordinates $(\lambda,\varphi) = P^{-1}(\mathbf{x})$, where $P$ is the MapLibre projection (Web Mercator composed with the current view transform). The wind $\mathbf{w}=(u,v)$ in the east--north frame must be expressed in pixel space; this is done with the local Jacobian of $P$, estimated by finite differences with a small $\varepsilon$ (degrees): \begin{align} \mathbf{J}_\lambda &= \frac{P(\lambda+\varepsilon,\varphi) - \mathbf{x}}{\varepsilon}, & \mathbf{J}_\varphi &= \frac{P(\lambda,\varphi+\varepsilon) - \mathbf{x}}{\varepsilon}. \label{eq:jacobian} \end{align} \noindent The pixel-space velocity is the Jacobian applied to the wind vector, scaled by a dimensionless speed factor $c$ (the configurable \texttt{particleSpeed} times a base constant): \begin{equation} \dot{\mathbf{x}} = c\,\big(\mathbf{J}_\lambda\, u + \mathbf{J}_\varphi\, v\big). \label{eq:pixel_velocity} \end{equation} Because $\mathbf{J}_\varphi$ points toward $-y$ (north is up), a northward wind moves the particle up the screen, and the projection automatically supplies the correct scale at every zoom level and the meridian-convergence distortion near the poles. The particle is integrated one explicit Euler step per frame: \begin{equation} \mathbf{x}_{t+1} = \mathbf{x}_t + \dot{\mathbf{x}}. \label{eq:euler} \end{equation} A particle whose position leaves the data grid (interpolator returns \texttt{null}) or whose age exceeds $A_{\max}$ frames is respawned at a random pixel that has wind, with a randomised initial age to desynchronise the population. \subsection{Colour mapping} Speed is mapped to a 15-stop colour ramp (blue $\to$ red) by linear quantisation into the range $[v_{\min}, v_{\max}]$: \begin{equation} k(\lVert\mathbf{w}\rVert) = \operatorname{round}\!\left( \frac{\lVert\mathbf{w}\rVert - v_{\min}}{v_{\max}-v_{\min}}\,(K-1) \right), \quad k \in \{0,\dots,K-1\}, \end{equation} \noindent clamped to the ramp, where $K=15$ and $v_{\max}$ is the configurable \texttt{maxVelocity}. Trails sharing a colour bucket are batched into a single stroked path per frame. \subsection{Trail fading} Rather than clearing the canvas each frame, the previous frame is faded toward \emph{transparency} (so the basemap stays visible) by compositing a translucent rectangle with the \texttt{destination-in} operator: \begin{equation} \alpha_{t+1}(\mathbf{x}) = \rho\,\alpha_t(\mathbf{x}), \qquad \rho \in [0,1), \end{equation} \noindent where $\rho$ is the configurable \texttt{trailPersistence}; an existing trail therefore decays geometrically, retaining a visible tail of length $\sim 1/(1-\rho)$ frames. New trail segments are then drawn with the \texttt{source-over} operator. \subsection{Particle count} The particle population is proportional to the canvas area: \begin{equation} N = \min\!\big(N_{\max},\; W H \, \mu \, \texttt{density}\big), \end{equation} \noindent with base multiplier $\mu = 1/350$ particles per pixel, the configurable \texttt{density} factor, and a hard cap $N_{\max}=6000$ to bound per-frame cost. The field is re-seeded on resize and on map \texttt{moveend}; trails are cleared while the map is panning/zooming and resume afterwards. %--------------------------------------------------------------------------- \section{Trajectory Bounding Box} %--------------------------------------------------------------------------- Let the prediction trajectory be the sequence of points $\{(\varphi_k, \lambda_k)\}_{k=0}^{N-1}$. The axis-aligned bounding box with margin $m$ (degrees) is \begin{align} \varphi_{\min}' &= \min_k \varphi_k - m, & \varphi_{\max}' &= \max_k \varphi_k + m, \notag\\ \lambda_{\min}' &= \min_k \lambda_k - m, & \lambda_{\max}' &= \max_k \lambda_k + m. \label{eq:bbox} \end{align} A wind field request is suppressed when either span exceeds the configured maximum $D_{\max}$: \begin{equation} (\varphi_{\max}' - \varphi_{\min}') > D_{\max} \quad\text{or}\quad (\lambda_{\max}' - \lambda_{\min}') > D_{\max}. \label{eq:bbox_guard} \end{equation} Default values: $m = 1^\circ$, $D_{\max} = 20^\circ$. %--------------------------------------------------------------------------- \section{Trajectory Altitude Lookup} %--------------------------------------------------------------------------- The prediction result contains two parallel arrays: $\mathbf{p} = \{(\varphi_k, \lambda_k, a_k)\}$ (flight path with altitude in metres) and $\mathbf{t} = \{t_k\}$ (absolute epoch timestamps in milliseconds, $t_k < t_{k+1}$). Given a flight-time offset $\delta$ (milliseconds from launch), the absolute query time is $T = t_0 + \delta$. The index of the immediately following trajectory point is found by binary search: \begin{equation} k^* = \min\{k \in \{0,\ldots,N-1\} : t_k \ge T\}. \label{eq:binsearch} \end{equation} The trajectory altitude used for the wind query is then $a_{k^*}$ (nearest-neighbour in time, no interpolation needed since the pre-fetch frames are already spaced far apart relative to the step size). %--------------------------------------------------------------------------- \section{Time Series Pre-Fetching} %--------------------------------------------------------------------------- \subsection{Frame schedule} Let $F$ be the total flight duration in milliseconds and $\Delta T$ the pre-fetch interval (milliseconds, default $15 \times 60\,000$). The set of pre-fetch time offsets is \begin{equation} \mathcal{S} = \{0,\;\Delta T,\;2\Delta T,\;\ldots,\;F\}, \label{eq:schedule} \end{equation} where the last element is clamped to $F$ (so the landing point is always included). For each $\delta \in \mathcal{S}$ a wind field is fetched at absolute time $T = t_0 + \delta$ and trajectory altitude $a_{k^*(\delta)}$ within the bounding box~\eqref{eq:bbox}. \subsection{Guard conditions} Pre-fetching is skipped entirely when \begin{enumerate} \item $F > F_{\max}$ (flight too long; default $F_{\max} = 4$\,h), or \item either bounding-box dimension exceeds $D_{\max}$ (equation~\eqref{eq:bbox_guard}). \end{enumerate} Both limits are configurable in the application settings. \subsection{Temporal interpolation between frames} During playback at flight-time offset $\delta$, rather than snapping to the nearest cached frame (which would make the wind jump every $\Delta T$), the displayed field is \emph{linearly interpolated} between the two bracketing frames $\delta_p \le \delta < \delta_{p+1}$ ($\delta_p,\delta_{p+1}\in\mathcal{S}$). With blend factor \begin{equation} \alpha = \frac{\delta - \delta_p}{\delta_{p+1} - \delta_p} \in [0,1), \label{eq:blend_alpha} \end{equation} each wind component is blended cell-for-cell: \begin{equation} u(\delta) = (1-\alpha)\,u^{(p)} + \alpha\,u^{(p+1)}, \qquad v(\delta) = (1-\alpha)\,v^{(p)} + \alpha\,v^{(p+1)}. \label{eq:time_lerp} \end{equation} This is valid because every frame is fetched over the \emph{same} bounding box~\eqref{eq:bbox} and step, so the grids are cell-aligned ($n_x,n_y, \lambda_1,\varphi_1$ identical) and~\eqref{eq:time_lerp} requires only an element-wise blend of the two flat $[u,v]$ arrays. Note that the two frames are sampled at slightly different altitudes $a_{k^*(\delta_p)}$ and $a_{k^*(\delta_{p+1})}$; the blend therefore also interpolates across the balloon's changing altitude, which is the desired behaviour. The result is continuous wind evolution along the route while still requiring only $|\mathcal{S}|$ network requests per trajectory. The blended field~\eqref{eq:time_lerp} is handed to the particle renderer (\S\,Particle Advection) as the interpolator swapped in each frame, so the flowing particles advect through the smoothly time-evolving wind without any visible stepping between pre-fetch frames. %--------------------------------------------------------------------------- \section{Complexity and Performance Notes} %--------------------------------------------------------------------------- \subsection{Grid size and render cost} For a regional bounding box of $L_\varphi \times L_\lambda$ degrees and step $h$ degrees, the number of grid cells fetched is \begin{equation} n = \left\lceil \frac{L_\varphi}{h} \right\rceil \left\lceil \frac{L_\lambda}{h} \right\rceil. \end{equation} With the default trajectory step $h = 1^\circ$ and worst-case dimensions $20^\circ \times 20^\circ$ this is $n = 400$ cells; the static global view at $h = 2^\circ$ is $90 \times 180 = 16\,200$ cells. Crucially, the grid size only affects the \emph{fetch} and the per-particle bilinear lookup, not the render budget: the per-frame cost is governed by the particle count $N$ (eq.~for $N$), capped at $N_{\max}=6000$, independent of the grid resolution or the geographic extent. Each particle costs one unprojection, one interpolation, and two projections (for the Jacobian) per frame. \subsection{Request count} The total number of requests for one trajectory is \begin{equation} |\mathcal{S}| = \left\lfloor \frac{F}{\Delta T} \right\rfloor + 1 \;\le\; \frac{F_{\max}}{\Delta T} + 1. \end{equation} With the defaults $F_{\max} = 4$\,h and $\Delta T = 15$\,min: $|\mathcal{S}| \le 17$. Requests are issued sequentially to avoid bursty load on the predictor. \subsection{Caching} The in-memory cache (keyed by the full parameter tuple) ensures that re-running a prediction with the same parameters, scrubbing the timeline back and forth, or toggling the wind layer all reuse existing responses. \end{document}