diff --git a/src/main/kotlin/com/cognite/units/UnitService.kt b/src/main/kotlin/com/cognite/units/UnitService.kt index 3b3767a1..9c45ea97 100644 --- a/src/main/kotlin/com/cognite/units/UnitService.kt +++ b/src/main/kotlin/com/cognite/units/UnitService.kt @@ -23,6 +23,7 @@ import java.net.URL import kotlin.math.abs import kotlin.math.ceil import kotlin.math.log10 +import kotlin.math.nextDown import kotlin.math.pow import kotlin.math.roundToLong @@ -231,12 +232,42 @@ class UnitService(units: String, systems: String) { return roundToSignificantDigits(targetUnitValue, 12) } + // Find the range of numbers that would convert to the given valueTo in the target unit + fun convertBetweenUnitsInverseRange(unitFrom: TypedUnit, unitTo: TypedUnit, valueTo: Double): Pair { + if (unitFrom == unitTo) { + return valueTo to valueTo // avoid rounding errors + } + verifyIsConvertible(unitFrom, unitTo) + val roundingValues = roundingNeighbors(valueTo, 12) + // roundedTarget is the aim: we want to find everything that would convert and round to this value + val roundedTarget = roundingValues.rounded + // Find the bounds for the target value. Anything between these bounds would round to roundedTarget + val lowerBoundTo = (roundingValues.nextLower + roundedTarget) / 2 + val upperBoundTo = (roundedTarget + roundingValues.nextUpper) / 2 + // Do the reverse conversion to find the corresponding bounds in the source unit + val lowerBaseUnit = convertBetweenUnits(unitTo, unitFrom, lowerBoundTo) + val upperBaseUnit = convertBetweenUnits(unitTo, unitFrom, upperBoundTo) + // Because the bounds can not be represented exactly, we do one more check to see if we are correct. + // If they do not convert to the exact roundedTarget, we must use the neighbor number instead. + val adjustedLower = if (convertBetweenUnits(unitFrom, unitTo, lowerBaseUnit) == roundedTarget) { + lowerBaseUnit + } else { + roundingNeighbors(lowerBaseUnit, 12).nextUpper + } + val adjustedUpper = if (convertBetweenUnits(unitFrom, unitTo, upperBaseUnit) == roundedTarget) { + upperBaseUnit + } else { + roundingNeighbors(upperBaseUnit, 12).nextLower + } + return adjustedLower to adjustedUpper + } + /* * Conversion factors can't always be represented exactly in floating point. Also, some arithmetics may result * in numbers like 0.9999999999999999 which should be rounded to 1.0. * This function rounds to the specified number of significant digits. */ - private fun roundToSignificantDigits(value: Double, significantDigits: Int): Double { + fun roundToSignificantDigits(value: Double, significantDigits: Int): Double { if (value == 0.0 || !value.isFinite()) { return value } @@ -247,6 +278,30 @@ class UnitService(units: String, systems: String) { return shifted / magnitude } + data class RoundingValues( + val nextLower: Double, + val rounded: Double, + val nextUpper: Double, + ) + + /* Find the rounding neighbors. Eg. with 2 significant digits, + * the neighbors of 1.234 are (1.22, 1.24). (First round to 1.23, then find neighbors.) + */ + fun roundingNeighbors(value: Double, significantDigits: Int) : RoundingValues{ + if (value == 0.0 || !value.isFinite()) { + return RoundingValues(value, value, value) + } + val digits = ceil(log10(abs(value))) + val power = significantDigits - digits + val magnitude = 10.0.pow(power) + val shifted = (value * magnitude).roundToLong() + return RoundingValues( + (shifted - 1) / magnitude, + shifted / magnitude, + (shifted + 1) / magnitude, + ) + } + fun isValidUnit(unitExternalId: String): Boolean { return unitsByExternalId.containsKey(unitExternalId) } diff --git a/src/test/kotlin/UnitTest.kt b/src/test/kotlin/UnitTest.kt index db9bc125..38c88ae8 100644 --- a/src/test/kotlin/UnitTest.kt +++ b/src/test/kotlin/UnitTest.kt @@ -22,7 +22,10 @@ import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.net.URL +import kotlin.math.pow +import kotlin.random.Random import kotlin.test.DefaultAsserter +import kotlin.test.assertNotEquals class UnitTest { @@ -55,6 +58,81 @@ class UnitTest { assertEquals(0.555555555556, unitService.convertBetweenUnits(unitFahrenheit, unitCelsius, 33.0)) } + @Test + fun testConversionRanges() { + val unitService = UnitService.service + val unitCelsius = unitService.getUnitByExternalId("temperature:deg_c") + val unitFahrenheit = unitService.getUnitByExternalId("temperature:deg_f") + + // Generate random numbers with a wide range of magnitudes + fun randomTemperature() : Double { + val power = Random.nextInt(-10, 12) + val targetTemperature = Random.nextDouble(-1.0, 1.0) * 10.0.pow(power.toDouble()) + val roundedTarget = unitService.roundToSignificantDigits(targetTemperature, 12) + return roundedTarget + } + + repeat (100) { + val temperature = randomTemperature() + println("Testing temperature: $temperature") + for ((targetUnit, sourceUnit) in listOf( + Pair(unitCelsius, unitFahrenheit), + Pair(unitFahrenheit, unitCelsius), + )) { + val (lowerBound, upperBound) = unitService + .convertBetweenUnitsInverseRange(sourceUnit, targetUnit, temperature) + if (lowerBound > upperBound) { + println("Not possible to reach temperature. SourceUnit: ${sourceUnit.name} Lower bound: $lowerBound, Upper bound: $upperBound, Temperature: $temperature") + continue + } + assertEquals( + temperature, + unitService.convertBetweenUnits(sourceUnit, targetUnit, lowerBound), + ) + assertEquals( + temperature, + unitService.convertBetweenUnits(sourceUnit, targetUnit, upperBound), + ) + assertNotEquals( + temperature, + unitService.convertBetweenUnits( + sourceUnit, + targetUnit, + unitService.roundingNeighbors(lowerBound, 12).nextLower), + 0.0, + "Failed for temperature $temperature, lowerBound $lowerBound, sourceUnit ${sourceUnit.name}" + ) + assertNotEquals( + temperature, + unitService.convertBetweenUnits( + sourceUnit, + targetUnit, + unitService.roundingNeighbors(upperBound, 12).nextUpper), + 0.0, + "Failed for temperature $temperature, upperBound $upperBound, sourceUnit ${sourceUnit.name}", + ) + println("OK conversion from ${sourceUnit.name} for temperature $temperature") + } + } + } + + @Test + fun blah() { + val unitService = UnitService.service + val unitCelsius = unitService.getUnitByExternalId("temperature:deg_c") + val unitFahrenheit = unitService.getUnitByExternalId("temperature:deg_f") + + val temperature = -196.593259651 + val upperTemp = -196.5932596505 + val upperBound = -321.867867372 + val nextUpperB = -321.867867371 + println("Converted temperature: ${unitService.convertBetweenUnits(unitCelsius, unitFahrenheit, temperature)}") + println("Converted upperTemp: ${unitService.convertBetweenUnits(unitCelsius, unitFahrenheit, upperTemp)}") + println("Converted upperBound: ${unitService.convertBetweenUnits(unitFahrenheit, unitCelsius, upperBound)}") + println("Converted nextUpperB: ${unitService.convertBetweenUnits(unitFahrenheit, unitCelsius, nextUpperB)}") + } + + @Test fun convertToSystem() { val unitService = UnitService.service