# Open Sail Data — v1 (draft) An open, human-readable interchange format for **sailing race telemetry** — one race, one file, many boats, plus the course and the wind. Designed so independent tools (Josh Groom's *Sail Data Player* / player-b, and *Telltale*) can read and write the same data as equals — a shared standard neither side owns. Status: **draft for review** — proposed jointly; player-b's existing format stays importable for back-compat (see the mapping table at the end). --- ## Design goals 1. **One document per race.** The whole fleet, the course, and the wind in a single file — no juggling separate `id`/`sid`/`iid` links. 2. **Self-describing.** Field names spelled out, units declared once, a version number so it can evolve without breaking old readers. 3. **Compact.** Per-boat tracks are **columnar** (`fields` + `rows`) instead of repeating keys on every 1 Hz sample — typically 40–60 % smaller than row-of-objects JSON, and still plain text. 4. **Raw, not derived.** Store what was measured; anything computable (COG, VMG, TWD, leeway, %-polar) is optional and may be recomputed by the reader so it never drifts. 5. **Extensible.** Add a column, or a per-boat `extras` object, without breaking existing readers. 6. **No lock-in.** Plain JSON (optionally gzipped for transport). Anyone can read it with a text editor. --- ## Top-level shape ```json { "format": "opensaildata", "version": 1, "session": { "name": "Matariki Classic", "date": "2026-07-10", "venue": "Wellington Harbour", "host": "EBYC", "event": "Matariki Classic", "source": "player-b", "sampleRateHz": 1 }, "units": { "speed": "kn", "angle": "deg", "position": "deg", "time": "iso8601" }, "course": { "startTime": "2026-07-10T22:50:00.000Z", "startLine": [ { "lat": -41.301, "lon": 174.812 }, { "lat": -41.303, "lon": 174.815 } ], "finishLine": [ { "lat": -41.290, "lon": 174.800 }, { "lat": -41.292, "lon": 174.802 } ], "marks": [ { "name": "1", "lat": -41.280, "lon": 174.790 } ], "sequence": ["start", "1", "2", "3", "finish"], "annotations": [ { "t": "2026-07-10T23:10:00Z", "label": "wind shift left" } ] }, "wind": { "stations": [ { "id": "brothers", "name": "Brothers Island", "lat": -41.11, "lon": 174.44, "obs": [ { "t": "2026-07-10T22:50:00Z", "dir": 330, "spd": 14, "gust": 19 } ] } ] }, "boats": [ { "name": "Sliver", "sail": "1234", "class": "Davidson 37", "rating": { "system": "NHC", "tcf": 0.8069 }, "color": "#e0533b", "fields": ["t","lat","lon","sog","stw","hdg","cog","twa","tws","awd","twd","awa","aws","heel","pitch","leeway"], "rows": [ ["2026-07-10T22:49:15.000Z", -41.3001, 174.8123, 6.2, 6.0, 210, 208, 42, 12, 170, 168, 40, 14, 8, -3, 4], ["2026-07-10T22:49:16.000Z", -41.3002, 174.8124, 6.3, 6.1, 211, 209, 41, 12, 171, 169, 40, 14, 9, -3, 4] ] } ] } ``` `boats[].fields` names each column; `rows` is a list of value-arrays in that column order. A boat need only include the columns it has (an AIS boat might carry just `t,lat,lon,sog,cog`). --- ## Field dictionary All angles are **degrees** (0–360 for directions, signed where noted); all speeds are **knots**; positions are **decimal degrees** (WGS-84). Time is **ISO-8601 UTC**. ### `session` | key | meaning | |---|---| | `name` | race / session name | | `date` | race date `YYYY-MM-DD` | | `venue` | water / location | | `host` | organising club (code or name), optional | | `event` | series or event name, optional | | `source` | what produced the file (`player-b`, `telltale`, `nmea-upload`, `ais`, …) | | `sampleRateHz` | nominal sample rate of the tracks, optional | ### `course` | key | meaning | |---|---| | `startTime` | gun time (ISO-8601) | | `startLine` / `finishLine` | array of 2 `{lat,lon}` points | | `marks` | array of `{name,lat,lon}` | | `sequence` | rounding order, e.g. `["start","1","2","1","finish"]` | | `annotations` | array of `{t,label,[lat,lon]}` free notes | ### `wind.stations[]` | key | meaning | |---|---| | `id` `name` `lat` `lon` | station identity + position | | `obs[]` | `{ t, dir, spd, gust }` — direction (° from), speed, gust (kn) | ### `boats[].fields` — the track columns | column | meaning | measured / derived | |---|---|---| | `t` | ISO-8601 timestamp | measured | | `lat` `lon` | position | measured | | `sog` | speed over ground | measured (GPS) | | `stw` | boat speed through water | measured | | `hdg` | heading (° true) | measured (compass) | | `cog` | course over ground | derived (from track) | | `twa` `tws` | true wind angle / speed | measured or derived | | `awa` `aws` | apparent wind angle / speed | measured | | `awd` `twd` | apparent / true wind direction | derived | | `heel` | heel / roll angle (° — signed) | measured (IMU) | | `pitch` | pitch angle (° — signed) | measured (IMU) | | `leeway` | leeway angle (°) | derived | | `vmg` | velocity made good | derived | | `polar` | % of polar boatspeed | derived (needs a polar) | Readers should treat any column they don't recognise as an opaque number and pass it through. --- ## Encoding & transport - The document is **UTF-8 JSON**. For transport it MAY be **gzipped** (`.json.gz`); readers should sniff the gzip magic bytes (`1f 8b`) and inflate. - Multiple boats live in `boats[]` — there is **no** need for separate files per vessel. - Google Drive / any static host is fine; a single link resolves to the whole race. --- ## Relationship to player-b (back-compat) player-b's current format is a **strict subset** of this once mapped. An importer converts as: | player-b (source) | Open Sail Data | notes | |---|---|---| | `vessel.shipName` | `boats[].name` | one player-b file = one boat | | `vessel.data[]` (rows of objects) | `boats[].rows` (columnar) | pivot object-keys → columns | | `t, lat, lon, sog` | `t, lat, lon, sog` | unchanged | | `bs` | `stw` | boat speed → speed through water | | `th` | `hdg` | true heading | | `r` | `heel` | roll angle | | `p` | `pitch` | pitch angle | | `_cog` / `cog` | `cog` | prefer non-prefixed | | `_twd` | `twd` | | | `_awd` | `awd` | | | `_vmg` | `vmg` | may be dropped (recomputable) | | `_leeway` / `leeway` | `leeway` | newer player-b field | | course `iid`: `markList` (space string) | `course.sequence` (array) | split on spaces | | course `startLine`/`finishLine`/`marks`/`startTime` | same keys under `course` | inline into the one document | | `weatherStations[]` `{dd,ff,gust}` | `wind.stations[]` `{dir,spd,gust}` | rename dd→dir, ff→spd | **Derived fields** (`_cog`, `_twd`, `_awd`, `_vmg`, `polar`, `leeway`) are optional on write — a producer may omit them and let the reader recompute, keeping files smaller and internally consistent. > **TODO — pin the exact leeway key.** player-b's live build surfaces *Heel* and *Leeway* in its UI; > `heel` maps cleanly from `r`, but the on-disk key for leeway (`_leeway` vs `leeway`) is inside a > minified bundle and should be confirmed against one current data file before this is called final. --- *v1 draft. Proposed as a shared standard — feedback and co-authorship welcome.*