atmospheric_lidar/licel.py

Mon, 18 Mar 2024 16:32:24 +0100

author
Giuseppe D'Amico <giuseppe.damico@imaa.cnr.it>
date
Mon, 18 Mar 2024 16:32:24 +0100
changeset 215
7f915581fb54
parent 202
79a75340ae30
permissions
-rwxr-xr-x

Fixed a bug in licel.py file

ioannis@55 1 import datetime
ioannis@55 2 import logging
i@117 3 import copy
i@156 4 import os
ioannis@194 5 import collections
ioannis@55 6
binietoglou@0 7 import numpy as np
i@101 8 import pytz
ioannis@55 9
i@150 10 from .generic import BaseLidarMeasurement, LidarChannel
ioannis@165 11 from .diva import DivaConverterMixin
binietoglou@0 12
i@101 13 logger = logging.getLogger(__name__)
i@101 14
i@116 15 c = 299792458.0 # Speed of light
i@116 16
ioannis@22 17
i@130 18 class LicelChannelData:
i@128 19 """ A class representing a single channel found in a single Licel file."""
i@128 20
ioannis@186 21 def __init__(self, raw_info, raw_data, duration, use_id_as_name=False, channel_name=None):
i@128 22 """
i@128 23 This is run when creating a new object.
i@128 24
i@128 25 Parameters
i@128 26 ----------
i@128 27 raw_info : dict
i@128 28 A dictionary containing raw channel information.
i@128 29 raw_data : dict
i@128 30 An array with raw channel data.
i@128 31 duration : float
i@128 32 Duration of the file, in seconds
i@128 33 use_id_as_name : bool
i@128 34 If True, the transient digitizer name (e.g. BT0) is used as a channel
i@128 35 name. If False, a more descriptive name is used (e.g. '01064.o_an').
ioannis@186 36 channel_name : str or None
ioannis@186 37 If provided, it will override the automatic generated channel name. It can be used if names are not unique.
i@128 38 """
i@128 39 self.raw_info = raw_info
i@128 40 self.raw_data = raw_data
i@128 41 self.duration = duration
i@128 42 self.use_id_as_name = use_id_as_name
ioannis@186 43 self.channel_name_input = channel_name
i@180 44 self._assign_properties()
i@128 45
i@180 46 def _assign_properties(self):
i@180 47 """ Assign properties """
i@180 48 self.adcbits = int(self.raw_info['ADCbits'])
i@180 49 self.active = int(self.raw_info['active'])
i@180 50 self.analog_photon = self.raw_info['analog_photon']
i@180 51 self.bin_width = float(self.raw_info['bin_width'])
i@180 52 self.data_points = int(self.raw_info['number_of_datapoints'])
i@180 53 self.hv = float(self.raw_info['HV'])
i@180 54 self.id = self.raw_info['ID']
i@180 55 self.laser_used = int(self.raw_info['laser_used'])
i@180 56 self.number_of_shots = int(self.raw_info['number_of_shots'])
i@180 57 self.wavelength_str = self.raw_info['wavelength']
i@180 58
i@180 59 self.address = int(self.id[-1:], base=16)
i@128 60
i@128 61 if self.is_analog:
i@180 62 self.discriminator = float(self.raw_info['discriminator']) * 1000 # Analog range in mV
i@128 63 else:
i@180 64 self.discriminator = float(self.raw_info['discriminator'])
i@128 65
i@128 66 @property
i@130 67 def is_photodiode(self):
i@130 68 return self.id[0:2] == 'PD'
i@130 69
i@130 70 @property
i@128 71 def wavelength(self):
i@128 72 """ Property describing the nominal wavelength of the channel.
i@128 73
i@128 74 Returns
i@128 75 -------
i@128 76 : int or None
i@128 77 The integer value describing the wavelength. If no raw_info have been provided,
i@128 78 returns None.
i@128 79 """
i@128 80 wavelength = self.wavelength_str.split('.')[0]
i@128 81 return int(wavelength)
i@128 82
i@128 83 @property
i@128 84 def channel_name(self):
i@128 85 """
i@128 86 Construct the channel name adding analog photon info to avoid duplicates
i@128 87
i@128 88 If use_id_as_name is True, the channel name will be the transient digitizer ID (e.g. BT01).
i@128 89 This could be useful if the lidar system has multiple telescopes, so the descriptive name is
i@128 90 not unique.
i@128 91
i@128 92 Returns
i@128 93 -------
i@128 94 channel_name : str
i@128 95 The channel name
i@128 96 """
ioannis@186 97 if self.channel_name_input is not None:
ioannis@186 98 return self.channel_name_input
ioannis@186 99
i@128 100 if self.use_id_as_name:
i@128 101 channel_name = self.id
i@128 102 else:
i@128 103 acquisition_type = self.analog_photon_string
i@128 104 channel_name = "%s_%s" % (self.wavelength_str, acquisition_type)
i@128 105 return channel_name
i@128 106
i@128 107 @property
i@128 108 def analog_photon_string(self):
i@128 109 """ Convert the analog/photon flag found in the Licel file to a proper sting.
i@128 110
i@128 111 Returns
i@128 112 -------
i@128 113 string : str
i@128 114 'an' or 'ph' string, for analog or photon-counting channel respectively.
i@128 115 """
i@128 116 if self.analog_photon == '0':
i@128 117 string = 'an'
i@180 118 elif self.analog_photon == '1':
i@180 119 string = 'ph'
i@180 120 elif self.analog_photon == '2':
i@180 121 string = 'std_an'
i@180 122 elif self.analog_photon == '3':
i@180 123 string = 'std_ph'
i@128 124 else:
giuseppe@215 125 string = str(self.analog_photon)
i@128 126 return string
i@128 127
i@128 128 def calculate_physical(self):
i@128 129 """ Calculate physically-meaningful data from raw channel data:
i@128 130
i@128 131 * In case of analog signals, the data are converted to mV.
i@128 132 * In case of photon counting signals, data are stored as number of photons.
i@128 133
i@128 134 In addition, some ancillary variables are also calculated (z, dz, number_of_bins).
i@128 135 """
ioannis@194 136
ioannis@193 137 norm = self.raw_data / float(self.number_of_shots)
i@129 138 dz = self.bin_width
i@128 139
i@129 140 if self.is_analog:
i@128 141 # If the channel is in analog mode
i@128 142 ADCrange = self.discriminator # Discriminator value already in mV
i@128 143
i@130 144 if self.is_photodiode and (self.adcbits == 0):
ioannis@202 145 logger.debug(
ioannis@194 146 "Assuming adcbits equal 1. This is a bug in current licel format when storing photodiode data.")
i@130 147 channel_data = norm * ADCrange / (2 ** self.adcbits)
i@130 148 else:
ioannis@194 149 channel_data = norm * ADCrange / (
ioannis@194 150 (2 ** self.adcbits) - 1) # Licel LabView code has a bug (does not account -1).
i@130 151
i@128 152 else:
ioannis@194 153 channel_data = norm * self.number_of_shots
i@128 154
i@128 155 # Calculate Z
i@128 156 self.z = np.array([dz * bin_number + dz / 2.0 for bin_number in range(self.data_points)])
i@128 157 self.dz = dz
i@128 158 self.data = channel_data
i@128 159
i@128 160 @property
i@128 161 def is_analog(self):
i@128 162 return self.analog_photon == '0'
i@128 163
ioannis@189 164 @property
ioannis@189 165 def laser_shots(self):
ioannis@189 166 """ Alias for number_of_shots """
ioannis@189 167 return self.number_of_shots
ioannis@189 168
ioannis@189 169
i@129 170 class LicelFile(object):
ulalume3@105 171 """ A class representing a single binary Licel file. """
i@116 172
i@129 173 licel_file_header_format = ['filename',
i@129 174 'start_date start_time end_date end_time altitude longitude latitude zenith_angle',
i@125 175 # Appart from Site that is read manually
i@129 176 'LS1 rate_1 LS2 rate_2 number_of_datasets', ]
i@129 177 licel_file_channel_format = 'active analog_photon laser_used number_of_datapoints 1 HV bin_width wavelength d1 d2 d3 d4 ADCbits number_of_shots discriminator ID'
i@125 178
i@130 179 channel_data_class = LicelChannelData
i@125 180
i@176 181 # If True, it corrects the old Raymetrics convention of zenith angle definition (zenith = -90 degrees)
i@176 182 fix_zenith_angle = False
i@176 183
ioannis@186 184 def __init__(self, file_path, use_id_as_name=False, get_name_by_order=False, licel_timezone="UTC", import_now=True):
ulalume3@105 185 """
ulalume3@105 186 This is run when creating a new object.
i@128 187
ulalume3@105 188 Parameters
ulalume3@105 189 ----------
ulalume3@105 190 file_path : str
ulalume3@105 191 The path to the Licel file.
ulalume3@105 192 use_id_as_name : bool
ulalume3@105 193 If True, the transient digitizer name (e.g. BT0) is used as a channel
ulalume3@105 194 name. If False, a more descriptive name is used (e.g. '01064.o_an').
ioannis@186 195 get_name_by_order : bool
ioannis@186 196 If True, the channel name is given by the order of the channel in the file. In this case the
ioannis@186 197 `use_id_as_name` variable is ignored.
ulalume3@105 198 licel_timezone : str
ulalume3@105 199 The timezone of dates found in the Licel files. Should match the available
i@128 200 timezones in the TZ database.
i@173 201 import_now : bool
ioannis@144 202 If True, the header and data are read immediately. If not, the user has to call the
ioannis@144 203 corresponding methods directly. This is used to speed up reading files when only
ioannis@144 204 header information are required.
ulalume3@105 205 """
i@156 206 self.file_path = file_path
i@156 207 self.file_name = os.path.basename(file_path)
i@156 208
ioannis@55 209 self.use_id_as_name = use_id_as_name
ioannis@186 210 self.get_name_by_order = get_name_by_order
binietoglou@0 211 self.start_time = None
binietoglou@0 212 self.stop_time = None
i@102 213 self.licel_timezone = licel_timezone
ulalume3@47 214
ioannis@194 215 self.header_lines = [] # Store raw header lines, to be used in save_as_txt
ioannis@194 216
ioannis@144 217 if import_now:
ioannis@144 218 self.import_file()
ioannis@177 219 else:
ioannis@177 220 self.import_header_only()
i@130 221
ioannis@144 222 def import_file(self):
i@129 223 """ Read the header info and data of the Licel file.
ulalume3@105 224 """
ioannis@194 225 channels = collections.OrderedDict()
ioannis@194 226 photodiodes = collections.OrderedDict()
binietoglou@33 227
i@156 228 with open(self.file_path, 'rb') as f:
binietoglou@33 229
binietoglou@33 230 self.read_header(f)
ulalume3@47 231
binietoglou@33 232 # Check the complete header is read
i@101 233 f.readline()
binietoglou@0 234
binietoglou@33 235 # Import the data
ioannis@186 236 for channel_no, current_channel_info in enumerate(self.channel_info):
i@129 237 raw_data = np.fromfile(f, 'i4', int(current_channel_info['number_of_datapoints']))
binietoglou@33 238 a = np.fromfile(f, 'b', 1)
binietoglou@33 239 b = np.fromfile(f, 'b', 1)
ulalume3@47 240
binietoglou@33 241 if (a[0] != 13) | (b[0] != 10):
i@156 242 logger.warning("No end of line found after record. File could be corrupt: %s" % self.file_path)
ioannis@146 243 logger.warning('a: {0}, b: {1}.'.format(a, b))
ulalume3@47 244
ioannis@186 245 if self.get_name_by_order:
ioannis@186 246 channel_name = channel_no
ioannis@186 247 else:
ioannis@186 248 channel_name = None
ioannis@186 249
i@130 250 channel = self.channel_data_class(current_channel_info, raw_data, self.duration(),
ioannis@186 251 use_id_as_name=self.use_id_as_name, channel_name=channel_name)
ulalume3@47 252
i@130 253 # Assign the channel either as normal channel or photodiode
i@130 254 if channel.is_photodiode:
i@130 255 if channel.channel_name in photodiodes.keys():
i@130 256 # Check if current naming convention produces unique files
i@130 257 raise IOError('Trying to import two photodiodes with the same name')
i@130 258 photodiodes[channel.channel_name] = channel
i@130 259 else:
i@130 260 if channel.channel_name in channels.keys():
i@130 261 # Check if current naming convention does not produce unique files
i@130 262 raise IOError('Trying to import two channels with the same name')
i@130 263 channels[channel.channel_name] = channel
ulalume3@47 264
binietoglou@33 265 self.channels = channels
i@130 266 self.photodiodes = photodiodes
ulalume3@47 267
ioannis@144 268 self._calculate_physical()
ioannis@144 269
binietoglou@33 270 def read_header(self, f):
i@128 271 """ Read the header of an open Licel file.
i@128 272
ulalume3@105 273 Parameters
ulalume3@105 274 ----------
ulalume3@105 275 f : file-like object
ulalume3@105 276 An open file object.
ulalume3@105 277 """
ulalume3@47 278 # Read the first 3 lines of the header
binietoglou@0 279 raw_info = {}
binietoglou@33 280 channel_info = []
ulalume3@47 281
binietoglou@33 282 # Read first line
ioannis@194 283 first_line = f.readline().decode().strip()
ioannis@194 284 raw_info['Filename'] = first_line
ioannis@194 285 self.header_lines.append(first_line)
ulalume3@47 286
i@125 287 raw_info.update(self._read_second_header_line(f))
ulalume3@47 288
i@125 289 raw_info.update(self._read_rest_of_header(f))
ulalume3@47 290
binietoglou@33 291 # Update the object properties based on the raw info
i@129 292 start_string = '%s %s' % (raw_info['start_date'], raw_info['start_time'])
i@129 293 stop_string = '%s %s' % (raw_info['end_date'], raw_info['end_time'])
binietoglou@0 294 date_format = '%d/%m/%Y %H:%M:%S'
ulalume3@47 295
i@101 296 try:
i@101 297 logger.debug('Creating timezone object %s' % self.licel_timezone)
i@101 298 timezone = pytz.timezone(self.licel_timezone)
i@101 299 except:
i@101 300 raise ValueError("Cloud not create time zone object %s" % self.licel_timezone)
i@101 301
i@101 302 # According to pytz docs, timezones do not work with default datetime constructor.
i@101 303 local_start_time = timezone.localize(datetime.datetime.strptime(start_string, date_format))
i@101 304 local_stop_time = timezone.localize(datetime.datetime.strptime(stop_string, date_format))
i@101 305
i@101 306 # Only save UTC time.
i@101 307 self.start_time = local_start_time.astimezone(pytz.utc)
i@101 308 self.stop_time = local_stop_time.astimezone(pytz.utc)
i@101 309
binietoglou@0 310 # Read the rest of the header.
i@129 311 for c1 in range(int(raw_info['number_of_datasets'])):
ioannis@194 312 channel_line = f.readline().decode()
ioannis@194 313 channel_info.append(self.match_lines(channel_line, self.licel_file_channel_format))
ioannis@194 314 self.header_lines.append(channel_line.strip())
ulalume3@47 315
binietoglou@33 316 self.raw_info = raw_info
binietoglou@33 317 self.channel_info = channel_info
ulalume3@47 318
i@129 319 self._assign_properties()
i@129 320
i@129 321 def _assign_properties(self):
i@129 322 """ Assign properties from the raw_info dictionary. """
i@129 323 self.number_of_datasets = int(self.raw_info['number_of_datasets'])
i@129 324 self.altitude = float(self.raw_info['altitude'])
i@129 325 self.longitude = float(self.raw_info['longitude'])
i@129 326 self.latitude = float(self.raw_info['latitude'])
i@173 327
i@173 328 self.zenith_angle_raw = float(self.raw_info['zenith_angle'])
i@173 329
i@176 330 logger.debug('Fix zenith angle? %s' % self.fix_zenith_angle)
i@176 331
i@173 332 if self.fix_zenith_angle:
i@176 333 logger.debug('Fixing zenith angle.')
i@173 334 self.zenith_angle = self._correct_zenith_angle(self.zenith_angle_raw)
i@173 335 else:
i@173 336 self.zenith_angle = self.zenith_angle_raw
i@173 337
i@173 338 @staticmethod
i@173 339 def _correct_zenith_angle(zenith_angle):
i@173 340 """ Correct zenith angle from Raymetrics convention (zenith = -90 degrees).
i@173 341
i@173 342 Parameters
i@173 343 ----------
i@173 344 zenith_angle : float
i@173 345 Zenith angle in Raymetrics convention.
i@173 346
i@173 347 Returns
i@173 348 -------
i@173 349 corrected_angle : float
i@173 350 Corrected zenith angle.
i@173 351 """
i@176 352 corrected_angle = 90 + zenith_angle
i@173 353 return corrected_angle
i@129 354
i@125 355 def _read_second_header_line(self, f):
i@125 356 """ Read the second line of a licel file. """
i@125 357 raw_info = {}
i@125 358
ioannis@170 359 second_line = f.readline().decode()
ioannis@194 360 self.header_lines.append(second_line.strip())
i@125 361 # Many Licel files don't follow the licel standard. Specifically, the
i@125 362 # measurement site is not always 8 characters, and can include white
i@125 363 # spaces. For this, the site name is detect everything before the first
i@125 364 # date. For efficiency, the first date is found by the first '/'.
i@125 365 # e.g. assuming a string like 'Site name 01/01/2010 ...'
i@125 366
i@125 367 site_name = second_line.split('/')[0][:-2]
i@125 368 clean_site_name = site_name.strip()
i@129 369 raw_info['site'] = clean_site_name
i@156 370 self.site = clean_site_name
i@125 371
i@125 372 raw_info.update(self.match_lines(second_line[len(clean_site_name) + 1:], self.licel_file_header_format[1]))
i@125 373 return raw_info
i@125 374
i@125 375 def _read_rest_of_header(self, f):
i@125 376 """ Read the rest of the header lines, after line 2. """
i@125 377 # Read third line
ioannis@170 378 third_line = f.readline().decode()
ioannis@194 379 self.header_lines.append(third_line.strip())
ioannis@194 380
i@125 381 raw_dict = self.match_lines(third_line, self.licel_file_header_format[2])
i@125 382 return raw_dict
i@125 383
ioannis@144 384 def _calculate_physical(self):
ioannis@144 385 """ Calculate physical quantities from raw data for all channels in the file. """
ioannis@168 386 for channel in self.channels.values():
ioannis@144 387 channel.calculate_physical()
ioannis@144 388
ioannis@168 389 for photodiode in self.photodiodes.values():
ioannis@144 390 photodiode.calculate_physical()
ioannis@144 391
ulalume3@27 392 def duration(self):
i@128 393 """ Return the duration of the file.
i@128 394
ulalume3@105 395 Returns
ulalume3@105 396 -------
ulalume3@105 397 : float
ulalume3@105 398 The duration of the file in seconds.
ulalume3@105 399 """
ulalume3@27 400 dt = self.stop_time - self.start_time
ulalume3@27 401 return dt.seconds
ulalume3@47 402
ioannis@144 403 def import_header_only(self):
ioannis@146 404 """ Import only the header lines, without reading the actual data."""
i@156 405 with open(self.file_path, 'rb') as f:
ioannis@144 406 self.read_header(f)
ioannis@144 407
i@180 408 @property
i@180 409 def has_photodiode(self):
i@180 410 return len(self.photodiodes) != 0
i@180 411
i@116 412 @staticmethod
i@116 413 def match_lines(f1, f2):
ioannis@194 414 # TODO: Change this to regex?
i@116 415 list1 = f1.split()
i@116 416 list2 = f2.split()
i@116 417
i@116 418 if len(list1) != len(list2):
i@129 419 logging.debug("Channel parameter list has different length from LICEL specifications.")
i@116 420 logging.debug("List 1: %s" % list1)
i@116 421 logging.debug("List 2: %s" % list2)
i@129 422
ioannis@168 423 combined = list(zip(list2, list1))
i@116 424 combined = dict(combined)
i@116 425 return combined
i@116 426
ioannis@194 427 def save_as_csv(self, file_path=None, fill_value=-999):
ioannis@194 428 """ Save the Licel file in txt format.
ioannis@194 429
ioannis@194 430 The format roughly follows the txt files created by Licel software. There are two main differences:
ioannis@194 431 a) Channel names are used as headers.
ioannis@194 432 b) Photon-counting data are given in shots, not in MHz.
ioannis@194 433
ioannis@194 434 Parameters
ioannis@194 435 ----------
ioannis@194 436 file_path : str or None
ioannis@194 437 The output file path. If nan, the input file path is used with a .txt suffix.
ioannis@194 438 fill_value : float
ioannis@194 439 A fill value to be used in case of different length columns, e.g. when saving photodiode data.
ioannis@194 440
ioannis@194 441 Returns
ioannis@194 442 -------
ioannis@194 443 file_path : str
ioannis@194 444 Returns the used file paths. This is useful when input file_path is None.
ioannis@194 445 """
ioannis@194 446 if file_path is None:
ioannis@194 447 file_path = self.file_path + ".csv"
ioannis@194 448
ioannis@194 449 # Collect channel names and data
ioannis@194 450 column_names = []
ioannis@194 451 column_data = []
ioannis@194 452
ioannis@194 453 for name, channel in self.channels.items():
ioannis@194 454 if channel.is_analog:
ioannis@194 455 column_name = "{0} (mV)".format(name)
ioannis@194 456 else:
ioannis@194 457 column_name = "{0} (counts)".format(name)
ioannis@194 458
ioannis@194 459 column_names.append(column_name)
ioannis@194 460 column_data.append(channel.data)
ioannis@194 461
ioannis@194 462 for name, photodiode in self.photodiodes.items():
ioannis@194 463 if 'PD' not in name:
ioannis@194 464 name = 'PD_' + name
ioannis@194 465
ioannis@194 466 column_names.append(name)
ioannis@194 467 column_data.append(photodiode.data)
ioannis@194 468
ioannis@194 469 column_data = self._common_length_array(column_data, fill_value)
ioannis@194 470
ioannis@194 471 header_text = '\n'.join(self.header_lines) + '\n'
ioannis@194 472 column_header = ', '.join(column_names)
ioannis@194 473
ioannis@194 474 np.savetxt(file_path, column_data.T, fmt='%.4f', delimiter=',', header=header_text + column_header, comments='')
ioannis@194 475
ioannis@194 476 return file_path
ioannis@194 477
ioannis@194 478 @staticmethod
ioannis@194 479 def _common_length_array(array_list, fill_value):
ioannis@194 480 """ Make a signle array out of serveral 1D arrays with, possibly, different length"""
ioannis@194 481
ioannis@194 482 lengths = [len(a) for a in array_list]
ioannis@194 483
ioannis@194 484 if len(set(lengths)) == 1:
ioannis@194 485 output_array = np.array(array_list)
ioannis@194 486 else:
ioannis@194 487 dimensions = (len(lengths), max(lengths))
ioannis@194 488 output_array = np.ma.masked_all(dimensions)
ioannis@194 489
ioannis@194 490 for n, array in enumerate(array_list):
ioannis@194 491 output_array[n, :len(array)] = array
ioannis@194 492
ioannis@194 493 output_array.filled(fill_value)
ioannis@194 494
ioannis@194 495 return output_array
ioannis@194 496
ulalume3@47 497
i@128 498 class LicelChannel(LidarChannel):
ulalume3@105 499
i@128 500 def __init__(self):
i@128 501 self.name = None
i@128 502 self.resolution = None
i@128 503 self.points = None
i@128 504 self.wavelength = None
i@128 505 self.laser_used = None
i@116 506
i@128 507 self.rc = []
i@128 508 self.raw_info = []
i@128 509 self.laser_shots = []
i@128 510 self.duration = []
i@128 511 self.discriminator = []
i@128 512 self.hv = []
i@128 513 self.data = {}
ulalume3@47 514
i@131 515 def append_file(self, current_file, file_channel):
i@128 516 """ Append file to the current object """
ulalume3@47 517
i@131 518 self._assign_properties(current_file, file_channel)
i@128 519
i@128 520 self.binwidth = self.resolution * 2. / c # in seconds
i@128 521 self.z = file_channel.z
ulalume3@47 522
i@131 523 self.data[current_file.start_time] = file_channel.data
i@128 524 self.raw_info.append(file_channel.raw_info)
i@180 525
i@128 526 self.duration.append(file_channel.duration)
i@182 527 self.laser_shots.append(file_channel.number_of_shots)
i@128 528 self.discriminator.append(file_channel.discriminator)
i@128 529 self.hv.append(file_channel.hv)
ulalume3@47 530
i@180 531 @property
i@180 532 def number_of_shots(self):
i@180 533 """ Redundant, kept here for backward compatibility """
i@180 534 return self.laser_shots
i@180 535
i@131 536 def _assign_properties(self, current_file, file_channel):
i@128 537 self._assign_unique_property('name', file_channel.channel_name)
i@128 538 self._assign_unique_property('resolution', file_channel.dz)
i@128 539 self._assign_unique_property('wavelength', file_channel.wavelength)
i@128 540 self._assign_unique_property('points', file_channel.data_points)
i@128 541 self._assign_unique_property('adcbits', file_channel.adcbits)
i@128 542 self._assign_unique_property('active', file_channel.active)
i@129 543 self._assign_unique_property('laser_used', file_channel.laser_used)
i@128 544 self._assign_unique_property('analog_photon_string', file_channel.analog_photon_string)
i@181 545 self._assign_unique_property('latitude', current_file.latitude)
i@181 546 self._assign_unique_property('longitude', current_file.longitude)
ulalume3@47 547
i@128 548 def _assign_unique_property(self, property_name, value):
i@128 549
i@128 550 current_value = getattr(self, property_name, None)
i@128 551
i@128 552 if current_value is None:
i@128 553 setattr(self, property_name, value)
binietoglou@0 554 else:
i@128 555 if current_value != value:
i@128 556 raise ValueError('Cannot combine channels with different values of {0}.'.format(property_name))
ioannis@22 557
i@116 558 @property
i@116 559 def is_analog(self):
i@128 560 return self.analog_photon_string == 'an'
i@128 561
i@128 562 @property
i@128 563 def is_photon_counting(self):
i@128 564 return self.analog_photon_string == 'ph'
i@116 565
i@128 566 def __unicode__(self):
i@128 567 return "<Licel channel: %s>" % self.name
i@128 568
i@128 569 def __str__(self):
ioannis@168 570 return str(self).encode('utf-8')
i@130 571
i@130 572
i@130 573 class PhotodiodeChannel(LicelChannel):
i@130 574
i@131 575 def _assign_properties(self, current_channel, file_channel):
i@130 576 """ In contrast with normal channels, don't check for constant points."""
i@130 577 self._assign_unique_property('name', file_channel.channel_name)
i@130 578 self._assign_unique_property('resolution', file_channel.dz)
i@130 579 self._assign_unique_property('wavelength', file_channel.wavelength)
i@130 580 self._assign_unique_property('adcbits', file_channel.adcbits)
i@130 581 self._assign_unique_property('active', file_channel.active)
i@130 582 self._assign_unique_property('laser_used', file_channel.laser_used)
i@130 583 self._assign_unique_property('adcbits', file_channel.adcbits)
i@130 584 self._assign_unique_property('analog_photon_string', file_channel.analog_photon_string)
i@130 585
ulalume3@47 586
binietoglou@0 587 class LicelLidarMeasurement(BaseLidarMeasurement):
i@125 588 file_class = LicelFile
i@125 589 channel_class = LicelChannel
i@130 590 photodiode_class = PhotodiodeChannel
i@125 591
ioannis@186 592 def __init__(self, file_list=None, use_id_as_name=False, get_name_by_order=False, licel_timezone='UTC'):
i@116 593 self.raw_info = {} # Keep the raw info from the files
i@116 594 self.durations = {} # Keep the duration of the files
i@116 595 self.laser_shots = []
i@116 596
ulalume3@47 597 self.use_id_as_name = use_id_as_name
ioannis@186 598 self.get_name_by_order = get_name_by_order
i@101 599 self.licel_timezone = licel_timezone
ioannis@194 600 self.photodiodes = collections.OrderedDict()
i@130 601
ulalume3@92 602 super(LicelLidarMeasurement, self).__init__(file_list)
ulalume3@47 603
ulalume3@92 604 def _import_file(self, filename):
i@130 605
binietoglou@0 606 if filename in self.files:
i@116 607 logger.warning("File has been imported already: %s" % filename)
binietoglou@0 608 else:
i@117 609 logger.debug('Importing file {0}'.format(filename))
i@173 610 current_file = self.file_class(filename, use_id_as_name=self.use_id_as_name,
ioannis@186 611 get_name_by_order=self.get_name_by_order,
i@176 612 licel_timezone=self.licel_timezone)
i@156 613 self.raw_info[current_file.file_path] = current_file.raw_info
i@156 614 self.durations[current_file.file_path] = current_file.duration()
i@104 615
victor@84 616 file_laser_shots = []
ulalume3@47 617
i@125 618 self._create_or_append_channel(current_file)
i@104 619
victor@84 620 self.laser_shots.append(file_laser_shots)
i@156 621 self.files.append(current_file.file_path)
ulalume3@47 622
i@125 623 def _create_or_append_channel(self, current_file):
i@125 624
i@125 625 for channel_name, channel in current_file.channels.items():
i@125 626 if channel_name not in self.channels:
i@125 627 self.channels[channel_name] = self.channel_class()
i@131 628 self.channels[channel_name].append_file(current_file, channel)
i@125 629
i@130 630 for photodiode_name, photodiode in current_file.photodiodes.items():
i@130 631 if photodiode_name not in self.photodiodes:
i@130 632 self.photodiodes[photodiode_name] = self.photodiode_class()
i@131 633 self.photodiodes[photodiode_name].append_file(current_file, photodiode)
i@130 634
binietoglou@0 635 def append(self, other):
binietoglou@0 636
binietoglou@0 637 self.start_times.extend(other.start_times)
binietoglou@0 638 self.stop_times.extend(other.stop_times)
binietoglou@0 639
binietoglou@0 640 for channel_name, channel in self.channels.items():
binietoglou@0 641 channel.append(other.channels[channel_name])
binietoglou@0 642
ioannis@22 643 def _get_duration(self, raw_start_in_seconds):
ulalume3@92 644 """ Return the duration for a given time scale. If only a single
ioannis@22 645 file is imported, then this cannot be guessed from the time difference
ioannis@22 646 and the raw_info of the file are checked.
ulalume3@92 647 """
ulalume3@47 648
ulalume3@47 649 if len(raw_start_in_seconds) == 1: # If only one file imported
ioannis@168 650 duration = next(iter(self.durations.values())) # Get the first (and only) raw_info
ioannis@31 651 duration_sec = duration
ioannis@22 652 else:
ioannis@22 653 duration_sec = np.diff(raw_start_in_seconds)[0]
binietoglou@0 654
ioannis@22 655 return duration_sec
i@104 656
i@116 657 def _get_custom_variables(self, channel_names):
i@116 658
i@116 659 daq_ranges = np.ma.masked_all(len(channel_names))
i@116 660 for n, channel_name in enumerate(channel_names):
i@116 661 channel = self.channels[channel_name]
i@116 662 if channel.is_analog:
i@116 663 unique_values = list(set(channel.discriminator))
i@116 664 if len(unique_values) > 1:
ioannis@194 665 logger.warning(
ioannis@194 666 'More than one discriminator levels for channel {0}: {1}'.format(channel_name, unique_values))
i@116 667 daq_ranges[n] = unique_values[0]
i@116 668
i@116 669 laser_shots = []
i@116 670 for channel_name in channel_names:
i@116 671 channel = self.channels[channel_name]
i@116 672 laser_shots.append(channel.laser_shots)
i@116 673
ioannis@144 674 try:
ioannis@144 675 laser_shots = np.vstack(laser_shots).T
ioannis@144 676 except Exception as e:
ioannis@144 677 logger.error('Could not read laser shots as an array. Maybe files contain different number of channels?')
ioannis@144 678 raise e
i@116 679
victor@84 680 params = [{
i@104 681 "name": "DAQ_Range",
i@104 682 "dimensions": ('channels',),
i@104 683 "type": 'd',
i@116 684 "values": daq_ranges,
i@104 685 }, {
i@104 686 "name": "Laser_Shots",
i@104 687 "dimensions": ('time', 'channels',),
i@104 688 "type": 'i',
i@116 689 "values": laser_shots,
i@104 690 },
victor@84 691 ]
i@104 692
victor@84 693 return params
i@104 694
i@116 695 def _get_custom_global_attributes(self):
i@116 696 """
i@116 697 NetCDF global attributes that should be included
i@116 698 in the final NetCDF file.
i@116 699
i@116 700 Currently the method assumes that all files in the measurement object have the same altitude, lat and lon
i@116 701 properties.
i@116 702 """
i@117 703 logger.debug('Setting custom global attributes')
i@117 704 logger.debug('raw_info keys: {0}'.format(self.raw_info.keys()))
i@117 705
victor@87 706 params = [{
i@104 707 "name": "Altitude_meter_asl",
i@137 708 "value": float(self.raw_info[self.files[0]]["altitude"])
i@104 709 }, {
i@104 710 "name": "Latitude_degrees_north",
i@137 711 "value": float(self.raw_info[self.files[0]]["latitude"])
i@104 712 }, {
i@104 713 "name": "Longitude_degrees_east",
i@137 714 "value": float(self.raw_info[self.files[0]]["longitude"])
i@104 715 },
victor@87 716 ]
victor@87 717
victor@87 718 return params
ioannis@22 719
i@117 720 def subset_by_channels(self, channel_subset):
i@117 721 """
i@117 722 Create a measurement object containing only a subset of channels.
i@117 723
i@117 724 This method overrides the parent method to add some licel-spefic parameters to the new object.
i@117 725
i@117 726 Parameters
i@117 727 ----------
i@117 728 channel_subset : list
i@117 729 A list of channel names (str) to be included in the new measurement object.
i@117 730
i@117 731 Returns
i@117 732 -------
i@117 733 m : BaseLidarMeasurements object
i@117 734 A new measurements object
i@117 735 """
i@117 736 new_measurement = super(LicelLidarMeasurement, self).subset_by_channels(channel_subset)
i@117 737
i@117 738 new_measurement.raw_info = copy.deepcopy(self.raw_info)
i@117 739 new_measurement.durations = copy.deepcopy(self.durations)
i@117 740 new_measurement.laser_shots = copy.deepcopy(self.laser_shots)
i@117 741
i@117 742 return new_measurement
i@117 743
i@119 744 def subset_by_time(self, channel_subset):
i@119 745 """
i@119 746 Subsetting by time does not work yet with Licel files.
i@117 747
i@119 748 This requires changes in generic.py
i@119 749 """
i@119 750 raise NotImplementedError("Subsetting by time, not yet implemented for Licel files.")
i@151 751
i@151 752 def print_channels(self):
i@151 753 """ Print the available channel information on the screen.
i@151 754 """
i@151 755 keys = sorted(self.channels.keys())
i@151 756
i@151 757 print("Name Wavelength Mode Resolution Bins ")
i@151 758
i@151 759 for key in keys:
i@151 760 channel = self.channels[key]
i@151 761 print("{0:<3} {1:<10} {2:<4} {3:<10} {4:<5}".format(channel.name, channel.wavelength,
ioannis@154 762 channel.analog_photon_string, channel.resolution,
ioannis@154 763 channel.points))
i@150 764
ioannis@165 765
ioannis@165 766 class LicelDivaLidarMeasurement(DivaConverterMixin, LicelLidarMeasurement):
ioannis@194 767 pass

mercurial