Signed-off-by: Mark Herwege [mark.herwege@telenet.be](mailto:mark.herwege@telene…t.be)
EDIT 05/09/23: Summary updated with analysis, conclusions and proposed behaviour.
In tests performed for a Blockly issue, it was identified that calculations with temperatures in °F produce strange results: https://github.com/openhab/openhab-webui/issues/2001#issuecomment-1699460223
Notably:
- 65 °F / Qty(1) produces 36.11 K: reason is 65° F is treated as relative temperature and the result is relative as well. Δ 1 K = Δ 0.55 °F. Converting back the result to °F therefore is not equal to 65 °F, which would be the typical expectation fo a user. Note that 65 °F / 1 does produce 65 °F, so there is a difference in behaviour between scalar and dimensionless quantity.
- 65 °F / 1 °F produces 65: seems intuitively correct, but does not make sense, again shows division is with relative values, ignoring the offset.
- 65 °F * 1 °F produces 20.0617 K<sup>2</sup>: this is the result of Δ 65 °F * Δ 1 °F = 65 * 0.55 K * 1 * 0.55 K.
The issue is that all quantities in OH are considered relative. This is fine for most dimensions and units that have the same zero value, but leads to issues with temperatures in general and with °F in particular (°C is easier to interpret as Δ 1° C = Δ 1 K).
This PR changes the behaviour by converting the quantities to absolute before doing the calculations, i.e.
- for addition or subtraction, convert the first argument to absolute, second argument remains relative. This effectively makes the second argument an offset.
- for multiplication or division, convert both arguments to absolute. Multiplying or dividing relative values does not make sense in most cases.
Note that this has been discussed before: https://github.com/openhab/openhab-core/issues/2386 and https://github.com/eclipse-archived/smarthome/pull/5697. It has lead to creating special treatment for temperatures in the SystemOffsetProfile, but no adjustment for rule languages in general. The proposed fix eliminates the need for a specific fix in the SystemOffsetProfile and brings the behaviour in rules in line with the offset profile behaviour.
With the proposed changes:
- No change to behaviour for units with same 0 base (as far as I know, the only practical exception in the smarthome context is with temperatures).
- Adding/substracting temperatures will be commutative: A + B = B + A
- Adding/substracting temperatures will be associative: (A + B) + C = A + (B + C)
- Mixing compatible units in adding/substracting yields expected results and respects commutativity, associativity (was not the case before)
- Converting units on the result of addition/substraction yieds the same as converting units on the argument(s) before addition/substraction (was not the case before)
- Multiplication/division always works with absolute values from the absolute 0, i.e. uses Kelvin for temperatures (was not the case before)
- Multiplying/dividing with scalar or dimensionless quantities gives the same result (was not the case before)
- Multiplying/dividing with 1 or Qty(1) gives the same as the input (was not the case before for Qty(1))
- Converting units on the result of multiplication/division yieds the same as converting units on the argument(s) before multiplication/division (was not the case before)
- Distributivity of addition/substraction would not be respected: (A + B) * factor is not equal (A * factor) + (B * factor), e.g. (1 °C + 2 °C) * 2 = 279.15 °C while 1 °C * 2 + 2 °C * 2 = 552.3 °C. This would have been respected before when working with scalars, and yield 6°C (no conversion would take place, arguments and result are relative). It would not have been respected with dimensionless quantities or when mixing units anyway, so distributivity would only have been respected in very specific cases. I also think the difference is easy enough to understand, easier than the issues seen before. If one would want to force distributivity, both arguments in addition/substraction should always be converted to absolute, but in most cases adding temperatures would be expected to be relative in its second argument (or you would have to force users to work in Kelvin only for addition). I therefore think breaking distributivity is not a major concern. Note that distributivity is not broken for all common units except temperatures. Also for temperatures, it is respected when working with Kelvin.
I did tests with this in a scratchpad using DSL. I believe this gives much more predictable and expected results than before. These tests are now also part of the Java unit tests.
Here is the DSL script used for the test:
```
val temperature = 65|°F
val one = java.math.BigDecimal.valueOf(1)
val two = java.math.BigDecimal.valueOf(2)
val temperature1 = 1|°F
val temperature2 = 2|°F
val energykWh = 65|kWh
val energykJ = 65|kJ
val energykWh1 = 1|kWh
val energykJ1 = 1|kJ
var QuantityType result
result = temperature.multiply(one)
logInfo("Quantity test", "Qty({}) * 1 = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature.divide(one)
logInfo("Quantity test", "Qty({}) / 1 = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature.multiply(two)
logInfo("Quantity test", "Qty({}) * 2 = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature.divide(two)
logInfo("Quantity test", "Qty({}) / 2 = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature.multiply(QuantityType.valueOf(1, ONE))
logInfo("Quantity test", "Qty({}) * Qty(1) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature.divide(QuantityType.valueOf(1, ONE))
logInfo("Quantity test", "Qty({}) / Qty(1) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature.multiply(QuantityType.valueOf(2, ONE))
logInfo("Quantity test", "Qty({}) * Qty(2) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature.divide(QuantityType.valueOf(2, ONE))
logInfo("Quantity test", "Qty({}) / Qty(2) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = result = QuantityType.valueOf(1, ONE).multiply(temperature)
logInfo("Quantity test", "Qty(1) * Qty({}) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = result = QuantityType.valueOf(1, ONE).divide(temperature)
logInfo("Quantity test", "Qty(1) / Qty({}) = {}", temperature.toString, result.toString)
result = result = QuantityType.valueOf(2, ONE).multiply(temperature)
logInfo("Quantity test", "Qty(2) * Qty({}) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = result = QuantityType.valueOf(2, ONE).divide(temperature)
logInfo("Quantity test", "Qty(2) / Qty({}) = {}", temperature.toString, result.toString)
result = temperature.add(temperature1)
logInfo("Quantity test", "Qty({}) + Qty(1 °F) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature.subtract(temperature1)
logInfo("Quantity test", "Qty({}) - Qty(1 °F) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature.multiply(temperature1)
logInfo("Quantity test", "Qty({}) * Qty(1 °F) = {}", temperature.toString, result.toString)
result = temperature.divide(temperature1)
logInfo("Quantity test", "Qty({}) / Qty(1 °F) = {}", temperature.toString, result.toString)
result = temperature.add(temperature2)
logInfo("Quantity test", "Qty({}) + Qty(2 °F) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature.subtract(temperature2)
logInfo("Quantity test", "Qty({}) - Qty(2 °F) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature.multiply(temperature2)
logInfo("Quantity test", "Qty({}) * Qty(2 °F) = {}", temperature.toString, result.toString)
result = temperature.divide(temperature2)
logInfo("Quantity test", "Qty({}) / Qty(2 °F) = {}", temperature.toString, result.toString)
result = temperature1.add(temperature)
logInfo("Quantity test", "Qty(1 °F) + Qty({}) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature1.subtract(temperature)
logInfo("Quantity test", "Qty(1 °F) - Qty({}) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature1.multiply(temperature)
logInfo("Quantity test", "Qty(1 °F) * Qty({}) = {}", temperature.toString, result.toString)
result = temperature1.divide(temperature)
logInfo("Quantity test", "Qty(1 °F) / Qty({}) = {}", temperature.toString, result.toString)
result = temperature2.add(temperature)
logInfo("Quantity test", "Qty(2 °F) + Qty({}) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature2.subtract(temperature)
logInfo("Quantity test", "Qty(2 °F) - Qty({}) = {} = {}", temperature.toString, result.toString, result.toUnit("°F").toString)
result = temperature2.multiply(temperature)
logInfo("Quantity test", "Qty(2 °F) * Qty({}) = {}", temperature.toString, result.toString)
result = temperature2.divide(temperature)
logInfo("Quantity test", "Qty(2 °F) / Qty({}) = {}", temperature.toString, result.toString)
result = energykWh.multiply(QuantityType.valueOf(1, ONE))
logInfo("Quantity test", "Qty({}) * Qty(1) = {} = {}", energykWh.toString, result.toString, result.toUnit("kWh").toString)
result = energykWh.divide(QuantityType.valueOf(1, ONE))
logInfo("Quantity test", "Qty({}) / Qty(1) = {} = {}", energykWh.toString, result.toString, result.toUnit("kWh").toString)
result = energykWh.add(energykWh1)
logInfo("Quantity test", "Qty({}) + Qty(1 kWh) = {} = {}", energykWh.toString, result.toString, result.toUnit("kWh").toString)
result = energykWh.subtract(energykWh1)
logInfo("Quantity test", "Qty({}) - Qty(1 kWh) = {} = {}", energykWh.toString, result.toString, result.toUnit("kWh").toString)
result = energykWh.multiply(energykWh1)
logInfo("Quantity test", "Qty({}) * Qty(1 kWh) = {}", energykWh.toString, result.toString)
result = energykWh.divide(energykWh1)
logInfo("Quantity test", "Qty({}) / Qty(1 kWh) = {}", energykWh.toString, result.toString)
result = energykJ.add(energykJ1)
logInfo("Quantity test", "Qty({}) + Qty(1 kJ) = {} = {}", energykJ.toString, result.toString, result.toUnit("kJ").toString)
result = energykJ.subtract(energykJ1)
logInfo("Quantity test", "Qty({}) - Qty(1 kJ) = {} = {}", energykJ.toString, result.toString, result.toUnit("kJ").toString)
result = energykJ.multiply(energykJ1)
logInfo("Quantity test", "Qty({}) * Qty(1 kJ) = {}", energykJ.toString, result.toString)
result = energykJ.divide(energykJ1)
logInfo("Quantity test", "Qty({}) / Qty(1 kJ) = {}", energykJ.toString, result.toString)
result = energykWh.add(energykJ1)
logInfo("Quantity test", "Qty({}) + Qty(1 kJ) = {} = {} = {}", energykWh.toString, result.toString, result.toUnit("kWh").toString, result.toUnit("kJ").toString)
result = energykWh.subtract(energykJ1)
logInfo("Quantity test", "Qty({}) - Qty(1 kJ) = {} = {} = {}", energykWh.toString, result.toString, result.toUnit("kWh").toString, result.toUnit("kJ").toString)
result = energykWh.multiply(energykJ1)
logInfo("Quantity test", "Qty({}) * Qty(1 kJ) = {}", energykWh.toString, result.toString)
result = energykWh.divide(energykJ1)
logInfo("Quantity test", "Qty({}) / Qty(1 kJ) = {} = {}", energykWh.toString, result.toString, result.toUnit(ONE).toString)
```
And the result:
```
18:20:49.342 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) * 1 = 65.00000000000000000000000000000004 °F = 65.00000000000000000000000000000004 °F
18:20:49.344 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) / 1 = 65.00000000000000000000000000000004 °F = 65.00000000000000000000000000000004 °F
18:20:49.347 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) * 2 = 589.6700000000000000000000000000001 °F = 589.6700000000000000000000000000001 °F
18:20:49.358 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) / 2 = -197.3350000000000000000000000000000 °F = -197.3350000000000000000000000000000 °F
18:20:49.361 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) * Qty(1) = 291.483333333333333333333333333333356652 K = 65.00000000000000000000000000000004 °F
18:20:49.365 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) / Qty(1) = 291.483333333333333333333333333333356652 K = 65.00000000000000000000000000000004 °F
18:20:49.370 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) * Qty(2) = 582.966666666666666666666666666666713304 K = 589.6700000000000000000000000000001 °F
18:20:49.373 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) / Qty(2) = 145.7416666666666666666666666666666783260 K = -197.3350000000000000000000000000000 °F
18:20:49.378 [INFO ] [nhab.core.model.script.Quantity test] - Qty(1) * Qty(65 °F) = 291.483333333333333333333333333333356652 K = 65.00000000000000000000000000000004 °F
18:20:49.380 [INFO ] [nhab.core.model.script.Quantity test] - Qty(1) / Qty(65 °F) = 0.003430727886099834181485505174681228 1/K
18:20:49.385 [INFO ] [nhab.core.model.script.Quantity test] - Qty(2) * Qty(65 °F) = 582.966666666666666666666666666666713304 K = 589.6700000000000000000000000000001 °F
18:20:49.386 [INFO ] [nhab.core.model.script.Quantity test] - Qty(2) / Qty(65 °F) = 0.006861455772199668362971010349362456 1/K
18:20:49.390 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) + Qty(1 °F) = 66.00000000000000000000000000000004 °F = 66.00000000000000000000000000000004 °F
18:20:49.393 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) - Qty(1 °F) = 64.00000000000000000000000000000004 °F = 64.00000000000000000000000000000004 °F
18:20:49.397 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) * Qty(1 °F) = 74598.68175925925925925925925925927 K²
18:20:49.400 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) / Qty(1 °F) = 1.138928083009529598193934920876115122114246640762367855514793670089202480
18:20:49.403 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) + Qty(2 °F) = 67.00000000000000000000000000000004 °F = 67.00000000000000000000000000000004 °F
18:20:49.406 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) - Qty(2 °F) = 63.00000000000000000000000000000004 °F = 63.00000000000000000000000000000004 °F
18:20:49.409 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) * Qty(2 °F) = 74760.61694444444444444444444444446 K²
18:20:49.411 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 °F) / Qty(2 °F) = 1.136461108584053544739749171486126553533555353390950245846600385556783676
18:20:49.413 [INFO ] [nhab.core.model.script.Quantity test] - Qty(1 °F) + Qty(65 °F) = 66.00000000000000000000000000000003 °F = 66.00000000000000000000000000000003 °F
18:20:49.415 [INFO ] [nhab.core.model.script.Quantity test] - Qty(1 °F) - Qty(65 °F) = -63.99999999999999999999999999999996 °F = -63.99999999999999999999999999999996 °F
18:20:49.420 [INFO ] [nhab.core.model.script.Quantity test] - Qty(1 °F) * Qty(65 °F) = 74598.68175925925925925925925925927 K²
18:20:49.422 [INFO ] [nhab.core.model.script.Quantity test] - Qty(1 °F) / Qty(65 °F) = 0.878018564049783673547182038233556349552596235093804994885674169795613456
18:20:49.425 [INFO ] [nhab.core.model.script.Quantity test] - Qty(2 °F) + Qty(65 °F) = 67.00000000000000000000000000000003 °F = 67.00000000000000000000000000000003 °F
18:20:49.427 [INFO ] [nhab.core.model.script.Quantity test] - Qty(2 °F) - Qty(65 °F) = -62.99999999999999999999999999999996 °F = -62.99999999999999999999999999999996 °F
18:20:49.430 [INFO ] [nhab.core.model.script.Quantity test] - Qty(2 °F) * Qty(65 °F) = 74760.61694444444444444444444444446 K²
18:20:49.434 [INFO ] [nhab.core.model.script.Quantity test] - Qty(2 °F) / Qty(65 °F) = 0.879924523986505803648007318886157031927295252253797625173918844225890256
18:20:49.435 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kWh) * Qty(1) = 65 kWh = 65 kWh
18:20:49.436 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kWh) / Qty(1) = 65 kWh = 65 kWh
18:20:49.437 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kWh) + Qty(1 kWh) = 66 kWh = 66 kWh
18:20:49.437 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kWh) - Qty(1 kWh) = 64 kWh = 64 kWh
18:20:49.438 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kWh) * Qty(1 kWh) = 65 kWh²
18:20:49.438 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kWh) / Qty(1 kWh) = 65
18:20:49.438 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kJ) + Qty(1 kJ) = 66 kJ = 66 kJ
18:20:49.439 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kJ) - Qty(1 kJ) = 64 kJ = 64 kJ
18:20:49.439 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kJ) * Qty(1 kJ) = 65 kJ²
18:20:49.440 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kJ) / Qty(1 kJ) = 65
18:20:49.441 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kWh) + Qty(1 kJ) = 65.00027777777777777777777777777778 kWh = 65.00027777777777777777777777777778 kWh = 234001 kJ
18:20:49.467 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kWh) - Qty(1 kJ) = 64.99972222222222222222222222222222 kWh = 64.99972222222222222222222222222222 kWh = 233999 kJ
18:20:49.468 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kWh) * Qty(1 kJ) = 65 kWh·kJ
18:20:49.472 [INFO ] [nhab.core.model.script.Quantity test] - Qty(65 kWh) / Qty(1 kJ) = 65 kWh/kJ = 234000
```
<s>If this is an acceptable approach, I can convert the test DSL script to unit test.</s>
<s>Open question for me still: should the result of calculations be converted to relative again. I would be happy to hear views on this.</s>