compare panel, docs update, wind visualisation
This commit is contained in:
parent
b7f7ec8dc5
commit
48140f0f77
29 changed files with 2299 additions and 38 deletions
41
docs/diagrams/README.md
Normal file
41
docs/diagrams/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Диаграммы (PlantUML)
|
||||
|
||||
Исходники диаграмм для диссертации. Построены по фактическому коду
|
||||
(`src/lib/api/*`, `src/lib/features/tracking/*`, `src/lib/features/wind/*`).
|
||||
|
||||
## Архитектура
|
||||
|
||||
| Файл | Что описывает |
|
||||
|------|----------------|
|
||||
| [architecture.puml](architecture.puml) | Слоистая архитектура клиента: routes → features → lib (api/map/ui/state/i18n/auth/domain), внешние сервисы (Backend, предсказатель) и библиотеки |
|
||||
|
||||
## Диаграммы последовательности (по всем запросам)
|
||||
|
||||
| Файл | Что описывает | Запросы |
|
||||
|------|----------------|---------|
|
||||
| [seq-request-csrf.puml](seq-request-csrf.puml) | Сквозная обёртка `request<T>()`: CSRF, cookie, обработка ошибок и 401 | `GET /api/csrf/` |
|
||||
| [seq-auth.puml](seq-auth.puml) | Проверка сессии, вход, выход | `GET /api/session/`, `GET /api/whoami/`, `POST /api/login/`, `POST /api/logout/` |
|
||||
| [seq-resource-crud.puml](seq-resource-crud.puml) | Единый CRUD-шаблон справочников | `/api/saved-points/`, `/api/saved-templates/`, `/api/saved-profiles/` (GET/POST/PUT/DELETE) |
|
||||
| [seq-prediction.puml](seq-prediction.puml) | Запуск прогноза траектории | `POST /api/predictions/` |
|
||||
| [seq-telemetry.puml](seq-telemetry.puml) | Загрузка истории и приём телеметрии | `GET /api/{id}/telemetry/`, `WS /api/ws/satellite/{id}/telemetry/` |
|
||||
| [seq-wind.puml](seq-wind.puml) | Визуализация поля ветра (статика + синхронизация) | `GET /api/v1/wind/field`, `GET /api/v1/wind/meta` |
|
||||
|
||||
## Диаграмма потоков данных
|
||||
|
||||
| Файл | Что описывает |
|
||||
|------|----------------|
|
||||
| [dfd-telemetry.puml](dfd-telemetry.puml) | DFD подсистемы слежения: внешние сущности, процессы 1.0–5.0, хранилища D1–D3 |
|
||||
|
||||
## Рендеринг в PNG/SVG
|
||||
|
||||
В системе есть Java, но нет CLI PlantUML. Один раз скачать jar и собрать все
|
||||
диаграммы:
|
||||
|
||||
```sh
|
||||
curl -L -o plantuml.jar https://github.com/plantuml/plantuml/releases/latest/download/plantuml.jar
|
||||
java -jar plantuml.jar -tpng docs/diagrams/*.puml # PNG
|
||||
java -jar plantuml.jar -tsvg docs/diagrams/*.puml # SVG (для печати)
|
||||
```
|
||||
|
||||
Альтернатива без установки — онлайн-редактор <https://www.plantuml.com/plantuml>
|
||||
или расширение PlantUML для VS Code (предпросмотр `Alt+D`).
|
||||
102
docs/diagrams/architecture.puml
Normal file
102
docs/diagrams/architecture.puml
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
@startuml architecture
|
||||
title Архитектура клиентской части системы
|
||||
|
||||
skinparam shadowing false
|
||||
skinparam defaultFontName Helvetica
|
||||
skinparam packageStyle rectangle
|
||||
skinparam rectangle {
|
||||
BorderColor #2C5AA0
|
||||
BackgroundColor #EAF1FB
|
||||
}
|
||||
skinparam package {
|
||||
BorderColor #6B6B6B
|
||||
BackgroundColor #FFFFFF
|
||||
}
|
||||
skinparam node {
|
||||
BorderColor #444444
|
||||
BackgroundColor #F5F5F5
|
||||
}
|
||||
|
||||
node "Браузер (SPA) — SvelteKit 5 + Vite" as spa {
|
||||
|
||||
package "routes/ (страницы)" as routes {
|
||||
rectangle "/login" as r_login
|
||||
rectangle "/predict" as r_predict
|
||||
rectangle "/track" as r_track
|
||||
rectangle "/user/*" as r_user
|
||||
}
|
||||
|
||||
package "features/ (функциональные модули)" as features {
|
||||
rectangle "auth\nLoginForm, Navbar" as f_auth
|
||||
rectangle "prediction\nControlPanel, ScenarioPanel,\nCurveEditor" as f_pred
|
||||
rectangle "workspaces\nWorkspacesPanel,\nWorkspaceRenderer, store" as f_ws
|
||||
rectangle "tracking\nTelemetryPanel,\nTelemetryStore" as f_track
|
||||
rectangle "wind\nWindRenderer,\nParticleField, WindCache" as f_wind
|
||||
rectangle "timeline\nTimeLine, store" as f_time
|
||||
rectangle "settings\nSettingsPanel, schema, store" as f_set
|
||||
rectangle "footer" as f_foot
|
||||
}
|
||||
|
||||
package "lib/ (ядро)" as core {
|
||||
rectangle "api/\nclient (HTTP+WS, CSRF),\npredictions, telemetry, wind,\npoints, scenarios, profiles" as l_api
|
||||
rectangle "map/\nобёртка MapLibre GL JS,\nслои, инструменты" as l_map
|
||||
rectangle "ui/\nнезависимые примитивы\n(CollapsibleCard, Toast, …)" as l_ui
|
||||
rectangle "i18n/\nсловари ru / en" as l_i18n
|
||||
rectangle "auth/\nguard, store" as l_auth
|
||||
rectangle "state/\npersisted store\n(localStorage)" as l_state
|
||||
rectangle "domain/\ngeo, math, telemetry,\nprediction, scenario, wind\n(чистые типы и функции)" as l_dom
|
||||
}
|
||||
}
|
||||
|
||||
' ── Внешние сервисы ───────────────────────────────────────────────
|
||||
node "Backend (Django)" as be {
|
||||
rectangle "REST API\n/api/*" as be_rest
|
||||
rectangle "WebSocket\n/api/ws/satellite/{id}/telemetry/" as be_ws
|
||||
}
|
||||
node "Сервис предсказателя\n(127.0.0.1:8080)" as predictor {
|
||||
rectangle "GET /api/v1/wind/*" as pred_wind
|
||||
}
|
||||
|
||||
' ── Внешние библиотеки ───────────────────────────────────────────
|
||||
package "Внешние библиотеки" as libs {
|
||||
rectangle "Svelte 5 / SvelteKit / Vite" as lib_svelte
|
||||
rectangle "MapLibre GL JS" as lib_map
|
||||
rectangle "Bootstrap / Sveltestrap" as lib_bs
|
||||
rectangle "Chart.js, Luxon, js-cookie" as lib_misc
|
||||
}
|
||||
|
||||
' ── Связи: страницы → модули ─────────────────────────────────────
|
||||
r_login --> f_auth
|
||||
r_predict --> f_pred
|
||||
r_predict --> f_ws
|
||||
r_predict --> f_wind
|
||||
r_predict --> f_time
|
||||
r_predict --> f_set
|
||||
r_track --> f_track
|
||||
r_user --> f_pred
|
||||
|
||||
' ── Модули → ядро ────────────────────────────────────────────────
|
||||
features --> l_api
|
||||
features --> l_map
|
||||
features --> l_ui
|
||||
features --> l_state
|
||||
features --> l_i18n
|
||||
f_auth --> l_auth
|
||||
|
||||
' ── Ядро → домен (чистые типы) ───────────────────────────────────
|
||||
l_api --> l_dom
|
||||
l_map --> l_dom
|
||||
l_state --> l_dom
|
||||
|
||||
' ── Ядро → внешние сервисы ───────────────────────────────────────
|
||||
l_api --> be_rest : HTTP /api/*
|
||||
f_track --> be_ws : WebSocket
|
||||
f_wind --> pred_wind : HTTP (без CSRF)
|
||||
|
||||
' ── Использование внешних библиотек ──────────────────────────────
|
||||
spa ..> lib_svelte
|
||||
l_map ..> lib_map
|
||||
l_ui ..> lib_bs
|
||||
core ..> lib_misc
|
||||
|
||||
@enduml
|
||||
62
docs/diagrams/dfd-telemetry.puml
Normal file
62
docs/diagrams/dfd-telemetry.puml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
@startuml dfd-telemetry
|
||||
title Диаграмма потоков данных (DFD) подсистемы слежения (телеметрия)
|
||||
|
||||
skinparam shadowing false
|
||||
skinparam defaultFontName Helvetica
|
||||
skinparam rectangle {
|
||||
BorderColor black
|
||||
BackgroundColor #F5F5F5
|
||||
}
|
||||
skinparam usecase {
|
||||
BorderColor #2C5AA0
|
||||
BackgroundColor #E8F0FE
|
||||
}
|
||||
skinparam database {
|
||||
BorderColor #6B6B6B
|
||||
BackgroundColor #FFFFFF
|
||||
}
|
||||
|
||||
' ── Внешние сущности ───────────────────────────────────────────────
|
||||
rectangle "Стратосферный зонд\n(тестовый клиент)" as sat
|
||||
rectangle "Оператор\n(браузер)" as op
|
||||
|
||||
' ── Процессы ──────────────────────────────────────────────────────
|
||||
usecase "1.0\nПриём телеметрии\n(WebSocket onmessage)" as p1
|
||||
usecase "2.0\nЗагрузка истории\n(REST fetchHistory)" as p2
|
||||
usecase "3.0\nНормализация\nparseTelemetry" as p3
|
||||
usecase "4.0\nОтрисовка на карте\n(MapLibre $effect)" as p4
|
||||
usecase "5.0\nАнализ отклонений\ncomputeDeviations" as p5
|
||||
|
||||
' ── Хранилища данных ──────────────────────────────────────────────
|
||||
database "D1 | БД телеметрии\n(Backend)" as d1
|
||||
database "D2 | points[]\n(TelemetryStore, in-memory)" as d2
|
||||
database "D3 | result\n(прогноз рабочей области)" as d3
|
||||
|
||||
' ── Потоки данных ─────────────────────────────────────────────────
|
||||
sat --> p1 : пакет телеметрии\n(JSON: lat, lon, alt, ts)
|
||||
p1 --> d1 : сохранение пакета
|
||||
p1 --> d2 : TelemetryPoint\n(unix-сек → ISO 8601)
|
||||
|
||||
op --> p2 : UUID спутника
|
||||
d1 --> p2 : RawTelemetryPacket[]\n(новые первыми)
|
||||
p2 --> d2 : история (reverse → хронология)
|
||||
|
||||
d2 --> p3 : points[]
|
||||
p3 --> p4 : Telemetry\n{flight_path[lat,lng,alt], launch}
|
||||
p4 --> op : трек + маркеры + анимированный\nмаркер текущего положения
|
||||
|
||||
d2 --> p5 : фактические точки
|
||||
d3 --> p5 : прогнозная траектория
|
||||
p5 --> op : профиль высоты,\nгоризонтальное отклонение (Хаверсин),\nΔh, макс./текущее отклонение
|
||||
|
||||
' ── Текущие показатели (геттер latest) ────────────────────────────
|
||||
d2 --> op : широта, долгота, высота,\nсчётчик пакетов
|
||||
|
||||
legend left
|
||||
Нотация DFD (Йордан/ДеМарко):
|
||||
▢ прямоугольник — внешняя сущность
|
||||
◯ овал — процесс
|
||||
▭ database — хранилище данных
|
||||
→ — поток данных
|
||||
endlegend
|
||||
@enduml
|
||||
41
docs/diagrams/seq-auth.puml
Normal file
41
docs/diagrams/seq-auth.puml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
@startuml seq-auth
|
||||
title Аутентификация: проверка сессии, вход, выход
|
||||
autonumber
|
||||
|
||||
actor "Пользователь" as user
|
||||
participant "LoginForm /\nguard.ts" as ui
|
||||
participant "authApi" as authapi
|
||||
participant "client.ts\nrequest<T>()" as client
|
||||
participant "Backend\n(Django)" as be
|
||||
|
||||
== Проверка сессии при открытии защищённой страницы ==
|
||||
ui -> authapi : requireAuthenticated()
|
||||
authapi -> client : session()
|
||||
client -> be : GET /api/session/
|
||||
be --> client : { isAuthenticated }
|
||||
client --> authapi : SessionInfo
|
||||
alt не аутентифицирован
|
||||
authapi --> ui : goto('/login')
|
||||
end
|
||||
|
||||
== Вход ==
|
||||
user -> ui : ввод логина и пароля
|
||||
ui -> authapi : login(username, password)
|
||||
authapi -> client : post('/login/', {username, password})
|
||||
client -> be : POST /api/login/
|
||||
be --> client : 200 { detail } | 400/401 ApiError
|
||||
client --> authapi : результат
|
||||
authapi -> client : whoami()
|
||||
client -> be : GET /api/whoami/
|
||||
be --> client : { username }
|
||||
client --> ui : WhoAmI
|
||||
ui --> user : переход на рабочую страницу
|
||||
|
||||
== Выход ==
|
||||
user -> ui : «Выйти»
|
||||
ui -> authapi : logout()
|
||||
authapi -> client : post('/logout/', {})
|
||||
client -> be : POST /api/logout/
|
||||
be --> client : 204
|
||||
client --> ui : сброс состояния, goto('/login')
|
||||
@enduml
|
||||
41
docs/diagrams/seq-prediction.puml
Normal file
41
docs/diagrams/seq-prediction.puml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
@startuml seq-prediction
|
||||
title Запуск прогноза траектории
|
||||
autonumber
|
||||
|
||||
actor "Пользователь" as user
|
||||
participant "ControlPanel /\nWorkspacesPanel" as ui
|
||||
participant "workspacesStore" as store
|
||||
participant "predictionsApi" as predapi
|
||||
participant "client.ts" as client
|
||||
participant "Backend\n(Django)" as be
|
||||
participant "parsePrediction\n(domain)" as parse
|
||||
participant "WorkspaceRenderer\n(карта)" as render
|
||||
|
||||
user -> ui : «Выполнить прогнозирование»
|
||||
ui -> store : run(workspaceId)
|
||||
activate store
|
||||
|
||||
store -> predapi : run(params, launchDateTime)
|
||||
activate predapi
|
||||
predapi -> predapi : buildLaunchDateTime(date, time)\n→ ISO 8601 (UTC, Z)
|
||||
predapi -> predapi : getLatestDataset()\n→ актуальный слот GFS
|
||||
predapi -> client : post('/predictions/', payload)
|
||||
client -> be : POST /api/predictions/\n{FlightParameters, launch_datetime, dataset}
|
||||
be --> client : { result: PredictionStage[] }
|
||||
client --> predapi : PredictionResponse
|
||||
predapi --> store : RawPrediction
|
||||
deactivate predapi
|
||||
|
||||
store -> parse : parsePrediction(stages)
|
||||
parse --> store : Prediction\n{flight_path[lat,lng,alt], timestamps[], launch/burst/landing}
|
||||
store -> store : setResult(workspaceId, prediction)
|
||||
deactivate store
|
||||
|
||||
store -> render : реактивное обновление
|
||||
render -> render : линия трека + маркеры\n(старт, разрыв, приземление)
|
||||
|
||||
alt ошибка сервера/сети
|
||||
store -> store : lastRunError = message
|
||||
store --> ui : toast «Ошибка прогнозирования»
|
||||
end
|
||||
@enduml
|
||||
60
docs/diagrams/seq-telemetry.puml
Normal file
60
docs/diagrams/seq-telemetry.puml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
@startuml seq-telemetry
|
||||
title Слежение: загрузка истории и приём телеметрии в реальном времени
|
||||
autonumber
|
||||
|
||||
actor "Пользователь" as user
|
||||
participant "TelemetryPanel" as ui
|
||||
participant "TelemetryStore\n(.svelte.ts)" as store
|
||||
participant "telemetryApi" as tapi
|
||||
participant "client.ts" as client
|
||||
participant "Backend REST" as rest
|
||||
participant "Backend\nWebSocket" as ws
|
||||
participant "track/+page\n($effect)" as page
|
||||
participant "MapLibre\n(карта)" as map
|
||||
|
||||
user -> ui : ввод UUID, «Подключиться»
|
||||
ui -> store : connect(id)
|
||||
activate store
|
||||
|
||||
store -> store : проверка UUID регулярным выражением
|
||||
alt UUID невалиден
|
||||
store -> store : status = 'error'
|
||||
store --> ui : (выход без соединения)
|
||||
end
|
||||
|
||||
store -> store : disconnect()\nзакрыть прежний WS, очистить points
|
||||
store -> store : status = 'connecting'
|
||||
|
||||
== Загрузка истории (некритическая) ==
|
||||
store -> tapi : fetchHistory(id)
|
||||
tapi -> client : get('/{id}/telemetry/')
|
||||
client -> rest : GET /api/{id}/telemetry/?from&till
|
||||
rest --> client : RawTelemetryPacket[] | { results }
|
||||
client --> tapi : массив пакетов
|
||||
tapi --> store : RawTelemetryPacket[]
|
||||
store -> store : reverse() → хронологический порядок,\npoints = [...history]
|
||||
note right of store : сбой сети → console.warn,\nподключение не прерывается
|
||||
|
||||
== WebSocket-соединение ==
|
||||
store -> tapi : buildWsUrl(id)
|
||||
tapi --> store : ws(s)://host/api/ws/satellite/{id}/telemetry/
|
||||
store -> ws : new WebSocket(url)
|
||||
ws --> store : onopen → status = 'connected'
|
||||
|
||||
loop каждый новый пакет
|
||||
ws --> store : onmessage(JSON)
|
||||
store -> store : RawPacket → TelemetryPoint\n(unix-сек → ISO 8601),\npoints = [...points, point]
|
||||
store --> page : реактивное изменение points (Svelte $state)
|
||||
page -> map : scene.clear()
|
||||
page -> map : addLine(трек) + addMarker(старт)\n+ plotAnimatedMarker(текущая точка)
|
||||
page -> map : fitBounds() — однократно (fittedBounds)
|
||||
end
|
||||
|
||||
== Отключение ==
|
||||
user -> ui : «Отключиться»
|
||||
ui -> store : disconnect()
|
||||
store -> ws : close()
|
||||
ws --> store : onclose → status = 'idle'
|
||||
store -> store : points = [], satelliteId = null
|
||||
deactivate store
|
||||
@enduml
|
||||
54
docs/diagrams/seq-wind.puml
Normal file
54
docs/diagrams/seq-wind.puml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
@startuml seq-wind
|
||||
title Визуализация поля ветра: статический режим и синхронизация с траекторией
|
||||
autonumber
|
||||
|
||||
participant "predict/+page\n<WindRenderer>" as render
|
||||
participant "WindRenderer\n(.svelte)" as wr
|
||||
participant "WindCache\n(in-memory)" as cache
|
||||
participant "windApi" as wapi
|
||||
participant "Сервис\nпредсказателя\n(127.0.0.1:8080)" as pred
|
||||
participant "createWindInterpolator\n+ ParticleField" as pf
|
||||
|
||||
note over wapi, pred
|
||||
Запросы идут НАПРЯМУЮ через fetch к сервису предсказателя,
|
||||
минуя client.ts: без CSRF и сессионных cookie.
|
||||
end note
|
||||
|
||||
alt Статический режим (ветер по умолчанию)
|
||||
wr -> wr : параметры из активной области\n(высота старта, дата старта)
|
||||
wr -> cache : get(ключ = JSON параметров)
|
||||
alt промах кэша
|
||||
wr -> wapi : field({ altitude, step, time })
|
||||
wapi -> pred : GET /api/v1/wind/field?altitude&step&time
|
||||
pred --> wapi : WindField [C_U, C_V]
|
||||
wapi --> wr : WindField
|
||||
wr -> cache : put(ключ, field)
|
||||
end
|
||||
wr -> pf : установить поле → анимация частиц
|
||||
end
|
||||
|
||||
alt Режим синхронизации с траекторией
|
||||
note over wr : активен прогноз И timeline.max > 0
|
||||
wr -> wr : bbox траектории + проверки\n(F ≤ Fmax, сторона ≤ Dmax)
|
||||
loop δ ∈ {0, ΔT, 2ΔT, …, F}
|
||||
wr -> wr : T = t0 + δ; высота a_{k*} (бин. поиск)
|
||||
wr -> cache : get(ключ кадра)
|
||||
alt промах кэша
|
||||
wr -> wapi : field({ altitude, step, time, min/max lat/lng })
|
||||
wapi -> pred : GET /api/v1/wind/field?…(bbox)
|
||||
pred --> wapi : WindField кадра
|
||||
wapi --> wr : WindField
|
||||
wr -> cache : put(ключ кадра, field)
|
||||
end
|
||||
end
|
||||
loop при воспроизведении (timeline)
|
||||
wr -> wr : fieldAtFlightTime(δ):\nлинейная интерполяция [u,v]\nмежду соседними кадрами
|
||||
wr -> pf : currentField → плавная адвекция частиц
|
||||
end
|
||||
end
|
||||
|
||||
note over wapi, pred
|
||||
windApi.meta() → GET /api/v1/wind/meta
|
||||
(опорное время / параметры сетки, при необходимости)
|
||||
end note
|
||||
@enduml
|
||||
395
docs/wind-vis-math.tex
Normal file
395
docs/wind-vis-math.tex
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
\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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue