Source code for diveplan.dive.formatters.subsurface

"""Subsurface dive-log XML formatter.

Emits a minimal Subsurface-compatible dive log: one dive with cylinders
(one per gas), a planned-dive computer with depth samples and gas-change
events. Written against Subsurface's XML dive-log format and verified by
importing into Subsurface 6.0; the format is theirs, not a public
standard, so re-verify after major Subsurface releases.
"""

from datetime import datetime, timedelta
from xml.etree import ElementTree

from diveplan.core.gas import Gas
from diveplan.dive.dive_report import DiveReport
from diveplan.dive.formatters import BaseFormatter

__all__ = ["SubsurfaceXmlFormatter"]


def _mmss(td: timedelta) -> str:
    total = round(td.total_seconds())
    return f"{total // 60}:{total % 60:02d} min"


def _depth(metres: float) -> str:
    return f"{max(0.0, metres):.1f} m"


def _gas_attrs(gas: Gas) -> dict[str, str]:
    attrs = {"o2": f"{gas.fo2 * 100:.1f}%"}
    if gas.fhe > 0:
        attrs["he"] = f"{gas.fhe * 100:.1f}%"
    return attrs


[docs] class SubsurfaceXmlFormatter(BaseFormatter): """Render the report as a Subsurface dive-log XML document.""" NAME = "subsurface" SAMPLE_STEP = timedelta(seconds=10) def __init__(self, *, planned_at: datetime | None = None): self.planned_at = planned_at if planned_at is not None else datetime.now()
[docs] def format(self, report: DiveReport) -> str: """Render the report as a Subsurface dive-log XML string.""" profile = report.profile segments = profile.segments divelog = ElementTree.Element( "divelog", {"program": "diveplan", "version": "3"} ) dives = ElementTree.SubElement(divelog, "dives") dive = ElementTree.SubElement( dives, "dive", { "number": "1", "date": self.planned_at.strftime("%Y-%m-%d"), "time": self.planned_at.strftime("%H:%M:%S"), "duration": _mmss(report.runtime), }, ) # One cylinder per distinct gas, in order of first use. gases: list[Gas] = [] for segment in segments: if segment.gas not in gases: gases.append(segment.gas) for gas in gases: ElementTree.SubElement( dive, "cylinder", {**_gas_attrs(gas), "description": gas.name} ) computer = ElementTree.SubElement( dive, "divecomputer", {"model": "diveplan (planned dive)"} ) # Time-weighted mean depth (exact for linear segments). total_s = report.runtime.total_seconds() mean_m = 0.0 if total_s > 0: weighted = sum( s.average_pressure.depth_m * s.duration.total_seconds() for s in segments ) mean_m = weighted / total_s ElementTree.SubElement( computer, "depth", {"max": _depth(report.max_depth.depth_m), "mean": _depth(mean_m)}, ) # Gas-change events at the segment boundaries where the gas changes. elapsed = timedelta(0) current_gas = segments[0].gas if segments else None for segment in segments: if segment.gas != current_gas: ElementTree.SubElement( computer, "event", { "time": _mmss(elapsed), "name": "gaschange", "cylinder": str(gases.index(segment.gas)), **_gas_attrs(segment.gas), }, ) current_gas = segment.gas elapsed += segment.duration # Depth samples on a fixed grid (plus the exact start and end). ElementTree.SubElement( computer, "sample", {"time": _mmss(timedelta(0)), "depth": _depth(0.0)} ) for sample in profile.iter_samples(self.SAMPLE_STEP): ElementTree.SubElement( computer, "sample", { "time": _mmss(sample.time), "depth": _depth(sample.pressure.depth_m), }, ) ElementTree.indent(divelog) return ElementTree.tostring(divelog, encoding="unicode", xml_declaration=True)