Skip to content

Commit 80009a5

Browse files
authored
Add: Imperial support to DivePlanner by passing the UnitSystem enum (#179)
`DivePlanner.addDive()` is now aware of the `UnitSystem`. When `UnitSystem.IMPERIAL` is active, output depths are produced in feet and deco stops align to the 10 feet grid. Required for: #49
1 parent e2052a2 commit 80009a5

3 files changed

Lines changed: 85 additions & 15 deletions

File tree

domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/Configuration.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,24 @@ data class Configuration(
152152
max(ceilTolerant(pressureDifference / rate).toInt(), 1)
153153
}
154154
}
155+
156+
/**
157+
* Snaps all depth-related configuration values to whole display units for the given
158+
* [UnitSystem]. For example a stored value of 3.0 meters would in imperial otherwise become
159+
* 9.84251969 feet, while we need to be using exactly 10 feet to align with the display and
160+
* diver expectations (align to 10 feet grid). The configuration returned by this method remains
161+
* in meters, but the values might be fractions now so that when converted to display-units they
162+
* align to the display unit grid.
163+
*/
164+
fun snapToDisplayUnit(unitSystem: UnitSystem): Configuration = copy(
165+
decoStepSize = unitSystem.snapMetersToDisplayUnit(decoStepSize),
166+
lastDecoStopDepth = unitSystem.snapMetersToDisplayUnit(lastDecoStopDepth),
167+
maxAscentRate = unitSystem.snapMetersToDisplayUnit(maxAscentRate),
168+
maxDescentRate = unitSystem.snapMetersToDisplayUnit(maxDescentRate),
169+
maxEND = unitSystem.snapMetersToDisplayUnit(maxEND),
170+
contingencyDeeper = unitSystem.snapMetersToDisplayUnit(contingencyDeeper),
171+
altitude = unitSystem.snapMetersToDisplayUnit(altitude),
172+
ccrToHighSetpointSwitchDepth = ccrToHighSetpointSwitchDepth?.let { unitSystem.snapMetersToDisplayUnit(it) },
173+
ccrToLowSetpointSwitchDepth = ccrToLowSetpointSwitchDepth?.let { unitSystem.snapMetersToDisplayUnit(it) },
174+
)
155175
}

domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/diveplanning/DivePlanner.kt

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ import org.neotech.app.abysner.domain.core.model.BreathingMode
1919
import org.neotech.app.abysner.domain.core.model.Configuration
2020
import org.neotech.app.abysner.domain.core.model.DiveMode
2121
import org.neotech.app.abysner.domain.core.model.SetpointSwitch
22+
import org.neotech.app.abysner.domain.core.model.UnitSystem
23+
import org.neotech.app.abysner.domain.core.physics.ambientPressureToFeet
2224
import org.neotech.app.abysner.domain.core.physics.ambientPressureToMeters
25+
import org.neotech.app.abysner.domain.core.physics.feetToHydrostaticPressure
2326
import org.neotech.app.abysner.domain.core.physics.metersToAmbientPressure
2427
import org.neotech.app.abysner.domain.core.physics.metersToHydrostaticPressure
2528
import org.neotech.app.abysner.domain.decompression.DecoGrid
@@ -39,10 +42,11 @@ import kotlin.time.Duration
3942
* adds multi-level dive handling.
4043
*/
4144
class DivePlanner(
42-
configuration: Configuration = Configuration()
45+
configuration: Configuration = Configuration(),
46+
private val unitSystem: UnitSystem = UnitSystem.METRIC,
4347
) {
4448

45-
var configuration: Configuration = configuration
49+
var configuration: Configuration = configuration.snapToDisplayUnit(unitSystem)
4650
private set
4751

4852
private var model: DecompressionModel = createDecompressionModel()
@@ -53,7 +57,7 @@ class DivePlanner(
5357
*/
5458
fun updateConfiguration(newConfiguration: Configuration) {
5559
val snapshot = model.snapshot()
56-
configuration = newConfiguration
60+
configuration = newConfiguration.snapToDisplayUnit(unitSystem)
5761
model = createDecompressionModel()
5862
model.reset(snapshot)
5963
}
@@ -69,6 +73,7 @@ class DivePlanner(
6973
cylinders: List<AssignedCylinder>,
7074
diveMode: DiveMode = DiveMode.OPEN_CIRCUIT,
7175
bailout: Boolean = false,
76+
unitSystem: UnitSystem = UnitSystem.METRIC,
7277
): DivePlan {
7378

7479
require(!bailout || diveMode.isCcr) {
@@ -99,7 +104,7 @@ class DivePlanner(
99104
)
100105
}
101106

102-
val decompressionPlanner = createDecompressionPlanner(model, configuration)
107+
val decompressionPlanner = createDecompressionPlanner(model, configuration, unitSystem)
103108

104109
decompressionPlanner.setDecoGases(decoCylinders)
105110

@@ -255,14 +260,24 @@ class DivePlanner(
255260
private fun createDecompressionPlanner(
256261
model: DecompressionModel,
257262
configuration: Configuration,
263+
unitSystem: UnitSystem = UnitSystem.METRIC,
258264
): DecompressionPlanner {
259265
val environment = configuration.environment
260266
val grid = DecoGrid(
261267
surfacePressure = environment.atmosphericPressure,
262268
decoStepSizePressureDelta = metersToHydrostaticPressure(configuration.decoStepSize, environment).value,
263269
lastDecoStopAmbientPressure = metersToAmbientPressure(configuration.lastDecoStopDepth, environment).value,
264-
displayUnitPressureDelta = metersToHydrostaticPressure(1.0, environment).value,
270+
displayUnitPressureDelta = when (unitSystem) {
271+
UnitSystem.METRIC -> metersToHydrostaticPressure(1.0, environment).value
272+
UnitSystem.IMPERIAL -> feetToHydrostaticPressure(1.0, environment).value
273+
},
265274
)
275+
// removeFloatingPointNoise makes test assertions easier to read since depths are
276+
// usually whole numbers without requiring tolerance handling at every call site.
277+
val pressureToDepth: (Double) -> Double = when (unitSystem) {
278+
UnitSystem.METRIC -> { pressure -> removeFloatingPointNoise(ambientPressureToMeters(pressure, environment)) }
279+
UnitSystem.IMPERIAL -> { pressure -> removeFloatingPointNoise(ambientPressureToFeet(pressure, environment)) }
280+
}
266281
return DecompressionPlanner(
267282
model = model,
268283
grid = grid,
@@ -271,14 +286,7 @@ class DivePlanner(
271286
ascentRatePressureDelta = metersToHydrostaticPressure(configuration.maxAscentRate, environment).value,
272287
forceMinimalDecoStopTime = configuration.forceMinimalDecoStopTime,
273288
gasSwitchTime = configuration.gasSwitchTime,
274-
pressureToDepth = { pressure ->
275-
// This is not strictly required to make the UI work, since the UI already formats
276-
// to zero decimals or 1 maybe 2 decimals at most. However, it makes the current
277-
// test assertions easier to read, since these are usually in whole meters, without
278-
// this noise normalization tolerance handling at assertion call sites would be
279-
// required, or more precise less readable floating point numbers.
280-
removeFloatingPointNoise(ambientPressureToMeters(pressure, environment))
281-
},
289+
pressureToDepth = pressureToDepth,
282290
)
283291
}
284292

@@ -314,5 +322,3 @@ class DivePlanner(
314322

315323
class NotEnoughTimeToDecompress : PlanningException()
316324
}
317-
318-

domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/diveplanning/DivePlannerTest.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import org.neotech.app.abysner.domain.core.model.Cylinder
2020
import org.neotech.app.abysner.domain.core.model.DiveMode
2121
import org.neotech.app.abysner.domain.core.model.Gas
2222
import org.neotech.app.abysner.domain.core.model.Salinity
23+
import org.neotech.app.abysner.domain.core.model.UnitSystem
24+
import org.neotech.app.abysner.domain.core.physics.METERS_PER_FOOT
2325
import org.neotech.app.abysner.domain.decompression.model.DiveSegment
2426
import org.neotech.app.abysner.domain.diveplanning.model.DiveProfileSection
2527
import org.neotech.app.abysner.domain.diveplanning.model.assign
@@ -512,6 +514,48 @@ class DivePlannerTest {
512514
plan.assertSegment(7, DiveSegment.Type.GAS_SWITCH, startDepth = 6.0, endDepth = 6.0, duration = 1, gas = decoGas80)
513515
plan.assertSegment(8, DiveSegment.Type.ASCENT, startDepth = 6.0, endDepth = 3.0, duration = 1, gas = decoGasO2)
514516
}
517+
518+
@Test
519+
fun referencePlanImperial_producesSegmentsInFeetWithTenFootDecoStops() {
520+
val divePlanner = DivePlanner(
521+
configuration = Configuration(
522+
maxAscentRate = 10.0 * METERS_PER_FOOT,
523+
maxDescentRate = 20.0 * METERS_PER_FOOT,
524+
gfLow = 0.3,
525+
gfHigh = 0.7,
526+
salinity = Salinity.WATER_SALT,
527+
algorithm = Algorithm.BUHLMANN_ZH16C,
528+
altitude = 0.0,
529+
decoStepSize = 10.0 * METERS_PER_FOOT,
530+
lastDecoStopDepth = 10.0 * METERS_PER_FOOT,
531+
gasSwitchTime = 1,
532+
),
533+
unitSystem = UnitSystem.IMPERIAL,
534+
)
535+
536+
val bottomGas = Cylinder.steel12Liter(Gas.Air)
537+
538+
val divePlan = divePlanner.addDive(
539+
plan = listOf(DiveProfileSection(duration = 25, 100.0 * METERS_PER_FOOT, bottomGas)),
540+
cylinders = listOf(bottomGas, Cylinder.aluminium80Cuft(Gas.Nitrox50)).assign(),
541+
unitSystem = UnitSystem.IMPERIAL,
542+
)
543+
val plan = divePlan.segmentsCollapsed
544+
545+
// Verify the decent and bottom sections are in feet
546+
plan.assertSegment(index = 0, type = DiveSegment.Type.DECENT, startDepth = 0.0, endDepth = 100.0, duration = 5, gas = bottomGas)
547+
plan.assertSegment(index = 1, type = DiveSegment.Type.FLAT, startDepth = 100.0, endDepth = 100.0, duration = 20, gas = bottomGas)
548+
549+
// Verify all stop and gas switch depths align to the 10 feet grid
550+
plan.filter {
551+
it.type == DiveSegment.Type.DECO_STOP || it.type == DiveSegment.Type.GAS_SWITCH
552+
}.forEach {
553+
assertEquals(0.0, it.endDepth % 10.0)
554+
}
555+
556+
// Verify the dive ends at the surface (0 feet)
557+
assertEquals(0.0, plan.last().endDepth)
558+
}
515559
}
516560

517561
fun List<DiveSegment>.assertSegment(

0 commit comments

Comments
 (0)