Repository: chrisguidry/measured (language: python)

Issue:
Add `Celsius`, `Fahrenheit`, and `Rankine`, with conversions from `Kelvin`
The conversions here aren't just multiplication.  Celsius <-> Kelvin is a translation rather than a scale, and Fahrenheit to Celsius/Kelvin is a translation and a scale.  Is there a good way to define those without resorting to arbitrary functions?  Look for other examples.


Discussion / hints:

Fix patch:
diff --git a/src/measured/__init__.py b/src/measured/__init__.py
index a3c5c5a..06e2bb7 100644
--- a/src/measured/__init__.py
+++ b/src/measured/__init__.py
@@ -115,7 +115,6 @@
 
 """
 
-import math
 import sys
 from collections import defaultdict
 from functools import lru_cache, total_ordering
@@ -141,6 +140,8 @@
 
 if sys.version_info < (3, 9):  # pragma: no cover
     # math.gcd changed in Python 3.8 from a two-argument for to a variable argument form
+    import math
+
     from typing_extensions import SupportsIndex
 
     def recursive_gcd(*integers: SupportsIndex) -> int:
@@ -151,10 +152,10 @@ def recursive_gcd(*integers: SupportsIndex) -> int:
     gcd = recursive_gcd
 
 else:  # pragma: no cover
-    gcd = math.gcd
+    from math import gcd
 
 
-__version__ = "0.2.0"
+__version__ = "0.3.0"
 
 NUMERIC_CLASSES = (int, float)
 Numeric = Union[int, float]
@@ -829,7 +830,13 @@ def equals(self, other: "Quantity") -> None:
         """Defines a conversion between this Unit and another"""
         if other.unit == self:
             raise ValueError("No need to define conversions for a unit and itself")
-        return conversions.equate(1 * self, other)
+        conversions.equate(1 * self, other)
+
+    def zero(self, zero: "Quantity") -> None:
+        """Defines this unit as a scale with a zero point at another Quantity"""
+        if zero.unit == self:
+            raise ValueError("No need to define conversions for a unit and itself")
+        conversions.translate(self, zero)
 
     # Pickle support
 
@@ -1100,12 +1107,6 @@ def in_base_units(self) -> "Quantity":
 
     def in_unit(self, other: Unit) -> "Quantity":
         """Convert this Quantity into another unit"""
-        if self.unit.dimension != other.dimension:
-            raise TypeError(
-                f"Cannot convert between dimensions {self.unit.dimension} "
-                f"to {other.dimension}"
-            )
-
         return conversions.convert(self, other)
 
     # JSON support
@@ -1290,76 +1291,108 @@ def assert_approximates(self, other: "Quantity", within: Numeric = 1e-6) -> None
         assert self.approximates(other, within=within), message
 
 
+Ratio = Numeric
+Offset = Numeric
+
+
 class ConversionTable:
-    _known: Dict[Unit, Dict[Unit, Numeric]]
+    _ratios: Dict[Unit, Dict[Unit, Ratio]]
+    _offsets: Dict[Unit, Dict[Unit, Offset]]
 
     def __init__(self) -> None:
-        self._known = defaultdict(dict)
+        self._ratios = defaultdict(dict)
+        self._offsets = defaultdict(dict)
 
     def equate(self, a: Quantity, b: Quantity) -> None:
+        """Defines a conversion between one Unit and another, expressed as a ratio
+        between the two."""
+
+        if a.unit == b.unit:
+            raise ValueError("No need to define conversions for a unit and itself")
+
         a = a.in_base_units()
         b = b.in_base_units()
 
-        self._known[a.unit][b.unit] = b.magnitude / a.magnitude
-        self._known[b.unit][a.unit] = a.magnitude / b.magnitude
+        self._ratios[a.unit][b.unit] = b.magnitude / a.magnitude
+        self._ratios[b.unit][a.unit] = a.magnitude / b.magnitude
 
-    @classmethod
-    def format_path(cls, path: Optional[Iterable[Tuple[Numeric, Unit]]]) -> str:
-        if path is None:
-            return "None"
-        return str([f"* {s} -> {u}" for s, u in path])
+    def translate(self, scale: Unit, zero: Quantity) -> None:
+        """Defines a unit as a scale starting from the given zero point in another
+        unit"""
+        if scale == zero.unit:
+            raise ValueError("No need to define conversions for a unit and itself")
+
+        degree = zero.unit
+        offset = zero.magnitude
+
+        self._ratios[degree][scale] = 1
+        self._ratios[scale][degree] = 1
+
+        self._offsets[degree][scale] = -offset
+        self._offsets[scale][degree] = +offset
 
     def convert(self, quantity: Quantity, other_unit: Unit) -> Quantity:
         """Converts the given quantity into another unit, if possible"""
+        if quantity.unit.dimension != other_unit.dimension:
+            raise ConversionNotFound(
+                "No conversion from "
+                f"{quantity.unit} ({quantity.unit.dimension}) to "
+                f"{other_unit} ({other_unit.dimension})"
+            )
+
         this = quantity.in_base_units()
         other = (1 * other_unit).in_base_units()
 
         this_numerator, this_denominator = this.unit.as_ratio()
         other_numerator, other_denominator = other.unit.as_ratio()
 
-        numerator_path = conversions.find(this_numerator, other_numerator)
+        numerator_path = self._find(this_numerator, other_numerator)
         if not numerator_path:
             raise ConversionNotFound(
                 f"No conversion from {this_numerator} to {other_numerator}"
             )
 
-        denominator_path = conversions.find(this_denominator, other_denominator)
+        denominator_path = self._find(this_denominator, other_denominator)
         if not denominator_path:
             raise ConversionNotFound(
                 f"No conversion from {this_denominator} to {other_denominator}"
             )
 
-        magnitude = this.magnitude / other.magnitude
+        numerator = this.magnitude / other.magnitude
+
+        for scale, offset, _ in numerator_path:
+            numerator *= scale
+            numerator += offset
 
-        for scale, _ in numerator_path:
-            magnitude *= scale
+        denominator = 1.0
+        for scale, offset, _ in denominator_path:
+            denominator *= scale
+            denominator += offset
 
-        for scale, _ in denominator_path:
-            magnitude /= scale
+        return Quantity(numerator / denominator, other_unit)
 
-        return Quantity(magnitude, other_unit)
+    @classmethod
+    def _format_path(cls, path: Optional[Iterable[Tuple[Ratio, Offset, Unit]]]) -> str:
+        if path is None:
+            return "None"
+        return str([f"* {r} + {o} -> {u}" for r, o, u in path])
 
-    def find(
+    def _find(
         self,
         start: Unit,
         end: Unit,
-    ) -> Optional[Iterable[Tuple[Numeric, Unit]]]:
+    ) -> Optional[Iterable[Tuple[Ratio, Offset, Unit]]]:
         tracer: Callable[..., None] = lambda *args: None
         tracer(f"finding conversion from {start} -> {end}")
-
-        if start.dimension != end.dimension:
-            tracer(f"{start.dimension} != {end.dimension}")
-            return None
-
         if start.dimension == Number:
-            return [(1, end)]
+            return [(1, 0, end)]
 
         start_terms = self._terms_by_dimension(start)
         end_terms = self._terms_by_dimension(end)
 
         assert start_terms.keys() == end_terms.keys()
 
-        path: List[Tuple[Numeric, Unit]] = []
+        path: List[Tuple[Ratio, Offset, Unit]] = []
         for dimension in start_terms:
             for s, e in zip(start_terms[dimension], end_terms[dimension]):
                 this_path = self._find_path(s, e, tracer=tracer)
@@ -1383,14 +1416,14 @@ def _find_path(
         visited: Optional[Set[Unit]] = None,
         depth: int = 1,
         tracer: Callable[..., None] = lambda *args: None,
-    ) -> Optional[List[Tuple[Numeric, Unit]]]:
+    ) -> Optional[List[Tuple[Ratio, Offset, Unit]]]:
 
         indent = "  " * depth
         tracer(indent, f"finding path from {start} -> {end}")
 
         if start is end:
             tracer(indent, f"{start} == {end}")
-            return [(1, end)]
+            return [(1, 0, end)]
 
         if visited is None:
             visited = {start}
@@ -1414,26 +1447,30 @@ def _find_path(
             # should be able to find available conversions
             tracer(indent, f"backtracking from {end} -> {start}")
             backtracked = self._backtrack(self._find_path(end, start), exponent, end)
-            tracer(indent, f"backtracked: {self.format_path(backtracked)}")
+            tracer(indent, f"backtracked: {self._format_path(backtracked)}")
             return backtracked
 
         best_path = None
 
-        for intermediate, scale in self._known[start].items():
+        for intermediate, scale in self._ratios[start].items():
+            offset = self._offsets[start].get(intermediate, 0)
             if intermediate == end:
-                tracer(indent, f"found direct conversion {scale}")
-                return [(scale**exponent, end**exponent)]
+                tracer(indent, f"found direct conversion * {scale} + {offset}")
+                return [(scale**exponent, offset**exponent, end**exponent)]
 
             path = self._find_path(intermediate, end, visited=visited, depth=depth + 1)
             if not path:
                 continue
 
-            path = [(scale, intermediate)] + list(path)
-            path = [(scale**exponent, unit**exponent) for scale, unit in path]
+            path = [(scale, offset, intermediate)] + list(path)
+            path = [
+                (scale**exponent, offset**exponent, unit**exponent)
+                for scale, offset, unit in path
+            ]
             if not best_path or len(path) < len(best_path):
                 best_path = path
 
-        tracer(indent, f"best path: {self.format_path(best_path)}")
+        tracer(indent, f"best path: {self._format_path(best_path)}")
         if best_path:
             return best_path
 
@@ -1459,10 +1496,10 @@ def _reduce_dimension(cls, start: Unit, end: Unit) -> Tuple[int, Unit, Unit]:
     @classmethod
     def _backtrack(
         cls,
-        path: Optional[Iterable[Tuple[Numeric, Unit]]],
+        path: Optional[Iterable[Tuple[Ratio, Offset, Unit]]],
         exponent: int,
         end: Unit,
-    ) -> Optional[List[Tuple[Numeric, Unit]]]:
+    ) -> Optional[List[Tuple[Ratio, Offset, Unit]]]:
         """Given a path to convert a start unit to an end unit, produce the reverse
         path, which would convert the end unit to the start unit"""
         if path is None:
@@ -1470,12 +1507,12 @@ def _backtrack(
 
         path = list(reversed(list(path)))
 
-        units = [u for _, u in path[1:]] + [end]
-        scales = [s for s, _ in path]
+        units = [u for _, _, u in path[1:]] + [end]
+        scales_and_offsets = [(s, o) for s, o, _ in path]
 
         backtracked = [
-            (1 / (scale**exponent), unit**exponent)
-            for scale, unit in zip(scales, units)
+            (1 / (scale**exponent), -(offset**exponent), unit**exponent)
+            for (scale, offset), unit in zip(scales_and_offsets, units)
         ]
         return backtracked
 
diff --git a/src/measured/si.py b/src/measured/si.py
index 3aaf260..2f6a820 100644
--- a/src/measured/si.py
+++ b/src/measured/si.py
@@ -166,7 +166,8 @@
 Weber = Unit.derive(Joule / Ampere, name="weber", symbol="Wb")
 Tesla = Unit.derive(Weber / Meter**2, name="tesla", symbol="T")
 
-# Celsius = Kelvin.translate(273.15)
+Celsius = Temperature.unit(name="celsius", symbol="°C")
+Celsius.zero(273.15 * Kelvin)
 
 Lumen = Unit.derive(Candela * Steradian, name="lumen", symbol="lm")
 Lux = Unit.derive(Lumen / Meter**2, name="lux", symbol="lux")
diff --git a/src/measured/us.py b/src/measured/us.py
index c6cebe2..4e4062c 100644
--- a/src/measured/us.py
+++ b/src/measured/us.py
@@ -4,8 +4,8 @@
 [1]: https://en.wikipedia.org/wiki/United_States_customary_units
 """
 
