2
\$\begingroup\$

ToTemp is a temperature conversion package with Celsius, Delisle, Fahrenheit, Kelvin, Rankine, Réaumur, Newton and Rømer scales. With a documentation and already in PyPI.

The source code for the main implementation here, since is too long to be completely shown, it is basically a ABC for Abstract Temperature Scales, which all scales inhehit from.

Here's a snippet of it (ommited most methods and docs for brevity, besides for __init_subclass__, __init__, __repr__, __str__, __add__, convert_to and to_fahrenheit):

from __future__ import annotations

from abc import ABCMeta, abstractmethod
from typing import Any, ClassVar, TypeVar

T = TypeVar('T', bound='AbstractTemperature')


class AbstractTemperature(metaclass=ABCMeta):
    """
    ...
    """

    _symbol: ClassVar[str]
    _value: float

    @classmethod
    def __init_subclass__(cls, **kwargs: object) -> None:
        """Ensures subclasses set the `_symbol` attribute."""
        super().__init_subclass__(**kwargs)
        try:
            _ = cls._symbol
        except AttributeError:
            raise AttributeError(
                'Temperature subclasses must set the `_symbol` class attribute'
            ) from None

    def __init__(self, value: float) -> None:
        self._value = value

    def __str__(self) -> str:
        """
        ...
        """
        return f'{self._value} {self._symbol}'

    def __repr__(self) -> str:
        """
        ...
        """
        return f'{self.__class__.__name__}({self._value})'

    def __add__(self: T, other: Any) -> T:
        """
        Returns a new instance of the same class with the sum of the values.
        If `other` is a temperature instance, it is first converted to the
        calling class, then the values are added.
        Otherwise, an attempt is made to add `other` to the value directly.

        Notes
        -----
        If `other` is not a temperature instance, atempts to return: cls(self._value + other)

        Returns
        -------
        self.__add__(other) : T
            cls(self._value + other.convert_to(cls).value)
        """
        cls = self.__class__
        try:
            if isinstance(other, AbstractTemperature):
                return cls(self._value + other.convert_to(cls).value)
            return cls(self._value + other)
        except TypeError:
            return NotImplemented

    def to_fahrenheit(self) -> Fahrenheit:
        """
        Returns a Fahrenheit object which contains the class attribute "value"
        with the result from the conversion typed the same as the attribute.

        Returns
        -------
        convert_to(Fahrenheit) : Fahrenheit
        """
        return self.convert_to(Fahrenheit)

    @abstractmethod
    def convert_to(self, temp_cls: type[T]) -> T:
        """
        Returns an instance of `temp_cls` containing the converted value.
        If no conversion to `temp_cls` is possible, `TypeError` is raised.
        """
        ...

This package aims to bring the simple and straight to the point, but precise, Object Oriented experience of working with temperature scale data types.

First of all, install the package:

pip install totemp

The instances:

from totemp import Celsius, Fahrenheit

if __name__ == '__main__':
    temps: list = [Celsius(12), Celsius(25), Celsius(50)]
    print(temps[0])  # '12 ºC'
    print(temps)  # [Celsius(12), Celsius(25), Celsius(50)]

    temps = list(map(Celsius.to_fahrenheit, temps))
    print(temps[0])  # '53.6 ºF'
    print(temps)  # [Fahrenheit(53.6), Fahrenheit(77.0), Fahrenheit(122.0)]

It's representations and properties:

Property symbol is read-only.

from totemp import Fahrenheit

if __name__ == '__main__':
    temp0 = Fahrenheit(53.6)
    print(temp0.__repr__())  # 'Fahrenheit(53.6)'
    print(temp0.__str__())  # '53.6 ºF'
    print(temp0.symbol)  # 'ºF'
    print(temp0.value)  # 53.6

Comparision operations ('==', '!=', '>', '>=', '<',...):

The comparision/arithmetic implementation attempts to convert the value of other (if it is a temperature instance) and then evaluate the expression.

import totemp as tp

if __name__ == '__main__':
    temp0, temp1 = tp.Celsius(0), tp.Fahrenheit(32)

    print(f'temp0: {repr(temp0)}')  # Celsius(0)
    print(f'temp1: {repr(temp1.to_celsius())}')  # Celsius(0.0)

    print(temp0 != temp1)  # False

    print(temp0 > temp1)  # False

    print(temp0 < temp1)  # False

    print(temp0 >= temp1)  # True

    print(temp0 <= temp1)  # True

    print(temp0 == temp1)  # True

Arithmetic operations ('+', '-', '*', '**', '/', '//', '%', ...):

from totemp import Newton, Rankine

