| 1 | # This program is public domain |
|---|
| 2 | # Author: Paul Kienzle |
|---|
| 3 | """ |
|---|
| 4 | Define unit conversion support for NeXus style units. |
|---|
| 5 | |
|---|
| 6 | The unit format is somewhat complicated. There are variant spellings |
|---|
| 7 | and incorrect capitalization to worry about, as well as forms such as |
|---|
| 8 | "mili*metre" and "1e-7 seconds". |
|---|
| 9 | |
|---|
| 10 | This is a minimal implementation of units including only what I happen to |
|---|
| 11 | need now. It does not support the complete dimensional analysis provided |
|---|
| 12 | by the package udunits on which NeXus is based, or even the units used |
|---|
| 13 | in the NeXus definition files. |
|---|
| 14 | |
|---|
| 15 | Unlike other units modules, this module does not carry the units along |
|---|
| 16 | with the value, but merely provides a conversion function for |
|---|
| 17 | transforming values. |
|---|
| 18 | |
|---|
| 19 | Usage example:: |
|---|
| 20 | |
|---|
| 21 | import nxs.unit |
|---|
| 22 | u = nxs.unit.Converter('mili*metre') # Units stored in mm |
|---|
| 23 | v = u(3000,'m') # Convert the value 3000 mm into meters |
|---|
| 24 | |
|---|
| 25 | NeXus example:: |
|---|
| 26 | |
|---|
| 27 | # Load sample orientation in radians regardless of how it is stored. |
|---|
| 28 | # 1. Open the path |
|---|
| 29 | file.openpath('/entry1/sample/sample_orientation') |
|---|
| 30 | # 2. scan the attributes, retrieving 'units' |
|---|
| 31 | units = [for attr,value in file.attrs() if attr == 'units'] |
|---|
| 32 | # 3. set up the converter (assumes that units actually exists) |
|---|
| 33 | u = nxs.unit.Converter(units[0]) |
|---|
| 34 | # 4. read the data and convert to the correct units |
|---|
| 35 | v = u(file.read(),'radians') |
|---|
| 36 | |
|---|
| 37 | This is a standalone module, not relying on either DANSE or NeXus, and |
|---|
| 38 | can be used for other unit conversion tasks. |
|---|
| 39 | |
|---|
| 40 | Note: minutes are used for angle and seconds are used for time. We |
|---|
| 41 | cannot tell what the correct interpretation is without knowing something |
|---|
| 42 | about the fields themselves. If this becomes an issue, we will need to |
|---|
| 43 | allow the application to set the dimension for the units rather than |
|---|
| 44 | getting the dimension from the units as we are currently doing. |
|---|
| 45 | """ |
|---|
| 46 | |
|---|
| 47 | # TODO: Add udunits to NAPI rather than reimplementing it in python |
|---|
| 48 | # TODO: Alternatively, parse the udunits database directly |
|---|
| 49 | # UDUnits: |
|---|
| 50 | # http://www.unidata.ucar.edu/software/udunits/udunits-1/udunits.txt |
|---|
| 51 | |
|---|
| 52 | # TODO: Allow application to impose the map on the units |
|---|
| 53 | |
|---|
| 54 | from __future__ import division |
|---|
| 55 | |
|---|
| 56 | __all__ = ['Converter'] |
|---|
| 57 | |
|---|
| 58 | import math |
|---|
| 59 | |
|---|
| 60 | |
|---|
| 61 | # Limited form of units for returning objects of a specific type. |
|---|
| 62 | # Maybe want to do full units handling with e.g., pyre's |
|---|
| 63 | # unit class. For now lets keep it simple. Note that |
|---|
| 64 | def _build_metric_units(unit,abbr): |
|---|
| 65 | """ |
|---|
| 66 | Construct standard SI names for the given unit. |
|---|
| 67 | Builds e.g., |
|---|
| 68 | s, ns |
|---|
| 69 | second, nanosecond, nano*second |
|---|
| 70 | seconds, nanoseconds |
|---|
| 71 | Includes prefixes for femto through peta. |
|---|
| 72 | |
|---|
| 73 | Ack! Allows, e.g., Coulomb and coulomb even though Coulomb is not |
|---|
| 74 | a unit because some NeXus files store it that way! |
|---|
| 75 | |
|---|
| 76 | Returns a dictionary of names and scales. |
|---|
| 77 | """ |
|---|
| 78 | prefix = dict(peta=1e15,tera=1e12,giga=1e9,mega=1e6,kilo=1e3, |
|---|
| 79 | deci=1e-1,centi=1e-2,milli=1e-3,mili=1e-3,micro=1e-6, |
|---|
| 80 | nano=1e-9,pico=1e-12,femto=1e-15) |
|---|
| 81 | short_prefix = dict(P=1e15,T=1e12,G=1e9,M=1e6,k=1e3, |
|---|
| 82 | d=1e-1,c=1e-2,m=1e-3,u=1e-6, |
|---|
| 83 | n=1e-9,p=1e-12,f=1e-15) |
|---|
| 84 | map = {abbr:1} |
|---|
| 85 | map.update([(P+abbr,scale) for (P,scale) in short_prefix.iteritems()]) |
|---|
| 86 | for name in [unit,unit.capitalize()]: |
|---|
| 87 | map.update({name:1,name+'s':1}) |
|---|
| 88 | map.update([(P+name,scale) for (P,scale) in prefix.iteritems()]) |
|---|
| 89 | map.update([(P+'*'+name,scale) for (P,scale) in prefix.iteritems()]) |
|---|
| 90 | map.update([(P+name+'s',scale) for (P,scale) in prefix.iteritems()]) |
|---|
| 91 | return map |
|---|
| 92 | |
|---|
| 93 | def _build_plural_units(**kw): |
|---|
| 94 | """ |
|---|
| 95 | Construct names for the given units. Builds singular and plural form. |
|---|
| 96 | """ |
|---|
| 97 | map = {} |
|---|
| 98 | map.update([(name,scale) for name,scale in kw.iteritems()]) |
|---|
| 99 | map.update([(name+'s',scale) for name,scale in kw.iteritems()]) |
|---|
| 100 | return map |
|---|
| 101 | |
|---|
| 102 | def _build_all_units(): |
|---|
| 103 | # Various distance measures |
|---|
| 104 | distance = _build_metric_units('meter','m') |
|---|
| 105 | distance.update(_build_metric_units('metre','m')) |
|---|
| 106 | distance.update(_build_plural_units(micron=1e-6, Angstrom=1e-10)) |
|---|
| 107 | distance.update({'A':1e-10, 'Ang':1e-10}) |
|---|
| 108 | |
|---|
| 109 | # Various time measures. |
|---|
| 110 | # Note: minutes are used for angle rather than time |
|---|
| 111 | time = _build_metric_units('second','s') |
|---|
| 112 | time.update(_build_plural_units(hour=3600,day=24*3600,week=7*24*3600)) |
|---|
| 113 | |
|---|
| 114 | # Various angle measures. |
|---|
| 115 | # Note: seconds are used for time rather than angle |
|---|
| 116 | angle = _build_plural_units(degree=1, minute=1/60., |
|---|
| 117 | arcminute=1/60., arcsecond=1/3600., radian=180/math.pi) |
|---|
| 118 | angle.update(deg=1, arcmin=1/60., arcsec=1/3600., rad=180/math.pi) |
|---|
| 119 | |
|---|
| 120 | frequency = _build_metric_units('hertz','Hz') |
|---|
| 121 | frequency.update(_build_metric_units('Hertz','Hz')) |
|---|
| 122 | frequency.update(_build_plural_units(rpm=1/60.)) |
|---|
| 123 | |
|---|
| 124 | # Note: degrees are used for angle |
|---|
| 125 | # Note: temperature needs an offset as well as a scale |
|---|
| 126 | temperature = _build_metric_units('kelvin','K') |
|---|
| 127 | temperature.update(_build_metric_units('Kelvin','K')) |
|---|
| 128 | |
|---|
| 129 | charge = _build_metric_units('coulomb','C') |
|---|
| 130 | charge.update({'microAmp*hour':0.0036}) |
|---|
| 131 | |
|---|
| 132 | sld = { '10^-6 Angstrom^-2': 1e-6, 'Angstrom^-2': 1} |
|---|
| 133 | Q = { 'invAng': 1, 'invAngstroms': 1, |
|---|
| 134 | '10^-3 Angstrom^-1': 1e-3, 'nm^-1': 10 } |
|---|
| 135 | |
|---|
| 136 | # APS files may be using 'a.u.' for 'arbitrary units'. Other |
|---|
| 137 | # facilities are leaving the units blank, using ??? or not even |
|---|
| 138 | # writing the units attributes. |
|---|
| 139 | unknown = {None:1, '???':1, '': 1, 'a.u.':1} |
|---|
| 140 | |
|---|
| 141 | dims = [unknown, distance, time, angle, frequency, |
|---|
| 142 | temperature, charge, sld, Q] |
|---|
| 143 | return dims |
|---|
| 144 | |
|---|
| 145 | class Converter(object): |
|---|
| 146 | """ |
|---|
| 147 | Unit converter for NeXus style units. |
|---|
| 148 | |
|---|
| 149 | """ |
|---|
| 150 | # Define the units, using both American and European spelling. |
|---|
| 151 | scalemap = None |
|---|
| 152 | scalebase = 1 |
|---|
| 153 | dims = _build_all_units() |
|---|
| 154 | |
|---|
| 155 | def __init__(self,name): |
|---|
| 156 | self.base = name |
|---|
| 157 | for map in self.dims: |
|---|
| 158 | if name in map: |
|---|
| 159 | self.scalemap = map |
|---|
| 160 | self.scalebase = self.scalemap[name] |
|---|
| 161 | break |
|---|
| 162 | else: |
|---|
| 163 | self.scalemap = {'': 1} |
|---|
| 164 | self.scalebase = 1 |
|---|
| 165 | #raise ValueError, "Unknown unit %s"%name |
|---|
| 166 | |
|---|
| 167 | def scale(self, units=""): |
|---|
| 168 | if units == "" or self.scalemap is None: return 1 |
|---|
| 169 | return self.scalebase/self.scalemap[units] |
|---|
| 170 | |
|---|
| 171 | def __call__(self, value, units=""): |
|---|
| 172 | # Note: calculating a*1 rather than simply returning a would produce |
|---|
| 173 | # an unnecessary copy of the array, which in the case of the raw |
|---|
| 174 | # counts array would be bad. Sometimes copying and other times |
|---|
| 175 | # not copying is also bad, but copy on modify semantics isn't |
|---|
| 176 | # supported. |
|---|
| 177 | if units == "" or self.scalemap is None: return value |
|---|
| 178 | try: |
|---|
| 179 | return value * (self.scalebase/self.scalemap[units]) |
|---|
| 180 | except KeyError: |
|---|
| 181 | raise KeyError("%s not in %s"%(units," ".join(self.scalemap.keys()))) |
|---|
| 182 | |
|---|
| 183 | def _check(expect,get): |
|---|
| 184 | if expect != get: raise ValueError, "Expected %s but got %s"%(expect,get) |
|---|
| 185 | #print expect,"==",get |
|---|
| 186 | |
|---|
| 187 | def test(): |
|---|
| 188 | _check(2,Converter('mm')(2000,'m')) # 2000 mm -> 2 m |
|---|
| 189 | _check(0.003,Converter('microseconds')(3,units='ms')) # 3 us -> 0.003 ms |
|---|
| 190 | _check(45,Converter('nanokelvin')(45)) # 45 nK -> 45 nK |
|---|
| 191 | # TODO: more tests |
|---|
| 192 | _check(0.5,Converter('seconds')(1800,units='hours')) # 1800 -> 0.5 hr |
|---|
| 193 | _check(2.5,Converter('a.u.')(2.5,units='')) |
|---|
| 194 | |
|---|
| 195 | if __name__ == "__main__": |
|---|
| 196 | test() |
|---|