SailEver — Track Simulator Plan (tracksim)
Date: 2026-10-08
Status: Phases 1–5 built (tools/tracksim, mise tracksim, /sim-track skill). Phase 6 waits for the watch ports.
Goal: describe a sail in plain words, get a realistic, reproducible track that never crosses land or rocks, and replay it on the Apple Watch simulator, the Wear OS emulator (and the Garmin simulator), or upload it straight to the Rails dev server. Tracks are saved as tracks/NNN-<slug>/ and referenced by number.
"Sail from Hvar to Vis, 10 kn wind from W/SW, boat speed 4–6 kn, tacking"
│ /sim-track (Claude Code skill): prompt → scenario.yaml
▼
tracksim generate ──► tracks/001-hvar-vis/
│ ├─ scenario.yaml the parsed, editable input (+ seed)
│ ├─ track.csv canonical 1 Hz samples
│ ├─ apple.gpx Xcode location simulation
│ ├─ android.gpx emulator route playback
│ ├─ garmin.fit CIQ simulator FIT playback
│ ├─ track.sltb ready for POST /api/v1/tracks/segments
│ ├─ map.html Leaflet + OpenSeaMap preview
│ └─ summary.md distance, time, tacks, checks
▼
mise tracksim play 1 --target apple|android|garmin --speed 10
mise tracksim upload 1 # SLTB to the local Rails server
mise tracksim show 1 / list
1. Design decisions
| Decision |
Choice |
Why |
| Prompt parsing |
Claude Code skill /sim-track turns the prompt into a scenario.yaml (validated against a JSON schema), then calls the CLI. |
No API key, no LLM inside the tool. The generator stays deterministic and testable. A Claude API front end can come later for use outside Claude Code. |
| Generator |
Python CLI (uv), deterministic given scenario.yaml + seed |
Reuses the reference encoder docs/sltb/sltb.py for byte-exact SLTB; good geo libraries (shapely, pyproj, networkx). |
| Location |
sailever/tools/tracksim/ (code), sailever/tracks/ (output, committed) |
Shared by all watch apps and Rails; tracks are small text/binary files and become shared test fixtures. |
| Numbering |
Next free 3-digit prefix, 001- upwards, slug from start / end (004-hvar-vis). Commands accept 4, 004 or the full name. |
As requested. Numbers are never reused, even after a delete. |
| Reproducibility |
scenario.yaml stores every resolved value (coordinates, start time, seed). tracksim regenerate 4 rebuilds identical files. |
You can tweak a track by editing its YAML. |
2. "Don't sail over rocks": geography
| Source |
Used for |
Notes |
OSM land polygons (osmdata.openstreetmap.de, land-polygons-split-4326) |
Coastline incl. small islets |
Clipped to the scenario bounding box, cached per tile. Fine enough for Pakleni otoci islets, unlike Natural Earth. |
OSM seamarks via Overpass (seamark:type=rock / wreck / obstruction, natural=reef, natural=shoal) |
Rocks awash, reefs, wrecks |
Each becomes a keep-out circle (default 50 m). Same data as the OpenSeaMap layer in Rails. |
| EMODnet Bathymetry (Phase 3) |
Shallow water |
Keep-out where depth < draft + 1 m. Adriatic coverage is good. |
Nominatim + OSM harbour tags + places.yaml |
"Hvar", "Vis" → berth and harbour entrance |
places.yaml is a small gazetteer you can extend (berth point, entrance point, anchorage points), e.g. Hvar town, Vis town, Komiža, Stari Grad, Palmižana. |
2.2 Routing
- Keep-out area = land ∪ rock circles ∪ shallows, buffered by a safety margin: 150 m offshore, 30 m inside a harbour (scenario can override).
- Harbour gates: berth → entrance is motored (vessel state
Engine, ~3 kn), along a short path routed with the small margin. Sails go up at the entrance (Sail), and come down at the destination entrance. This also exercises vessel state changes.
- Water route between the two entrances: rasterise water onto a ~50 m grid over the bbox, Theta* (any-angle A*) for the shortest path, then string-pull to the fewest waypoints that still have line of sight in water. Result: the "rhumb route" around islands (e.g. Hvar → around or through the Pakleni channels → Vis).
- Hard check: every generated segment is tested against the buffered keep-out area. A track that fails is not saved; the tool reports where it failed.
2.3 Sailing layer (on top of the route)
- Wind: direction and speed from the scenario, optional shifts (±10° every N minutes) and gusts.
- No-go zone: if a route section's true wind angle is < 45°, it is replaced by tacks at the optimal angle (~45–50° TWA). Each tack runs until it meets the safety margin or the corridor edge (default ±1 nm from the route), then the boat tacks. Every tack is validated against the keep-out area.
- Dead run (TWA > 165°): optional gybes, same corridor logic.
- Speed: a simple polar scaled into the requested range (4 kn close-hauled → 6 kn beam reach → 5 kn broad reach), plus smooth noise (Ornstein–Uhlenbeck) and a slowdown to ~2 kn for ~20 s at each tack or gybe.
- Heading / COG noise, GPS jitter (~3 m) and an optional GPS dropout, to test the app's filters.
- If the prompt asks for tacking that the geometry doesn't need, the tool says so. Example: Hvar → Vis runs roughly WSW (~240°). A NW wind (315°) gives ~75° TWA, a close reach, so no tacks. It then asks: keep NW (no tacks), or use W / SW wind so the boat has to beat?
2.4 Other channels in a scenario (for testing app features)
- Pressure: base value + trend (
pressure: 1013 → 1008 over 3 h) to trigger the baro alert.
- Stops:
anchor at Stončica 45 min, pause 30 min, drift 0.5 kn for 2 min while anchored (float alert), motor last 2 nm.
- Start time / date: default today 09:00 local; drives sunrise / sunset and tides.
3. Outputs and playback per target
The canonical file is track.csv (1 Hz: t, lat, lon, sog_kn, cog_deg, pressure_hpa, vessel_state, twa, tws). Everything else is derived from it.
| Target |
Files |
How tracksim play N feeds it |
Limits |
| Apple Watch simulator |
apple.gpx (timestamped trkpts); track.csv |
(a) System location: GPX as a custom location in the watch app scheme ("Allow Location Simulation"). (b) In-app replay: xcrun simctl copies track.csv into the app container; a debug-only ReplaySource feeds location and pressure, sped up (--speed 10). |
The simulator has no barometer, so pressure only comes via in-app replay. GPX plays in real time only. |
| Wear OS emulator |
android.gpx; track.csv |
(a) System location: streams adb emu geo fix at the chosen speed, plus adb emu sensor set pressure for the barometer. Works through the normal location stack. (b) In-app replay via adb push + a debug intent. |
Health Services exercise metrics in the emulator come from its synthetic provider or from location; check in the Wear OS spike. |
| Garmin CIQ simulator |
garmin.fit |
Simulator → Simulation → FIT Data → Playback: SOG, position and pressure play through Activity.Info / Position. |
Bonus target; tests the existing app with the same tracks. |
| Rails dev server |
track.sltb |
tracksim upload N POSTs the segments with a dev device token to http://localhost:3000/api/v1/tracks/segments. |
Tests the server pipeline with no watch at all. Writes rows to the dev database. |
Consequence for the watch apps: both ports get a sensor abstraction from day one (LocationSource, PressureSource, real vs. replay). The replay source is compiled into debug builds only. This is the only reliable way to feed barometer data and run a 3-hour sail in 18 minutes.
4. Phases
| Phase |
Content |
Exit criterion |
| 1. Skeleton |
tracksim CLI (generate, list, show, regenerate), numbering, scenario schema, straight-line track with constant wind, track.csv + apple.gpx + android.gpx + map.html |
tracks/001-… created from a hand-written YAML |
| 2. No rocks |
Land polygons + seamarks, harbour gates, Theta* route, keep-out check that blocks saving |
Hvar → Vis and Hvar → Palmižana routed cleanly; a test proves no segment touches the buffered keep-out area |
| 3. Sailing |
Polar, tacks / gybes in a corridor, noise, slowdowns, wind shifts, pressure trends, stops; bathymetry |
A beat from Vis to Hvar against an E wind looks plausible on the map; speed stays in range |
| 4. Targets |
track.sltb (via sltb.py), garmin.fit, play for android (adb stream) and garmin, upload to Rails |
Track appears in the web dashboard; Garmin simulator shows the sail |
| 5. Prompting |
/sim-track skill: prompt → YAML → generate → summary + map link; asks back when the prompt contradicts the geometry |
The example prompt produces track 00N in one step |
| 6. Apple / Wear replay |
play --target apple / android in-app replay, once the ports have their ReplaySource |
10× replay on both simulators |
5. Tests
- Golden scenarios in
tools/tracksim/tests/scenarios/ (Hvar → Vis reach, Vis → Hvar beat, Pakleni channel, harbour gates), regenerated in CI and compared against stored checksums.
- Property checks on every generated track (also run before saving): no segment in the keep-out area; SOG within the requested range ± noise; tacks only when TWA < no-go; timestamps strictly increasing at 1 Hz; SLTB decodes back with
sltb.decode.
6. Decisions
- Example prompt: wind from W/SW (247.5°), so Hvar → Vis is a beat. With NW the tool generates a reach and warns that no tacks were needed (
tests/scenarios/hvar-vis-reach.yaml).
- Committed tracks: yes,
tracks/ is committed (shared fixtures). So is the geodata cache.
- Garmin FIT output: included, with the watch app's developer fields (
vessel_state, trip_token, app_version).
7. As built: deviations from the plan
- Harbour exits route to open sea, the largest connected water body at the offshore margin. An enclosed bay such as Viška luka is not open sea, so its exit is motored at the harbour margin.
- Tacks and gybes are counted when the bow crosses the wind, whatever triggered the turn.
- Golden-checksum tests became a determinism test (same scenario → identical CSV) plus
mise tracksim regenerate N --check, which compares a committed track with a fresh build.
- No current, leeway or tides in the boat model.