Exploring SI Units in Python Part 1: Base Units

The International System of Units, commonly known as the SI System, consists of seven base units for measuring quantities such as mass, time and electric current. These base units can be combined into derived units to measure a wide range of other quantities.

In this article I will develop a Python class to represent base unit values and associated methods. In a future article I will extend the project to handle derived units.

SI Units

As I mentioned above there are seven base units, as listed below.

QuantityUnitSymbolNotes
MasskilogramkgThe only base unit with a power suffix. This offends my sense of order and consistency!
LengthmetremThe distance travelled by light in a vacuum in 1/299792458 second.
TimesecondsYou may be familiar with milliseconds and even nanoseconds, but positive powers like megasecond aren't exactly commonplace. We tend to go with minutes, hours etc.
Thermodynamic TemperaturekelvinKUnits are the same size as those used for the Celsius (aka centigrade) scale, but start at absolute zero, ie the point at which there is no temperature. This is -273.18°C, or 273.18° below the freezing point of water.
Notice that the symbol is upper-case although the name of the unit is lower-case.
Electric CurrentampereAAs with kelvin the symbol is upper-case but the name of the unit is lower-case
Luminous IntensitycandelacdNot a very familiar unit so to give you a feeling for luminous intensity, 1 candela is approximately the brightness of a candle. If only everything was that easy to remember.
Amount of Substancemolemol1 mole is 6.02214076×1023 elementary particles of a substance. Those particles can be subatomic particles, ions, atoms or molecules. As an example, 1 litre of water is just over 50 moles, in this case measuring molecules of H2O.

There is also a wide range of prefixes representing both positive and negative powers. These are used to create more conventiently sized units although many unit/prefix combinations are not widely used. (When did you last hear anybody say "yottamole"?!)

I won't list the prefixes here because that would just be boring, but later we'll see a full list hard-coded in Python.

The Project

This project consists of the following files:

  • sibaseunit.py

  • siunits.py

The files can be downloaded as a zip from the Downloads page, or you can clone/download the Github repository if you prefer.

Source Code Links

ZIP File
GitHub

The purpose of this project is to create a class that will represent an amount of the required quantity along with any specified prefix. The class will also provide a number of arithmetic and comparison methods which can be safely used with units of the same quantity but different prefixes, for example adding metres and centimetres.

This class will also form the basis of a further project on derived units.

The Code

Let's get stuck into the code, starting with the SIBaseUnit class.

sibaseunit.py

from types import MappingProxyType


