# HG changeset patch # User Ioannis # Date 1537871445 -10800 # Node ID 5d99df1c7e8113c30f4d629b2da408e22e1323e1 # Parent 03b470b0a05f5ced1d05021bc9a64f2f0adcb430# Parent 3e4e6472b88befa0ba7f8b1a605651e2391397f0 Added vertical lidar measurement class. diff -r 3e4e6472b88b -r 5d99df1c7e81 atmospheric_lidar/diva.py --- a/atmospheric_lidar/diva.py Thu Sep 06 16:51:13 2018 +0300 +++ b/atmospheric_lidar/diva.py Tue Sep 25 13:30:45 2018 +0300 @@ -8,13 +8,16 @@ import datetime import os import numpy as np +import logging import pytz from .generic import BaseLidarMeasurement +logger = logging.getLogger(__name__) -class DivaOutput(BaseLidarMeasurement): + +class DivaMixin: def save_as_diva_netcdf(self, output_path, parameter_file): """ Save the current data in the 'draft' DIVA format. """ @@ -413,3 +416,48 @@ raise ValueError('Emission polarization not one of {0}: {1}'.format(choices.keys(), pol_string)) return choices[pol_string] + + +class DivaLidarMeasurement(BaseLidarMeasurement): + + def __init__(self, file_list): + """ + This is run when creating a new object. + + Parameters + ---------- + file_list : list or str + A list of the full paths to the input file(s). + """ + if isinstance(file_list, str): + file_list = [file_list, ] + super(DivaLidarMeasurement, self).__init__(file_list=file_list) + + def _import_file(self, filename): + """ Import data from a single DIVA file. """ + + logger.debug('Importing file {0}'.format(filename)) + current_file = self.file_class(filename, use_id_as_name=self.use_id_as_name, licel_timezone=self.licel_timezone) + self.durations[current_file.filename] = current_file.duration() + + + self._create_or_append_channel(current_file) + file_laser_shots = [] + self.laser_shots.append(file_laser_shots) + + +class DivaChannel(object): + + def __init__(self, file_name, channel_group): + """ This is run when first creating the object. + + Parameters + ---------- + file_name : str + The filename of the diva dataset. + channel_group : str + The name of the netCDF4 group that holds the channel data. + """ + self.file_name = file_name + self.channel_group = channel_group + diff -r 3e4e6472b88b -r 5d99df1c7e81 atmospheric_lidar/generic.py --- a/atmospheric_lidar/generic.py Thu Sep 06 16:51:13 2018 +0300 +++ b/atmospheric_lidar/generic.py Tue Sep 25 13:30:45 2018 +0300 @@ -34,8 +34,8 @@ Parameters ---------- - file_list : list - A list of the full paths to the input file. + file_list : list or str + A list of the full paths to the input file(s). """ self.info = {} self.dimensions = {} diff -r 3e4e6472b88b -r 5d99df1c7e81 atmospheric_lidar/licel.py --- a/atmospheric_lidar/licel.py Thu Sep 06 16:51:13 2018 +0300 +++ b/atmospheric_lidar/licel.py Tue Sep 25 13:30:45 2018 +0300 @@ -1,11 +1,13 @@ import datetime import logging import copy +import os import numpy as np import pytz -from generic import BaseLidarMeasurement, LidarChannel +from .generic import BaseLidarMeasurement, LidarChannel +from .diva import DivaMixin logger = logging.getLogger(__name__) @@ -171,7 +173,9 @@ corresponding methods directly. This is used to speed up reading files when only header information are required. """ - self.filename = file_path + self.file_path = file_path + self.file_name = os.path.basename(file_path) + self.use_id_as_name = use_id_as_name self.start_time = None self.stop_time = None @@ -186,7 +190,7 @@ channels = {} photodiodes = {} - with open(self.filename, 'rb') as f: + with open(self.file_path, 'rb') as f: self.read_header(f) @@ -200,7 +204,7 @@ b = np.fromfile(f, 'b', 1) if (a[0] != 13) | (b[0] != 10): - logger.warning("No end of line found after record. File could be corrupt: %s" % self.filename) + logger.warning("No end of line found after record. File could be corrupt: %s" % self.file_path) logger.warning('a: {0}, b: {1}.'.format(a, b)) channel = self.channel_data_class(current_channel_info, raw_data, self.duration(), @@ -295,6 +299,7 @@ site_name = second_line.split('/')[0][:-2] clean_site_name = site_name.strip() raw_info['site'] = clean_site_name + self.site = clean_site_name raw_info.update(self.match_lines(second_line[len(clean_site_name) + 1:], self.licel_file_header_format[1])) return raw_info @@ -327,7 +332,7 @@ def import_header_only(self): """ Import only the header lines, without reading the actual data.""" - with open(self.filename, 'rb') as f: + with open(self.file_path, 'rb') as f: self.read_header(f) @staticmethod @@ -452,15 +457,15 @@ else: logger.debug('Importing file {0}'.format(filename)) current_file = self.file_class(filename, use_id_as_name=self.use_id_as_name, licel_timezone=self.licel_timezone) - self.raw_info[current_file.filename] = current_file.raw_info - self.durations[current_file.filename] = current_file.duration() + self.raw_info[current_file.file_path] = current_file.raw_info + self.durations[current_file.file_path] = current_file.duration() file_laser_shots = [] self._create_or_append_channel(current_file) self.laser_shots.append(file_laser_shots) - self.files.append(current_file.filename) + self.files.append(current_file.file_path) def _create_or_append_channel(self, current_file): @@ -600,5 +605,8 @@ for key in keys: channel = self.channels[key] print("{0:<3} {1:<10} {2:<4} {3:<10} {4:<5}".format(channel.name, channel.wavelength, - channel.analog_photon_string, channel.resolution, - channel.points)) \ No newline at end of file + channel.analog_photon_string, channel.resolution, + channel.points)) + +class LicelDivaLidarMeasurement(DivaMixin, LicelLidarMeasurement): + pass \ No newline at end of file diff -r 3e4e6472b88b -r 5d99df1c7e81 atmospheric_lidar/scripts/licel2tc.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/atmospheric_lidar/scripts/licel2tc.py Tue Sep 25 13:30:45 2018 +0300 @@ -0,0 +1,290 @@ +""" Command line tool to convert Licel binary files to EARLINET telecover files. +""" +import argparse +import glob +import logging +import os +import sys + +import numpy as np +from matplotlib import pyplot as plt +import yaml + +from lidar_processing import pre_processing + +from ..licel import LicelFile +from ..__init__ import __version__ + +logger = logging.getLogger(__name__) + + +class TelecoverDataset(object): + def __init__(self, filenames, settings_path, licel_timezone='UTC'): + """ Create a telecover dataset object. + + Parameters + ---------- + filenames : list of str + List of candidate files to include in the telecover. + settings_path : str + Path to the YAML settings file. + licel_timezone : str + The timezone of dates found in the Licel files. Should match the available + timezones in the TZ database. + """ + self.settings_path = settings_path + self.filenames = filenames + self.licel_timezone = licel_timezone + + self.settings = self.read_settings(settings_path) + self.file_sites = {} + self.read_files() + + self.check_files_ok() + + @staticmethod + def read_settings(settings_path): + """ Read the configuration file. + + The file should be in YAML syntax.""" + + if not os.path.isfile(settings_path): + raise IOError("Wrong path for settings file (%s)" % settings_path) + + with open(settings_path) as yaml_file: + try: + settings = yaml.safe_load(yaml_file) + logger.debug("Read settings file(%s)" % settings_path) + except: + raise IOError("Could not parse YAML file (%s)" % settings_path) + + return settings + + def read_files(self): + for filename in self.filenames: + # Load only header + current_file = LicelFile(filename, use_id_as_name=True, licel_timezone=self.licel_timezone, import_now=False) + current_file.import_header_only() + + # Populate the dictionary linking sites with files. + site = current_file.site + + if site in self.settings['sectors'].values(): + current_file.import_file() # Import full data + if current_file.site in self.file_sites.keys(): + + self.file_sites[site].append(current_file) + else: + self.file_sites[site] = [current_file, ] + + def check_files_ok(self): + """ Check if the available files are enough to create the ASCII files. """ + # Check that all sectors are available. + sectors = self.settings['sectors'] + for sector_name, site_name in sectors.items(): + if site_name not in self.file_sites.keys(): + raise ValueError('Site name {0} corresponding to sector {1} not found in any file.'.format(site_name, + sector_name)) + + len_sector = len(self.file_sites[site_name]) + if len_sector > 1: + logger.info('More than one files ({0}) found in sector {1} (site name {2})'.format(len_sector, + sector_name, + site_name)) + + # Check that all files have the correct channels + channels = self.settings['channels'].keys() + for site_name in sectors.values(): + for current_file in self.file_sites[site_name]: + for channel in channels: + if channel not in current_file.channels.keys(): + raise ValueError( + 'File {0} does not contain the required channel {1}.'.format(current_file.file_name, + channel)) + + def save_ascii(self, output_dir='.'): + """ Save the files in the appropriate format. + + Parameters + --------- + output_dir : str + The output directory. + + Returns + ------- + file_paths : list of str + A list of output paths for the saved files. + """ + + date, z = self._get_file_metadata() + + file_paths = [] + + logger.info('Creating {0} files.'.format(len(self.settings['channels']))) + + for channel_id, channel_settings in self.settings['channels'].items(): + + output_filename = '{call_sign}_tc_{date}_{name}.dat'.format(call_sign=self.settings['call_sign'], + date=date.strftime('%Y%m%dT%H'), + name=channel_settings['short_name']) + + output_path = os.path.join(output_dir, output_filename) + + logger.info("Output file path: {0}".format(output_path)) + + header_txt = ("{s[call_sign]} ({s[site]})\r\n" + "{s[system_name]}\r\n" + "{name}\r\n" + "{date}\r\n" + "range, {sectors}").format(s=self.settings, + name=channel_settings['name'], + date=date.strftime('%d.%m.%Y, %H'), + sectors=', '.join(self.settings['sector_order'])) + + # Get altitude data + sectors = self.settings['sector_order'] + + bin_max = self.settings['bins_to_keep'] + + data = [z, ] + for sector in sectors: + site_name = self.settings['sectors'][sector] + files = self.file_sites[site_name] + channel_data = [self.pre_process_channel(f.channels[channel_id]) for f in files] + data.append(np.mean(channel_data, axis=0)) + + data = np.array(data).T[:bin_max, :] + + np.savetxt(output_path, data, fmt='%.5e', delimiter=',', newline='\r\n', header=header_txt, comments='') + + logger.info("File saved.") + + file_paths.append(output_path) + + return file_paths + + def _get_file_metadata(self): + """ Get date string and altitude array that describes the measurement. + + Currently, use the date of the first file in the North direction. + """ + first_sector = self.settings['sector_order'][0] # Could be N, or NO, or ... + first_site = self.settings['sectors'][first_sector] + first_file = self.file_sites[first_site][0] + + channel_id = self.settings['channels'].keys()[0] + channel = first_file.channels[channel_id] + + return first_file.start_time, channel.z + + def pre_process_channel(self, channel): + """ Pre-process channel according to the settings. + + The role of this method is to choose if the channel is photon or analog and call the approparite + method. + """ + settings = self.settings['channels'][channel.channel_name] + + trigger_delay = settings.get('trigger_delay', 0) + background_min = settings['background_min'] + background_max = settings['background_max'] + + if channel.is_analog: + data_rc = self.pre_process_analog(channel, trigger_delay, background_min, background_max) + else: + dead_time = settings['dead_time'] + data_rc = self.pre_process_photon(channel, dead_time, trigger_delay, background_min, background_max) + + return data_rc + + @staticmethod + def pre_process_photon(channel, dead_time, trigger_delay, background_min, background_max): + """ Pre-process photon counting signals""" + interval_ns = channel.bin_width * 1e9 * channel.number_of_shots # Interval in ns, assuming constant laser shots + + data_dt = pre_processing.correct_dead_time_nonparalyzable(channel.data, interval_ns, dead_time) + data_bs, background_mean, background_std = pre_processing.subtract_background(data_dt, background_min, + background_max) + data_tc = pre_processing.correct_trigger_delay_bins(data_bs, trigger_delay) + data_rc = pre_processing.apply_range_correction(data_tc, channel.z) + + return data_rc + + @staticmethod + def pre_process_analog(channel, trigger_delay, background_min, background_max): + + data_bs, background_mean, background_std = pre_processing.subtract_background(channel.data, + background_min, + background_max) + data_tc = pre_processing.correct_trigger_delay_bins(data_bs, trigger_delay) + data_rc = pre_processing.apply_range_correction(data_tc, channel.z) + + return data_rc + + +def convert_to_telecover(file_paths, settings_path, licel_timezone, output_dir): + """ Convert files to SCC. """ + + try: + dataset = TelecoverDataset(file_paths, settings_path, licel_timezone) + except Exception as err: + logger.error(err) + sys.exit(1) + + try: + dataset.save_ascii(output_dir) + except Exception as err: + logger.error(err) + sys.exit(2) + + +def main(): + # Define the command line argument + parser = argparse.ArgumentParser( + description="A program to convert Licel binary files to EARLIENT telecover ASCII format") + parser.add_argument("settings_file", help="The path to a parameter YAML.") + parser.add_argument("files", + help="Location of licel files. Use relative path and filename wildcards. (default './*.*')", + default="./*.*") + parser.add_argument('-o', '--output', help='Output directory.', default='.') + + parser.add_argument('--licel_timezone', help="String describing the timezone according to the tz database.", + default="UTC", dest="licel_timezone", + ) + # Verbosity settings from http://stackoverflow.com/a/20663028 + parser.add_argument('-d', '--debug', help="Print dubuging information.", action="store_const", + dest="loglevel", const=logging.DEBUG, default=logging.INFO, + ) + parser.add_argument('-s', '--silent', help="Show only warning and error messages.", action="store_const", + dest="loglevel", const=logging.WARNING + ) + parser.add_argument('--version', help="Show current version.", action='store_true') + + args = parser.parse_args() + + # Get the logger with the appropriate level + logging.basicConfig(format='%(levelname)s: %(message)s', level=args.loglevel) + logger = logging.getLogger(__name__) + + # Check for version + if args.version: + print("Version: %s" % __version__) + sys.exit(0) + + # Get a list of files to process + logger.debug("Search path: {0}".format(os.path.expanduser(args.files))) + files = glob.glob(os.path.expanduser(args.files)) + + # If not files found, exit + if len(files) == 0: + logger.error("No files found when searching for %s." % args.files) + sys.exit(1) + + # If everything OK, proceed + logger.info("Found {0} files matching {1}".format(len(files), args.files)) + + convert_to_telecover(files, args.settings_file, args.licel_timezone, args.output) + + +if __name__ == "__main__": + main() diff -r 3e4e6472b88b -r 5d99df1c7e81 config/telecover_settings.yaml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/config/telecover_settings.yaml Tue Sep 25 13:30:45 2018 +0300 @@ -0,0 +1,26 @@ +site: Munich # e.g. Munich +call_sign: mu # according to EARLINET +system_name: POLIS # according to HOI +channels: # Link Licel channel name with HOI channel name + BT0: + name: 355 elastic, parallel, analog # For file header + short_name: 355_epa # For file name + trigger_delay: -9 # Positive: trigger delay, Negative: bin shift + background_min: 14000 # Minimum bin for background subtraction + background_max: 15000 # Maximum bin for background subtraction + BC1: + name: 355 elastic, parallel, photon-counting + short_name: 355_epp + trigger_delay: -9 # Positive: trigger delay, Negative: bin shift + background_min: 14000 # Minimum bin for background subtraction + background_max: 15000 # Maximum bin for background subtraction + dead_time: 3.8 # Dead time in ns +sectors: # Link Licel "site" value (as recorded in the file header) with telecover sector + N: 'NO' # These are key: value pairs. The key is the name of the sector, the value is the "site" name in the file + E: EA # 'NO' is a reserved word and evaluates to false. Thus it has to be written in quotes + W: WE + S: SO + N2: NO2 + D: DARK +sector_order: [N, E, W, S, N2, D] # Order for recording +bins_to_keep: 1500 # Number of bins to keep in each profile \ No newline at end of file diff -r 3e4e6472b88b -r 5d99df1c7e81 setup.py --- a/setup.py Thu Sep 06 16:51:13 2018 +0300 +++ b/setup.py Tue Sep 25 13:30:45 2018 +0300 @@ -58,6 +58,7 @@ ], entry_points={ 'console_scripts': ['licel2scc = atmospheric_lidar.scripts.licel2scc:main', - 'licel2scc-depol = atmospheric_lidar.scripts.licel2scc_depol:main'], + 'licel2scc-depol = atmospheric_lidar.scripts.licel2scc_depol:main', + 'licel2tc = atmospheric_lidar.scripts.licel2tc:main'], }, )