Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Abysner - Dive planner
* Copyright (C) 2026 Neotech
*
* Abysner is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License version 3,
* as published by the Free Software Foundation.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

package org.neotech.app.abysner.presentation.utilities

import org.neotech.app.abysner.domain.core.model.UnitSystem
import org.neotech.app.abysner.domain.core.physics.LITERS_PER_CUBIC_FOOT
import org.neotech.app.abysner.domain.core.physics.PSI_PER_BAR
import org.neotech.app.abysner.domain.utilities.DecimalFormat
import kotlin.math.roundToInt

val UnitSystem.depthUnitLabel: String
get() = when (this) {
UnitSystem.METRIC -> "m"
UnitSystem.IMPERIAL -> "ft"
}

val UnitSystem.rateUnitLabel: String
get() = when (this) {
UnitSystem.METRIC -> "m/min"
UnitSystem.IMPERIAL -> "ft/min"
}

val UnitSystem.pressureUnitLabel: String
get() = when (this) {
UnitSystem.METRIC -> "bar"
UnitSystem.IMPERIAL -> "psi"
}

val UnitSystem.volumeUnitLabel: String
get() = when (this) {
UnitSystem.METRIC -> "L"
UnitSystem.IMPERIAL -> "ft³"
}

val UnitSystem.sacRateUnitLabel: String
get() = when (this) {
UnitSystem.METRIC -> "L/min"
UnitSystem.IMPERIAL -> "ft³/min"
}

/**
* Formats a depth value that is already in display-units (meters for metric, feet for imperial).
* Rounds to integer and optionally appends the unit suffix.
*/
fun Double.formatDisplayDepth(unitSystem: UnitSystem, includeUnit: Boolean = true): String {
val value = roundToInt()
return if (includeUnit) { "$value ${unitSystem.depthUnitLabel}" } else { value.toString() }
}

/**
* Converts a depth in meters to a formatted display string with optional unit suffix.
*/
fun Double.formatDepth(unitSystem: UnitSystem, includeUnit: Boolean = true): String {
val displayValue = unitSystem.metersToDisplayDepth(this).toInt()
return if (includeUnit) { "$displayValue ${unitSystem.depthUnitLabel}" } else { displayValue.toString() }
}

/**
* Formats a pressure value in bar to a display string. In imperial mode the value is converted
* to psi.
*/
fun Double.formatPressure(unitSystem: UnitSystem, includeUnit: Boolean = true): String {
val value = when (unitSystem) {
UnitSystem.METRIC -> roundToInt()
UnitSystem.IMPERIAL -> (this * PSI_PER_BAR).roundToInt()
}
return if (includeUnit) { "$value ${unitSystem.pressureUnitLabel}" } else { value.toString() }
}