-from . import Area, Length, Mass, Volume, avoirdupois
-from .si import Gram, Kilo, Liter, Meter, Micro, Milli
+from . import Area, Length, Mass, Temperature, Volume, avoirdupois
+from .si import Gram, Kelvin, Kilo, Liter, Meter, Micro, Milli
 
 # Length
 # https://en.wikipedia.org/wiki/United_States_customary_units#Length
@@ -210,3 +210,14 @@
 Ton = Mass.unit("short ton", "ton")
 Ton.equals(20 * Hundredweight)
 Ton.equals(907.18474 * Kilo * Gram)
+
+
+# Temperature
+# https://en.wikipedia.org/wiki/Rankine_scale
+# https://en.wikipedia.org/wiki/Fahrenheit
+
+Rankine = Temperature.unit("Rankine", "R")
+Rankine.equals(5 / 9 * Kelvin)
+
+Fahrenheit = Temperature.unit(name="fahrenheit", symbol="°F")
+Fahrenheit.zero(+459.67 * Rankine)


Test patch:
diff --git a/tests/si/test_temperature.py b/tests/si/test_temperature.py
new file mode 100644
index 0000000..8f61017
--- /dev/null
+++ b/tests/si/test_temperature.py
@@ -0,0 +1,31 @@
+import pytest
+
+from measured import Quantity
+from measured.si import Celsius, Kelvin, Kilo, Milli
+
+
+@pytest.mark.parametrize(
+    "kelvin, celsius",
+    [
+        (0 * Kelvin, -273.15 * Celsius),
+        (273.15 * Kelvin, 0 * Celsius),
+        (373.15 * Kelvin, 100 * Celsius),
+        (10273.15 * Kelvin, 10 * Kilo * Celsius),
+        (1 * Kilo * Kelvin, 726.85 * Celsius),
+    ],
+)
+def test_kelvin_equals_celsius(kelvin: Quantity, celsius: Quantity) -> None:
+    assert kelvin == celsius
+    assert celsius == kelvin
+
+
+@pytest.mark.parametrize(
+    "kelvin, celsius",
+    [
+        (273.16 * Kelvin, 10 * Milli * Celsius),
+        (283150.0 * Milli * Kelvin, 10 * Celsius),
+    ],
+)
+def test_kelvin_approximates_celsius(kelvin: Quantity, celsius: Quantity) -> None:
+    kelvin.assert_approximates(celsius)
+    celsius.assert_approximates(kelvin)
diff --git a/tests/test_quantity_conversions.py b/tests/test_quantity_conversions.py
index 8db8166..7cc35fd 100644
--- a/tests/test_quantity_conversions.py
+++ b/tests/test_quantity_conversions.py
@@ -1,5 +1,3 @@
-from typing import Iterable, Tuple
-
 import pytest
 
 from measured import Area, ConversionNotFound, Length, Mass, Numeric, Unit, conversions
