# VisNav Mission Planner — kontrakt strony i paczki USB

Ten dokument jest źródłem prawdy, jeśli przepisujesz planner w **nowym projekcie**.  
Jetson i ESP **nie** czytają HTML. Czytają wyłącznie pliki z pendrive’a (i potem UART `winds_esp32.bin`). Jeśli ZIP będzie inny, ingest na padzie padnie albo ESP dostanie NAK HASH.

Kod referencyjny (ten repo):

| Warstwa | Pliki |
|---|---|
| Strona (statyczna) | `VisNav_Planner.html`, `planner_app.js` |
| API | `planner_server.py` → `POST /api/*` |
| Format ESP | `planner/wesp.py` |
| JSON misji | `planner/package.py` |
| Klasyfikacja USB na Jetsonie | `Visnav/core/usb_package.py` |
| Wire UART | `Visnav/docs/ESP32_FIRMWARE_CONTRACT.md` |

---

## 1. Co strona ma zwrócić (jedyny produkt)

**Jedna rzecz:** plik `VisNav_USB_FlashPackage.zip`.

To nie jest „raport GO”. GO/MARGINAL/NO-GO, tory, wiązki i siatka są **narzędziem operatora**. Na samolot idzie tylko ZIP.

Operator po eksporcie:

1. Rozpakowuje ZIP **na root FAT32** (nazwy plików dokładnie jak niżej, nie w podfolderze).
2. Dokłada z sejfu `luks_master.key` (**nie** jest w ZIP i **nigdy** nie wolno go tam wkładać).
3. Wkłada pendrive w Jetson na padzie. Jetson sam wgrywa mapę i klucz na ESP UART-em.

Sukces strony = ZIP, który `inspect()` na Jetsonie klasyfikuje jako **ground package**, a `winds_esp32.bin` przechodzi `winds_remap()` na ESP (magia `WESP`, version `1`).

---

## 2. Z czego składa się strona

Trzy procesy. Nie łącz ich.

```
[przeglądarka]  VisNav_Planner.html + planner_app.js
       │  JSON /api/*          (wiatry, tory, binarne mapy — BEZ klucza LUKS)
       ▼
[serwer]        planner_server.py  +  Open-Meteo GEFS / później Herbie
       │
[przeglądarka]  WebCrypto: 512 B luks_key.bin  →  dopina do ZIP
```

| Część | Rola | Gdzie wolno trzymać klucz misji |
|---|---|---|
| HTML + JS | UI, mapa Leaflet, ZIP, WebCrypto | **tylko RAM przeglądarki** |
| Python API | fetch GEFS, DR, pakowanie WESP | **nigdy** — API nie dostaje `luks_key.bin` |
| Open-Meteo | wiatr | — |

CDN na stronie (można zastąpić vendorami): Leaflet, JSZip, FileSaver.

Parametr `?api=` — jeśli HTML jest na `file://` albo na innym originie niż API, np. `VisNav_Planner.html?api=https://api.example.com`.

### Bloki UI (to, co operator ustawia)

1. **Launch window** — `t0` UTC (`datetime-local` traktowane jako UTC), duration h, wysokość przelotu, alt±Δ, wake lead km/min, cap timeoutu.
2. **Launch and cut** — checkbox *Solve launch from primary cut* (wyłącza ręczny pad; reverse z pierwszego celu). Lista celów: pierwszy = primary (reverse), reszta = backup. Jetson OR-uje promienie (pierwszy hit odcina).
3. **Wind domain** — bbox kostki ESP (przerywany niebieski). Grid °, max punktów, control vs mean. **To nie jest fiolet.**
4. **Jetson wake box** — fiolet. Kiedy ESP ma włączyć Orin (geofence **lub** timeout).
5. **Werdykt** — GO / MARGINAL / NO-GO (planowanie; na USB opcjonalnie `day_suitability`).
6. **Akcje** — Fetch+simulate, Download USB package.

Mapa: control (ciemny), GEFS (szary), alt±Δ (przerywane), kropki siatki, closest approach, wake marker.

### API, które strona woła

Zachowaj te ścieżki i JSON przy przepisaniu (AWS: HTML na CDN, Python za API Gateway).

