├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── igc_lib.py ├── igc_lib_demo.py ├── lib ├── __init__.py ├── dumpers.py ├── geo.py ├── test_dumpers.py ├── test_geo.py ├── test_viterbi.py └── viterbi.py ├── requirements.txt ├── run_tests.sh ├── test_igc_lib.py ├── testfiles ├── flight_with_middle_landing.igc ├── napret.igc ├── napret.lkt ├── new_date_format.igc ├── new_zealand.igc ├── no_time_increment.igc └── olsztyn.igc └── tools ├── baum_welch_trainer.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.igc 3 | .*.swp 4 | 5 | # Do not ignore anything in testfiles 6 | !testfiles/* 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Marcin Osowski, Stuart Mackintosh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A simple library to parse IGC logs and extract thermals. 2 | 3 | Uses ground speed to detect flight and aircraft bearing rate of 4 | change to detect thermalling. Both are smoothed using the 5 | Viterbi algorithm. 6 | 7 | The code has been battle-tested against a couple hundred thousand 8 | IGC files. Detects various anomalies in the logs and marks files 9 | as suspicious/invalid, providing an explaination for the decision. 10 | If you find an IGC file on which the library misbehaves please 11 | open a GitHub issue, we'd be happy to investigate. 12 | 13 | Example usage: 14 | 15 | ``` 16 | python igc_lib_demo.py some_file.igc 17 | ``` 18 | 19 | Should work both on Python 2.7 and on Python 3. 20 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcin-osowski/igc_lib/14d683768e5f970abf612e978ed8ad98013d6d0d/__init__.py -------------------------------------------------------------------------------- /igc_lib.py: -------------------------------------------------------------------------------- 1 | """A simple library for parsing IGC files. 2 | 3 | Main abstraction defined in this file is the Flight class, which 4 | represents a parsed IGC file. A Flight is a collection of: 5 | - GNNSFix objects, one per B record in the original file, 6 | - IGC metadata, extracted from A/I/H records 7 | - a list of detected Thermals, 8 | - a list of detected Glides. 9 | 10 | A Flight is assumed to have one actual flight, 11 | from takeoff to landing. If it has zero then it will 12 | be invalid, i.e. `Flight.valid` will be False. 13 | 14 | If there's more than one actual flight in the IGC file then 15 | the `which_flight_to_pick` option in FlightParsingConfig 16 | will determine behavior. 17 | 18 | For example usage see the attached igc_lib_demo.py file. Please note 19 | that after creating a Flight instance you should always check for its 20 | validity via the `Flight.valid` attribute prior to using it, as many 21 | IGC records are broken. See `Flight.notes` for details on why a file 22 | was considered broken. 23 | """ 24 | 25 | from __future__ import print_function 26 | 27 | import collections 28 | import datetime 29 | import math 30 | import re 31 | import xml.dom.minidom 32 | from pathlib2 import Path 33 | 34 | from collections import defaultdict 35 | 36 | import lib.viterbi as viterbi 37 | import lib.geo as geo 38 | 39 | 40 | def _strip_non_printable_chars(string): 41 | """Filters a string removing non-printable characters. 42 | 43 | Args: 44 | string: A string to be filtered. 45 | 46 | Returns: 47 | A string, where non-printable characters are removed. 48 | """ 49 | printable = set("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL" 50 | "MNOPQRSTUVWXYZ!\"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ ") 51 | 52 | printable_string = [x for x in string if x in printable] 53 | return ''.join(printable_string) 54 | 55 | 56 | def _rawtime_float_to_hms(timef): 57 | """Converts time from floating point seconds to hours/minutes/seconds. 58 | 59 | Args: 60 | timef: A floating point time in seconds to be converted 61 | 62 | Returns: 63 | A namedtuple with hours, minutes and seconds elements 64 | """ 65 | time = int(round(timef)) 66 | hms = collections.namedtuple('hms', ['hours', 'minutes', 'seconds']) 67 | 68 | return hms((time/3600), (time % 3600)/60, time % 60) 69 | 70 | 71 | class Turnpoint: 72 | """A single turnpoint in a Task. 73 | 74 | Attributes: 75 | lat: a float, latitude in degrees 76 | lon: a float, longitude in degrees 77 | radius: a float, radius of cylinder or line in km 78 | kind: type of turnpoint; "start_exit", "start_enter", "cylinder", 79 | "End_of_speed_section", "goal_cylinder", "goal_line" 80 | """ 81 | 82 | def __init__(self, lat, lon, radius, kind): 83 | self.lat = lat 84 | self.lon = lon 85 | self.radius = radius 86 | self.kind = kind 87 | assert kind in ["start_exit", "start_enter", "cylinder", 88 | "End_of_speed_section", "goal_cylinder", 89 | "goal_line"], \ 90 | "turnpoint type is not valid: %r" % kind 91 | 92 | def in_radius(self, fix): 93 | """Checks whether the provided GNSSFix is within the radius""" 94 | distance = geo.earth_distance(self.lat, self.lon, fix.lat, fix.lon) 95 | return distance < self.radius 96 | 97 | 98 | class Task: 99 | """Stores a single flight task definition 100 | 101 | Checks if a Flight has achieved the turnpoints in the Task. 102 | 103 | Attributes: 104 | turnpoints: A list of Turnpoint objects. 105 | start_time: Raw time (seconds past midnight). The time the race starts. 106 | The pilots must start at or after this time. 107 | end_time: Raw time (seconds past midnight). The time the race ends. 108 | The pilots must finish the race at or before this time. 109 | No credit is given for distance covered after this time. 110 | """ 111 | 112 | @staticmethod 113 | def create_from_lkt_file(filename): 114 | """ Creates Task from LK8000 task file, which is in xml format. 115 | LK8000 does not have End of Speed Section or task finish time. 116 | For the goal, at the moment, Turnpoints can't handle goal cones or 117 | lines, for this reason we default to goal_cylinder. 118 | """ 119 | 120 | # Open XML document using minidom parser 121 | DOMTree = xml.dom.minidom.parse(filename) 122 | task = DOMTree.documentElement 123 | 124 | # Get the taskpoints, waypoints and time gate 125 | # TODO: add code to handle if these tags are missing. 126 | taskpoints = task.getElementsByTagName("taskpoints")[0] 127 | waypoints = task.getElementsByTagName("waypoints")[0] 128 | gate = task.getElementsByTagName("time-gate")[0] 129 | tpoints = taskpoints.getElementsByTagName("point") 130 | wpoints = waypoints.getElementsByTagName("point") 131 | start_time = gate.getAttribute("open-time") 132 | 133 | start_hours, start_minutes = start_time.split(':') 134 | start_time = int(start_hours) * 3600 + int(start_minutes) * 60 135 | end_time = 23*3600 + 59*60 + 59 # default end_time of 23:59:59 136 | 137 | # Create a dictionary of names and a list of longitudes and latitudes 138 | # as the waypoints co-ordinates are stored separate to turnpoint 139 | # details. 140 | coords = defaultdict(list) 141 | 142 | for point in wpoints: 143 | name = point.getAttribute("name") 144 | longitude = float(point.getAttribute("longitude")) 145 | latitude = float(point.getAttribute("latitude")) 146 | coords[name].append(longitude) 147 | coords[name].append(latitude) 148 | 149 | # Create list of turnpoints 150 | turnpoints = [] 151 | for point in tpoints: 152 | lat = coords[point.getAttribute("name")][1] 153 | lon = coords[point.getAttribute("name")][0] 154 | radius = float(point.getAttribute("radius"))/1000 155 | 156 | if point == tpoints[0]: 157 | # It is the first turnpoint, the start 158 | if point.getAttribute("Exit") == "true": 159 | kind = "start_exit" 160 | else: 161 | kind = "start_enter" 162 | else: 163 | if point == tpoints[-1]: 164 | # It is the last turnpoint, i.e. the goal 165 | if point.getAttribute("type") == "line": 166 | # TODO(kuaka): change to 'line' once we can process it 167 | kind = "goal_cylinder" 168 | else: 169 | kind = "goal_cylinder" 170 | else: 171 | # All turnpoints other than the 1st and the last are 172 | # "cylinders". In theory they could be 173 | # "End_of_speed_section" but this is not supported by 174 | # LK8000. For paragliders it would be safe to assume 175 | # that the 2nd to last is always "End_of_speed_section". 176 | kind = "cylinder" 177 | 178 | turnpoint = Turnpoint(lat, lon, radius, kind) 179 | turnpoints.append(turnpoint) 180 | task = Task(turnpoints, start_time, end_time) 181 | return task 182 | 183 | def __init__(self, turnpoints, start_time, end_time): 184 | self.turnpoints = turnpoints 185 | self.start_time = start_time 186 | self.end_time = end_time 187 | 188 | def check_flight(self, flight): 189 | """ Checks a Flight object against the task. 190 | 191 | Args: 192 | flight: a Flight object 193 | 194 | Returns: 195 | a list of GNSSFixes of when turnpoints were achieved. 196 | """ 197 | reached_turnpoints = [] 198 | proceed_to_start = False 199 | t = 0 200 | for fix in flight.fixes: 201 | if t >= len(self.turnpoints): 202 | # Pilot has arrived in goal (last turnpoint) so we can stop. 203 | break 204 | 205 | if self.end_time < fix.rawtime: 206 | # Task has ended 207 | break 208 | 209 | # Pilot must have at least 1 fix inside the start after the start 210 | # time, then exit. 211 | if self.turnpoints[t].kind == "start_exit": 212 | if proceed_to_start: 213 | if not self.turnpoints[t].in_radius(fix): 214 | reached_turnpoints.append(fix) # pilot has started 215 | t += 1 216 | if fix.rawtime > self.start_time and not proceed_to_start: 217 | if self.turnpoints[t].in_radius(fix): 218 | # Pilot is inside start after the start time. 219 | proceed_to_start = True 220 | 221 | # Pilot must have at least 1 fix outside the start after 222 | # the start time, then enter. 223 | elif self.turnpoints[t].kind == "start_enter": 224 | if proceed_to_start: 225 | if self.turnpoints[t].in_radius(fix): 226 | # Pilot has started 227 | reached_turnpoints.append(fix) 228 | t += 1 229 | if fix.rawtime > self.start_time and not proceed_to_start: 230 | if not self.turnpoints[t].in_radius(fix): 231 | # Pilot is outside start after the start time. 232 | proceed_to_start = True 233 | 234 | elif self.turnpoints[t].kind in ["cylinder", 235 | "End_of_speed_section", 236 | "goal_cylinder"]: 237 | if self.turnpoints[t].in_radius(fix): 238 | # pilot has achieved turnpoint 239 | reached_turnpoints.append(fix) 240 | t += 1 241 | else: 242 | assert False, ( 243 | "Unknown turnpoint kind: %s" % self.turnpoints[t].kind) 244 | 245 | return reached_turnpoints 246 | 247 | 248 | class GNSSFix: 249 | """Stores single GNSS flight recorder fix (a B-record). 250 | 251 | Raw attributes (i.e. attributes read directly from the B record): 252 | rawtime: a float, time since last midnight, UTC, seconds 253 | lat: a float, latitude in degrees 254 | lon: a float, longitude in degrees 255 | validity: a string, GPS validity information from flight recorder 256 | press_alt: a float, pressure altitude, meters 257 | gnss_alt: a float, GNSS altitude, meters 258 | extras: a string, B record extensions 259 | 260 | Derived attributes: 261 | index: an integer, the position of the fix in the IGC file 262 | timestamp: a float, true timestamp (since epoch), UTC, seconds 263 | alt: a float, either press_alt or gnss_alt 264 | gsp: a float, current ground speed, km/h 265 | bearing: a float, aircraft bearing, in degrees 266 | bearing_change_rate: a float, bearing change rate, degrees/second 267 | flying: a bool, whether this fix is during a flight 268 | circling: a bool, whether this fix is inside a thermal 269 | """ 270 | 271 | @staticmethod 272 | def build_from_B_record(B_record_line, index): 273 | """Creates GNSSFix object from IGC B-record line. 274 | 275 | Args: 276 | B_record_line: a string, B record line from an IGC file 277 | index: the zero-based position of the fix in the parent IGC file 278 | 279 | Returns: 280 | The created GNSSFix object 281 | """ 282 | match = re.match( 283 | '^B' + '(\d\d)(\d\d)(\d\d)' 284 | + '(\d\d)(\d\d)(\d\d\d)([NS])' 285 | + '(\d\d\d)(\d\d)(\d\d\d)([EW])' 286 | + '([AV])' + '([-\d]\d\d\d\d)' + '([-\d]\d\d\d\d)' 287 | + '([0-9a-zA-Z\-]*).*$', B_record_line) 288 | if match is None: 289 | return None 290 | (hours, minutes, seconds, 291 | lat_deg, lat_min, lat_min_dec, lat_sign, 292 | lon_deg, lon_min, lon_min_dec, lon_sign, 293 | validity, press_alt, gnss_alt, 294 | extras) = match.groups() 295 | 296 | rawtime = (float(hours)*60.0 + float(minutes))*60.0 + float(seconds) 297 | 298 | lat = float(lat_deg) 299 | lat += float(lat_min) / 60.0 300 | lat += float(lat_min_dec) / 1000.0 / 60.0 301 | if lat_sign == 'S': 302 | lat = -lat 303 | 304 | lon = float(lon_deg) 305 | lon += float(lon_min) / 60.0 306 | lon += float(lon_min_dec) / 1000.0 / 60.0 307 | if lon_sign == 'W': 308 | lon = -lon 309 | 310 | press_alt = float(press_alt) 311 | gnss_alt = float(gnss_alt) 312 | 313 | return GNSSFix(rawtime, lat, lon, validity, press_alt, gnss_alt, 314 | index, extras) 315 | 316 | def __init__(self, rawtime, lat, lon, validity, press_alt, gnss_alt, 317 | index, extras): 318 | """Initializer of GNSSFix. Not meant to be used directly.""" 319 | self.rawtime = rawtime 320 | self.lat = lat 321 | self.lon = lon 322 | self.validity = validity 323 | self.press_alt = press_alt 324 | self.gnss_alt = gnss_alt 325 | self.index = index 326 | self.extras = extras 327 | self.flight = None 328 | 329 | def set_flight(self, flight): 330 | """Sets parent Flight object.""" 331 | self.flight = flight 332 | if self.flight.alt_source == "PRESS": 333 | self.alt = self.press_alt 334 | elif self.flight.alt_source == "GNSS": 335 | self.alt = self.gnss_alt 336 | else: 337 | assert(False) 338 | self.timestamp = self.rawtime + flight.date_timestamp 339 | 340 | def __repr__(self): 341 | return self.__str__() 342 | 343 | def __str__(self): 344 | return ( 345 | "GNSSFix(rawtime=%02d:%02d:%02d, lat=%f, lon=%f, press_alt=%.1f, gnss_alt=%.1f)" % 346 | (_rawtime_float_to_hms(self.rawtime) + 347 | (self.lat, self.lon, self.press_alt, self.gnss_alt))) 348 | 349 | def bearing_to(self, other): 350 | """Computes bearing in degrees to another GNSSFix.""" 351 | return geo.bearing_to(self.lat, self.lon, other.lat, other.lon) 352 | 353 | def distance_to(self, other): 354 | """Computes great circle distance in kilometers to another GNSSFix.""" 355 | return geo.earth_distance(self.lat, self.lon, other.lat, other.lon) 356 | 357 | def to_B_record(self): 358 | """Reconstructs an IGC B-record.""" 359 | rawtime = int(self.rawtime) 360 | hours = rawtime / 3600 361 | minutes = (rawtime % 3600) / 60 362 | seconds = rawtime % 60 363 | 364 | if self.lat < 0.0: 365 | lat = -self.lat 366 | lat_sign = 'S' 367 | else: 368 | lat = self.lat 369 | lat_sign = 'N' 370 | lat = int(round(lat*60000.0)) 371 | lat_deg = lat / 60000 372 | lat_min = (lat % 60000) / 1000 373 | lat_min_dec = lat % 1000 374 | 375 | if self.lon < 0.0: 376 | lon = -self.lon 377 | lon_sign = 'W' 378 | else: 379 | lon = self.lon 380 | lon_sign = 'E' 381 | lon = int(round(lon*60000.0)) 382 | lon_deg = lon / 60000 383 | lon_min = (lon % 60000) / 1000 384 | lon_min_dec = lon % 1000 385 | 386 | validity = self.validity 387 | press_alt = int(self.press_alt) 388 | gnss_alt = int(self.gnss_alt) 389 | extras = self.extras 390 | 391 | return ( 392 | "B" + 393 | "%02d%02d%02d" % (hours, minutes, seconds) + 394 | "%02d%02d%03d%s" % (lat_deg, lat_min, lat_min_dec, lat_sign) + 395 | "%03d%02d%03d%s" % (lon_deg, lon_min, lon_min_dec, lon_sign) + 396 | validity + 397 | "%05d%05d" % (press_alt, gnss_alt) + 398 | extras) 399 | 400 | 401 | class Thermal: 402 | """Represents a single thermal detected in a flight. 403 | 404 | Attributes: 405 | enter_fix: a GNSSFix, entry point of the thermal 406 | exit_fix: a GNSSFix, exit point of the thermal 407 | """ 408 | def __init__(self, enter_fix, exit_fix): 409 | self.enter_fix = enter_fix 410 | self.exit_fix = exit_fix 411 | 412 | def time_change(self): 413 | """Returns the time spent in the thermal, seconds.""" 414 | return self.exit_fix.rawtime - self.enter_fix.rawtime 415 | 416 | def alt_change(self): 417 | """Returns the altitude gained/lost in the thermal, meters.""" 418 | return self.exit_fix.alt - self.enter_fix.alt 419 | 420 | def vertical_velocity(self): 421 | """Returns average vertical velocity in the thermal, m/s.""" 422 | if math.fabs(self.time_change()) < 1e-7: 423 | return 0.0 424 | return self.alt_change() / self.time_change() 425 | 426 | def __repr__(self): 427 | return self.__str__() 428 | 429 | def __str__(self): 430 | hms = _rawtime_float_to_hms(self.time_change()) 431 | return ("Thermal(vertical_velocity=%.2f m/s, duration=%dm %ds)" % 432 | (self.vertical_velocity(), hms.minutes, hms.seconds)) 433 | 434 | 435 | class Glide: 436 | """Represents a single glide detected in a flight. 437 | 438 | Glides are portions of the recorded track between thermals. 439 | 440 | Attributes: 441 | enter_fix: a GNSSFix, entry point of the glide 442 | exit_fix: a GNSSFix, exit point of the glide 443 | track_length: a float, the total length, in kilometers, of the recorded 444 | track, between the entry point and the exit point; note that this is 445 | not the same as the distance between these points 446 | """ 447 | 448 | def __init__(self, enter_fix, exit_fix, track_length): 449 | self.enter_fix = enter_fix 450 | self.exit_fix = exit_fix 451 | self.track_length = track_length 452 | 453 | def time_change(self): 454 | """Returns the time spent in the glide, seconds.""" 455 | return self.exit_fix.timestamp - self.enter_fix.timestamp 456 | 457 | def speed(self): 458 | """Returns the average speed in the glide, km/h.""" 459 | return self.track_length / (self.time_change() / 3600.0) 460 | 461 | def alt_change(self): 462 | """Return the overall altitude change in the glide, meters.""" 463 | return self.exit_fix.alt - self.enter_fix.alt 464 | 465 | def glide_ratio(self): 466 | """Returns the L/D of the glide.""" 467 | if math.fabs(self.alt_change()) < 1e-7: 468 | return 0.0 469 | return (self.track_length * 1000.0) / self.alt_change() 470 | 471 | def __repr__(self): 472 | return self.__str__() 473 | 474 | def __str__(self): 475 | hms = _rawtime_float_to_hms(self.time_change()) 476 | return ( 477 | ("Glide(dist=%.2f km, avg_speed=%.2f kph, " 478 | "avg L/D=%.2f duration=%dm %ds)") % ( 479 | self.track_length, self.speed(), self.glide_ratio(), 480 | hms.minutes, hms.seconds)) 481 | 482 | 483 | class FlightParsingConfig(object): 484 | """Configuration for parsing an IGC file. 485 | 486 | Defines a set of parameters used to validate a file, and to detect 487 | thermals and flight mode. Details in comments. 488 | """ 489 | 490 | # 491 | # Flight validation parameters. 492 | # 493 | 494 | # Minimum number of fixes in a file. 495 | min_fixes = 50 496 | 497 | # Maximum time between fixes, seconds. 498 | # Soft limit, some fixes are allowed to exceed. 499 | max_seconds_between_fixes = 50.0 500 | 501 | # Minimum time between fixes, seconds. 502 | # Soft limit, some fixes are allowed to exceed. 503 | min_seconds_between_fixes = 1.0 504 | 505 | # Maximum number of fixes exceeding time between fix constraints. 506 | max_time_violations = 10 507 | 508 | # Maximum number of times a file can cross the 0:00 UTC time. 509 | max_new_days_in_flight = 2 510 | 511 | # Minimum average of absolute values of altitude changes in a file. 512 | # This is needed to discover altitude sensors (either pressure or 513 | # gps) that report either always constant altitude, or almost 514 | # always constant altitude, and therefore are invalid. The unit 515 | # is meters/fix. 516 | min_avg_abs_alt_change = 0.01 517 | 518 | # Maximum altitude change per second between fixes, meters per second. 519 | # Soft limit, some fixes are allowed to exceed. 520 | max_alt_change_rate = 50.0 521 | 522 | # Maximum number of fixes that exceed the altitude change limit. 523 | max_alt_change_violations = 3 524 | 525 | # Absolute maximum altitude, meters. 526 | max_alt = 10000.0 527 | 528 | # Absolute minimum altitude, meters. 529 | min_alt = -600.0 530 | 531 | # 532 | # Flight detection parameters. 533 | # 534 | 535 | # Minimum ground speed to switch to flight mode, km/h. 536 | min_gsp_flight = 15.0 537 | 538 | # Minimum idle time (i.e. time with speed below min_gsp_flight) to switch 539 | # to landing, seconds. Exception: end of the file (tail fixes that 540 | # do not trigger the above condition), no limit is applied there. 541 | min_landing_time = 5.0 * 60.0 542 | 543 | # In case there are multiple continuous segments with ground 544 | # speed exceeding the limit, which one should be taken? 545 | # Available options: 546 | # - "first": take the first segment, ignore the part after 547 | # the first detected landing. 548 | # - "concat": concatenate all segments; will include the down 549 | # periods between segments (legacy behavior) 550 | which_flight_to_pick = "concat" 551 | 552 | # 553 | # Thermal detection parameters. 554 | # 555 | 556 | # Minimum bearing change to enter a thermal, deg/sec. 557 | min_bearing_change_circling = 6.0 558 | 559 | # Minimum time between fixes to calculate bearing change, seconds. 560 | # See the usage for a more detailed comment on why this is useful. 561 | min_time_for_bearing_change = 5.0 562 | 563 | # Minimum time to consider circling a thermal, seconds. 564 | min_time_for_thermal = 60.0 565 | 566 | 567 | class Flight: 568 | """Parses IGC file, detects thermals and checks for record anomalies. 569 | 570 | Before using an instance of Flight check the `valid` attribute. An 571 | invalid Flight instance is not usable. For an explaination why is 572 | a Flight invalid see the `notes` attribute. 573 | 574 | General attributes: 575 | valid: a bool, whether the supplied record is considered valid 576 | notes: a list of strings, warnings and errors encountered while 577 | parsing/validating the file 578 | fixes: a list of GNSSFix objects, one per each valid B record 579 | thermals: a list of Thermal objects, the detected thermals 580 | glides: a list of Glide objects, the glides between thermals 581 | takeoff_fix: a GNSSFix object, the fix at which takeoff was detected 582 | landing_fix: a GNSSFix object, the fix at which landing was detected 583 | 584 | IGC metadata attributes (some might be missing if the flight does not 585 | define them): 586 | glider_type: a string, the declared glider type 587 | competition_class: a string, the declared competition class 588 | fr_manuf_code: a string, the flight recorder manufaturer code 589 | fr_uniq_id: a string, the flight recorded unique id 590 | i_record: a string, the I record (describing B record extensions) 591 | fr_firmware_version: a string, the version of the recorder firmware 592 | fr_hardware_version: a string, the version of the recorder hardware 593 | fr_recorder_type: a string, the type of the recorder 594 | fr_gps_receiver: a string, the used GPS receiver 595 | fr_pressure_sensor: a string, the used pressure sensor 596 | 597 | Other attributes: 598 | alt_source: a string, the chosen altitude sensor, 599 | either "PRESS" or "GNSS" 600 | press_alt_valid: a bool, whether the pressure altitude sensor is OK 601 | gnss_alt_valid: a bool, whether the GNSS altitude sensor is OK 602 | """ 603 | 604 | @staticmethod 605 | def create_from_file(filename, config_class=FlightParsingConfig): 606 | """Creates an instance of Flight from a given file. 607 | 608 | Args: 609 | filename: a string, the name of the input IGC file 610 | config_class: a class that implements FlightParsingConfig 611 | 612 | Returns: 613 | An instance of Flight built from the supplied IGC file. 614 | """ 615 | config = config_class() 616 | fixes = [] 617 | a_records = [] 618 | i_records = [] 619 | h_records = [] 620 | abs_filename = Path(filename).expanduser().absolute() 621 | with abs_filename.open('r', encoding="ISO-8859-1") as flight_file: 622 | for line in flight_file: 623 | line = line.replace('\n', '').replace('\r', '') 624 | if not line: 625 | continue 626 | if line[0] == 'A': 627 | a_records.append(line) 628 | elif line[0] == 'B': 629 | fix = GNSSFix.build_from_B_record(line, index=len(fixes)) 630 | if fix is not None: 631 | if fixes and math.fabs(fix.rawtime - fixes[-1].rawtime) < 1e-5: 632 | # The time did not change since the previous fix. 633 | # Ignore this fix. 634 | pass 635 | else: 636 | fixes.append(fix) 637 | elif line[0] == 'I': 638 | i_records.append(line) 639 | elif line[0] == 'H': 640 | h_records.append(line) 641 | else: 642 | # Do not parse any other types of IGC records 643 | pass 644 | flight = Flight(fixes, a_records, h_records, i_records, config) 645 | return flight 646 | 647 | def __init__(self, fixes, a_records, h_records, i_records, config): 648 | """Initializer of the Flight class. Do not use directly.""" 649 | self._config = config 650 | self.fixes = fixes 651 | self.valid = True 652 | self.notes = [] 653 | if len(fixes) < self._config.min_fixes: 654 | self.notes.append( 655 | "Error: This file has %d fixes, less than " 656 | "the minimum %d." % (len(fixes), self._config.min_fixes)) 657 | self.valid = False 658 | return 659 | 660 | self._check_altitudes() 661 | if not self.valid: 662 | return 663 | 664 | self._check_fix_rawtime() 665 | if not self.valid: 666 | return 667 | 668 | if self.press_alt_valid: 669 | self.alt_source = "PRESS" 670 | elif self.gnss_alt_valid: 671 | self.alt_source = "GNSS" 672 | else: 673 | self.notes.append( 674 | "Error: neither pressure nor gnss altitude is valid.") 675 | self.valid = False 676 | return 677 | 678 | if a_records: 679 | self._parse_a_records(a_records) 680 | if i_records: 681 | self._parse_i_records(i_records) 682 | if h_records: 683 | self._parse_h_records(h_records) 684 | 685 | if not hasattr(self, 'date_timestamp'): 686 | self.notes.append("Error: no date record (HFDTE) in the file") 687 | self.valid = False 688 | return 689 | 690 | for fix in self.fixes: 691 | fix.set_flight(self) 692 | 693 | self._compute_ground_speeds() 694 | self._compute_flight() 695 | self._compute_takeoff_landing() 696 | if not hasattr(self, 'takeoff_fix'): 697 | self.notes.append("Error: did not detect takeoff.") 698 | self.valid = False 699 | return 700 | 701 | self._compute_bearings() 702 | self._compute_bearing_change_rates() 703 | self._compute_circling() 704 | self._find_thermals() 705 | 706 | def _parse_a_records(self, a_records): 707 | """Parses the IGC A record. 708 | 709 | A record contains the flight recorder manufacturer ID and 710 | device unique ID. 711 | """ 712 | self.fr_manuf_code = _strip_non_printable_chars(a_records[0][1:4]) 713 | self.fr_uniq_id = _strip_non_printable_chars(a_records[0][4:7]) 714 | 715 | def _parse_i_records(self, i_records): 716 | """Parses the IGC I records. 717 | 718 | I records contain a description of extensions used in B records. 719 | """ 720 | self.i_record = _strip_non_printable_chars(" ".join(i_records)) 721 | 722 | def _parse_h_records(self, h_records): 723 | """Parses the IGC H records. 724 | 725 | H records (header records) contain a lot of interesting metadata 726 | about the file, such as the date of the flight, name of the pilot, 727 | glider type, competition class, recorder accuracy and more. 728 | Consult the IGC manual for details. 729 | """ 730 | for record in h_records: 731 | self._parse_h_record(record) 732 | 733 | def _parse_h_record(self, record): 734 | if record[0:5] == 'HFDTE': 735 | match = re.match( 736 | '(?:HFDTE|HFDTEDATE:[ ]*)(\d\d)(\d\d)(\d\d)', 737 | record, flags=re.IGNORECASE) 738 | if match: 739 | dd, mm, yy = [_strip_non_printable_chars(group) for group in match.groups()] 740 | year = int(2000 + int(yy)) 741 | month = int(mm) 742 | day = int(dd) 743 | if 1 <= month <= 12 and 1 <= day <= 31: 744 | epoch = datetime.datetime(year=1970, month=1, day=1) 745 | date = datetime.datetime(year=year, month=month, day=day) 746 | self.date_timestamp = (date - epoch).total_seconds() 747 | elif record[0:5] == 'HFGTY': 748 | match = re.match( 749 | 'HFGTY[ ]*GLIDER[ ]*TYPE[ ]*:[ ]*(.*)', 750 | record, flags=re.IGNORECASE) 751 | if match: 752 | (self.glider_type,) = map( 753 | _strip_non_printable_chars, match.groups()) 754 | elif record[0:5] == 'HFRFW' or record[0:5] == 'HFRHW': 755 | match = re.match( 756 | 'HFR[FH]W[ ]*FIRMWARE[ ]*VERSION[ ]*:[ ]*(.*)', 757 | record, flags=re.IGNORECASE) 758 | if match: 759 | (self.fr_firmware_version,) = map( 760 | _strip_non_printable_chars, match.groups()) 761 | match = re.match( 762 | 'HFR[FH]W[ ]*HARDWARE[ ]*VERSION[ ]*:[ ]*(.*)', 763 | record, flags=re.IGNORECASE) 764 | if match: 765 | (self.fr_hardware_version,) = map( 766 | _strip_non_printable_chars, match.groups()) 767 | elif record[0:5] == 'HFFTY': 768 | match = re.match( 769 | 'HFFTY[ ]*FR[ ]*TYPE[ ]*:[ ]*(.*)', 770 | record, flags=re.IGNORECASE) 771 | if match: 772 | (self.fr_recorder_type,) = map(_strip_non_printable_chars, 773 | match.groups()) 774 | elif record[0:5] == 'HFGPS': 775 | match = re.match( 776 | 'HFGPS(?:[: ]|(?:GPS))*(.*)', 777 | record, flags=re.IGNORECASE) 778 | if match: 779 | (self.fr_gps_receiver,) = map(_strip_non_printable_chars, 780 | match.groups()) 781 | elif record[0:5] == 'HFPRS': 782 | match = re.match( 783 | 'HFPRS[ ]*PRESS[ ]*ALT[ ]*SENSOR[ ]*:[ ]*(.*)', 784 | record, flags=re.IGNORECASE) 785 | if match: 786 | (self.fr_pressure_sensor,) = map(_strip_non_printable_chars, 787 | match.groups()) 788 | elif record[0:5] == 'HFCCL': 789 | match = re.match( 790 | 'HFCCL[ ]*COMPETITION[ ]*CLASS[ ]*:[ ]*(.*)', 791 | record, flags=re.IGNORECASE) 792 | if match: 793 | (self.competition_class,) = map(_strip_non_printable_chars, 794 | match.groups()) 795 | 796 | def __str__(self): 797 | descr = "Flight(valid=%s, fixes: %d" % ( 798 | str(self.valid), len(self.fixes)) 799 | if hasattr(self, 'thermals'): 800 | descr += ", thermals: %d" % len(self.thermals) 801 | descr += ")" 802 | return descr 803 | 804 | def _check_altitudes(self): 805 | press_alt_violations_num = 0 806 | gnss_alt_violations_num = 0 807 | press_huge_changes_num = 0 808 | gnss_huge_changes_num = 0 809 | press_chgs_sum = 0.0 810 | gnss_chgs_sum = 0.0 811 | for i in range(len(self.fixes) - 1): 812 | press_alt_delta = math.fabs( 813 | self.fixes[i+1].press_alt - self.fixes[i].press_alt) 814 | gnss_alt_delta = math.fabs( 815 | self.fixes[i+1].gnss_alt - self.fixes[i].gnss_alt) 816 | rawtime_delta = math.fabs( 817 | self.fixes[i+1].rawtime - self.fixes[i].rawtime) 818 | if rawtime_delta > 0.5: 819 | if (press_alt_delta / rawtime_delta > 820 | self._config.max_alt_change_rate): 821 | press_huge_changes_num += 1 822 | else: 823 | press_chgs_sum += press_alt_delta 824 | if (gnss_alt_delta / rawtime_delta > 825 | self._config.max_alt_change_rate): 826 | gnss_huge_changes_num += 1 827 | else: 828 | gnss_chgs_sum += gnss_alt_delta 829 | if (self.fixes[i].press_alt > self._config.max_alt 830 | or self.fixes[i].press_alt < self._config.min_alt): 831 | press_alt_violations_num += 1 832 | if (self.fixes[i].gnss_alt > self._config.max_alt or 833 | self.fixes[i].gnss_alt < self._config.min_alt): 834 | gnss_alt_violations_num += 1 835 | press_chgs_avg = press_chgs_sum / float(len(self.fixes) - 1) 836 | gnss_chgs_avg = gnss_chgs_sum / float(len(self.fixes) - 1) 837 | 838 | press_alt_ok = True 839 | if press_chgs_avg < self._config.min_avg_abs_alt_change: 840 | self.notes.append( 841 | "Warning: average pressure altitude change between fixes " 842 | "is: %f. It is lower than the minimum: %f." 843 | % (press_chgs_avg, self._config.min_avg_abs_alt_change)) 844 | press_alt_ok = False 845 | 846 | if press_huge_changes_num > self._config.max_alt_change_violations: 847 | self.notes.append( 848 | "Warning: too many high changes in pressure altitude: %d. " 849 | "Maximum allowed: %d." 850 | % (press_huge_changes_num, 851 | self._config.max_alt_change_violations)) 852 | press_alt_ok = False 853 | 854 | if press_alt_violations_num > 0: 855 | self.notes.append( 856 | "Warning: pressure altitude limits exceeded in %d fixes." 857 | % (press_alt_violations_num)) 858 | press_alt_ok = False 859 | 860 | gnss_alt_ok = True 861 | if gnss_chgs_avg < self._config.min_avg_abs_alt_change: 862 | self.notes.append( 863 | "Warning: average gnss altitude change between fixes is: %f. " 864 | "It is lower than the minimum: %f." 865 | % (gnss_chgs_avg, self._config.min_avg_abs_alt_change)) 866 | gnss_alt_ok = False 867 | 868 | if gnss_huge_changes_num > self._config.max_alt_change_violations: 869 | self.notes.append( 870 | "Warning: too many high changes in gnss altitude: %d. " 871 | "Maximum allowed: %d." 872 | % (gnss_huge_changes_num, 873 | self._config.max_alt_change_violations)) 874 | gnss_alt_ok = False 875 | 876 | if gnss_alt_violations_num > 0: 877 | self.notes.append( 878 | "Warning: gnss altitude limits exceeded in %d fixes." % 879 | gnss_alt_violations_num) 880 | gnss_alt_ok = False 881 | 882 | self.press_alt_valid = press_alt_ok 883 | self.gnss_alt_valid = gnss_alt_ok 884 | 885 | def _check_fix_rawtime(self): 886 | """Checks for rawtime anomalies, fixes 0:00 UTC crossing. 887 | 888 | The B records do not have fully qualified timestamps (just the current 889 | time in UTC), therefore flights that cross 0:00 UTC need special 890 | handling. 891 | """ 892 | DAY = 24.0 * 60.0 * 60.0 893 | days_added = 0 894 | rawtime_to_add = 0.0 895 | rawtime_between_fix_exceeded = 0 896 | for i in range(1, len(self.fixes)): 897 | f0 = self.fixes[i-1] 898 | f1 = self.fixes[i] 899 | f1.rawtime += rawtime_to_add 900 | 901 | if (f0.rawtime > f1.rawtime and 902 | f1.rawtime + DAY < f0.rawtime + 200.0): 903 | # Day switch 904 | days_added += 1 905 | rawtime_to_add += DAY 906 | f1.rawtime += DAY 907 | 908 | time_change = f1.rawtime - f0.rawtime 909 | if time_change < self._config.min_seconds_between_fixes - 1e-5: 910 | rawtime_between_fix_exceeded += 1 911 | if time_change > self._config.max_seconds_between_fixes + 1e-5: 912 | rawtime_between_fix_exceeded += 1 913 | 914 | if rawtime_between_fix_exceeded > self._config.max_time_violations: 915 | self.notes.append( 916 | "Error: too many fixes intervals exceed time between fixes " 917 | "constraints. Allowed %d fixes, found %d fixes." 918 | % (self._config.max_time_violations, 919 | rawtime_between_fix_exceeded)) 920 | self.valid = False 921 | if days_added > self._config.max_new_days_in_flight: 922 | self.notes.append( 923 | "Error: too many times did the flight cross the UTC 0:00 " 924 | "barrier. Allowed %d times, found %d times." 925 | % (self._config.max_new_days_in_flight, days_added)) 926 | self.valid = False 927 | 928 | def _compute_ground_speeds(self): 929 | """Adds ground speed info (km/h) to self.fixes.""" 930 | self.fixes[0].gsp = 0.0 931 | for i in range(1, len(self.fixes)): 932 | dist = self.fixes[i].distance_to(self.fixes[i-1]) 933 | rawtime = self.fixes[i].rawtime - self.fixes[i-1].rawtime 934 | if math.fabs(rawtime) < 1e-5: 935 | self.fixes[i].gsp = 0.0 936 | else: 937 | self.fixes[i].gsp = dist/rawtime*3600.0 938 | 939 | def _flying_emissions(self): 940 | """Generates raw flying/not flying emissions from ground speed. 941 | 942 | Standing (i.e. not flying) is encoded as 0, flying is encoded as 1. 943 | Exported to a separate function to be used in Baum-Welch parameters 944 | learning. 945 | """ 946 | emissions = [] 947 | for fix in self.fixes: 948 | if fix.gsp > self._config.min_gsp_flight: 949 | emissions.append(1) 950 | else: 951 | emissions.append(0) 952 | 953 | return emissions 954 | 955 | def _compute_flight(self): 956 | """Adds boolean flag .flying to self.fixes. 957 | 958 | Two pass: 959 | 1. Viterbi decoder 960 | 2. Only emit landings (0) if the downtime is more than 961 | _config.min_landing_time (or it's the end of the log). 962 | """ 963 | # Step 1: the Viterbi decoder 964 | emissions = self._flying_emissions() 965 | decoder = viterbi.SimpleViterbiDecoder( 966 | # More likely to start the log standing, i.e. not in flight 967 | init_probs=[0.80, 0.20], 968 | transition_probs=[ 969 | [0.9995, 0.0005], # transitions from standing 970 | [0.0005, 0.9995], # transitions from flying 971 | ], 972 | emission_probs=[ 973 | [0.8, 0.2], # emissions from standing 974 | [0.2, 0.8], # emissions from flying 975 | ]) 976 | 977 | outputs = decoder.decode(emissions) 978 | 979 | # Step 2: apply _config.min_landing_time. 980 | ignore_next_downtime = False 981 | apply_next_downtime = False 982 | for i, (fix, output) in enumerate(zip(self.fixes, outputs)): 983 | if output == 1: 984 | fix.flying = True 985 | # We're in flying mode, therefore reset all expectations 986 | # about what's happening in the next down mode. 987 | ignore_next_downtime = False 988 | apply_next_downtime = False 989 | else: 990 | if apply_next_downtime or ignore_next_downtime: 991 | if apply_next_downtime: 992 | fix.flying = False 993 | else: 994 | fix.flying = True 995 | else: 996 | # We need to determine whether to apply_next_downtime 997 | # or to ignore_next_downtime. This requires a scan into 998 | # upcoming fixes. Find the next fix on which 999 | # the Viterbi decoder said "flying". 1000 | j = i + 1 1001 | while j < len(self.fixes): 1002 | upcoming_fix_decoded = outputs[j] 1003 | if upcoming_fix_decoded == 1: 1004 | break 1005 | j += 1 1006 | 1007 | if j == len(self.fixes): 1008 | # No such fix, end of log. Then apply. 1009 | apply_next_downtime = True 1010 | fix.flying = False 1011 | else: 1012 | # Found next flying fix. 1013 | upcoming_fix = self.fixes[j] 1014 | upcoming_fix_time_ahead = upcoming_fix.rawtime - fix.rawtime 1015 | # If it's far enough into the future of then apply. 1016 | if upcoming_fix_time_ahead >= self._config.min_landing_time: 1017 | apply_next_downtime = True 1018 | fix.flying = False 1019 | else: 1020 | ignore_next_downtime = True 1021 | fix.flying = True 1022 | 1023 | def _compute_takeoff_landing(self): 1024 | """Finds the takeoff and landing fixes in the log. 1025 | 1026 | Takeoff fix is the first fix in the flying mode. Landing fix 1027 | is the next fix after the last fix in the flying mode or the 1028 | last fix in the file. 1029 | """ 1030 | takeoff_fix = None 1031 | landing_fix = None 1032 | was_flying = False 1033 | for fix in self.fixes: 1034 | if fix.flying and takeoff_fix is None: 1035 | takeoff_fix = fix 1036 | if not fix.flying and was_flying: 1037 | landing_fix = fix 1038 | if self._config.which_flight_to_pick == "first": 1039 | # User requested to select just the first flight in the log, 1040 | # terminate now. 1041 | break 1042 | was_flying = fix.flying 1043 | 1044 | if takeoff_fix is None: 1045 | # No takeoff found. 1046 | return 1047 | 1048 | if landing_fix is None: 1049 | # Landing on the last fix 1050 | landing_fix = self.fixes[-1] 1051 | 1052 | self.takeoff_fix = takeoff_fix 1053 | self.landing_fix = landing_fix 1054 | 1055 | def _compute_bearings(self): 1056 | """Adds bearing info to self.fixes.""" 1057 | for i in range(len(self.fixes) - 1): 1058 | self.fixes[i].bearing = self.fixes[i].bearing_to(self.fixes[i+1]) 1059 | self.fixes[-1].bearing = self.fixes[-2].bearing 1060 | 1061 | def _compute_bearing_change_rates(self): 1062 | """Adds bearing change rate info to self.fixes. 1063 | 1064 | Computing bearing change rate between neighboring fixes proved 1065 | itself to be noisy on tracks recorded with minimum interval (1 second). 1066 | Therefore we compute rates between points that are at least 1067 | min_time_for_bearing_change seconds apart. 1068 | """ 1069 | def find_prev_fix(curr_fix): 1070 | """Computes the previous fix to be used in bearing rate change.""" 1071 | prev_fix = None 1072 | for i in range(curr_fix - 1, 0, -1): 1073 | time_dist = math.fabs(self.fixes[curr_fix].timestamp - 1074 | self.fixes[i].timestamp) 1075 | if (time_dist > 1076 | self._config.min_time_for_bearing_change - 1e-7): 1077 | prev_fix = i 1078 | break 1079 | return prev_fix 1080 | 1081 | for curr_fix in range(len(self.fixes)): 1082 | prev_fix = find_prev_fix(curr_fix) 1083 | 1084 | if prev_fix is None: 1085 | self.fixes[curr_fix].bearing_change_rate = 0.0 1086 | else: 1087 | bearing_change = (self.fixes[prev_fix].bearing - 1088 | self.fixes[curr_fix].bearing) 1089 | if math.fabs(bearing_change) > 180.0: 1090 | if bearing_change < 0.0: 1091 | bearing_change += 360.0 1092 | else: 1093 | bearing_change -= 360.0 1094 | time_change = (self.fixes[prev_fix].timestamp - 1095 | self.fixes[curr_fix].timestamp) 1096 | change_rate = bearing_change/time_change 1097 | self.fixes[curr_fix].bearing_change_rate = change_rate 1098 | 1099 | def _circling_emissions(self): 1100 | """Generates raw circling/straight emissions from bearing change. 1101 | 1102 | Staight flight is encoded as 0, circling is encoded as 1. Exported 1103 | to a separate function to be used in Baum-Welch parameters learning. 1104 | """ 1105 | emissions = [] 1106 | for fix in self.fixes: 1107 | bearing_change = math.fabs(fix.bearing_change_rate) 1108 | bearing_change_enough = ( 1109 | bearing_change > self._config.min_bearing_change_circling) 1110 | if fix.flying and bearing_change_enough: 1111 | emissions.append(1) 1112 | else: 1113 | emissions.append(0) 1114 | return emissions 1115 | 1116 | def _compute_circling(self): 1117 | """Adds .circling to self.fixes.""" 1118 | emissions = self._circling_emissions() 1119 | decoder = viterbi.SimpleViterbiDecoder( 1120 | # More likely to start in straight flight than in circling 1121 | init_probs=[0.80, 0.20], 1122 | transition_probs=[ 1123 | [0.982, 0.018], # transitions from straight flight 1124 | [0.030, 0.970], # transitions from circling 1125 | ], 1126 | emission_probs=[ 1127 | [0.942, 0.058], # emissions from straight flight 1128 | [0.093, 0.907], # emissions from circling 1129 | ]) 1130 | 1131 | output = decoder.decode(emissions) 1132 | 1133 | for i in range(len(self.fixes)): 1134 | self.fixes[i].circling = (output[i] == 1) 1135 | 1136 | def _find_thermals(self): 1137 | """Go through the fixes and find the thermals. 1138 | 1139 | Every point not in a thermal is put into a glide.If we get to end of 1140 | the fixes and there is still an open glide (i.e. flight not finishing 1141 | in a valid thermal) the glide will be closed. 1142 | """ 1143 | takeoff_index = self.takeoff_fix.index 1144 | landing_index = self.landing_fix.index 1145 | flight_fixes = self.fixes[takeoff_index:landing_index + 1] 1146 | 1147 | self.thermals = [] 1148 | self.glides = [] 1149 | circling_now = False 1150 | gliding_now = False 1151 | first_fix = None 1152 | first_glide_fix = None 1153 | last_glide_fix = None 1154 | distance = 0.0 1155 | for fix in flight_fixes: 1156 | if not circling_now and fix.circling: 1157 | # Just started circling 1158 | circling_now = True 1159 | first_fix = fix 1160 | distance_start_circling = distance 1161 | elif circling_now and not fix.circling: 1162 | # Just ended circling 1163 | circling_now = False 1164 | thermal = Thermal(first_fix, fix) 1165 | if (thermal.time_change() > 1166 | self._config.min_time_for_thermal - 1e-5): 1167 | self.thermals.append(thermal) 1168 | # glide ends at start of thermal 1169 | glide = Glide(first_glide_fix, first_fix, 1170 | distance_start_circling) 1171 | self.glides.append(glide) 1172 | gliding_now = False 1173 | 1174 | if gliding_now: 1175 | distance = distance + fix.distance_to(last_glide_fix) 1176 | last_glide_fix = fix 1177 | else: 1178 | # just started gliding 1179 | first_glide_fix = fix 1180 | last_glide_fix = fix 1181 | gliding_now = True 1182 | distance = 0.0 1183 | 1184 | if gliding_now: 1185 | glide = Glide(first_glide_fix, last_glide_fix, distance) 1186 | self.glides.append(glide) 1187 | -------------------------------------------------------------------------------- /igc_lib_demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import os 5 | import sys 6 | 7 | import igc_lib 8 | import lib.dumpers as dumpers 9 | 10 | 11 | def print_flight_details(flight): 12 | print("Flight:", flight) 13 | print("Takeoff:", flight.takeoff_fix) 14 | thermals = flight.thermals 15 | glides = flight.glides 16 | for i in range(max(len(thermals), len(glides))): 17 | if i < len(glides): 18 | print(" glide[%d]:" % i, glides[i]) 19 | if i < len(thermals): 20 | print(" thermal[%d]:" % i, thermals[i]) 21 | print("Landing:", flight.landing_fix) 22 | 23 | 24 | def dump_flight(flight, input_file): 25 | input_base_file = os.path.splitext(input_file)[0] 26 | wpt_file = "%s-thermals.wpt" % input_base_file 27 | cup_file = "%s-thermals.cup" % input_base_file 28 | thermals_csv_file = "%s-thermals.csv" % input_base_file 29 | flight_csv_file = "%s-flight.csv" % input_base_file 30 | kml_file = "%s-flight.kml" % input_base_file 31 | 32 | print("Dumping thermals to %s, %s and %s" % 33 | (wpt_file, cup_file, thermals_csv_file)) 34 | dumpers.dump_thermals_to_wpt_file(flight, wpt_file, True) 35 | dumpers.dump_thermals_to_cup_file(flight, cup_file) 36 | 37 | print("Dumping flight to %s and %s" % (kml_file, flight_csv_file)) 38 | dumpers.dump_flight_to_csv(flight, flight_csv_file, thermals_csv_file) 39 | dumpers.dump_flight_to_kml(flight, kml_file) 40 | 41 | 42 | def main(): 43 | if len(sys.argv) < 2: 44 | print("Usage: %s file.igc [file.lkt]" % sys.argv[0]) 45 | sys.exit(1) 46 | 47 | input_file = sys.argv[1] 48 | task_file = None 49 | if len(sys.argv) > 2: 50 | task_file = sys.argv[2] 51 | 52 | flight = igc_lib.Flight.create_from_file(input_file) 53 | if not flight.valid: 54 | print("Provided flight is invalid:") 55 | print(flight.notes) 56 | sys.exit(1) 57 | 58 | print_flight_details(flight) 59 | dump_flight(flight, input_file) 60 | 61 | if task_file: 62 | task = igc_lib.Task.create_from_lkt_file(task_file) 63 | reached_turnpoints = task.check_flight(flight) 64 | for t, fix in enumerate(reached_turnpoints): 65 | print("Turnpoint[%d] achieved at:" % t, fix.rawtime) 66 | 67 | 68 | if __name__ == "__main__": 69 | main() 70 | -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcin-osowski/igc_lib/14d683768e5f970abf612e978ed8ad98013d6d0d/lib/__init__.py -------------------------------------------------------------------------------- /lib/dumpers.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import simplekml 3 | from pathlib2 import Path 4 | 5 | 6 | def _degrees_float_to_degrees_minutes_seconds(dd, lon_or_lat): 7 | """Converts from floating point degrees to degrees/minutes/seconds. 8 | 9 | Args: 10 | dd: a float, degrees to be converted 11 | lon_or_lat: a string, argument used to calculate the hemisphere; 12 | options are 'lon' - for longitude or 'lat' - for latitude 13 | 14 | Returns: 15 | A namedtuple with hemisphere, degrees, minutes and floating point 16 | seconds elements. 17 | """ 18 | ddmmss = collections.namedtuple( 19 | 'ddmmss', ['hemisphere', 'degrees', 'minutes', 'seconds']) 20 | negative = dd < 0 21 | dd = abs(dd) 22 | minutes, seconds = divmod(dd * 3600, 60) 23 | degrees, minutes = divmod(minutes, 60) 24 | if lon_or_lat == 'lon': 25 | hemisphere = 'E' 26 | elif lon_or_lat == 'lat': 27 | hemisphere = 'N' 28 | 29 | if negative: 30 | if lon_or_lat == 'lon': 31 | hemisphere = 'W' 32 | elif lon_or_lat == 'lat': 33 | hemisphere = 'S' 34 | 35 | return ddmmss(hemisphere, degrees, minutes, seconds) 36 | 37 | 38 | def dump_thermals_to_wpt_file(flight, wptfilename_local, endpoints=False): 39 | """Dump flight's thermals to a .wpt file in Geo format. 40 | 41 | Args: 42 | flight: an igc_lib.Flight, the flight to be written 43 | wptfilename_local: File to be written. If it exists it will be overwritten. 44 | endpoints: optional argument. If true thermal endpoints as well 45 | as startpoints will be written with suffix END in the waypoint label. 46 | """ 47 | wptfilename = Path(wptfilename_local).expanduser().absolute() 48 | with wptfilename.open('w') as wpt: 49 | wpt.write(u"$FormatGEO\n") 50 | 51 | for x, thermal in enumerate(flight.thermals): 52 | lat = _degrees_float_to_degrees_minutes_seconds( 53 | flight.thermals[x].enter_fix.lat, 'lat') 54 | lon = _degrees_float_to_degrees_minutes_seconds( 55 | flight.thermals[x].enter_fix.lon, 'lon') 56 | wpt.write(u"%02d " % x) 57 | wpt.write(u"%s %02d %02d %05.2f " % ( 58 | lat.hemisphere, lat.degrees, lat.minutes, lat.seconds)) 59 | wpt.write(u"%s %03d %02d %05.2f " % ( 60 | lon.hemisphere, lon.degrees, lon.minutes, lon.seconds)) 61 | wpt.write(u" %d\n" % flight.thermals[x].enter_fix.gnss_alt) 62 | 63 | if endpoints: 64 | lat = _degrees_float_to_degrees_minutes_seconds( 65 | flight.thermals[x].exit_fix.lat, 'lat') 66 | lon = _degrees_float_to_degrees_minutes_seconds( 67 | flight.thermals[x].exit_fix.lon, 'lon') 68 | wpt.write(u"%02dEND " % x) 69 | wpt.write(u"%s %02d %02d %05.2f " % ( 70 | lat.hemisphere, lat.degrees, lat.minutes, lat.seconds)) 71 | wpt.write(u"%s %03d %02d %05.2f " % ( 72 | lon.hemisphere, lon.degrees, lon.minutes, lon.seconds)) 73 | wpt.write(u" %d\n" % ( 74 | flight.thermals[x].exit_fix.gnss_alt)) 75 | 76 | 77 | def dump_thermals_to_cup_file(flight, cup_filename_local): 78 | """Dump flight's thermals to a .cup file (SeeYou). 79 | 80 | Args: 81 | flight: an igc_lib.Flight, the flight to be written 82 | cup_filename_local: a string, the name of the file to be written. 83 | """ 84 | cup_filename = Path(cup_filename_local).expanduser().absolute() 85 | with cup_filename.open('wt') as wpt: 86 | wpt.write(u'name,code,country,lat,') 87 | wpt.write(u'lon,elev,style,rwdir,rwlen,freq,desc,userdata,pics\n') 88 | 89 | def write_fix(name, fix): 90 | lat = _degrees_float_to_degrees_minutes_seconds(fix.lat, 'lat') 91 | lon = _degrees_float_to_degrees_minutes_seconds(fix.lon, 'lon') 92 | wpt.write(u'"%s",,,%02d%02d.%03d%s,' % ( 93 | name, lat.degrees, lat.minutes, 94 | int(round(lat.seconds/60.0*1000.0)), lat.hemisphere)) 95 | wpt.write(u'%03d%02d.%03d%s,%fm,,,,,,,' % ( 96 | lon.degrees, lon.minutes, 97 | int(round(lon.seconds/60.0*1000.0)), lon.hemisphere, 98 | fix.gnss_alt)) 99 | wpt.write(u'\n') 100 | 101 | for i, thermal in enumerate(flight.thermals): 102 | write_fix(u'%02d' % i, thermal.enter_fix) 103 | write_fix(u'%02d_END' % i, thermal.exit_fix) 104 | 105 | 106 | def dump_flight_to_kml(flight, kml_filename_local): 107 | """Dumps the flight to KML format. 108 | 109 | Args: 110 | flight: an igc_lib.Flight, the flight to be saved 111 | kml_filename_local: a string, the name of the output file 112 | """ 113 | assert flight.valid 114 | kml = simplekml.Kml() 115 | 116 | def add_point(name, fix): 117 | kml.newpoint(name=name, coords=[(fix.lon, fix.lat)]) 118 | 119 | coords = [] 120 | for fix in flight.fixes: 121 | coords.append((fix.lon, fix.lat)) 122 | kml.newlinestring(coords=coords) 123 | 124 | add_point(name="Takeoff", fix=flight.takeoff_fix) 125 | add_point(name="Landing", fix=flight.landing_fix) 126 | 127 | for i, thermal in enumerate(flight.thermals): 128 | add_point(name="thermal_%02d" % i, fix=thermal.enter_fix) 129 | add_point(name="thermal_%02d_END" % i, fix=thermal.exit_fix) 130 | kml_filename = Path(kml_filename_local).expanduser().absolute() 131 | kml.save(kml_filename.as_posix()) 132 | 133 | 134 | def dump_flight_to_csv(flight, track_filename_local, thermals_filename_local): 135 | """Dumps flight data to CSV files. 136 | 137 | Args: 138 | flight: an igc_lib.Flight, the flight to be written 139 | track_filename_local: a string, the name of the output CSV with track data 140 | thermals_filename_local: a string, the name of the output CSV with thermal data 141 | """ 142 | track_filename = Path(track_filename_local).expanduser().absolute() 143 | with track_filename.open('wt') as csv: 144 | csv.write(u"timestamp,lat,lon,bearing,bearing_change_rate," 145 | u"gsp,flying,circling\n") 146 | for fix in flight.fixes: 147 | csv.write(u"%f,%f,%f,%f,%f,%f,%s,%s\n" % ( 148 | fix.timestamp, fix.lat, fix.lon, 149 | fix.bearing, fix.bearing_change_rate, 150 | fix.gsp, str(fix.flying), str(fix.circling))) 151 | 152 | thermals_filename = Path(thermals_filename_local).expanduser().absolute() 153 | with thermals_filename.open('wt') as csv: 154 | csv.write(u"timestamp_enter,timestamp_exit\n") 155 | for thermal in flight.thermals: 156 | csv.write(u"%f,%f\n" % ( 157 | thermal.enter_fix.timestamp, thermal.exit_fix.timestamp)) 158 | -------------------------------------------------------------------------------- /lib/geo.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | EARTH_RADIUS_KM = 6371.0 4 | 5 | 6 | def sphere_distance(lat1, lon1, lat2, lon2): 7 | """Computes the great circle distance on a unit sphere. 8 | 9 | All angles and the return value are in radians. 10 | 11 | Args: 12 | lat1: A float, latitude of the first point. 13 | lon1: A float, longitude of the first point. 14 | lat2: A float, latitude of the second point. 15 | lon2: A float, latitude of the second point. 16 | 17 | Returns: 18 | The computed great circle distance on a sphere. 19 | """ 20 | dlon = lon2 - lon1 21 | dlat = lat2 - lat1 22 | a = (math.sin(dlat/2)**2 + 23 | math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2) 24 | return 2.0 * math.asin(math.sqrt(a)) 25 | 26 | 27 | def earth_distance(lat1, lon1, lat2, lon2): 28 | """Computes Earth distance between two points, in kilometers. 29 | 30 | Input angles are in degrees, WGS-84. Output is in kilometers. 31 | 32 | Args: 33 | lat1: A float, latitude of the first point. 34 | lon1: A float, longitude of the first point. 35 | lat2: A float, latitude of the second point. 36 | lon2: A float, latitude of the second point. 37 | 38 | Returns: 39 | A float, the computed Earth distance. 40 | """ 41 | lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) 42 | return EARTH_RADIUS_KM * sphere_distance(lat1, lon1, lat2, lon2) 43 | 44 | 45 | def bearing_to(lat1, lon1, lat2, lon2): 46 | """Computes bearing between the current point and the heading point. 47 | 48 | Input angles and the output bearing are in degrees. Bearing 49 | is 0.0 when we are facing north, +90.0 when facing east, 50 | -90.0 when facing west, +/-180.0 when facing south. 51 | 52 | Args: 53 | lat1: A float, latitude of the current point. 54 | lon1: A float, longitude of the current point. 55 | lat2: A float, latitude of the heading to point. 56 | lon2: A float, latitude of the heading to point. 57 | 58 | Returns: 59 | A float, the heading (north = 0.0). 60 | """ 61 | lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) 62 | dLon = lon2 - lon1 63 | y = math.sin(dLon) * math.cos(lat2) 64 | x = (math.cos(lat1) * math.sin(lat2) - 65 | math.sin(lat1) * math.cos(lat2) * math.cos(dLon)) 66 | return math.degrees(math.atan2(y, x)) 67 | 68 | 69 | def sphere_angle(lat1, lon1, lat, lon, lat2, lon2): 70 | """Computes the angle on a sphere given three points. 71 | 72 | Input angles and the output angle are in degrees. The first 73 | input point denotes the first side of the angle, the second 74 | input point is the vertex of the angle, the third input point 75 | denotes the second side of the angle. 76 | 77 | Example (output=90.0): 78 | (lat=0.0, lon=0.0) -------- (lat1=0.0, lon1=10.0) 79 | | 80 | | 81 | (lat2=-20.0, lon2=0.0) 82 | 83 | Args: 84 | lat1: a float, latitude of the first point 85 | lon1: a float, longitude of the first point 86 | lat: a float, latitude of the vertex. 87 | lon: a float, longitude of the vertex. 88 | lat2: a float, latitude of the second point. 89 | lon2: a float, latitude of the second point. 90 | 91 | Returns: 92 | A float, the angle between the points. 93 | """ 94 | lat1, lon1, lat, lon, lat2, lon2 = map( 95 | math.radians, [lat1, lon1, lat, lon, lat2, lon2]) 96 | side1 = sphere_distance(lat, lon, lat1, lon1) 97 | side2 = sphere_distance(lat, lon, lat2, lon2) 98 | opposite = sphere_distance(lat1, lon1, lat2, lon2) 99 | cosine = (math.cos(opposite) - math.cos(side1) * math.cos(side2)) 100 | cosine /= (math.sin(side1) * math.sin(side2)) 101 | 102 | if cosine > 1.0: 103 | cosine = 1.0 104 | if cosine < -1.0: 105 | cosine = -1.0 106 | angle = math.acos(cosine) 107 | return math.degrees(angle) 108 | -------------------------------------------------------------------------------- /lib/test_dumpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import unittest 4 | import tempfile 5 | 6 | import igc_lib 7 | import lib.dumpers as dumpers 8 | 9 | 10 | class TestDumpers(unittest.TestCase): 11 | 12 | def setUp(self): 13 | igc_file = 'testfiles/napret.igc' 14 | self.flight = igc_lib.Flight.create_from_file(igc_file) 15 | self.tmp_output_dir = tempfile.mkdtemp() 16 | 17 | def tearDown(self): 18 | # Best-effort removal of temporary output files 19 | shutil.rmtree(self.tmp_output_dir, ignore_errors=True) 20 | 21 | def assertFileNotEmpty(self, filename): 22 | self.assertTrue(os.path.isfile(filename)) 23 | self.assertGreater(os.path.getsize(filename), 0) 24 | 25 | def testWptDumpNotEmpty(self): 26 | tmp_wpt_file = os.path.join(self.tmp_output_dir, 'thermals.wpt') 27 | dumpers.dump_thermals_to_wpt_file(self.flight, tmp_wpt_file) 28 | self.assertFileNotEmpty(tmp_wpt_file) 29 | 30 | def testCupDumpNotEmpty(self): 31 | tmp_cup_file = os.path.join(self.tmp_output_dir, 'thermals.cup') 32 | dumpers.dump_thermals_to_cup_file(self.flight, tmp_cup_file) 33 | self.assertFileNotEmpty(tmp_cup_file) 34 | 35 | def testKmlDumpNotEmpty(self): 36 | tmp_kml_file = os.path.join(self.tmp_output_dir, 'flight.kml') 37 | dumpers.dump_flight_to_kml(self.flight, tmp_kml_file) 38 | self.assertFileNotEmpty(tmp_kml_file) 39 | 40 | def testCsvDumpsNotEmpty(self): 41 | tmp_csv_track = os.path.join(self.tmp_output_dir, 'flight.csv') 42 | tmp_csv_thermals = os.path.join(self.tmp_output_dir, 'thermals.csv') 43 | dumpers.dump_flight_to_csv( 44 | self.flight, tmp_csv_track, tmp_csv_thermals) 45 | self.assertFileNotEmpty(tmp_csv_track) 46 | self.assertFileNotEmpty(tmp_csv_thermals) 47 | -------------------------------------------------------------------------------- /lib/test_geo.py: -------------------------------------------------------------------------------- 1 | import math 2 | import unittest 3 | 4 | import lib.geo as geo 5 | 6 | 7 | class TestSphereDistance(unittest.TestCase): 8 | 9 | def testAlongTheEquator(self): 10 | for lon_start in [0.0, 10.0, 20.0, 35.0]: 11 | for lon_end in [0.0, 5.0, 10.0, 45.0, 90.0, 180.0]: 12 | self.assertAlmostEqual( 13 | geo.sphere_distance( 14 | lat1=math.radians(0.0), lon1=math.radians(lon_start), 15 | lat2=math.radians(0.0), lon2=math.radians(lon_end)), 16 | math.radians(math.fabs(lon_start - lon_end))) 17 | 18 | def testEquatorToNorthPole(self): 19 | for lon in [0.0, 15.0, 45.0, 90.0, 180.0, 270.0]: 20 | self.assertAlmostEqual( 21 | geo.sphere_distance( 22 | lat1=math.radians(0.0), lon1=math.radians(lon), 23 | lat2=math.radians(90.0), lon2=math.radians(lon)), 24 | math.radians(90.0)) 25 | 26 | def testNorthPoleToSouthPole(self): 27 | self.assertAlmostEqual( 28 | geo.sphere_distance( 29 | lat1=math.radians(90.0), lon1=math.radians(0.0), 30 | lat2=math.radians(-90.0), lon2=math.radians(0.0)), 31 | math.radians(180.0)) 32 | 33 | def testFewExampleValues(self): 34 | self.assertAlmostEqual( 35 | geo.sphere_distance( 36 | lat1=math.radians(45.0), lon1=math.radians(10.0), 37 | lat2=math.radians(20.0), lon2=math.radians(15.0)), 38 | math.radians(25.34062553)) 39 | 40 | self.assertAlmostEqual( 41 | geo.sphere_distance( 42 | lat1=math.radians(-19.0), lon1=math.radians(-3.0), 43 | lat2=math.radians(-20.0), lon2=math.radians(20.0)), 44 | math.radians(21.68698928)) 45 | 46 | self.assertAlmostEqual( 47 | geo.sphere_distance( 48 | lat1=math.radians(-20.0), lon1=math.radians(10.0), 49 | lat2=math.radians(20.0), lon2=math.radians(-45.0)), 50 | math.radians(67.07642430)) 51 | 52 | 53 | class TestEarthDistance(unittest.TestCase): 54 | 55 | def testLondonToNewYork(self): 56 | self.assertAlmostEqual( 57 | geo.earth_distance( 58 | lat1=51.507222, lon1=-0.1275, 59 | lat2=40.7127, lon2=-74.0059), 60 | 5570.249, places=2) 61 | 62 | def testHonoluluToKualaLumpur(self): 63 | self.assertAlmostEqual( 64 | geo.earth_distance( 65 | lat1=21.3, lon1=-157.816667, 66 | lat2=3.133333, lon2=101.683333), 67 | 10964.740, places=2) 68 | 69 | def testSmallDistance(self): 70 | # Almost 10 meters 71 | self.assertAlmostEqual( 72 | geo.earth_distance( 73 | lat1=46.46338889, lon1=6.51755271, 74 | lat2=46.46339444, lon2=6.51768333), 75 | 0.010, places=4) 76 | 77 | def testMediumDistance(self): 78 | # Almost 1000 meters 79 | self.assertAlmostEqual( 80 | geo.earth_distance( 81 | lat1=46.44307778, lon1=6.44688056, 82 | lat2=46.44813889, lon2=6.45766932), 83 | 1.000, places=4) 84 | 85 | 86 | class TestBearingTo(unittest.TestCase): 87 | 88 | def testEquatorToNorthPole(self): 89 | for lon in [0.0, 15.0, 45.0, 90.0, 180.0, 270.0]: 90 | self.assertAlmostEqual( 91 | geo.bearing_to( 92 | lat1=0.0, lon1=lon, 93 | lat2=90.0, lon2=lon), 94 | 0.0) 95 | 96 | def testEquatorToSouthPole(self): 97 | for lon in [0.0, 15.0, 45.0, 90.0, 180.0, 270.0]: 98 | self.assertAlmostEqual( 99 | geo.bearing_to( 100 | lat1=0.0, lon1=lon, 101 | lat2=-90.0, lon2=lon), 102 | 180.0) 103 | 104 | def testEquatorFacingEast(self): 105 | self.assertAlmostEqual( 106 | geo.bearing_to( 107 | lat1=0.0, lon1=0.0, 108 | lat2=0.0, lon2=15.0), 109 | 90.0) 110 | 111 | def testEquatorFacingWest(self): 112 | self.assertAlmostEqual( 113 | geo.bearing_to( 114 | lat1=0.0, lon1=0.0, 115 | lat2=0.0, lon2=-15.0), 116 | -90.0) 117 | 118 | def testLondonToNewYork(self): 119 | self.assertAlmostEqual( 120 | geo.bearing_to( 121 | lat1=51.507222, lon1=-0.1275, 122 | lat2=40.7127, lon2=-74.0059), 123 | -71.67013, places=4) 124 | 125 | def testHonoluluToKualaLumpur(self): 126 | self.assertAlmostEqual( 127 | geo.bearing_to( 128 | lat1=21.3, lon1=-157.816667, 129 | lat2=3.133333, lon2=101.683333), 130 | -83.20267, places=4) 131 | 132 | 133 | class TestSphereAngle(unittest.TestCase): 134 | 135 | def testEquatorAndStraightNorthSouth(self): 136 | for latitude in [10.0, -20.0, 30.0, -50.0, 90.0]: 137 | self.assertAlmostEqual( 138 | geo.sphere_angle( 139 | lat1=0.0, lon1=-20.0, 140 | lat=0.0, lon=0.0, 141 | lat2=latitude, lon2=0.0), 142 | 90.0) 143 | 144 | def testFlatAngleOnTheEquator(self): 145 | self.assertAlmostEqual( 146 | geo.sphere_angle( 147 | lat1=0.0, lon1=-20.0, 148 | lat=0.0, lon=0.0, 149 | lat2=0.0, lon2=-40.0), 150 | 0.0, places=5) 151 | 152 | def testHalfAngleOnTheEquator(self): 153 | self.assertAlmostEqual( 154 | geo.sphere_angle( 155 | lat1=0.0, lon1=5.0, 156 | lat=0.0, lon=0.0, 157 | lat2=0.0, lon2=-5.0), 158 | 180.0) 159 | 160 | def testHalfAngleEquatorAndTwoPoles(self): 161 | self.assertAlmostEqual( 162 | geo.sphere_angle( 163 | lat1=-90.0, lon1=0.0, 164 | lat=0.0, lon=0.0, 165 | lat2=90.0, lon2=0.0), 166 | 180.0) 167 | 168 | def testBrusselsLondonParis(self): 169 | self.assertAlmostEqual( 170 | geo.sphere_angle( 171 | lat1=50.85, lon1=4.35, 172 | lat=51.507222, lon=-0.1275, 173 | lat2=48.856667, lon2=2.350833), 174 | 46.704, places=3) 175 | -------------------------------------------------------------------------------- /lib/test_viterbi.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import lib.viterbi as viterbi 4 | 5 | 6 | class TestSimpleViterbiDecoder(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.init_probs = [0.5, 0.5] 10 | self.transition_probs = [ 11 | [0.9, 0.1], 12 | [0.1, 0.9], 13 | ] 14 | self.emission_probs = [ 15 | [0.7, 0.3], 16 | [0.3, 0.7], 17 | ] 18 | self.decoder = viterbi.SimpleViterbiDecoder( 19 | init_probs=self.init_probs, 20 | transition_probs=self.transition_probs, 21 | emission_probs=self.emission_probs) 22 | 23 | def assertDecode(self, emissions, expected_result): 24 | result = self.decoder.decode(emissions) 25 | self.assertListEqual(result, expected_result) 26 | 27 | def testEmptyDecode(self): 28 | self.assertDecode([], []) 29 | 30 | def testSimpleDecodeZeros(self): 31 | for i in range(20): 32 | data = [0] * i 33 | self.assertDecode(data, data) 34 | 35 | def testSimpleDecodeOnes(self): 36 | for i in range(20): 37 | data = [1] * i 38 | self.assertDecode(data, data) 39 | 40 | def testMixedIdentityDecode(self): 41 | data = [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1] 42 | self.assertDecode(data, data) 43 | 44 | def testIgnoresSmallFluctuationsZeros(self): 45 | data = [1, 0, 0, 0, 1, 1, 0, 0, 0] 46 | expected_result = [0, 0, 0, 0, 0, 0, 0, 0, 0] 47 | self.assertDecode(data, expected_result) 48 | 49 | def testIgnoresSmallFluctuationsOnes(self): 50 | data = [1, 0, 1, 1, 0, 0, 1, 1, 1] 51 | expected_result = [1, 1, 1, 1, 1, 1, 1, 1, 1] 52 | self.assertDecode(data, expected_result) 53 | -------------------------------------------------------------------------------- /lib/viterbi.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | 4 | class SimpleViterbiDecoder(object): 5 | """A simple Viterbi algorightm implementation. 6 | 7 | For Markov models with two hidden states and two emission letters. The 8 | states and the emissions are represented by 0 and 1. 9 | """ 10 | 11 | def __init__(self, init_probs, transition_probs, emission_probs): 12 | """Initializer for the class. 13 | 14 | Args: 15 | init_probs: a vector of 2 floats, the initial probabilities 16 | for the hidden states 17 | transition_probs: a 2x2 matrix of floats, the transition 18 | probabilities, from current hidden state to next hidden states 19 | emission_probs: a 2x2 matrix of floats, the emission 20 | probabilities, from current hidden state to emissions 21 | """ 22 | assert len(init_probs) == 2 23 | assert len(transition_probs) == 2 24 | assert list(map(len, transition_probs)) == [2, 2] 25 | assert len(emission_probs) == 2 26 | assert list(map(len, emission_probs)) == [2, 2] 27 | 28 | self._init_log = list(map(math.log, init_probs)) 29 | self._transition_log = [list(map(math.log, xs)) for xs in transition_probs] 30 | self._emission_log = [list(map(math.log, xs)) for xs in emission_probs] 31 | 32 | def decode(self, emissions): 33 | """Run the Viterbi decoder. 34 | 35 | Args: 36 | emissions: a list of {0, 1} - the observed emissions 37 | 38 | Returns: 39 | a list of {0, 1} - the most likely sequence of hidden states 40 | """ 41 | if not emissions: 42 | # Edge case, handle empty list here, to simplify the algorithm 43 | return [] 44 | 45 | N = len(emissions) 46 | state_log = [[None, None] for i in range(N)] 47 | backtrack_info = [[None, None] for i in range(N)] 48 | 49 | # Forward pass, calculate the probabilities of states and the 50 | # back-tracking information. 51 | 52 | # The initial state probability estimates are treated separately 53 | # because these come from the initial distribution. 54 | state_log[0] = self._init_log 55 | state_log[0][0] += self._emission_log[0][emissions[0]] 56 | state_log[0][1] += self._emission_log[1][emissions[0]] 57 | 58 | # Successive state probability estimates are calculated using 59 | # the log-probabilities in the transition matrix. 60 | for i in range(1, N): 61 | for target in [0, 1]: 62 | from_0 = state_log[i - 1][0] + self._transition_log[0][target] 63 | from_1 = state_log[i - 1][1] + self._transition_log[1][target] 64 | emission_log = self._emission_log[target][emissions[i]] 65 | if from_0 > from_1: 66 | backtrack_info[i][target] = 0 67 | state_log[i][target] = from_0 + emission_log 68 | else: 69 | backtrack_info[i][target] = 1 70 | state_log[i][target] = from_1 + emission_log 71 | 72 | # Backward pass, find the most likely sequence of states. 73 | if state_log[N - 1][0] > state_log[N - 1][1]: 74 | state = 0 75 | else: 76 | state = 1 77 | 78 | states = [state] 79 | for i in range(N - 1, 0, -1): 80 | state = backtrack_info[i][state] 81 | states.append(state) 82 | states.reverse() 83 | 84 | return states 85 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | simplekml >= 1.3.1 2 | pathlib2 >= 2.1.0 3 | -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Running tests with python2" 5 | if [ -z "$1" ]; then 6 | # No argument provided - run all tests 7 | /usr/bin/env python2 -m unittest discover 8 | else 9 | /usr/bin/env python2 -m unittest $1 10 | fi 11 | 12 | echo "Running tests with python3" 13 | if [ -z "$1" ]; then 14 | # No argument provided - run all tests 15 | /usr/bin/env python3 -m unittest discover 16 | else 17 | /usr/bin/env python3 -m unittest $1 18 | fi 19 | -------------------------------------------------------------------------------- /test_igc_lib.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import igc_lib 4 | 5 | 6 | class TestBuildFromBRecord(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.test_record = 'B1227484612592N01249579EA0043700493extra-3s' 10 | self.test_index = 10 11 | 12 | def testBasicBRecordParse(self): 13 | b_record = igc_lib.GNSSFix.build_from_B_record( 14 | self.test_record, self.test_index) 15 | self.assertIsNotNone(b_record) 16 | 17 | def testRawimeParse(self): 18 | b_record = igc_lib.GNSSFix.build_from_B_record( 19 | self.test_record, self.test_index) 20 | 21 | # 12:27:48, from B "122748" 4612592N01249579EA0043700493extra-3s 22 | expected_time = 48.0 # seconds 23 | expected_time += 60.0 * 27.0 # minutes 24 | expected_time += 3600.0 * 12.0 # hours 25 | self.assertAlmostEqual(expected_time, b_record.rawtime) 26 | 27 | def testLatParse(self): 28 | b_record = igc_lib.GNSSFix.build_from_B_record( 29 | self.test_record, self.test_index) 30 | 31 | # 46* 12.592' N, from B122748 "4612592N" 01249579EA0043700493extra-3s 32 | expected_lat = 12.592 / 60.0 # minutes 33 | expected_lat += 46.0 # degrees 34 | self.assertAlmostEqual(expected_lat, b_record.lat) 35 | 36 | def testLonParse(self): 37 | b_record = igc_lib.GNSSFix.build_from_B_record( 38 | self.test_record, self.test_index) 39 | 40 | # 012* 49.579' E, from B1227484612592N "01249579E" A0043700493extra-3s 41 | expected_lon = 49.579 / 60.0 # minutes 42 | expected_lon += 12.0 # degrees 43 | self.assertAlmostEqual(expected_lon, b_record.lon) 44 | 45 | def testValidityParse(self): 46 | b_record = igc_lib.GNSSFix.build_from_B_record( 47 | self.test_record, self.test_index) 48 | 49 | # "A", from B1227484612592N01249579E "A" 0043700493extra-3s 50 | self.assertEqual("A", b_record.validity) 51 | 52 | def testPressureAltParse(self): 53 | b_record = igc_lib.GNSSFix.build_from_B_record( 54 | self.test_record, self.test_index) 55 | 56 | # 437 meters, from B1227484612592N01249579EA "00437" 00493extra-3s 57 | self.assertEqual(437.0, b_record.press_alt) 58 | 59 | def testGNSSAltParse(self): 60 | b_record = igc_lib.GNSSFix.build_from_B_record( 61 | self.test_record, self.test_index) 62 | 63 | # 493 meters, from B1227484612592N01249579EA00437 "00493" extra-3s 64 | self.assertEqual(493.0, b_record.gnss_alt) 65 | 66 | def testExtrasParse(self): 67 | b_record = igc_lib.GNSSFix.build_from_B_record( 68 | self.test_record, self.test_index) 69 | 70 | # "extra-3s", from B1227484612592N01249579EA0043700493 "extra-3s" 71 | self.assertEqual("extra-3s", b_record.extras) 72 | 73 | 74 | class TestNapretTaskParsing(unittest.TestCase): 75 | 76 | def setUp(self): 77 | test_file = 'testfiles/napret.lkt' 78 | self.task = igc_lib.Task.create_from_lkt_file(test_file) 79 | 80 | def testTaskHasStartTime(self): 81 | self.assertAlmostEqual(self.task.start_time, 12*3600) 82 | 83 | def testTaskHasEndTime(self): 84 | self.assertAlmostEqual(self.task.end_time, 23*3600 + 59*60 + 59) 85 | 86 | def testTaskHasTurnpoints(self): 87 | self.assertEqual(len(self.task.turnpoints), 11) 88 | self.assertEqual(self.task.turnpoints[0].kind, "start_enter") 89 | 90 | def testTaskHasTurnpointsWithRadius(self): 91 | self.assertGreaterEqual(min(map(lambda turnpoint: turnpoint.radius, 92 | self.task.turnpoints)), 0.2) 93 | self.assertLessEqual(max(map(lambda turnpoint: turnpoint.radius, 94 | self.task.turnpoints)), 4) 95 | 96 | def testTaskHasTurnpointsWithLatitude(self): 97 | self.assertEqual(max(map(lambda turnpoint: int(turnpoint.lat / 46), 98 | self.task.turnpoints)), 1) 99 | 100 | def testTaskHasTurnpointsWithLongitude(self): 101 | self.assertEqual(max(map(lambda turnpoint: int(turnpoint.lon / 12), 102 | self.task.turnpoints)), 1) 103 | 104 | 105 | class TestNapretFlightParsing(unittest.TestCase): 106 | 107 | def setUp(self): 108 | test_file = 'testfiles/napret.igc' 109 | self.flight = igc_lib.Flight.create_from_file(test_file) 110 | 111 | def testFileParsesOK(self): 112 | self.assertListEqual(self.flight.notes, []) 113 | self.assertTrue(self.flight.valid) 114 | 115 | def testBothPressureSensorsAreOK(self): 116 | self.assertTrue(self.flight.press_alt_valid) 117 | self.assertTrue(self.flight.gnss_alt_valid) 118 | 119 | def testChosesPressureSensor(self): 120 | self.assertEqual(self.flight.alt_source, 'PRESS') 121 | 122 | def testMetadataIsCorrectlyRead(self): 123 | self.assertEqual(self.flight.fr_manuf_code, 'XGD') 124 | self.assertEqual(self.flight.fr_uniq_id, 'jos') 125 | self.assertFalse(hasattr(self.flight, 'i_record')) 126 | # 2016-04-03 0:00 UTC 127 | self.assertAlmostEqual(self.flight.date_timestamp, 1459641600.0) 128 | self.assertEqual(self.flight.glider_type, 'test_glider') 129 | self.assertEqual(self.flight.competition_class, 130 | 'test_competition_class') 131 | self.assertFalse(hasattr(self.flight, 'fr_firmware_version')) 132 | self.assertFalse(hasattr(self.flight, 'fr_hardware_version')) 133 | self.assertFalse(hasattr(self.flight, 'fr_recorder_type')) 134 | self.assertFalse(hasattr(self.flight, 'fr_gps_receiver')) 135 | self.assertFalse(hasattr(self.flight, 'fr_pressure_sensor')) 136 | 137 | def testBRecordsParsing(self): 138 | self.assertEqual(len(self.flight.fixes), 5380) 139 | 140 | def testFixesHaveCorrectIndices(self): 141 | for i, fix in enumerate(self.flight.fixes): 142 | self.assertEqual(i, fix.index) 143 | 144 | def testFlightsDetection(self): 145 | # Basic test, there should be at least one thermal 146 | self.assertGreater(len(self.flight.thermals), 0) 147 | 148 | def testGlidesDetection(self): 149 | # Basic test, there should be at least one glide 150 | self.assertGreater(len(self.flight.glides), 0) 151 | 152 | def testSomeFixesAreInFlight(self): 153 | self.assertTrue( 154 | any(map(lambda fix: fix.flying, self.flight.fixes))) 155 | 156 | def testSomeFixesAreNotInFlight(self): 157 | self.assertTrue( 158 | any(map(lambda fix: not fix.flying, self.flight.fixes))) 159 | 160 | def testHasTakeoff(self): 161 | self.assertTrue(hasattr(self.flight, 'takeoff_fix')) 162 | 163 | def testHasLanding(self): 164 | self.assertTrue(hasattr(self.flight, 'landing_fix')) 165 | 166 | def testSomeFixesAreInCircling(self): 167 | self.assertTrue( 168 | any(map(lambda fix: fix.circling, self.flight.fixes))) 169 | 170 | def testSomeFixesAreNotInCircling(self): 171 | self.assertTrue( 172 | any(map(lambda fix: not fix.circling, self.flight.fixes))) 173 | 174 | def testThermalsAreAfterTakeoff(self): 175 | takeoff_index = self.flight.takeoff_fix.index 176 | for thermal in self.flight.thermals: 177 | self.assertGreaterEqual(thermal.enter_fix.index, takeoff_index) 178 | self.assertGreaterEqual(thermal.exit_fix.index, takeoff_index) 179 | 180 | def testThermalsAreBeforeLanding(self): 181 | landing_index = self.flight.landing_fix.index 182 | for thermal in self.flight.thermals: 183 | self.assertLessEqual(thermal.enter_fix.index, landing_index) 184 | self.assertLessEqual(thermal.exit_fix.index, landing_index) 185 | 186 | def testGlidesAreAfterTakeoff(self): 187 | takeoff_index = self.flight.takeoff_fix.index 188 | for glide in self.flight.glides: 189 | self.assertGreaterEqual(glide.enter_fix.index, takeoff_index) 190 | self.assertGreaterEqual(glide.exit_fix.index, takeoff_index) 191 | 192 | def testGlidesAreBeforeLanding(self): 193 | landing_index = self.flight.landing_fix.index 194 | for glide in self.flight.glides: 195 | self.assertLessEqual(glide.enter_fix.index, landing_index) 196 | self.assertLessEqual(glide.exit_fix.index, landing_index) 197 | 198 | 199 | class TestNewIGCDateIncrement(unittest.TestCase): 200 | 201 | def setUp(self): 202 | test_file = "testfiles/new_date_format.igc" 203 | self.flight = igc_lib.Flight.create_from_file(test_file) 204 | 205 | def testFileParsesOK(self): 206 | self.assertListEqual(self.flight.notes, []) 207 | self.assertTrue(self.flight.valid) 208 | 209 | def testDateIsReadCorrectly(self): 210 | # 2018-04-03 0:00 UTC 211 | self.assertAlmostEqual(self.flight.date_timestamp, 1522713600.0) 212 | 213 | 214 | class TestNoTimeIncrementFlightParsing(unittest.TestCase): 215 | 216 | def setUp(self): 217 | test_file = 'testfiles/no_time_increment.igc' 218 | self.flight = igc_lib.Flight.create_from_file(test_file) 219 | 220 | def testFileParsesOK(self): 221 | self.assertListEqual(self.flight.notes, []) 222 | self.assertTrue(self.flight.valid) 223 | 224 | def testBRecordsParsing(self): 225 | # There are 200 B records in the file, but the last 226 | # 50 do not increment the time, and therefore should be dropped. 227 | self.assertEqual(len(self.flight.fixes), 150) 228 | 229 | 230 | class TestOlsztynFlightParsing(unittest.TestCase): 231 | 232 | def setUp(self): 233 | test_file = 'testfiles/olsztyn.igc' 234 | self.flight = igc_lib.Flight.create_from_file(test_file) 235 | 236 | def testFileParsesOK(self): 237 | self.assertListEqual(self.flight.notes, []) 238 | self.assertTrue(self.flight.valid) 239 | 240 | def testMetadataIsCorrectlyRead(self): 241 | self.assertEqual(self.flight.fr_manuf_code, 'LXN') 242 | self.assertEqual(self.flight.fr_uniq_id, 'ABC') 243 | self.assertEqual( 244 | self.flight.i_record, 245 | 'I073638FXA3941ENL4246TAS4751GSP5254TRT5559VAT6063OAT') 246 | # 2011-09-02 0:00 UTC 247 | self.assertAlmostEqual(self.flight.date_timestamp, 1314921600.0) 248 | self.assertEqual(self.flight.glider_type, 'test_glider_xx') 249 | self.assertEqual(self.flight.competition_class, 250 | 'some_competition_class') 251 | self.assertEqual(self.flight.fr_firmware_version, '2.2') 252 | self.assertEqual(self.flight.fr_hardware_version, '2') 253 | self.assertEqual(self.flight.fr_recorder_type, 254 | 'LXNAVIGATION,LX8000F') 255 | self.assertEqual(self.flight.fr_gps_receiver, 256 | 'uBLOX LEA-4S-2,16,max9000m') 257 | self.assertEqual(self.flight.fr_pressure_sensor, 258 | 'INTERSEMA,MS5534A,max10000m') 259 | 260 | def testBRecordsParsing(self): 261 | self.assertEqual(len(self.flight.fixes), 2469) 262 | 263 | 264 | class TestNewZealandFlightParsing(unittest.TestCase): 265 | 266 | def setUp(self): 267 | test_file = 'testfiles/new_zealand.igc' 268 | self.flight = igc_lib.Flight.create_from_file(test_file) 269 | 270 | def testFileParsesOK(self): 271 | self.assertListEqual(self.flight.notes, []) 272 | self.assertTrue(self.flight.valid) 273 | 274 | 275 | class ParsePickFirst(igc_lib.FlightParsingConfig): 276 | which_flight_to_pick = 'first' 277 | 278 | 279 | class ParsePickConcat(igc_lib.FlightParsingConfig): 280 | which_flight_to_pick = 'concat' 281 | 282 | 283 | class TestWhichFlightToPick(unittest.TestCase): 284 | 285 | def setUp(self): 286 | self.test_file = 'testfiles/flight_with_middle_landing.igc' 287 | 288 | def testFileParsesOKPickFirst(self): 289 | flight = igc_lib.Flight.create_from_file( 290 | self.test_file, config_class=ParsePickFirst) 291 | self.assertListEqual(flight.notes, []) 292 | self.assertTrue(flight.valid) 293 | 294 | def testFileParsesOKPickConcat(self): 295 | flight = igc_lib.Flight.create_from_file( 296 | self.test_file, config_class=ParsePickConcat) 297 | self.assertListEqual(flight.notes, []) 298 | self.assertTrue(flight.valid) 299 | 300 | def testConcatIsLongerThanFirst(self): 301 | flight_first = igc_lib.Flight.create_from_file( 302 | self.test_file, config_class=ParsePickFirst) 303 | flight_concat = igc_lib.Flight.create_from_file( 304 | self.test_file, config_class=ParsePickConcat) 305 | # Takeoff is the same 306 | self.assertEqual( 307 | flight_first.takeoff_fix.timestamp, 308 | flight_concat.takeoff_fix.timestamp) 309 | # But landing is earlier 310 | self.assertLess( 311 | flight_first.landing_fix.timestamp, 312 | flight_concat.landing_fix.timestamp) 313 | -------------------------------------------------------------------------------- /testfiles/napret.lkt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /testfiles/new_date_format.igc: -------------------------------------------------------------------------------- 1 | AXGDjos 2 | HFDTEDATE: 030418 3 | HFPLTPILOT: test_pilot 4 | HFGTYGLIDERTYPE: test_glider 5 | HFGIDGLIDERID: test_glider_id 6 | HFDTM100GPSDATUM: WGS-84 7 | HFCIDCOMPETITIONID: test_competition_id 8 | HFCCLCOMPETITION CLASS: test_competition_class 9 | HFSITSite: None 10 | B1200004612584N01249706EA0098801046 11 | B1200014612581N01249699EA0098701045 12 | B1200024612579N01249692EA0098601044 13 | B1200034612576N01249685EA0098501043 14 | B1200044612573N01249678EA0098401042 15 | B1200054612570N01249671EA0098201041 16 | B1200064612567N01249664EA0098101039 17 | B1200074612564N01249657EA0098001038 18 | B1200084612561N01249650EA0097901037 19 | B1200094612558N01249643EA0097801036 20 | B1200104612555N01249636EA0097701035 21 | B1200114612552N01249630EA0097601034 22 | B1200124612548N01249623EA0097601033 23 | B1200134612545N01249617EA0097501032 24 | B1200144612542N01249610EA0097401031 25 | B1200154612538N01249603EA0097301030 26 | B1200164612535N01249596EA0097201029 27 | B1200174612532N01249589EA0097101028 28 | B1200184612528N01249582EA0097001027 29 | B1200194612525N01249575EA0096901026 30 | B1200204612521N01249568EA0096801025 31 | B1200214612518N01249561EA0096701024 32 | B1200224612514N01249553EA0096601023 33 | B1200234612511N01249546EA0096501022 34 | B1200244612507N01249539EA0096301021 35 | B1200254612504N01249532EA0096201019 36 | B1200264612500N01249524EA0096101019 37 | B1200274612497N01249517EA0096101018 38 | B1200284612494N01249509EA0095901017 39 | B1200294612490N01249502EA0095801016 40 | B1200304612487N01249494EA0095701014 41 | B1200314612484N01249487EA0095601013 42 | B1200324612481N01249479EA0095501011 43 | B1200334612477N01249471EA0095401010 44 | B1200344612474N01249464EA0095201009 45 | B1200354612471N01249456EA0095101008 46 | B1200364612468N01249449EA0095001007 47 | B1200374612464N01249442EA0094901006 48 | B1200384612461N01249434EA0094801004 49 | B1200394612457N01249426EA0094601003 50 | B1200404612453N01249418EA0094501002 51 | B1200414612450N01249411EA0094401001 52 | B1200424612446N01249403EA0094301000 53 | B1200434612443N01249396EA0094100999 54 | B1200444612439N01249389EA0094000997 55 | B1200454612436N01249381EA0093900996 56 | B1200464612433N01249373EA0093700994 57 | B1200474612430N01249365EA0093600992 58 | B1200484612426N01249357EA0093500991 59 | B1200494612423N01249349EA0093400991 60 | B1200504612420N01249341EA0093200990 61 | B1200514612416N01249334EA0093100988 62 | B1200524612413N01249327EA0092900986 63 | B1200534612409N01249319EA0092700984 64 | B1200544612406N01249311EA0092600983 65 | B1200554612402N01249303EA0092500982 66 | B1200564612399N01249295EA0092400981 67 | B1200574612396N01249287EA0092300980 68 | B1200584612393N01249280EA0092100979 69 | B1200594612390N01249272EA0092000977 70 | B1201004612387N01249264EA0091900976 71 | B1201014612384N01249256EA0091800975 72 | B1201024612382N01249247EA0091700974 73 | B1201034612380N01249239EA0091600974 74 | B1201044612378N01249231EA0091500973 75 | B1201054612375N01249224EA0091400972 76 | B1201064612373N01249216EA0091300971 77 | B1201074612370N01249208EA0091200969 78 | B1201084612368N01249200EA0091100968 79 | B1201094612365N01249193EA0091000968 80 | B1201104612362N01249185EA0091000967 81 | B1201114612359N01249178EA0090900967 82 | B1201124612356N01249171EA0090900966 83 | B1201134612353N01249164EA0090800965 84 | B1201144612350N01249157EA0090700964 85 | B1201154612346N01249150EA0090600962 86 | B1201164612342N01249143EA0090500961 87 | B1201174612337N01249136EA0090400960 88 | B1201184612333N01249129EA0090300960 89 | B1201194612330N01249122EA0090200959 90 | B1201204612327N01249115EA0090100959 91 | B1201214612324N01249108EA0090000958 92 | B1201224612321N01249100EA0089900956 93 | B1201234612318N01249093EA0089800955 94 | B1201244612314N01249085EA0089700954 95 | B1201254612311N01249077EA0089600953 96 | B1201264612309N01249069EA0089500952 97 | B1201274612306N01249060EA0089400951 98 | B1201284612303N01249052EA0089300951 99 | B1201294612301N01249044EA0089200950 100 | B1201304612298N01249036EA0089200949 101 | B1201314612296N01249027EA0089100948 102 | B1201324612295N01249017EA0089000947 103 | B1201334612293N01249008EA0089000947 104 | B1201344612291N01248999EA0089000946 105 | B1201354612288N01248990EA0088900946 106 | B1201364612286N01248982EA0088800946 107 | B1201374612285N01248973EA0088800945 108 | B1201384612283N01248964EA0088700945 109 | B1201394612282N01248956EA0088600944 110 | B1201404612280N01248947EA0088500944 111 | B1201414612278N01248939EA0088400942 112 | B1201424612277N01248931EA0088300941 113 | B1201434612275N01248922EA0088200939 114 | B1201444612272N01248912EA0088000937 115 | B1201454612270N01248903EA0088000937 116 | B1201464612267N01248895EA0087900936 117 | -------------------------------------------------------------------------------- /testfiles/no_time_increment.igc: -------------------------------------------------------------------------------- 1 | ALXNABCFLIGHT:1 2 | HFDTE010203 3 | B1016485346296N02025184EA0012300122 4 | B1016495346296N02025184EA0012300121 5 | B1016505346296N02025184EA0012300121 6 | B1016515346296N02025184EA0012300121 7 | B1016525346296N02025184EA0012300121 8 | B1016535346296N02025184EA0012300121 9 | B1016545346296N02025184EA0012300121 10 | B1016555346296N02025184EA0012300121 11 | B1016565346296N02025184EA0012300121 12 | B1016575346296N02025182EA0012300121 13 | B1016585346296N02025179EA0012300121 14 | B1016595346297N02025174EA0012300121 15 | B1017005346297N02025167EA0012300121 16 | B1017015346297N02025158EA0012300121 17 | B1017025346297N02025148EA0012400121 18 | B1017035346297N02025136EA0012300122 19 | B1017045346298N02025122EA0012400122 20 | B1017055346298N02025107EA0012400122 21 | B1017065346299N02025091EA0012400122 22 | B1017075346300N02025074EA0012400122 23 | B1017085346301N02025055EA0012400123 24 | B1017095346303N02025036EA0012400123 25 | B1017105346304N02025015EA0012400123 26 | B1017115346305N02024993EA0012600123 27 | B1017125346307N02024970EA0012700124 28 | B1017205346321N02024753EA0012900124 29 | B1017285346339N02024509EA0015600150 30 | B1017365346358N02024267EA0017900175 31 | B1017445346381N02024038EA0020300199 32 | B1017525346421N02023817EA0022000214 33 | B1018005346501N02023625EA0023900234 34 | B1018085346614N02023509EA0026400263 35 | B1018165346749N02023480EA0028000279 36 | B1018245346886N02023562EA0029500297 37 | B1018325346999N02023742EA0031300312 38 | B1018405347068N02023999EA0032700326 39 | B1018485347096N02024282EA0034700341 40 | B1018565347042N02024562EA0035500355 41 | B1019025346948N02024714EA0037400372 42 | B1019035346929N02024732EA0037500373 43 | B1019045346909N02024747EA0037600375 44 | B1019055346889N02024761EA0037800377 45 | B1019065346869N02024773EA0038000378 46 | B1019075346848N02024782EA0038300380 47 | B1019085346827N02024789EA0038400381 48 | B1019095346806N02024791EA0038400383 49 | B1019105346786N02024791EA0038600385 50 | B1019115346765N02024789EA0038800387 51 | B1019125346745N02024784EA0039000389 52 | B1019135346726N02024776EA0039300391 53 | B1019145346708N02024765EA0039500393 54 | B1019155346691N02024750EA0039800395 55 | B1019165346675N02024732EA0040000397 56 | B1019175346660N02024712EA0040000402 57 | B1019185346646N02024693EA0040400407 58 | B1019195346633N02024672EA0041000411 59 | B1019205346620N02024651EA0041600414 60 | B1019215346608N02024628EA0041700416 61 | B1019225346596N02024604EA0041800418 62 | B1019235346586N02024579EA0042200419 63 | B1019245346576N02024554EA0042200422 64 | B1019255346566N02024528EA0042200425 65 | B1019265346557N02024502EA0042700429 66 | B1019275346547N02024476EA0043200432 67 | B1019285346538N02024450EA0043400434 68 | B1019295346528N02024424EA0043600436 69 | B1019305346519N02024398EA0043900439 70 | B1019315346511N02024372EA0044200442 71 | B1019395346453N02024163EA0047600472 72 | B1019475346407N02023956EA0050600504 73 | B1019555346359N02023752EA0052700528 74 | B1020035346246N02023648EA0052400521 75 | B1020115346096N02023660EA0053700537 76 | B1020195346003N02023869EA0053400525 77 | B1020275346040N02024127EA0052700518 78 | B1020355346178N02024122EA0051300505 79 | B1020435346222N02023937EA0054300537 80 | B1020515346099N02024009EA0054800543 81 | B1020595346174N02024223EA0055200548 82 | B1021075346263N02024149EA0056900562 83 | B1021155346133N02024139EA0056900563 84 | B1021235346167N02024389EA0059200581 85 | B1021315346280N02024350EA0061300607 86 | B1021395346167N02024261EA0061900612 87 | B1021475346126N02024525EA0062800619 88 | B1021555346234N02024711EA0065400645 89 | B1022035346255N02024536EA0066600657 90 | B1022115346131N02024643EA0068500677 91 | B1022195346171N02024893EA0070500697 92 | B1022275346260N02024754EA0070200695 93 | B1022355346130N02024698EA0072600718 94 | B1022435346161N02024940EA0073400724 95 | B1022515346281N02024837EA0075500748 96 | B1022595346174N02024734EA0076300754 97 | B1023075346112N02024976EA0079200783 98 | B1023095346134N02025031EA0079600786 99 | B1023105346149N02025051EA0080000788 100 | B1023115346166N02025065EA0080200789 101 | B1023125346184N02025073EA0080200790 102 | B1023135346202N02025074EA0080200791 103 | B1023145346220N02025070EA0080500793 104 | B1023155346237N02025060EA0080700795 105 | B1023165346252N02025045EA0081100797 106 | B1023175346265N02025026EA0081100800 107 | B1023185346275N02025002EA0081300802 108 | B1023195346282N02024976EA0081300803 109 | B1023205346285N02024948EA0081400805 110 | B1023215346285N02024919EA0081700807 111 | B1023225346281N02024891EA0082000809 112 | B1023235346273N02024865EA0082200811 113 | B1023245346262N02024843EA0082200813 114 | B1023255346247N02024826EA0082300815 115 | B1023265346230N02024815EA0082400816 116 | B1023275346211N02024811EA0082500817 117 | B1023285346192N02024814EA0082600818 118 | B1023295346172N02024824EA0082600818 119 | B1023305346154N02024840EA0082700818 120 | B1023315346138N02024863EA0082800819 121 | B1023325346124N02024891EA0082800821 122 | B1023335346113N02024922EA0083200823 123 | B1023345346107N02024957EA0083500826 124 | B1023355346104N02024992EA0083800828 125 | B1023365346107N02025028EA0083900830 126 | B1023375346113N02025061EA0084100832 127 | B1023385346123N02025091EA0084500834 128 | B1023395346136N02025115EA0084800837 129 | B1023405346152N02025133EA0084800840 130 | B1023415346169N02025144EA0085300843 131 | B1023425346187N02025148EA0085300844 132 | B1023435346205N02025146EA0085500845 133 | B1023445346222N02025137EA0085500845 134 | B1023455346238N02025121EA0085500845 135 | B1023465346250N02025100EA0085500845 136 | B1023475346260N02025074EA0085500846 137 | B1023485346267N02025045EA0085500848 138 | B1023495346269N02025015EA0085600850 139 | B1023505346269N02024984EA0086000852 140 | B1023515346264N02024955EA0086300855 141 | B1023525346256N02024928EA0086700859 142 | B1023535346245N02024905EA0087200862 143 | B1023545346231N02024886EA0087400865 144 | B1023555346214N02024872EA0087400866 145 | B1023565346196N02024863EA0087400866 146 | B1023575346177N02024860EA0087700867 147 | B1023585346157N02024862EA0087700867 148 | B1023595346137N02024872EA0087700868 149 | B1024005346119N02024888EA0087800869 150 | B1024015346102N02024910EA0088100872 151 | B1024025346088N02024937EA0088400875 152 | B1024035346078N02024969EA0088400878 153 | B1024035346072N02025003EA0088600881 154 | B1024035346071N02025038EA0089000884 155 | B1024035346073N02025072EA0089400886 156 | B1024035346078N02025105EA0089600886 157 | B1024035346087N02025136EA0089400885 158 | B1024035346100N02025163EA0089300884 159 | B1024035346115N02025186EA0089300884 160 | B1024035346133N02025203EA0089300885 161 | B1024035346152N02025211EA0089300887 162 | B1024035346171N02025211EA0089800890 163 | B1024035346190N02025204EA0090200893 164 | B1024035346206N02025191EA0090400896 165 | B1024035346221N02025172EA0090600898 166 | B1024035346234N02025149EA0090800899 167 | B1024035346243N02025123EA0090800900 168 | B1024035346250N02025094EA0090900901 169 | B1024035346252N02025063EA0091100902 170 | B1024035346249N02025033EA0091400905 171 | B1024035346242N02025005EA0091600908 172 | B1024035346230N02024981EA0091900910 173 | B1024035346216N02024962EA0092000911 174 | B1024035346199N02024948EA0092000911 175 | B1024035346179N02024940EA0092000910 176 | B1024035346159N02024939EA0091800908 177 | B1024035346138N02024945EA0091600907 178 | B1024035346117N02024958EA0091500907 179 | B1024035346097N02024977EA0091600907 180 | B1024035346080N02025001EA0091900910 181 | B1024035346064N02025028EA0092400915 182 | B1024035346051N02025059EA0093000920 183 | B1024035346042N02025092EA0093000927 184 | B1024035346036N02025125EA0093700932 185 | B1024035346034N02025160EA0094200937 186 | B1024035346036N02025194EA0094600939 187 | B1024035346042N02025226EA0094800939 188 | B1024035346051N02025255EA0094900939 189 | B1024035346064N02025281EA0094800938 190 | B1024035346080N02025302EA0094800937 191 | B1024035346098N02025317EA0094700936 192 | B1024035346117N02025324EA0094700937 193 | B1024035346136N02025322EA0094900940 194 | B1024035346153N02025311EA0095200944 195 | B1024035346168N02025293EA0095600948 196 | B1024035346179N02025269EA0095800949 197 | B1024035346187N02025241EA0095800950 198 | B1024035346191N02025211EA0095800950 199 | B1024035346189N02025180EA0095800950 200 | B1024035346182N02025150EA0095800950 201 | B1024035346171N02025124EA0095900952 202 | B1024035346157N02025103EA0096200955 203 | -------------------------------------------------------------------------------- /tools/baum_welch_trainer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """A tool to learn Viterbi filter (Markov model) parameters from IGC files. 3 | 4 | Learns parameters for the circling/not circling filter and for 5 | the flying/not flying filter. Training data is loaded from 6 | a directory. 7 | """ 8 | 9 | import os 10 | import sys 11 | from Bio.Alphabet import Alphabet 12 | from Bio.HMM.MarkovModel import MarkovModelBuilder 13 | from Bio.HMM.Trainer import BaumWelchTrainer 14 | from Bio.HMM.Trainer import TrainingSequence 15 | from Bio.Seq import Seq 16 | 17 | # A hack to import from the parent directory 18 | sys.path.insert(0, '..') 19 | import igc_lib 20 | 21 | 22 | def list_igc_files(directory): 23 | files = [] 24 | for entry in os.listdir(directory): 25 | full_entry = os.path.join(directory, entry) 26 | if os.path.isfile(full_entry) and entry.endswith('.igc'): 27 | files.append(full_entry) 28 | return files 29 | 30 | 31 | def initial_markov_model_circling(): 32 | state_alphabet = Alphabet() 33 | state_alphabet.letters = list("cs") 34 | emissions_alphabet = Alphabet() 35 | emissions_alphabet.letters = list("CS") 36 | 37 | mmb = MarkovModelBuilder(state_alphabet, emissions_alphabet) 38 | mmb.set_initial_probabilities({'c': 0.20, 's': 0.80}) 39 | mmb.allow_all_transitions() 40 | mmb.set_transition_score('c', 'c', 0.90) 41 | mmb.set_transition_score('c', 's', 0.10) 42 | mmb.set_transition_score('s', 'c', 0.10) 43 | mmb.set_transition_score('s', 's', 0.90) 44 | mmb.set_emission_score('c', 'C', 0.90) 45 | mmb.set_emission_score('c', 'S', 0.10) 46 | mmb.set_emission_score('s', 'C', 0.10) 47 | mmb.set_emission_score('s', 'S', 0.90) 48 | mm = mmb.get_markov_model() 49 | return mm 50 | 51 | 52 | def initial_markov_model_flying(): 53 | state_alphabet = Alphabet() 54 | state_alphabet.letters = list("fs") 55 | emissions_alphabet = Alphabet() 56 | emissions_alphabet.letters = list("FS") 57 | 58 | mmb = MarkovModelBuilder(state_alphabet, emissions_alphabet) 59 | mmb.set_initial_probabilities({'f': 0.20, 's': 0.80}) 60 | mmb.allow_all_transitions() 61 | mmb.set_transition_score('f', 'f', 0.99) 62 | mmb.set_transition_score('f', 's', 0.01) 63 | mmb.set_transition_score('s', 'f', 0.01) 64 | mmb.set_transition_score('s', 's', 0.99) 65 | mmb.set_emission_score('f', 'F', 0.90) 66 | mmb.set_emission_score('f', 'S', 0.10) 67 | mmb.set_emission_score('s', 'F', 0.10) 68 | mmb.set_emission_score('s', 'S', 0.90) 69 | mm = mmb.get_markov_model() 70 | return mm 71 | 72 | 73 | def get_circling_sequence(flight): 74 | state_alphabet = Alphabet() 75 | state_alphabet.letters = list("cs") 76 | emissions_alphabet = Alphabet() 77 | emissions_alphabet.letters = list("CS") 78 | 79 | emissions = [] 80 | for x in flight._circling_emissions(): 81 | if x == 1: 82 | emissions.append("C") 83 | else: 84 | emissions.append("S") 85 | emissions = Seq("".join(emissions), emissions_alphabet) 86 | empty_states = Seq("", state_alphabet) 87 | return TrainingSequence(emissions, empty_states) 88 | 89 | 90 | def get_flying_sequence(flight): 91 | state_alphabet = Alphabet() 92 | state_alphabet.letters = list("fs") 93 | emissions_alphabet = Alphabet() 94 | emissions_alphabet.letters = list("FS") 95 | 96 | emissions = [] 97 | for x in flight._flying_emissions(): 98 | if x == 1: 99 | emissions.append("F") 100 | else: 101 | emissions.append("S") 102 | emissions = Seq("".join(emissions), emissions_alphabet) 103 | empty_states = Seq("", state_alphabet) 104 | return TrainingSequence(emissions, empty_states) 105 | 106 | 107 | def get_training_sequences(files): 108 | circling_sequences = [] 109 | flying_sequences = [] 110 | for fname in files: 111 | flight = igc_lib.Flight.create_from_file(fname) 112 | if flight.valid: 113 | circling_sequences.append(get_circling_sequence(flight)) 114 | flying_sequences.append(get_flying_sequence(flight)) 115 | return circling_sequences, flying_sequences 116 | 117 | 118 | def stop_function(log_likelihood_change, num_iterations): 119 | print "num_iterations: %d" % num_iterations, 120 | print "log_likelihood_change: %f" % log_likelihood_change 121 | return log_likelihood_change < 0.05 and num_iterations > 5 122 | 123 | 124 | def main(): 125 | if len(sys.argv) != 2: 126 | print "Usage: %s directory_with_igc_files" 127 | sys.exit(1) 128 | 129 | learning_dir = sys.argv[1] 130 | files = list_igc_files(learning_dir) 131 | print "Found %d IGC files in '%s'." % (len(files), learning_dir) 132 | 133 | print "Reading and processing files" 134 | circling_sequences, flying_sequences = get_training_sequences(files) 135 | print "Found %d valid tracks." % len(circling_sequences) 136 | 137 | if len(circling_sequences) == 0: 138 | print "Found no valid tracks. Aborting." 139 | sys.exit(1) 140 | 141 | flying_mm = initial_markov_model_flying() 142 | trainer = BaumWelchTrainer(flying_mm) 143 | trainer.train(flying_sequences, stop_function) 144 | print "Flying model training complete!" 145 | 146 | circling_mm = initial_markov_model_circling() 147 | trainer = BaumWelchTrainer(circling_mm) 148 | trainer.train(circling_sequences, stop_function) 149 | print "Circling model training complete!" 150 | 151 | print "flying_mm.transition_prob:", flying_mm.transition_prob 152 | print "flying_mm.emission_prob:", flying_mm.emission_prob 153 | print "circling_mm.transition_prob:", circling_mm.transition_prob 154 | print "circling_mm.emission_prob:", circling_mm.emission_prob 155 | 156 | 157 | if __name__ == "__main__": 158 | main() 159 | -------------------------------------------------------------------------------- /tools/requirements.txt: -------------------------------------------------------------------------------- 1 | BioPython >= 1.63b 2 | --------------------------------------------------------------------------------