diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/UnitConversions.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/UnitConversions.kt index a372ef2f1..9582e2ea9 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/UnitConversions.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/UnitConversions.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -14,21 +14,31 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.model.util import kotlin.math.ln +import kotlin.math.roundToInt object UnitConversions { @Suppress("MagicNumber") fun celsiusToFahrenheit(celsius: Float): Float = (celsius * 1.8F) + 32 - fun Float.toTempString(isFahrenheit: Boolean) = if (isFahrenheit) { - val fahrenheit = celsiusToFahrenheit(this) - "%.0f°F".format(fahrenheit) - } else { - "%.0f°C".format(this) + /** Formats temperature as a string with the unit suffix. */ + fun Float.toTempString(isFahrenheit: Boolean): String { + val temp = if (isFahrenheit) celsiusToFahrenheit(this) else this + val unit = if (isFahrenheit) "F" else "C" + + // Convoluted calculation due to edge case: rounding negative values. + // We round the absolute value using roundToInt() (banker's rounding), then reapply the sign so values + val absoluteTemp: Float = kotlin.math.abs(temp) + val roundedAbsoluteTemp: Int = absoluteTemp.roundToInt() + + val isZero = roundedAbsoluteTemp == 0 + val isPositive = kotlin.math.sign(temp) > 0 + val sign: String = if (isPositive || isZero) "" else "-" + + return "$sign$roundedAbsoluteTemp°$unit" } /** diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt new file mode 100644 index 000000000..16424f7fb --- /dev/null +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshtastic.core.model.util.UnitConversions.toTempString + +class UnitConversionsTest { + + // Test data: (celsius, isFahrenheit, expected) + private val tempTestCases = + listOf( + // Issue #4150: negative zero should display as "0" + Triple(-0.1f, false, "0°C"), + Triple(-0.2f, false, "0°C"), + Triple(-0.4f, false, "0°C"), + Triple(-0.49f, false, "0°C"), + // Boundary: -0.5 rounds to -1 + Triple(-0.5f, false, "-1°C"), + Triple(-0.9f, false, "-1°C"), + Triple(-1.0f, false, "-1°C"), + // Zero and small positives + Triple(0.0f, false, "0°C"), + Triple(0.1f, false, "0°C"), + Triple(0.4f, false, "0°C"), + // Typical values + Triple(1.0f, false, "1°C"), + Triple(20.0f, false, "20°C"), + Triple(25.4f, false, "25°C"), + Triple(25.5f, false, "26°C"), + // Negative + Triple(-5.0f, false, "-5°C"), + Triple(-10.0f, false, "-10°C"), + Triple(-20.4f, false, "-20°C"), + // Fahrenheit conversions + Triple(0.0f, true, "32°F"), + Triple(20.0f, true, "68°F"), + Triple(25.0f, true, "77°F"), + Triple(100.0f, true, "212°F"), + Triple(-40.0f, true, "-40°F"), // -40°C = -40°F + // Issue #4150: negative zero in Fahrenheit + Triple(-0.1f, true, "32°F"), + Triple(-17.78f, true, "0°F"), + ) + + @Test + fun `toTempString formats all temperatures correctly`() { + tempTestCases.forEach { (celsius, isFahrenheit, expected) -> + assertEquals( + "Failed for $celsius°C (Fahrenheit=$isFahrenheit)", + expected, + celsius.toTempString(isFahrenheit), + ) + } + } + + @Test + fun `toTempString handles extreme temperatures`() { + assertEquals("100°C", 100.0f.toTempString(false)) + assertEquals("-40°C", (-40.0f).toTempString(false)) + assertEquals("-40°F", (-40.0f).toTempString(true)) + } + + @Test + fun `celsiusToFahrenheit converts correctly`() { + mapOf( + 0.0f to 32.0f, + 20.0f to 68.0f, + 100.0f to 212.0f, + -40.0f to -40.0f, + ).forEach { (celsius, expectedFahrenheit) -> + assertEquals(expectedFahrenheit, UnitConversions.celsiusToFahrenheit(celsius), 0.01f) + } + } + + @Test + fun `calculateDewPoint returns expected values`() { + // At 100% humidity, dew point equals temperature + assertEquals(20.0f, UnitConversions.calculateDewPoint(20.0f, 100.0f), 0.1f) + + // Known reference: 20°C at 60% humidity ≈ 12°C dew point + assertEquals(12.0f, UnitConversions.calculateDewPoint(20.0f, 60.0f), 0.5f) + + // Higher humidity = higher dew point + val highHumidity = UnitConversions.calculateDewPoint(25.0f, 80.0f) + val lowHumidity = UnitConversions.calculateDewPoint(25.0f, 40.0f) + assertTrue("Dew point should be higher at higher humidity", highHumidity > lowHumidity) + } + + @Test + fun `calculateDewPoint handles edge cases`() { + // 0% humidity results in NaN (ln(0) = -Infinity, causing invalid calculation) + val zeroHumidity = UnitConversions.calculateDewPoint(20.0f, 0.0f) + assertTrue("Expected NaN for 0% humidity", zeroHumidity.isNaN()) + } +}