Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ this file is about the package, whose version moves independently.

## Unreleased

- **Breaking: a place is one object, and a dive site carries it.** §6.9 of
[the specification](https://github.com/divejson/divejson/blob/main/spec/divejson.md) is
*Location* rather than *Trip Location* and both a trip part and a dive site reference it, so
`sites[].location` is an object where it was a free-text string and anything reading it as
text reads `location.name`. Its second text member is `full_name`, the fullest written form
the source held for the place; `display_name` is removed rather than re-pointed, so a
document still carrying it is refused as an undefined member rather than read as something
it no longer means. §3's `south ≤ north` reads on either host now, and a dive site's
reversed bounding box is reported at `sites/<i>/location/bbox`.

- **The UDDF reader fills a site's locality with its name and nothing else.**
`<geography>`'s own coordinates are the site's pin and not the locality's centre (§6.10),
and UDDF has no element for a fuller form of a place or for its extent. The writer spends
the one `<location>` element each host has the same way it is read — a site's on
`location.name`, a trip part's on `location.full_name`, the part's own `<name>` already
holding the name — and reports a site's `full_name`, `position` and `bbox` dropped at
`sites/<i>/location`, where a note at the site's own path would read as a claim about the
coordinates the same element just carried.

## 0.9.0

- **Breaking: a trip is a sequence of parts, and records no dates of its own.** §6.8 and the
Expand Down
2 changes: 1 addition & 1 deletion SPEC_REF
Original file line number Diff line number Diff line change
@@ -1 +1 @@
373bf78002d15102e60f8913f31b74dbe7c12ffc
977288489f1312ff94451ec7379fcef1f27f6d75
22 changes: 13 additions & 9 deletions divejson/uddf.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,13 @@
# specifies — see `_volume_litres`.
LITRES_THRESHOLD = Decimal(1)

# `MAX_NOTES` and `MAX_NAME` are `converter.py`'s: every adapter meets those two. The three
# below are UDDF's own, being the only reader that fills the members they cap. §6.12's
# `serial` is 1-64 rather than the 255 its neighbours share, and the reason is worth
# `MAX_NOTES` and `MAX_NAME` are `converter.py`'s: every adapter meets those two, and
# §6.9's `name` is one of them — a place's name shares the 255 every name in the format
# has. The two below are UDDF's own, being the only reader that fills the members they cap.
# §6.12's `serial` is 1-64 rather than the 255 its neighbours share, and the reason is worth
# knowing: a gear serial longer than a device's (§6.4b) could never equal one, and equality
# between the two is what says a kit item and a device are one machine.
MAX_LOCATION = 255
MAX_DISPLAY_NAME = 512
MAX_FULL_NAME = 512
MAX_SERIAL = 64
MIN_PO2_LIMIT = Decimal("0.4")
MAX_PO2_LIMIT = Decimal("2.0")
Expand Down Expand Up @@ -678,7 +678,11 @@ def read_sites(self) -> list[dict[str, Any]]:
geography = _kid(element, "geography")
location = _text_of(geography, "location")
if location:
site["location"] = self.capped(location, MAX_LOCATION, where, "the site location")
# The place's name and nothing else. `<geography>`'s own coordinates are the
# **site's** pin, not the locality's centre (§6.10), and UDDF has no element
# for a fuller form of the place or for its extent — so a site read from
# here never arrives with a `full_name`, a `location.position` or a `bbox`.
site["location"] = {"name": self.capped(location, MAX_NAME, where, "the site's locality")}
position = self.position(geography, where)
if position:
site["position"] = position
Expand Down Expand Up @@ -765,12 +769,12 @@ def read_trip_parts(self, element: ET.Element, where: str) -> tuple[list[dict[st

geography = _kid(part, "geography")
part_name = _text_of(part, "name")
display_name = _text_of(geography, "location")
full_name = _text_of(geography, "location")
location: dict[str, Any] | None = None
if part_name:
location = {"name": self.capped(part_name, MAX_NAME, part_where, "the trip part's name")}
if display_name and display_name != part_name:
location["display_name"] = self.capped(display_name, MAX_DISPLAY_NAME, part_where, "the location")
if full_name and full_name != part_name:
location["full_name"] = self.capped(full_name, MAX_FULL_NAME, part_where, "the location")
position = self.position(geography, part_where)
if position:
location["position"] = position
Expand Down
38 changes: 29 additions & 9 deletions divejson/uddf_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -916,29 +916,49 @@ def divesite_element(self) -> ET.Element | None:
self.unmapped(where, site, frozenset({"uuid", "name", "location", "position", "notes"}))
element = _sub(divesite, "site", id=_uddf_id("site", site["uuid"]))
_sub(element, "name", str(site.get("name") or ""))
self.geography(element, where, site.get("location"), site.get("position"), noun="site")
location = site.get("location")
if location:
# The locality's own members report here or nowhere: `unmapped` is flat, and
# the call above carries `location` as a member *name* rather than walking
# into it. At the locality's path and not the site's, which is not cosmetic
# — `geography` writes the **site's** pin two lines down, so a note saying
# UDDF has no slot for `position` at `sites/<i>` would read as a claim about
# the coordinates this same call just wrote (§6.10).
self.unmapped(f"{where}/location", location, frozenset({"name"}))
self.geography(
element,
where,
location.get("name") if location else None,
site.get("position"),
noun="site",
)
self.notes_of(element, where, site)
return divesite

def geography(
self,
parent: ET.Element,
where: str,
location: Any,
place: Any,
position: Any,
*,
noun: str,
) -> None:
"""`<geography>`, which UDDF will not let carry coordinates without a place name.

`place` is the text the one `<location>` element gets, and each host spends that
element on a different member of §6.9's location — a site on the place's `name`, a
trip part on its `full_name`, the part's own `<name>` already holding the name.
`docs/uddf-mapping.md` has the asymmetry and why UDDF forces it.

`<location>` is mandatory in `geographyType`, and there is nothing honest to put
there for a record that has none: copying the record's own **name** in — which is
what the reference writer does, having one to spare and an app's own export to
produce — would hand a round trip a location the diver never wrote. So the
coordinates are dropped and reported, which is the loss this format actually
imposes.
"""
if not location:
if not place:
if position:
self.note(
where,
Expand All @@ -948,7 +968,7 @@ def geography(
)
return
geography = _sub(parent, "geography")
_sub(geography, "location", str(location))
_sub(geography, "location", str(place))
if position:
_sub(geography, "latitude", _num(position["latitude"]))
_sub(geography, "longitude", _num(position["longitude"]))
Expand Down Expand Up @@ -989,17 +1009,17 @@ def divetrip_element(self) -> ET.Element | None:
if record is not None:
self.nameless_part(part_where, record)
else:
self.unmapped(part_where, location, frozenset({"name", "display_name", "position"}))
self.unmapped(part_where, location, frozenset({"name", "full_name", "position"}))
_sub(part, "name", str(location.get("name") or ""))
if record is not None:
self.date_of_trip(part, part_where, record)
if location is not None:
# `display_name` and nothing else: the reader takes a part's
# `<geography><location>` as the display name and only where it differs
# `full_name` and nothing else: the reader takes a part's
# `<geography><location>` as the fuller form and only where it differs
# from the part's own name, so writing the name here would round-trip as
# no display name at all.
# no full name at all.
self.geography(
part, part_where, location.get("display_name"), location.get("position"), noun="location"
part, part_where, location.get("full_name"), location.get("position"), noun="location"
)
if part_index == 0:
self.notes_of(part, where, trip)
Expand Down
35 changes: 25 additions & 10 deletions divejson/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,27 @@ def _present(obj: dict[str, Any], member: str) -> bool:
return obj.get(member) is not None


def _check_location_bbox(record: dict[str, Any], path: str, issues: list[Issue]) -> None:
"""§3's `south ≤ north`, on whichever record carries the location.

§6.9's box has two hosts — a trip part and a dive site — so a check that walked trips
alone would pass a site's reversed box perfectly, the schema bounding each corner and
saying nothing about the pair. `fixtures/invalid/site-bbox-south-exceeds-north.divejson`
is the document that fails only for a validator reaching both.
"""
location = record.get("location")
if not isinstance(location, dict):
return
bbox = location.get("bbox")
if not isinstance(bbox, dict):
return
try:
if bbox["south"] > bbox["north"]:
issues.append(Issue(f"{path}/location/bbox", "south exceeds north"))
except (KeyError, TypeError):
pass


def _semantic_issues(doc: dict[str, Any]) -> list[Issue]:
issues: list[Issue] = []

Expand Down Expand Up @@ -289,16 +310,10 @@ def _semantic_issues(doc: dict[str, Any]) -> list[Issue]:
issues.append(Issue(part_path, "ends_on precedes starts_on"))
except TypeError:
pass
location = part.get("location")
if not isinstance(location, dict):
continue
bbox = location.get("bbox")
if isinstance(bbox, dict):
try:
if bbox["south"] > bbox["north"]:
issues.append(Issue(f"{part_path}/location/bbox", "south exceeds north"))
except (KeyError, TypeError):
pass
_check_location_bbox(part, part_path, issues)

for index, site in enumerate(collections["sites"]):
_check_location_bbox(site, f"sites/{index}", issues)

for index, gear_set in enumerate(collections["gear_sets"]):
_check_reference_list(
Expand Down
14 changes: 7 additions & 7 deletions docs/ssrf-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ one, so nothing about it could be checked against output Subsurface actually pro
| --- | --- |
| `<trip>`'s own attributes | No real export in hand carries a `<trip>` to read its `@date` and `@location` from — so the dives inside one are carried and the grouping is reported as dropped. The element is still walked *through*, or a trip's dives would disappear with it. §6.8's `starts_on` was a second reason and is no longer one: a trip records no dates now, and §6.9a's part is the shape `@date` and `@location` together make. What is left to settle is a trip's `name`, which §6.8 still REQUIRES and which no attribute here states. This is the first thing to map when such a file arrives. |
| `<site @gps>` | site coordinates, and the highest-value entry in this table. No file in hand carries one, so neither the separator nor the coordinate order can be checked; `converting.md`'s Null Island and half-a-pair rules are already shared and waiting for it. |
| `<site><geo>` | Subsurface's country/region taxonomy, whose `@cat` codes are not documented in any file here. `sites[].location` is where it would land. |
| `<site><geo>` | Subsurface's country/region taxonomy, whose `@cat` codes are not documented in any file here. `sites[].location.name` is where it would land — the tags say what the place is called and nothing about where it sits, so the rest of §6.9's members would stay empty. |
| `<weightsystem>` | `dive.weight` is the member, and the unit spelling and the multiple-system summing rule are both unchecked against a real file. |
| `<sample @pressure>`, `@sensor` | `profile.pressures[]` and the cylinder numbering it needs. No file in hand carries a sample pressure, and a channel tied to the wrong cylinder is worse than no channel. |
| `<sample @ndl>`, `@tts`, `@cns`, `@dc_supplied_ppo2` | §6.4's `ndl`, `tts`, `cns` and `ppo2` channels. Nothing in this table is closer to landing: the members exist and Subsurface writes all four. No `.ssrf` in hand carries one — every `<sample>` in the four fixtures states depth, temperature and nothing else — and this corpus does not adopt a mapping no pair exercises. Named here as Subsurface writes them so the reader that maps them starts from the right list. |
Expand Down Expand Up @@ -387,12 +387,12 @@ exporter's doing. Everything else in both documents is equal.
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.
- **`sites[].location`** — the site's own name there, absent here, on all five sites. The
exporter writes a `<geography><location>` holding exactly what `<name>` holds, and the
UDDF reader carries it 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 `<site>` 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.
- **`sites[].location`** — a place named after the site itself there, absent here, on all
five sites. The exporter writes a `<geography><location>` holding exactly what `<name>`
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
`<site>` 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.

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`
Expand Down
10 changes: 8 additions & 2 deletions docs/uddf-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ holding `n/a`, a dash or a person's name is read as no email recorded, and repor
| UDDF | DiveJSON |
| --- | --- |
| `name` | `sites[].name` — REQUIRED, so a nameless site is dropped |
| `geography/location` | `sites[].location` |
| `geography/location` | `sites[].location.name` — the only slot UDDF has for a locality, so nothing else of the place is filled in |
| `geography/latitude` + `longitude` | `sites[].position` |
| `notes/para` | `sites[].notes`, paragraphs joined with blank lines |

Expand All @@ -162,13 +162,19 @@ which is the file that taught `converting.md`'s rule that such a pair is not a p
| `name` | `trips[].name` |
| `trippart` | `trips[].parts[]`, in file order |
| `trippart/name` | `trips[].parts[].location.name` |
| `trippart/geography/location` | `trips[].parts[].location.display_name`, when it differs from the name |
| `trippart/geography/location` | `trips[].parts[].location.full_name`, when it differs from the name |
| `trippart/geography/latitude` + `longitude` | `trips[].parts[].location.position` |
| `trippart/dateoftrip/@startdate` | `trips[].parts[].starts_on` |
| `trippart/dateoftrip/@enddate` | `trips[].parts[].ends_on` |
| `trippart/notes/para` | `trips[].notes`, every part's joined |
| dive's `informationbeforedive/tripmembership/@ref` | `dives[].trip_uuid` |

**`<geography><location>` lands in a different member on each host**, and the asymmetry is
UDDF's rather than this reader's. A `<trippart>`'s `<name>` *is* its place's name, so the
element beside it is free to carry the fuller form; a `<site>`'s `<name>` is the site's own,
which leaves `<location>` as the only slot the locality has at all — and a site read from
UDDF therefore never arrives with a `full_name`, a locality position or a box.

**A `<trippart>` is a part**, which is as close to an identity as this table gets: both
formats model a trip as a sequence of stretches each carrying its own dates and its own
place, so the dates stay where the file put them instead of being collapsed into a span.
Expand Down
Loading
Loading