| Method | Path | Wejście (skrót) | Wyjście |
|---|---|---|---|
| GET | `/api/health` | — | `{ ok, service, version, wind_source, integrator }` |
| POST | `/api/forecast/async` | bbox, `t0_unix`, hours, members, `grid_deg`, `max_points`, `dr_source` | `{ job_id }` |
| GET | `/api/job?id=` | — | `{ status, logs, result, error }` |
| POST | `/api/simulate` | `cube_id`, start_lat, start_lon, targets[], `solve_launch`, alt_m, alt_error_m, lead_km, lead_min, duration_hours, `timeout_cap_hours`, `ascent_rate_ms`, `surface_alt_m` | tory + `wake_bbox` + `wake_polygon` (elipsa 16 pkt) + `ellipse_params` + `corridor_bbox` + `suggested_wind_bbox` + czasy (`timeout_hours`, `cut_time_hours`, `wake_time_hours`) + suitability |
| POST | `/api/package` | `cube_id`, start_lat, start_lon, start_alt_m, `t0_unix`, `wake_timeout_hours`, `wake_bbox`, targets[], `wake_polygon`, `suitability`, `reverse_start` | pliki **base64** (bez LUKS) |
| POST | `/api/wind-bbox` | start/cut, extra_points, duration_hours, `solve_launch` | bbox (asymetryczny adwekcyjnie pod wiatr) |

`/api/package` **nie** zwraca klucza. Klucz powstaje w JS: `crypto.getRandomValues(512)`.

`t0_unix` = Unix UTC **z formularza**, nie z zegara Jetsona (brak Wi-Fi w locie).

---

## 3. Zawartość ZIP (obowiązkowa)

Nazwa pliku na dysku: `VisNav_USB_FlashPackage.zip`.  
W środku, **w root ZIPa** (nie `folder/winds_…`):

| Plik | Kto go robi | Format | Obowiązkowy |
|---|---|---|---|
| `luks_key.bin` | przeglądarka | 512 bajtów surowy CSPRNG | **tak** — bez tego nie ma ground package |
| `luks_key.sha256` | przeglądarka | ASCII `hex64  luks_key.bin\n` | tak (weryfikacja operatora) |
| `winds_esp32.bin` | API | WESP v1 + VN1X, little-endian | **tak** — UART na ESP |
| `winds_jetson.bin` | API | `float16` C-order `[nT,nP,nLat,nLon,3]` | **tak** — DR na Orinie |
| `winds_jetson.json` | API | JSON, osie kostki | **tak** — ingest wymaga pary bin+json |
| `mission_config.json` | API + JS dopina SHA | JSON UTF-8, `version: "3.0"` | **tak** |
| `README_USB.txt` | API | tekst | zalecany |

### Czego NIE ma w ZIP

| Plik | Dlaczego |
|---|---|
| `luks_master.key` | tylko z sejfu, na pendrive **po** rozpakowaniu ZIP |
| 30 członków GEFS | tylko na ziemi (GO/wake); ESP leci **jednym** polem (control albo mean) |
| GO report / PNG mapy | nie są częścią ingestu |

### Po rozpakowaniu na FAT32 (to widzi Jetson)

```
/ (root pendrive)
  luks_key.bin
  luks_key.sha256          # opcjonalne dla ingestu, ważne dla operatora
  winds_esp32.bin
  winds_jetson.bin
  winds_jetson.json
  mission_config.json
  README_USB.txt
  luks_master.key          # DOKŁADA OPERATOR, nie ZIP
  KEEP_ON                  # opcjonalnie, pusty; lab bez poweroff
```

Klasyfikator (`usb_package.py`):

- **ground package** = jest `luks_key.bin` **oraz** (`winds_jetson.bin`+`winds_jetson.json` **lub** `mission_config.json`) **oraz** brak `handoff_state.json`.
- **inflight ESP** = jest `handoff_state.json`, brak mastera, brak kostki wiatrów. Tego ZIP **nie** produkuje (to ESP w locie).

---

## 4. Formaty binarne i JSON

### 4.1 `luks_key.bin`

- Dokładnie **512** bajtów.
- `crypto.getRandomValues`, nie `Math.random`.
- SHA-256 hex (64 znaki) wpisane w `mission_config.json` → `luks_key_sha256` i w `luks_key.sha256`.
- Ten sam blob Jetson wgrywa do LUKS **slot 1** i UART `VAULT_PUT` na ESP.
- Nigdy nie POST-ować na API, nie logować, nie mailować ZIPa.

### 4.2 `winds_esp32.bin` — WESP v1 + trailer VN1X

Little-endian. Firmware `winds.c` wymaga nagłówka; goły int16 cube → NAK mimo zgodnego SHA.

