395 lines
14 KiB
TeX
395 lines
14 KiB
TeX
\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}
|