if __name__ == '__main__':
    temp0 = Newton(33)
    temp1 = Rankine(671.67)

    temp2 = temp0 + temp1

    print('temp2:', temp2)  # temp2: 65.99999999999999 ºN
    print('temp2:', repr(temp2))  # temp2: Newton(65.99999999999999)
    print('temp2:', temp2.value, temp2.symbol)  # temp2: 65.99999999999999 ºN

    print((temp0 + temp1).rounded())  # 66 ºN
    print(repr((temp0 + temp1).rounded()))  # Newton(66)

    print(temp2 + 12.55)  # 78.54999999999998 ºN
    print((12 + temp2.rounded()))  # 78 ºN

ToTemp classes can work with many built-in Python functions:

from math import floor, ceil, trunc

from totemp import Reaumur

if __name__ == '__main__':
    temp = Reaumur(100.4)

    float(temp)  # 100.4
    int(temp)  # 100
    round(temp)  # Reaumur(100)
    abs(temp)  # Reaumur(100)
    floor(temp)  # Reaumur(100)
    ceil(temp)  # Reaumur(101)
    trunc(temp)  # Reaumur(100)
    divmod(temp, temp0 := Reaumur(25.1))  # (Reaumur(4.0), Reaumur(0.0))

Temperature Instance Conversions:

import totemp

if __name__ == '__main__':
    temp = totemp.Fahrenheit(32)

    print(temp.to_celsius())  # 0.0 ºC
    print(temp.to_fahrenheit())  # 32 ºF
    print(temp.to_delisle())  # 150.0 ºDe
    print(temp.to_kelvin())  # 273.15 K
    print(temp.to_newton())  # 0.0 ºN
    print(temp.to_rankine())  # 491.67 ºR
    print(temp.to_reaumur())  # 0.0 ºRé
    print(temp.to_romer())  # 7.5 ºRø

And that's it, that's my first ever project, some feedback or collaborations would be wonderfull! Proud of doing this, always gratefull for the friends and other programmer dudes of the internet that helped me make this project happen.

\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

Tests

You test that a value in corresponds to an expected value out, but where did you get the calculated value out? Was it from your code? If so, it's only testing regression.

I'd prefer to see some tests based on the scales' definitions themselves (i.e. freezing/boiling pt of water) are in agreement, along with some other known quantities (e.g. 0K to check nothing reasonably goes below 0K on conversion [though this is possible in limited contexts unlikely to occur here], human body temp, other melting/boiling points) as well as some arbitrary points on the scale for regression testing.

You should also probably do conversion there and back i.e. degC -> degF -> degC to check that both formulae are implemented correctly with tolerable accuracy.

You also only test coversions, not any of your comparisons or arithmetic ops.

Your test function takes a strange input of a 4-tuple, which is really two 2-tuples to be compared. This would make much more sense to me as a map (dict) of input->output, this also makes them iterable as pairs e.g.

def func_to_test_precise_rounded_results(mapping):
    for inp, outp in mapping.items():
       if inp != outp: # Rather than `not inp == outp`
            errors.append(f'{inp} != {outp} -> should be equal')

This could then be called as:

temps = {
         Romer(25).to_newton(): Newton(11.0),
         Romer(25).to_newton().rounded(): Newton(11),
        }
func_to_test_precise_rounded_results(temps)

Which, to me, makes the intent more clear and also allows testing as many comparisons as you'd like.

Also, to me, the name func_to_test_precise_rounded_results is overly verbose with no extra detail. We know it's a func because we give it a verb name. It doesn't test precise vs rounded results, because those are manually provided. It compares the first 2 and last 2 elements of a 4-tuple.

Implementation

A common way to do lots of conversions is to have an internal value which serves as your standard format, say you picked K. You would then convert any value stored to K and then back out to unit. This means instead of having to implement new converters for every unit to every other:

C -> F, F -> C, C -> K, K -> C, F -> K, K -> F   [combinatoric growth]

You just need:

C -> K, F -> K, K -> C, K -> F                   [2N-1]

Then C -> F is implemented as C -> K -> F. This does mean you can lose some accuracy over repeated operations, but in general this doesn't cause too much of a problem.

Arithmetic operations

You have implemented arithmetic operations, but what does an arithmetic operation mean on a physical quantity? 3m * 3m != 9m! It's 9m^2. Likewise for division and who knows what a mod (%) does to the units.

Misc

You have slightly inconsistent renderings of some of your formulae which made me have to double check whether they were right

class Celsius(AbstractTemperature):
    ...
    def convert_to(self, temp_cls: type[T]) -> T:
        ...
        if temp_cls is Newton:
            return temp_cls(self._value * 33 / 100)
class Newton(AbstractTemperature):
    ...
    def convert_to(self, temp_cls: type[T]) -> T:
        if temp_cls is Celsius:
            return temp_cls(self._value / 0.33)

Consistency of style can be very helpful.

Converting to Newton scale is unlikely to be consistent across the board as it's a relatively simple scale with 2 different metrics.

Some points

  1. 0degC = 0degN
  2. 100degC = 33degN (where you get 1/3)
  3. 271degC = 81degN (81*3 = 243) - Melting pt of bismuth
  4. 327degC = 96degN (96*3 = 288) - Melting pt of lead
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.