@@ -11,6 +9,20 @@ def test_disallow_self_conversion() -> None:
     with pytest.raises(ValueError, match="unit and itself"):
         Meter.equals(1 * Meter)
 
+    with pytest.raises(ValueError, match="unit and itself"):
+        conversions.equate(1 * Meter, 1 * Meter)
+
+    with pytest.raises(ValueError, match="unit and itself"):
+        conversions.equate(1 * Meter, 2 * Meter)
+
+
+def test_disallow_self_zeroing() -> None:
+    with pytest.raises(ValueError, match="unit and itself"):
+        Meter.zero(1 * Meter)
+
+    with pytest.raises(ValueError, match="unit and itself"):
+        conversions.translate(Meter, 1 * Meter)
+
 
 def test_approximating_requires_units_with_conversion() -> None:
     Bogus = Length.unit(name="bogus", symbol="bog")
@@ -22,14 +34,10 @@ def test_approximating_requires_units_with_conversion() -> None:
 
 
 def test_unit_conversion_must_be_in_same_dimension() -> None:
-    with pytest.raises(TypeError):
+    with pytest.raises(ConversionNotFound):
         (1 * Meter).in_unit(Second)
 
 
-def test_finding_conversions_in_different_dimensions_returns_none() -> None:
-    assert conversions.find(Meter, Second) is None
-
-
 def test_unit_always_converts_to_itself() -> None:
     converted = (10 * Meter).in_unit(Meter)
     assert converted.magnitude == 10
