Welcome, guest | Sign In | My Account | Store | Cart

Programs that deal with measured quantities usually make an implicit assumption of the measurement unit, a practice which is error prone, inflexible and cumbersome. This metaclass solution takes the burden of dealing with measurement units from the user, by associating each quantity to a unit of some measure. Operations on instances of these measures are unit-safe; moreover, unit conversions take place implicitly.

Python, 221 lines
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
def demo():
    # The definition of measure classes is straightforward, for example:
    class Length(object):
        __metaclass__ = MeasureType
        DEFAULT_UNIT = "m"
        _TO_DEFAULT = { "mm":0.001, "cm":0.01, "km":1000,
                        "in":0.0254, "ft":0.3048, "yd":0.9144, "mi":1609.34
                      }
    
    l1 = Length(12.3,'cm')
    l2 = Length(50,'mm')
    Length.setDefaultUnit("in")
    print l1+l2             # "6.811024 in"
    print 3*l2.value("m")   # "0.15"
    print l1>l2             # "True"

    # Measures with arbitrary unit conversion functions, like temperature,
    # are also supported:
    class Temperature(object):
        __metaclass__ = MeasureType
        DEFAULT_UNIT = "Celsius"
        _TO_DEFAULT =   { "Fahrenheit" : lambda tf: 5.0/9.0 * (tf-32) }
        _FROM_DEFAULT = { "Fahrenheit" : lambda tc: 9.0/5.0 * tc + 32 }

#todo:
# - support for unit prefixes (e.g. "c" for 10^-2, "k" for 10^3", etc.);
#   thus "cm", "km", etc. won't have to be specified for Length
# - unit name aliases (e.g. "kilometers","km")
# - compound measures: 
#   a. Compound measure definition (e.g. Velocity)
#   b. Modify *,/ to be allowed between any Measure instances (e.g. Length/Duration)
#   c. Combine a and b (?)
# - thorough unit testing

__author__ = "George Sakkis"