```
offset 0    winds_hdr_t  40 B
            u32 magic     = 0x50534557   ('WESP')
            u16 version   = 1            // inna = remap fail
            u16 n_t, n_p, n_lat, n_lon
            u16 reserved  = 0
            i32 t0        Unix UTC = launch z formularza
            i32 dt_sec    krok czasu osi
            f32 lat0, dlat, lon0, dlon
+ n_p * u32 pressures_pa   (paskale, zwykle malejąco: 40000, 30000, …)
+ int16 cube [nT][nP][nLat][nLon][3]
            physical = stored / 10
            c=0 U m/s (east), c=1 V m/s (north), c=2 T kelvin
+ 44 B VN1X  (remap dziś ignoruje; SHA UART obejmuje trailer)
            'VN1X' | u16 ver=1 | u16 flags (bit0 = geofence_valid)
            f32 start_lat, start_lon, start_alt_m
            u32 t0_unix | u16 wake_timeout_hours | u16 reserved
            f32 geo_south, geo_north, geo_west, geo_east
```

Indeks: `((((t * nP + p) * nLat + la) * nLon + lo) * 3 + c)`.

Poza bbox: clamp do krawędzi (fałszywy wiatr — kostka **musi** pokrywać korytarz lotu).

Budżet: partycja `mission` od `0x180000`, nie cały flash 16 MB. Planner liczy limit **2 MiB** na ten blob. Typowo dziesiątki–setki KiB.

Pakować **GEFS control** (domyślnie), nie średnią ensemble, gdy wiązki się rozjeżdżają.

Poziomy ciśnienia (ten planner): 400, 300, 250, 200, 150, 100 hPa.

### 4.3 `winds_jetson.bin` + `winds_jetson.json`

Bin: surowy `float16` little-endian, ten sam shape `[nT, nP, nLat, nLon, 3]`, wartości fizyczne (m/s, K), **nie** ×10.

JSON (osie; `dtype` informacyjny `"float16"`):

```json
{
  "timestamps": [1756987200, ...],
  "pressures_pa": [40000.0, 30000.0, 25000.0, 20000.0, 15000.0, 10000.0],
  "lats": [50.0, 51.0, ...],
  "lons": [14.0, 15.0, ...],
  "shape": [25, 6, 7, 11, 3],
  "dtype": "float16",
  "model_source": "gefs-gfs05-control-1.0",
  "bbox": { "south": 50.0, "north": 56.0, "west": 14.0, "east": 22.0 }
}
```

`shape` musi zgadzać się z `len(timestamps) × len(pressures_pa) × len(lats) × len(lons) × 3` i z liczbą bajtów bin = ten iloczyn × 2.

### 4.4 `mission_config.json`

`version` = `"3.0"`. Pola, których Jetson naprawdę używa:

```json
{
  "version": "3.0",
  "created_at_utc": "2026-09-04T15:00:00Z",
  "weather_model": "gefs-gfs05-control-1.0",
  "luks_key_bytes": 512,
  "luks_key_sha256": "64 hex chars",
  "ground_ingest": { "poweroff_after": true },
  "mission": {
    "start_lat": 52.689,
    "start_lon": 14.644,
    "start_alt_m": 10000.0,
    "t0_unix": 1756987200,
    "start_datetime_utc": "2026-09-04T12:00:00Z",
    "forecast_hours": 24,
    "esp32_wake_timeout_hours": 18.0,
    "esp32_wake_bbox": {
      "north": 54.2, "south": 52.8, "west": 16.5, "east": 20.5
    },
    "esp32_wake_polygon": [
      [54.21, 16.52], [54.18, 17.10], [54.12, 17.65], [53.95, 18.20]
      /* 16 punktów wielokąta elipsy kowariancji 3σ + FOV kamer; fallback: 4 wierzchołki bboxa */
    ],
    "targets": [
      {
        "id": "Cut-1",
        "name": "Cut-1",
        "lat": 54.08,
        "lon": 18.80,
        "radius_km": 3.0
      }
    ],
    "wind_bbox": { "south": 50.0, "north": 56.5, "west": 12.0, "east": 22.0 },
    "day_suitability": "GO"
  },
  "dead_reckoning": {
    "start_lat": 52.689,
    "start_lon": 14.644,
    "update_interval_sec": 5.0,
    "drop_targets": [ "…ta sama lista co mission.targets…" ],
    "cutter_gpio_pin": 12,
    "cutter_armed": true
  }
}
```

