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