class SIBaseUnit(object):

    """
    The SIBaseUnit class provides properties for
    representing values of the 7 SI base units,
    multiplier prefixes such as kilo and nano,
    methods for string representation,
    and arithmetic and comparison operators
    """

    quantities = MappingProxyType({
                    "time":
                        {"name": "time", "baseunit": "second", "symbol": "s"},

                    "length":
                        {"name": "length", "baseunit": "metre", "symbol": "m"},

                    "mass":
                        {"name": "mass", "baseunit": "gram", "symbol": "g"},

                    "electric current":
                        {"name": "electric current", "baseunit": "ampere", "symbol": "A"},

                    "thermodynamic temperature":
                        {"name": "thermodynamic temperature", "baseunit": "kelvin", "symbol": "K"},

                    "amount of substance":
                        {"name": "amount of substance", "baseunit": "mole", "symbol": "mol"},

                    "luminous intensity":
                        {"name": "luminous intensity", "baseunit": "candela", "symbol": "cd"}
                  })


    prefixes = MappingProxyType({
                    "yotta": {"name": "yotta", "power": 24, "symbol": "Y"},
                    "zetta": {"name": "zetta", "power": 21, "symbol": "Z"},
                    "exa": {"name": "exa", "power": 18, "symbol": "E"},
                    "peta": {"name": "peta", "power": 15, "symbol": "P"},
                    "tera": {"name": "tera", "power": 12, "symbol": "T"},
                    "giga": {"name": "giga", "power": 9, "symbol": "G"},
                    "mega": {"name": "mega", "power": 6, "symbol": "M"},
                    "kilo": {"name": "kilo", "power": 3, "symbol": "k"},
                    "hecto": {"name": "hecto", "power": 2, "symbol": "h"},
                    "deca": {"name": "deca", "power": 1, "symbol": "da"},

                    "none": {"name": "none", "power": 0, "symbol": ""},

                    "deci": {"name": "deci", "power": -1, "symbol": "d"},
                    "centi": {"name": "centi", "power": -2, "symbol": "c"},
                    "milli": {"name": "milli", "power": -3, "symbol": "m"},
                    "micro": {"name": "micro", "power": -6, "symbol": "undefined"},
                    "nano": {"name": "nano", "power": -9, "symbol": "n"},
                    "pico": {"name": "pico", "power": -12, "symbol": "p"},
                    "femto": {"name": "femto", "power": -15, "symbol": "f"},
                    "atto": {"name": "atto", "power": -18, "symbol": "a"},
                    "zepto": {"name": "zepto", "power": -21, "symbol": "z"},
                    "yocto": {"name": "yocto", "power": -24, "symbol": "y"}
               })


    def __init__(self, quantity, prefix, value = 0):

        """
        quantity is one of the quantities values,
        eg SIBaseUnit.quantities["length"]

        prefix is one of the prefixes values,
        eg SIBaseUnit.prefixes["centi"]
        """

        self._quantity = quantity
        self._prefix = prefix
        self._value_base = self.prefixed_value_to_base(value, prefix)


    @property
    def value(self):
        return self.base_value_to_prefixed(self._value_base, self._prefix)
    @value.setter
    def value(self, value):
        self._value_base = self.prefixed_value_to_base(value, self._prefix)


    @property
    def prefix(self):
        return self._prefix


    def __repr__(self):
        return f'SIBaseUnit("{self._quantity}","{self._prefix}",{self.base_value_to_prefixed()})'


    def __str__(self):

        return self.string_short()


    def string_long(self):

        if self._prefix['name'] != "none":
            prefix = self._prefix['name']
        else:
            prefix = ""

        return f"{self.base_value_to_prefixed(self._value_base, self._prefix):g} {prefix}{self._quantity['baseunit']}"


    def string_short(self):

        if self._prefix['name'] != "none":
            symbol = self._prefix['symbol']
        else:
            symbol = ""

        return f"{self.base_value_to_prefixed(self._value_base, self._prefix ):g}{symbol}{self._quantity['symbol']}"


    # selection of arithmetic and comparison dunder methods


    def __add__(self, other):

        self.__check_quantities(other)

        base_1 = self.prefixed_value_to_base(self.value, self.prefix)
        base_2 = self.prefixed_value_to_base(other.value, other.prefix)
        base_total = base_1 + base_2
        prefixed_total = self.base_value_to_prefixed(base_total, self.prefix)

        return SIBaseUnit(self._quantity, self._prefix, prefixed_total)


    def __sub__(self, other):

        self.__check_quantities(other)

        base_1 = self.prefixed_value_to_base(self.value, self.prefix)
        base_2 = self.prefixed_value_to_base(other.value, other.prefix)
        base_difference = base_1 - base_2
        prefixed_difference = self.base_value_to_prefixed(base_difference, self.prefix)

        return SIBaseUnit(self._quantity, self._prefix, prefixed_difference)


    def __mul__(self, value):

        base_1 = self.prefixed_value_to_base(self.value, self.prefix)
        base_product = base_1 * value
        prefixed_product = self.base_value_to_prefixed(base_product, self.prefix)

        return SIBaseUnit(self._quantity, self._prefix, prefixed_product)


    def __rmul__(self, value):

        base_1 = self.prefixed_value_to_base(self.value, self.prefix)
        base_product = base_1 * value
        prefixed_product = self.base_value_to_prefixed(base_product, self.prefix)

        return SIBaseUnit(self._quantity, self._prefix, prefixed_product)


    def __truediv__(self, value):

        base_1 = self.prefixed_value_to_base(self.value, self.prefix)
        base_quotient = base_1 / value
        prefixed_quotient = self.base_value_to_prefixed(base_quotient, self.prefix)

        return SIBaseUnit(self._quantity, self._prefix, prefixed_quotient)


    def __lt__(self, other):

        self.__check_quantities(other)

        base_1 = self.prefixed_value_to_base(self.value, self.prefix)
        base_2 = self.prefixed_value_to_base(other.value, other.prefix)

        return base_1 < base_2


    def __le__(self, other):

        self.__check_quantities(other)

        base_1 = self.prefixed_value_to_base(self.value, self.prefix)
        base_2 = self.prefixed_value_to_base(other.value, other.prefix)

        return base_1 <= base_2


    def __eq__(self, other):

        self.__check_quantities(other)

        base_1 = self.prefixed_value_to_base(self.value, self.prefix)
        base_2 = self.prefixed_value_to_base(other.value, other.prefix)

        return base_1 == base_2


    def __gt__(self, other):

        self.__check_quantities(other)

        base_1 = self.prefixed_value_to_base(self.value, self.prefix)
        base_2 = self.prefixed_value_to_base(other.value, other.prefix)

        return base_1 > base_2


    def __ge__(self, other):

        self.__check_quantities(other)

        base_1 = self.prefixed_value_to_base(self.value, self.prefix)
        base_2 = self.prefixed_value_to_base(other.value, other.prefix)

        return base_1 >= base_2


    def __ne__(self, other):

        self.__check_quantities(other)

        base_1 = self.prefixed_value_to_base(self.value, self.prefix)
        base_2 = self.prefixed_value_to_base(other.value, other.prefix)

        return base_1 != base_2


    def __check_quantities(self, other):

        if self._quantity != other._quantity:
            raise TypeError('Both units must be of same quantity')


    def output(self):

        print(f"quantity:      {self._quantity['name']}")
        print(f"base unit:     {self._quantity['baseunit']}")
        print(f"prefix name:    {self._prefix['name']}")
        print(f"prefix power:   {self._prefix['power']}")
        print(f"value_base:    {self._value_base:f}")
        print(f"value prefixed: {self.base_value_to_prefixed(self._value_base, self._prefix):f}")


    @staticmethod
    def prefixed_value_to_base(value_prefixed, prefix):

        return value_prefixed * 10 ** prefix['power']


    @staticmethod
    def base_value_to_prefixed(value_base, prefix):

        return value_base / (10 ** prefix['power'])