@@ -45,44 +53,26 @@ def test_conversion_with_exponents() -> None:
 
 
 @pytest.mark.parametrize(
-    "start, end, path",
+    "start, end, magnitude",
     [
-        (Foot, Inch, [(12.0, Inch)]),
-        (Foot**2, Meter**2, [(0.09290304, Meter**2)]),
-        (Meter**2, Foot**2, [(10.76391041670972, Foot**2)]),
-        (Acre, Foot**2, [(43560.0, Foot**2)]),
-        (Foot**2, Inch**2, [(144.0, Inch**2)]),
-        (
-            Acre,
-            Inch**2,
-            [
-                (43560.0, Foot**2),
-                (144.0, Inch**2),
-            ],
-        ),
-        (Inch**2, Foot**2, [(0.006944444444444444, Foot**2)]),
-        (Foot**2, Acre, [(0.00002295684113865932, Acre)]),
-        (
-            Inch**2,
-            Acre,
-            [
-                (0.006944444444444444, Foot**2),
-                (0.00002295684113865932, Acre),
-            ],
-        ),
+        (Foot, Inch, 12.0),
+        (Foot**2, Meter**2, 0.09290304),
+        (Meter**2, Foot**2, 10.76391041670972),
+        (Acre, Foot**2, 43560.0),
+        (Foot**2, Inch**2, 144.0),
+        (Acre, Inch**2, 43560.0 * 144.0),
+        (Inch**2, Foot**2, 0.006944444444444444),
+        (Foot**2, Acre, 0.00002295684113865932),
+        (Inch**2, Acre, 0.006944444444444444 * 0.00002295684113865932),
     ],
 )
 def test_conversion_can_navigate_exponents(
-    start: Unit, end: Unit, path: Iterable[Tuple[Numeric, Unit]]
+    start: Unit, end: Unit, magnitude: Numeric
 ) -> None:
