Tue, 12 Dec 2017 11:54:57 +0200
First attempt to support timezones in licel files.
ioannis@55 | 1 | import datetime |
ioannis@55 | 2 | import logging |
ioannis@55 | 3 | |
binietoglou@0 | 4 | import numpy as np |
i@101 | 5 | import pytz |
ioannis@55 | 6 | |
victor@89 | 7 | from .systems.musa import musa_2009_netcdf_parameters |
victor@89 | 8 | from .systems.musa import musa_netcdf_parameters |
victor@89 | 9 | |
ulalume3@27 | 10 | from generic import BaseLidarMeasurement, LidarChannel |
binietoglou@0 | 11 | |
i@101 | 12 | logger = logging.getLogger(__name__) |
i@101 | 13 | |
i@101 | 14 | |
binietoglou@0 | 15 | licel_file_header_format = ['Filename', |
ulalume3@47 | 16 | 'StartDate StartTime EndDate EndTime Altitude Longtitude Latitude ZenithAngle', |
ulalume3@47 | 17 | # Appart from Site that is read manually |
binietoglou@0 | 18 | 'LS1 Rate1 LS2 Rate2 DataSets', ] |
binietoglou@0 | 19 | licel_file_channel_format = 'Active AnalogPhoton LaserUsed DataPoints 1 HV BinW Wavelength d1 d2 d3 d4 ADCbits NShots Discriminator ID' |
binietoglou@0 | 20 | |
ioannis@22 | 21 | |
binietoglou@0 | 22 | class LicelFile: |
i@101 | 23 | def __init__(self, filename, use_id_as_name=False, licel_timezone="UTC"): |
ulalume3@47 | 24 | self.filename = filename |
ioannis@55 | 25 | self.use_id_as_name = use_id_as_name |
binietoglou@0 | 26 | self.start_time = None |
binietoglou@0 | 27 | self.stop_time = None |
binietoglou@0 | 28 | self.import_file(filename) |
binietoglou@0 | 29 | self.calculate_physical() |
i@101 | 30 | self.licel_timezone=licel_timezone |
ulalume3@47 | 31 | |
binietoglou@0 | 32 | def calculate_physical(self): |
binietoglou@0 | 33 | for channel in self.channels.itervalues(): |
binietoglou@0 | 34 | channel.calculate_physical() |
ulalume3@47 | 35 | |
binietoglou@0 | 36 | def import_file(self, filename): |
binietoglou@0 | 37 | """Imports a licel file. |
binietoglou@0 | 38 | Input: filename |
binietoglou@0 | 39 | Output: object """ |
binietoglou@0 | 40 | |
binietoglou@0 | 41 | channels = {} |
binietoglou@33 | 42 | |
binietoglou@33 | 43 | with open(filename, 'rb') as f: |
binietoglou@33 | 44 | |
binietoglou@33 | 45 | self.read_header(f) |
ulalume3@47 | 46 | |
binietoglou@33 | 47 | # Check the complete header is read |
i@101 | 48 | f.readline() |
binietoglou@0 | 49 | |
binietoglou@33 | 50 | # Import the data |
binietoglou@33 | 51 | for current_channel_info in self.channel_info: |
binietoglou@33 | 52 | raw_data = np.fromfile(f, 'i4', int(current_channel_info['DataPoints'])) |
binietoglou@33 | 53 | a = np.fromfile(f, 'b', 1) |
binietoglou@33 | 54 | b = np.fromfile(f, 'b', 1) |
ulalume3@47 | 55 | |
binietoglou@33 | 56 | if (a[0] != 13) | (b[0] != 10): |
ioannis@55 | 57 | logging.warning("No end of line found after record. File could be corrupt: %s" % filename) |
ioannis@55 | 58 | channel = LicelFileChannel(current_channel_info, raw_data, self.duration(), |
ioannis@55 | 59 | use_id_as_name=self.use_id_as_name) |
ulalume3@47 | 60 | |
binietoglou@33 | 61 | channel_name = channel.channel_name |
ulalume3@47 | 62 | |
binietoglou@33 | 63 | if channel_name in channels.keys(): |
binietoglou@33 | 64 | # If the analog/photon naming scheme is not enough, find a new one! |
binietoglou@33 | 65 | raise IOError('Trying to import two channels with the same name') |
ulalume3@47 | 66 | |
binietoglou@33 | 67 | channels[channel_name] = channel |
ulalume3@47 | 68 | |
binietoglou@33 | 69 | self.channels = channels |
ulalume3@47 | 70 | |
binietoglou@33 | 71 | def read_header(self, f): |
binietoglou@33 | 72 | """ Read the header of a open file f. |
binietoglou@33 | 73 | |
binietoglou@33 | 74 | Returns raw_info and channel_info. Updates some object properties. """ |
ulalume3@47 | 75 | |
ulalume3@47 | 76 | # Read the first 3 lines of the header |
binietoglou@0 | 77 | raw_info = {} |
binietoglou@33 | 78 | channel_info = [] |
ulalume3@47 | 79 | |
binietoglou@33 | 80 | # Read first line |
binietoglou@33 | 81 | raw_info['Filename'] = f.readline().strip() |
ulalume3@47 | 82 | |
binietoglou@33 | 83 | # Read second line |
binietoglou@33 | 84 | second_line = f.readline() |
ulalume3@47 | 85 | |
binietoglou@33 | 86 | # Many licel files don't follow the licel standard. Specifically, the |
binietoglou@33 | 87 | # measurement site is not always 8 characters, and can include white |
binietoglou@33 | 88 | # spaces. For this, the site name is detect everything before the first |
binietoglou@33 | 89 | # date. For efficiency, the first date is found by the first '/'. |
binietoglou@33 | 90 | # e.g. assuming a string like 'Site name 01/01/2010 ...' |
ulalume3@47 | 91 | |
binietoglou@33 | 92 | site_name = second_line.split('/')[0][:-2] |
binietoglou@33 | 93 | clean_site_name = site_name.strip() |
binietoglou@33 | 94 | raw_info['Site'] = clean_site_name |
binietoglou@33 | 95 | raw_info.update(match_lines(second_line[len(clean_site_name) + 1:], licel_file_header_format[1])) |
ulalume3@47 | 96 | |
binietoglou@33 | 97 | # Read third line |
binietoglou@33 | 98 | third_line = f.readline() |
binietoglou@33 | 99 | raw_info.update(match_lines(third_line, licel_file_header_format[2])) |
ulalume3@47 | 100 | |
binietoglou@33 | 101 | # Update the object properties based on the raw info |
binietoglou@0 | 102 | start_string = '%s %s' % (raw_info['StartDate'], raw_info['StartTime']) |
binietoglou@0 | 103 | stop_string = '%s %s' % (raw_info['EndDate'], raw_info['EndTime']) |
binietoglou@0 | 104 | date_format = '%d/%m/%Y %H:%M:%S' |
ulalume3@47 | 105 | |
i@101 | 106 | try: |
i@101 | 107 | logger.debug('Creating timezone object %s' % self.licel_timezone) |
i@101 | 108 | timezone = pytz.timezone(self.licel_timezone) |
i@101 | 109 | except: |
i@101 | 110 | raise ValueError("Cloud not create time zone object %s" % self.licel_timezone) |
i@101 | 111 | |
i@101 | 112 | # According to pytz docs, timezones do not work with default datetime constructor. |
i@101 | 113 | local_start_time = timezone.localize(datetime.datetime.strptime(start_string, date_format)) |
i@101 | 114 | local_stop_time = timezone.localize(datetime.datetime.strptime(stop_string, date_format)) |
i@101 | 115 | |
i@101 | 116 | # Only save UTC time. |
i@101 | 117 | self.start_time = local_start_time.astimezone(pytz.utc) |
i@101 | 118 | self.stop_time = local_stop_time.astimezone(pytz.utc) |
i@101 | 119 | |
binietoglou@0 | 120 | self.latitude = float(raw_info['Latitude']) |
binietoglou@0 | 121 | self.longitude = float(raw_info['Longtitude']) |
ulalume3@47 | 122 | |
binietoglou@0 | 123 | # Read the rest of the header. |
binietoglou@0 | 124 | for c1 in range(int(raw_info['DataSets'])): |
binietoglou@0 | 125 | channel_info.append(match_lines(f.readline(), licel_file_channel_format)) |
ulalume3@47 | 126 | |
binietoglou@33 | 127 | self.raw_info = raw_info |
binietoglou@33 | 128 | self.channel_info = channel_info |
ulalume3@47 | 129 | |
ulalume3@27 | 130 | def duration(self): |
ulalume3@27 | 131 | """ Return the duration of the file. """ |
ulalume3@27 | 132 | dt = self.stop_time - self.start_time |
ulalume3@27 | 133 | return dt.seconds |
ulalume3@47 | 134 | |
ulalume3@47 | 135 | |
binietoglou@0 | 136 | class LicelFileChannel: |
ulalume3@47 | 137 | def __init__(self, raw_info=None, raw_data=None, duration=None, use_id_as_name=False): |
binietoglou@0 | 138 | self.raw_info = raw_info |
binietoglou@0 | 139 | self.raw_data = raw_data |
ulalume3@27 | 140 | self.duration = duration |
ulalume3@47 | 141 | self.use_id_as_name = use_id_as_name |
ulalume3@47 | 142 | |
binietoglou@0 | 143 | @property |
binietoglou@0 | 144 | def wavelength(self): |
binietoglou@0 | 145 | if self.raw_info is not None: |
binietoglou@0 | 146 | wave_str = self.raw_info['Wavelength'] |
binietoglou@0 | 147 | wavelength = wave_str.split('.')[0] |
binietoglou@0 | 148 | return int(wavelength) |
binietoglou@0 | 149 | else: |
binietoglou@0 | 150 | return None |
ulalume3@47 | 151 | |
binietoglou@0 | 152 | @property |
binietoglou@0 | 153 | def channel_name(self): |
binietoglou@0 | 154 | ''' |
binietoglou@0 | 155 | Construct the channel name adding analog photon info to avoid duplicates |
ulalume3@47 | 156 | |
ulalume3@47 | 157 | If use_id_as_name is True, the channel name will be the transient digitizer ID (e.g. BT01). |
ulalume3@47 | 158 | This could be useful if the lidar system has multiple telescopes, so the descriptive name is |
ulalume3@47 | 159 | not unique. |
binietoglou@0 | 160 | ''' |
ulalume3@47 | 161 | if self.use_id_as_name: |
ulalume3@47 | 162 | channel_name = self.raw_info['ID'] |
ulalume3@47 | 163 | else: |
ulalume3@47 | 164 | acquisition_type = self.analog_photon_string(self.raw_info['AnalogPhoton']) |
ulalume3@47 | 165 | channel_name = "%s_%s" % (self.raw_info['Wavelength'], acquisition_type) |
binietoglou@0 | 166 | return channel_name |
ulalume3@47 | 167 | |
binietoglou@0 | 168 | def analog_photon_string(self, analog_photon_number): |
binietoglou@0 | 169 | if analog_photon_number == '0': |
binietoglou@0 | 170 | string = 'an' |
binietoglou@0 | 171 | else: |
binietoglou@0 | 172 | string = 'ph' |
binietoglou@0 | 173 | return string |
ulalume3@47 | 174 | |
binietoglou@0 | 175 | def calculate_physical(self): |
binietoglou@0 | 176 | data = self.raw_data |
binietoglou@0 | 177 | |
binietoglou@0 | 178 | number_of_shots = float(self.raw_info['NShots']) |
ulalume3@27 | 179 | norm = data / number_of_shots |
binietoglou@0 | 180 | dz = float(self.raw_info['BinW']) |
ulalume3@47 | 181 | |
ulalume3@47 | 182 | if self.raw_info['AnalogPhoton'] == '0': |
binietoglou@0 | 183 | # If the channel is in analog mode |
binietoglou@0 | 184 | ADCb = int(self.raw_info['ADCbits']) |
ulalume3@47 | 185 | ADCrange = float(self.raw_info['Discriminator']) * 1000 # Value in mV |
ulalume3@47 | 186 | channel_data = norm * ADCrange / ((2 ** ADCb) - 1) |
ulalume3@47 | 187 | |
binietoglou@0 | 188 | # print ADCb, ADCRange,cdata,norm |
binietoglou@0 | 189 | else: |
binietoglou@0 | 190 | # If the channel is in photoncounting mode |
binietoglou@0 | 191 | # Frequency deduced from range resolution! (is this ok?) |
binietoglou@0 | 192 | # c = 300 # The result will be in MHZ |
binietoglou@0 | 193 | # SR = c/(2*dz) # To account for pulse folding |
binietoglou@0 | 194 | # channel_data = norm*SR |
binietoglou@0 | 195 | # CHANGE: |
binietoglou@0 | 196 | # For the SCC the data are needed in photons |
ulalume3@47 | 197 | channel_data = norm * number_of_shots |
ulalume3@47 | 198 | # print res,c,cdata,norm |
binietoglou@0 | 199 | |
ulalume3@47 | 200 | # Calculate Z |
ulalume3@47 | 201 | number_of_bins = int(self.raw_info['DataPoints']) |
ulalume3@47 | 202 | self.z = np.array([dz * bin_number + dz / 2.0 for bin_number in range(number_of_bins)]) |
binietoglou@0 | 203 | self.dz = dz |
binietoglou@0 | 204 | self.number_of_bins = number_of_bins |
binietoglou@0 | 205 | self.data = channel_data |
ioannis@22 | 206 | |
ulalume3@47 | 207 | |
binietoglou@0 | 208 | class LicelLidarMeasurement(BaseLidarMeasurement): |
binietoglou@0 | 209 | ''' |
binietoglou@0 | 210 | |
binietoglou@0 | 211 | ''' |
binietoglou@0 | 212 | extra_netcdf_parameters = musa_netcdf_parameters |
ulalume3@47 | 213 | raw_info = {} # Keep the raw info from the files |
ulalume3@27 | 214 | durations = {} # Keep the duration of the files |
victor@84 | 215 | laser_shots = [] |
ulalume3@47 | 216 | |
i@101 | 217 | def __init__(self, file_list=None, use_id_as_name=False, licel_timezone='UTC'): |
ulalume3@47 | 218 | self.use_id_as_name = use_id_as_name |
i@101 | 219 | self.licel_timezone = licel_timezone |
ulalume3@92 | 220 | super(LicelLidarMeasurement, self).__init__(file_list) |
ulalume3@47 | 221 | |
ulalume3@92 | 222 | def _import_file(self, filename): |
binietoglou@0 | 223 | if filename in self.files: |
ioannis@55 | 224 | logging.warning("File has been imported already: %s" % filename) |
binietoglou@0 | 225 | else: |
i@101 | 226 | current_file = LicelFile(filename, use_id_as_name=self.use_id_as_name, licel_timezone=self.licel_timezone) |
ioannis@22 | 227 | self.raw_info[current_file.filename] = current_file.raw_info |
ulalume3@27 | 228 | self.durations[current_file.filename] = current_file.duration() |
victor@84 | 229 | |
victor@84 | 230 | file_laser_shots = [] |
ulalume3@47 | 231 | |
binietoglou@0 | 232 | for channel_name, channel in current_file.channels.items(): |
binietoglou@0 | 233 | if channel_name not in self.channels: |
ulalume3@27 | 234 | self.channels[channel_name] = LicelChannel(channel) |
ulalume3@47 | 235 | self.channels[channel_name].data[current_file.start_time] = channel.data |
victor@84 | 236 | file_laser_shots.append(channel.raw_info['NShots']) |
victor@84 | 237 | |
victor@84 | 238 | self.laser_shots.append(file_laser_shots) |
binietoglou@0 | 239 | self.files.append(current_file.filename) |
ulalume3@47 | 240 | |
binietoglou@0 | 241 | def append(self, other): |
binietoglou@0 | 242 | |
binietoglou@0 | 243 | self.start_times.extend(other.start_times) |
binietoglou@0 | 244 | self.stop_times.extend(other.stop_times) |
binietoglou@0 | 245 | |
binietoglou@0 | 246 | for channel_name, channel in self.channels.items(): |
binietoglou@0 | 247 | channel.append(other.channels[channel_name]) |
binietoglou@0 | 248 | |
ioannis@22 | 249 | def _get_duration(self, raw_start_in_seconds): |
ulalume3@92 | 250 | """ Return the duration for a given time scale. If only a single |
ioannis@22 | 251 | file is imported, then this cannot be guessed from the time difference |
ioannis@22 | 252 | and the raw_info of the file are checked. |
ulalume3@92 | 253 | """ |
ulalume3@47 | 254 | |
ulalume3@47 | 255 | if len(raw_start_in_seconds) == 1: # If only one file imported |
ulalume3@47 | 256 | duration = self.durations.itervalues().next() # Get the first (and only) raw_info |
ioannis@31 | 257 | duration_sec = duration |
ioannis@22 | 258 | else: |
ioannis@22 | 259 | duration_sec = np.diff(raw_start_in_seconds)[0] |
binietoglou@0 | 260 | |
ioannis@22 | 261 | return duration_sec |
victor@84 | 262 | |
ulalume3@92 | 263 | def get_custom_channel_parameters(self): |
victor@84 | 264 | params = [{ |
victor@84 | 265 | "name": "DAQ_Range", |
victor@84 | 266 | "dimensions": ('channels',), |
victor@84 | 267 | "type": 'd', |
victor@84 | 268 | "values": [self.channels[x].raw_info['Discriminator'] for x in self.channels.keys()] |
victor@84 | 269 | }, { |
victor@84 | 270 | "name": "LR_Input", |
victor@84 | 271 | "dimensions": ('channels',), |
victor@84 | 272 | "type": 'i', |
victor@84 | 273 | "values": [self.channels[x].raw_info['LaserUsed'] for x in self.channels.keys()] |
victor@84 | 274 | }, { |
victor@84 | 275 | "name": "Laser_Shots", |
victor@84 | 276 | "dimensions": ('time', 'channels',), |
victor@84 | 277 | "type": 'i', |
victor@84 | 278 | "values": self.laser_shots |
victor@84 | 279 | }, |
victor@84 | 280 | ] |
victor@84 | 281 | |
victor@84 | 282 | return params |
victor@87 | 283 | |
ulalume3@92 | 284 | def get_custom_general_parameters(self): |
victor@87 | 285 | params = [{ |
victor@87 | 286 | "name": "Altitude_meter_asl", |
victor@87 | 287 | "value": self.raw_info[ self.files[0] ]["Altitude"] |
victor@87 | 288 | }, { |
victor@87 | 289 | "name": "Latitude_degrees_north", |
victor@87 | 290 | "value": self.raw_info[ self.files[0] ]["Latitude"] |
victor@87 | 291 | }, { |
victor@87 | 292 | "name": "Longitude_degrees_east", |
victor@87 | 293 | "value": self.raw_info[ self.files[0] ]["Longtitude"] |
victor@87 | 294 | }, |
victor@87 | 295 | ] |
victor@87 | 296 | |
victor@87 | 297 | return params |
ioannis@22 | 298 | |
ulalume3@47 | 299 | |
ulalume3@27 | 300 | class LicelChannel(LidarChannel): |
binietoglou@0 | 301 | def __init__(self, channel_file): |
binietoglou@33 | 302 | c = 299792458.0 # Speed of light |
binietoglou@0 | 303 | self.wavelength = channel_file.wavelength |
binietoglou@0 | 304 | self.name = channel_file.channel_name |
ulalume3@27 | 305 | self.binwidth = channel_file.dz * 2 / c # in seconds |
binietoglou@0 | 306 | self.data = {} |
binietoglou@0 | 307 | self.resolution = channel_file.dz |
ulalume3@47 | 308 | self.z = np.arange( |
ulalume3@47 | 309 | channel_file.number_of_bins) * self.resolution + self.resolution / 2.0 # Change: add half bin in the z |
binietoglou@0 | 310 | self.points = channel_file.number_of_bins |
binietoglou@0 | 311 | self.rc = [] |
ulalume3@47 | 312 | self.duration = channel_file.duration |
victor@84 | 313 | self.raw_info = channel_file.raw_info |
victor@84 | 314 | |
binietoglou@0 | 315 | def append(self, other): |
binietoglou@0 | 316 | if self.info != other.info: |
binietoglou@0 | 317 | raise ValueError('Channel info are different. Data can not be combined.') |
binietoglou@0 | 318 | |
binietoglou@0 | 319 | self.data = np.vstack([self.data, other.data]) |
binietoglou@0 | 320 | |
binietoglou@0 | 321 | def __unicode__(self): |
ioannis@26 | 322 | return "<Licel channel: %s>" % self.name |
ulalume3@47 | 323 | |
binietoglou@0 | 324 | def __str__(self): |
binietoglou@0 | 325 | return unicode(self).encode('utf-8') |
binietoglou@0 | 326 | |
ioannis@22 | 327 | |
binietoglou@0 | 328 | class Licel2009LidarMeasurement(LicelLidarMeasurement): |
binietoglou@0 | 329 | extra_netcdf_parameters = musa_2009_netcdf_parameters |
binietoglou@0 | 330 | |
ioannis@22 | 331 | |
ulalume3@47 | 332 | def match_lines(f1, f2): |
binietoglou@0 | 333 | list1 = f1.split() |
binietoglou@0 | 334 | list2 = f2.split() |
ulalume3@47 | 335 | |
binietoglou@0 | 336 | if len(list1) != len(list2): |
ioannis@55 | 337 | logging.debug("Channel parameter list has different length from licel specifications.") |
ioannis@55 | 338 | logging.debug("List 1: %s" % list1) |
ioannis@55 | 339 | logging.debug("List 2: %s" % list2) |
ulalume3@47 | 340 | combined = zip(list2, list1) |
binietoglou@0 | 341 | combined = dict(combined) |
binietoglou@0 | 342 | return combined |