From 690db2527751ec9cfa79359535dd81db5604318f Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Thu, 24 Sep 2026 22:23:21 +0300 Subject: [PATCH 1/9] Pin SPEC_REF at the specification's branch and re-vendor schema, fixtures and docs from it Co-Authored-By: Claude Opus 5.5 --- SPEC_REF | 2 +- docs/converting.md | 41 +- docs/fit-mapping.md | 38 +- docs/ssrf-mapping.md | 71 +- docs/suunto-json-mapping.md | 49 +- docs/suunto-xml-mapping.md | 48 +- docs/uddf-mapping.md | 66 +- docs/uddf-writing.md | 50 +- fixtures/README.md | 24 +- .../invalid/agency-other-missing.divejson | 82 +- .../invalid/avg-depth-exceeds-max.divejson | 82 +- fixtures/invalid/bad-version.divejson | 82 +- fixtures/invalid/bbox-missing-corner.divejson | 82 +- .../invalid/bbox-south-exceeds-north.divejson | 82 +- .../invalid/bbox-without-position.divejson | 82 +- .../invalid/channel-length-mismatch.divejson | 82 +- ...ourse-agency-other-without-agency.divejson | 200 +- .../invalid/course-dates-reversed.divejson | 82 +- fixtures/invalid/dangling-reference.divejson | 82 +- fixtures/invalid/deco-model-gf-order.divejson | 82 +- fixtures/invalid/device-empty-member.divejson | 84 +- .../dive-profile-outside-recording.divejson | 79 +- .../dive-readout-outside-recording.divejson | 703 ++++ .../invalid/diver-email-too-long.divejson | 200 +- fixtures/invalid/diver-name-too-long.divejson | 200 +- .../invalid/diver-username-too-long.divejson | 200 +- ...icate-file-uuid-across-recordings.divejson | 84 +- .../duplicate-file-uuid-portrait.divejson | 200 +- fixtures/invalid/duplicate-uuid.divejson | 82 +- .../emergency-contact-without-name.divejson | 200 +- .../event-without-type-or-label.divejson | 82 +- .../insurance-without-provider.divejson | 200 +- fixtures/invalid/missing-format.divejson | 82 +- fixtures/invalid/naive-exported-at.divejson | 82 +- .../invalid/non-increasing-samples.divejson | 84 +- fixtures/invalid/null-member.divejson | 82 +- fixtures/invalid/oxygen-helium-sum.divejson | 82 +- fixtures/invalid/position-incomplete.divejson | 82 +- fixtures/invalid/pressure-order.divejson | 82 +- .../invalid/profile-duration-short.divejson | 82 +- .../recording-without-content.divejson | 84 +- .../site-bbox-south-exceeds-north.divejson | 82 +- fixtures/invalid/species-no-identity.divejson | 82 +- .../trailing-newline-datetime.divejson | 82 +- fixtures/invalid/trip-dates-reversed.divejson | 82 +- fixtures/invalid/undefined-member.divejson | 82 +- .../invalid/version-before-format.divejson | 5 - fixtures/invalid/version-not-second.divejson | 5 - fixtures/invalid/water-type-en13319.divejson | 703 ++++ fixtures/valid/demo-logbook.divejson | 3641 +++++++++-------- fixtures/valid/technical-dive.divejson | 217 +- fixtures/valid/two-computers.divejson | 38 +- fixtures/write/uddf/opendiving.divejson | 38 +- fixtures/write/uddf/technical-dive.divejson | 217 +- schema/1.0/divejson.schema.json | 30 +- 55 files changed, 5651 insertions(+), 4118 deletions(-) create mode 100644 fixtures/invalid/dive-readout-outside-recording.divejson delete mode 100644 fixtures/invalid/version-before-format.divejson delete mode 100644 fixtures/invalid/version-not-second.divejson create mode 100644 fixtures/invalid/water-type-en13319.divejson diff --git a/SPEC_REF b/SPEC_REF index b3b35b4..446b2ef 100644 --- a/SPEC_REF +++ b/SPEC_REF @@ -1 +1 @@ -929788950d59e3b8130ccdbd27811e44aeb074ff +ed56eb3ad88c1ecbb1bdb5313004ad7a2a61f6d2 diff --git a/docs/converting.md b/docs/converting.md index 92f994a..9ba1397 100644 --- a/docs/converting.md +++ b/docs/converting.md @@ -66,6 +66,12 @@ own schema forbids, and where refusing would cost a diver their logbook. library parser is then used for. - **A time of day with no seconds is read as `:00`, and reported.** §5.2's grammar requires them, and refusing would cost a diver a whole dive over a spelling. +- **A date with no time of day is a date-only start, and reported.** §5.2 lets a dive's + `started_at` be the date alone, so a source that recorded the day and not the time gives + exactly that, with an `absent` finding for the time of day. Reading it as midnight would + put a time the source never stated into the logbook, and a real midnight dive read back + would be indistinguishable from it. This holds only for the dive's own start: every other + date-time member, a recording's own start among them, takes a date-time or nothing. ### For every XML source, a `` is refused outright @@ -103,8 +109,8 @@ mean, a conversion by an irrational factor — which each format's mapping docum Arithmetic runs on decimal values parsed from the source text, not on floating point: `2.6 × 100` is exactly `260` that way, where the float route arrives at `260.00000000000003` and has to be rounded back out. Rounding to an integer is -half-away-from-zero — a reading of 2.5 seconds is 3, not the 2 that banker's rounding -gives. +half-away-from-zero, at whatever grain the member counts — a sample stamped 2.5 ms in is at +3 ms, not the 2 that banker's rounding gives. ## Identity @@ -140,8 +146,9 @@ it; nothing can recover from a writer that does not keep its own ids stable. ## Provenance What the source said about itself — its format version, its generating application — rides -under the `divejson` producer key in `extensions` (§5.5), because the source's own identity -is worth keeping and the core vocabulary has nowhere for it. Each format's mapping document +under the `divejson` producer key in `extensions`, which §5.5 reserves for converters +following this document, because the source's own identity is worth keeping and the core +vocabulary has nowhere for it. Each format's mapping document names the members it writes there. `generator` is **the converter**, not the source; §4 defines it as what produced *this* @@ -150,10 +157,11 @@ document. `exported_at` is the moment of conversion, always offset-aware. ## What the format cannot hold, and what the source did not record - **A member whose type constrains the text a source may put in it is checked, not merely - capped.** Most source strings reach a free-text member where the only limit is a length; - a constrained one — an email address is the case in hand — is different. A source string - that the member cannot hold is read as *not recorded* and reported: a member the format - cannot hold is a member the source did not fill in. A converter that passed it through + capped.** Most source strings reach a free-text member that takes any text, up to a + length where the member has one; a constrained one — an email address is the case in + hand — is different. A source string that the member cannot hold is read as *not + recorded* and reported: a member the format cannot hold is a member the source did not + fill in. A converter that passed it through would emit a document that fails its own validation, and since that is treated as the converter's bug rather than the file's, one unusable header field would discard an entire logbook. Any mapping added later that lands a source string on a constrained member owes @@ -237,6 +245,14 @@ about the dive, and the recording is where it lives. format that states a dive's start once. A source that timestamps each computer's record separately — `.ssrf`'s `` — states one per recording, and writing it is what keeps a second computer's samples on their own axis. +- **A readout goes on the recording its computer produced** (§6.4a). A source that states + one per computer's record — a FIT file, a Suunto export, one per file — gives it to that + recording. A source that states one on the **dive** — Subsurface's `` and + `@otu`, UDDF's `` — gives it to the recording its first computer + produced, the primary, and reports it `resolved` where the dive has more than one + recording, the file not saying which computer computed it. A dive with a readout and no + recording otherwise gets one carrying the readout alone, which §6.4a allows: the figure is + a computer's, whether or not the file said which. - **A device is data on a recording, never a gear item.** The two can describe one piece of hardware; they are not one record. A converter that minted a `computer` gear item per dive from a model string would fill a logbook's kit list with duplicates of one computer, @@ -313,11 +329,12 @@ readings beside 29 temperatures keeps both, rather than gaining 402 invented one make. This is reported, unlike a dive that simply carries no samples: the source did record a profile, and this is the converter unable to carry it — the same class as a dropped sample rather than an absence. -- Source sample times are commonly fractional while §6.5's `times` are strictly increasing - integers, so **two readings of one channel that round to the same second keep the first - and report the second**. The collision is **per channel, not per source record**: §6.5 +- §6.5's `times` are milliseconds, so a source's seconds are multiplied by a thousand and + rounded to the whole millisecond — which keeps every sub-second offset a source states — + and **two readings of one channel that round to the same millisecond keep the first and + report the second**. The collision is **per channel, not per source record**: §6.5 gives every channel its own `times`, so readings of *different* channels landing on one - second are collected into one sample rather than competing for it. Sources that append + millisecond are collected into one sample rather than competing for it. Sources that append each sensor's stream as its own record — a depth here, a temperature there, almost never two at once — are common, and offering those records to the axis one at a time makes the axis choose between two readings that were never in competition, losing real ones by it. diff --git a/docs/fit-mapping.md b/docs/fit-mapping.md index c116138..ab5eece 100644 --- a/docs/fit-mapping.md +++ b/docs/fit-mapping.md @@ -134,6 +134,7 @@ value. | sample temperature | `sint8` whole degrees Celsius | tenths of a degree on a §6.5 channel | | tank pressure | `uint16` bar scaled by 100 | bar; tenths of a bar on a §6.5 channel | | elapsed and timer time | `uint32` seconds scaled by 1000 | whole seconds, halves away from zero | +| a sample's place on the axis | `uint32` whole seconds, a `record`'s `timestamp` | milliseconds on a §6.5 axis, × 1000 | | CNS | `uint8` percent | percent | | OTU | `uint16` "OTUs" | the same number | | oxygen, helium | `uint8` whole percent | whole percent | @@ -240,10 +241,10 @@ FIT has no field for what a device calls itself, so §6.4b's `name` has no sourc | `session.total_elapsed_time` (7), else `total_timer_time` (8), else `dive_summary.bottom_time` (11) **untested** | | `duration` | | `session.max_depth` (141), else `dive_summary.max_depth` (3) **untested**, else the depth samples | | `max_depth` | | `session.avg_depth` (140), else `dive_summary.avg_depth` (2) **untested**, else the depth samples | | `avg_depth` | -| `dive_summary.start_cns` (5) **untested**, else `session.start_cns` (143) | | `cns_start` | -| `dive_summary.end_cns` (6) **untested**, else `session.end_cns` (144) | | `cns_end` | -| `dive_summary.o2_toxicity` (9) **untested**, else `session.o2_toxicity` (155) | | `otu_end` | -| `dive_settings.water_type` (4) | | `water_type` | +| `dive_summary.start_cns` (5) **untested**, else `session.start_cns` (143) | | the recording's `cns_start` | +| `dive_summary.end_cns` (6) **untested**, else `session.end_cns` (144) | | the recording's `cns_end` | +| `dive_summary.o2_toxicity` (9) **untested**, else `session.o2_toxicity` (155) | | the recording's `otu_end` | +| `dive_settings.water_type` (4) **untested** | | the recording's `salinity` | **The duration order is what a diver means by one.** `total_elapsed_time` is the wall clock from the moment the dive started to the moment it ended. `total_timer_time` excludes pauses, @@ -254,7 +255,8 @@ Garmin's `bottom_time` is deliberately last: it measures time *at depth*, not th which looks inconsistent and is not. The depths agree wherever both exist, and the fallback is for a device that summarises a dive in one message and not the other. The CNS and OTU totals are the *dive's*, and on a multi-dive Garmin file the session's cover the whole -activity while the `dive_summary` describes the dive being read. +activity while the `dive_summary` describes the dive being read. Being the computer's own +figures, they land on the file's one recording (§6.4a) rather than on the dive. A Garmin freediving activity writes a `dive_summary` per descent **plus** a session-level one, and `reference_mesg` (0) is what separates them: the summary referring to `session` is @@ -272,11 +274,16 @@ reader reports rather than fills: the XML records `8` and t carries no `start_cns` at all, which is why that member is the one `absent` line both fixtures raise. -`water_type`'s enum is `{fresh, salt, en13319, custom}`, and three of the four are §6.2 -members under the same name. `en13319` stays `en13319` rather than being folded into `salt`: -it is the calibration a computer ships set to, and rewriting it as the nearest real water -would be inventing a reading. `custom` says the diver dialled in a `water_density` number, -which §6.2 has nowhere to put, and is reported rather than rounded off. A device that wrote +`water_type`'s enum is `{fresh, salt, en13319, custom}`, and three of the four are §6.4a +`salinity` values under the same name: the field is the density the computer was set to, +which is a setting of that device and not a record of the water, so it reaches the +recording and never the dive's `water_type`. `en13319` stays `en13319` rather than being +folded into `salt`: it is the calibration a computer ships set to, and rewriting it as the +nearest real water would be inventing a reading. `custom` says the diver dialled in a +`water_density` number, which §6.4a has nowhere to put, and is reported rather than rounded +off. No file in the corpus carries a `dive_settings.water_type` +([`fixtures/README.md`](../fixtures/README.md#fit), the `suunto-d5.fit` row), which is why +the row is untested. A device that wrote no `dive_settings` raises nothing — the message is the computer's *configuration* rather than a record of the dive, unlike the session summaries above, every one of which the device was describing this dive when it left empty. @@ -447,16 +454,17 @@ source defect rather than a scale to reinterpret: the profile states the unit ou | `temperature` (13) | | the `temperature` channel, tenths of a degree | | `position_lat` (0) / `position_long` (1) | | `entry_position` / `exit_position` | -The axis origin is the session's own `start_time`, so the profile's seconds are elapsed time -from the instant `started_at` names; a file whose session recorded no start time falls back +The axis origin is the session's own `start_time`, so the profile's axis is the elapsed +milliseconds from the instant `started_at` names — whole seconds multiplied by a thousand, +`timestamp` carrying nothing finer; a file whose session recorded no start time falls back to its earliest reading. The rest of the axis — ordering by recorded time, the sample with no -time, two samples on one second, a dive whose samples carry nothing this format can hold — is -`converting.md`'s and shared with every other format. +time, two samples on one instant, a dive whose samples carry nothing this format can hold — +is `converting.md`'s and shared with every other format. **Each channel takes only the records that carried its reading.** A Suunto Ocean writes 4,295 `record`s of which 431 carry a depth and 4,294 a temperature, and padding either to the other's length would invent nearly four thousand depths the dive never reached. A `record` -and a `tank_update` on one second are one sample rather than two, by `converting.md`'s +and a `tank_update` on one timestamp are one sample rather than two, by `converting.md`'s per-channel collision rule — the two carry different channels and were never in competition. diff --git a/docs/ssrf-mapping.md b/docs/ssrf-mapping.md index 05eb4ee..7949593 100644 --- a/docs/ssrf-mapping.md +++ b/docs/ssrf-mapping.md @@ -77,19 +77,20 @@ in a converter, are in `converting.md`. | --- | --- | --- | --- | --- | | `profile.depth` values | centimetres | `` | `'1.45 m'` | × 100 | | `profile.temperature` values | tenths of a °C | `` | `'24.4 C'` | × 10 | -| `profile.duration`, `times` | seconds | `` | `'0:10 min'` | `M × 60 + S` | +| `profile.duration`, `times` | milliseconds | `` | `'0:10 min'` | `(M × 60 + S) × 1 000` | | `duration` | seconds | `` | `'66:50 min'` | `M × 60 + S` | | `max_depth`, `avg_depth` | metres | ``, `@mean` | `'45.91 m'` | — | | `bottom_temperature` | °C | `` | `'22.4 C'` | — | | `cylinders[].volume` | litres | `` | `'12.0 l'` | — | | `cylinders[].start_pressure`, `.end_pressure` | bar | ``, `@end` | `'200.0 bar'` | — | | `cylinders[].oxygen`, `.helium` | percent | ``, `@he` | `'32.0%'` | — | -| `cns_end` | CNS % | `` | `'11%'` | — | -| `otu_end` | OTU | `` | `'31'` | — | +| a recording's `cns_end` | CNS % | `` | `'11%'` | — | +| a recording's `otu_end` | OTU | `` | `'31'` | — | | `number` | — | `` | `'45'` | — | -**The two channel rows are the trap**, exactly as `converting.md` says: they carry a scale -the scalar rows beside them do not, and the most-executed conversion in this module is the +**The three profile rows are the trap**, exactly as `converting.md` says: they carry a +scale the scalar rows beside them do not — the axis a thousand, where the dive's own +`duration` beside it takes none — and the most-executed conversion in this module is the first of them. The bare-count rows are a unit too — a `%` on an `@otu` is as much a refusal as a `ft` on a depth. @@ -154,8 +155,8 @@ and no version. | `@number` | `number` | | `@duration` | `duration` | | `@divesiteid` | `site_uuids`, one entry | -| `@cns` | `cns_end` | -| `@otu` | `otu_end` | +| `@cns` | the primary recording's `cns_end` — see below | +| `@otu` | the primary recording's `otu_end` | | `` | `notes` | | `` | `cylinders[]` | | ``, `@mean` | `max_depth`, `avg_depth` — **the first element's only**, see below | @@ -200,13 +201,19 @@ naming: the same logbook's other exports do carry a time zone, and taking one fr would be this converter asserting a zone the file does not. `converting.md` forbids supplying one, and §5.2 is what it is protecting. -A dive with no `@date` is dropped, since §6.2 makes `started_at` REQUIRED. A `@time` missing -its seconds is read as `:00` and reported, which is `converting.md`'s leniency: a -hand-edited save file writes exactly that, and without it one such dive would fail a -converter's own output validation and cost the whole logbook. +A dive with no `@date` is dropped, since §6.2 makes `started_at` REQUIRED. A `@date` with no +`@time` at all is a date-only start (§5.2), with the time of day reported absent — the rule +is `converting.md`'s, and no pair here reaches it. A `@time` missing its seconds is read as +`:00` and reported, which is `converting.md`'s leniency: a hand-edited save file writes +exactly that, and without it one such dive would fail a converter's own output validation +and cost the whole logbook. -`@cns` and `@otu` are the dive's *end* figures. Subsurface records no starting pair, so -`cns_start` and `otu_start` have no source here. +`@cns` and `@otu` are the *end* figures of the computer that computed them. Subsurface +records no starting pair, so `cns_start` and `otu_start` have no source here. They sit on the +`` rather than on a ``, so they go where `converting.md` sends a +dive-level readout: onto the primary recording, reported `resolved` where the dive has more +than one, and onto a recording of their own where no `` yields one. No pair +here carries either figure on a two-computer dive. Zero is read two different ways, which is `converting.md`'s zero rule meeting two members with different constraints. A `` or `@mean` of `0.0 m` is **not recorded** — @@ -278,8 +285,10 @@ the diver called their computer. ## This format settles no ambiguity `converting.md` defines a `resolved` finding for a value the source recorded whose scale, -units or *meaning* are genuinely in doubt. **This reader emits none**, and that is a property -of the format rather than an omission. There is no scale to settle: every measurement states +units or *meaning* are genuinely in doubt. **This reader emits one, and it is not about a +scale**: the dive-level `@cns` and `@otu` above, on a dive with more than one recording, where +the file does not say whose figures they are. Past that the absence is a property of the +format rather than an omission. There is no scale to settle: every measurement states its unit, so there is no fraction-or-percent and no litres-or-cubic-metres for a magnitude test to reach. Where UDDF's `0.32` and `34` are both schema-valid and mean the same gas, `.ssrf` writes `o2='32.0%'` and there is nothing left to decide. And there is @@ -290,12 +299,13 @@ writer needs two writers to tell apart. `profile.duration` is the other thing that is not a finding, for `converting.md`'s reason: §6.4 defines it as the span of the profile's own samples, so taking the largest sample time is structural rather than derived. It is regularly longer than the dive's own `@duration` — -4300 against 4010 on the reference logbook's first dive — and samples are never trimmed to -make the two agree. +a 4300 s span against 4010 s on the reference logbook's first dive — and samples are never +trimmed to make the two agree. -**So the report this reader produces carries two kinds and only two: `absent` and -`dropped`.** No pair in `fixtures/ssrf/` expects another, and a `resolved` finding appearing -there would mean a reader had started guessing at a scale. +**So the report this reader produces carries `absent` and `dropped`, and `resolved` for +that one question of where.** No pair in `fixtures/ssrf/` expects a `resolved` finding, and +one appearing anywhere but on a dive-level readout would mean a reader had started guessing +at a scale. ## Two things read and deliberately not carried @@ -378,21 +388,22 @@ exporter's doing. Everything else in both documents is equal. - **`bottom_temperature`** — a temperature here (`22.4` on the first dive; each dive has its own), absent there, on all eight dives. The UDDF export carries no `` for the water temperature the save file keeps. -- **`cns_end`** — a CNS figure here (`11` on the first dive), absent there, on the seven - dives whose `` carries an `@cns`. The UDDF export writes no CNS anywhere in the - document — not on a dive, not on a waypoint — for the figure the save file keeps. The - eighth dive carries neither `@cns` nor `@otu`, and there the two readings agree. -- **`otu_end`** — an OTU figure here (`31` on the first dive), absent there, on the same - seven dives, and absent from the UDDF export for the same reason. Where a UDDF document - does carry these two they are per-waypoint series rather than the dive's end scalar, and - `uddf-mapping.md` records this reader declining to derive a scalar from them; that policy - never comes into play here, because there is nothing in the export to derive from. +- **`cns_end`**, on the recording — a CNS figure here (`11` on the first dive), absent + there, on the seven dives whose `` carries an `@cns`. The UDDF export writes no CNS + anywhere in the document — not on a dive, not on a waypoint — for the figure the save file + keeps. The eighth dive carries neither `@cns` nor `@otu`, and there the two readings agree. +- **`otu_end`**, on the recording — an OTU figure here (`31` on the first dive), absent + there, on the same seven dives, and absent from the UDDF export for the same reason. Where + a UDDF document does carry these two they are per-waypoint series rather than the + recording's end scalar, and `uddf-mapping.md` records this reader declining to derive a + scalar from them; that policy never comes into play here, because there is nothing in the + export to derive from. - **`sites[].location`** — a place named after the site itself there, absent here, on all five sites. The exporter writes a `` holding exactly what `` holds, and the UDDF reader carries it into `location.name` because §6.10's `location` is a real member and a reader cannot know that a writer filled it by copying. The save file's `` has one name and no second field to copy it into. This is the one difference - `dives` cannot see: the other seven all live on a dive. + `dives` cannot see: the other seven all live on a dive or its recording. The record UUIDs differ too, and always will: each format has its own frozen identity namespace, so the same site converted through both paths is two records. `converting.md` diff --git a/docs/suunto-json-mapping.md b/docs/suunto-json-mapping.md index 9bc0a69..e461611 100644 --- a/docs/suunto-json-mapping.md +++ b/docs/suunto-json-mapping.md @@ -105,6 +105,7 @@ validates perfectly and describes a dive nobody took. | `Samples[].Latitude` / `Longitude` | **radians** | decimal degrees at six places | × 180/π | | `DiveRouteOrigin.Latitude` / `Longitude` | **degrees** | decimal degrees, exactly as recorded | — | | `DiveTime`, `Duration` | seconds, fractional | whole seconds, halves away from zero | — | +| `Samples[].TimeISO8601` | an ISO 8601 instant, fractional | **milliseconds** on a §6.5 axis, elapsed from `Header.DateTime` | the difference × 1 000, halves away from zero | | `NoDecTime`, `TimeToSurface` | seconds | **seconds** on a §6.5 channel | — | | `RtGradientFactors.gf99`, `.gfSurface` | whole percent | **whole percent** on a §6.5 channel | — | @@ -198,18 +199,19 @@ than an empty record (§6.4a). `fixtures/suunto_json/header-only.json` is that s | `Header.DiveTime`, else `Header.Duration` | | `duration` | | `Header.Depth.Max` | | `max_depth` | | `Header.DepthAverage`, else `Header.Depth.Avg` | | `avg_depth` | -| `Diving.StartTissue.CNS` × 100 | | `cns_start` | -| `Diving.EndTissue.CNS` × 100 | | `cns_end` | -| `Diving.StartTissue.OTU` | | `otu_start` | -| `Diving.EndTissue.OTU` | | `otu_end` | -| `Diving.SurfacePressure` ÷ 100 000 | | `surface_pressure` | +| `Diving.StartTissue.CNS` × 100 | | the recording's `cns_start` | +| `Diving.EndTissue.CNS` × 100 | | the recording's `cns_end` | +| `Diving.StartTissue.OTU` | | the recording's `otu_start` | +| `Diving.EndTissue.OTU` | | the recording's `otu_end` | +| `Diving.SurfacePressure` ÷ 100 000 | | the recording's `surface_pressure` | | `Diving.DiveMode` | | the recording's `mode`, by the table below | | `Diving.Algorithm` | | `deco_model.name` verbatim, and `deco_model.algorithm` by the table below | | `Diving.Conservatism` | | `deco_model.conservatism` | -**The mode and the model are the recording's, not the dive's** (§6.4a), and only the D5 -header shape states them: the Ocean shape has no `Header.Diving` at all, so an Ocean file -yields the channels below and no `deco_model`, which is correct rather than a gap. +**The mode, the model and the readouts are the recording's, not the dive's** (§6.4a), and +only the D5 header shape states them: the Ocean shape has no `Header.Diving` at all, so an +Ocean file yields the channels below and no `deco_model` and no readout, which is correct +rather than a gap. | `Diving.DiveMode` | `mode` | seen on | | --- | --- | --- | @@ -264,7 +266,7 @@ An average depth deeper than the maximum cannot be, and the average is dropped. | `TankSize` × 1 000 | | `volume` | | `StartPressure` ÷ 100 000 | | `start_pressure` | | `EndPressure` ÷ 100 000 | | `end_pressure` | -| `PO2` ÷ 100 000 | | `po2_limit` | +| `PO2` ÷ 100 000 | | `ppo2_limit` | | `State` | | `role`, through the table below | **The block is authoritative where it exists** and the sample telemetry adds nothing to it. @@ -337,14 +339,14 @@ channels keep. So a converted dive's last channel value and its cylinder's `end_ disagree, on purpose, and every fixture with a `DiveTime` and a pressure channel encodes that. **The extremes are taken over the samples' own recorded instants, not off the profile.** -The merged axis is not what loses them — it folds an entry into a second another channel's +The merged axis is not what loses them — it folds an entry into an instant another channel's entry already holds rather than dropping it, which is `converting.md`'s collision rule read per channel, and *The profile* below is where this exporter's habit of appending its sensor streams separately makes that rule visible. Two other readings do, and both are measured on the dive `suunto-ocean.json` is reduced from, whose start pressure -is 211.625 bar: an **unmerged** axis, one entry per second with the first winning it whole, -gives 211.26562, the earlier depth entry taking the second and carrying the cylinder reading -0.1 s later away with it; and the axis's pressure **channel**, which §6.5 stores in tenths +is 211.625 bar: an **unmerged** whole-second axis, one entry per second with the first +winning it whole, gives 211.26562, the earlier depth entry taking the second and carrying +the cylinder reading 0.1 s later away with it; and the axis's pressure **channel**, which §6.5 stores in tenths of a bar, gives 211.6, which is a rounding of a value the source recorded and so is `converting.md`'s rule the other way round. @@ -373,7 +375,7 @@ switch that happened, and saying so is honest where guessing a position would no | member | | into | | --- | --- | --- | -| `TimeISO8601` | | the sample's second, elapsed from `Header.DateTime` | +| `TimeISO8601` | | the sample's instant, in milliseconds elapsed from `Header.DateTime` | | `Depth` | | the `depth` channel, centimetres | | `Ceiling` | | the `ceiling` channel, centimetres, **where it is above zero** | | `Temperature` | | the `temperature` channel, tenths of a degree Celsius | @@ -388,10 +390,13 @@ switch that happened, and saying so is honest where guessing a position would no **This exporter is what made `converting.md`'s collision rule per channel.** It appends its sensor streams as separate entries: on the dive `suunto-ocean.json` is reduced from, 7 477 entries carry a depth, a temperature, a satellite fix or a battery reading, almost never two -of those at once, and they collide on the whole seconds §6.5 requires. Offering them to the -axis one at a time leaves it choosing between a depth and a temperature recorded at the same -instant, and keeps 345 of that dive's 431 depths; merging them keeps all 431 — the count the -same dive's FIT reading gives. +of those at once. On a whole-second axis they collide: offering them one at a time leaves +the axis choosing between a depth and a temperature recorded within one second, and keeps +345 of that dive's 431 depths, where merging them keeps all 431 — the count the same dive's +FIT reading gives. §6.5's axis is milliseconds, which places each entry at its own stated +instant and keeps the sub-second offsets the export writes: `suunto-ocean.json`'s first three +depths sit at 160, 1 200 000 and 4 000 020 ms. The per-channel rule is what still decides two +entries of different channels stamped the same millisecond. **Samples are ordered by their own recorded time.** The union of an Ocean export's sample timestamps is not monotonic: adjacent entries go backwards by up to a second — 1.05 s is @@ -575,10 +580,10 @@ unit, and neither is in doubt. cylinder; a start time with no UTC offset; a zero in a member whose schema makes zero a placeholder; a member the header does not state. - **`dropped`** — an activity that is not a dive; a start time that is not one; a sample - with no `TimeISO8601`; a sample before the dive began; one channel twice on a second; an - end pressure above its start; a pressure, ppO₂ limit or surface pressure outside what §6 - allows; a mix whose halves sum above 100 %; cylinders past the cap; samples that carry a - time and no reading this format can hold. + with no `TimeISO8601`; a sample before the dive began; one channel twice on a + millisecond; an end pressure above its start; a pressure, ppO₂ limit or surface pressure + outside what §6 allows; a mix whose halves sum above 100 %; cylinders past the cap; samples + that carry a time and no reading this format can hold. - **`inferred`** — never. This export summarises its own dive, so there is nothing for this reader to compute, and `extensions.divejson.inferred` is never written. - **`resolved`** — never, as above. diff --git a/docs/suunto-xml-mapping.md b/docs/suunto-xml-mapping.md index b9b4760..4078dbc 100644 --- a/docs/suunto-xml-mapping.md +++ b/docs/suunto-xml-mapping.md @@ -138,7 +138,7 @@ both makes it visible. Each row below is cross-checked that way. trap worth stating twice. Read at the cylinder scale, `104900` would be 104.9 bar — a hundred metres of seawater, at the surface. Two things settle it: every one of the 384 exports lands in 103 100 to 106 700, which is a barometric range only on the Pascal reading, -and §6.2's own 0.4 to 1.2 bar bound refuses the other one outright. +and §6.4a's own 0.4 to 1.2 bar bound refuses the other one outright. ### The factor table @@ -147,18 +147,18 @@ and §6.2's own 0.4 to 1.2 bar bound refuses the other one outright. | ``, `` | `max_depth`, `avg_depth` | metres, ×1 | | `` | `bottom_temperature` | °C, ×1 | | `` | `duration` | seconds, ×1, rounded to a whole one | -| `` | `surface_pressure` | Pascal ÷ 100 000 | -| ``, `` | `cns_start`, `cns_end` | percent, ×1 | -| ``, `` | `otu_start`, `otu_end` | count, ×1 | +| `` | the recording's `surface_pressure` | Pascal ÷ 100 000 | +| ``, `` | the recording's `cns_start`, `cns_end` | percent, ×1 | +| ``, `` | the recording's `otu_start`, `otu_end` | count, ×1 | | `` | `cylinders[].volume` | litres, ×1 | | ``, `` | `cylinders[].start_pressure`, `end_pressure` | millibar ÷ 1 000 | | ``, `` | `cylinders[].oxygen`, `helium` | percent, ×1 | -| `` | `cylinders[].po2_limit` | bar, ×1 | +| `` | `cylinders[].ppo2_limit` | bar, ×1 | | `` | `profile.depth.values` | metres **× 100** — centimetres | | `` | `profile.ceiling.values` | metres **× 100** — centimetres | | `` | `profile.temperature.values` | °C **× 10** — tenths | | `` | `profile.pressures[].values` | millibar **÷ 1 000 × 10** — tenths of a bar | -| ` + + + + 2026-07-05 + + + 21.5 + + Logged by hand on the boat: the day, and no time of day. + + 2880 + + From 0c5e47b8e6ca29757372c0aebdab595aaf4d84f5 Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Fri, 25 Sep 2026 06:32:43 +0300 Subject: [PATCH 7/9] Account for a written recording that carried nothing UDDF holds Co-Authored-By: Claude Opus 5.5 --- tests/test_uddf_write_fixtures.py | 48 +++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/tests/test_uddf_write_fixtures.py b/tests/test_uddf_write_fixtures.py index e753dd8..ea25c5a 100644 --- a/tests/test_uddf_write_fixtures.py +++ b/tests/test_uddf_write_fixtures.py @@ -127,6 +127,9 @@ "trips/0/notes", # `` is one string, so a labelled bookmark keeps the type. "dives/0/recordings/0/profile/events/6/label", + # The third dive's one recording carries a CNS end and nothing else, and UDDF + # has no slot for it, so none of the recording is written and none comes back. + "dives/2/recordings", # UDDF records no cylinder numbering, so the labels come back as positions. *(f"dives/0/cylinders/{index}/gas_number" for index in range(4)), *(f"dives/0/recordings/0/profile/pressures/{index}/gas_number" for index in range(4)), @@ -287,14 +290,55 @@ def test_the_report_names_everything_that_changed(source) -> None: written = write_uddf(document) read_back = convert(written.data, format="uddf").document + before, after = _compared(document), _compared(read_back) unreported = [ path - for path in _differences(_compared(document), _compared(read_back)) - if path not in RETURNED[source.stem] and not _reported(path, written.notes) + for path in _differences(before, after) + if path not in RETURNED[source.stem] + and not _reported(path, written.notes) + and not _wholly_reported(path, before, after, written.notes) ] assert unreported == [] +def _at(document: Any, path: str) -> Any: + """The value at a `dives/2/recordings` path, or `_MISSING` where there is none.""" + node = document + for part in path.split("/"): + if isinstance(node, dict) and part in node: + node = node[part] + elif isinstance(node, list) and part.isdigit() and int(part) < len(node): + node = node[int(part)] + else: + return _MISSING + return node + + +_MISSING = object() + + +def _leaves(value: Any, path: str) -> list[str]: + if isinstance(value, dict): + return [leaf for key, child in value.items() for leaf in _leaves(child, f"{path}/{key}")] + if isinstance(value, list): + return [leaf for index, child in enumerate(value) for leaf in _leaves(child, f"{path}/{index}")] + return [path] + + +def _wholly_reported(path: str, before: Any, after: Any, notes) -> bool: + """A record that did not come back at all is accounted for when everything it carried is. + + `_differences` names a vanished member by its own path, and a writer reports from the + record — a readout-only recording's `cns_end`, one level down — so a member absent from + the read-back is matched on its leaves. A member that came back *changed* is not: it has + to be named where it differs. + """ + if _at(after, path) is not _MISSING: + return False + leaves = _leaves(_at(before, path), path) + return bool(leaves) and all(_reported(leaf, notes) for leaf in leaves) + + # -- agreement with the reference writer ------------------------------------------------ # The same logbook written twice: `fixtures/uddf/opendiving.uddf` is what DiveJSON's From 4dffb1863151f59e1dd58ea50fa48897db160fd5 Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Fri, 25 Sep 2026 06:43:01 +0300 Subject: [PATCH 8/9] Hold the .ssrf clock's product to the representability bound, and rewrap two docstrings Co-Authored-By: Claude Opus 5.5 --- divejson/converter.py | 12 +++++++----- divejson/ssrf.py | 12 ++++++++++-- tests/test_ssrf_units.py | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/divejson/converter.py b/divejson/converter.py index 7004082..970073c 100644 --- a/divejson/converter.py +++ b/divejson/converter.py @@ -402,8 +402,9 @@ def channel_floor(member: str) -> int | None: A channel is a `$ref` to a series definition rather than a member carrying constraints of its own, so `recorded` is not the question to ask about one — every call site it has - names a record's own member, and `_floor` finds nothing under a reference. This resolves the reference instead: the six decompression readouts share a - definition whose `values` floor at zero, because no-decompression time, time to surface, + names a record's own member, and `_floor` finds nothing under a reference. This + resolves the reference instead: the six decompression readouts share a definition whose + `values` floor at zero, because no-decompression time, time to surface, ppO₂, CNS and a gradient factor have no negative reading and a source that writes one is spelling absence in the only space it had. Depth, ceiling and temperature share the signed definition and floor at nothing. @@ -479,7 +480,8 @@ def recorded(value: Decimal | int | None, *, record: str, member: str) -> bool: # Divided by 1000, the largest factor any adapter applies to a number it has read — UDDF's # cubic metres to litres, every source's seconds to the profile axis's milliseconds, and # above any channel scale — so that checking the value as the text is read also covers -# every value derived from it. +# every value derived from it. A reader that multiplies before that factor, as `.ssrf`'s +# `M:SS` does minutes into seconds, checks its own product against this bound too. MAX_MAGNITUDE = Decimal(sys.float_info.max) / 1000 # §5.1's grain for elapsed time on a profile axis — a Series' `times`, a profile's @@ -556,8 +558,8 @@ def in_seconds(milliseconds: int) -> str: The report speaks seconds because that is what every source format and every dive computer's display does; the document's milliseconds are an encoding, and a note saying a waypoint is "at 1200020" would send a diver looking for a number their file does not - contain. A whole second is written as the integer it is, so a report on a file that - samples whole seconds reads exactly as it did before the axis moved. + contain. A whole second is written as the integer it is — `30`, never `30.0` — so a + report on a file that samples whole seconds shows no fraction the file does not. """ return format((Decimal(milliseconds) / MILLISECONDS_PER_SECOND).normalize(), "f") diff --git a/divejson/ssrf.py b/divejson/ssrf.py index 6cc549c..a9284dd 100644 --- a/divejson/ssrf.py +++ b/divejson/ssrf.py @@ -59,6 +59,7 @@ from .converter import ( CENTIMETRES_PER_METRE, + MAX_MAGNITUDE, MAX_NAME, PRODUCER_KEY, TENTHS_PER_UNIT, @@ -123,14 +124,21 @@ def _clock(raw: str) -> Decimal | None: - """`'66:50'` as 4010 seconds, or nothing for text that is not a `M:SS` time.""" + """`'66:50'` as 4010 seconds, or nothing for text that is not a `M:SS` time. + + The product is held to `MAX_MAGNITUDE` as well as the minutes, because a sample time + takes two factors past its text — sixty here and a thousand on the axis — where the + bound covers one: minutes that clear it can still reach the axis as a number no double + holds, and a time that cannot be carried is text that is not a time. + """ match = _CLOCK.match(raw) if match is None: return None minutes = decimal_of(match.group(1)) if minutes is None: return None - return minutes * SECONDS_PER_MINUTE + Decimal(match.group(2)) + seconds = minutes * SECONDS_PER_MINUTE + Decimal(match.group(2)) + return seconds if seconds <= MAX_MAGNITUDE else None def _calendar_date(text: str) -> bool: diff --git a/tests/test_ssrf_units.py b/tests/test_ssrf_units.py index e1b9dd0..6e3a6a5 100644 --- a/tests/test_ssrf_units.py +++ b/tests/test_ssrf_units.py @@ -107,6 +107,21 @@ def test_a_samples_clock_is_the_same_seconds_on_a_millisecond_axis(written: str, assert profile(f"")["depth"]["times"] == [milliseconds] +def test_a_clock_too_large_for_the_axis_is_not_a_time() -> None: + """Minutes that clear `decimal_of`'s bound still take two factors to reach the axis — + sixty, then a thousand — so the product is bounded too, and a sample the axis could not + carry as a double is dropped rather than written as a number no reader can read back.""" + minutes = "9" * 305 + samples = f"" + conversion = convert(one_ssrf_computer(samples)) + depth = profile_of(conversion.document["dives"][0])["depth"] + assert depth == {"times": [10_000], "values": [100]} + assert any( + " is recorded in min with something that is not a number" in note.message + for note in conversion.notes + ) + + @pytest.mark.parametrize("written", ["66", "66:5", "1:60", "66:50:00", "1.5", "66m50s"]) def test_something_that_is_not_a_clock_is_refused_rather_than_read_as_minutes(written: str) -> None: """A bare `66` is the tempting one, and reading it would be a factor-of-60 guess.""" From a24dd80dfd95f2abe96db33c1006dae6ddf6876c Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Fri, 25 Sep 2026 06:57:06 +0300 Subject: [PATCH 9/9] Say what the validator checks rather than what it stopped checking Co-Authored-By: Claude Opus 5.5 --- tests/test_validate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_validate.py b/tests/test_validate.py index d1c0cca..5ceea40 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -10,8 +10,8 @@ So these, and deliberately nothing else: the channels §6.4 added, the gradient-factor ordering §3 rule 6 states, what §3 rule 4 accepts as a recording's content, the one member -that may hold a date as well as a date-time, the member order the validator no longer -checks — no `invalid/` document can pin an absence of a rule — and the one uuid claim whose +that may hold a date as well as a date-time, the member order the validator does not +check — no `invalid/` document can pin an absence of a rule — and the one uuid claim whose fixture is refused for another reason by any validator that does not know its member. Everything already covered by a pair stays covered by the pair. """