Reduce the doctest lease duration...
[python_utils.git] / conversion_utils.py
index b83d62e4daf50ec76850e047672069a8e9f47c7b..57902a79754e2b7950f800cf82d3bdabc74114a9 100644 (file)
 #!/usr/bin/env python3
+# -*- coding: utf-8 -*-
 
-from numbers import Number
-from typing import Callable
+# © Copyright 2021-2022, Scott Gasch
+
+"""Utilities involving converting between different units."""
+
+from typing import Callable, SupportsFloat
 
 import constants
 
 
 class Converter(object):
-    def __init__(self,
-                 name: str,
-                 category: str,
-                 to_canonical: Callable,
-                 from_canonical: Callable,
-                 unit: str) -> None:
+    """A converter has a canonical name and a category.  The name defines
+    a unit of measurement in a category or class of measurements.
+    This framework will allow conversion between named units in the
+    same category.  e.g. name may be "meter", "inch", "mile" and their
+    category may be "length".
+
+    The way that conversions work is that we convert the magnitude
+    first to a canonical unit and then (if needed) from the canonical
+    unit to the desired destination unit.  e.g. if "meter" is the
+    canonical unit of measurement for the category "length", in order
+    to convert miles into inches we first convert miles into meters
+    and, from there, meters into inches.  This is potentially
+    dangerous because it requires two floating point operations which
+    each have the potential to overflow, underflow, or introduce
+    floating point errors.  Caveat emptor.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        category: str,
+        to_canonical: Callable,  # convert to canonical unit
+        from_canonical: Callable,  # convert from canonical unit
+        suffix: str,
+    ) -> None:
+        """Construct a converter.
+
+        Args:
+            name: the unit name
+            category: the converter category
+            to_canonical: a Callable to convert this unit into the
+                canonical unit of the category.
+            from_canonical: a Callable to convert from the canonical
+                unit of this category into this unit.
+            suffix: the abbreviation of the unit name.
+        """
+
         self.name = name
         self.category = category
         self.to_canonical_f = to_canonical
         self.from_canonical_f = from_canonical
-        self.unit = unit
+        self.suffix = suffix
 
-    def to_canonical(self, n: Number) -> Number:
+    def to_canonical(self, n: SupportsFloat) -> SupportsFloat:
+        """Convert into the canonical unit of this caregory by using the
+        Callable provided during construction."""
         return self.to_canonical_f(n)
 
-    def from_canonical(self, n: Number) -> Number:
+    def from_canonical(self, n: SupportsFloat) -> SupportsFloat:
+        """Convert from the canonical unit of this category by using the
+        Callable provided during construction."""
         return self.from_canonical_f(n)
 
     def unit_suffix(self) -> str:
-        return self.unit
+        """Get this unit's suffix abbreviation."""
+        return self.suffix
 
 
+# A catalog of converters.
 conversion_catalog = {
-    "Second": Converter("Second",
-                        "time",
-                        lambda s: s,
-                        lambda s: s,
-                        "s"),
-    "Minute": Converter("Minute",
-                        "time",
-                        lambda m: (m * constants.SECONDS_PER_MINUTE),
-                        lambda s: (s / constants.SECONDS_PER_MINUTE),
-                        "m"),
-    "Hour": Converter("Hour",
-                      "time",
-                      lambda h: (h * constants.SECONDS_PER_HOUR),
-                      lambda s: (s / constants.SECONDS_PER_HOUR),
-                      "h"),
-    "Day": Converter("Day",
-                     "time",
-                     lambda d: (d * constants.SECONDS_PER_DAY),
-                     lambda s: (s / constants.SECONDS_PER_DAY),
-                     "d"),
-    "Week": Converter("Week",
-                      "time",
-                      lambda w: (w * constants.SECONDS_PER_WEEK),
-                      lambda s: (s / constants.SECONDS_PER_WEEK),
-                      "w"),
-    "Fahrenheit": Converter("Fahrenheit",
-                            "temperature",
-                            lambda f: (f - 32.0) * 0.55555555,
-                            lambda c: c * 1.8 + 32.0,
-                            "°F"),
-    "Celsius": Converter("Celsius",
-                         "temperature",
-                         lambda c: c,
-                         lambda c: c,
-                         "°C"),
-    "Kelvin": Converter("Kelvin",
-                        "temperature",
-                        lambda k: k - 273.15,
-                        lambda c: c + 273.15,
-                        "°K"),
+    "Second": Converter("Second", "time", lambda s: s, lambda s: s, "s"),
+    "Minute": Converter(
+        "Minute",
+        "time",
+        lambda m: (m * constants.SECONDS_PER_MINUTE),
+        lambda s: (s / constants.SECONDS_PER_MINUTE),
+        "m",
+    ),
+    "Hour": Converter(
+        "Hour",
+        "time",
+        lambda h: (h * constants.SECONDS_PER_HOUR),
+        lambda s: (s / constants.SECONDS_PER_HOUR),
+        "h",
+    ),
+    "Day": Converter(
+        "Day",
+        "time",
+        lambda d: (d * constants.SECONDS_PER_DAY),
+        lambda s: (s / constants.SECONDS_PER_DAY),
+        "d",
+    ),
+    "Week": Converter(
+        "Week",
+        "time",
+        lambda w: (w * constants.SECONDS_PER_WEEK),
+        lambda s: (s / constants.SECONDS_PER_WEEK),
+        "w",
+    ),
+    "Fahrenheit": Converter(
+        "Fahrenheit",
+        "temperature",
+        lambda f: (f - 32.0) * 0.55555555,
+        lambda c: c * 1.8 + 32.0,
+        "°F",
+    ),
+    "Celsius": Converter("Celsius", "temperature", lambda c: c, lambda c: c, "°C"),
+    "Kelvin": Converter(
+        "Kelvin",
+        "temperature",
+        lambda k: k - 273.15,
+        lambda c: c + 273.15,
+        "°K",
+    ),
 }
 
 