This isn't a hugely long class but there's a lot to discuss so I'll work through each point one at a time.

quantities

This is a dictionary (wrapped in an immutable MappingProxyType) of the seven base units, each containing a "sub-dictionary" of details.

prefixes

Another MappingProxyType wrapped dictionary, this time for the prefixes including "none".

__init__

The __init__ method takes a value from the quantities and prefixes dictionaries, and also a value representing the amount of the quantity. This value is stored internally in the base unit, irrespective of any prefix. The conversion is done using the prefixed_value_to_base method which we'll see later.

As I mentioned in my (slightly tetchy?) comment the SI unit of mass is the kilogram, not gram. For the purposes of this project I have assumed common sense prevailed so used the gram for base values of mass. No doubt the SI Police will be after me soon.

Properties

There are two properties, value and prefix. value is settable and converted to the base unit. The prefix property is gettable only.

__repr__ and __str__

The __repr__ method combines the properties into a string representation of the object, and __str__ simply calls string_short which we'll see next.

string_long and string_short

The string_long method returns the value and the long version of the unit name, for example kilogram or millimetre. The string_short method is similar but uses the abbreviation such as kg or mm.

Arithmetic and Comparison Operators

If you are writing a class that represents objects which can have arithmetic or comparison operators carried out on them then you can easily implement these operations with dunder methods, ie methods with names that start and end with double underscores.

The methods have meaningful names like __add__ for addition and __lt__ for less than. Within these methods you can do whatever you need to achieve the correct result, and here it is necessary in most cases to check that the two SIBaseUnit objects represent the same quantity so we don't, for example, try to add a mass and a length. This is done by a separate function, __check_quantities. However, the prefixes can be different so values need to be converted to base values before operations are carried out, and converted back to prefixed values before being returned.

Once these dunder methods are implemented we can use symbols such as + and < with instances of the class.

There are two dunder methods for multiplication: __mul__ and __rmul__. The first is for when the object is on the left of the expression and multiplier on the right, and the second is for when they are reversed. This is an example snippet where length is an instance of SIBaseUnit.

__mul__ and __rmul__

