Source code for diveplan.dive.formatters.runtime
"""Runtime-table formatter: the plan as a diver would write it on a slate.
Follows the dive-table convention (matching Subsurface's plan details with
"display transitions in deco" off): travel between deco stops is folded into
the following stop's row — its duration includes the ascent, its runtime is
the departure time. The ascent from the bottom to the first stop and the
final surfacing keep their own rows. Whole minutes throughout. Plain ASCII,
so the output pastes anywhere and prints from any encoding.
Use :meth:`~diveplan.dive.formatters.BaseFormatter.write` to save the sheet
as a ``.txt``.
"""
from datetime import timedelta
from diveplan.core.gas import Gas
from diveplan.dive.dive_report import DiveReport
from diveplan.dive.formatters import BaseFormatter
__all__ = ["RuntimeFormatter"]
# Row markers: v descent, - hold (bottom/stop), ^ ascent.
_MARK = {"DESCENT": "v", "FORCED_ASCENT": "^", "DECO_ASCENT": "^"}
_FOLDABLE_BEFORE_STOP = ("STOP", "GAS_SWITCH")
def _minutes(td: timedelta) -> int:
return round(td.total_seconds() / 60)
[docs]
class RuntimeFormatter(BaseFormatter):
"""Printable runtime sheet: depth / duration / runtime / gas."""
NAME = "runtime"
[docs]
def format(self, report: DiveReport) -> str:
"""Render the runtime sheet as plain ASCII text."""
lines = [
f"DIVE PLAN - {report.model_name} | "
f"runtime {_minutes(report.runtime)} min | "
f"max depth {report.max_depth.depth_m:.0f} m",
"generated by diveplan - DO NOT USE FOR REAL DIVES",
"",
" depth duration runtime gas",
]
shown_gas = None
for mark, depth_m, duration, runtime_end, gas in self._table_rows(report):
gas_label = ""
if gas != shown_gas:
gas_label = f" {gas.name}"
shown_gas = gas
lines.append(
f" {mark} {depth_m:3.0f}m {_minutes(duration):5d}min "
f"{_minutes(runtime_end):6d}min{gas_label}"
)
lines.append("")
consumed = " | ".join(
f"{gas.name} {litres:.0f} L" for gas, litres in report.consumption_l
)
lines.append(f"gas used {consumed}")
lines.append(
f"sac bottom {report.sac_bottom:g} / "
f"deco {report.sac_deco:g} L/min | "
f"rock bottom x{report.sac_factor:g}"
)
lines.append(
f"rock bottom {report.rock_bottom_l:.0f} L "
f"@ {report.max_depth.depth_m:.0f} m"
)
lines.append(f"exposure CNS {report.cns:.0f}% | OTU {report.otus:.0f}")
if report.tts_variations is not None:
per_m = report.tts_variations.per_meter.total_seconds() / 60
per_min = report.tts_variations.per_minute.total_seconds() / 60
lines.append(
f"variations +{per_min:.1f} min per extra min, "
f"+{per_m:.1f} min per extra m"
)
return "\n".join(lines)
@staticmethod
def _table_rows(
report: DiveReport,
) -> list[tuple[str, float, timedelta, timedelta, Gas]]:
"""Fold deco travel/switches into stop rows; keep phase boundaries.
A DECO_ASCENT that departs from a stop is folded into the next row
(dive-table convention); the first deco ascent (from the bottom) and
the final surfacing keep their own rows. GAS_SWITCH segments always
fold into the following stop — the gas column marks the change.
"""
rows = report.rows
out: list[tuple[str, float, timedelta, timedelta, Gas]] = []
pending = timedelta(0)
previous_kind: str | None = None
for i, row in enumerate(rows):
is_last = i == len(rows) - 1
if row.kind == "DECO_ASCENT" and not is_last:
if previous_kind in _FOLDABLE_BEFORE_STOP:
pending += row.duration # travel between stops: fold
previous_kind = row.kind
continue
if row.kind == "GAS_SWITCH" and not is_last:
pending += row.duration # switch time counts as stop time
previous_kind = row.kind
continue
mark = _MARK.get(row.kind, "-")
out.append(
(
mark,
row.end_depth_m if mark in ("v", "^") else row.start_depth_m,
row.duration + pending,
row.runtime,
row.gas,
)
)
pending = timedelta(0)
previous_kind = row.kind
return out