-def convert(magnitude: Number,
-            from_thing: str,
-            to_thing: str) -> float:
+def convert(magnitude: SupportsFloat, from_thing: str, to_thing: str) -> float:
+    """Convert between units using the internal catalog.
+
+    Args:
+        magnitude: the quantity from which to convert
+        from_thing: the quantity's source unit we're coverting from
+        to_thing: the unit we are coverting to
+
+    Returns:
+        The converted magnitude.  Raises on error.
+    """
     src = conversion_catalog.get(from_thing, None)
     dst = conversion_catalog.get(to_thing, None)
     if src is None or dst is None:
@@ -85,99 +138,234 @@ def convert(magnitude: Number,
     return _convert(magnitude, src, dst)
 
 
-def _convert(magnitude: Number,
-             from_unit: Converter,
-             to_unit: Converter) -> float:
+def _convert(magnitude: SupportsFloat, from_unit: Converter, to_unit: Converter) -> float:
+    """Internal conversion code."""
     canonical = from_unit.to_canonical(magnitude)
     converted = to_unit.from_canonical(canonical)
     return float(converted)
 
 
 def sec_to_min(s: float) -> float:
+    """
+    Convert seconds into minutes.
+
+    >>> sec_to_min(120)
+    2.0
+    """
     return convert(s, "Second", "Minute")
 
 
 def sec_to_hour(s: float) -> float:
+    """
+    Convert seconds into hours.
+
+    >>> sec_to_hour(1800)
+    0.5
+    """
     return convert(s, "Second", "Hour")
 
 
 def sec_to_day(s: float) -> float:
+    """
+    Convert seconds into days.
+
+    >>> sec_to_day(1800)
+    0.020833333333333332
+    """
     return convert(s, "Second", "Day")
 
 
 def sec_to_week(s: float) -> float:
+    """
+    Convert seconds into weeks.
+
+    >>> sec_to_week(1800)
+    0.002976190476190476
+    """
     return convert(s, "Second", "Week")
 
 
 def min_to_sec(m: float) -> float:
+    """
+    Convert minutes into seconds.
+
+    >>> min_to_sec(5)
+    300.0
+    """
     return convert(m, "Minute", "Second")
 
 
 def min_to_hour(m: float) -> float:
+    """
+    Convert minutes into hours.
+
+    >>> min_to_hour(120)
+    2.0
+    """
     return convert(m, "Minute", "Hour")
 
 
 def min_to_day(m: float) -> float:
+    """
+    Convert minutes into days.
+
+    >>> min_to_day(60 * 12)
+    0.5
+    """
     return convert(m, "Minute", "Day")
 
 
 def min_to_week(m: float) -> float:
+    """
+    Convert minutes into weeks.
+
+    >>> min_to_week(60 * 24 * 3)
+    0.42857142857142855
+    """
     return convert(m, "Minute", "Week")
 
 
 def hour_to_sec(h: float) -> float:
+    """
+    Convert hours into seconds.
+
+    >>> hour_to_sec(1)
+    3600.0
+    """
     return convert(h, "Hour", "Second")
 
 
 def hour_to_min(h: float) -> float:
+    """
+    Convert hours into minutes.
+
+    >>> hour_to_min(2)
+    120.0
+    """
     return convert(h, "Hour", "Minute")
 
 
 def hour_to_day(h: float) -> float:
+    """
+    Convert hours into days.
+
+    >>> hour_to_day(36)
+    1.5
+    """
     return convert(h, "Hour", "Day")
 
 
 def hour_to_week(h: float) -> float:
+    """
+    Convert hours into weeks.
+
+    >>> hour_to_week(24)
+    0.14285714285714285
+    """
     return convert(h, "Hour", "Week")
 
 
 def day_to_sec(d: float) -> float:
+    """
+    Convert days into seconds.
+
+    >>> day_to_sec(1)
+    86400.0
+    """
     return convert(d, "Day", "Second")
 
 
 def day_to_min(d: float) -> float:
+    """
+    Convert days into minutes.
+
+    >>> day_to_min(0.1)
+    144.0
+    """
     return convert(d, "Day", "Minute")
 
 
 def day_to_hour(d: float) -> float:
+    """
+    Convert days into hours.
+
+    >>> day_to_hour(1)
+    24.0
+    """
     return convert(d, "Day", "Hour")
 
 
 def day_to_week(d: float) -> float:
+    """
+    Convert days into weeks.
+
+    >>> day_to_week(14)
+    2.0
+    """
     return convert(d, "Day", "Week")
 
 
 def week_to_sec(w: float) -> float:
+    """
+    Convert weeks into seconds.
+
+    >>> week_to_sec(10)
+    6048000.0
+    """
     return convert(w, "Week", "Second")
 
 
 def week_to_min(w: float) -> float:
+    """
+    Convert weeks into minutes.
+
+    >>> week_to_min(1)
+    10080.0
+    """
     return convert(w, "Week", "Minute")
 
 
 def week_to_hour(w: float) -> float:
+    """
+    Convert weeks into hours.
+
+    >>> week_to_hour(1)
+    168.0
+    """
     return convert(w, "Week", "Hour")
 
 
 def week_to_day(w: float) -> float:
+    """
+    Convert weeks into days.
+
+    >>> week_to_day(1)
+    7.0
+    """
     return convert(w, "Week", "Day")
 
 
 def f_to_c(temp_f: float) -> float:
-    """Fahrenheit to Celsius."""
+    """
+    Convert Fahrenheit into Celsius.
+
+    >>> f_to_c(32.0)
+    0.0
+    """
     return convert(temp_f, "Fahrenheit", "Celsius")
 
 
 def c_to_f(temp_c: float) -> float:
-    """Celsius to Fahrenheit."""
+    """
+    Convert Celsius to Fahrenheit.
+
+    >>> c_to_f(0.0)
+    32.0
+    """
     return convert(temp_c, "Celsius", "Fahrenheit")
+
+
+if __name__ == '__main__':
+    import doctest
+
+    doctest.testmod()