"""A simple library for parsing IGC files. Main abstraction defined in this file is the Flight class, which represents a parsed IGC file. A Flight is a collection of: - GNNSFix objects, one per B record in the original file, - IGC metadata, extracted from A/I/H records - a list of detected Thermals, - a list of detected Glides. A Flight is assumed to have one actual flight, from takeoff to landing. If it has zero then it will be invalid, i.e. `Flight.valid` will be False. If there's more than one actual flight in the IGC file then the `which_flight_to_pick` option in FlightParsingConfig will determine behavior. For example usage see the attached igc_lib_demo.py file. Please note that after creating a Flight instance you should always check for its validity via the `Flight.valid` attribute prior to using it, as many IGC records are broken. See `Flight.notes` for details on why a file was considered broken. """ from __future__ import print_function import collections import datetime import math import re import xml.dom.minidom from pathlib2 import Path from collections import defaultdict import lib.viterbi as viterbi import lib.geo as geo def _strip_non_printable_chars(string): """Filters a string removing non-printable characters. Args: string: A string to be filtered. Returns: A string, where non-printable characters are removed. """ printable = set("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL" "MNOPQRSTUVWXYZ!\"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ ") printable_string = [x for x in string if x in printable] return ''.join(printable_string) def _rawtime_float_to_hms(timef): """Converts time from floating point seconds to hours/minutes/seconds. Args: timef: A floating point time in seconds to be converted Returns: A namedtuple with hours, minutes and seconds elements """ time = int(round(timef)) hms = collections.namedtuple('hms', ['hours', 'minutes', 'seconds']) return hms((time/3600), (time % 3600)/60, time % 60) class Turnpoint: """A single turnpoint in a Task. Attributes: lat: a float, latitude in degrees lon: a float, longitude in degrees radius: a float, radius of cylinder or line in km kind: type of turnpoint; "start_exit", "start_enter", "cylinder", "End_of_speed_section", "goal_cylinder", "goal_line" """ def __init__(self, lat, lon, radius, kind): self.lat = lat self.lon = lon self.radius = radius self.kind = kind assert kind in ["start_exit", "start_enter", "cylinder", "End_of_speed_section", "goal_cylinder", "goal_line"], \ "turnpoint type is not valid: %r" % kind def in_radius(self, fix): """Checks whether the provided GNSSFix is within the radius""" distance = geo.earth_distance(self.lat, self.lon, fix.lat, fix.lon) return distance < self.radius class Task: """Stores a single flight task definition Checks if a Flight has achieved the turnpoints in the Task. Attributes: turnpoints: A list of Turnpoint objects. start_time: Raw time (seconds past midnight). The time the race starts. The pilots must start at or after this time. end_time: Raw time (seconds past midnight). The time the race ends. The pilots must finish the race at or before this time. No credit is given for distance covered after this time. """ @staticmethod def create_from_lkt_file(filename): """ Creates Task from LK8000 task file, which is in xml format. LK8000 does not have End of Speed Section or task finish time. For the goal, at the moment, Turnpoints can't handle goal cones or lines, for this reason we default to goal_cylinder. """ # Open XML document using minidom parser DOMTree = xml.dom.minidom.parse(filename) task = DOMTree.documentElement # Get the taskpoints, waypoints and time gate # TODO: add code to handle if these tags are missing. taskpoints = task.getElementsByTagName("taskpoints")[0] waypoints = task.getElementsByTagName("waypoints")[0] gate = task.getElementsByTagName("time-gate")[0] tpoints = taskpoints.getElementsByTagName("point") wpoints = waypoints.getElementsByTagName("point") start_time = gate.getAttribute("open-time") start_hours, start_minutes = start_time.split(':') start_time = int(start_hours) * 3600 + int(start_minutes) * 60 end_time = 23*3600 + 59*60 + 59 # default end_time of 23:59:59 # Create a dictionary of names and a list of longitudes and latitudes # as the waypoints co-ordinates are stored separate to turnpoint # details. coords = defaultdict(list) for point in wpoints: name = point.getAttribute("name") longitude = float(point.getAttribute("longitude")) latitude = float(point.getAttribute("latitude")) coords[name].append(longitude) coords[name].append(latitude) # Create list of turnpoints turnpoints = [] for point in tpoints: lat = coords[point.getAttribute("name")][1] lon = coords[point.getAttribute("name")][0] radius = float(point.getAttribute("radius"))/1000 if point == tpoints[0]: # It is the first turnpoint, the start if point.getAttribute("Exit") == "true": kind = "start_exit" else: kind = "start_enter" else: if point == tpoints[-1]: # It is the last turnpoint, i.e. the goal if point.getAttribute("type") == "line": # TODO(kuaka): change to 'line' once we can process it kind = "goal_cylinder" else: kind = "goal_cylinder" else: # All turnpoints other than the 1st and the last are # "cylinders". In theory they could be # "End_of_speed_section" but this is not supported by # LK8000. For paragliders it would be safe to assume # that the 2nd to last is always "End_of_speed_section". kind = "cylinder" turnpoint = Turnpoint(lat, lon, radius, kind) turnpoints.append(turnpoint) task = Task(turnpoints, start_time, end_time) return task def __init__(self, turnpoints, start_time, end_time): self.turnpoints = turnpoints self.start_time = start_time self.end_time = end_time def check_flight(self, flight): """ Checks a Flight object against the task. Args: flight: a Flight object Returns: a list of GNSSFixes of when turnpoints were achieved. """ reached_turnpoints = [] proceed_to_start = False t = 0 for fix in flight.fixes: if t >= len(self.turnpoints): # Pilot has arrived in goal (last turnpoint) so we can stop. break if self.end_time < fix.rawtime: # Task has ended break # Pilot must have at least 1 fix inside the start after the start # time, then exit. if self.turnpoints[t].kind == "start_exit": if proceed_to_start: if not self.turnpoints[t].in_radius(fix): reached_turnpoints.append(fix) # pilot has started t += 1 if fix.rawtime > self.start_time and not proceed_to_start: if self.turnpoints[t].in_radius(fix): # Pilot is inside start after the start time. proceed_to_start = True # Pilot must have at least 1 fix outside the start after # the start time, then enter. elif self.turnpoints[t].kind == "start_enter": if proceed_to_start: if self.turnpoints[t].in_radius(fix): # Pilot has started reached_turnpoints.append(fix) t += 1 if fix.rawtime > self.start_time and not proceed_to_start: if not self.turnpoints[t].in_radius(fix): # Pilot is outside start after the start time. proceed_to_start = True elif self.turnpoints[t].kind in ["cylinder", "End_of_speed_section", "goal_cylinder"]: if self.turnpoints[t].in_radius(fix): # pilot has achieved turnpoint reached_turnpoints.append(fix) t += 1 else: assert False, ( "Unknown turnpoint kind: %s" % self.turnpoints[t].kind) return reached_turnpoints class GNSSFix: """Stores single GNSS flight recorder fix (a B-record). Raw attributes (i.e. attributes read directly from the B record): rawtime: a float, time since last midnight, UTC, seconds lat: a float, latitude in degrees lon: a float, longitude in degrees validity: a string, GPS validity information from flight recorder press_alt: a float, pressure altitude, meters gnss_alt: a float, GNSS altitude, meters extras: a string, B record extensions Derived attributes: index: an integer, the position of the fix in the IGC file timestamp: a float, true timestamp (since epoch), UTC, seconds alt: a float, either press_alt or gnss_alt gsp: a float, current ground speed, km/h bearing: a float, aircraft bearing, in degrees bearing_change_rate: a float, bearing change rate, degrees/second flying: a bool, whether this fix is during a flight circling: a bool, whether this fix is inside a thermal """ @staticmethod def build_from_B_record(B_record_line, index): """Creates GNSSFix object from IGC B-record line. Args: B_record_line: a string, B record line from an IGC file index: the zero-based position of the fix in the parent IGC file Returns: The created GNSSFix object """ match = re.match( '^B' + '(\d\d)(\d\d)(\d\d)' + '(\d\d)(\d\d)(\d\d\d)([NS])' + '(\d\d\d)(\d\d)(\d\d\d)([EW])' + '([AV])' + '([-\d]\d\d\d\d)' + '([-\d]\d\d\d\d)' + '([0-9a-zA-Z\-]*).*$', B_record_line) if match is None: return None (hours, minutes, seconds, lat_deg, lat_min, lat_min_dec, lat_sign, lon_deg, lon_min, lon_min_dec, lon_sign, validity, press_alt, gnss_alt, extras) = match.groups() rawtime = (float(hours)*60.0 + float(minutes))*60.0 + float(seconds) lat = float(lat_deg) lat += float(lat_min) / 60.0 lat += float(lat_min_dec) / 1000.0 / 60.0 if lat_sign == 'S': lat = -lat lon = float(lon_deg) lon += float(lon_min) / 60.0 lon += float(lon_min_dec) / 1000.0 / 60.0 if lon_sign == 'W': lon = -lon press_alt = float(press_alt) gnss_alt = float(gnss_alt) return GNSSFix(rawtime, lat, lon, validity, press_alt, gnss_alt, index, extras) def __init__(self, rawtime, lat, lon, validity, press_alt, gnss_alt, index, extras): """Initializer of GNSSFix. Not meant to be used directly.""" self.rawtime = rawtime self.lat = lat self.lon = lon self.validity = validity self.press_alt = press_alt self.gnss_alt = gnss_alt self.index = index self.extras = extras self.flight = None def set_flight(self, flight): """Sets parent Flight object.""" self.flight = flight if self.flight.alt_source == "PRESS": self.alt = self.press_alt elif self.flight.alt_source == "GNSS": self.alt = self.gnss_alt else: assert(False) self.timestamp = self.rawtime + flight.date_timestamp def __repr__(self): return self.__str__() def __str__(self): return ( "GNSSFix(rawtime=%02d:%02d:%02d, lat=%f, lon=%f, press_alt=%.1f, gnss_alt=%.1f)" % (_rawtime_float_to_hms(self.rawtime) + (self.lat, self.lon, self.press_alt, self.gnss_alt))) def bearing_to(self, other): """Computes bearing in degrees to another GNSSFix.""" return geo.bearing_to(self.lat, self.lon, other.lat, other.lon) def distance_to(self, other): """Computes great circle distance in kilometers to another GNSSFix.""" return geo.earth_distance(self.lat, self.lon, other.lat, other.lon) def to_B_record(self): """Reconstructs an IGC B-record.""" rawtime = int(self.rawtime) hours = rawtime / 3600 minutes = (rawtime % 3600) / 60 seconds = rawtime % 60 if self.lat < 0.0: lat = -self.lat lat_sign = 'S' else: lat = self.lat lat_sign = 'N' lat = int(round(lat*60000.0)) lat_deg = lat / 60000 lat_min = (lat % 60000) / 1000 lat_min_dec = lat % 1000 if self.lon < 0.0: lon = -self.lon lon_sign = 'W' else: lon = self.lon lon_sign = 'E' lon = int(round(lon*60000.0)) lon_deg = lon / 60000 lon_min = (lon % 60000) / 1000 lon_min_dec = lon % 1000 validity = self.validity press_alt = int(self.press_alt) gnss_alt = int(self.gnss_alt) extras = self.extras return ( "B" + "%02d%02d%02d" % (hours, minutes, seconds) + "%02d%02d%03d%s" % (lat_deg, lat_min, lat_min_dec, lat_sign) + "%03d%02d%03d%s" % (lon_deg, lon_min, lon_min_dec, lon_sign) + validity + "%05d%05d" % (press_alt, gnss_alt) + extras) class Thermal: """Represents a single thermal detected in a flight. Attributes: enter_fix: a GNSSFix, entry point of the thermal exit_fix: a GNSSFix, exit point of the thermal """ def __init__(self, enter_fix, exit_fix): self.enter_fix = enter_fix self.exit_fix = exit_fix def time_change(self): """Returns the time spent in the thermal, seconds.""" return self.exit_fix.rawtime - self.enter_fix.rawtime def alt_change(self): """Returns the altitude gained/lost in the thermal, meters.""" return self.exit_fix.alt - self.enter_fix.alt def vertical_velocity(self): """Returns average vertical velocity in the thermal, m/s.""" if math.fabs(self.time_change()) < 1e-7: return 0.0 return self.alt_change() / self.time_change() def __repr__(self): return self.__str__() def __str__(self): hms = _rawtime_float_to_hms(self.time_change()) return ("Thermal(vertical_velocity=%.2f m/s, duration=%dm %ds)" % (self.vertical_velocity(), hms.minutes, hms.seconds)) class Glide: """Represents a single glide detected in a flight. Glides are portions of the recorded track between thermals. Attributes: enter_fix: a GNSSFix, entry point of the glide exit_fix: a GNSSFix, exit point of the glide track_length: a float, the total length, in kilometers, of the recorded track, between the entry point and the exit point; note that this is not the same as the distance between these points """ def __init__(self, enter_fix, exit_fix, track_length): self.enter_fix = enter_fix self.exit_fix = exit_fix self.track_length = track_length def time_change(self): """Returns the time spent in the glide, seconds.""" return self.exit_fix.timestamp - self.enter_fix.timestamp def speed(self): """Returns the average speed in the glide, km/h.""" return self.track_length / (self.time_change() / 3600.0) def alt_change(self): """Return the overall altitude change in the glide, meters.""" return self.exit_fix.alt - self.enter_fix.alt def glide_ratio(self): """Returns the L/D of the glide.""" if math.fabs(self.alt_change()) < 1e-7: return 0.0 return (self.track_length * 1000.0) / self.alt_change() def __repr__(self): return self.__str__() def __str__(self): hms = _rawtime_float_to_hms(self.time_change()) return ( ("Glide(dist=%.2f km, avg_speed=%.2f kph, " "avg L/D=%.2f duration=%dm %ds)") % ( self.track_length, self.speed(), self.glide_ratio(), hms.minutes, hms.seconds)) class FlightParsingConfig(object): """Configuration for parsing an IGC file. Defines a set of parameters used to validate a file, and to detect thermals and flight mode. Details in comments. """ # # Flight validation parameters. # # Minimum number of fixes in a file. min_fixes = 50 # Maximum time between fixes, seconds. # Soft limit, some fixes are allowed to exceed. max_seconds_between_fixes = 50.0 # Minimum time between fixes, seconds. # Soft limit, some fixes are allowed to exceed. min_seconds_between_fixes = 1.0 # Maximum number of fixes exceeding time between fix constraints. max_time_violations = 10 # Maximum number of times a file can cross the 0:00 UTC time. max_new_days_in_flight = 2 # Minimum average of absolute values of altitude changes in a file. # This is needed to discover altitude sensors (either pressure or # gps) that report either always constant altitude, or almost # always constant altitude, and therefore are invalid. The unit # is meters/fix. min_avg_abs_alt_change = 0.01 # Maximum altitude change per second between fixes, meters per second. # Soft limit, some fixes are allowed to exceed. max_alt_change_rate = 50.0 # Maximum number of fixes that exceed the altitude change limit. max_alt_change_violations = 3 # Absolute maximum altitude, meters. max_alt = 10000.0 # Absolute minimum altitude, meters. min_alt = -600.0 # # Flight detection parameters. # # Minimum ground speed to switch to flight mode, km/h. min_gsp_flight = 15.0 # Minimum idle time (i.e. time with speed below min_gsp_flight) to switch # to landing, seconds. Exception: end of the file (tail fixes that # do not trigger the above condition), no limit is applied there. min_landing_time = 5.0 * 60.0 # In case there are multiple continuous segments with ground # speed exceeding the limit, which one should be taken? # Available options: # - "first": take the first segment, ignore the part after # the first detected landing. # - "concat": concatenate all segments; will include the down # periods between segments (legacy behavior) which_flight_to_pick = "concat" # # Thermal detection parameters. # # Minimum bearing change to enter a thermal, deg/sec. min_bearing_change_circling = 6.0 # Minimum time between fixes to calculate bearing change, seconds. # See the usage for a more detailed comment on why this is useful. min_time_for_bearing_change = 5.0 # Minimum time to consider circling a thermal, seconds. min_time_for_thermal = 60.0 class Flight: """Parses IGC file, detects thermals and checks for record anomalies. Before using an instance of Flight check the `valid` attribute. An invalid Flight instance is not usable. For an explaination why is a Flight invalid see the `notes` attribute. General attributes: valid: a bool, whether the supplied record is considered valid notes: a list of strings, warnings and errors encountered while parsing/validating the file fixes: a list of GNSSFix objects, one per each valid B record thermals: a list of Thermal objects, the detected thermals glides: a list of Glide objects, the glides between thermals takeoff_fix: a GNSSFix object, the fix at which takeoff was detected landing_fix: a GNSSFix object, the fix at which landing was detected IGC metadata attributes (some might be missing if the flight does not define them): glider_type: a string, the declared glider type competition_class: a string, the declared competition class fr_manuf_code: a string, the flight recorder manufaturer code fr_uniq_id: a string, the flight recorded unique id i_record: a string, the I record (describing B record extensions) fr_firmware_version: a string, the version of the recorder firmware fr_hardware_version: a string, the version of the recorder hardware fr_recorder_type: a string, the type of the recorder fr_gps_receiver: a string, the used GPS receiver fr_pressure_sensor: a string, the used pressure sensor Other attributes: alt_source: a string, the chosen altitude sensor, either "PRESS" or "GNSS" press_alt_valid: a bool, whether the pressure altitude sensor is OK gnss_alt_valid: a bool, whether the GNSS altitude sensor is OK """ @staticmethod def create_from_file(filename, config_class=FlightParsingConfig): """Creates an instance of Flight from a given file. Args: filename: a string, the name of the input IGC file config_class: a class that implements FlightParsingConfig Returns: An instance of Flight built from the supplied IGC file. """ config = config_class() fixes = [] a_records = [] i_records = [] h_records = [] abs_filename = Path(filename).expanduser().absolute() with abs_filename.open('r', encoding="ISO-8859-1") as flight_file: for line in flight_file: line = line.replace('\n', '').replace('\r', '') if not line: continue if line[0] == 'A': a_records.append(line) elif line[0] == 'B': fix = GNSSFix.build_from_B_record(line, index=len(fixes)) if fix is not None: if fixes and math.fabs(fix.rawtime - fixes[-1].rawtime) < 1e-5: # The time did not change since the previous fix. # Ignore this fix. pass else: fixes.append(fix) elif line[0] == 'I': i_records.append(line) elif line[0] == 'H': h_records.append(line) else: # Do not parse any other types of IGC records pass flight = Flight(fixes, a_records, h_records, i_records, config) return flight def __init__(self, fixes, a_records, h_records, i_records, config): """Initializer of the Flight class. Do not use directly.""" self._config = config self.fixes = fixes self.valid = True self.notes = [] if len(fixes) < self._config.min_fixes: self.notes.append( "Error: This file has %d fixes, less than " "the minimum %d." % (len(fixes), self._config.min_fixes)) self.valid = False return self._check_altitudes() if not self.valid: return self._check_fix_rawtime() if not self.valid: return if self.press_alt_valid: self.alt_source = "PRESS" elif self.gnss_alt_valid: self.alt_source = "GNSS" else: self.notes.append( "Error: neither pressure nor gnss altitude is valid.") self.valid = False return if a_records: self._parse_a_records(a_records) if i_records: self._parse_i_records(i_records) if h_records: self._parse_h_records(h_records) if not hasattr(self, 'date_timestamp'): self.notes.append("Error: no date record (HFDTE) in the file") self.valid = False return for fix in self.fixes: fix.set_flight(self) self._compute_ground_speeds() self._compute_flight() self._compute_takeoff_landing() if not hasattr(self, 'takeoff_fix'): self.notes.append("Error: did not detect takeoff.") self.valid = False return self._compute_bearings() self._compute_bearing_change_rates() self._compute_circling() self._find_thermals() def _parse_a_records(self, a_records): """Parses the IGC A record. A record contains the flight recorder manufacturer ID and device unique ID. """ self.fr_manuf_code = _strip_non_printable_chars(a_records[0][1:4]) self.fr_uniq_id = _strip_non_printable_chars(a_records[0][4:7]) def _parse_i_records(self, i_records): """Parses the IGC I records. I records contain a description of extensions used in B records. """ self.i_record = _strip_non_printable_chars(" ".join(i_records)) def _parse_h_records(self, h_records): """Parses the IGC H records. H records (header records) contain a lot of interesting metadata about the file, such as the date of the flight, name of the pilot, glider type, competition class, recorder accuracy and more. Consult the IGC manual for details. """ for record in h_records: self._parse_h_record(record) def _parse_h_record(self, record): if record[0:5] == 'HFDTE': match = re.match( '(?:HFDTE|HFDTEDATE:[ ]*)(\d\d)(\d\d)(\d\d)', record, flags=re.IGNORECASE) if match: dd, mm, yy = [_strip_non_printable_chars(group) for group in match.groups()] year = int(2000 + int(yy)) month = int(mm) day = int(dd) if 1 <= month <= 12 and 1 <= day <= 31: epoch = datetime.datetime(year=1970, month=1, day=1) date = datetime.datetime(year=year, month=month, day=day) self.date_timestamp = (date - epoch).total_seconds() elif record[0:5] == 'HFGTY': match = re.match( 'HFGTY[ ]*GLIDER[ ]*TYPE[ ]*:[ ]*(.*)', record, flags=re.IGNORECASE) if match: (self.glider_type,) = map( _strip_non_printable_chars, match.groups()) elif record[0:5] == 'HFRFW' or record[0:5] == 'HFRHW': match = re.match( 'HFR[FH]W[ ]*FIRMWARE[ ]*VERSION[ ]*:[ ]*(.*)', record, flags=re.IGNORECASE) if match: (self.fr_firmware_version,) = map( _strip_non_printable_chars, match.groups()) match = re.match( 'HFR[FH]W[ ]*HARDWARE[ ]*VERSION[ ]*:[ ]*(.*)', record, flags=re.IGNORECASE) if match: (self.fr_hardware_version,) = map( _strip_non_printable_chars, match.groups()) elif record[0:5] == 'HFFTY': match = re.match( 'HFFTY[ ]*FR[ ]*TYPE[ ]*:[ ]*(.*)', record, flags=re.IGNORECASE) if match: (self.fr_recorder_type,) = map(_strip_non_printable_chars, match.groups()) elif record[0:5] == 'HFGPS': match = re.match( 'HFGPS(?:[: ]|(?:GPS))*(.*)', record, flags=re.IGNORECASE) if match: (self.fr_gps_receiver,) = map(_strip_non_printable_chars, match.groups()) elif record[0:5] == 'HFPRS': match = re.match( 'HFPRS[ ]*PRESS[ ]*ALT[ ]*SENSOR[ ]*:[ ]*(.*)', record, flags=re.IGNORECASE) if match: (self.fr_pressure_sensor,) = map(_strip_non_printable_chars, match.groups()) elif record[0:5] == 'HFCCL': match = re.match( 'HFCCL[ ]*COMPETITION[ ]*CLASS[ ]*:[ ]*(.*)', record, flags=re.IGNORECASE) if match: (self.competition_class,) = map(_strip_non_printable_chars, match.groups()) def __str__(self): descr = "Flight(valid=%s, fixes: %d" % ( str(self.valid), len(self.fixes)) if hasattr(self, 'thermals'): descr += ", thermals: %d" % len(self.thermals) descr += ")" return descr def _check_altitudes(self): press_alt_violations_num = 0 gnss_alt_violations_num = 0 press_huge_changes_num = 0 gnss_huge_changes_num = 0 press_chgs_sum = 0.0 gnss_chgs_sum = 0.0 for i in range(len(self.fixes) - 1): press_alt_delta = math.fabs( self.fixes[i+1].press_alt - self.fixes[i].press_alt) gnss_alt_delta = math.fabs( self.fixes[i+1].gnss_alt - self.fixes[i].gnss_alt) rawtime_delta = math.fabs( self.fixes[i+1].rawtime - self.fixes[i].rawtime) if rawtime_delta > 0.5: if (press_alt_delta / rawtime_delta > self._config.max_alt_change_rate): press_huge_changes_num += 1 else: press_chgs_sum += press_alt_delta if (gnss_alt_delta / rawtime_delta > self._config.max_alt_change_rate): gnss_huge_changes_num += 1 else: gnss_chgs_sum += gnss_alt_delta if (self.fixes[i].press_alt > self._config.max_alt or self.fixes[i].press_alt < self._config.min_alt): press_alt_violations_num += 1 if (self.fixes[i].gnss_alt > self._config.max_alt or self.fixes[i].gnss_alt < self._config.min_alt): gnss_alt_violations_num += 1 press_chgs_avg = press_chgs_sum / float(len(self.fixes) - 1) gnss_chgs_avg = gnss_chgs_sum / float(len(self.fixes) - 1) press_alt_ok = True if press_chgs_avg < self._config.min_avg_abs_alt_change: self.notes.append( "Warning: average pressure altitude change between fixes " "is: %f. It is lower than the minimum: %f." % (press_chgs_avg, self._config.min_avg_abs_alt_change)) press_alt_ok = False if press_huge_changes_num > self._config.max_alt_change_violations: self.notes.append( "Warning: too many high changes in pressure altitude: %d. " "Maximum allowed: %d." % (press_huge_changes_num, self._config.max_alt_change_violations)) press_alt_ok = False if press_alt_violations_num > 0: self.notes.append( "Warning: pressure altitude limits exceeded in %d fixes." % (press_alt_violations_num)) press_alt_ok = False gnss_alt_ok = True if gnss_chgs_avg < self._config.min_avg_abs_alt_change: self.notes.append( "Warning: average gnss altitude change between fixes is: %f. " "It is lower than the minimum: %f." % (gnss_chgs_avg, self._config.min_avg_abs_alt_change)) gnss_alt_ok = False if gnss_huge_changes_num > self._config.max_alt_change_violations: self.notes.append( "Warning: too many high changes in gnss altitude: %d. " "Maximum allowed: %d." % (gnss_huge_changes_num, self._config.max_alt_change_violations)) gnss_alt_ok = False if gnss_alt_violations_num > 0: self.notes.append( "Warning: gnss altitude limits exceeded in %d fixes." % gnss_alt_violations_num) gnss_alt_ok = False self.press_alt_valid = press_alt_ok self.gnss_alt_valid = gnss_alt_ok def _check_fix_rawtime(self): """Checks for rawtime anomalies, fixes 0:00 UTC crossing. The B records do not have fully qualified timestamps (just the current time in UTC), therefore flights that cross 0:00 UTC need special handling. """ DAY = 24.0 * 60.0 * 60.0 days_added = 0 rawtime_to_add = 0.0 rawtime_between_fix_exceeded = 0 for i in range(1, len(self.fixes)): f0 = self.fixes[i-1] f1 = self.fixes[i] f1.rawtime += rawtime_to_add if (f0.rawtime > f1.rawtime and f1.rawtime + DAY < f0.rawtime + 200.0): # Day switch days_added += 1 rawtime_to_add += DAY f1.rawtime += DAY time_change = f1.rawtime - f0.rawtime if time_change < self._config.min_seconds_between_fixes - 1e-5: rawtime_between_fix_exceeded += 1 if time_change > self._config.max_seconds_between_fixes + 1e-5: rawtime_between_fix_exceeded += 1 if rawtime_between_fix_exceeded > self._config.max_time_violations: self.notes.append( "Error: too many fixes intervals exceed time between fixes " "constraints. Allowed %d fixes, found %d fixes." % (self._config.max_time_violations, rawtime_between_fix_exceeded)) self.valid = False if days_added > self._config.max_new_days_in_flight: self.notes.append( "Error: too many times did the flight cross the UTC 0:00 " "barrier. Allowed %d times, found %d times." % (self._config.max_new_days_in_flight, days_added)) self.valid = False def _compute_ground_speeds(self): """Adds ground speed info (km/h) to self.fixes.""" self.fixes[0].gsp = 0.0 for i in range(1, len(self.fixes)): dist = self.fixes[i].distance_to(self.fixes[i-1]) rawtime = self.fixes[i].rawtime - self.fixes[i-1].rawtime if math.fabs(rawtime) < 1e-5: self.fixes[i].gsp = 0.0 else: self.fixes[i].gsp = dist/rawtime*3600.0 def _flying_emissions(self): """Generates raw flying/not flying emissions from ground speed. Standing (i.e. not flying) is encoded as 0, flying is encoded as 1. Exported to a separate function to be used in Baum-Welch parameters learning. """ emissions = [] for fix in self.fixes: if fix.gsp > self._config.min_gsp_flight: emissions.append(1) else: emissions.append(0) return emissions def _compute_flight(self): """Adds boolean flag .flying to self.fixes. Two pass: 1. Viterbi decoder 2. Only emit landings (0) if the downtime is more than _config.min_landing_time (or it's the end of the log). """ # Step 1: the Viterbi decoder emissions = self._flying_emissions() decoder = viterbi.SimpleViterbiDecoder( # More likely to start the log standing, i.e. not in flight init_probs=[0.80, 0.20], transition_probs=[ [0.9995, 0.0005], # transitions from standing [0.0005, 0.9995], # transitions from flying ], emission_probs=[ [0.8, 0.2], # emissions from standing [0.2, 0.8], # emissions from flying ]) outputs = decoder.decode(emissions) # Step 2: apply _config.min_landing_time. ignore_next_downtime = False apply_next_downtime = False for i, (fix, output) in enumerate(zip(self.fixes, outputs)): if output == 1: fix.flying = True # We're in flying mode, therefore reset all expectations # about what's happening in the next down mode. ignore_next_downtime = False apply_next_downtime = False else: if apply_next_downtime or ignore_next_downtime: if apply_next_downtime: fix.flying = False else: fix.flying = True else: # We need to determine whether to apply_next_downtime # or to ignore_next_downtime. This requires a scan into # upcoming fixes. Find the next fix on which # the Viterbi decoder said "flying". j = i + 1 while j < len(self.fixes): upcoming_fix_decoded = outputs[j] if upcoming_fix_decoded == 1: break j += 1 if j == len(self.fixes): # No such fix, end of log. Then apply. apply_next_downtime = True fix.flying = False else: # Found next flying fix. upcoming_fix = self.fixes[j] upcoming_fix_time_ahead = upcoming_fix.rawtime - fix.rawtime # If it's far enough into the future of then apply. if upcoming_fix_time_ahead >= self._config.min_landing_time: apply_next_downtime = True fix.flying = False else: ignore_next_downtime = True fix.flying = True def _compute_takeoff_landing(self): """Finds the takeoff and landing fixes in the log. Takeoff fix is the first fix in the flying mode. Landing fix is the next fix after the last fix in the flying mode or the last fix in the file. """ takeoff_fix = None landing_fix = None was_flying = False for fix in self.fixes: if fix.flying and takeoff_fix is None: takeoff_fix = fix if not fix.flying and was_flying: landing_fix = fix if self._config.which_flight_to_pick == "first": # User requested to select just the first flight in the log, # terminate now. break was_flying = fix.flying if takeoff_fix is None: # No takeoff found. return if landing_fix is None: # Landing on the last fix landing_fix = self.fixes[-1] self.takeoff_fix = takeoff_fix self.landing_fix = landing_fix def _compute_bearings(self): """Adds bearing info to self.fixes.""" for i in range(len(self.fixes) - 1): self.fixes[i].bearing = self.fixes[i].bearing_to(self.fixes[i+1]) self.fixes[-1].bearing = self.fixes[-2].bearing def _compute_bearing_change_rates(self): """Adds bearing change rate info to self.fixes. Computing bearing change rate between neighboring fixes proved itself to be noisy on tracks recorded with minimum interval (1 second). Therefore we compute rates between points that are at least min_time_for_bearing_change seconds apart. """ def find_prev_fix(curr_fix): """Computes the previous fix to be used in bearing rate change.""" prev_fix = None for i in range(curr_fix - 1, 0, -1): time_dist = math.fabs(self.fixes[curr_fix].timestamp - self.fixes[i].timestamp) if (time_dist > self._config.min_time_for_bearing_change - 1e-7): prev_fix = i break return prev_fix for curr_fix in range(len(self.fixes)): prev_fix = find_prev_fix(curr_fix) if prev_fix is None: self.fixes[curr_fix].bearing_change_rate = 0.0 else: bearing_change = (self.fixes[prev_fix].bearing - self.fixes[curr_fix].bearing) if math.fabs(bearing_change) > 180.0: if bearing_change < 0.0: bearing_change += 360.0 else: bearing_change -= 360.0 time_change = (self.fixes[prev_fix].timestamp - self.fixes[curr_fix].timestamp) change_rate = bearing_change/time_change self.fixes[curr_fix].bearing_change_rate = change_rate def _circling_emissions(self): """Generates raw circling/straight emissions from bearing change. Staight flight is encoded as 0, circling is encoded as 1. Exported to a separate function to be used in Baum-Welch parameters learning. """ emissions = [] for fix in self.fixes: bearing_change = math.fabs(fix.bearing_change_rate) bearing_change_enough = ( bearing_change > self._config.min_bearing_change_circling) if fix.flying and bearing_change_enough: emissions.append(1) else: emissions.append(0) return emissions def _compute_circling(self): """Adds .circling to self.fixes.""" emissions = self._circling_emissions() decoder = viterbi.SimpleViterbiDecoder( # More likely to start in straight flight than in circling init_probs=[0.80, 0.20], transition_probs=[ [0.982, 0.018], # transitions from straight flight [0.030, 0.970], # transitions from circling ], emission_probs=[ [0.942, 0.058], # emissions from straight flight [0.093, 0.907], # emissions from circling ]) output = decoder.decode(emissions) for i in range(len(self.fixes)): self.fixes[i].circling = (output[i] == 1) def _find_thermals(self): """Go through the fixes and find the thermals. Every point not in a thermal is put into a glide.If we get to end of the fixes and there is still an open glide (i.e. flight not finishing in a valid thermal) the glide will be closed. """ takeoff_index = self.takeoff_fix.index landing_index = self.landing_fix.index flight_fixes = self.fixes[takeoff_index:landing_index + 1] self.thermals = [] self.glides = [] circling_now = False gliding_now = False first_fix = None first_glide_fix = None last_glide_fix = None distance = 0.0 for fix in flight_fixes: if not circling_now and fix.circling: # Just started circling circling_now = True first_fix = fix distance_start_circling = distance elif circling_now and not fix.circling: # Just ended circling circling_now = False thermal = Thermal(first_fix, fix) if (thermal.time_change() > self._config.min_time_for_thermal - 1e-5): self.thermals.append(thermal) # glide ends at start of thermal glide = Glide(first_glide_fix, first_fix, distance_start_circling) self.glides.append(glide) gliding_now = False if gliding_now: distance = distance + fix.distance_to(last_glide_fix) last_glide_fix = fix else: # just started gliding first_glide_fix = fix last_glide_fix = fix gliding_now = True distance = 0.0 if gliding_now: glide = Glide(first_glide_fix, last_glide_fix, distance) self.glides.append(glide)