length * 4 # __mul__ is called
6 * length # __rmul__ is called

Another slightly mysteriously named dunder method is __truediv__ which is normal rather than floor division. It is used when the / operator is used rather than the // operator. (If you need floor division implement __floordiv__. There was also a __div__ in Python 2.x which is now obsolete.)

__check_quantities

As I mentioned most of the dunder methods work on two instances of SIBaseUnit so need to check the quantities are the same. To avoid repetition this check is split off into a separate function which raises a TypeError if they are different.

output

This method prints out all the information there is on the current instance. You probably wouldn't use it in production code but it's useful for demoing and testing.

Converting to and from Prefixed Values

Finally we have a couple of static methods for converting between base values and prefixed values.

Let's Try it Out

That's the SIBaseUnit class finished so let's write a bit of code to show it in action.

siunits.py

import sibaseunit as sibu


def main():

    print("----------------------")
    print("| codedrome.com      |")
    print("| SI Units in Python |")
    print("| Part 1: Base Units |")
    print("----------------------\n")


    quantities()

    # add_and_subtract()

    # add_error()

    # multiply_and_divide()

    # comparison_operators()


def quantities():

    print("time\n====")

    time = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["time"],
                           prefix = sibu.SIBaseUnit.prefixes["nano"],
                           value = 2800000000)

    print(f"string_long:  {time.string_long()}")
    print(f"string_short: {time.string_short()}")

    print("output:")
    time.output()


    print("\nlength\n======")

    length = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["length"],
                             prefix = sibu.SIBaseUnit.prefixes["kilo"],
                             value = 1)

    print(f"string_long:  {length.string_long()}")
    print(f"string_short: {length.string_short()}")

    print("\noutput:")
    length.output()


    print("\nmass\n====")

    mass = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["mass"],
                           prefix = sibu.SIBaseUnit.prefixes["milli"],
                           value = 500)

    print(f"string_long:  {mass.string_long()}")
    print(f"string_short: {mass.string_short()}")

    print("\noutput:")
    mass.output()


    print("\nelectric current\n================")

    current = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["electric current"],
                              prefix = sibu.SIBaseUnit.prefixes["none"],
                              value = 13)

    print(f"string_long:  {current.string_long()}")
    print(f"string_short: {current.string_short()}")

    print("\noutput:")
    current.output()


    print("\nthermodynamic temperature\n=========================")

    temperature = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["thermodynamic temperature"],
                              prefix = sibu.SIBaseUnit.prefixes["none"],
                              value = 273.15)

    print(f"string_long:  {temperature.string_long()}")
    print(f"string_short: {temperature.string_short()}")

    print("\noutput:")
    temperature.output()


    print("\namount of substance\n===================")

    amount = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["amount of substance"],
                              prefix = sibu.SIBaseUnit.prefixes["none"],
                              value = 25)

    print(f"string_long:  {amount.string_long()}")
    print(f"string_short: {amount.string_short()}")

    print("\noutput:")
    amount.output()


    print("\nluminous intensity\n===================")

    luminousintensity = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["luminous intensity"],
                              prefix = sibu.SIBaseUnit.prefixes["none"],
                              value = 135)

    print(f"string_long:  {luminousintensity.string_long()}")
    print(f"string_short: {luminousintensity.string_short()}")

    print("\noutput:")
    luminousintensity.output()


def add_and_subtract():

    l_1 = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["length"],
                          prefix = sibu.SIBaseUnit.prefixes["centi"],
                          value = 12)

    l_2 = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["length"],
                          prefix = sibu.SIBaseUnit.prefixes["milli"],
                          value = 30)

    total = l_1 + l_2

    print(f"l_1:       {l_1}")
    print(f"l_2:       {l_2}")
    print(f"l_1 + l_2: {total}")

    difference = l_1 - l_2

    print(f"l_1 - l_2: {difference}")


def add_error():

    length = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["length"],
                          prefix = sibu.SIBaseUnit.prefixes["centi"],
                          value = 30)

    mass = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["mass"],
                          prefix = sibu.SIBaseUnit.prefixes["kilo"],
                          value = 2)

    print(f"length:        {length}")
    print(f"mass:          {mass}")

    # this will raise an error as we are
    # trying to add a mass to a length
    try:
        total = length + mass
        print(f"length + mass: {total}")
    except TypeError as e:
        print(e)