class MeasureType(type):
    '''Metaclass for supporting unit-safe measure objects. Classes generated with 
      this as metaclass (e.g. Length, Area, Temperature, etc.) must define the 
      following class-scope variables:
      1.  DEFAULT_UNIT (a string), and 
      2a. either _TO_DEFAULT, a dictionary that maps each other unit to
          - either a constant number, which is interpreted as the ratio of the
            unit to the DEFAULT_UNIT
          - or a function, for more complicate conversions from the unit to
            DEFAULT_UNIT (e.g. from Celsius to Fahrenheit).
       2b. or _FROM_DEFAULT. Inverse of _TO_DEFAULT, maps each unit
            (other than the DEFAULT_UNIT) to the function that
            returns the measure in this unit, given the DEFAULT_UNIT
            (e.g. from Fahrenheit to Celsius).
      If there is at least one function present, both _TO_DEFAULT and _FROM_DEFAULT
      have to be given (so that both the function and its inverse are defined).'''
    
    def __new__(cls,classname,bases,classdict):
        defUnit = classdict["DEFAULT_UNIT"]
        toDefault = classdict.get("_TO_DEFAULT") or {}
        fromDefault = classdict.get("_FROM_DEFAULT") or {}
        units = uniq([defUnit] + toDefault.keys() + fromDefault.keys())
        restUnits = units[1:]
        # form convertion matrix
        conversion_matrix = {}
        # 1. non-default unit -> default unit
        for unit in restUnits:
            try: conversion_matrix[(unit,defUnit)] = toDefault[unit]
            except KeyError:
                try: conversion_matrix[(unit,defUnit)] = 1.0 / fromDefault[unit]
                except (KeyError,TypeError): raise LookupError, "Cannot convert %s to %s" % (unit,defUnit)
        # 2. default unit -> non-default unit
        for unit in restUnits:
            # check if toDefault[unit] is number
            try: conversion_matrix[(defUnit,unit)] = fromDefault[unit]
            except KeyError:
                try: conversion_matrix[(defUnit,unit)] = 1.0 / toDefault[unit]
                except (KeyError,TypeError): raise LookupError, "Cannot convert %s to %s" % (defUnit,unit)
        # 3. non-default unit -> non-default unit
        for fromUnit in restUnits:
            for toUnit in restUnits:
                if fromUnit != toUnit:
                    try: conversion_matrix[(fromUnit,toUnit)] = conversion_matrix[(fromUnit,defUnit)] \
                                                               * conversion_matrix[(defUnit,toUnit)]
                    except TypeError: 
                        conversion_matrix[(fromUnit,toUnit)] = \
                                lambda value: conversion_matrix[(defUnit,toUnit)](
                                                conversion_matrix[(fromUnit,defUnit)](value))
        ## print conversion_matrix
        # 4. convert all constants c to functions lambda x: c*x
        for (k,v) in conversion_matrix.iteritems():
            try: conversion_matrix[k] = lambda x,i=float(v): x*i
            except (TypeError,ValueError): pass
        # update classdict                                        
        assert "_CONVERT_UNITS" not in classdict
        classdict["_CONVERT_UNITS"] = conversion_matrix
        classdict["_UNITS"] = units
        if "_TO_DEFAULT" in classdict: del classdict["_TO_DEFAULT"]
        if "_FROM_DEFAULT" in classdict: del classdict["_FROM_DEFAULT"]
        # set Measure to be base a class of cls
        bases = tuple(uniq((MeasureType.Measure,) + bases))
        return type.__new__(cls,classname,bases,classdict)

    class Measure(object):
        '''The superclass of every Measure class. Concrete measure classes
           don't have to specify explicitly this class as their superclass; 
           it is added implicitly by the MeasureType metaclass.'''
        
        __slots__  = "_value", "_unit"
        
        def setDefaultUnit(cls,unit):
            if unit != cls.DEFAULT_UNIT:
                cls.__verifyUnit(unit)
                cls.DEFAULT_UNIT = unit
        setDefaultUnit = classmethod(setDefaultUnit)

        def value(self, unit=None):
            if unit == None: unit = self.__class__.DEFAULT_UNIT
            self.__verifyUnit(unit)
            return self.__convert(self._value, self._unit, unit)

        def __init__(self, value, unit=None):
            if unit == None: unit = self.__class__.DEFAULT_UNIT
            self.__verifyUnit(unit)
            self._unit = self.__class__.DEFAULT_UNIT
            self._value = self.__convert(value, unit, self._unit)
                        
        def __str__(self,unit=None):
            if unit == None: unit = self.__class__.DEFAULT_UNIT
            return "%f %s" % (self.value(unit), unit)
        
        def __abs__(self): return self.__class__(abs(self._value), self._unit)
        
        def __neg__(self): return self.__class__(-self._value, self._unit)
    
        def __cmp__(self,other):
            try: return cmp(self._value, self.__coerce(other))
            except ValueError: return cmp(id(self),id(other))

        def __add__(self,other):
            try: return self.__class__(self._value + self.__coerce(other), self._unit)
            except ValueError: raise ValueError, "can only add '%s' (not '%s') to '%s'" \
                                                % (self.__class__.__name__,
                                                   other.__class__.__name__,
                                                   self.__class__.__name__)
        def __sub__(self,other):
            try: return self.__class__(self._value - self.__coerce(other), self._unit)
            except ValueError: raise ValueError, "can only subtract '%s' (not '%s') from '%s'" \
                                                % (self.__class__.__name__,
                                                   other.__class__.__name__,
                                                   self.__class__.__name__)
        def __iadd__(self,other):
            try: self._value += self.__coerce(other)
            except ValueError: raise ValueError, "can only increment '%s' (not '%s') by '%s'" \
                                                % (self.__class__.__name__,
                                                   other.__class__.__name__,
                                                   self.__class__.__name__)
    
        def __isub__(self,other):
            try: self._value -= self.__coerce(other)
            except ValueError: raise ValueError, "can only decrement '%s' (not '%s') by '%s'" \
                                                % (self.__class__.__name__,
                                                   other.__class__.__name__,
                                                   self.__class__.__name__)
    
        def __mul__(self,other):
            try: return self.__class__(self._value * float(other), self._unit)
            except TypeError: raise ValueError, "can only multiply '%s' (not '%s') by a number" \
                                                % (self.__class__.__name__, other.__class__.__name__)
            
        __rmul__ = __mul__        
                        
        def __div__(self,other):
            try: return self.__class__(self._value / float(other), self._unit)
            except TypeError: raise ValueError, "can only divide '%s' (not '%s') by a number" \
                                                % (self.__class__.__name__, other.__class__.__name__)
        def __imul__(self,other):
            try: self._value *= float(other)
            except TypeError: raise ValueError, "can only multiply '%s' (not '%s') by a number" \
                                                % (self.__class__.__name__, other.__class__.__name__)
    
        def __idiv__(self,other):
            try: self._value /= float(other)
            except TypeError: raise ValueError, "can only divide '%s' (not '%s') by a number" \
                                                % (self.__class__.__name__, other.__class__.__name__)
        
        def __pow__(self,other):
            try: return self.__class__(self._value ** float(other), self._unit)
            except TypeError: raise ValueError, "can only raise '%s' to a number (not '%s')" \
                                                % (self.__class__.__name__, other.__class__.__name__)
            
        def __ipow__(self,other):
            try: self._value **= float(other)
            except TypeError: raise ValueError, "can only raise '%s' to a number (not '%s')" \
                                                % (self.__class__.__name__, other.__class__.__name__)
            
        ################################## 'private' Methods ##################################
    
        def __convert(cls, value, fromUnit, toUnit):
            if fromUnit == toUnit: return value
            else: return cls._CONVERT_UNITS[(fromUnit,toUnit)](value)
        __convert = classmethod(__convert)
        
        def __verifyUnit(cls,unit):
            if unit not in cls._UNITS:
                raise ValueError, "'%s' is not a recognized %s unit (pick one from %s)" % \
                                  (unit, cls.__name__, ', '.join(["'%s'" % u for u in cls._UNITS]))
        __verifyUnit = classmethod(__verifyUnit)
        
        def __coerce(self,other):
            if type(self) != type(other) or not isinstance(self,MeasureType.Measure):
                raise ValueError
            return self.__convert(other._value,other._unit,self._unit)