/**
* Formats a volume value in liters to a display string. In imperial mode the value is converted
* to cubic feet.
*/
fun Double.formatVolume(unitSystem: UnitSystem, decimals: Int = 0, unit: String = unitSystem.volumeUnitLabel): String {
val value = when (unitSystem) {
UnitSystem.METRIC -> DecimalFormat.format(decimals, this)
UnitSystem.IMPERIAL -> DecimalFormat.format(decimals, this / LITERS_PER_CUBIC_FOOT)
}
return if (unit.isNotEmpty()) { "$value $unit" } else { value }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Abysner - Dive planner
* Copyright (C) 2026 Neotech
*
* Abysner is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License version 3,
* as published by the Free Software Foundation.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

package org.neotech.app.abysner.presentation.utilities

import org.neotech.app.abysner.domain.core.model.UnitSystem
import kotlin.test.Test
import kotlin.test.assertEquals

class UnitFormatterTest {

@Test
fun formatDisplayDepth_roundsAndAppendsUnit() {
assertEquals("30 m", 30.0.formatDisplayDepth(UnitSystem.METRIC))
assertEquals("100 ft", 100.0.formatDisplayDepth(UnitSystem.IMPERIAL))
}

@Test
fun formatDepth_convertsMetersToDisplayUnit() {
assertEquals("30 m", 30.48.formatDepth(UnitSystem.METRIC))
assertEquals("100 ft", 30.48.formatDepth(UnitSystem.IMPERIAL))
}


@Test
fun formatPressure_convertsBarToDisplayUnit() {
assertEquals("200 bar", 200.0.formatPressure(UnitSystem.METRIC))
assertEquals("2901 psi", 200.0.formatPressure(UnitSystem.IMPERIAL))
}

@Test
fun formatVolume_convertsLitersToDisplayUnit() {
assertEquals("2400 L", 2400.0.formatVolume(UnitSystem.METRIC))
assertEquals("100 ft³", 2831.6846592.formatVolume(UnitSystem.IMPERIAL))
}

@Test
fun formatVolume_respectsDecimalParameter() {
assertEquals("12.0 L", 12.0.formatVolume(UnitSystem.METRIC, decimals = 1))
assertEquals("1.0 ft³", 28.316846592.formatVolume(UnitSystem.IMPERIAL, decimals = 1))
}

@Test
fun formatVolume_supportsUnitOverride() {
assertEquals("20 L/min", 20.0.formatVolume(UnitSystem.METRIC, unit = UnitSystem.METRIC.sacRateUnitLabel))
assertEquals("1 ft³/min", 28.316846592.formatVolume(UnitSystem.IMPERIAL, unit = UnitSystem.IMPERIAL.sacRateUnitLabel))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
package org.neotech.app.abysner.domain.core.model

import org.neotech.app.abysner.domain.core.physics.GasEquationOfStateModel
import org.neotech.app.abysner.domain.core.physics.PSI_PER_BAR
import org.neotech.app.abysner.domain.utilities.DecimalFormat
import org.neotech.app.abysner.domain.utilities.generateUUID

Expand All @@ -30,6 +31,18 @@ data class Cylinder(
) {
fun fill(gas: Gas, pressure: Double = workingPressure) = Cylinder(gas, pressure, this)

/**
* Capacity in liters (uses real-gas properties based on Air at working pressure). This is
* what manufacturers might list on spec sheets as the cylinder's true capacity.
*
* For an exact fill capacity for a specific gas use: [Cylinder.capacity] or
* [Cylinder.capacityAt].
*/
fun ratedCapacity(
gasEquationOfStateModel: GasEquationOfStateModel = GasEquationOfStateModel.Default
): Double =
gasEquationOfStateModel.getGasVolume(Gas.Air, waterVolume, workingPressure)

companion object {

/**
Expand Down Expand Up @@ -97,6 +110,10 @@ data class Cylinder(

companion object {

private const val BAR_3000_PSI = 3000.0 / PSI_PER_BAR
private const val BAR_3300_PSI = 3300.0 / PSI_PER_BAR
private const val BAR_3442_PSI = 3442.0 / PSI_PER_BAR

// Common (EU) metric steel cylinders (not based on any specific manufacturer's specs, but
// very consistently available in Europe)

Expand All @@ -116,37 +133,48 @@ data class Cylinder(

// Aluminium cylinders (Luxfer specs)
// Source: https://scubapro.ae/wp-content/uploads/2015/03/Luxfer-Aluminum-Specifications.pdf

val AL6 = Size(name = "AL6", waterVolume = 0.9, workingPressure = 207.0)
val AL13 = Size(name = "AL13", waterVolume = 1.9, workingPressure = 207.0)
val AL19 = Size(name = "AL19", waterVolume = 2.9, workingPressure = 207.0)
val AL30 = Size(name = "AL30", waterVolume = 4.3, workingPressure = 207.0)
val AL40 = Size(name = "AL40", waterVolume = 5.7, workingPressure = 207.0)
val AL50 = Size(name = "AL50", waterVolume = 6.9, workingPressure = 207.0)
val AL63 = Size(name = "AL63", waterVolume = 9.0, workingPressure = 207.0)
val AL80 = Size(name = "AL80", waterVolume = 11.1, workingPressure = 207.0)
val AL100 = Size(name = "AL100", waterVolume = 13.2, workingPressure = 227.0)
// Note: Luxfer lists working pressures in both bar and PSI, but they don't round-trip
// exactly, since working pressure is more important in imperial mode, the exact PSI values
// are used as reference.

val AL6 = Size(name = "AL6", waterVolume = 0.9, workingPressure = BAR_3000_PSI)
val AL13 = Size(name = "AL13", waterVolume = 1.9, workingPressure = BAR_3000_PSI)
val AL19 = Size(name = "AL19", waterVolume = 2.9, workingPressure = BAR_3000_PSI)
val AL30 = Size(name = "AL30", waterVolume = 4.3, workingPressure = BAR_3000_PSI)
val AL40 = Size(name = "AL40", waterVolume = 5.7, workingPressure = BAR_3000_PSI)
val AL50 = Size(name = "AL50", waterVolume = 6.9, workingPressure = BAR_3000_PSI)
val AL63 = Size(name = "AL63", waterVolume = 9.0, workingPressure = BAR_3000_PSI)
val AL80 = Size(name = "AL80", waterVolume = 11.1, workingPressure = BAR_3000_PSI)
val AL100 = Size(name = "AL100", waterVolume = 13.2, workingPressure = BAR_3300_PSI)

// Imperial steel cylinders (Faber specs)
// Source: https://www.divegearexpress.com/library/articles/calculating-scuba-cylinder-capacities#truecapacitytable
// Note: Faber lists working pressures in both bar and PSI, but they don't round-trip
// exactly, since working pressure is more important in imperial mode, the exact PSI values
// are used as reference.

val HP80 = Size(name = "HP80", waterVolume = 10.2, workingPressure = 237.0)
val HP100 = Size(name = "HP100", waterVolume = 12.9, workingPressure = 237.0)
val HP117 = Size(name = "HP117", waterVolume = 15.0, workingPressure = 237.0)
val HP120 = Size(name = "HP120", waterVolume = 15.3, workingPressure = 237.0)
val HP133 = Size(name = "HP133", waterVolume = 17.0, workingPressure = 237.0)
val HP80 = Size(name = "HP80", waterVolume = 10.2, workingPressure = BAR_3442_PSI)
val HP100 = Size(name = "HP100", waterVolume = 12.9, workingPressure = BAR_3442_PSI)
val HP117 = Size(name = "HP117", waterVolume = 15.0, workingPressure = BAR_3442_PSI)
val HP120 = Size(name = "HP120", waterVolume = 15.3, workingPressure = BAR_3442_PSI)
val HP133 = Size(name = "HP133", waterVolume = 17.0, workingPressure = BAR_3442_PSI)

val StandardSizes = listOf(
val StandardMetricSizes = listOf(
STEEL_3L, STEEL_7L, STEEL_8_5L, STEEL_10L, STEEL_12L, STEEL_15L,
D7, D8_5, D10, D12,
)

val StandardImperialSizes = listOf(
AL40, AL63, AL80, AL100,
HP80, HP100, HP117, HP120, HP133,
)

val StandardSizes = StandardMetricSizes + StandardImperialSizes

fun steel3LiterOxygen(pressure: Double = 200.0) = STEEL_3L.fill(Gas.Oxygen, pressure)
fun steel10Liter(gas: Gas, pressure: Double = 232.0) = STEEL_10L.fill(gas, pressure)
fun steel12Liter(gas: Gas, pressure: Double = 232.0) = STEEL_12L.fill(gas, pressure)
fun aluminium80Cuft(gas: Gas, pressure: Double = 207.0) = AL80.fill(gas, pressure)
fun aluminium63Cuft(gas: Gas, pressure: Double = 207.0) = AL63.fill(gas, pressure)
fun aluminium80Cuft(gas: Gas, pressure: Double = BAR_3000_PSI) = AL80.fill(gas, pressure)
fun aluminium63Cuft(gas: Gas, pressure: Double = BAR_3000_PSI) = AL63.fill(gas, pressure)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@

package org.neotech.app.abysner.domain.core.model

import org.neotech.app.abysner.domain.core.physics.METERS_PER_FOOT
import org.neotech.app.abysner.domain.core.physics.Pressure
import org.neotech.app.abysner.domain.core.physics.ambientPressureToFeet
import org.neotech.app.abysner.domain.core.physics.ambientPressureToMeters
import org.neotech.app.abysner.domain.core.physics.feetToAmbientPressure
import org.neotech.app.abysner.domain.core.physics.metersToAmbientPressure
import kotlin.math.round
import kotlin.math.roundToInt

enum class UnitSystem {
/**
* Metric system for divers: meters, bar and liters.
Expand All @@ -21,5 +30,46 @@ enum class UnitSystem {
/**
* Imperial system for divers: feet, psi and cubic feet.
*/
IMPERIAL,
IMPERIAL;

fun depthToAmbientPressure(depth: Double, environment: Environment): Pressure {
return when(this) {
METRIC -> metersToAmbientPressure(depth, environment)
IMPERIAL -> feetToAmbientPressure(depth, environment)
}
}

fun ambientPressureToDepth(ambientPressure: Pressure, environment: Environment): Double {
return when(this) {
METRIC -> ambientPressureToMeters(ambientPressure.value, environment)
IMPERIAL -> ambientPressureToFeet(ambientPressure.value, environment)
}
}

/**
* Rounds a metric-meter value to the nearest whole display unit and converts back to meters.
* In metric mode this rounds to the nearest whole meter. In imperial mode it rounds to the
* nearest whole foot and converts back to meters.
*/
fun snapMetersToDisplayUnit(value: Double): Double = when (this) {
METRIC -> value.roundToInt().toDouble()
IMPERIAL -> (value / METERS_PER_FOOT).roundToInt().toDouble() * METERS_PER_FOOT
}

/**
* Converts a depth value in display units (meters or feet) to meters.
*/
fun displayDepthToMeters(displayDepth: Double): Double = when (this) {
METRIC -> displayDepth
IMPERIAL -> displayDepth * METERS_PER_FOOT
}

/**
* Converts a depth value in meters to display units (meters or feet), rounded to the nearest
* whole display unit.
*/
fun metersToDisplayDepth(meters: Double): Double = when (this) {
METRIC -> round(meters)
IMPERIAL -> round(meters / METERS_PER_FOOT)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,16 @@ import kotlin.math.ln
@JvmInline
value class Pressure(val value: Double) {
operator fun plus(other: Pressure): Pressure = Pressure(this.value + other.value)
operator fun plus(other: Double): Pressure = Pressure(this.value + other)
operator fun minus(other: Pressure): Pressure = Pressure(this.value - other.value)
operator fun times(factor: Double): Pressure = Pressure(this.value * factor)
operator fun div(divisor: Double): Pressure = Pressure(this.value / divisor)
operator fun compareTo(other: Pressure): Int = this.value.compareTo(other.value)
}

operator fun Double.plus(other: Pressure): Pressure = Pressure(this + other.value)
operator fun Double.times(other: Pressure): Pressure = Pressure(this * other.value)

/**
* Calculates the partial pressure of an individual gas component from the total pressure of the gas
* mixture, using a volume fraction.
Expand All @@ -61,9 +66,9 @@ fun pascalToBar(pascals: Double): Double = pascals / 100000.0
*/
fun barToPascal(bars: Double): Double = bars * 100000.0

fun Double.asPsiToBar(): Double = this / 14.503774
fun Double.asPsiToBar(): Double = this / PSI_PER_BAR

fun Double.asBarToPsi(): Double = this * 14.503774
fun Double.asBarToPsi(): Double = this * PSI_PER_BAR

/**
* Converts depth in meters given a certain density (salinity) and atmospheric pressure to pressure
Expand Down Expand Up @@ -180,4 +185,14 @@ internal const val GRAVITY_ON_EARTH = 9.80665
/**
* Constant for foot to meter conversion, according to international standard.
*/
private const val METERS_PER_FOOT = 0.3048
const val METERS_PER_FOOT = 0.3048

/**
* Conversion factor from bar to psi (pounds per square inch).
*/
const val PSI_PER_BAR = 14.503773773

/**
* Conversion factor from liters to cubic feet.
*/
const val LITERS_PER_CUBIC_FOOT = 28.316846592
Loading
Loading