> [!NOTE]
> **Kontrakt ESP32 (VN1X) vs Jetson**:  
> Firmware ESP32 (`winds.c`) sprawdza geofence testem brzegowym boxa, dlatego w trailerze binarnym VN1X zapisywany jest wyłącznie prostokąt osiowy (`geo_south, geo_north, geo_west, geo_east`). Pełny 16-wierzchołkowy wielokąt elipsy kowariancji $3\sigma$ (`esp32_wake_polygon`) trafia do `mission_config.json` dla Jetsona i pozwala na precyzyjną akwizycję optyczną w korytarzu dyspersji wiązek.

**Cele (target):** min. jeden. Wymagane `id`, `lat`, `lon`. Do cuttera: `radius_km` (domyślnie 3). `name` opcjonalne. Lista w `mission.targets` **i** `dead_reckoning.drop_targets` ma być ta sama. Jetson: pierwszy promień, w który wleci DR/VisNav, odpala GPIO **BOARD 12** (cutter, nie ACK).

`t0_unix` / start lat-lon muszą być spójne z nagłówkiem WESP i VN1X.

---

## 5. Co strona liczy, a czego nie wkłada do ZIP

| Wynik UI | Na USB? |
|---|---|
| Tory control + 30 GEFS + alt±Δ | nie (tylko pomagają wybrać dzień i bbox) |
| GO / MARGINAL / NO-GO | opcjonalnie `day_suitability` |
| Corridor bbox (przerywany) | jako `mission.wind_bbox` jeśli zapiszesz aktualny fetch |
| Wake fioletowy | tak — `esp32_wake_bbox` + VN1X geo_* + timeout |
| Reverse / solved launch | start_lat/lon; opcjonalnie `suggested_launch_from_target` |
| Kropki siatki / rozmiar WESP | nie |

GO (orientacyjnie, v3.4): miss GFS ≤ R **i** P90 anomalii wokół GFS ≤ ~2R **i** dolna granica Wilsona P(hit) ≥ 40%. Surowy GEFS 0.50° nie jest progiem GO. To **nie** jest komenda dla ESP. Matematyka: `ENSEMBLE_CONFIDENCE.md`.

Fiolet = wake Jetsona (koniec lotu). Przerywany niebieski = kostka wiatrów na cały DR. Nie mylić.

---

## 6. Kryteria akceptacji nowego projektu

Nowy frontend/backend jest zgodny, gdy:

1. ZIP ma dokładnie nazwy z §3 w root.
2. `luks_key.bin` = 512 B, SHA zgadza się z JSON i sidecar.
3. `winds_esp32.bin` zaczyna się od `57 45 53 50` (`WESP`), `version == 1`, unpack jak `planner/wesp.py`.
4. `winds_jetson.bin` + json: shape × 2 = liczba bajtów.
5. `mission.targets` niepuste, każdy ma `id, lat, lon`.
6. `t0_unix` w JSON = `t0` w WESP = launch UTC operatora.
7. Klucz nie pojawia się w logach serwera ani w body `/api/package`.
8. Katalog z tymi plikami (bez `handoff_state.json`) → `UsbVolume.is_ground_package is True`.
9. `winds_esp32.bin` ≤ budżetu partycji mission (~2 MiB).

Golden unpack: `python3 -m unittest tests.test_planner` w `vn_jetson/web` (WESP roundtrip, bez sieci).

---

## 7. Przepływ operatora (żeby ZIP miał sens)

1. Ustaw `t0` UTC i duration tak, żeby okno forecastu **pokrywało launch** (nie „N godzin od teraz”).
2. Cele na mapie; opcjonalnie zaznacz *Solve launch from primary cut* (z uwzględnieniem prędkości wznoszenia w troposferze `ascent_rate_ms`, np. 5.0 m/s). Korytarz wstępny rozszerza się asymetrycznie pod prąd wiatru.
3. Fetch wiatrów → Simulate.
4. Jeśli trajektoria dotknęła brzegu siatki (`launch_outside_cube` lub `cube_too_small`) — kliknij `Apply corridor & re-fetch` (zbieżność dwuetapowa). Nowy, ściśle dopasowany korytarz eliminuje zafałszowania clampingowania wiatru i wyznacza ostateczny, zbieżny punkt startu.
5. Sprawdź wyliczony automatycznie timeout wybudzenia Jetsona (`Effective wake timeout`, domyślnie tryb `Auto` wyliczający czas cięcia + 2h z ograniczeniem przez `wake_timeout_cap_hours`).
6. Werdykt GO/MARGINAL — decyzja człowieka.
7. Download USB package.
8. FAT32 + `luks_master.key` z sejfu → Jetson.

Bez kroku 7 nie ma produktu strony. Bez kroku 8 nie ma misji.
