Skip to content

Commit ee64701

Browse files
authored
Refactor: Extract grid logic out of DecompressionPlanner (#172)
Moved grid-related state and methods into a standalone `DecoGrid` class. `DecompressionPlanner` now accepts a `DecoGrid` instance and delegates grid operations to it.
1 parent 72b9e98 commit ee64701

5 files changed

Lines changed: 365 additions & 189 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* Abysner - Dive planner
3+
* Copyright (C) 2026 Neotech
4+
*
5+
* Abysner is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License version 3,
7+
* as published by the Free Software Foundation.
8+
*
9+
* You should have received a copy of the GNU Affero General Public License
10+
* along with this program. If not, see https://www.gnu.org/licenses/.
11+
*/
12+
13+
package org.neotech.app.abysner.domain.decompression
14+
15+
import org.neotech.app.abysner.domain.utilities.ceilTolerant
16+
import org.neotech.app.abysner.domain.utilities.equalsTolerant
17+
import org.neotech.app.abysner.domain.utilities.greaterThanTolerant
18+
import org.neotech.app.abysner.domain.utilities.lessThanOrEqualTolerant
19+
import org.neotech.app.abysner.domain.utilities.lessThanTolerant
20+
import kotlin.math.floor
21+
import kotlin.math.round
22+
23+
/**
24+
* While the decompression algorithm calculates the required deco stops and gas switches at any
25+
* arbitrary pressure, divers plan and work in whole meters or feet, and for gas switches and
26+
* decompression stops in steps of 3 meter or 10 feet.
27+
*
28+
* The DecoGrid encapsulates this grid-thinking logic, and allows configuring a grid for different
29+
* display units (meters, feet or anything custom).
30+
*/
31+
class DecoGrid(
32+
val surfacePressure: Double,
33+
/**
34+
* The deco step size in pressure (for example about 0.3 bar for 3m steps) used to determine at
35+
* which pressures deco stops are required. Must be an exact multiple of
36+
* [displayUnitPressureDelta].
37+
*/
38+
val decoStepSizePressureDelta: Double,
39+
/**
40+
* The ambient pressure of the last allowed deco stop, must fall on a
41+
* [displayUnitPressureDelta] grid point relative to [surfacePressure].
42+
*/
43+
val lastDecoStopAmbientPressure: Double,
44+
/**
45+
* The pressure delta that corresponds to the smallest display unit step (for example about
46+
* 0.1 bar for steps of 1 meter).
47+
*/
48+
val displayUnitPressureDelta: Double,
49+
) {
50+
51+
init {
52+
// Both decoStepSizePressureDelta and lastDecoStopAmbientPressure must align to
53+
// displayUnitPressureDelta so that deco stops always land on whole display units (e.g.
54+
// 3, 6 or 9 meter instead of 2.9, 5.8 or 8.7 meter).
55+
val decoStepInDisplayUnits = decoStepSizePressureDelta / displayUnitPressureDelta
56+
require(decoStepInDisplayUnits.equalsTolerant(round(decoStepInDisplayUnits))) {
57+
"decoStepSizePressureDelta ($decoStepSizePressureDelta) must be an exact multiple of displayUnitPressureDelta ($displayUnitPressureDelta)."
58+
}
59+
val lastDecoStopDepthPressure = lastDecoStopAmbientPressure - surfacePressure
60+
val lastDecoStopInDisplayUnits = lastDecoStopDepthPressure / displayUnitPressureDelta
61+
require(lastDecoStopInDisplayUnits.equalsTolerant(round(lastDecoStopInDisplayUnits))) {
62+
"lastDecoStopAmbientPressure ($lastDecoStopAmbientPressure) must fall on a display unit grid point relative to surfacePressure ($surfacePressure)."
63+
}
64+
}
65+
66+
/**
67+
* Snaps a raw ceiling pressure to the nearest deeper deco stop pressure on the grid, clamping
68+
* to [lastDecoStopAmbientPressure] if the result falls between [surfacePressure] and the last
69+
* allowed stop. Returns [surfacePressure] when the ceiling is at or above the surface.
70+
*/
71+
fun snapCeilingToDecoGrid(rawCeilingPressure: Double): Double {
72+
if (rawCeilingPressure.lessThanOrEqualTolerant(surfacePressure)) {
73+
return surfacePressure
74+
}
75+
76+
val depthPressure = rawCeilingPressure - surfacePressure
77+
78+
// Snap (ceil) to the deco grid (e.g. 3 meter or 10 feet increments). Because
79+
// decoStepSizePressureDelta is guaranteed to be an exact multiple of
80+
// displayUnitPressureDelta, this always lands on a display-unit boundary too.
81+
val decoGridSteps = ceilTolerant(depthPressure / decoStepSizePressureDelta).toInt()
82+
if (decoGridSteps <= 0) {
83+
return surfacePressure
84+
}
85+
val snappedPressure = surfacePressure + decoGridSteps * decoStepSizePressureDelta
86+
87+
// If the deco stop falls between surface and the last allowed deco stop depth, clamp to
88+
// the last allowed deco stop.
89+
return if (snappedPressure.lessThanTolerant(lastDecoStopAmbientPressure) && snappedPressure.greaterThanTolerant(surfacePressure)) {
90+
lastDecoStopAmbientPressure
91+
} else {
92+
snappedPressure
93+
}
94+
}
95+
96+
/**
97+
* Returns the next shallower deco stop pressure that is shallower than [fromAmbientPressure].
98+
* If the given ambient pressure is already on a deco stop pressure, returns one step shallower.
99+
* Returns [surfacePressure] when the next shallower deco stop would fall between
100+
* [surfacePressure] and [lastDecoStopAmbientPressure].
101+
*/
102+
fun findNextDecoStopPressure(fromAmbientPressure: Double): Double {
103+
val depthPressure = fromAmbientPressure - surfacePressure
104+
if (depthPressure.lessThanOrEqualTolerant(0.0)) {
105+
return surfacePressure
106+
}
107+
108+
val steps = depthPressure / decoStepSizePressureDelta
109+
val nearestWholeStep = round(steps)
110+
val isOnGrid = steps.equalsTolerant(nearestWholeStep)
111+
112+
// If exactly on a deco-grid point, go one step shallower, so we select the next stop. If
113+
// not, we are in between deco-grid points. In that case the next stop is the nearest
114+
// shallower one, so use floor to find it.
115+
val nextStep = if (isOnGrid) {
116+
nearestWholeStep.toInt() - 1
117+
} else {
118+
// No need for tolerance, if there was any floating-point noise the isOnGrid check would have caught it
119+
floor(steps).toInt()
120+
}
121+
if (nextStep <= 0) {
122+
// No more deco stops, next step on the grid is surface, return early and exactly
123+
return surfacePressure
124+
}
125+
126+
val nextPressure = surfacePressure + nextStep * decoStepSizePressureDelta
127+
128+
// If the next stop falls between surface and the configured last deco stop depth, skip to
129+
// surface (the dive was configured to not stop in between the surface and
130+
// lastDecoStopAmbientPressure).
131+
return if (nextPressure.lessThanTolerant(lastDecoStopAmbientPressure)) {
132+
surfacePressure
133+
} else {
134+
nextPressure
135+
}
136+
}
137+
138+
/**
139+
* Returns true if [ambientPressure] falls on a deco stop level.
140+
*/
141+
fun isAtDecoStop(ambientPressure: Double): Boolean {
142+
val steps = (ambientPressure - surfacePressure) / decoStepSizePressureDelta
143+
val nearestWholeStep = round(steps)
144+
// If nearest whole step is within tolerance to the actual step we are at, the diver is on a
145+
// deco grid point.
146+
return steps.equalsTolerant(nearestWholeStep)
147+
}
148+
}

0 commit comments

Comments
 (0)