def multiply_and_divide():

    l_1 = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["length"],
                          prefix = sibu.SIBaseUnit.prefixes["centi"],
                          value = 16)

    print(f"l_1:     {l_1}")

    product = l_1 * 4
    print(f"l_1 * 4: {product}")

    product = 6 * l_1
    print(f"6 * l_1: {product}")

    quotient = l_1 / 4
    print(f"l_1 / 4: {quotient}")


def comparison_operators():

    l_1 = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["length"],
                          prefix = sibu.SIBaseUnit.prefixes["centi"],
                          value = 12)

    l_2 = sibu.SIBaseUnit(quantity = sibu.SIBaseUnit.quantities["length"],
                          prefix = sibu.SIBaseUnit.prefixes["milli"],
                          value = 30)

    print(f"{l_1.string_short()} < {l_2.string_short()}    {l_1 < l_2}")
    print(f"{l_1.string_short()} <= {l_2.string_short()}   {l_1 <= l_2}")
    print(f"{l_1.string_short()} == {l_2.string_short()}   {l_1 == l_2}")
    print(f"{l_1.string_short()} > {l_2.string_short()}    {l_1 > l_2}")
    print(f"{l_1.string_short()} >= {l_2.string_short()}   {l_1 >= l_2}")
    print(f"{l_1.string_short()} != {l_2.string_short()}   {l_1 != l_2}")


if __name__ == "__main__":

    main()

quantities

There are five functions here which are called in main. We can uncomment each one in turn and run it.

The first is quantities which creates 7 instances of SIBaseUnit, one for each quantity, before calling their string_long, string_short and output methods.

Now run the program:

Running the Code

python3 siunits.py

This is the output, showing only the first object, time.

Program Output 1: quantities() (truncated)

----------------------
| codedrome.com      |
| SI Units in Python |
| Part 1: Base Units |
----------------------

time
====
string_long:  2.8e+09 nanosecond
string_short: 2.8e+09ns
output:
quantity:      time
base unit:     second
prefix name:    nano
prefix power:   -9
value_base:    2.800000
value prefixed: 2800000000.000000

add_and_subtract

The next function creates two SIBaseUnit objects representing lengths, but with different prefixes: the first is centimetres and the second in millimetres. However, we can still add and subtract them and get the correct results which will have the suffix of the first instance, in this case centimetres.

Uncomment add_and_subtract and run the program again.

Program Output 2: add_and_subtract()

----------------------
| codedrome.com      |
| SI Units in Python |
| Part 1: Base Units |
----------------------

l_1:       12cm
l_2:       30mm
l_1 + l_2: 15cm
l_1 - l_2: 9cm

add_error

Just to demonstrate what happens if you try to carry out operations on units with different quantities I have attempted to add a length and a mass, within a try/except block which prints any error. As you can see if you run the function the error message is shown.

Program Output 3: add_error()

----------------------
| codedrome.com      |
| SI Units in Python |
| Part 1: Base Units |
----------------------

length:        30cm
mass:          2kg
Both units must be of same quantity

multiply_and_divide

These two operations work on an instance of SIBaseUnit and a numeric value, rather than two SIBaseUnit objects. This function shows the operations in action including the alternative "flavour" of multiplication where the multiplier comes first.

Program Output 4: multiply_and_divide()

----------------------
| codedrome.com      |
| SI Units in Python |
| Part 1: Base Units |
----------------------

l_1:     16cm
l_1 * 4: 64cm
6 * l_1: 96cm
l_1 / 4: 4cm

comparison_operators

Finally let's look at the six comparison operators. As with addition and subtraction they can safely be used when units have different prefixes.

Program Output 5: comparison_operators()

----------------------
| codedrome.com      |
| SI Units in Python |
| Part 1: Base Units |
----------------------

12cm < 30mm    False
12cm <= 30mm   False
12cm == 30mm   False
12cm > 30mm    True
12cm >= 30mm   True
12cm != 30mm   True

So now we have a class which can handle the SI base units together with a wide selection of prefixes and operations. Next I'll combine them into derived units such as metre per second.