Source code for diveplan.models.base

"""Decompression model base classes.

Each deco model defines its own :class:`DecoState` subclass — the typed,
copyable snapshot of everything the model knows at an instant (tissue
tensions, ceiling, …). Models are generic over their state type, so
``ZHL16C.integrate_segment(...)`` returns a ``ZHL16State``, not a bare dict.
State snapshots are what the result/report layer stores at checkpoints and
what counterfactual queries (TTS at time t) resume from.
"""

from abc import ABC, abstractmethod
from datetime import timedelta
from typing import Any, ClassVar, Self

from diveplan.core.dive_segment import DiveSegment
from diveplan.core.gas import Gas
from diveplan.core.pressure import Pressure

__all__ = ["BaseDecoModel", "DecoState"]


[docs] class DecoState: """Base class for model-specific decompression state snapshots. Subclasses should be immutable value objects (frozen dataclass or ``__slots__`` with guards) so they can be checkpointed, compared, and resumed from without defensive copying. """ __slots__ = ()
[docs] class BaseDecoModel[StateT: DecoState](ABC): """Base class for all decompression models. Generic over the model's :class:`DecoState` subclass: each model type processes and returns its own state type. """ __slots__ = ("sample_rate_seconds",) NAME: ClassVar[str] @abstractmethod def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the decompression model. Concrete models define their own configuration parameters (gradient factors, conservatism, …); this permissive abstract signature is what lets registry-typed construction (``registry.model(name)(**config)``) type-check. Arguments are still validated at runtime by the concrete ``__init__`` — do not forward them here. """ self.sample_rate_seconds = 1 @property def name(self) -> str: """Display name of this model instance, conservatism included. Defaults to the registry ``NAME``. Models with a conservatism setting (gradient factors, VPM conservatism level) append it, so the name alone says how a schedule was computed — e.g. ``"zhl16c GF 30/70"`` or ``"vpmb +3"``. """ return getattr(type(self), "NAME", type(self).__name__)
[docs] def integrate_segment(self, segment: DiveSegment) -> StateT: """Integrate a dive segment into the model and return the state after it. Numerical scheme: rectangle rule — each sample step is integrated at the pressure of its end point over the actual elapsed dt (the final step may be shorter than the sample rate). Models with an analytic solution for linear pressure change (Schreiner) should override this. """ interval = timedelta(seconds=self.sample_rate_seconds) previous = timedelta(0) for elapsed, pressure in segment.iter_pressures(interval): dt = elapsed - previous if dt > timedelta(0): self._integrate_model(pressure, segment.gas, dt) previous = elapsed return self._get_deco_state()
[docs] def get_state(self) -> StateT: """Snapshot the current model state (public accessor).""" return self._get_deco_state()
[docs] def integrate(self, pressure: Pressure, gas: Gas, dt: timedelta) -> None: """Advance the model by a single step at the given pressure and gas. Public stepwise entry point for consumers that drive their own sample loop (result layer, planner); ``integrate_segment`` remains the segment-level API. """ self._integrate_model(pressure, gas, dt)
@abstractmethod def _integrate_model(self, pressure: Pressure, gas: Gas, dt: timedelta) -> None: """Advance the model by dt at the given ambient pressure and gas.""" @abstractmethod def _get_deco_state(self) -> StateT: """Snapshot the current model state."""
[docs] @abstractmethod def get_ceiling(self) -> Pressure: """Return the current (conservative) ceiling. Anything the ceiling depends on beyond tissue state — gradient factors, conservatism level — is instance configuration, baked in at construction. Overrides keep this exact signature. """
[docs] def get_ascent_ceiling( self, target: Pressure, first_stop: Pressure | None = None ) -> Pressure: """Ceiling for testing an ascent to `target` during staged deco. The ascent planner calls this — never a model-specific API — so models whose tolerance evolves over the ascent can express that here: Bühlmann interpolates its gradient factor toward `target`, and VPM-B's Boyle/CVA compensation will land here too. `first_stop` is the first (deepest) stop of the ascent being planned, or None while it is not yet known. Default: the plain :meth:`get_ceiling`, which is correct for any model whose tolerance does not depend on ascent context. """ return self.get_ceiling()
[docs] @abstractmethod def set_state(self, state: StateT) -> None: """Restore the model to a previously snapshotted state (lossless). Together with :meth:`copy`, this is the checkpointing contract the result layer relies on for state-at-time and counterfactual (TTS) queries. """
[docs] @abstractmethod def copy(self) -> Self: """Independent clone with identical configuration and current state."""