def uniq(sequence):
    '''Removes the duplicates from the given sequence, preserving the order
       of the remaining elements.'''
    dict = {}
    try:
        return [dict.setdefault(i,i) for i in sequence if i not in dict]
    except TypeError:   # unhashable item(s)
        return [dict.setdefault(repr(i),i) for i in sequence if repr(i) not in dict]

if __name__ == '__main__': demo()

The usual way of dealing with measures of various kinds (e.g. length, angle, duration,etc.) is by implying a fixed unit (e.g. meters,rad,seconds) and using simple float numbers for the quantities. That's not a problem as long as all parts of the program use the same unit (though shifting to another unit may require changes in several parts). However, it is much more error-prone if more than one units of the same measure are involved, and different modules interpret such numbers in different ways. Also, applying the necessary unit conversions explicitly results in less clear code.

This metaclass solution takes the burden of dealing with units from the user, by associating each quantity to a unit of some measure. Operations on instances of these measures are unit-safe; moreover, unit conversions take place implicitly.

The release is in alpha state; the documentation is scarce and there may be some bugs around. Also, there are several possible useful extensions to be done (see the comments for a tentative "todo" list).

4 comments

Tim Hoffman 20 years, 1 month ago  # | flag

Have a look at Unum heaps of work already done. Hi

Its good to see these types of advanced recipes, but if you are interested in this type of thing it would probably pay to have a look at the Unum module.

http://home.tiscali.be/be052320/Unum.html

Tim

George Sakkis (author) 20 years, 1 month ago  # | flag

As you can guess, I didn't know of it; it looks neat indeed. Thanks, I'll check it out.

George

henry crun 20 years, 1 month ago  # | flag

There was a very good HP/Agilent paper on the topic of units and unit maths.

This is very useful for interactive use. For programs you would often want to use it like an assert ie waste time doing unit computations during debug or first run.

David Eyk 16 years, 6 months ago  # | flag

Keeping units with values. Thanks for the great recipe. I've modified value() to return a 2-tuple of (value, units), which is easy enough to plug back in to another Measure. This way, you're not discarding information.

My physics teacher always got mad at us when we dropped the units from a number. "5 what?" he'd ask. "5 candy bars? 5 elephants? 5 meters/second?"

Created by George Sakkis on Mon, 23 Feb 2004 (PSF)
Python recipes (4591)
George Sakkis's recipes (26)

Required Modules

  • (none specified)

Other Information and Tasks