-    message = " != ".join(
-        [
-            conversions.format_path(conversions.find(start, end)),
-            conversions.format_path(path),
-        ]
-    )
-
-    assert conversions.find(start, end) == path, message
+    converted = conversions.convert(1 * start, end)
+    assert converted == magnitude * end
+    assert converted.unit == end
+    assert converted.magnitude == magnitude
 
 
 def test_backtracking_conversions_with_no_path() -> None:
@@ -90,8 +80,11 @@ def test_backtracking_conversions_with_no_path() -> None:
     Hiya = Length.unit("hello", "hello")
     Mundo = Area.unit("world", "world")
 
-    assert conversions.find(Hiya**2, Mundo) is None
-    assert conversions.find(Mundo, Hiya**2) is None
+    with pytest.raises(ConversionNotFound):
+        conversions.convert(1 * Hiya**2, Mundo)
+
+    with pytest.raises(ConversionNotFound):
+        conversions.convert(1 * Mundo, Hiya**2)
 
 
 def test_failing_to_convert_numerator() -> None:
diff --git a/tests/test_scales.py b/tests/test_scales.py
new file mode 100644
index 0000000..e0ba1d7
--- /dev/null
+++ b/tests/test_scales.py
@@ -0,0 +1,42 @@
+import pytest
+
+from measured import Quantity
+from measured.si import Celsius, Kelvin
+from measured.us import Fahrenheit, Rankine
+
+
+# https://en.wikipedia.org/wiki/Rankine_scale
+@pytest.mark.parametrize(
+    "kelvin, celsius, fahrenheit, rankine",
+    [
+        # Absolute zero
+        (0 * Kelvin, -273.15 * Celsius, -459.67 * Fahrenheit, 0 * Rankine),
+        # freezing point of brine
+        (
+            255.372222222 * Kelvin,
+            -17.777777777 * Celsius,
+            0 * Fahrenheit,
+            459.67 * Rankine,
+        ),
+        # freezing point of water
+        (273.15 * Kelvin, 0 * Celsius, 32 * Fahrenheit, 491.67 * Rankine),
+        # boiling point of water
+        (
+            373.1339 * Kelvin,
+            99.9839 * Celsius,
+            211.97102 * Fahrenheit,
+            671.64102 * Rankine,
+        ),
+    ],
+)
+def test_temperature_conversion_scale(
+    kelvin: Quantity, celsius: Quantity, fahrenheit: Quantity, rankine: Quantity
+) -> None:
+    kelvin.assert_approximates(celsius)
+    celsius.assert_approximates(kelvin)
+    celsius.assert_approximates(fahrenheit)
+    fahrenheit.assert_approximates(celsius)
+    fahrenheit.assert_approximates(rankine)
+    rankine.assert_approximates(kelvin)
+    rankine.assert_approximates(celsius)
+    kelvin.assert_approximates(rankine)
diff --git a/tests/us/test_temperature.py b/tests/us/test_temperature.py
new file mode 100644
index 0000000..d85444e
--- /dev/null
+++ b/tests/us/test_temperature.py
@@ -0,0 +1,68 @@
+import pytest
+
+from measured import Quantity
+from measured.si import Kelvin, Kilo, Milli
+from measured.us import Fahrenheit, Rankine
+
+
+@pytest.mark.parametrize(
+    "kelvin, rankine",
+    [
+        # Absolute Zero
+        (0 * Kelvin, 0 * Rankine),
+        # Combined with prefixes > 1
+        (10 * Kilo * Kelvin, 18 * Kilo * Rankine),
+        (10 * Kilo * Kelvin, 18000 * Rankine),
+        (10000 * Kelvin, 18 * Kilo * Rankine),
+    ],
+)
+def test_kelvin_and_rankine(kelvin: Quantity, rankine: Quantity) -> None:
+    assert kelvin == rankine
+    assert rankine == kelvin
+
+
+@pytest.mark.parametrize(
+    "kelvin, rankine",
+    [
+        # Freezing point of brine
+        (255.37 * Kelvin, 459.665999 * Rankine),
+        # Freezing point of water
+        (273.15 * Kelvin, 491.67 * Rankine),
+        # Boiling point of water
+        (373.1339 * Kelvin, 671.64102 * Rankine),
+        # Combined with prefixes < 1
+        (10 * Milli * Kelvin, 18 * Milli * Rankine),
+        (10 * Milli * Kelvin, 0.018 * Rankine),
+        (0.01 * Kelvin, 18 * Milli * Rankine),
+    ],
+)
+def test_kelvin_approximates_rankine(kelvin: Quantity, rankine: Quantity) -> None:
+    kelvin.assert_approximates(rankine)
+    rankine.assert_approximates(kelvin)
+
+
+@pytest.mark.parametrize(
+    "rankine, fahrenheit",
+    [
+        (0 * Rankine, -459.67 * Fahrenheit),
+        (459.67 * Rankine, 0 * Fahrenheit),
+    ],
+)
+def test_rankine_equals_fahrenheit(rankine: Quantity, fahrenheit: Quantity) -> None:
+    assert rankine == fahrenheit
+    assert fahrenheit == rankine
+
+
+@pytest.mark.parametrize(
+    "rankine, fahrenheit",
+    [
+        (459.68 * Rankine, 10 * Milli * Fahrenheit),
+        (559.67 * Rankine, 100 * Fahrenheit),
+        (559670.0 * Milli * Rankine, 100 * Fahrenheit),
+    ],
+)
+def test_rankine_approximates_fahrenheit(
+    rankine: Quantity, fahrenheit: Quantity
+) -> None:
+    rankine.assert_approximates(fahrenheit)
+    fahrenheit.assert_approximates(rankine)