messageboard-2020-04-23-1556.py
01234567890123456789012345678901234567890123456789012345678901234567890123456789
123 456789 1011 121314151617181920 212223 24       252627 28293031323334 35     36  37 3839  40        41  42     43   4445 464748495051525354   5556                                      5758596061                       6263646566    6768697071727374757677787980818283 84858687888990919293949596979899100101102103104 105106107108 109110111112113114115116117118119120121122123124 125126127128129130131132133134135136137138139140141142143144145146147148149150151152153  154155            156157158159160 161162163164165166167 168169170171172173174175176177178179180181     182183 184185186187188189190191192193                                     194195196197198199200201202203204205206207208209210211212213








270271272273274275276277278279280281282283284285286287288289   290291292293294295296297298299300301302303304305306307308309








322323324325326327328329330331332333334335336337338339340341                                                                                                                            342                  343344345346347348349350    351352353354355356357358359360361362  363364365          366367368369370371372373374375376377378379380381382383384385








401402403404405406407408409410411412413414415416417418419420421422423424425 426427428429430431432433   434435   436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469                    470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522                                                                                                523                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    524525526527528529530531532533534535536537538539540541542543








654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695








7167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635








2645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684     268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711 27122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773








2805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847 2848284928502851 28522853285428552856285728582859286028612862286328642865286628672868286928702871 2872287328742875287628772878287928802881 2882288328842885288628872888 288928902891289228932894 28952896289728982899290029012902290329042905 2906290729082909291029112912291329142915291629172918291929202921 29222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947  29482949295029512952295329542955295629572958295929602961296229632964    2965296629672968       296929702971297229732974 297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019       3020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470








34743475347634773478347934803481348234833484348534863487348834893490349134923493                                                                                                                                                                                                                                                                                                                                                                                                                                                     3494349534963497349834993500     350135023503350435053506350735083509                                   35103511  351235133514351535163517351835193520  3521       352235233524  35253526352735283529353035313532353335343535                                                                                       35363537      353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585     358635873588 358935903591 3592359335943595 359635973598   3599
#!/usr/bin/python3

import datetime

import json
import random
import math
import numbers
import os
import pickle

import signal
import shutil

import textwrap
import time

import bs4
import dateutil.relativedelta
import numpy
import matplotlib
import matplotlib.pyplot
import psutil

import pytz
import requests
import tzlocal










HOME_LAT = 37.64406
HOME_LON = -122.43463

HOME_ALT = 29  #altitude in meters
RADIUS = 6371.0e3  # radius of earth in meters
HOME = (HOME_LAT, HOME_LON) # lat / lon tuple of antenna
MIN_METERS = 1000 # only planes within this distance will be detailed
# planes not seen within MIN_METERS in 5 seconds will be dropped from the nearby list
PERSISTENCE_SECONDS = 5
FEET_IN_METER = 3.28084

METERS_PER_SECOND_IN_KNOTS = 0.514444





TRUNCATE = 50  # max number of keys to include in a histogram image file




DUMP_JSON_FILE = '/run/dump1090-mutability/aircraft.json'



MESSAGEBOARD_PATH = '/home/pi/splitflap/'








PICKLEFILE_30D = 'flights_30d.pk' #pickled list of up to about 30d of flights


PICKLEFILE_ARCHIVE = 'flights_archive.pk' #pickled list of all flights





LOGFILE = 'log.txt'




WEBSERVER_PATH = '/var/www/html/'

WEBSERVER_IMAGE_FOLDER = 'images/'
HOURLY_HISTOGRAM_FILE = 'histogram.txt'
CONFIG_FILE = 'settings.txt'
HOURLY_IMAGE_FILE = 'hours.png'
HOURLY_DATA_FILE = 'hours.txt'
ALL_MESSAGE_FILE = 'all_messages.txt'  #enumeration of all messages sent to board
ROLLING_MESSAGE_FILE = 'rolling_messages.txt'
HISTOGRAM_IMAGE_PREFIX = 'histogram_'
HISTOGRAM_IMAGE_SUFFIX = 'png'



HISTOGRAM_EMPTY_IMAGE_FILE = 'empty.png'
ROLLING_LOGFILE = 'rolling_log.txt' #file for error messages







































FLAG_MSG_FLIGHT = 1  # basic flight details
FLAG_MSG_INTERESTING = 2  # random tidbit about a flight
FLAG_MSG_HISTOGRAM = 3 # histogram message
























#if running on raspberry, then need to prepend path to file names
if psutil.sys.platform.title() == 'Linux':
  PICKLEFILE_30D = MESSAGEBOARD_PATH + PICKLEFILE_30D
  PICKLEFILE_ARCHIVE = MESSAGEBOARD_PATH + PICKLEFILE_ARCHIVE
  LOGFILE = MESSAGEBOARD_PATH + LOGFILE





  HOURLY_HISTOGRAM_FILE = WEBSERVER_PATH + HOURLY_HISTOGRAM_FILE
  CONFIG_FILE = WEBSERVER_PATH + CONFIG_FILE
  HOURLY_IMAGE_FILE = WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HOURLY_IMAGE_FILE
  HOURLY_DATA_FILE = WEBSERVER_PATH + HOURLY_DATA_FILE
  ROLLING_MESSAGE_FILE = WEBSERVER_PATH + ROLLING_MESSAGE_FILE
  HISTOGRAM_IMAGE_PREFIX = (
      WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_IMAGE_PREFIX)
  HISTOGRAM_EMPTY_IMAGE_FILE = (
      WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_EMPTY_IMAGE_FILE)
  ALL_MESSAGE_FILE = WEBSERVER_PATH + ALL_MESSAGE_FILE
  ROLLING_LOGFILE = WEBSERVER_PATH + ROLLING_LOGFILE

TIMEZONE = 'US/Pacific' # timezone of display
TZ = pytz.timezone(TIMEZONE)

SCREENIFY_SPLIT_FLAP = True

SPLITFLAP_CHARS_PER_LINE = 23
SPLITFLAP_LINE_COUNT = 7
SPLITFLAP_HEADER = '+' + '-'*SPLITFLAP_CHARS_PER_LINE + '+'
if SCREENIFY_SPLIT_FLAP:
  BORDER_CHARACTER = '|'
else:
  BORDER_CHARACTER = ''

DIRECTIONS_4 = ['N', 'E', 'S', 'W']
DIRECTIONS_8 = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
DIRECTIONS_16 = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
                 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']

HOURS = ['12a', ' 1a', ' 2a', ' 3a', ' 4a', ' 5a', ' 6a', ' 7a',
         ' 8a', ' 9a', '10a', '11a', '12p', ' 1p', ' 2p', ' 3p',
         ' 4p', ' 5p', ' 6p', ' 7p', ' 8p', ' 9p', '10p', '11p']

SECONDS_IN_MINUTE = 60
MINUTES_IN_HOUR = 60
HOURS_IN_DAY = 24
SECONDS_IN_HOUR = SECONDS_IN_MINUTE * MINUTES_IN_HOUR

SECONDS_IN_DAY = SECONDS_IN_HOUR*HOURS_IN_DAY

#debug: still need to validate vert_rate units!
CLIMB_RATE_UNITS = 'fpm'  # need to confirm

#speed units from tracker are knots, based on dump-1090/track.c
#https://github.com/SDRplay/dump1090/blob/master/track.c
SPEED_UNITS = 'kn'
DISTANCE_UNITS = 'ft'

# For displaying histograms
# If a key is not present, how should it be displayed in histograms?
KEY_NOT_PRESENT_STRING = 'Unknown'
OTHER_STRING = 'Other' # What key strings should be listed last in sequence?
# What key strings should be listed last in sequence?
SORT_AT_END_STRINGS = [OTHER_STRING, KEY_NOT_PRESENT_STRING]
# What is the sorted sequence of keys for days of week?
DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']

aircraft_length = {} # in meters
aircraft_length['Airbus A220-100 (twin-jet)'] = 35

aircraft_length['Airbus A319 (twin-jet)'] = 33.84
aircraft_length['Airbus A320 (twin-jet)'] = 37.57
aircraft_length['Airbus A320neo (twin-jet)'] = 37.57
aircraft_length['Airbus A321 (twin-jet)'] = 44.51
aircraft_length['Airbus A321neo (twin-jet)'] = 44.51
aircraft_length['Airbus A330-200 (twin-jet)'] = 58.82
aircraft_length['Airbus A330-300 (twin-jet)'] = 63.67
aircraft_length['Airbus A340-300 (quad-jet)'] = 63.69
aircraft_length['Airbus A350-1000 (twin-jet)'] = 73.79
aircraft_length['Airbus A350-900 (twin-jet)'] = 66.8
aircraft_length['Airbus A380-800 (quad-jet)'] = 72.72
aircraft_length['Boeing 737-400 (twin-jet)'] = 36.4
aircraft_length['Boeing 737-700 (twin-jet)'] = 33.63
aircraft_length['Boeing 737-800 (twin-jet)'] = 39.47
aircraft_length['Boeing 737-900 (twin-jet)'] = 42.11
aircraft_length['Boeing 747-400 (quad-jet)'] = 36.4
aircraft_length['Boeing 747-8 (quad-jet)'] = 76.25
aircraft_length['Boeing 757-200 (twin-jet)'] = 47.3
aircraft_length['Boeing 757-300 (twin-jet)'] = 54.4
aircraft_length['Boeing 767-200 (twin-jet)'] = 48.51
aircraft_length['Boeing 767-300 (twin-jet)'] = 54.94
aircraft_length['Boeing 777-200 (twin-jet)'] = 63.73
aircraft_length['Boeing 777-200LR/F (twin-jet)'] = 63.73
aircraft_length['Boeing 777-300ER (twin-jet)'] = 73.86
aircraft_length['Boeing 787-10 (twin-jet)'] = 68.28
aircraft_length['Boeing 787-8 (twin-jet)'] = 56.72
aircraft_length['Boeing 787-9 (twin-jet)'] = 62.81
aircraft_length['Canadair Regional Jet CRJ-200 (twin-jet)'] = 26.77
aircraft_length['Canadair Regional Jet CRJ-700 (twin-jet)'] = 32.3


aircraft_length['Bombardier Challenger 300 (twin-jet)'] = 20.92
aircraft_length['EMBRAER 175 (long wing) (twin-jet)'] = 31.68














def CheckIfProcessRunning(processName):
  #  Check if there is any running process that contains the given name processName.
  this_process_id = os.getpid()

  for proc in psutil.process_iter():
    try:
      # Check if process name contains the given name string.

      commands = proc.as_dict(attrs=['cmdline', 'pid'])
      if commands['cmdline']:
        command_running = any([processName.lower() in s.lower() for s in commands['cmdline']])

        if command_running and commands['pid'] != this_process_id:
          return commands['pid']
    except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
      pass
  return None


def LogMessage(message, file=LOGFILE):
  """Write a message to a logfile along with a timestamp.

  Args:
      message: string message to write
      file: string representing file name and, if needed, path to the file to write to
  """





  try:
    with open(file, 'a') as f:

      f.write('='*80+'\n')
      f.write(str(datetime.datetime.now(TZ))+'\n')
      f.write('\n')
      f.write(message+'\n')
  except IOError:
    LogMessage('Unable to append to ' + file)

  existing_log_lines = ReadFile(LOGFILE).splitlines()
  with open(ROLLING_LOGFILE, 'w') as f:
    f.write('\n'.join(existing_log_lines[-1000:]))







































def UtcToLocalTimeDifference(timezone=TIMEZONE):
  """Calculates number of seconds between UTC and given timezone.

  Returns number of seconds between UTC and given timezone; if no timezone given, uses
  TIMEZONE defined in global variable.

  Args:
    timezone: string representing a valid pytz timezone in pytz.all_timezones.

  Returns:
    Integer number of seconds.
  """
  utcnow = pytz.timezone('utc').localize(datetime.datetime.utcnow())
  home_time = utcnow.astimezone(pytz.timezone(timezone)).replace(tzinfo=None)
  system_time = utcnow.astimezone(tzlocal.get_localzone()).replace(tzinfo=None)

  offset = dateutil.relativedelta.relativedelta(home_time, system_time)
  offset_seconds = offset.hours * SECONDS_IN_HOUR




                            <----SKIPPED LINES---->




  lambda3 = lambda1 + dlambda13
  intersection = (degrees(phi3), degrees(lambda3))
  return intersection


def ConvertBearingToCompassDirection(bearing, length=3, pad=False):
  """Converts a bearing (in degrees) to a compass dir of 1, 2, or 3 chars (N, NW, NNW).

  Args:
    bearing: degrees to be converted
    length: if 1, 2, or 3, converts to one of 4, 8, or 16 headings:
      - 1: N, S, E, W
      - 2: SE, SW, etc. also valid
      - 3: NWN, ESE, etc. also valid
    pad: boolean indicating whether the direction should be right-justified to length
      characters

  Returns:
    String representation of the compass heading.
  """



  divisions = 2**(length+1)  # i.e.: 4, 8, or 16
  division_size = 360 / divisions  # i.e.: 90, 45, or 22.5
  bearing_number = round(bearing / division_size)

  if length == 1:
    directions = DIRECTIONS_4
  elif length == 2:
    directions = DIRECTIONS_8
  else:
    directions = DIRECTIONS_16

  direction = directions[bearing_number%divisions]
  if pad:
    direction = direction.rjust(length)
  return direction


def HaversineDistanceMeters(pos1, pos2):
  """Calculate the distance between two points on a sphere (e.g. Earth).





                            <----SKIPPED LINES---->




    pos2: a 2-tuple defining (lat, lon) in decimal degrees

  Returns:
    Distance between two points in meters.
  """
  is_numeric = [isinstance(x, numbers.Number) for x in (*pos1, *pos2)]
  if False in is_numeric:
    return None

  lat1, lon1, lat2, lon2 = [math.radians(x) for x in (*pos1, *pos2)]
  hav = (math.sin((lat2 - lat1) / 2.0)**2
         + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2.0)**2)
  distance = 2 * RADIUS * math.asin(math.sqrt(hav))

  # Note: though pyproj has this, having trouble installing on rpi
  #az12, az21, distance = g.inv(lon1, lat1, lon2, lat2)

  return distance






























































































































def Angles(pos1, elevation1, pos2, elevation2):


















  sin = math.sin
  cos = math.cos
  atan2 = math.atan2
  atan = math.atan
  sqrt = math.sqrt
  radians = math.radians
  degrees = math.degrees





  distance = HaversineDistanceMeters(pos1, pos2)  # from home to plumb line of plane
  lat1, lon1, lat2, lon2 = [radians(x) for x in (*pos1, *pos2)]
  d_lon = lon2 - lon1
  # azimuth calc from https://www.omnicalculator.com/other/azimuth
  az = atan2((sin(d_lon)*cos(lat2)), (cos(lat1)*sin(lat2)-sin(lat1)*cos(lat2)*cos(d_lon)))
  az_degrees = degrees(az)
  elevation = elevation2 - elevation1
  alt = atan(elevation / distance)
  alt_degrees = degrees(alt)
  crow_distance = sqrt(elevation**2 + distance**2)  # from home to the plane

  return (az_degrees, alt_degrees, distance, crow_distance)




def TrajectoryLatLon(pos, distance, track):










  #distance in meters
  #track in degrees
  sin = math.sin
  cos = math.cos
  atan2 = math.atan2
  asin = math.asin
  radians = math.radians
  degrees = math.degrees

  track = radians(track)
  lat1 = radians(pos[0])
  lon1 = radians(pos[1])

  d_div_R = distance/RADIUS
  lat2 = asin(sin(lat1)*cos(d_div_R) + cos(lat1)*sin(d_div_R)*cos(track))
  lon2 = lon1 + atan2(sin(track)*sin(d_div_R)*cos(lat1), cos(d_div_R)-sin(lat1)*sin(lat2))

  lat2_degrees = degrees(lat2)
  lon2_degrees = degrees(lon2)
  return (lat2_degrees, lon2_degrees)




                            <----SKIPPED LINES---->




  is_numeric = [isinstance(x, numbers.Number) for x in (*pos, bearing)]
  if False in is_numeric:
    return None

  # To find the minimum distance, we must first find the point at which the minimum
  # distance will occur, which in turn is accomplished by finding the intersection
  # between that trajectory and a trajectory orthogonal (+90 degrees, or -90 degrees)
  # to it but intersecting HOME.
  potential_intersection1 = IntersectionForTwoPaths(pos, bearing, HOME, bearing + 90)
  potential_intersection2 = IntersectionForTwoPaths(pos, bearing, HOME, bearing - 90)
  potential_distance1 = HaversineDistanceMeters(potential_intersection1, HOME)
  potential_distance2 = HaversineDistanceMeters(potential_intersection2, HOME)

  # Since one of those two potential intersection points (i.e.: +90 or -90 degrees) will
  # create an irrational result, and given the strong locality to HOME that is expected
  # from the initial position, the "correct" result is identified by simply taking the
  # minimum distance of the two candidate.
  return min(potential_distance1, potential_distance2)


def SecondsToHhMm(seconds):
  """Converts integer number of seconds to xhym string (i.e.: 7h17m).

  Args:
    seconds: number of seconds


  Returns:
    String representation of hours and minutes.
  """
  minutes = int(abs(seconds) / SECONDS_IN_MINUTE)
  if minutes > MINUTES_IN_HOUR:
    hours = int(minutes / MINUTES_IN_HOUR)
    minutes = minutes % MINUTES_IN_HOUR



    text = str(hours) + 'h' + str(minutes) + 'm'
  else:



    text = str(minutes) + 'm'
  return text


def SecondsToHours(seconds):
  """Converts integer number of seconds to xh string (i.e.: 7h).

  Args:
    seconds: number of seconds

  Returns:
    String representation of hours.
  """
  minutes = int(abs(seconds) / SECONDS_IN_MINUTE)
  hours = round(minutes / MINUTES_IN_HOUR)
  return hours


def SecondsToDdHh(seconds):
  """Converts integer number of seconds to xdyh string (i.e.: 7d17h).

  Args:
    seconds: number of seconds

  Returns:
    String representation of days and hours.
  """
  days = int(abs(seconds) / SECONDS_IN_DAY)
  hours = SecondsToHours(seconds - days*SECONDS_IN_DAY)
  if hours == HOURS_IN_DAY:
    hours = 0
    days += 1
  text = '%dd%dh' % (days, hours)
  return text






















def HoursSinceMidnight(timezone=TIMEZONE):
  """Returns the float number of hours elapsed since midnight in the given timezone."""
  tz = pytz.timezone(timezone)
  now = datetime.datetime.now(tz)
  seconds_since_midnight = (
      now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds()
  hours = seconds_since_midnight / SECONDS_IN_HOUR
  return hours


def HoursSinceFlight(now, then):
  """Returns the number of hours between a timestamp and a flight.

  Args:
    now: timezone-aware datetime representation of timestamp
    then: string representation of a timestamp in format '%Y-%m-%d %H:%M:%S.%f%z' (i.e.:
        '2020-03-22 00:05:44.026630-07:00'

  Returns:
    Number of hours between now and then (i.e.: now - then; a positive return value
    means now occurred after then).
  """
  then_time = datetime.datetime.strptime(then, '%Y-%m-%d %H:%M:%S.%f%z')
  delta = now - then_time
  delta_hours = delta.days * HOURS_IN_DAY + delta.seconds / SECONDS_IN_HOUR
  return delta_hours


def DataHistoryHours(flights):
  """Calculates the number of hours between the earliest flight in data and now.

  flights: List of all flights in sequential order, so that the first in list is earliest
      in time.

  Returns:
    Return time difference in hours between the first flight in data and now.
  """
  min_time_string = flights[0]['calcd_display_time']
  min_time = datetime.datetime.strptime(min_time_string, '%Y-%m-%d %H:%M:%S.%f%z')
  tz = min_time.tzinfo
  now = datetime.datetime.now(tz)
  delta = now - min_time
  delta_hours = delta.days * HOURS_IN_DAY + delta.seconds / SECONDS_IN_HOUR
  return round(delta_hours)


def DictGetReplaceNone(d, key, default_value=''):
  """d[key] if key exists and is not None; default_value otherwise."""
  value = d.get(key)
  if value is None:
    value = default_value
































































































  return value






































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































def SortByValues(values, keys, ignore_sort_at_end_strings=False):
  """Sorts the list of values in descending sequence, applying same resorting to keys.

  Given a list of keys and values representing a histogram, returns two new lists that
  are sorted so that the values occur in descending sequence and the keys are moved
  around in the same way. This allows the printing of a histogram with the largest
  keys listed first - i.e.: top five airlines.

  Keys identified by SORT_AT_END_STRINGS - such as, perhaps, 'Other' - will optionally
  be placed at the end of the sequence. And where values are identical, the secondary
  sort is based on the keys.

  Args:
    values: list of values for the histogram to be used as the primary sort key.
    keys: list of keys for the histogram that will be moved in the same way as the values.
    ignore_sort_at_end_strings: boolean indicating whether specially-defined keys will be
        sorted at the end.





                            <----SKIPPED LINES---->




    keyfunction,
    sort_type,
    title,
    position=None,
    truncate=0,
    hours=float('inf'),
    max_distance_feet=float('inf'),
    max_altitude_feet=float('inf'),
    normalize_factor=0,
    exhaustive=False,
    figsize_inches=(9, 6)):
  """Creates matplotlib.pyplot of histogram that can then be saved or printed.

  Args:
    data: the iterable (i.e.: list) of flight details, where each element in the list is
        a dictionary of the flight attributes.
    keyfunction: a function that when applied to a single flight (i.e.:
        keyfunction(data[0]) returns the key to be used for the histogram.
    data: the iterable of the raw data from which the histogram will be generated;
        each element of the iterable is a dictionary, that contains at least the key
        'calcd_display_time', and depending on other parameters, also potentially
        'calcd_min_feet' amongst others.
    keyfunction: the function that determines how the key or label of the histogram
        should be generated; it is called for each element of the data iterable. For
        instance, to simply generate a histogram on the attribute 'heading',
        keyfunction would be lambda a: a['heading'].
    title: the "base" title to include on the histogram; it will additionally be
        augmented with the details about the date range.
    position: Either a 3-digit integer or an iterable of three separate integers
        describing the position of the subplot. If the three integers are nrows, ncols,
        and index in order, the subplot will take the index position on a grid with nrows
        rows and ncols columns. index starts at 1 in the upper left corner and increases
        to the right.
    sort_type: determines how the keys (and the corresponding values) are sorted:
        'key': the keys are sorted by a simple comparison operator between them, which
            sorts strings alphabetically and numbers numerically.
        'value': the keys are sorted by a comparison between the values, which means
            that more frequency-occurring keys are listed first.
        list: if instead of the strings a list is passed, the keys are then sorted in
            the sequence enumerated in the list. This is useful for, say, ensuring that
            the days of the week (Tues, Wed, Thur, ...) are listed in sequence. Keys
            that are generated by keyfunction but that are not in the given list are




                            <----SKIPPED LINES---->




        the list, including potentially those with a frequency of zero, within the
        restrictions of truncate.
    figsize_inches: a 2-tuple of width, height indicating the size of the histogram.
  """
  (values, keys, filtered_data) = GenerateHistogramData(
      data,
      keyfunction,
      sort_type,
      truncate=truncate,
      hours=hours,
      max_distance_feet=max_distance_feet,
      max_altitude_feet=max_altitude_feet,
      normalize_factor=normalize_factor,
      exhaustive=exhaustive)
  if position:
    matplotlib.pyplot.subplot(*position)
  matplotlib.pyplot.figure(figsize=figsize_inches)
  values_coordinates = numpy.arange(len(keys))
  matplotlib.pyplot.bar(values_coordinates, values)

  earliest_flight_time = int(filtered_data[0]['calcd_timestamp'])
  last_flight_time = int(filtered_data[-1]['calcd_timestamp'])
  date_range_string = ' (%d flights over last %s hours)' % (
      sum(values), SecondsToDdHh(last_flight_time - earliest_flight_time))

  matplotlib.pyplot.title(title + date_range_string)

  matplotlib.pyplot.subplots_adjust(bottom=0.15, left=0.09, right=0.99, top=0.92)

  matplotlib.pyplot.xticks(
      values_coordinates, keys, rotation='vertical', wrap=True,
      horizontalalignment='right',
      verticalalignment='center')


def SaveTimeOfDayDataFile(flights, max_days, filename=HOURLY_DATA_FILE, precision=100):
  """Extracts hourly histogram into text file for a variety of altitudes and distances.

  Generates a csv with 26 columns:
  - col#1: altitude (in feet)
  - col#2: distance (in feet)
  - cols#3-26: hour of the day

  The first row is a header row; subsequent rows list the number of flights that have
  occurred in the last max_days with an altitude and distance less than that identified
  in the first two columns. Each row increments elevation or altitude by precision feet,
  up to the max determined by the max altitude and max distance amongst all the flights.

  Args:
    flights: list of the flights.
    max_days: maximum number of days as described.
    filename: file into which to save the csv.
    precision: number of feet to increment the altitude or distance.
  """
  max_altitude = int(max([flight.get('altitude', -1) for flight in flights]))
  max_distance = int(max([
      flight.get('distance_meters', -1)*FEET_IN_METER for flight in flights]))
  header_printed = False
  for flight in flights:
    flight['calcd_hour'] = ConvertHourStringTimeString(flight)
  lines = []
  for altitude in range(0, max_altitude, precision):
    for distance in range(0, max_distance, precision):
      (values, keys, unused_filtered_data) = GenerateHistogramData(
          flights,
          lambda a: a['calcd_hour'],
          HOURS,
          hours=max_days*HOURS_IN_DAY,
          max_distance_feet=distance,
          max_altitude_feet=altitude,
          normalize_factor=1, #debug: max_days
          exhaustive=True)
      if not header_printed:
        header_elements = ['altitude', 'distance', *keys]
        header_elements = [str(v) for v in header_elements]
        header = ','.join(header_elements)
        lines.append(header)
        header_printed = True

      line_elements = [altitude, distance]
      line_elements.extend(values)
      line_elements = [str(v) for v in line_elements]
      line = ','.join(line_elements)
      lines.append(line)
  try:
    with open(filename, 'w') as f:
      for line in lines:
        f.write(line+'\n')
  except IOError:
    LogMessage('Unable to write hourly histogram data file ' + filename)


def SaveTimeOfDayHistogramPng(
    flights,
    max_distance_feet,
    max_altitude_feet,
    max_days,
    filename=HOURLY_IMAGE_FILE,
    last_max_distance_feet=None,
    last_max_altitude_feet=None):
  """Saves as a png file the histogram of the hourly flight data for the given filters.

  Generates a png histogram of the count of flights by hour that meet the specified
  criteria: max altitude, max distance, and within the last number of days. Saves this
  histogram to disk, as well as potentially moving the previously saved image to a new
  location.

  Args:
    flights: list of the flights.
    max_distance_feet: max distance for which to include flights in the histogram.
    max_altitude_feet: max altitude for which to include flights in the histogram.
    max_days: maximum number of days as described.
    filename: file into which to save the csv.
    last_max_distance_feet: if provided, along with last_max_altitude_feet, generates
        a second data series with different criteria for distance and altitude, for
        which the histogram data will be plotted alongside the first series.
    last_max_altitude_feet: see above.
  """
  (values, keys, unused_filtered_data) = GenerateHistogramData(
      flights,
      ConvertHourStringTimeString,
      HOURS,
      hours=max_days*HOURS_IN_DAY,
      max_distance_feet=max_distance_feet,
      max_altitude_feet=max_altitude_feet,
      normalize_factor=max_days,
      exhaustive=True)

  comparison = last_max_distance_feet is not None and last_max_altitude_feet is not None
  if comparison:
    (last_values, unused_last_keys, unused_filtered_data) = GenerateHistogramData(
        flights,
        ConvertHourStringTimeString,
        HOURS,
        hours=max_days*HOURS_IN_DAY,
        max_distance_feet=last_max_distance_feet,
        max_altitude_feet=last_max_altitude_feet,
        normalize_factor=max_days,
        exhaustive=True)

  x = numpy.arange(len(keys))
  unused_fig, ax = matplotlib.pyplot.subplots()
  width = 0.35
  ax.bar(
      x - width/2, values, width,
      label='Current - alt: %d; dist: %d' % (max_altitude_feet, max_distance_feet))
  title = 'Daily Flights Expected: %d / day' % sum(values)
  if comparison:
    ax.bar(
        x + width/2, last_values, width,
        label='Prior - alt: %d; dist: %d' % (
            last_max_altitude_feet, last_max_distance_feet))
    title += ' (%+d)' % (round(sum(values) - sum(last_values)))

  ax.set_title(title)
  ax.set_ylabel('Average Observed Flights')
  if comparison:
    ax.legend()
  matplotlib.pyplot.xticks(
      x, keys, rotation='vertical', wrap=True,
      horizontalalignment='right',
      verticalalignment='center')

  matplotlib.pyplot.savefig(filename)
  matplotlib.pyplot.close()


def GenerateHistogramData(
    data,
    keyfunction,
    sort_type,
    truncate=float('inf'),
    hours=float('inf'),
    max_distance_feet=float('inf'),
    max_altitude_feet=float('inf'),
    normalize_factor=0,
    exhaustive=False):
  """Generates sorted data for a histogram from a description of the flights.

  Given an iterable describing the flights, this function generates the label (or key),
  and the frequency (or value) from which a histogram can be rendered.

  Args:
    data: the iterable of the raw data from which the histogram will be generated;
        each element of the iterable is a dictionary, that contains at least the key
        'calcd_display_time', and depending on other parameters, also potentially
        'calcd_min_feet' amongst others.
    keyfunction: the function that determines how the key or label of the histogram
        should be generated; it is called for each element of the data iterable. For
        instance, to simply generate a histogram on the attribute 'heading',
        keyfunction would be lambda a: a['heading'].
    sort_type: determines how the keys (and the corresponding values) are sorted:
        'key': the keys are sorted by a simple comparison operator between them, which
            sorts strings alphabetically and numbers numerically.
        'value': the keys are sorted by a comparison between the values, which means
            that more frequency-occurring keys are listed first.
        list: if instead of the strings a list is passed, the keys are then sorted in
            the sequence enumerated in the list. This is useful for, say, ensuring that
            the days of the week (Tues, Wed, Thur, ...) are listed in sequence. Keys
            that are generated by keyfunction but that are not in the given list are
            sorted last (and then amongst those, alphabetically).
    truncate: integer indicating the maximum number of keys to return; if set to 0, or if
        set to a value larger than the number of keys, no truncation occurs. But if set
        to a value less than the number of keys, then the keys with the lowest frequency
        are combined into one key named OTHER_STRING so that the number of keys
        in the resulting histogram (together with OTHER_STRING) is equal to truncate.
    hours: integer indicating the number of hours of history to include. Flights with a
        calcd_display_time more than this many hours in the past are excluded from the
        histogram generation. Note that this is timezone aware, so that if the histogram
        data is generated on a machine with a different timezone than that that recorded
        the original data, the correct number of hours is still honored.
    max_distance_feet: number indicating the geo fence outside of which flights should
        be ignored for the purposes of including the flight data in the histogram.
    max_altitude_feet: number indicating the maximum altitude outside of which flights
        should be ignored for the purposes of including the flight data in the histogram.
    normalize_factor: divisor to apply to all the values, so that we can easily
        renormalize the histogram to display on a percentage or daily basis; if zero,
        no renormalization is applied.
    exhaustive: boolean only relevant if sort_type is a list, in which case, this ensures
        that the returned set of keys (and matching values) contains all the elements in
        the list, including potentially those with a frequency of zero, within the
        restrictions of truncate.

  Returns:
    2-tuple of lists cut and sorted as indicated by parameters above:
    - list of values (or frequency) of the histogram elements
    - list of keys (or labels) of the histogram elements
  """
  histogram_dict = {}
  filtered_data = []

  # get timezone & now so that we can generate a timestamp for comparison just once
  if hours:
    now = GetNowInTimeZoneOfArbtraryFlight(data)
  for element in data:
    if (
        DictGetReplaceNone(
            element, 'calcd_min_feet', float('inf')) < max_distance_feet and
        DictGetReplaceNone(element, 'altitude', float('inf')) < max_altitude_feet and
        HoursSinceFlight(now, element['calcd_display_time']) <= hours):
      filtered_data.append(element)
      key = keyfunction(element)
      if key is None or key == '':
        key = KEY_NOT_PRESENT_STRING
      if key in histogram_dict:
        histogram_dict[key] += 1
      else:
        histogram_dict[key] = 1
  values = list(histogram_dict.values())
  keys = list(histogram_dict.keys())

  if normalize_factor:
    values = [v / normalize_factor for v in values]

  sort_by_enumerated_list = isinstance(sort_type, list)
  if exhaustive and sort_by_enumerated_list:
    missing_keys = set(sort_type).difference(set(keys))
    missing_values = [0 for unused_k in missing_keys]
    keys.extend(missing_keys)
    values.extend(missing_values)

  if keys:  # filters could potentially have removed all data
    if not truncate or len(keys) <= truncate:
      if sort_by_enumerated_list:
        (values, keys) = SortByDefinedList(values, keys, sort_type)
      elif sort_type == 'value':
        (values, keys) = SortByValues(values, keys)
      else:
        (values, keys) = SortByKeys(values, keys)
    else: #Unknown might fall in the middle, and so shouldn't be truncated
      (values, keys) = SortByValues(values, keys, ignore_sort_at_end_strings=True)

      truncated_values = list(values[:truncate-1])
      truncated_keys = list(keys[:truncate-1])
      other_value = sum(values[truncate-1:])
      truncated_values.append(other_value)
      truncated_keys.append(OTHER_STRING)
      if sort_by_enumerated_list:
        (values, keys) = SortByDefinedList(
            truncated_values, truncated_keys, sort_type)
      elif sort_type == 'value':
        (values, keys) = SortByValues(
            truncated_values, truncated_keys, ignore_sort_at_end_strings=False)
      else:
        (values, keys) = SortByKeys(truncated_values, truncated_keys)
  else:
    values = []
    keys = []
  return (values, keys, filtered_data)


def ReadFile(filename, log_exception=False):
  """Returns text from the given file name if available, empty string if not available.

  Args:
    filename: string of the filename to open, potentially also including the full path.
    log_exception: boolean indicating whether to log an exception if file not found.

  Returns:
    Return text string of file contents.
  """
  try:
    with open(filename, 'r') as content_file:
      file_contents = content_file.read()
  except IOError:
    if log_exception:
      LogMessage('Unable to read '+filename)
    return ''
  return file_contents


def ParseDumpJson(dump_json):
  """Identifies all airplanes within given distance of home from the dump1090 file.

  Since the dump1090 json will have messages from all flights that the antenna has picked
  up, we want to keep only flights that are within a relevant distance to us, and also to
  extract from the full set of data in the json to just the relevant fields for additional
  analysis.

  Args:
    dump_json: The text representation of the json message from dump1090-mutability

  Returns:
    Return 2-tuple, with first element being a dictionary of all airplanes, where keys are
    flight numbers (i.e.: 'SWA7543'), and the value is itself a dictionary of attributes.
    The included attributes are: speed, altitude, vert_rate, distance_meters, lat / lon,
    and track (or bearing / heading). The second element is the time stamp in the json
    file.
  """
  parsed = json.loads(dump_json)
  nearby_aircraft = {}
  for aircraft in parsed['aircraft']:
    flight_number = aircraft.get('flight', KEY_NOT_PRESENT_STRING).strip()
    if 'lat' in aircraft and 'lon' in aircraft:
      now = datetime.datetime.now(TZ)
      lat = aircraft.get('lat', KEY_NOT_PRESENT_STRING)
      lon = aircraft.get('lon', KEY_NOT_PRESENT_STRING)
      track = aircraft.get('track', KEY_NOT_PRESENT_STRING)
      distance_meters = round(
          HaversineDistanceMeters(HOME, (aircraft['lat'], aircraft['lon'])))
      min_meters = MinMetersToHome((lat, lon), track)
      if min_meters:
        min_feet = min_meters * FEET_IN_METER
      else:
        min_feet = None
      # simplified_aircraft is all attributes from radio or derivable from radio & math
      simplified_aircraft = {
          'distance_meters': distance_meters,
          # altitude is unknown units, but believed to be feet?
          'altitude': aircraft.get('altitude', KEY_NOT_PRESENT_STRING),
          # speed is is in knots
          'speed': aircraft.get('speed', KEY_NOT_PRESENT_STRING),
          # vert_rate in unknown units
          'vert_rate': aircraft.get('vert_rate', KEY_NOT_PRESENT_STRING),
          'lat': lat,
          'lon': lon,
          'track': track,
          'squawk': aircraft.get('squawk', KEY_NOT_PRESENT_STRING),
          'radio_now': parsed['now'],
          'dump_flight_number': flight_number,
          'calcd_display_time': now.strftime('%Y-%m-%d %H:%M:%S.%f%z'),
          'calcd_timestamp': datetime.datetime.timestamp(now),
          'calcd_min_feet': min_feet}

      if distance_meters < MIN_METERS:
        nearby_aircraft[flight_number] = simplified_aircraft
        if not flight_number:
          LogMessage('Dump JSON does not include a flight number: %s' % str(aircraft),
                     file=LOGFILE)

  return (nearby_aircraft, parsed['now'])


def GetFlightAwareJson(flight_number):
  """Scrapes the text json message from FlightAware for a given flight number.

  Given a flight number, loads the corresponding FlightAware webpage for that flight and
  extracts the relevant script that contains all the flight details from that page.

  Args:
    flight_number: text flight number (i.e.: SWA1234)

  Returns:
    Text representation of the json message from FlightAware.
  """
  url = 'https://flightaware.com/live/flight/' + flight_number
  try:
    response = requests.get(url)
  except:
    LogMessage('Unable to query FA for URL: ' + url)
    return ''
  soup = bs4.BeautifulSoup(response.text, "html.parser")
  l = soup.findAll('script')
  flight_script = None
  for script in l:
    if "trackpollBootstrap" in script.text:
      flight_script = script.text
      break
  if not flight_script:
    LogMessage('Unable to find trackpollBootstrap script in page: ' + response.text)
    return ''
  first_open_curly_brace = flight_script.find('{')
  flight_json = flight_script[first_open_curly_brace:-1]
  return flight_json


def ParseFlightAwareJson(flight_json):
  """Strips relevant data about the flight from FlightAware feed.

  The FlightAware json has hundreds of fields about a flight, only a fraction of which
  are relevant to extract. Note that some of the fields are inconsistently populated
  (i.e.: scheduled and actual times for departure and take-off).

  Args:
    flight_json: Text representation of the FlightAware json about a single flight.

  Returns:
    Dictionary of flight attributes extracted from the FlightAware json.
  """
  flight_details = {}
  parsed_json = json.loads(flight_json)
  fa_flight_number = list(parsed_json['flights'].keys())[0]
  parsed_flight_details = parsed_json['flights'][fa_flight_number]
  flight_details['fa_flight_number'] = fa_flight_number

  origin = parsed_flight_details.get('origin', '')
  if origin:
    origin_friendly = origin.get('friendlyLocation', '')
    origin_iata = origin.get('iata', '')
  else:
    origin_friendly = ''
    origin_iata = ''
  flight_details['origin_friendly'] = origin_friendly
  flight_details['origin_iata'] = origin_iata

  destination = parsed_flight_details.get('destination', '')
  if destination:
    destination_friendly = destination.get('friendlyLocation', '')
    destination_iata = destination.get('iata', '')
  else:
    destination_friendly = ''
    destination_iata = ''
  flight_details['destination_friendly'] = destination_friendly
  flight_details['destination_iata'] = destination_iata

  if origin_iata and destination_iata == origin_iata:
    LogMessage('Origin & destination both %s in FA JSON: %s'
               % (origin_iata, str(parsed_json)), file=LOGFILE)

  # perhaps interesting for private planes
  flight_details['owner_location'] = parsed_flight_details.get('ownerLocation')
  flight_details['owner'] = parsed_flight_details.get('owner')
  flight_details['tail'] = parsed_flight_details.get('tail')

  aircraft_type = parsed_flight_details.get('aircraft')
  if aircraft_type:
    aircraft_type_code = aircraft_type.get('type', '')
    aircraft_type_friendly = aircraft_type.get('friendlyType', '')
  else:
    aircraft_type_code = ''
    aircraft_type_friendly = ''
  flight_details['aircraft_type_code'] = aircraft_type_code
  flight_details['aircraft_type_friendly'] = aircraft_type_friendly

  takeoff_time = parsed_flight_details.get('takeoffTimes', '')
  if takeoff_time:
    scheduled_takeoff_time = takeoff_time.get('scheduled', '')
    actual_takeoff_time = takeoff_time.get('actual', '')
  else:
    scheduled_takeoff_time = ''
    actual_takeoff_time = ''
  flight_details['scheduled_takeofftime'] = scheduled_takeoff_time
  flight_details['actual_takeoff_time'] = actual_takeoff_time

  gate_departure_time = parsed_flight_details.get('gateDepartureTimes', '')
  if gate_departure_time:
    scheduled_departure_time = gate_departure_time.get('scheduled', '')
    actual_departure_time = gate_departure_time.get('actual', '')
  else:
    scheduled_departure_time = ''
    actual_departure_time = ''
  flight_details['scheduled_departure_time'] = scheduled_departure_time
  flight_details['actual_departure_time'] = actual_departure_time

  gate_arrival_time = parsed_flight_details.get('gateArrivalTimes', '')
  if gate_arrival_time:
    scheduled_arrival_time = gate_arrival_time.get('scheduled', '')
    estimated_arrival_time = gate_arrival_time.get('estimated', '')
  else:
    scheduled_arrival_time = ''
    estimated_arrival_time = ''
  flight_details['scheduled_arrival_time'] = scheduled_arrival_time
  flight_details['estimated_arrival_time'] = estimated_arrival_time

  landing_time = parsed_flight_details.get('landingTimes', '')
  if landing_time:
    scheduled_landing_time = landing_time.get('scheduled', '')
    estimated_landing_time = landing_time.get('estimated', '')
  else:
    scheduled_landing_time = ''
    estimated_landing_time = ''
  flight_details['scheduled_landing_time'] = scheduled_landing_time
  flight_details['estimated_landing_time'] = estimated_landing_time

  airline = parsed_flight_details.get('airline', '')
  if airline:
    airline_call_sign = airline.get('callsign', '')
    airline_short_name = airline.get('shortName', '')
    airline_full_name = airline.get('fullName', '')
  else:
    airline_call_sign = ''
    airline_short_name = ''
    airline_full_name = ''
  flight_details['airline_call_sign'] = airline_call_sign
  flight_details['airline_short_name'] = airline_short_name
  flight_details['airline_full_name'] = airline_full_name

  if len(parsed_json['flights'].keys()) > 1:
    LogMessage('There are multiple flights in the FlightAware json: ' + parsed_json)

  return flight_details


def AugmentWithDisplayableAirline(flight_details):
  """Augments flight details with display-ready airline attributes.

  Args:
    flight_details: dictionary with key-value attributes about the flight, but not yet
      containing all the necessary print-ready flight attributes.
  """
  # LINE1: UAL1425 - UNITED
  airline = DictGetReplaceNone(flight_details, 'airline_short_name')
  if not airline:
    airline = DictGetReplaceNone(flight_details, 'airline_full_name')
  if not airline:
    airline = KEY_NOT_PRESENT_STRING
  flight_details['calcd_airline'] = airline


def AugmentWithDisplayableAircraft(flight_details):
  """Augments flight details with display-ready aircraft attributes.

  Args:
    flight_details: dictionary with key-value attributes about the flight, but not yet
      containing all the necessary print-ready flight attributes.
  """
  # LINE2: Boeing 737-800 (twin-jet)
  aircraft_type = DictGetReplaceNone(flight_details, 'aircraft_type_friendly')
  aircraft_type = aircraft_type.replace('(twin-jet)', '(twin)')
  aircraft_type = aircraft_type.replace('(quad-jet)', '(quad)')
  aircraft_type = aircraft_type.replace('Regional Jet ', '')
  aircraft_type = aircraft_type[:SPLITFLAP_CHARS_PER_LINE]
  flight_details['calcd_aircraft_type'] = aircraft_type


def AugmentWithDisplayableOriginDestination(flight_details):
  """Augments flight details with display-ready origin and destination attributes.

  If the origin or destination is among a few key airports where the IATA code is
  well-known, then we can display only that code. Otherwise, we'll want to display
  both the code and a longer description of the airport. But we need to be mindful of
  the overall length of the display. So, for instance, these might be produced as
  valid origin-destination pairs:
  SFO-CLT Charlotte       <- Known origin
  Charlotte CLT-SFO       <- Known destination
  Charl CLT-SAN San Diego <- Neither origin nor destination known

  Args:
    flight_details: dictionary with key-value attributes about the flight, but not yet
      containing all the necessary print-ready flight attributes.
  """
  iata_length = 3
  known_airports = ('SJC', 'SFO', 'OAK') # Airport codes we don't need to expand
  origin = (DictGetReplaceNone(flight_details, 'origin_iata') + ' ' +
            DictGetReplaceNone(flight_details, 'origin_friendly').split(',')[0])
  destination = (DictGetReplaceNone(flight_details, 'destination_iata') + ' ' +
                 DictGetReplaceNone(flight_details, 'destination_friendly').split(',')[0])
  max_line_length_after_divider = SPLITFLAP_CHARS_PER_LINE - len('-')
  if (origin[:iata_length] not in known_airports
      and destination[:iata_length] not in known_airports):
    max_origin_length = int(max_line_length_after_divider/2)
    max_destination_length = max_line_length_after_divider - max_origin_length
    if len(origin) > max_origin_length and len(destination) > max_destination_length:
      origin_length = max_origin_length
      destination_length = max_destination_length
    elif len(origin) > max_origin_length:
      destination_length = len(destination)
      origin_length = max_line_length_after_divider - destination_length
    elif len(destination) > max_destination_length:
      origin_length = len(origin)
      destination_length = max_line_length_after_divider - origin_length
    else:
      origin_length = max_origin_length
      destination_length = max_destination_length
  elif origin[:iata_length] in known_airports:
    origin_length = iata_length
    destination_length = max_line_length_after_divider - origin_length
  elif destination[:iata_length] in known_airports:
    destination_length = iata_length
    origin_length = max_line_length_after_divider - destination_length
  else:
    destination_length = iata_length
    origin_length = iata_length

  origin = origin[:origin_length]
  destination = destination[:destination_length]
  flight_details['calcd_origin'] = origin
  flight_details['calcd_destination'] = destination

  origin_destination_pair = ''
  if origin and destination:
    origin_destination_pair = '-'.join([origin, destination])
  flight_details['calcd_origin_destination_pair'] = origin_destination_pair


def AugmentWithDisplayableDelay(flight_details):
  """Augments flight details with display-ready departure time and delay attributes.

  Args:
    flight_details: dictionary with key-value attributes about the flight, but not yet
      containing all the necessary print-ready flight attributes.
  """
  # LINE4: Dep: 08:18 (10m early)
  #        Dep: Unknown
  actual_departure = flight_details.get('actual_departure_time')
  scheduled_departure = flight_details.get('scheduled_departure_time')
  actual_takeoff_time = flight_details.get('actual_takeoff_time')
  scheduled_takeoff_time = flight_details.get('scheduled_takeofftime')
  calculable_delay = False

  scheduled = None
  delay_seconds = None
  delay_text = ''

  if actual_departure and scheduled_departure:
    actual = actual_departure
    scheduled = scheduled_departure
    departure_label = 'Dep'
    calculable_delay = True
  elif actual_takeoff_time and scheduled_takeoff_time:
    actual = actual_takeoff_time
    scheduled = scheduled_takeoff_time
    departure_label = 'T-O'
    calculable_delay = True
  elif actual_departure:
    actual = actual_departure
    departure_label = 'Act Dep'
  elif scheduled_departure:
    actual = scheduled_departure
    departure_label = 'Sch Dep'
  elif actual_takeoff_time:
    actual = actual_takeoff_time
    departure_label = 'Act T-O'
  elif scheduled_takeoff_time:
    actual = scheduled_takeoff_time
    departure_label = 'Sch T-O'
  else:
    actual = 0
    departure_time_text = 'Dep: Unknown'

  if actual:
    tz_corrected_actual = actual + UtcToLocalTimeDifference()
    departure_time_text = (
        departure_label + ': ' +
        datetime.datetime.fromtimestamp(tz_corrected_actual).strftime('%I:%M'))

    if calculable_delay:
      delay_seconds = actual - scheduled
      if int(delay_seconds / SECONDS_IN_MINUTE) == 0:
        delay_text = 'on time'
      else:
        if delay_seconds < 0:
          delay_direction = ' early'
        else:
          delay_direction = ' late'
        delay_text = SecondsToHhMm(delay_seconds) + delay_direction

      delay_text = ' (' + delay_text + ')'

  flight_details['calcd_actual_departure'] = actual
  flight_details['calcd_departure_time_text'] = departure_time_text
  flight_details['calcd_calculable_delay'] = calculable_delay
  flight_details['calcd_scheduled_departure'] = scheduled
  flight_details['calcd_delay_seconds'] = delay_seconds
  flight_details['calcd_delay_text'] = delay_text


def AugmentWithDisplayableTimeRemaining(flight_details):
  """Augments flight details with display-ready flight time remaining attributes.

  Args:
    flight_details: dictionary with key-value attributes about the flight, but not yet
      containing all the necessary print-ready flight attributes.
  """
  # LINE5: Rem: 4h18m
  #        Rem: Unk
  arrival = flight_details.get('estimated_arrival_time')
  if not arrival:
    arrival = flight_details.get('estimated_landing_time')
  if not arrival:
    arrival = flight_details.get('scheduled_arrival_time')
  if not arrival:
    arrival = flight_details.get('scheduled_landing_time')

  actual = flight_details['calcd_actual_departure']
  if actual and arrival:
    remaining_seconds = arrival - actual
  else:
    remaining_seconds = None
  flight_details['calcd_remaining_seconds'] = remaining_seconds


def Screenify(lines):
  """Transforms a list of lines to a single text string either for printing or sending.

  Given a list of lines that is a fully-formed message to send to the splitflap display,
  this function transforms the list of strings to a single string that is an
  easier-to-read and more faithful representation of how the message will be displayed.
  The transformations are to add blank lines to the message to make it consistent number
  of lines, and to add border to the sides & top / bottom of the message.

  Args:
    lines: list of strings that comprise the message

  Returns:
    String - which includes embedded new line characters, borders, etc. as described
    above, that can be printed to screen as the message.
  """
  if SCREENIFY_SPLIT_FLAP:
    for unused_n in range(SPLITFLAP_LINE_COUNT-len(lines)):
      lines.append('')
    lines = [BORDER_CHARACTER + line.ljust(SPLITFLAP_CHARS_PER_LINE) + BORDER_CHARACTER
             for line in lines]
    lines.insert(0, SPLITFLAP_HEADER)
    lines.append(SPLITFLAP_HEADER)

  return '\n'.join(lines)


def CreateMessageAboutFlight(flight_details):
  """Creates a message to describe interesting attributes about a single flight.

  Generates a multi-line description of a flight. A typical message might look like:
  UAL300 - UNITED        <- Flight number and airline
  BOEING 777-200 (TWIN)  <- Aircraft type
  SFO-HNL HONOLULU       <- Origin & destination
  DEP: 02:08 (12M EARLY) <- Departure time details
  REM: 5H14M             <- Remaining flight time
  185MPH 301DEG D:117FT  <- Trajectory details: speed; bearing; forecast min dist to HOME
  1975FT (+2368FPM)      <- Altitude details: current altitude & rate or ascent / descent

  However, not all of these details are always present, so some may be listed as unknown,
  or entire lines may be left out.

  Args:
    flight_details: dictionary of flight attributes.

  Returns:
    Printable string (with embedded new line characters)
  """
  lines = []

  # LINE1: UAL1425 - UNITED
  #        =======================
  airline = flight_details['calcd_airline']
  flight_number = DictGetReplaceNone(flight_details, 'dump_flight_number')
  line = (flight_number + ' - ' + airline)[:SPLITFLAP_CHARS_PER_LINE]
  if airline or flight_number:
    lines.append(line.upper())

  # LINE2: Boeing 737-800 (twin-jet)
  #        =======================
  aircraft_type = flight_details['calcd_aircraft_type']
  if aircraft_type:
    lines.append(aircraft_type.upper())

  # LINE3: SFO-CLT Charlotte
  #        Charlotte CLT-SFO
  #        =======================
  origin_destination_pair = flight_details.get('calcd_origin_destination_pair')
  if origin_destination_pair:
    lines.append(origin_destination_pair.upper())

  # LINE4: Dep: 08:18 (10m early)
  #        Dep: Unknown
  #        =======================
  departure_time_text = DictGetReplaceNone(flight_details, 'calcd_departure_time_text')
  if departure_time_text:
    delay_text = flight_details['calcd_delay_text']
    line = departure_time_text + delay_text
    lines.append(line.upper())

  # LINE5: Rem: 4h18m
  #        Rem: Unk
  #        =======================
  remaining_seconds = flight_details['calcd_remaining_seconds']
  if remaining_seconds is not None:
    line = 'Rem: ' + SecondsToHhMm(remaining_seconds)
  else:
    line = 'Rem: Unknown'
  lines.append(line.upper())

  # LINE6: 123mph 297deg D:1383ft
  #        =======================
  speed = flight_details.get('speed')
  heading = flight_details.get('track')

  if heading is None:
    line = str(speed) + 'mph'
  else:
    min_feet = flight_details['calcd_min_feet']
    line = '%d%s %sdeg D:%d%s' % (speed, SPEED_UNITS, heading, min_feet, DISTANCE_UNITS)
  lines.append(line.upper())

  # LINE7: Alt: 12345ft +1234fpm
  #        =======================
  altitude = flight_details.get('altitude')
  vert_rate = flight_details.get('vert_rate')

  line = ''
  if altitude:
    line += '%d%s' % (altitude, DISTANCE_UNITS)
  if vert_rate:
    line += ' (%+d%s)' % (vert_rate, CLIMB_RATE_UNITS)
  if line:
    lines.append(line.upper())

  return Screenify(lines)


def FlightInsightsTestHarness(flights, display=True):
  """Simulates what insightful messages might be displayed by replaying past flights."""

  messages = []
  for n in range(len(flights)):

    flight_message = CreateMessageAboutFlight(flights[n])
    if display:
      print(flight_message)
    messages.append(flight_message)

    interesting_things = FlightInsights(flights[:n+1])
    if display:
      for thing in interesting_things:
        print(thing)
    messages.extend(interesting_things)

  return messages


def FlightInsightLastSeen(flights, days_ago=2):
  """Generates string indicating when flight was last seen.

  Generates text of the following form.
  - KAL214 was last seen 2d0h ago

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    days_ago: the minimum time difference for which a message should be generated -
        i.e.: many flights are daily, and so we are not necessarily interested to see
        about every daily flight that it was seen yesterday. However, more infrequent
        flights might be of interest.

  Returns:
    List of printable string messages; if no messages or insights to generate, then
    the list will be empty.
  """
  messages = []
  this_flight = flights[-1]
  this_flight_number = this_flight['dump_flight_number']
  this_timestamp = flights[-1]['calcd_timestamp']
  last_seen = [f for f in flights[:-1] if f['dump_flight_number'] == this_flight_number]
  messages = []
  if last_seen and this_flight_number:
    last_timestamp = last_seen[-1]['calcd_timestamp']
    if this_timestamp - last_timestamp > days_ago*SECONDS_IN_DAY:
      messages = [
          '%s was last seen %s ago' %
          (this_flight_number.strip(), SecondsToDdHh(this_timestamp - last_timestamp))]
  return messages


def FlightInsightDifferentAircraft(flights, percent_size_difference=0.1):
  """Generates string indicating changes in aircraft for the most recent flight.

  Generates text of the following form for the "focus" flight in the data.
  - Last time ASA1964 was seen on Mar 16, it was with a much larger plane (Airbus A320
    (twin-jet) @ 123ft vs. Airbus A319 (twin-jet) @ 111ft)
  - Last time ASA743 was seen on Mar 19, it was with a different type of airpline
    (Boeing 737-900 (twin-jet) vs. Boeing 737-800 (twin-jet))

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    percent_size_difference: the minimum size (i.e.: length) difference for the insight
        to warrant including the size details.

  Returns:
    List of printable string messages; if no messages or insights to generate, then
    the list will be empty.
  """
  messages = []
  this_flight = flights[-1]
  this_flight_number = this_flight['dump_flight_number']
  last_seen = [f for f in flights[:-1] if f['dump_flight_number'] == this_flight_number]

  # Yesterday this same flight flew a materially different type of aircraft
  if last_seen and this_flight_number:
    last_flight = last_seen[-1]

    last_aircraft = last_flight.get('aircraft_type_friendly')
    last_aircraft_length = aircraft_length.get(last_aircraft, 0)

    this_aircraft = this_flight.get('aircraft_type_friendly')
    this_aircraft_length = aircraft_length.get(this_aircraft, 0)

    this_likely_commercial_flight = (
        this_flight.get('origin_iata') and this_flight.get('destination_iata'))
    if this_likely_commercial_flight and not this_aircraft_length:
      LogMessage('%s appears to be used in a commercial flight but yet is missing length'
                 'details' % this_aircraft, file=LOGFILE)

    likely_same_commercial_flight = (
        last_flight.get('origin_iata') == this_flight.get('origin_iata') and
        last_flight.get('destination_iata') == this_flight.get('destination_iata') and
        last_flight.get('airline_call_sign') == this_flight.get('airline_call_sign'))

    this_aircraft_bigger = False
    last_aircraft_bigger = False
    if (likely_same_commercial_flight and
        this_aircraft_length > last_aircraft_length * (1 + percent_size_difference)):
      this_aircraft_bigger = True
      comparative_text = 'larger'
    elif (likely_same_commercial_flight and
          last_aircraft_length > this_aircraft_length * (1 + percent_size_difference)):
      last_aircraft_bigger = True
      comparative_text = 'smaller'

    last_flight_time_string = datetime.datetime.fromtimestamp(
        last_flight['calcd_timestamp']).strftime('%b %-d')
    if this_aircraft_bigger or last_aircraft_bigger:
      messages = [
          'Last time %s was seen on %s, it was with a much %s '
          'plane (%s @ %dft vs. %s @ %dft)' % (
              this_flight_number.strip(), last_flight_time_string, comparative_text,
              this_aircraft, this_aircraft_length*FEET_IN_METER,
              last_aircraft, last_aircraft_length*FEET_IN_METER)]
    elif last_aircraft and this_aircraft and last_aircraft != this_aircraft:
      messages = [
          'Last time %s was seen on %s, it was with a different '
          'type of airpline (%s vs. %s)' % (
              this_flight_number.strip(), last_flight_time_string,
              last_aircraft, this_aircraft)]

  return messages


def FlightInsightNthFlight(flights, hours=1, min_multiple_flights=2):
  """Generates string about seeing many flights to the same destination in a short period.

  Generates text of the following form for the "focus" flight in the data.
  - ASA1337 was the 4th flight to PHX in the last 53 minutes, served by Alaska Airlines,
    American Airlines, Southwest and United
  - SWA3102 was the 2nd flight to SAN in the last 25 minutes, both with Southwest

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    hours: the time horizon over which to look for flights with the same destination.
    min_multiple_flights: the minimum number of flights to that same destination to
        warrant generating an insight.

  Returns:
    List of printable string messages; if no messages or insights to generate, then
    the list will be empty.
  """
  messages = []
  this_flight = flights[-1]
  this_flight_number = DictGetReplaceNone(this_flight, 'dump_flight_number').strip()
  this_destination = this_flight.get('destination_iata', '')
  this_airline = DictGetReplaceNone(
      this_flight, 'airline_short_name', default_value=KEY_NOT_PRESENT_STRING)
  if not this_airline:
    this_airline = KEY_NOT_PRESENT_STRING # in case airline was stored as, say, ''
  this_timestamp = this_flight['calcd_timestamp']
  if this_destination and this_destination not in ['SFO', 'LAX']:
    similar_flights = [f for f in flights[:-1] if
                       this_timestamp - f['calcd_timestamp'] < SECONDS_IN_HOUR*hours and
                       this_destination == f.get('destination_iata', '')]
    similar_flights_count = len(similar_flights) + 1  # +1 for this_flight
    similar_flights_airlines = list(set([
        DictGetReplaceNone(f, 'airline_short_name', default_value=KEY_NOT_PRESENT_STRING)
        for f in similar_flights]))

    same_airline = [this_airline] == similar_flights_airlines

    if similar_flights_count >= min_multiple_flights:
      ordinal = lambda n: '%d%s' % (
          n, 'tsnrhtdd'[(math.floor(n/10)%10 != 1)*(n%10 < 4)*n%10::4])
      n_minutes = (
          (this_flight['calcd_timestamp'] - similar_flights[0]['calcd_timestamp'])
          / SECONDS_IN_MINUTE)
      message = ('%s was the %s flight to %s in the last %d minutes' % (
          this_flight_number, ordinal(similar_flights_count),
          this_destination, n_minutes))
      if same_airline and similar_flights_count == 2:
        message += ', both with %s' % this_airline
      elif same_airline:
        message += ', all with %s' % this_airline
      else:
        similar_flights_airlines.append(this_airline)
        similar_flights_airlines.sort()
        message += ', served by %s and %s' % (
            ', '.join(similar_flights_airlines[:-1]),
            similar_flights_airlines[-1])
      messages = [message]

  return messages


def FlightInsightSuperlativeAttribute(
    flights,
    key,
    label,
    units,
    absolute_list,
    insight_min=True,
    insight_max=True,
    hours=24):
  """Generates string about a numeric attribute of the flight being an extreme value.

  Generates text of the following form for the "focus" flight in the data.
  - N5286C has the slowest groundspeed (113mph vs. 163mph) in last 24 hours
  - CKS828 has the highest altitude (40000ft vs. 16575ft) in last 24 hours

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    key: the key of the attribute of interest - i.e.: 'speed'.
    label: the human-readable string that should be displayed in the message - i.e.:
        'groundspeed'.
    units: the string units that should be used to label the value of the key - i.e.:
        'MPH'.
    absolute_list: a 2-tuple of strings that is used to label the min and the max - i.e.:
        ('lowest', 'highest'), or ('slowest', 'fastest').
    insight_min: boolean indicating whether to generate an insight about the min value.
    insight_max: boolean indicating whether to generate an insight about the max value.
    hours: the time horizon over which to look for superlative flights.

  Returns:
    List of printable string messages; if no messages or insights to generate, then
    the list will be empty.
  """
  messages = []
  this_flight = flights[-1]
  this_flight_number = DictGetReplaceNone(
      this_flight, 'dump_flight_number', 'The last flight').strip()
  first_timestamp = flights[0]['calcd_timestamp']
  last_timestamp = flights[-1]['calcd_timestamp']
  included_seconds = last_timestamp - first_timestamp

  if included_seconds > SECONDS_IN_HOUR * hours:
    relevant_flights = [
        f for f in flights[:-1]
        if last_timestamp - f['calcd_timestamp'] < SECONDS_IN_HOUR * hours]
    value_min = min([DictGetReplaceNone(
        f, key, default_value=float('inf')) for f in relevant_flights])
    value_max = max([DictGetReplaceNone(
        f, key, default_value=float('-inf')) for f in relevant_flights])
    values_other = len(
        [1 for f in relevant_flights if isinstance(f.get(key), numbers.Number)])

    this_value = this_flight.get(key)

    if this_value and values_other:

      superlative = True
      if this_value > value_max and insight_max:
        absolute_string = absolute_list[1]
        other_value = value_max
      elif this_value < value_min and insight_min:
        absolute_string = absolute_list[0]
        other_value = value_min
      else:
        superlative = False

      if superlative:
        messages.append('%s has the %s %s (%d%s vs. %d%s) in last %d hours' % (
            this_flight_number, absolute_string, label,
            this_value, units, other_value, units, hours))

  return messages


def PercentileScore(scores, value):
  """Returns the percentile that a particular value is in a list of numbers.

  Roughly inverts numpy.percentile. That is, numpy.percentile(scores_list, percentile)
  to get the value of the list that is at that percentile;
  PercentileScore(scores_list, value) will yield back approximately that percentile.

  If the value matches identical elements in the list, this function takes the average
  position of those identical values to compute a percentile. Thus, for some lists
  (i.e.: where there are lots of flights that have a 0 second delay, or a 100% delay
  frequency), you may not get a percentile of 0 or 100 even with values equal to the
  min or max element in the list.

  Args:
    scores: the list of numbers.
    value: the value for which we want to determine the percentile.

  Returns:
    Returns an integer percentile in the range [0, 100] inclusive.
  """
  scores = sorted(list(scores))
  count_values_below_score = len([1 for s in scores if s < value])
  count_values_at_score = len([1 for s in scores if s == value])
  percentile = (count_values_below_score + count_values_at_score / 2) / len(scores)
  return round(percentile*100)


def FlightInsightGroupPercentile(
    flights,
    group_function,
    value_function,
    value_string_function,
    group_label,
    value_label,
    min_days=1,
    lookback_days=30,
    min_this_group_size=0,
    min_comparison_group_size=0,
    min_group_qty=0,
    percentile_low=10,
    percentile_high=90):
  """Generates a string about extreme values of groups of flights.

  Generates text of the following form for the "focus" flight in the data.
  - flight SIA31 (n=7) has a delay frequency in the 95th %tile, with 100% of flights
    delayed an average of 6m over the last 4d1h
  - flight UAL300 (n=5) has a delay time in the 1st %tile, with an average delay of 0m
  over the last 4d5h

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    group_function: function that, when called with a flight, returns the grouping key.
        That is, for example, group_function(flight) = 'B739'
    value_function: function that, when called with a list of flights, returns the
        value to be used for the comparison to identify min / max. Typically, the count,
        but could also be a sum, standard deviation, etc. - for perhaps the greatest
        range in flight altitude. If the group does not have a valid value and so
        should be excluded from comparison - i.e.: average delay of a group of flights
        which did not have a calculable_delay on any flight, this function should
        return None.
    value_string_function: function that, when called with the two parameters flights
        and value, returns a string (inclusive of units and label) that should be
        displayed to describe the quantity. For instance, if value_function returns
        seconds, value_string_function could convert that to a string '3h5m'. Or if
        value_function returns an altitude range, value_string_function could return
        a string 'altitude range of 900ft (1100ft - 2000ft)'.
    group_label: string to identify the group type - i.e.: 'aircraft' or 'flight' in
        the examples above.
    value_label: string to identify the value - i.e.: 'flights' in the examples above,
        but might also be i.e.: longest *delay*, or other quantity descriptor.
    min_days: the minimum amount of history required to start generating insights
        about delays.
    lookback_days: the maximum amount of history which will be considered in generating
        insights about delays.
    min_this_group_size: even if this group has, say, the maximum average delay, if its
        a group of size 1, that is not necessarily very interesting. This sets the
        minimum group size for the focus flight.
    min_comparison_group_size: similarly, comparing the focus group to groups of size
        one does not necessarily produce a meaningful comparison; this sets to minimum
        size for the other groups.
    min_group_qty: when generating a percentile, if there are only 3 or 4 groups among
        which to generate a percentile (i.e.: only a handful of destinations have been
        seen so far, etc.) then it is not necessarily very interesting to generate a
        message; this sets the minimum quantity of groups necessary (including the
        focus group) to generate a message.
    percentile_low: number [0, 100] inclusive that indicates the percentile that the focus
        flight group must equal or be less than for the focus group to trigger an insight;
        if None, no high percentile insight will be generated.
    percentile_high: number [0, 100] inclusive that indicates the percentile that the
        focus flight group must equal or be greater than for the focus group to trigger an
        insight; if None, no high percentile insight will be generated.

  Returns:
    List of printable string messages; if no messages or insights to generate, then
    the list will be empty.
  """
  messages = []
  first_timestamp = flights[0]['calcd_timestamp']
  last_timestamp = flights[-1]['calcd_timestamp']
  included_seconds = last_timestamp - first_timestamp

  if included_seconds > SECONDS_IN_DAY * min_days:

    relevant_flights = [
        f for f in flights if
        last_timestamp - f['calcd_timestamp'] < SECONDS_IN_DAY * lookback_days]

    grouped_flights = {}
    for flight in relevant_flights:
      group = group_function(flight)
      grouping = grouped_flights.get(group, [])
      grouping.append(flight)
      grouped_flights[group] = grouping

    grouped_values = {g: value_function(grouped_flights[g]) for g in grouped_flights}
    this_group = group_function(relevant_flights[-1])
    this_value = grouped_values[this_group]
    # Remove those for which no value could be calculated
    grouped_values = {
        g: grouped_values[g] for g in grouped_values if grouped_values[g] is not None}

    min_value_percentile = float('-inf')
    if percentile_low is not None:
      min_value_percentile = numpy.percentile(
          list(grouped_values.values()),
          percentile_low)

    max_value_percentile = float('inf')
    if percentile_high is not None:
      max_value_percentile = numpy.percentile(
          list(grouped_values.values()),
          percentile_high)

    if this_value and len(grouped_values) > min_group_qty:

      this_group_size = len(grouped_flights[this_group])

      time_horizon_string = SecondsToDdHh(
          last_timestamp - relevant_flights[0]['calcd_timestamp'])
      min_comparison_group_size_string = ''
      if min_comparison_group_size > 1:
        min_comparison_group_size_string = ' amongst %s with at least %d flights' % (
            group_label, min_comparison_group_size)

      ordinal = lambda n: '%d%s' % (
          n, 'tsnrhtdd'[(math.floor(n/10)%10 != 1)*(n%10 < 4)*n%10::4])

      # FLIGHT X (n=7) is has the Xth percentile of DELAYS, with an average delay of
      # 80 MINUTES
      if this_group_size > min_this_group_size and (
          this_value <= min_value_percentile or this_value >= max_value_percentile):
        messages = [
            '%s %s (n=%d) has a %s in the %s %%tile, with %s over the last %s%s' % (
                group_label,
                this_group,
                this_group_size,
                value_label,
                ordinal(PercentileScore(grouped_values.values(), this_value)),
                value_string_function(grouped_flights[this_group], this_value),
                time_horizon_string,
                min_comparison_group_size_string)]

  return messages


def FlightInsightSuperlativeGroup(
    flights,
    group_function,
    value_function,
    value_string_function,
    group_label,
    value_label,
    absolute_list,
    min_days=1,
    lookback_days=30,
    min_this_group_size=0,
    min_comparison_group_size=0,
    insight_min=True,
    insight_max=True):
  """Generates a string about extreme values of groups of flights.

  Generates text of the following form for the "focus" flight in the data.
  - aircraft B739 (n=7) is tied with B738 and A303 for the most flights at 7 flights
    over the last 3d7h amongst aircraft with a least 5 flights
  - aircraft B739 (n=7) is tied with 17 others for the most flights at 7 flights over
    the last 3d7h amongst aircraft with a least 5 flights
  - flight UAL1075 (n=12) has the most flights with 12 flights; the next most flights
    is 11 flights over the last 7d5h

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    group_function: function that, when called with a flight, returns the grouping key.
        That is, for example, group_function(flight) = 'B739'
    value_function: function that, when called with a list of flights, returns the
        value to be used for the comparison to identify min / max. Typically, the count,
        but could also be a sum, standard deviation, etc. - for perhaps the greatest
        range in flight altitude. If the group does not have a valid value and so
        should be excluded from comparison - i.e.: average delay of a group of flights
        which did not have a calculable_delay on any flight, this function should
        return None.
    value_string_function: function that, when called with the two parameters flights
        and value, returns a string (inclusive of units and label) that should be
        displayed to describe the quantity. For instance, if value_function returns
        seconds, value_string_function could convert that to a string '3h5m'. Or if
        value_function returns an altitude range, value_string_function could return
        a string 'altitude range of 900ft (1100ft - 2000ft)'.
    group_label: string to identify the group type - i.e.: 'aircraft' or 'flight' in
        the examples above.
    value_label: string to identify the value - i.e.: 'flights' in the examples above,
        but might also be i.e.: longest *delay*, or other quantity descriptor.
    absolute_list: a 2-tuple of strings that is used to label the min and the max - i.e.:
        ('most', 'least'), or ('lowest average', 'highest average').
    min_days: the minimum amount of history required to start generating insights
        about delays.
    lookback_days: the maximum amount of history which will be considered in generating
        insights about delays.
    min_this_group_size: even if this group has, say, the maximum average delay, if its
        a group of size 1, that is not necessarily very interesting. This sets the
        minimum group size for the focus flight.
    min_comparison_group_size: similarly, comparing the focus group to groups of size
        one does not necessarily produce a meaningful comparison; this sets to minimum
        size for the other groups.
    insight_min: boolean indicating whether to possibly generate insight based on the
        occurrence of the min value.
    insight_max: boolean indicating whether to possibly generate insight based on the
        occurrence of the max value.

  Returns:
    List of printable string messages; if no messages or insights to generate, then
    the list will be empty.
  """
  messages = []
  first_timestamp = flights[0]['calcd_timestamp']
  last_timestamp = flights[-1]['calcd_timestamp']
  included_seconds = last_timestamp - first_timestamp

  if included_seconds > SECONDS_IN_DAY * min_days:

    relevant_flights = [
        f for f in flights if
        last_timestamp - f['calcd_timestamp'] < SECONDS_IN_DAY * lookback_days]

    grouped_flights = {}
    for flight in relevant_flights:
      group = group_function(flight)
      grouping = grouped_flights.get(group, [])
      grouping.append(flight)
      grouped_flights[group] = grouping

    grouped_values = {g: value_function(grouped_flights[g]) for g in grouped_flights}

    grouped_values = {g: value_function(grouped_flights[g]) for g in grouped_flights}
    this_group = group_function(relevant_flights[-1])
    this_value = grouped_values[this_group]
    # Remove those for which no value could be calculated
    grouped_values = {
        g: grouped_values[g] for g in grouped_values if grouped_values[g] is not None}

    other_values = list(grouped_values.values())
    other_values = [v for v in other_values if v > min_comparison_group_size]
    if this_value in other_values:
      other_values.remove(this_value)

    if other_values:
      min_value = min(other_values)
      max_value = max(other_values)

      if this_value:
        if this_value > max_value and insight_max:
          superlative = True
          equality = False
          superlative_string = absolute_list[1]
          next_value = max_value
        elif this_value == max_value and insight_max:
          superlative = False
          equality = True
          superlative_string = absolute_list[1]
        elif this_value < min_value and insight_min:
          superlative = True
          equality = False
          superlative_string = absolute_list[0]
          next_value = min_value
        elif this_value == min_value and insight_min:
          superlative = False
          equality = True
          superlative_string = absolute_list[0]
        else:
          superlative = False
          equality = False

        this_group_size = len(grouped_flights[this_group])
        time_horizon_string = SecondsToDdHh(
            last_timestamp - relevant_flights[0]['calcd_timestamp'])
        min_comparison_group_size_string = ''
        if min_comparison_group_size > 1:
          min_comparison_group_size_string = (
              ' amongst %s with at least %d flights' %
              (group_label, min_comparison_group_size))

        # flight x (n=7) is tied with a, b, and c for the (longest average, shortest
        # average) delay at 80 minutes
        # flight x is tied with a, b, and c for the (most frequent, least frequent)
        # delay at 30%
        if equality and this_group_size > min_this_group_size:

          identical_groups = sorted([
              str(g) for g in grouped_values
              if grouped_values[g] == this_value and g != this_group])
          if len(identical_groups) > 4:
            identical_string = '%d others' % len(identical_groups)
          elif len(identical_groups) > 1:
            identical_string = (
                '%s and %s' % (', '.join(identical_groups[:-1]), identical_groups[-1]))
          else:
            identical_string = str(identical_groups[0])

          messages = [
              '%s %s (n=%d) is tied with %s for the %s %s at %s over the last %s%s' % (
                  group_label,
                  this_group,
                  this_group_size,
                  identical_string,
                  superlative_string,
                  value_label,
                  value_string_function(flights, this_value),
                  time_horizon_string,
                  min_comparison_group_size_string)]

        elif superlative and this_group_size > min_this_group_size:
          messages = [
              '%s %s (n=%d) has the %s %s with %s; the next '
              '%s %s is %s over the last %s%s' % (
                  group_label,
                  this_group,
                  this_group_size,
                  superlative_string,
                  value_label,
                  value_string_function(flights, this_value),
                  superlative_string,
                  value_label,
                  value_string_function(flights, next_value),
                  time_horizon_string,
                  min_comparison_group_size_string)]

  return messages


def AverageDelay(flights):
  """Returns the average delay time for a list of flights.

  Args:
    flights: the list of the raw flight data.

  Returns:
    Average seconds of flight delay, calculated as the total seconds delayed amongst
    all the flights that have a positive delay, divided by the total number of flights
    that have a calculable delay. If no flights have a calculable delay, returns None.
  """
  delay_seconds = [
      f['calcd_delay_seconds']
      if f['calcd_delay_seconds'] > 0 else 0
      for f in flights if f['calcd_calculable_delay']]
  average_delay = None
  if delay_seconds:
    average_delay = sum(delay_seconds) / len(delay_seconds)
  return average_delay


def PercentDelay(flights):
  """Returns the percentage of flights that have a positive delay for a list of flights.

  Args:
    flights: the list of the raw flight data.

  Returns:
    Percentage of flights with a delay, calculated as the count of flights with a
    positive delay divided by the total number of flights that have a calculable delay.
    If no flights have a calculable delay, returns None.
  """
  calculable_delay_seconds = [
      f['calcd_delay_seconds'] for f in flights if f['calcd_calculable_delay']]
  delay_count = sum([1 for s in calculable_delay_seconds if s > 0])
  percent_delay = None
  if calculable_delay_seconds:
    percent_delay = delay_count / len(calculable_delay_seconds)
  return percent_delay


def FlightInsightFirstInstance(
    flights,
    key,
    label,
    days=7,
    additional_descriptor_fcn=''):
  """Generates string indicating the flight has the first instance of a particular key.

  Generates text of the following form for the "focus" flight in the data.
  - N311CG is the first time aircraft GLF6 (Gulfstream Aerospace Gulfstream G650
    (twin-jet)) has been seen since at least 7d5h ago
  - PCM8679 is the first time airline Westair Industries has been seen since 9d0h ago

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    key: the key of the attribute of interest - i.e.: 'destination_iata'.
    label: the human-readable string that should be displayed in the message - i.e.:
        'destination'.
    days: the minimum time of interest for an insight - i.e.: we probably see LAX every
        hour, but we are only interested in particular attributes that have not been
        seen for at least some number of days. Note, however, that the code will go back
        even further to find the last time that attribute was observed, or if never
        observed, indicating "at least".
    additional_descriptor_fcn: a function that, when passed a flight, returns an
        additional parenthetical notation to include about the attribute or flight
        observed - such as expanding the IATA airport code to its full name, etc.

  Returns:
    List of printable string messages; if no messages or insights to generate, then
    the list will be empty.
  """
  messages = []
  this_flight = flights[-1]
  this_flight_number = this_flight['dump_flight_number']
  first_timestamp = flights[0]['calcd_timestamp']
  last_timestamp = flights[-1]['calcd_timestamp']
  included_seconds = last_timestamp - first_timestamp

  if included_seconds > SECONDS_IN_DAY * days:
    this_instance = DictGetReplaceNone(this_flight, key)
    matching = [f for f in flights[:-1] if DictGetReplaceNone(f, key) == this_instance]

    last_potential_observation_sec = included_seconds
    if matching:
      last_potential_observation_sec = last_timestamp - matching[-1]['calcd_timestamp']

    if this_instance and last_potential_observation_sec > SECONDS_IN_DAY * days:
      additional_descriptor = ''
      if additional_descriptor_fcn:
        additional_descriptor = ' (%s)' % additional_descriptor_fcn(this_flight)
      last_potential_observation_string = SecondsToDdHh(last_potential_observation_sec)
      if matching:
        messages.append(
            '%s is the first time %s %s%s has been seen since %s ago' %
            (this_flight_number, label, this_instance, additional_descriptor,
             last_potential_observation_string))
      else:
        messages.append(
            '%s is the first time %s %s%s has been seen since at least %s ago' %
            (this_flight_number, label, this_instance, additional_descriptor,
             last_potential_observation_string))

  return messages


def FlightInsightSuperlativeVertrate(flights, hours=24):
  """Generates string about the climb rate of the flight being an extreme value.

  Generates text of the following form for the "focus" flight in the data.
  - UAL631   has the fastest ascent rate (5248fpm, 64fpm faster than next fastest) in
    last 24 hours
  - CKS1820 has the fastest descent rate (-1152fpm, -1088fpm faster than next fastest)
    in last 24 hours

  While this is conceptually similar to the more generic FlightInsightSuperlativeVertrate
  function, vert_rate - because it can be either positive or negative, with different
  signs requiring different labeling and comparisons - it needs its own special handling.

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    hours: the time horizon over which to look for superlative flights.

  Returns:
    List of printable string messages; if no messages or insights to generate, then
    the list will be empty.
  """
  messages = []
  this_flight = flights[-1]
  this_flight_number = this_flight['dump_flight_number']
  first_timestamp = flights[0]['calcd_timestamp']
  last_timestamp = flights[-1]['calcd_timestamp']
  sufficient_data = (last_timestamp - first_timestamp) > SECONDS_IN_HOUR * hours
  pinf = float('inf')
  ninf = float('-inf')

  if sufficient_data:
    relevant_flights = [
        f for f in flights[:-1]
        if last_timestamp - f['calcd_timestamp'] < SECONDS_IN_HOUR * hours]

    def AscentRate(f, default):
      vert_rate = DictGetReplaceNone(f, 'vert_rate', default_value=default)
      if vert_rate < 0:
        vert_rate = default
      return vert_rate

    other_ascents = len([
        1 for f in relevant_flights
        if isinstance(f.get('vert_rate'), numbers.Number) and AscentRate(f, ninf) > 0])
    if other_ascents:
      ascent_min = min(
          [AscentRate(f, pinf) for f in relevant_flights if AscentRate(f, ninf) > 0])
      ascent_max = max(
          [AscentRate(f, ninf) for f in relevant_flights if AscentRate(f, ninf) > 0])

    def DescentRate(f, default):
      vert_rate = DictGetReplaceNone(f, 'vert_rate', default_value=default)
      if vert_rate > 0:
        vert_rate = default
      return vert_rate

    other_descents = len([
        1 for f in relevant_flights
        if isinstance(f.get('vert_rate'), numbers.Number) and DescentRate(f, pinf) < 0])
    if other_descents:
      descent_min = min(
          [DescentRate(f, pinf) for f in relevant_flights if DescentRate(f, pinf) < 0])
      descent_max = max(
          [DescentRate(f, ninf) for f in relevant_flights if DescentRate(f, pinf) < 0])

    this_vert_rate = this_flight.get('vert_rate')

    if this_vert_rate is not None and this_vert_rate >= 0:
      this_ascent = this_vert_rate
      this_descent = None
    else:
      this_descent = this_vert_rate
      this_ascent = None

    if this_ascent and other_ascents and this_ascent > ascent_max:
      messages.append('%s has the fastest ascent rate (%d%s, %d%s faster '
                      'than next fastest) in last %d hours' % (
                          this_flight_number, this_ascent, CLIMB_RATE_UNITS,
                          this_ascent - ascent_max, CLIMB_RATE_UNITS, hours))
    elif this_ascent and other_ascents and this_ascent < ascent_min:
      messages.append('%s has the slowest ascent rate (%d%s, %d%s slower '
                      'than next slowest) in last %d hours' % (
                          this_flight_number, this_ascent, CLIMB_RATE_UNITS,
                          ascent_min - this_ascent, CLIMB_RATE_UNITS, hours))
    elif this_descent and other_descents and this_descent < descent_min:
      messages.append('%s has the fastest descent rate (%d%s, %d%s faster '
                      'than next fastest) in last %d hours' % (
                          this_flight_number, this_descent, CLIMB_RATE_UNITS,
                          this_descent - descent_min, CLIMB_RATE_UNITS, hours))
    elif this_descent and other_descents and this_descent > descent_max:
      messages.append('%s has the slowest descent rate (%d%s, %d%s slower '
                      'than next slowest) in last %d hours' % (
                          this_flight_number, this_descent, CLIMB_RATE_UNITS,
                          descent_max - this_descent, CLIMB_RATE_UNITS, hours))

  return messages


def FlightInsightDelays(
    flights,
    min_days=1,
    lookback_days=30,
    min_late_percentage=0.75,
    min_this_delay_minutes=0,
    min_average_delay_minutes=0):
  """Generates string about the delays this flight has seen in the past.

  Only if this flight has a caclculable delay itself, this will generate text of the
  following form for the "focus" flight in the data.
  - This 8m delay is the longest UAL1175 has seen in the last 9 days (avg delay is 4m);
    overall stats: 1 early; 9 late; 10 total
  - With todays delay of 7m, UAL1175 is delayed 88% of the time in the last 8 days for
    avg delay of 4m; overall stats: 1 early; 8 late; 9 total

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    min_days: the minimum amount of history required to start generating insights
        about delays.
    lookback_days: the maximum amount of history which will be considered in generating
        insights about delays.
    min_late_percentage: flights that are not very frequently delayed are not
        necessarily very interesting to generate insights about; this specifies the
        minimum percentage the flight must be late to generate a message that focuses
        on the on-time percentage.
    min_this_delay_minutes: a delay of 1 minute is not necessarily interesting; this
        specifies the minimum delay time this instance of the flight must be late to
        generate a message that focuses on this flight's delay.
    min_average_delay_minutes: an average delay of only 1 minute, even if it happens
        every day, is not necessarily very interesting; this specifies the minimum
        average delay time to generate either type of delay message.

  Returns:
    List of printable string messages; if no messages or insights to generate, then
    the list will be empty.
  """
  messages = []
  this_flight = flights[-1]
  this_flight_number = this_flight.get('dump_flight_number', '')
  first_timestamp = flights[0]['calcd_timestamp']
  last_timestamp = flights[-1]['calcd_timestamp']
  included_seconds = last_timestamp - first_timestamp

  if (included_seconds > SECONDS_IN_DAY * min_days
      and this_flight['calcd_calculable_delay']):
    this_delay_seconds = this_flight['calcd_delay_seconds']
    relevant_flights = [
        f for f in flights if
        last_timestamp - f['calcd_timestamp'] < SECONDS_IN_DAY * lookback_days and
        this_flight_number == f.get('dump_flight_number', '')]

    if (
        len(relevant_flights) > 1 and
        this_delay_seconds >= min_this_delay_minutes*SECONDS_IN_MINUTE):
      delay_seconds_list = [
          f['calcd_delay_seconds'] for f in relevant_flights
          if f.get('calcd_calculable_delay')]
      delay_unknown_count = len(relevant_flights) - len(delay_seconds_list)
      delay_ontime_count = len([d for d in delay_seconds_list if not d])
      delay_early_count = len([d for d in delay_seconds_list if d < 0])
      delay_late_count = len([d for d in delay_seconds_list if d > 0])

      delay_late_avg_sec = 0
      delay_late_max_sec = 0

      superlative = False
      if delay_late_count > 1:
        delay_late_avg_sec = sum(
            [d for d in delay_seconds_list if d > 0]) / delay_late_count

        # max / min excluding this flight
        delay_late_max_sec = max([d for d in delay_seconds_list[:-1] if d > 0])
        delay_late_min_sec = min([d for d in delay_seconds_list[:-1] if d > 0])

        if delay_late_max_sec > 0:
          if this_delay_seconds > delay_late_max_sec:
            delay_keyword = 'longest'
            superlative = True
          if this_delay_seconds < delay_late_min_sec:
            delay_keyword = 'shortest'
            superlative = True

      overall_stats_elements = []
      if delay_early_count:
        overall_stats_elements.append('%d early' % delay_early_count)
      if delay_ontime_count:
        overall_stats_elements.append('%d ontime' % delay_ontime_count)
      if delay_late_count:
        overall_stats_elements.append('%d late' % delay_late_count)
      if delay_unknown_count:
        overall_stats_elements.append('%d unknown' % delay_unknown_count)
      overall_stats_elements.append('%d total' % len(relevant_flights))
      overall_stats_text = '; '.join(overall_stats_elements)

      days_history = (int(
          round(last_timestamp - relevant_flights[0]['calcd_timestamp']) / SECONDS_IN_DAY)
                      + 1)

      late_percentage = delay_late_count / len(relevant_flights)

      if (superlative and
          delay_late_avg_sec >= min_average_delay_minutes * SECONDS_IN_MINUTE):
        messages = [
            'This %s delay is the %s %s has seen in the last %d days (avg delay is %s);'
            ' overall stats: %s' % (
                SecondsToHhMm(this_delay_seconds),
                delay_keyword,
                this_flight_number.strip(),
                days_history,
                SecondsToHhMm(delay_late_avg_sec),
                overall_stats_text)]
      elif (late_percentage > min_late_percentage and
            delay_late_avg_sec >= min_average_delay_minutes * SECONDS_IN_MINUTE):
        # it's just been delayed frequently!
        messages = [
            'With today''s delay of %s, %s is delayed %d%% of the time in the last %d '
            'days for avg delay of %s; overall stats: %s' % (
                SecondsToHhMm(this_delay_seconds),
                this_flight_number.strip(),
                int(100 * late_percentage),
                days_history,
                SecondsToHhMm(delay_late_avg_sec),
                overall_stats_text)]
  return messages


def FlightInsights(flights):
  """Identifies an interesting attribute or pattern about the most recently seen flight.

  Generates a possibly-empty list of messages about the flight.

  Args:
    flights: List of all flights where the last flight in the list is the focus flight
        for which we are trying to identify something interesting.

  Returns:
    List of printable strings (with embedded new line characters) for something
    interesting about the flight; if there isn't anything interesting, returns an empty
    list.
  """
  messages = []

  # This flight number was last seen x days ago
  messages.extend(FlightInsightLastSeen(flights, days_ago=2))

  # Yesterday this same flight flew a materially different type of aircraft
  messages.extend(FlightInsightDifferentAircraft(flights, percent_size_difference=0.1))

  # This is the 3rd flight to the same destination in the last hour
  messages.extend(FlightInsightNthFlight(flights, hours=1, min_multiple_flights=2))

  # This is the [lowest / highest] [speed / altitude / climbrate] in the last 24 hours
  messages.extend(FlightInsightSuperlativeAttribute(
      flights, 'speed', 'groundspeed', SPEED_UNITS, ['slowest', 'fastest'], hours=24))
  messages.extend(FlightInsightSuperlativeAttribute(
      flights, 'altitude', 'altitude', DISTANCE_UNITS, ['lowest', 'highest'], hours=24))
  messages.extend(FlightInsightSuperlativeVertrate(flights))

  # First instances: destination, first aircraft, etc.
  messages.extend(FlightInsightFirstInstance(
      flights, 'destination_iata', 'destination', days=7,
      additional_descriptor_fcn=lambda f: f['destination_friendly']))
  messages.extend(FlightInsightFirstInstance(
      flights, 'origin_iata', 'origin', days=7,
      additional_descriptor_fcn=lambda f: f['origin_friendly']))
  messages.extend(FlightInsightFirstInstance(
      flights, 'airline_short_name', 'airline', days=7))
  messages.extend(FlightInsightFirstInstance(
      flights, 'aircraft_type_code', 'aircraft', days=7,
      additional_descriptor_fcn=lambda f: f['aircraft_type_friendly']))

  # This is the longest / shortest delay this flight has seen in the last 30 days at
  # 2h5m; including today, this flight has been delayed x of the last y times.
  messages.extend(FlightInsightDelays(
      flights, min_late_percentage=0.75,
      min_this_delay_minutes=0,
      min_average_delay_minutes=0))

  # flight UAL1 (n=5) has a delay frequency in the 72nd %tile, with 100% of flights
  # delayed an average of 44m over the last 4d13h
  messages.extend(FlightInsightGroupPercentile(
      flights,
      group_function=lambda flight: DictGetReplaceNone(
          flight, 'dump_flight_number', KEY_NOT_PRESENT_STRING).strip(),
      value_function=PercentDelay,
      value_string_function=
      lambda flights, value: '%d%% of flights delayed an average of %s'
      % (round(value*100), SecondsToHhMm(AverageDelay(flights))),
      group_label='flight',
      value_label='delay frequency',
      min_days=1,
      min_this_group_size=4,
      min_comparison_group_size=0,
      min_group_qty=0,
      lookback_days=30,
      percentile_low=None,
      percentile_high=70))

  # flight UAL1 (n=5) has a delay time in the 90th %tile, with average delay of
  # 2h17m over the last 4d13h
  messages.extend(FlightInsightGroupPercentile(
      flights,
      group_function=lambda flight: DictGetReplaceNone(
          flight, 'dump_flight_number', KEY_NOT_PRESENT_STRING).strip(),
      value_function=AverageDelay,
      value_string_function=
      lambda flights, value: 'average delay of %s' % SecondsToHhMm(value),
      group_label='flight',
      value_label='delay time',
      min_days=1,
      min_this_group_size=4,
      min_comparison_group_size=0,
      min_group_qty=0,
      lookback_days=30,
      percentile_low=10,
      percentile_high=90))

  messages = [
      Screenify(textwrap.wrap(t.upper(), width=SPLITFLAP_CHARS_PER_LINE))
      for t in messages]

  return messages


def MessageboardHistogramsTestHarness():
  """Test harness to generate messageboard histograms."""
  flights = UnpickleFlights(PICKLEFILE_30D)
  messages = MessageboardHistograms(flights, 'aircraft', '30d', 'all', False)
  for message in messages:
    print(message)


def HistogramSettingsHours(how_much_history):
  """Extracts the desired history (in hours) from the histogram configuration string.

  Args:
    how_much_history: string from the histogram config file.

  Returns:
    Number of hours of history to include in the histogram.
  """
  if how_much_history == 'today':
    hours = HoursSinceMidnight()
  elif how_much_history == '24h':
    hours = 24
  elif how_much_history == '7d':
    hours = 7 * HOURS_IN_DAY
  elif how_much_history == '30d':
    hours = 30 * HOURS_IN_DAY
  else:




                            <----SKIPPED LINES---->




  Args:
    max_screens: string from the histogram config file.

  Returns:
    Number of maximum number of screens to display for a splitflap histogram.
  """
  if max_screens == '_1':
    screen_limit = 1
  elif max_screens == '_2':
    screen_limit = 2
  elif max_screens == '_5':
    screen_limit = 5
  elif max_screens == 'all':
    screen_limit = 0  # no limit on screens
  else:
    LogMessage('Histogram form has invalid value for max_screens: %s' % max_screens)
    screen_limit = 1
  return screen_limit


def HistogramSettingsKeySortTitle(which, max_altitude=45000, max_distance=3300):
  """Provides the arguments necessary to generate a histogram from the config string.

  The same parameters are used to generate either a splitflap text or web-rendered
  histogram in terms of the histogram title, the keyfunction, and how to sort the keys.
  For a given histogram name (based on the names defined in the histogram config file),
  this provides those parameters.

  Args:
    which: string from the histogram config file indicating the histogram to provide
        settings for.
    max_altitude: indicates the maximum altitude that should be included on the
        altitude labels.
    max_distance: indicates the maximum distance that should be included on the
        distance labels.

  Returns:
    A 3-tuple of the parameters used by either CreateSingleHistogramChart or
    MessageboardHistogram, of the keyfunction, sort, and title.
  """





  if which == 'destination':
    key = lambda k: k.get('destination_iata', KEY_NOT_PRESENT_STRING)
    sort = 'value'
    title = 'Destination'
  elif which == 'origin':
    key = lambda k: k.get('origin_iata', KEY_NOT_PRESENT_STRING)
    sort = 'value'
    title = 'Origin'
  elif which == 'hour':
    key = lambda k: datetime.datetime.fromtimestamp(
        k.get('calcd_timestamp', KEY_NOT_PRESENT_STRING), TZ).strftime('%H')
    sort = 'key'
    title = 'Hour'
  elif which == 'airline':
    key = lambda k: k.get('airline_short_name', KEY_NOT_PRESENT_STRING)
    sort = 'value'
    title = 'Airline'
  elif which == 'aircraft':
    key = lambda k: k.get('aircraft_type_code', KEY_NOT_PRESENT_STRING)
    sort = 'value'
    title = 'Aircraft'
  elif which == 'altitude':
    key = lambda k: '%2d'%int(k['altitude']/1000)
    sort = ['%2d'%x for x in range(0, int((max_altitude+1)/1000))]
    title = 'Altitude (1000s of ft)'
  elif which == 'bearing':
    key = lambda k: ConvertBearingToCompassDirection(k['track'], pad=True, length=3)

    sort = [d.rjust(3) for d in DIRECTIONS_16]
    title = 'Bearing'
  elif which == 'distance':
    key = lambda k: '%2d'%int(k['calcd_min_feet']/100)
    sort = ['%2d'%x for x in range(0, int((max_distance+1)/100))]
    title = 'Min Dist (100s of ft)'
  elif which == 'day_of_week':
    key = lambda k: datetime.datetime.fromtimestamp(
        k.get('calcd_timestamp', KEY_NOT_PRESENT_STRING), TZ).strftime('%a')
    sort = DAYS_OF_WEEK
    title = 'Day of Week'
  elif which == 'day_of_month':
    key = lambda k: datetime.datetime.fromtimestamp(
        k.get('calcd_timestamp', KEY_NOT_PRESENT_STRING), TZ).strftime('%d')
    today_day = datetime.datetime.now(TZ).day
    days = list(range(today_day, 0, -1))  # today down to the first of the month
    days.extend(range(31, today_day, -1))  # 31st of the month down to day after today
    days = [str(d).rjust(2) for d in days]
    sort = days
    title = 'Day of Month'
  else:
    LogMessage(
        'Histogram form has invalid value for which_histograms: %s' % which)
    return HistogramSettingsKeySortTitle(
        'destination', max_altitude=max_altitude, max_distance=max_distance)

  return (key, sort, title)


def ImageHistograms(
    flights,
    which_histograms,
    how_much_history,
    filename_prefix=HISTOGRAM_IMAGE_PREFIX,
    filename_suffix=HISTOGRAM_IMAGE_SUFFIX):
  """Generates multiple split histogram images.

  Args:
    flights: the iterable of the raw data from which the histogram will be generated;
        each element of the iterable is a dictionary, that contains at least the key
        'calcd_display_time', and depending on other parameters, also potentially
        'calcd_min_feet' amongst others.
    which_histograms: string paramater indicating which histogram(s) to generate, which
        can be either the special string 'all', or a string linked to a specific
        histogram.
    how_much_history: string parameter taking a value among ['today', '24h', '7d', '30d].
    filename_prefix: this string indicates the file path and name prefix for the images
        that are created. File names are created in the form [prefix]name.[suffix], i.e.:
        if the prefix is histogram_ and the suffix is png, then the file name might be
        histogram_aircraft.png.
    filename_suffix: see above; also interpreted by savefig to generate the correct
        format.

  Returns:
    List of the names of the histograms generated.
  """
  hours = HistogramSettingsHours(how_much_history)

  histograms_to_generate = []
  if which_histograms in ['destination', 'all']:
    histograms_to_generate.append({'generate': 'destination'})
  if which_histograms in ['origin', 'all']:




                            <----SKIPPED LINES---->




        exhaustive=histogram.get('exhaustive', False))
    filename = filename_prefix + histogram['generate'] + '.' + filename_suffix
    matplotlib.pyplot.savefig(filename)
    matplotlib.pyplot.close()

  histograms_generated = [h['generate'] for h in histograms_to_generate]
  return histograms_generated


def MessageboardHistograms(
    flights,
    which_histograms,
    how_much_history,
    max_screens,
    data_summary):
  """Generates multiple split flap screen histograms.

  Args:
    flights: the iterable of the raw data from which the histogram will be generated;
        each element of the iterable is a dictionary, that contains at least the key
        'calcd_display_time', and depending on other parameters, also potentially
        'calcd_min_feet' amongst others.
    which_histograms: string paramater indicating which histogram(s) to generate, which
        can be either the special string 'all', or a string linked to a specific
        histogram.
    how_much_history: string parameter taking a value among ['today', '24h', '7d', '30d].
    max_screens: string parameter taking a value among ['_1', '_2', '_5', or 'all'].
    data_summary: parameter that evaluates to a boolean indicating whether the data
        summary screen in the histogram should be displayed.

  Returns:
    Returns a list of printable strings (with embedded new line characters) representing
    the histogram, for each screen in the histogram.
  """
  messages = []

  hours = HistogramSettingsHours(how_much_history)
  screen_limit = HistogramSettingsScreens(max_screens)

  histograms_to_generate = []
  if which_histograms in ['destination', 'all']:
    histograms_to_generate.append({
        'generate': 'destination',

        'columns': 3})
  if which_histograms in ['origin', 'all']:
    histograms_to_generate.append({
        'generate': 'origin',

        'columns': 3})
  if which_histograms in ['hour', 'all']:
    histograms_to_generate.append({
        'generate': 'hour',
        'columns': 4,
        'suppress_percent_sign': True,
        'column_divider': '|'})
  if which_histograms in ['airline', 'all']:
    histograms_to_generate.append({
        'generate': 'airline'})
  if which_histograms in ['aircraft', 'all']:
    histograms_to_generate.append({
        'generate': 'aircraft'})
  if which_histograms in ['altitude', 'all']:
    histograms_to_generate.append({
        'generate': 'altitude',
        'columns': 3})
  if which_histograms in ['bearing', 'all']:
    histograms_to_generate.append({
        'generate': 'bearing',

        'columns': 3})
  if which_histograms in ['distance', 'all']:
    histograms_to_generate.append({
        'generate': 'distance',
        'columns': 3})
  if ((which_histograms == 'all' and how_much_history == '7d')
      or which_histograms == 'day_of_week'):
    histograms_to_generate.append({
        'generate': 'day_of_week',
        'columns': 3})

  if ((which_histograms == 'all' and how_much_history == '30d')
      or which_histograms == 'day_of_month'):
    histograms_to_generate.append({
        'generate': 'day_of_month',
        'columns': 4,
        'suppress_percent_sign': True,
        'column_divider': '|'})


  for histogram in histograms_to_generate:
    this_histogram = which_histograms
    if this_histogram == 'all':
      this_histogram = histogram['generate']
    (key, sort, title) = HistogramSettingsKeySortTitle(this_histogram)

    histogram = MessageboardHistogram(
        flights,
        key,
        sort,
        title,
        screen_limit=screen_limit,
        columns=histogram.get('columns', 2),
        suppress_percent_sign=histogram.get('suppress_percent_sign', False),
        column_divider=histogram.get('column_divider', ' '),
        data_summary=data_summary,
        hours=hours)

    messages.extend(histogram)

  return messages


def MessageboardHistogram(
    data,
    keyfunction,
    sort_type,
    title,
    screen_limit=1,
    columns=2,
    column_divider=' ',
    data_summary=False,
    hours=0,
    suppress_percent_sign=False):

  """Generates a text representation of one histogram that can be rendered on the display.

  Args:
    data: the iterable of the raw data from which the histogram will be generated;
        each element of the iterable is a dictionary, that contains at least the key
        'calcd_display_time', and depending on other parameters, also potentially
        'calcd_min_feet' amongst others.
    keyfunction: the function that determines how the key or label of the histogram
        should be generated; it is called for each element of the data iterable. For
        instance, to simply generate a histogram on the attribute 'heading',
        keyfunction would be lambda a: a['heading'].
    sort_type: determines how the keys (and the corresponding values) are sorted; see
        GenerateHistogramData docstring for details
    title: string title, potentially truncated to fit, to be displayed for the histogram
    screen_limit: maximum number of screens to be displayed for the histogram; a value
        of zero is interpreted to mean no limit on screens.
    columns: number of columns of data to be displayed for the histogram; note that the
        keys of the histogram may need to be truncated in length to fit the display
        as more columns are squeezed into the space
    column_divider: string for the character(s) to be used to divide the columns
    data_summary: boolean indicating whether to augment the title with a second header
        line about the data presented in the histogram
    hours: integer indicating the oldest data to be included in the histogram
    suppress_percent_sign: boolean indicating whether to suppress the percent sign
        in the data (but to add it to the title) to reduce the amount of string
        truncation potentially necessary for display of the keys



  Returns:
    Returns a list of printable strings (with embedded new line characters) representing
    the histogram.
  """
  title_lines = 1
  if data_summary:
    title_lines += 1
  available_entries_per_screen = (SPLITFLAP_LINE_COUNT - title_lines) *  columns
  available_entries_total = available_entries_per_screen * screen_limit
  (values, keys, unused_filtered_data) = GenerateHistogramData(
      data, keyfunction, sort_type, truncate=available_entries_total, hours=hours)

  screen_count = math.ceil(len(keys) / available_entries_per_screen)

  column_width = int(
      (SPLITFLAP_CHARS_PER_LINE - len(column_divider)*(columns - 1)) / columns)





  # i.e.: ' 10%' or ' 10', depending on suppress_percent_sign
  value_size = 3
  printed_percent_sign = ''







  augment_title_units = ' %'
  if not suppress_percent_sign:
    value_size += 1
    printed_percent_sign = '%'
    augment_title_units = ''
  column_key_width = column_width - value_size

  total = sum(values)

  if data_summary:
    if hours:
      hours_of_data = min(hours, DataHistoryHours(data))
    else:
      hours_of_data = DataHistoryHours(data)
    time_horizon_text = 'Last %s' % SecondsToDdHh(hours_of_data * SECONDS_IN_HOUR)

    summary_text = '%s (n=%d)' % (time_horizon_text, sum(values))
    summary_text = summary_text.center(SPLITFLAP_CHARS_PER_LINE)

  split_flap_boards = []
  for screen in range(screen_count):
    if screen_count == 1:
      counter = ''
    else:
      counter = ' (%d/%d)' % (screen+1, screen_count)
    screen_title = '%s%s%s' % (
        title[:SPLITFLAP_CHARS_PER_LINE - len(counter) - len(augment_title_units)],
        augment_title_units, counter)

    screen_title = screen_title.center(SPLITFLAP_CHARS_PER_LINE)
    start_index = screen*available_entries_per_screen
    end_index = min((screen+1)*available_entries_per_screen-1, len(keys)-1)
    number_of_entries = end_index - start_index + 1
    number_of_lines = math.ceil(number_of_entries / columns)

    lines = []

    lines.append(screen_title.upper())
    if data_summary:
      lines.append(summary_text.upper())
    for line_index in range(number_of_lines):
      key_value = []
      for column_index in range(columns):
        index = start_index + column_index*number_of_lines + line_index
        if index <= end_index:
          # If the % is >=1%, display right-justified 2 digit percent, i.e. ' 5%'
          # Otherwise, if it rounds to at least 0.1%, display i.e. '.5%'
          if round(values[index]/total*100) >= 1:
            value_string = '%2d' % round(values[index]/total*100)
          elif round(values[index]/total*1000)/10 >= 0.1:
            value_string = ('%.1f' % (round(values[index]/total*1000)/10))[1:]
          else:







            value_string = ' 0'
          key_value.append('%s %s%s' % (
              str(keys[index])[:column_key_width].ljust(column_key_width),
              value_string,
              printed_percent_sign))

      line = (column_divider.join(key_value)).upper()
      lines.append(line)

    split_flap_boards.append(Screenify(lines))

  return split_flap_boards


def GetNowInTimeZoneOfArbtraryFlight(flights):
  """Returns datetime now in the timezone of an arbitrarily selected flight.

  Args:
    flights: iterable with dictionaries of the flight details.

  Returns:
    Timezone-aware datetime.
  """
  arbitrary_time_string = flights[0]['calcd_display_time']
  arbitrary_time = datetime.datetime.strptime(
      arbitrary_time_string, '%Y-%m-%d %H:%M:%S.%f%z')
  tz = arbitrary_time.tzinfo
  now = datetime.datetime.now(tz)
  return now


def ConvertHourStringTimeString(flight):
  """Convert calcd_display_time on flight to a string like '12a' or ' 1p'."""
  time_string = flight.get('calcd_display_time', '')
  if time_string:
    hour_string = time_string[11:13]
    hour_0_23 = int(hour_string)
    is_pm = int(hour_0_23/12) == 1
    hour_number = hour_0_23 % 12
    if hour_number == 0:
      hour_number = 12
    out_string = str(hour_number).rjust(2)
    if is_pm:
      out_string += 'p'
    else:
      out_string += 'a'
  else:
    out_string = KEY_NOT_PRESENT_STRING
  return out_string


def TruncatePickledFlights(file=PICKLEFILE_30D, days=30):
  """Truncate the pickled flights file to have at most some number of days of history.

  Delete old flights from the given pickle repository.

  Args:
    file: name (potentially including path) of the pickled file
    days: maximum number of days (measured from now) to include in the file
  """
  flights = UnpickleFlights(file)
  now = GetNowInTimeZoneOfArbtraryFlight(flights)

  # We want to be cautious, not overwriting the original file until we're sure this has
  # completed; this is why we use the tmp file
  tmp_file = file+'.tmp'
  for flight in flights:
    if HoursSinceFlight(now, flight['calcd_display_time']) <= days*HOURS_IN_DAY:
      PickleFlight(flight, tmp_file)

  shutil.move(tmp_file, file)


def UnpickleFlights(file):
  """Load a repository of pickled flight data into memory.

  Args:
    file: name (potentially including path) of the pickled file

  Returns:
    Return a list of all the flights, in the same sequence as written to the file.
  """
  flights = []
  f = open(file, 'rb')
  try:
    while True:
      data = pickle.load(f)
      flights.append(data)
  except EOFError:
    pass

  f.close()

  return flights


def PickleFlight(flight, file):
  """Append one pickled flight to the end of binary file.

  Args:
    flight: data to pickle
    file: name (potentially including path) of the pickled file
  """
  try:
    with open(file, 'ab') as f:
      f.write(pickle.dumps(flight))

  except IOError:
    LogMessage('Unable to append pickle ' + file)


def UpdateAircraftList(persistent_nearby_aircraft, current_nearby_aircraft, now):
  """Identifies newly seen aircraft and removes aircraft that haven't been seen recently.

  Updates persistent_nearby_aircraft as follows: flights that have been last seen more
  than PERSISTENCE_SECONDS seconds ago are removed; new flights in current_nearby_aircraft
  are added. Also identifies newly-seen aircraft and updates the last-seen timestamp of
  flights that have been seen again.

  Args:
    persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values
      are the time the flight was last seen.
    current_nearby_aircraft: dictionary where keys are flight numbers, and the values are
      themselves dictionaries with key-value pairs about that flight, with at least one of
      the kv-pairs being the time the flight was seen.
    now: the timestamp of the flights in the current_nearby_aircraft.

  Returns:
    A list of newly-nearby flight numbers.
  """
  newly_nearby_flight_numbers = []
  for flight_number in current_nearby_aircraft:
    if flight_number not in persistent_nearby_aircraft:
      newly_nearby_flight_numbers.append(flight_number)
    persistent_nearby_aircraft[flight_number] = now
  flights_to_delete = []
  for flight_number in persistent_nearby_aircraft:
    if (flight_number not in current_nearby_aircraft
        and (now - persistent_nearby_aircraft[flight_number]) > PERSISTENCE_SECONDS):
      flights_to_delete.append(flight_number)
  for flight_number in flights_to_delete:
    del persistent_nearby_aircraft[flight_number]
  return newly_nearby_flight_numbers


def ScanForNewFlights(persistent_nearby_aircraft):
  """Determines if there are any new aircraft in the radio message.

  The radio is continuously dumping new json messages to the Raspberry pi with all the
  flights currently observed. This function picks up the latest radio json, and for
  any new nearby flights - there should generally be at most one new flight on each
  pass through - gets additional flight data from FlightAware and augments the flight
  definition with the relevant fields to keep.

  Args:
    persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values
      are the time the flight was last seen.

  Returns:
    A 3-tuple:
    - updated persistent_nearby_aircraft
    - current_nearby_aircraft
    - (possibly empty) dictionary of flight attributes of the new flight upon its
      first observation.
  """
  flight_details = {}

  dump_json = ReadFile(DUMP_JSON_FILE, log_exception=True)

  if dump_json:
    (current_nearby_aircraft, now) = ParseDumpJson(dump_json)

    newly_nearby_flight_numbers = UpdateAircraftList(
        persistent_nearby_aircraft, current_nearby_aircraft, now)

    if newly_nearby_flight_numbers:

      if len(newly_nearby_flight_numbers) > 1:
        newly_nearby_flight_numbers_str = ', '.join(newly_nearby_flight_numbers)
        newly_nearby_flight_details_str = '\n'.join(
            [str(current_nearby_aircraft[f]) for f in newly_nearby_flight_numbers])
        LogMessage('Multiple newly-nearby flights: %s\n%s' % (
            newly_nearby_flight_numbers_str, newly_nearby_flight_details_str))

      flight_number = newly_nearby_flight_numbers[0]

      if flight_number:
        flight_aware_json = GetFlightAwareJson(flight_number)
      else:
        flight_aware_json = {}

      if flight_aware_json:
        flight_details = ParseFlightAwareJson(flight_aware_json)
      else:
        LogMessage('No json returned from Flightaware for flight: %s' % flight_number)
        flight_details = {}

      # Augment FlightAware details with radio / radio-derived details
      flight_details.update(current_nearby_aircraft[flight_number])

      # Augment FlightAware details with useful displayable attributes
      AugmentWithDisplayableAirline(flight_details)
      AugmentWithDisplayableAircraft(flight_details)
      AugmentWithDisplayableOriginDestination(flight_details)
      AugmentWithDisplayableDelay(flight_details)
      AugmentWithDisplayableTimeRemaining(flight_details)

  return (persistent_nearby_aircraft, current_nearby_aircraft, flight_details)


def ReadSettings(filename):
  """Parse delimited string of settings in file to a dict of key value pairs.

  Parses a string like 'distance=1426;altitude=32559;on=23;off=24;delay=15;insights=all;'
  into key value pairs.

  Args:
    filename: string of the filename to open, potentially also including the full path.

  Returns:
    Dict of key value pairs contained in the setting file; empty dict if file not
    available or if delimiters missing.
  """

  settings_dict = {}
  settings = ReadFile(filename)
  for setting in settings.split(';'):
    if '=' in setting:
      kv_list = setting.split('=')
      k = kv_list[0]
      v = kv_list[1]
      if v.isdigit():
        v = int(v)
      settings_dict[k] = v
  return settings_dict


def FlightMeetsDisplayCriteria(flight, configuration):
  """Returns boolean indicating whether the screen is currently accepting new flight data.

  Based on the configuration file, determines whether the flight data should be displayed.
  Specifically, the configuration:
  - may include 'enabled' indicating whether screen should be driven at all
  - should include 'on' & 'off' parameters indicating minute (from midnight) of operation
  - should include altitude & elevation parameters indicating max values of interest

  Args:
    flight: dictionary of flight attributes.
    configuration: dictionary of configuration attributes.

  Returns:
    Boolean as described.
  """
  flight_meets_criteria = 'enabled' in configuration

  flight_altitude = DictGetReplaceNone(flight, 'altitude', float('inf'))
  config_max_altitude = configuration['altitude']

  if flight_altitude > config_max_altitude:
    flight_meets_criteria = False
  else:
    flight_distance = DictGetReplaceNone(flight, 'calcd_min_feet', float('inf'))
    config_max_distance = configuration['distance']
    if flight_distance > config_max_distance:
      flight_meets_criteria = False
    else:
      flight_timestamp = flight['calcd_timestamp']
      dt = datetime.datetime.fromtimestamp(flight_timestamp, TZ)
      minute_of_day = dt.hour * MINUTES_IN_HOUR + dt.minute
      if minute_of_day < configuration['on'] or minute_of_day > configuration['off']:
        flight_meets_criteria = False

  return flight_meets_criteria


def MaintainRollingWebLog(message, max_count, filename=ROLLING_MESSAGE_FILE):
  """Maintains a rolling text file of at most max_count printed messages.

  Newest data at top and oldest data at the end, of at most max_count messages,
  where the delimiter between each message is identified by a special fixed string.

  Args:
    message: text message to prepend to the file.
    max_count: maximum number of messages to keep in the file; the max_count+1st message
        is deleted.
    filename: the file to update.
  """
  rolling_log_header = '='*len(SPLITFLAP_HEADER)
  existing_file = ReadFile(filename)
  log_message_count = existing_file.count(rolling_log_header)
  if log_message_count >= max_count:
    message_start_list = [i for i in range(0, len(existing_file))
                          if existing_file[i:].startswith(rolling_log_header)]
    existing_file_to_keep = existing_file[:message_start_list[max_count - 1]]
  else:
    existing_file_to_keep = existing_file

  t = datetime.datetime.now(TZ).strftime('%m/%d/%Y, %H:%M:%S')
  new_message = (
      '\n'.join([rolling_log_header, t, '', message])
      + '\n' + existing_file_to_keep)
  try:
    with open(filename, 'w') as f:
      f.write(new_message)
  except IOError:
    LogMessage('Unable to maintain rolling log at ' + filename)





def CurrentFlightPosition(flights, last_location_detail):
  focus_flight = flights[-1]
  focus_flight_number = focus_flight['dump_flight_number']
  lines = []

  new_flight = (
      not last_location_detail or
      last_location_detail.get('dump_flight_number') != focus_flight_number)

  def TrackMissingElements(key, missing):
    value = focus_flight.get(key)
    if value is None:
      missing.append(key)
    return value, missing

  missing_elements = []
  if new_flight:
    calculations_count = 0
    (track, missing_elements) = TrackMissingElements('track', missing_elements)
    (altitude, missing_elements) = TrackMissingElements('altitude', missing_elements)
    (speed, missing_elements) = TrackMissingElements('speed', missing_elements)
    (lat, missing_elements) = TrackMissingElements('lat', missing_elements)
    (lon, missing_elements) = TrackMissingElements('lon', missing_elements)
    (vert_rate, missing_elements) = TrackMissingElements('vert_rate', missing_elements)
    (last_timestamp, missing_elements) = TrackMissingElements(
        'calcd_timestamp', missing_elements)
  else:
    # this is a flight we've already started tracking loc of, so we know
    # all elements are present in last_location_detail
    calculations_count = last_location_detail['calculations_count'] + 1
    track = last_location_detail['track']
    altitude = last_location_detail['altitude']
    speed = last_location_detail['speed']
    lat = last_location_detail['lat']
    lon = last_location_detail['lon']
    vert_rate = last_location_detail['vert_rate']
    last_timestamp = last_location_detail['calcd_timestamp']

  if not missing_elements:
    now = time.time()
    meters_per_second = speed*METERS_PER_SECOND_IN_KNOTS
    elapsed_sec = now - last_timestamp
    meters_traveled = meters_per_second * elapsed_sec
    plane_pos = TrajectoryLatLon((lat, lon), meters_traveled, track)
    (az_degrees, alt_degrees, distance, crow_distance) = Angles(
        HOME, HOME_ALT, plane_pos, altitude / FEET_IN_METER)


    if new_flight:
      lines.append('='*80)
      lines.append('New flight: %s' % focus_flight_number)
      lines.append('='*80)
    else:
      lines.append('Existing flight: %s' % focus_flight_number)

    lines.append(
        'track: %.2fdg; speed: %.2f m/s; t since last pos: %.4fsec; covered %.2f meters' %
        (track, meters_per_second, elapsed_sec, meters_traveled))

    if new_flight:
      lines.append('Orig pos: (%.4f, %.4f); Alt: %d; hdist: %d' % (
          focus_flight.get('lat', 0),
          focus_flight.get('lon', 0),
          focus_flight.get('altitude', 0),
          focus_flight.get('distance_meters', 0)))  #WHY = 0 on first data pull?
    else:
      lines.append(
          'Last calc: (%.4f, %.4f); Alt: %d; hdist: %d; '
          'dist: %d; Az: %.4fdg; El: %.4fdg' % (
              last_location_detail.get('lat', 0),
              last_location_detail.get('lon', 0),
              last_location_detail.get('altitude', 0),
              last_location_detail.get('distance_meters', 0),
              last_location_detail.get('crow_distance', 0),
              last_location_detail.get('azimuth', 0),
              last_location_detail.get('elevation', 0)))


    # save the calc'd location at the calc'd time
    last_location_detail = {
        'track': track,
        'altitude': altitude,
        'speed': speed,
        'lat': plane_pos[0],
        'lon': plane_pos[1],
        'vert_rate': vert_rate,
        'calcd_timestamp': now,
        'azimuth': az_degrees,
        'elevation': alt_degrees,
        'distance_meters': distance,
        'crow_distance': crow_distance,
        'dump_flight_number': focus_flight_number,
        'calculations_count': calculations_count}

    lines.append(
        'Curr calc: (%.4f, %.4f); Alt: %d; hdist: %d; '
        'dist: %d; Az: %.4fdg; El: %.4fdg' % (
            last_location_detail.get('lat', 0),
            last_location_detail.get('lon', 0),
            last_location_detail.get('altitude', 0),
            last_location_detail.get('distance_meters', 0),
            last_location_detail.get('crow_distance', 0),
            last_location_detail.get('azimuth', 0),
            last_location_detail.get('elevation', 0)))


    LogMessage('\n'.join(lines))
  elif focus_flight == last_location_detail['dump_flight_number']:
    LogMessage('Flight %s missing %s in calculating the current position' % (
        focus_flight, str(missing_elements)))

  return last_location_detail










def TriggerHistograms(flights, histogram_settings):
  """Triggers the text-based or web-based histograms.

  Based on the histogram settings, determines whether to generate text or image histograms
  (or both). For image histograms, also generates empty images for the histograms not
  created so that broken image links are not displayed in the webpage.

  Args:
    flights: List of flight attribute dictionaries.
    histogram_settings: Dictionary of histogram parameters.

  Returns:
    List of histogram messages, if text-based histograms are selected; empty list
    otherwise.
  """
  histogram_messages = []

  if histogram_settings['type'] in ('messageboard', 'both'):




                            <----SKIPPED LINES---->




        histogram_settings['histogram_history'],
        histogram_settings['histogram_max_screens'],
        histogram_settings.get('histogram_data_summary', False))
  if histogram_settings['type'] in ('images', 'both'):
    histograms_generated = ImageHistograms(
        flights,
        histogram_settings['histogram'],
        histogram_settings['histogram_history'])
    all_available_histograms = [
        'destination', 'origin', 'hour', 'airline', 'aircraft', 'altitude',
        'bearing', 'distance', 'day_of_week', 'day_of_month']
    for histogram in all_available_histograms:
      if histogram not in histograms_generated:
        missing_filename = (
            HISTOGRAM_IMAGE_PREFIX + histogram + '.' + HISTOGRAM_IMAGE_SUFFIX)
        shutil.copyfile(HISTOGRAM_EMPTY_IMAGE_FILE, missing_filename)

  return histogram_messages























































































































































































































































































































































































































































def main():
  """Traffic cop between incoming radio flight messages, configuration, and messageboard.

  This is the main logic, checking for new flights, augmenting the radio signal with
  additional web-scraped data, and generating messages in a form presentable to the
  messageboard.
  """





  already_running_id = CheckIfProcessRunning('messageboard.py')
  if already_running_id:
    os.kill(already_running_id, signal.SIGKILL)

  TruncatePickledFlights(file=PICKLEFILE_30D)
  flights = UnpickleFlights(PICKLEFILE_30D)
  configuration = ReadSettings(CONFIG_FILE)
  last_distance = configuration.get('distance')
  last_altitude = configuration.get('altitude')




































  persistent_nearby_aircraft = {} # key = flight number; value = last seen



  # Next up to print is element 0; this is a list of tuples:
  # Element#1: flag indicating the type of message that this is
  # Element#2: the message itself
  message_queue = []
  next_message_time = time.time()
  last_location_detail = {}

  while True:










    new_configuration = ReadSettings(CONFIG_FILE)
    if (new_configuration.get('distance') != configuration.get('distance') or
        new_configuration.get('altitude') != configuration.get('altitude')):


      last_distance = configuration.get('distance')
      last_altitude = configuration.get('altitude')
      SaveTimeOfDayHistogramPng(
          flights,
          new_configuration['distance'],
          new_configuration['altitude'],
          7,
          last_max_distance_feet=last_distance,
          last_max_altitude_feet=last_altitude)
    configuration = new_configuration
























































































    histogram = ReadSettings(HOURLY_HISTOGRAM_FILE)
    if histogram:






      histogram_messages = TriggerHistograms(flights, histogram)
      histogram_messages = [(FLAG_MSG_HISTOGRAM, m) for m in histogram_messages]
      message_queue.extend(histogram_messages)
      os.remove(HOURLY_HISTOGRAM_FILE)

    (persistent_nearby_aircraft, current_nearby_aircraft, flight_details) = (
        ScanForNewFlights(persistent_nearby_aircraft))

    if flight_details:
      flights.append(flight_details)
      PickleFlight(flight_details, PICKLEFILE_30D)
      PickleFlight(flight_details, PICKLEFILE_ARCHIVE)
      flight_messages = []

      if FlightMeetsDisplayCriteria(flight_details, configuration):
        flight_messages.append((
            FLAG_MSG_FLIGHT,
            CreateMessageAboutFlight(flight_details)))

        next_message_time = time.time()  # display the next message about this flight now!
        # and delete any queued "interesting" messages about other flights that have
        # not yet displayed, since a newer flight has taken precedence
        message_queue = [m for m in message_queue if m[0] != FLAG_MSG_INTERESTING]

        flight_insights_enabled_string = configuration.get('insights', 'hide')
        if flight_insights_enabled_string in ('one', 'all'):
          insight_messages = FlightInsights(flights)
          if flight_insights_enabled_string == 'one' and insight_messages:
            insight_messages = [random.choice(insight_messages)]
          insight_messages = [(FLAG_MSG_INTERESTING, m) for m in insight_messages]
          flight_messages.extend(insight_messages)

        # i.e.: we need to make sure the final step is to insert the flight message
        # as the first message
        flight_messages.reverse()

        for flight_message in flight_messages:
          message_queue.insert(0, flight_message)


    new_plane = flights[-1]['dump_flight_number'] != last_location_detail.get(
        'dump_flight_number')
    if flights and (new_plane or last_location_detail.get('calculations_count', 0) <= 10):
      last_location_detail = CurrentFlightPosition(
          flights, last_location_detail)

    # check time & if appropriate, display next message from queue
    if time.time() >= next_message_time:





      if message_queue:
        next_message_time += configuration['delay']
        next_message = message_queue.pop(0)


        LogMessage(next_message[1], file=ALL_MESSAGE_FILE)
        MaintainRollingWebLog(next_message[1], 25)

      else:
        next_message_time += 1

    time.sleep(1)



if __name__ == "__main__":



  main()

01234567890123456789012345678901234567890123456789012345678901234567890123456789
12345 6789101112131415161718192021222324252627282930313233343536373839404142   434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990 919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 177178179180181182183184185186187188189190191     192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282 283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375








432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474








487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708








724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837 838839840841842843844845846847848849850851852853854855856857858859860   8618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517








362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669








369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           37233724372537263727372837293730373137323733373437353736373737383739374037413742








375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784  37853786378737883789379037913792379337943795379637973798379938003801380238033804 3805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829 3830383138323833 383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881








39133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088 4089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143    41444145414641474148414941504151415241534154415541564157415841594160416141624163                                                                                                                                                                                                                                                                                                                                                                                                                                   41644165416641674168416941704171417241734174417541764177417841794180418141824183








41874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659  466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707 470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833                                          4834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861
#!/usr/bin/python3

import datetime
import io
import json

import math
import numbers
import os
import pickle
import shutil
import signal
import statistics
import sys
import textwrap
import time

import bs4
import dateutil.relativedelta
import numpy
import matplotlib
import matplotlib.pyplot
import psutil
import pycurl
import pytz
import requests
import tzlocal
import unidecode

SIMULATION = False
SIMULATION_COUNTER = 0
SIMULATION_PREFIX = 'SIM_'
PICKLE_DUMP_JSON_FILE = 'dump_json.pk'
PICKLE_FA_JSON_FILE = 'fa_json.pk'
DUMP_JSONS = None  # loaded only if in simulation mode
FA_JSONS = None  # loaded only if in simulation mode

HOME_LAT = 37.64406
HOME_LON = -122.43463
HOME = (HOME_LAT, HOME_LON) # lat / lon tuple of antenna
HOME_ALT = 29  #altitude in meters
RADIUS = 6371.0e3  # radius of earth in meters




FEET_IN_METER = 3.28084
FEET_IN_MILE = 5280
METERS_PER_SECOND_IN_KNOTS = 0.514444

MIN_METERS = 5000/FEET_IN_METER # only planes within this distance will be detailed
# planes not seen within MIN_METERS in PERSISTENCE_SECONDS seconds will be dropped from
# the nearby list
PERSISTENCE_SECONDS = 10
TRUNCATE = 50  # max number of keys to include in a histogram image file
# number of seconds to pause between each radio poll / command processing loop
LOOP_DELAY_SECONDS = 1

# This file is where the radio drops its json file
DUMP_JSON_FILE = '/run/dump1090-mutability/aircraft.json'

# This is the directory that stores all the ancillary messageboard configuration files
# that do not need to be exposed via the webserver
MESSAGEBOARD_PATH = '/home/pi/splitflap/'
# This is the directory of the webserver; files placed here are available at
# http://adsbx-custom.local/; files placed in this directly are visible via a browser
WEBSERVER_PATH = '/var/www/html/'

# At the time a flight is first identified as being of interest (in that it falls
# within MIN_METERS meters of HOME), it - and core attributes derived from FlightAware,
# if any - is appended to the end of this pickle file. However, since this file is
# cached in working memory, flights older than 30 days are flushed from this periodically.
PICKLE_FLIGHTS_30D = 'flights_30d.pk' #pickled list of up to about 30d of flights
# This is the same concept as the 30d pickle file, except it is not auto-flushed, and
# so will grow indefinitely.
PICKLE_FLIGHTS_ARCHIVE = 'flights_archive.pk' #pickled list of all flights

CACHED_ELEMENT_PREFIX = 'cached_'

# This web-exposed file is used for non-error messages that might highlight data or
# code logic to check into. It is only cleared out manually.
LOGFILE = 'log.txt'
# Identical to the LOGFILE, except it includes just the most recent n lines. Newest
# lines are at the end.
ROLLING_LOGFILE = 'rolling_log.txt' #file for error messages

# Users can trigger .png histograms analogous to the text ones from the web interface;
# this is the folder (within WEBSERVER_PATH) where those files are placed
WEBSERVER_IMAGE_FOLDER = 'images/'
# Multiple histograms can be generated, i.e. for airline, aircraft, day of week, etc.
# The output files are named by the prefix & suffix, i.e.: prefix + type + . + suffix,
# as in histogram_aircraft.png. These names match up to the names expected by the html
# page that displays the images. Also, note that the suffix is interpreted by matplotlib
# to identify the image format to create.

HISTOGRAM_IMAGE_PREFIX = 'histogram_'
HISTOGRAM_IMAGE_SUFFIX = 'png'
# For those of the approximately ten different types of histograms _not_ generated,
# an empty image is copied into the location expected by the webpage instead; this is
# the location of that "empty" image file.
HISTOGRAM_EMPTY_IMAGE_FILE = 'empty.png'

# This file indicates a pending request for histograms - either png, text-based, or
# both - that may have come from either the web or Arduino interfaces; regardless,
# once it is processed, this file is deleted. The contents are concatenated key-value
# pairs, histogram=all;histogram_history=24h; etc.
HISTOGRAM_CONFIG_FILE = 'histogram.txt'
# This contains concatenated key-value configuration attributes in a similar format
# to the HISTOGRAM_CONFIG_FILE that are exposed to the user via the web interface or,
# for a subset of them, through the Arduino interface. They are polled at every iteration
# so that the most current value is always leveraged by the running software.
CONFIG_FILE = 'settings.txt'
# A few key settings for the messageboard are its sensitivity to displaying flights -
# though it logs all flights within range, it may not be desirable to display all
# flights to the user. Two key parameters are the maximum altitude, and the furthest
# away we anticipate the flight being at its closest point to HOME. As those two
# parameters are manipulated in the settings, a histogram is displayed with one or
# potentially two series, showing the present and potentially prior-set distribution
# of flights, by hour throughout the day, over the last seven days, normalized to
# flights per day. This allows those parameters to be fine-tuned in a useful way.
# This file is the location, on the webserver, of that image, which needs to be in
# alignment with the html page that displays it.
HOURLY_IMAGE_FILE = 'hours.png'

# Communication with the asynchronously-running Arduino interface is done thru files;
# this file includes the superset of key-value pairs that could potentially be sent
# directly, or after some manipulation, to the Arduino.
ARDUINO_FILE = 'arduino.txt'
# One potential command from the Arduino is to "display the last flight"; this request
# is communicated to the Arduino by the presence of this file. After that request is
# processed, this file is deleted. The contents of the file are not used in any way.
LAST_FLIGHT_FILE = 'last_flight.txt'

# This is all messages that have been sent to the board since the last time the
# file was manually cleared. Newest messages are at the bottom. It is visible at the
# webserver.
ALL_MESSAGE_FILE = 'all_messages.txt'  #enumeration of all messages sent to board
# This shows the most recent n messages sent to the board. Newest messages are at the
# top for easier viewing of "what did I miss".
ROLLING_MESSAGE_FILE = 'rolling_messages.txt'

FLAG_MSG_FLIGHT = 1  # basic flight details
FLAG_MSG_INTERESTING = 2  # random tidbit about a flight
FLAG_MSG_HISTOGRAM = 3 # histogram message

FLAG_INSIGHT_LAST_SEEN = 0
FLAG_INSIGHT_DIFF_AIRCRAFT = 1
FLAG_INSIGHT_NTH_FLIGHT = 2
FLAG_INSIGHT_GROUNDSPEED = 3
FLAG_INSIGHT_ALTITUDE = 4
FLAG_INSIGHT_VERTRATE = 5
FLAG_INSIGHT_FIRST_DEST = 6
FLAG_INSIGHT_FIRST_ORIGIN = 7
FLAG_INSIGHT_FIRST_AIRLINE = 8
FLAG_INSIGHT_FIRST_AIRCRAFT = 9
FLAG_INSIGHT_LONGEST_DELAY = 10
FLAG_INSIGHT_FLIGHT_DELAY_FREQUENCY = 11
FLAG_INSIGHT_FLIGHT_DELAY_TIME = 12
FLAG_INSIGHT_AIRLINE_DELAY_FREQUENCY = 13
FLAG_INSIGHT_AIRLINE_DELAY_TIME = 14
FLAG_INSIGHT_DESTINATION_DELAY_FREQUENCY = 15
FLAG_INSIGHT_DESTINATION_DELAY_TIME = 16
FLAG_INSIGHT_HOUR_DELAY_FREQUENCY = 17
FLAG_INSIGHT_HOUR_DELAY_TIME = 18
FLAG_INSIGHT_DATE_DELAY_FREQUENCY = 17
FLAG_INSIGHT_DATE_DELAY_TIME = 18
INSIGHT_TYPES = 21

#if running on raspberry, then need to prepend path to file names
if psutil.sys.platform.title() == 'Linux':
  PICKLE_FLIGHTS_30D = MESSAGEBOARD_PATH + PICKLE_FLIGHTS_30D
  PICKLE_FLIGHTS_ARCHIVE = MESSAGEBOARD_PATH + PICKLE_FLIGHTS_ARCHIVE
  LOGFILE = MESSAGEBOARD_PATH + LOGFILE
  PICKLE_DUMP_JSON_FILE = MESSAGEBOARD_PATH + PICKLE_DUMP_JSON_FILE
  PICKLE_FA_JSON_FILE = MESSAGEBOARD_PATH + PICKLE_FA_JSON_FILE
  LAST_FLIGHT_FILE = MESSAGEBOARD_PATH + LAST_FLIGHT_FILE
  ARDUINO_FILE = MESSAGEBOARD_PATH + ARDUINO_FILE

  HISTOGRAM_CONFIG_FILE = WEBSERVER_PATH + HISTOGRAM_CONFIG_FILE
  CONFIG_FILE = WEBSERVER_PATH + CONFIG_FILE
  HOURLY_IMAGE_FILE = WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HOURLY_IMAGE_FILE

  ROLLING_MESSAGE_FILE = WEBSERVER_PATH + ROLLING_MESSAGE_FILE
  HISTOGRAM_IMAGE_PREFIX = (
      WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_IMAGE_PREFIX)
  HISTOGRAM_EMPTY_IMAGE_FILE = (
      WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_EMPTY_IMAGE_FILE)
  ALL_MESSAGE_FILE = WEBSERVER_PATH + ALL_MESSAGE_FILE
  ROLLING_LOGFILE = WEBSERVER_PATH + ROLLING_LOGFILE

TIMEZONE = 'US/Pacific' # timezone of display
TZ = pytz.timezone(TIMEZONE)

KNOWN_AIRPORTS = ('SJC', 'SFO', 'OAK')  # iata codes that we don't need to expand

SPLITFLAP_CHARS_PER_LINE = 22
SPLITFLAP_LINE_COUNT = 6






DIRECTIONS_4 = ['N', 'E', 'S', 'W']
DIRECTIONS_8 = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
DIRECTIONS_16 = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
                 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']

HOURS = ['12a', ' 1a', ' 2a', ' 3a', ' 4a', ' 5a', ' 6a', ' 7a',
         ' 8a', ' 9a', '10a', '11a', '12p', ' 1p', ' 2p', ' 3p',
         ' 4p', ' 5p', ' 6p', ' 7p', ' 8p', ' 9p', '10p', '11p']

SECONDS_IN_MINUTE = 60
MINUTES_IN_HOUR = 60
HOURS_IN_DAY = 24
SECONDS_IN_HOUR = SECONDS_IN_MINUTE * MINUTES_IN_HOUR
MINUTES_IN_DAY = MINUTES_IN_HOUR * HOURS_IN_DAY
SECONDS_IN_DAY = SECONDS_IN_HOUR * HOURS_IN_DAY

# Units confirmed here:
# www.adsbexchange.com/forum/threads/units-in-the-dump1090-json-file.630617/#post-639541
CLIMB_RATE_UNITS = 'fpm'
#speed units from tracker are knots, based on dump-1090/track.c
#https://github.com/SDRplay/dump1090/blob/master/track.c
SPEED_UNITS = 'kn'
DISTANCE_UNITS = 'ft'  # altitude

# For displaying histograms
# If a key is not present, how should it be displayed in histograms?
KEY_NOT_PRESENT_STRING = 'Unknown'
OTHER_STRING = 'Other' # What key strings should be listed last in sequence?
# What key strings should be listed last in sequence?
SORT_AT_END_STRINGS = [OTHER_STRING, KEY_NOT_PRESENT_STRING]
# What is the sorted sequence of keys for days of week?
DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']

aircraft_length = {} # in meters
aircraft_length['Airbus A220-100 (twin-jet)'] = 35
aircraft_length['Airbus A300F4-600 (twin-jet)'] = 54.08
aircraft_length['Airbus A319 (twin-jet)'] = 33.84
aircraft_length['Airbus A320 (twin-jet)'] = 37.57
aircraft_length['Airbus A320neo (twin-jet)'] = 37.57
aircraft_length['Airbus A321 (twin-jet)'] = 44.51
aircraft_length['Airbus A321neo (twin-jet)'] = 44.51
aircraft_length['Airbus A330-200 (twin-jet)'] = 58.82
aircraft_length['Airbus A330-300 (twin-jet)'] = 63.67
aircraft_length['Airbus A340-300 (quad-jet)'] = 63.69
aircraft_length['Airbus A350-1000 (twin-jet)'] = 73.79
aircraft_length['Airbus A350-900 (twin-jet)'] = 66.8
aircraft_length['Airbus A380-800 (quad-jet)'] = 72.72
aircraft_length['Boeing 737-400 (twin-jet)'] = 36.4
aircraft_length['Boeing 737-700 (twin-jet)'] = 33.63
aircraft_length['Boeing 737-800 (twin-jet)'] = 39.47
aircraft_length['Boeing 737-900 (twin-jet)'] = 42.11
aircraft_length['Boeing 747-400 (quad-jet)'] = 36.4
aircraft_length['Boeing 747-8 (quad-jet)'] = 76.25
aircraft_length['Boeing 757-200 (twin-jet)'] = 47.3
aircraft_length['Boeing 757-300 (twin-jet)'] = 54.4
aircraft_length['Boeing 767-200 (twin-jet)'] = 48.51
aircraft_length['Boeing 767-300 (twin-jet)'] = 54.94
aircraft_length['Boeing 777-200 (twin-jet)'] = 63.73
aircraft_length['Boeing 777-200LR/F (twin-jet)'] = 63.73
aircraft_length['Boeing 777-300ER (twin-jet)'] = 73.86
aircraft_length['Boeing 787-10 (twin-jet)'] = 68.28
aircraft_length['Boeing 787-8 (twin-jet)'] = 56.72
aircraft_length['Boeing 787-9 (twin-jet)'] = 62.81
aircraft_length['Canadair Regional Jet CRJ-200 (twin-jet)'] = 26.77
aircraft_length['Canadair Regional Jet CRJ-700 (twin-jet)'] = 32.3
aircraft_length['Canadair Regional Jet CRJ-900 (twin-jet)'] = 36.2
aircraft_length['Canadair Challenger 350 (twin-jet)'] = 20.9
aircraft_length['Bombardier Challenger 300 (twin-jet)'] = 20.92
aircraft_length['EMBRAER 175 (long wing) (twin-jet)'] = 31.68
aircraft_length['Embraer ERJ-135 (twin-jet)'] = 26.33
aircraft_length['Cessna Caravan (single-turboprop)'] = 11.46
aircraft_length['Cessna Citation II (twin-jet)'] = 14.54
aircraft_length['Cessna Citation V (twin-jet)'] = 14.91
aircraft_length['Cessna Skyhawk (piston-single)'] = 8.28
aircraft_length['Cessna Skylane (piston-single)'] = 8.84
aircraft_length['Cessna Citation Sovereign (twin-jet)'] = 19.35
aircraft_length['Cessna T206 Turbo Stationair (piston-single)'] = 8.61
aircraft_length['Beechcraft Bonanza (33) (piston-single)'] = 7.65
aircraft_length['Beechcraft Super King Air 200 (twin-turboprop)'] = 13.31
aircraft_length['Beechcraft King Air 90 (twin-turboprop)'] = 10.82
aircraft_length['Pilatus PC-12 (single-turboprop)'] = 14.4


def CheckIfProcessRunning():
  """Returns proc id if process with identically-named python file running; else None."""
  this_process_id = os.getpid()
  this_process_name = os.path.basename(sys.argv[0])
  for proc in psutil.process_iter():
    try:
      # Check if process name contains this_process_name.

      commands = proc.as_dict(attrs=['cmdline', 'pid'])
      if commands['cmdline']:
        command_running = any(
            [this_process_name in s for s in commands['cmdline']])
        if command_running and commands['pid'] != this_process_id:
          return commands['pid']
    except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
      pass
  return None


def LogMessage(message, file=None):
  """Write a message to a logfile along with a timestamp.

  Args:
      message: string message to write
      file: string representing file name and, if needed, path to the file to write to
  """
  # can't define as a default parameter because LOGFILE name is potentially
  # modified based on SIMULATION flag
  if not file:
    file = LOGFILE

  try:
    with open(file, 'a') as f:
      if not SIMULATION:  # by excluding the timestamp, file diffs become easy between runs
        f.write('='*80+'\n')
        f.write(str(datetime.datetime.now(TZ))+'\n')
        f.write('\n')
      f.write(message+'\n')
  except IOError:
    LogMessage('Unable to append to ' + file)

  existing_log_lines = ReadFile(LOGFILE).splitlines()
  with open(ROLLING_LOGFILE, 'w') as f:
    f.write('\n'.join(existing_log_lines[-1000:]))


def MaintainRollingWebLog(message, max_count, filename=None):
  """Maintains a rolling text file of at most max_count printed messages.

  Newest data at top and oldest data at the end, of at most max_count messages,
  where the delimiter between each message is identified by a special fixed string.

  Args:
    message: text message to prepend to the file.
    max_count: maximum number of messages to keep in the file; the max_count+1st message
        is deleted.
    filename: the file to update.
  """
  # can't define as a default parameter because ROLLING_MESSAGE_FILE name is potentially
  # modified based on SIMULATION flag
  if not filename:
    filename = ROLLING_MESSAGE_FILE
  rolling_log_header = '='*(SPLITFLAP_CHARS_PER_LINE + 2)
  existing_file = ReadFile(filename)
  log_message_count = existing_file.count(rolling_log_header)
  if log_message_count >= max_count:
    message_start_list = [i for i in range(0, len(existing_file))
                          if existing_file[i:].startswith(rolling_log_header)]
    existing_file_to_keep = existing_file[:message_start_list[max_count - 1]]
  else:
    existing_file_to_keep = existing_file

  t = datetime.datetime.now(TZ).strftime('%m/%d/%Y, %H:%M:%S')
  new_message = (
      '\n'.join([rolling_log_header, t, '', message])
      + '\n' + existing_file_to_keep)
  try:
    with open(filename, 'w') as f:
      f.write(new_message)
  except IOError:
    LogMessage('Unable to maintain rolling log at ' + filename)


def UtcToLocalTimeDifference(timezone=TIMEZONE):
  """Calculates number of seconds between UTC and given timezone.

  Returns number of seconds between UTC and given timezone; if no timezone given, uses
  TIMEZONE defined in global variable.

  Args:
    timezone: string representing a valid pytz timezone in pytz.all_timezones.

  Returns:
    Integer number of seconds.
  """
  utcnow = pytz.timezone('utc').localize(datetime.datetime.utcnow())
  home_time = utcnow.astimezone(pytz.timezone(timezone)).replace(tzinfo=None)
  system_time = utcnow.astimezone(tzlocal.get_localzone()).replace(tzinfo=None)

  offset = dateutil.relativedelta.relativedelta(home_time, system_time)
  offset_seconds = offset.hours * SECONDS_IN_HOUR




                            <----SKIPPED LINES---->




  lambda3 = lambda1 + dlambda13
  intersection = (degrees(phi3), degrees(lambda3))
  return intersection


def ConvertBearingToCompassDirection(bearing, length=3, pad=False):
  """Converts a bearing (in degrees) to a compass dir of 1, 2, or 3 chars (N, NW, NNW).

  Args:
    bearing: degrees to be converted
    length: if 1, 2, or 3, converts to one of 4, 8, or 16 headings:
      - 1: N, S, E, W
      - 2: SE, SW, etc. also valid
      - 3: NWN, ESE, etc. also valid
    pad: boolean indicating whether the direction should be right-justified to length
      characters

  Returns:
    String representation of the compass heading.
  """
  if not isinstance(bearing, numbers.Number):
    return bearing

  divisions = 2**(length+1)  # i.e.: 4, 8, or 16
  division_size = 360 / divisions  # i.e.: 90, 45, or 22.5
  bearing_number = round(bearing / division_size)

  if length == 1:
    directions = DIRECTIONS_4
  elif length == 2:
    directions = DIRECTIONS_8
  else:
    directions = DIRECTIONS_16

  direction = directions[bearing_number%divisions]
  if pad:
    direction = direction.rjust(length)
  return direction


def HaversineDistanceMeters(pos1, pos2):
  """Calculate the distance between two points on a sphere (e.g. Earth).





                            <----SKIPPED LINES---->




    pos2: a 2-tuple defining (lat, lon) in decimal degrees

  Returns:
    Distance between two points in meters.
  """
  is_numeric = [isinstance(x, numbers.Number) for x in (*pos1, *pos2)]
  if False in is_numeric:
    return None

  lat1, lon1, lat2, lon2 = [math.radians(x) for x in (*pos1, *pos2)]
  hav = (math.sin((lat2 - lat1) / 2.0)**2
         + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2.0)**2)
  distance = 2 * RADIUS * math.asin(math.sqrt(hav))

  # Note: though pyproj has this, having trouble installing on rpi
  #az12, az21, distance = g.inv(lon1, lat1, lon2, lat2)

  return distance


def SpeedInMeters(speed_in_knots):
  """Converts speed in knots to speed in meters per second."""
  return speed_in_knots * METERS_PER_SECOND_IN_KNOTS


def MetersTraveled(speed_in_knots, seconds):
  """Converts speed in knots to distance traveled in meters given an elapsed seconds."""
  return SpeedInMeters(speed_in_knots) * seconds


def ClosestKnownLocation(flight, seconds):
  """Using the path in the flight, returns the most recent location observations.

  Flights in the flight dictionary have their path maintained over all the time
  that the radio continues to observe the flight. This function identifies the closest in
  time observation in the path, given number of seconds after the canonical time
  (or before, if sec is negative).

  Args:
    flight: Flight dictionary of interest.
    seconds: Number of seconds after the canonical time of the flight (i.e.: now).

  Returns:
    Tuple:
    - Dictionary of location attributes including the following keys: speed, lat, lon,
      track, altitude, vertrate, now (which is a timestamp reflecting when these
      observations were made)
    - seconds in the past (as compared to the seconds requested) that this observation
      was made. That is, if a location at seconds=10 was requested, if the closest found
      location attributes were at time of 8 seconds, then this would be +2. Since the
      closest time is found, this can also be negative. Or alternatively, this can be
      thought of as the number of seconds still to project the movement for, where
      positive is the future.
  """
  now = flight['now']
  if 'persistent_path' not in flight:
    location = {
        'speed': flight.get('speed'),
        'lat': flight.get('lat'),
        'lon': flight.get('lon'),
        'track': flight.get('track'),
        'altitude': flight.get('altitude'),
        'vertrate': flight.get('vertrate'),
        'now': now}
    return (location, seconds)

  path = flight['persistent_path']
  path_timestamps = [p['now'] for p in path]
  absolute_deltas = [abs(seconds - (t - now)) for t in path_timestamps]

  min_delta = min(absolute_deltas)
  index = absolute_deltas.index(min_delta)
  closest_now_to_request = path[index]['now']
  closest_observation = {
      'speed': path[index].get('speed'),
      'lat': path[index].get('lat'),
      'lon': path[index].get('lon'),
      'track': path[index].get('track'),
      'altitude': path[index].get('altitude'),
      'vertrate': path[index].get('vertrate'),
      'now': closest_now_to_request}
  # i.e.: suppose:
  #       now = 15000
  #       closest_to_now = 15008
  #       request seconds was for 10
  # So there's still 2 more seconds to elapse until the flight is here
  time_delta_from_request = seconds - (closest_now_to_request - now)
  return (closest_observation, time_delta_from_request)


def FlightAnglesSecondsElapsed(flight, seconds, key_suffix='', canonical_loc=False):
  """Returns angular position of flight given a certain amount of time elapsing from sight.

  As time elapses after the flight was first observed, it will be in a new position. That
  new position is based on the most up-to-date location details observed, as it may have
  been seen more recently than the original location details. Then, based on those most
  recent location details, we can estimate its new location at any given time by
  projecting the bearing, speed, etc. out in time.

  Args:
    flight: Flight dictionary of interest.
    seconds: Number of seconds after the canonical time of the flight (i.e.: now).
    key_suffix: Appended to the keys that are returned in the return dictionary.
    canonical_loc: Boolean indicating whether we should only examine the location details
      stored at seconds=0 in the path, which would be identical to that stored in the
      base dictionary itself. This provides access to the "original" reported loc details
      in the same format as the updated or more current values, primarily so that
      comparisons can be easily made between calculations that might fall back to the
      original values vs. the updated values.

  Returns:
    Dictionary of location attributes including the following keys: azimuth_degrees;
    altitude_degrees; ground_distance_feet; crow_distance_feet; lat; lon.
  """
  seconds_ahead_to_find_loc = seconds
  if canonical_loc:
    seconds_ahead_to_find_loc = 0

  (location, time_to_project) = ClosestKnownLocation(flight, seconds_ahead_to_find_loc)
  if not all([isinstance(x, numbers.Number) for x in (
      location.get('speed'),
      location.get('lat'),
      location.get('lon'),
      location.get('track'),
      location.get('altitude'))]):
    return {}

  if canonical_loc:
    time_to_project = seconds

  meters_traveled = MetersTraveled(location['speed'], time_to_project)
  new_position = TrajectoryLatLon(
      (location['lat'], location['lon']), meters_traveled, location['track'])
  angles = Angles(HOME, HOME_ALT, new_position, location['altitude'] / FEET_IN_METER)

  d = {}
  for key in angles:
    d[key + key_suffix] = angles[key]
  d['lat' + key_suffix] = location['lat']
  d['lon' + key_suffix] = location['lon']

  return d


def Angles(pos1, altitude1, pos2, altitude2):
  """Calculates the angular position of pos 2 from pos 1.

  Calculates the azimuth and the angular altitude to see point 2 from point 1, as well
  as two distance metrics: the "ground distance" and "crow distance". Ground is the
  distance between a plumb line to sea level for the two points; crow also takes into
  account the difference in altitude or elevation, and is the distance a bird would have
  to fly to reach the second point from the first.

  Args:
    pos1: a 2-tuple of lat-lon for the first point (i.e.: HOME), in degrees.
    altitude1: height above sea level of pos1, in meters
    pos2: a 2-tuple of lat-lon for the first point (i.e.: the plane), in degrees.
    altitude2: height above sea level of pos2, in meters

  Returns:
    Dictionary of location attributes including the following keys: azimuth_degrees;
    altitude_degrees; ground_distance_feet; crow_distance_feet.
  """
  sin = math.sin
  cos = math.cos
  atan2 = math.atan2
  atan = math.atan
  sqrt = math.sqrt
  radians = math.radians
  degrees = math.degrees

  if not all([isinstance(x, numbers.Number) for x in (
      *pos1, altitude1, *pos2, altitude2)]):
    return None

  distance = HaversineDistanceMeters(pos1, pos2)  # from home to plumb line of plane
  lat1, lon1, lat2, lon2 = [radians(x) for x in (*pos1, *pos2)]
  d_lon = lon2 - lon1
  # azimuth calc from https://www.omnicalculator.com/other/azimuth
  az = atan2((sin(d_lon)*cos(lat2)), (cos(lat1)*sin(lat2)-sin(lat1)*cos(lat2)*cos(d_lon)))
  az_degrees = degrees(az)
  altitude = altitude2 - altitude1
  alt = atan(altitude / distance)
  alt_degrees = degrees(alt)
  crow_distance = sqrt(altitude**2 + distance**2)  # from home to the plane

  return {'azimuth_degrees': az_degrees, 'altitude_degrees': alt_degrees,
          'ground_distance_feet': distance, 'crow_distance_feet': crow_distance}
  #### NEED TO WORK OUT THESE UNITS - feet or meters? DEBUG


def TrajectoryLatLon(pos, distance, track):
  """Calculates lat/lon a plane will be given its starting point and direction / speed.

  Args:
    pos: a 2-tuple of lat-lon for the flight, in degrees.
    distance: the distance, in meters, the flight is traveling from its current lat/lon.
    track: the track or bearing of the plane, in degrees.

  Returns:
    Updated lat/lon for the given trajectory.
  """
  #distance in meters
  #track in degrees
  sin = math.sin
  cos = math.cos
  atan2 = math.atan2
  asin = math.asin
  radians = math.radians
  degrees = math.degrees

  track = radians(track)
  lat1 = radians(pos[0])
  lon1 = radians(pos[1])

  d_div_R = distance/RADIUS
  lat2 = asin(sin(lat1)*cos(d_div_R) + cos(lat1)*sin(d_div_R)*cos(track))
  lon2 = lon1 + atan2(sin(track)*sin(d_div_R)*cos(lat1), cos(d_div_R)-sin(lat1)*sin(lat2))

  lat2_degrees = degrees(lat2)
  lon2_degrees = degrees(lon2)
  return (lat2_degrees, lon2_degrees)




                            <----SKIPPED LINES---->




  is_numeric = [isinstance(x, numbers.Number) for x in (*pos, bearing)]
  if False in is_numeric:
    return None

  # To find the minimum distance, we must first find the point at which the minimum
  # distance will occur, which in turn is accomplished by finding the intersection
  # between that trajectory and a trajectory orthogonal (+90 degrees, or -90 degrees)
  # to it but intersecting HOME.
  potential_intersection1 = IntersectionForTwoPaths(pos, bearing, HOME, bearing + 90)
  potential_intersection2 = IntersectionForTwoPaths(pos, bearing, HOME, bearing - 90)
  potential_distance1 = HaversineDistanceMeters(potential_intersection1, HOME)
  potential_distance2 = HaversineDistanceMeters(potential_intersection2, HOME)

  # Since one of those two potential intersection points (i.e.: +90 or -90 degrees) will
  # create an irrational result, and given the strong locality to HOME that is expected
  # from the initial position, the "correct" result is identified by simply taking the
  # minimum distance of the two candidate.
  return min(potential_distance1, potential_distance2)


def SecondsToHhMm(seconds, colon=False):
  """Converts integer number of seconds to xhym string (i.e.: 7h17m) or to 7:17.

  Args:
    seconds: number of seconds
    colon: controls format; if False, format is 7h17m; if True, format is 7:17.

  Returns:
    String representation of hours and minutes.
  """
  minutes = int(abs(seconds) / SECONDS_IN_MINUTE)
  if minutes > MINUTES_IN_HOUR:
    hours = int(minutes / MINUTES_IN_HOUR)
    minutes = minutes % MINUTES_IN_HOUR
    if colon:
      text = str(hours) + ':' + str(minutes)
    else:
      text = str(hours) + 'h' + str(minutes) + 'm'
  else:
    if colon:
      text = ':' + str(minutes)
    else:
      text = str(minutes) + 'm'
  return text


def SecondsToHours(seconds):
  """Converts integer number of seconds to xh string (i.e.: 7h).

  Args:
    seconds: number of seconds

  Returns:
    String representation of hours.
  """
  minutes = int(abs(seconds) / SECONDS_IN_MINUTE)
  hours = round(minutes / MINUTES_IN_HOUR)
  return hours


def SecondsToDdHh(seconds):
  """Converts integer number of seconds to xdyh string (i.e.: 7d17h).

  Args:
    seconds: number of seconds

  Returns:
    String representation of days and hours.
  """
  days = int(abs(seconds) / SECONDS_IN_DAY)
  hours = SecondsToHours(seconds - days*SECONDS_IN_DAY)
  if hours == HOURS_IN_DAY:
    hours = 0
    days += 1
  text = '%dd%dh' % (days, hours)
  return text


def HourString(flight):
  """Formats now on flight into a a 3-digit string like '12a' or ' 1p'."""
  time_string = DisplayTime(flight)
  if time_string:
    hour_string = time_string[11:13]
    hour_0_23 = int(hour_string)
    is_pm = int(hour_0_23/12) == 1
    hour_number = hour_0_23 % 12
    if hour_number == 0:
      hour_number = 12
    out_string = str(hour_number).rjust(2)
    if is_pm:
      out_string += 'p'
    else:
      out_string += 'a'
  else:
    out_string = KEY_NOT_PRESENT_STRING
  return out_string


def HoursSinceMidnight(timezone=TIMEZONE):
  """Returns the float number of hours elapsed since midnight in the given timezone."""
  tz = pytz.timezone(timezone)
  now = datetime.datetime.now(tz)
  seconds_since_midnight = (
      now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds()
  hours = seconds_since_midnight / SECONDS_IN_HOUR
  return hours


def HoursSinceFlight(now, then):
  """Returns the number of hours between a timestamp and a flight.

  Args:
    now: timezone-aware datetime representation of timestamp
    then: epoch (float)


  Returns:
    Number of hours between now and then (i.e.: now - then; a positive return value
    means now occurred after then).
  """
  then = datetime.datetime.fromtimestamp(then, TZ)
  delta = now - then
  delta_hours = delta.days * HOURS_IN_DAY + delta.seconds / SECONDS_IN_HOUR
  return delta_hours


def DataHistoryHours(flights):
  """Calculates the number of hours between the earliest & last flight in data.

  flights: List of all flights in sequential order, so that the first in list is earliest
      in time.

  Returns:
    Return time difference in hours between the first flight and last flight.
  """
  min_time = flights[0]['now']
  max_time = flights[-1]['now']
  delta_hours = (max_time - min_time) / SECONDS_IN_HOUR



  return round(delta_hours)


def ReadFile(filename, log_exception=False):
  """Returns text from the given file name if available, empty string if not available.

  Args:
    filename: string of the filename to open, potentially also including the full path.
    log_exception: boolean indicating whether to log an exception if file not found.

  Returns:
    Return text string of file contents.
  """
  try:
    with open(filename, 'r') as content_file:
      file_contents = content_file.read()
  except IOError:
    if log_exception:
      LogMessage('Unable to read '+filename)
    return ''
  return file_contents

# because reading is ~25x more expensive than getmtime, we will only read & parse if
# the getmtime is more recent than last call for this file. So this dict stores the
# a tuple, the last time read & the resulting parsed return value
CACHED_FILES = {}
def ReadAndParseSettings(filename):
  """Reads given filename and then parses the resulting key-value pairs into a dict."""
  global CACHED_FILES
  (last_read_time, settings) = CACHED_FILES.get(filename, (0, {}))
  if os.path.exists(filename):
    last_modified = os.path.getmtime(filename)
    if last_modified > last_read_time:
      setting_str = ReadFile(filename)
      settings = ParseSettings(setting_str)
      CACHED_FILES[filename] = (last_modified, settings)
    return settings

  # File does not - or at least no longer - exists; so remove the cache
  if filename in CACHED_FILES:
    CACHED_FILES.pop(filename)

  return {}


def BuildSettings(d):
  """Converts a dict to a string of form key1=value1;...;keyn=valuen; keys alpha sorted."""
  kv_pairs = []
  for key in sorted(list(d.keys())):
    kv_pairs.append('%s=%s' % (key, d[key]))
  s = ';'.join(kv_pairs)
  return s


def ParseSettings(settings):
  """Parse delimited string of settings in file to a dict of key value pairs.

  Parses a string like 'distance=1426;altitude=32559;on=23;off=24;delay=15;insights=all;'
  into key value pairs.

  Args:
    settings: semicolon-delimited sequence of equal-sign delimited key-value pairs, i.e.:
      key1=value1;key2=value2;....;keyn=valuen.

  Returns:
    Dict of key value pairs contained in the setting file; empty dict if file not
    available or if delimiters missing.
  """
  settings_dict = {}
  for setting in settings.split(';'):
    if '=' in setting:
      kv_list = setting.split('=')
      k = kv_list[0]
      v = kv_list[1]
      if v.isdigit():
        v = int(v)
      else:
        try:
          v = float(v)
        except ValueError:
          pass
      settings_dict[k] = v

  return settings_dict


def WriteFile(filename, text, log_exception=False):
  """Writes the text to the file, returning boolean indicating success.

  Args:
    filename: string of the filename to open, potentially also including the full path.
    text: the text to write
    log_exception: boolean indicating whether to log an exception if file not found.

  Returns:
    Boolean indicating whether the write was successful.
  """
  try:
    with open(filename, 'w') as content_file:
      content_file.write(text)
  except IOError:
    if log_exception:
      LogMessage('Unable to write to '+filename)
    return False
  return True


def TruncatePickledDictionaries(file, days=30):
  """Truncate the pickled flights file to have at most some number of days of history.

  Delete old data from the given pickle repository; the data must be sequentially
  pickled dictionaries where each dictionary has (at minimum) a key named 'now', which
  is the timestamp of the record.

  Args:
    file: name (potentially including path) of the pickled file
    days: maximum number of days (measured from now) to include in the file

  Returns:
    Data remaining after any truncation.
  """
  flights = UnpickleObjectFromFile(file)
  flights_passing_criteria = []
  now = datetime.datetime.now(TZ)

  # We want to be cautious, not overwriting the original file until we're sure this has
  # completed; this is why we use the tmp file
  tmp_file = file+'.tmp'
  for flight in flights:
    if HoursSinceFlight(now, flight['now']) <= days*HOURS_IN_DAY:
      PickleObjectToFile(flight, tmp_file)
      flights_passing_criteria.append(flight)

  shutil.move(tmp_file, file)

  return flights_passing_criteria


def UnpickleObjectFromFile(file):
  """Load a repository of pickled flight data into memory.

  Args:
    file: name (potentially including path) of the pickled file

  Returns:
    Return a list of all the flights, in the same sequence as written to the file.
  """
  data = []
  if os.path.exists(file):
    try:
      with open(file, 'rb') as f:
        while True:
          data.append(pickle.load(f))
    except (EOFError, pickle.UnpicklingError):
      pass

  return data


def PickleObjectToFile(data, file):
  """Append one pickled flight to the end of binary file.

  Args:
    data: data to pickle
    file: name (potentially including path) of the pickled file
  """
  try:
    with open(file, 'ab') as f:
      f.write(pickle.dumps(data))

  except IOError:
    LogMessage('Unable to append pickle ' + file)


def UpdateAircraftList(persistent_nearby_aircraft, current_nearby_aircraft, now):
  """Identifies newly seen aircraft and removes aircraft that haven't been seen recently.

  Updates persistent_nearby_aircraft as follows: flights that have been last seen more
  than PERSISTENCE_SECONDS seconds ago are removed; new flights in current_nearby_aircraft
  are added. Also identifies newly-seen aircraft and updates the last-seen timestamp of
  flights that have been seen again.

  Args:
    persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values
      are the time the flight was last seen.
    current_nearby_aircraft: dictionary where keys are flight numbers, and the values are
      themselves dictionaries with key-value pairs about that flight, with at least one of
      the kv-pairs being the time the flight was seen.
    now: the timestamp of the flights in the current_nearby_aircraft.

  Returns:
    A list of newly-nearby flight numbers.
  """
  newly_nearby_flight_numbers = []
  for flight_number in current_nearby_aircraft:
    if flight_number not in persistent_nearby_aircraft:
      newly_nearby_flight_numbers.append(flight_number)
    persistent_nearby_aircraft[flight_number] = now
  flights_to_delete = []
  for flight_number in persistent_nearby_aircraft:
    if (flight_number not in current_nearby_aircraft
        and (now - persistent_nearby_aircraft[flight_number]) > PERSISTENCE_SECONDS):
      flights_to_delete.append(flight_number)
  for flight_number in flights_to_delete:
    del persistent_nearby_aircraft[flight_number]
  return newly_nearby_flight_numbers


def ScanForNewFlights(persistent_nearby_aircraft, persistent_path):
  """Determines if there are any new aircraft in the radio message.

  The radio is continuously dumping new json messages to the Raspberry pi with all the
  flights currently observed. This function picks up the latest radio json, and for
  any new nearby flights - there should generally be at most one new flight on each
  pass through - gets additional flight data from FlightAware and augments the flight
  definition with the relevant fields to keep.

  Args:
    persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values
      are the time the flight was last seen.
    persistent_path: dictionary where keys are flight numbers, and the values are a
      sequential list of the location-attributes in the json file; allows for tracking
      the flight path over time.

  Returns:
    A tuple:
    - updated persistent_nearby_aircraft
    - current_nearby_aircraft
    - (possibly empty) dictionary of flight attributes of the new flight upon its
      first observation.
    - the time of the radio observation if present; None if no radio dump
    - a dictionary of attributes about the dump itself (i.e.: # of flights; furthest
      observed flight, etc.)
    - persistent_path, a data structure containing past details of a flight's location
      as described in ParseDumpJson
  """
  flight_details = {}
  now = time.time()
  if SIMULATION:
    (dump_json, json_time) = DUMP_JSONS[SIMULATION_COUNTER]
  else:
    dump_json = ReadFile(DUMP_JSON_FILE, log_exception=True)

  json_desc_dict = {}
  current_nearby_aircraft = {}
  if dump_json:
    (current_nearby_aircraft,
     now,
     json_desc_dict,
     persistent_path) = ParseDumpJson(dump_json, persistent_path)

    if not SIMULATION:
      PickleObjectToFile((dump_json, now), PICKLE_DUMP_JSON_FILE)

    newly_nearby_flight_numbers = UpdateAircraftList(
        persistent_nearby_aircraft, current_nearby_aircraft, now)

    if newly_nearby_flight_numbers:

      if len(newly_nearby_flight_numbers) > 1:
        newly_nearby_flight_numbers_str = ', '.join(newly_nearby_flight_numbers)
        newly_nearby_flight_details_str = '\n'.join(
            [str(current_nearby_aircraft[f]) for f in newly_nearby_flight_numbers])
        LogMessage('Multiple newly-nearby flights: %s\n%s' % (
            newly_nearby_flight_numbers_str, newly_nearby_flight_details_str))
      flight_number = newly_nearby_flight_numbers[0]

      flight_aware_json = {}
      if SIMULATION:
        json_times = [j[1] for j in FA_JSONS]
        if json_time in json_times:
          flight_aware_json = FA_JSONS[json_times.index(json_time)][0]
      elif flight_number:
        flight_aware_json = GetFlightAwareJson(flight_number)
        if not flight_aware_json:
          LogMessage('No json returned from Flightaware for flight: %s' % flight_number)

      flight_details = {}
      if flight_aware_json:
        flight_details = ParseFlightAwareJson(flight_aware_json)

      if not SIMULATION:
        PickleObjectToFile((flight_aware_json, now), PICKLE_FA_JSON_FILE)

      # Augment FlightAware details with radio / radio-derived details
      flight_details.update(current_nearby_aircraft[flight_number])

      # Augment with the past location data
      flight_details['persistent_path'] = persistent_path[flight_number][1]

  return (
      persistent_nearby_aircraft,
      current_nearby_aircraft,
      flight_details,
      now,
      json_desc_dict,
      persistent_path)


def DescribeDumpJson(parsed):
  """Generates a dictionary with descriptive attributes about the dump json file.

  Args:
    parsed: The parsed json file.

  Returns:
    Dictionary with attributes about radio range, number of flights seen, etc.
  """
  json_desc_dict = {}
  json_desc_dict['now'] = parsed['now']

  aircraft = [a for a in parsed['aircraft'] if a['seen'] < PERSISTENCE_SECONDS]
  json_desc_dict['radio_range_flights'] = len(aircraft)

  aircraft_with_pos = [a for a in aircraft if 'lat' in a and 'lon' in a]
  current_distances = [HaversineDistanceMeters(
      HOME, (a['lat'], a['lon'])) for a in aircraft_with_pos]
  current_distances = [
      d * FEET_IN_METER / FEET_IN_MILE for d in current_distances if d is not None]
  if current_distances:
    json_desc_dict['radio_range_miles'] = max(current_distances)

  return json_desc_dict


def ParseDumpJson(dump_json, persistent_path):
  """Identifies all airplanes within given distance of home from the dump1090 file.

  Since the dump1090 json will have messages from all flights that the antenna has picked
  up, we want to keep only flights that are within a relevant distance to us, and also to
  extract from the full set of data in the json to just the relevant fields for additional
  analysis.

  Args:
    dump_json: The text representation of the json message from dump1090-mutability
    persistent_path: dictionary where keys are flight numbers, and the values are a
      sequential list of the location-attributes in the json file; allows for tracking
      the flight path over time.

  Returns:
    Return tuple:
    - dictionary of all nearby planes, where keys are flight numbers (i.e.: 'SWA7543'),
      and the value is itself a dictionary of attributes.
    - time stamp in the json file.
    - dictionary of attributes about the radio range
    - persistent dictionary of the track of recent flights, where keys are the flight
      numbers and the value is a tuple, the first element being when the flight was last
      seen in this radio, and the second is a list of dictionaries with past location info
      from the radio where it's been seen, i.e.: d[flight] = (timestamp, [{}, {}, {}])
  """
  parsed = json.loads(dump_json)
  now = parsed['now']
  nearby_aircraft = {}

  # Build dictionary summarizing characteristics of the dump_json itself
  json_desc_dict = DescribeDumpJson(parsed)

  for aircraft in parsed['aircraft']:
    simplified_aircraft = {}

    simplified_aircraft['now'] = now

    # flight_number
    flight_number = aircraft.get('flight')
    if flight_number:
      flight_number = flight_number.strip()
      if flight_number:
        simplified_aircraft['flight_number'] = flight_number

    if 'lat' in aircraft and 'lon' in aircraft:
      lat = aircraft['lat']
      lon = aircraft['lon']
      if isinstance(lat, numbers.Number) and isinstance(lon, numbers.Number):
        simplified_aircraft['lat'] = lat
        simplified_aircraft['lon'] = lon

        simplified_aircraft['altitude'] = aircraft.get('altitude')
        simplified_aircraft['speed'] = aircraft.get('speed')
        simplified_aircraft['vert_rate'] = aircraft.get('vert_rate')
        simplified_aircraft['squawk'] = aircraft.get('squawk')

        track = aircraft.get('track')
        if isinstance(track, numbers.Number):
          min_meters = MinMetersToHome((lat, lon), track)
          simplified_aircraft['track'] = track
          simplified_aircraft['min_feet'] = min_meters * FEET_IN_METER

        if HaversineDistanceMeters(HOME, (lat, lon)) < MIN_METERS:
          nearby_aircraft[flight_number] = simplified_aircraft

        # keep all that track info - once we start reporting on a nearby flight, it will
        # become part of the flight's persistent record. Also, note that as we are
        # building a list of tracks for each flight, and we are later assigning the
        # flight dictionary to point to the list, we just simply need to continue
        # updating this list to keep the dictionary up to date (i.e.: we don't need
        # to directly touch the flights dictionary in main).
        (last_seen, current_path) = persistent_path.get(flight_number, (None, []))
        if simplified_aircraft not in current_path:
          current_path.append(simplified_aircraft)
        persistent_path[flight_number] = (now, current_path)

  # if the flight was last seen too far in the past, remove the track info
  for flight_number in list(persistent_path.keys()):
    (last_seen, current_path) = persistent_path[flight_number]
    if last_seen < now - PERSISTENCE_SECONDS:
      persistent_path.pop(flight_number)

  return (nearby_aircraft, now, json_desc_dict, persistent_path)


def GetFlightAwareJson(flight_number):
  """Scrapes the text json message from FlightAware for a given flight number.

  Given a flight number, loads the corresponding FlightAware webpage for that flight and
  extracts the relevant script that contains all the flight details from that page.

  Args:
    flight_number: text flight number (i.e.: SWA1234)

  Returns:
    Text representation of the json message from FlightAware.
  """
  url = 'https://flightaware.com/live/flight/' + flight_number
  try:
    response = requests.get(url)
  except requests.exceptions.RequestException as e:
    LogMessage('Unable to query FA for URL due to %s: %s' % (e, url))
    return ''
  soup = bs4.BeautifulSoup(response.text, "html.parser")
  l = soup.findAll('script')
  flight_script = None
  for script in l:
    if "trackpollBootstrap" in script.text:
      flight_script = script.text
      break
  if not flight_script:
    LogMessage('Unable to find trackpollBootstrap script in page: ' + response.text)
    return ''
  first_open_curly_brace = flight_script.find('{')
  flight_json = flight_script[first_open_curly_brace:-1]
  return flight_json


def Unidecode(s):
  """Convert a special unicode characters to closest ASCII representation."""
  if s is not None:
    s = unidecode.unidecode(s)
  return s


def ParseFlightAwareJson(flight_json):
  """Strips relevant data about the flight from FlightAware feed.

  The FlightAware json has hundreds of fields about a flight, only a fraction of which
  are relevant to extract. Note that some of the fields are inconsistently populated
  (i.e.: scheduled and actual times for departure and take-off).

  Args:
    flight_json: Text representation of the FlightAware json about a single flight.

  Returns:
    Dictionary of flight attributes extracted from the FlightAware json.
  """
  flight = {}
  parsed_json = json.loads(flight_json)

  fa_flight_number = list(parsed_json['flights'].keys())[0]
  parsed_flight_details = parsed_json['flights'][fa_flight_number]
  flight['fa_flight_number'] = fa_flight_number

  origin = parsed_flight_details.get('origin')
  if origin:
    flight['origin_friendly'] = origin.get('friendlyLocation')
    flight['origin_iata'] = origin.get('iata')

  destination = parsed_flight_details.get('destination')
  if destination:
    flight['destination_friendly'] = destination.get('friendlyLocation')
    flight['destination_iata'] = destination.get('iata')

  aircraft_type = parsed_flight_details.get('aircraft')
  if aircraft_type:
    flight['aircraft_type_code'] = aircraft_type.get('type')
    flight['aircraft_type_friendly'] = aircraft_type.get('friendlyType')
    flight['owner_location'] = Unidecode(aircraft_type.get('ownerLocation'))
    flight['owner'] = Unidecode(aircraft_type.get('owner'))
    flight['tail'] = Unidecode(aircraft_type.get('tail'))

  takeoff_time = parsed_flight_details.get('takeoffTimes')
  if takeoff_time:
    flight['scheduled_takeofftime'] = takeoff_time.get('scheduled')
    flight['actual_takeoff_time'] = takeoff_time.get('actual')

  gate_departure_time = parsed_flight_details.get('gateDepartureTimes')
  if gate_departure_time:
    flight['scheduled_departure_time'] = gate_departure_time.get('scheduled')
    flight['actual_departure_time'] = gate_departure_time.get('actual')

  gate_arrival_time = parsed_flight_details.get('gateArrivalTimes')
  if gate_arrival_time:
    flight['scheduled_arrival_time'] = gate_arrival_time.get('scheduled')
    flight['estimated_arrival_time'] = gate_arrival_time.get('estimated')

  landing_time = parsed_flight_details.get('landingTimes')
  if landing_time:
    flight['scheduled_landing_time'] = landing_time.get('scheduled')
    flight['estimated_landing_time'] = landing_time.get('estimated')

  airline = parsed_flight_details.get('airline')
  if airline:
    flight['airline_call_sign'] = Unidecode(airline.get('callsign'))
    flight['airline_short_name'] = Unidecode(airline.get('shortName'))
    flight['airline_full_name'] = Unidecode(airline.get('fullName'))

  if len(parsed_json['flights'].keys()) > 1:
    LogMessage('There are multiple flights in the FlightAware json: ' + parsed_json)

  return flight


def EpochDisplayTime(epoch, format_string='%Y-%m-%d %H:%M:%S.%f%z'):
  """Converts epoch in seconds to formatted time string."""
  return datetime.datetime.fromtimestamp(epoch, TZ).strftime(format_string)


def DisplayTime(flight, format_string='%Y-%m-%d %H:%M:%S.%f%z'):
  """Converts flight 'now' to formatted time string, caching results on flight."""
  cached_key = CACHED_ELEMENT_PREFIX + 'now-' + format_string
  cached_time = flight.get(cached_key)
  if cached_time:
    return cached_time

  epoch_display_time = EpochDisplayTime(flight['now'], format_string=format_string)
  flight[cached_key] = epoch_display_time
  return epoch_display_time


def DisplayAirline(flight):
  """Augments flight details with display-ready airline attributes.

  Args:
    flight: dictionary with key-value attributes about the flight.

  Returns:
    String identifying either the airline, or Unknown if not available.
  """
  airline = flight.get('airline_short_name', flight.get('airline_full_name'))

  # Some names are very similar to others and so appear identical on splitflap
  replacement_names = (
      ('Delta Private Jets', 'DPJ'),
      ('United Parcel Service', 'UPS'))
  for (old, new) in replacement_names:
    if airline and old.upper() == airline.upper():
      airline = new
      break

  if not airline:
    airline = KEY_NOT_PRESENT_STRING
  return airline


def DisplayAircraft(flight):
  """Provides a display-ready string about the aircraft used.

  Args:
    flight: dictionary with key-value attributes about the flight.

  Returns:
    Aircraft string if available; empty string otherwise.
  """
  aircraft = flight.get('aircraft_type_friendly')
  if aircraft:
    aircraft = aircraft.replace('(twin-jet)', '(twin)')
    aircraft = aircraft.replace('(quad-jet)', '(quad)')
    aircraft = aircraft.replace('Regional Jet ', '')
    aircraft = aircraft[:SPLITFLAP_CHARS_PER_LINE]
  else:
    aircraft = ''
  return aircraft


def DisplayFlightNumber(flight):
  """Generate a displayable string for flight number, falling back to SQUAWK."""
  squawk = flight.get('squawk', '')
  flight_number = flight.get('flight_number')
  identifier = flight_number
  if not identifier and squawk:
    identifier = 'SQK ' + str(squawk)
  if not identifier:
    identifier = KEY_NOT_PRESENT_STRING
  return identifier


def DisplayAirportCodeIata(flight, key):
  """Returns key if it is present and not evaluating to False; 'Unknown' otherwise."""
  airport_code = flight.get(key)
  if not airport_code:
    airport_code = KEY_NOT_PRESENT_STRING
  return airport_code


def DisplayOriginIata(flight):
  """Generates displayable string for origin airport code."""
  return DisplayAirportCodeIata(flight, 'origin_iata')


def DisplayDestinationIata(flight):
  """Generates displayable string for destination airport code."""
  return DisplayAirportCodeIata(flight, 'destination_iata')


def DisplayAirportCodeFriendly(flight, iata_key, friendly_key):
  """Generates displayable longer name of airport including city if available."""
  airport = flight.get(iata_key)
  if not airport:
    return KEY_NOT_PRESENT_STRING
  if airport in KNOWN_AIRPORTS:
    return airport
  airport += ' ' + flight.get(friendly_key, '').split(',')[0]
  return airport


def DisplayOriginFriendly(flight):
  """Generates displayable longer name of origin airport including city if available."""
  return DisplayAirportCodeFriendly(flight, 'origin_iata', 'origin_friendly')


def DisplayDestinationFriendly(flight):
  """Generates displayable longer name of dest airport including city if available."""
  return DisplayAirportCodeFriendly(flight, 'destination_iata', 'destination_friendly')


def DisplayOriginDestinationPair(flight):
  """Generates displayble origin-destination airport code mindful of screen width.

  If the origin or destination is among a few key airports where the IATA code is
  well-known, then we can display only that code. Otherwise, we'll want to display
  both the code and a longer description of the airport. But we need to be mindful of
  the overall length of the display. So, for instance, these might be produced as
  valid origin-destination pairs:
  SFO-CLT Charlotte       <- Known origin
  Charlotte CLT-SFO       <- Known destination
  Charl CLT-SAN San Diego <- Neither origin nor destination known

  Args:
    flight: dictionary with key-value attributes about the flight.

  Returns:
    String as described.
  """
  origin_iata = DisplayOriginIata(flight)
  destination_iata = DisplayDestinationIata(flight)

  origin_friendly = DisplayOriginFriendly(flight)
  destination_friendly = DisplayDestinationFriendly(flight)

  max_pair_length = SPLITFLAP_CHARS_PER_LINE - len('-')
  if (
      origin_iata not in KNOWN_AIRPORTS and
      destination_iata not in KNOWN_AIRPORTS and
      origin_iata != destination_iata):
    max_origin_length = int(max_pair_length/2)
    max_destination_length = max_pair_length - max_origin_length

    if (
        len(origin_friendly) > max_origin_length and
        len(destination_friendly) > max_destination_length):
      origin_length = max_origin_length
      destination_length = max_destination_length

    elif len(origin_friendly) > max_origin_length:
      destination_length = len(destination_friendly)
      origin_length = max_pair_length - destination_length
    elif len(destination_friendly) > max_destination_length:
      origin_length = len(origin_friendly)
      destination_length = max_pair_length - origin_length
    else:
      origin_length = max_origin_length
      destination_length = max_destination_length
  elif origin_iata in KNOWN_AIRPORTS and destination_iata not in KNOWN_AIRPORTS:
    origin_length = len(origin_iata)
    destination_length = max_pair_length - origin_length
  elif destination_iata in KNOWN_AIRPORTS and origin_iata not in KNOWN_AIRPORTS:
    destination_length = len(destination_iata)
    origin_length = max_pair_length - destination_length
  elif destination_iata == origin_iata:
    origin_length = len(origin_iata)
    destination_length = max_pair_length - origin_length
  else:
    destination_length = len(destination_iata)
    origin_length = len(origin_iata)

  if origin_iata == KEY_NOT_PRESENT_STRING and destination_iata == KEY_NOT_PRESENT_STRING:
    origin_destination_pair = KEY_NOT_PRESENT_STRING
  else:
    origin_destination_pair = (
        '%s-%s' %
        (origin_friendly[:origin_length], destination_friendly[:destination_length]))

  return origin_destination_pair


def DisplayDepartureTimes(flight):
  """Generates displayable fields about the flight times including details about the delay.

  Attempts to first find matching "pairs" of flight departure time details (departure vs.
  takeoff) in the belief that aligned nomenclature in the source data reflects an
  aligned concept of time where a flight delay can be best calculated.  Without a
  matching pair (or if perhaps no departure time information is provided), then a delay
  cannot be calculated at all.

  Args:
    flight: dictionary with key-value attributes about the flight.

  Returns:
    Dictionary with the following keys:
    - departure_timestamp: taken from one of potentially four timestamps indicating
      departure
    - departure_time_text: departure time formatted to HH:MM string
    - calculable_delay: boolean indicating whether sufficient data available to calc delay
    - delay_seconds: integer number of seconds of delay
    - delay_text: text of the format "7H16M early", where the descriptor early or late is
      abbreviated if needed to stay within the display width
  """
  cached_key = CACHED_ELEMENT_PREFIX + 'departure_times'
  cached_value = flight.get(cached_key)
  if cached_value:
    return cached_value

  actual_departure = flight.get('actual_departure_time')
  scheduled_departure = flight.get('scheduled_departure_time')
  actual_takeoff_time = flight.get('actual_takeoff_time')
  scheduled_takeoff_time = flight.get('scheduled_takeofftime')
  calculable_delay = False

  scheduled = None
  delay_seconds = None
  delay_text = ''

  if actual_departure and scheduled_departure:
    actual = actual_departure
    scheduled = scheduled_departure
    departure_label = 'Dep'
    calculable_delay = True
  elif actual_takeoff_time and scheduled_takeoff_time:
    actual = actual_takeoff_time
    scheduled = scheduled_takeoff_time
    departure_label = 'T-O'
    calculable_delay = True
  elif actual_departure:
    actual = actual_departure
    departure_label = 'ADP'
  elif scheduled_departure:
    actual = scheduled_departure
    departure_label = 'SDP'
  elif actual_takeoff_time:
    actual = actual_takeoff_time
    departure_label = 'ATO'
  elif scheduled_takeoff_time:
    actual = scheduled_takeoff_time
    departure_label = 'STO'
  else:
    actual = 0
    departure_time_text = 'Dep: Unknown'

  if actual:
    tz_corrected_actual = actual + UtcToLocalTimeDifference()
    departure_time_text = ' '.join([
        departure_label,
        datetime.datetime.fromtimestamp(tz_corrected_actual).strftime('%I:%M')])

    if calculable_delay:
      delay_seconds = actual - scheduled
      if int(delay_seconds / SECONDS_IN_MINUTE) == 0:
        delay_text = 'OT'
      elif delay_seconds < 0:
        delay_text = 'ER'
      else:
        delay_text = 'LT'
    else:
      delay_text = ''

  return_value = {
      'departure_time_text': departure_time_text,
      'calculable_delay': calculable_delay,
      'delay_seconds': delay_seconds,
      'delay_text': delay_text}
  flight[cached_key] = return_value

  return return_value


def DisplaySecondsRemaining(flight):
  """Calculates the number of seconds of travel time left in a flight.

  Args:
    flight: dictionary with key-value attributes about the flight.

  Returns:
    Seconds, if the remaining time is calculable; None otherwise.
  """
  arrival = flight.get('estimated_arrival_time')
  if not arrival:
    arrival = flight.get('estimated_landing_time')
  if not arrival:
    arrival = flight.get('scheduled_arrival_time')
  if not arrival:
    arrival = flight.get('scheduled_landing_time')

  if arrival:
    remaining_seconds = flight['now'] - arrival
  else:
    remaining_seconds = None
  return remaining_seconds


def FlightMeetsDisplayCriteria(flight, configuration, display_all_hours=False):
  """Returns boolean indicating whether the screen is currently accepting new flight data.

  Based on the configuration file, determines whether the flight data should be displayed.
  Specifically, the configuration:
  - may include 'enabled' indicating whether screen should be driven at all
  - should include 'on' & 'off' parameters indicating minute (from midnight) of operation
  - should include altitude & elevation parameters indicating max values of interest

  Args:
    flight: dictionary of flight attributes.
    configuration: dictionary of configuration attributes.
    display_all_hours: a boolean indicating whether we should ignore whether the
      screen is turned off (either via the enabling, or via the hour settings)

  Returns:
    Boolean as described.
  """
  flight_altitude = flight.get('altitude', float('inf'))
  config_max_altitude = configuration['setting_max_altitude']

  flight_meets_criteria = True
  if flight_altitude > config_max_altitude:
    flight_meets_criteria = False
  else:
    flight_distance = flight.get('min_feet', float('inf'))
    config_max_distance = configuration['setting_max_distance']
    if flight_distance > config_max_distance:
      flight_meets_criteria = False

  if not display_all_hours and flight_meets_criteria:
    flight_timestamp = flight['now']
    dt = datetime.datetime.fromtimestamp(flight_timestamp, TZ)
    minute_of_day = dt.hour * MINUTES_IN_HOUR + dt.minute
    if (
        minute_of_day < configuration['setting_on_time'] or
        minute_of_day > configuration['setting_off_time']):
      flight_meets_criteria = False
    if configuration.get('setting_screen_enabled', 'off') == 'off':
      print(configuration.get('setting_screen_enabled'))
      flight_meets_criteria = False

  return flight_meets_criteria


def IdentifyFlightDisplayed(flights, configuration, display_all_hours=False):
  """Finds the most recent flight in flights that meet the display criteria.

  Args:
    flights: list of flight dictionaries.
    configuration: dictionary of settings.
    display_all_hours: boolean indicating whether we should ignore the time constraints
      (i.e.: whether the screen is enabled, and its turn-on or turn-off times) in
      identifying the most recent flight. That is, if False, then this will only return
      flights that would have been displayed in the ordinarily usage, vs. if True,
      a flight irrespective of the time it would be displayed.

  Returns:
    A flight dictionary if one can be found; None otherwise.
  """
  for n in range(len(flights)-1, -1, -1):  # traverse the flights in reverse
    if FlightMeetsDisplayCriteria(
        flights[n], configuration, display_all_hours=display_all_hours):
      return n
  return None


def FlightMessageTestHarness(
    flights=None,
    display=True):
  """Simulates what flight messages might be displayed by replaying past flights."""
  if flights is None:
    flights = UnpickleObjectFromFile(PICKLE_FLIGHTS_30D)

  messages = []
  for flight in flights:

    flight_message = Screenify(CreateMessageAboutFlight(flight), False)
    if display:
      print(flight_message)
    messages.append(flight_message)

  return messages


def CreateMessageAboutFlight(flight):
  """Creates a message to describe interesting attributes about a single flight.

  Generates a multi-line description of a flight. A typical message might look like:
  UAL300 - UNITED        <- Flight number and airline
  BOEING 777-200 (TWIN)  <- Aircraft type
  SFO-HNL HONOLULU       <- Origin & destination
  DEP 02:08 ER REM 5:14  <- Time details: departure time; early / late / ontime; remaining
  185MPH 301DEG D:117FT  <- Trajectory details: speed; bearing; forecast min dist to HOME
  1975FT (+2368FPM)      <- Altitude details: current altitude & rate or ascent / descent

  However, not all of these details are always present, so some may be listed as unknown,
  or entire lines may be left out.

  Args:
    flight: dictionary of flight attributes.

  Returns:
    Printable string (with embedded new line characters)
  """
  lines = []

  # LINE1: UAL1425 - UNITED
  #        ======================
  flight_number = DisplayFlightNumber(flight)
  second_element = DisplayAirline(flight)

  if second_element == KEY_NOT_PRESENT_STRING:
    second_element = flight.get('owner', KEY_NOT_PRESENT_STRING)
    if second_element is None:
      second_element = KEY_NOT_PRESENT_STRING

  if flight_number == KEY_NOT_PRESENT_STRING and second_element == KEY_NOT_PRESENT_STRING:
    line = 'Unknown Flight'
  else:
    line = (flight_number + ' - ' + second_element)[:SPLITFLAP_CHARS_PER_LINE]
  lines.append(line)

  # LINE2: Boeing 737-800 (twin-jet)
  #        ======================
  aircraft_type = DisplayAircraft(flight)
  if aircraft_type:
    lines.append(aircraft_type)

  # LINE3: SFO-CLT Charlotte
  #        Charlotte CLT-SFO
  #        ======================
  origin_destination_pair = DisplayOriginDestinationPair(flight)
  if origin_destination_pair:
    lines.append(origin_destination_pair)

  # LINE4: DEP 02:08 ER REM 5:14
  #        Dep: Unknown
  #        ======================
  departure_time_details = DisplayDepartureTimes(flight)
  line_elements = []
  if departure_time_details:

    if departure_time_details.get('departure_time_text'):
      line_elements.append(departure_time_details['departure_time_text'])

    if departure_time_details.get('delay_text'):
      line_elements.append(departure_time_details['delay_text'])

    remaining_seconds = DisplaySecondsRemaining(flight)
    if remaining_seconds is not None:
      line_elements.append('Rem ' + SecondsToHhMm(remaining_seconds, colon=True))

  if line_elements:
    lines.append(EvenlySpace(line_elements))

  # LINE5: 123mph 297deg D:1383ft
  #        ======================
  speed = flight.get('speed')
  heading = flight.get('track')
  min_feet = flight.get('min_feet')

  line_elements = []
  if speed is not None:
    line_elements.append(str(round(speed)) + SPEED_UNITS)
  if heading is not None:
    line_elements.append(str(heading) + u'\u00b0')  # degrees deg unicode
  if min_feet is not None:
    line_elements.append('D:' + str(round(min_feet)) + DISTANCE_UNITS)
  if line_elements:
    lines.append(EvenlySpace(line_elements))

  # LINE6: Alt: 12345ft +1234fpm
  #        ======================
  altitude = flight.get('altitude')
  vert_rate = flight.get('vert_rate')

  line_elements = []
  if altitude:
    line_elements.append('Alt:%d%s' % (altitude, DISTANCE_UNITS))
  if vert_rate:
    line_elements.append('%+d%s' % (vert_rate, CLIMB_RATE_UNITS))
  if line_elements:
    lines.append(EvenlySpace(line_elements))

  return lines


def EvenlySpace(l):
  """Converts list to string with equal space between each element in list."""
  if not l:
    return ''
  if len(l) == 1:
    return l[0]
  extra_space = SPLITFLAP_CHARS_PER_LINE - sum([len(str(s)) for s in l])
  last_gap = round(extra_space / (len(l) - 1))
  return EvenlySpace([*l[:-2], str(l[-2]) + ' '*last_gap + str(l[-1])])


def RemoveParentheticals(s):
  """Removes all instances of () - and the text contained within - from a string."""
  if not s:
    return s
  if '(' in s and ')' in s:
    open_paren = s.find('(')
    close_paren = s.find(')')
  else:
    return s
  if close_paren < open_paren:
    return s
  s = s.replace(s[open_paren:close_paren+1], '').strip().replace('  ', ' ')
  return RemoveParentheticals(s)


def Ordinal(n):
  """Converts integer n to an ordinal string - i.e.: 2 -> 2nd; 5 -> 5th."""
  return '%d%s' % (n, 'tsnrhtdd'[(math.floor(n/10)%10 != 1)*(n%10 < 4)*n%10::4])


def Screenify(lines, splitflap):
  """Transforms a list of lines to a single text string either for printing or sending.

  Given a list of lines that is a fully-formed message to send to the splitflap display,
  this function transforms the list of strings to a single string that is an
  easier-to-read and more faithful representation of how the message will be displayed.
  The transformations are to add blank lines to the message to make it consistent number
  of lines, and to add border to the sides & top / bottom of the message.

  Args:
    lines: list of strings that comprise the message
    splitflap: boolean, True if directed for splitflap display; false if directed to screen

  Returns:
    String - which includes embedded new line characters, borders, etc. as described
    above, that can be printed to screen as the message.
  """
  divider = '+' + '-'*SPLITFLAP_CHARS_PER_LINE + '+'
  border_character = '|'
  append_character = '\n'

  if splitflap:
    border_character = ''
    append_character = ''

  for unused_n in range(SPLITFLAP_LINE_COUNT-len(lines)):
    lines.append('')
  lines = [
      border_character + line.ljust(SPLITFLAP_CHARS_PER_LINE).upper() + border_character
      for line in lines]

  if not splitflap:
    lines.insert(0, divider)
    lines.append(divider)

  return append_character.join(lines)


def FlightInsightsTestHarness(
    flights=None,
    display=True,
    flight_insights_enabled_string='all'):
  """Simulates what insightful messages might be displayed by replaying past flights."""
  if flights is None:
    flights = UnpickleObjectFromFile(PICKLE_FLIGHTS_30D)

  distribution = {}
  messages = []
  for (n, flight) in enumerate(flights):

    flight_message = CreateMessageAboutFlight(flight)
    if display:
      print('='*25)
      print(flight_message)
    messages.append(flight_message)

    insights = CreateFlightInsights(
        flights[:n+1], flight_insights_enabled_string, distribution)

    #DEBUG
    FlightInsightNextFlight(
        flights[:n+1])



    if display:
      for insight in insights:
        print(insight)
    messages.extend(insights)

  return messages


def FlightInsightLastSeen(flights, days_ago=2):
  """Generates string indicating when flight was last seen.

  Generates text of the following form.
  - KAL214 was last seen 2d0h ago

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    days_ago: the minimum time difference for which a message should be generated -
        i.e.: many flights are daily, and so we are not necessarily interested to see
        about every daily flight that it was seen yesterday. However, more infrequent
        flights might be of interest.

  Returns:
    Printable string message; if no message or insights to generate, then an empty string.
  """
  message = ''
  this_flight = flights[-1]
  this_flight_number = DisplayFlightNumber(this_flight)
  this_timestamp = flights[-1]['now']
  last_seen = [f for f in flights[:-1] if DisplayFlightNumber(f) == this_flight_number]
  if last_seen and 'flight_number' in this_flight:
    last_timestamp = last_seen[-1]['now']
    if this_timestamp - last_timestamp > days_ago*SECONDS_IN_DAY:
      message = '%s was last seen %s ago' % (
          this_flight_number, SecondsToDdHh(this_timestamp - last_timestamp))
  return message


def FlightInsightDifferentAircraft(flights, percent_size_difference=0.1):
  """Generates string indicating changes in aircraft for the most recent flight.

  Generates text of the following form for the "focus" flight in the data.
  - Last time ASA1964 was seen on Mar 16, it was with a much larger plane (Airbus A320
    (twin-jet) @ 123ft vs. Airbus A319 (twin-jet) @ 111ft)
  - Last time ASA743 was seen on Mar 19, it was with a different type of airpline
    (Boeing 737-900 (twin-jet) vs. Boeing 737-800 (twin-jet))

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    percent_size_difference: the minimum size (i.e.: length) difference for the insight
        to warrant including the size details.

  Returns:
    Printable string message; if no message or insights to generate, then an empty string.
  """
  message = ''
  this_flight = flights[-1]
  this_flight_number = DisplayFlightNumber(this_flight)
  last_seen = [f for f in flights[:-1] if DisplayFlightNumber(f) == this_flight_number]

  # Last time this same flight flew a materially different type of aircraft
  if last_seen and 'flight_number' in this_flight:
    last_flight = last_seen[-1]

    last_aircraft = last_flight.get('aircraft_type_friendly')
    last_aircraft_length = aircraft_length.get(last_aircraft, 0)

    this_aircraft = this_flight.get('aircraft_type_friendly')
    this_aircraft_length = aircraft_length.get(this_aircraft, 0)

    this_likely_commercial_flight = (
        this_flight.get('origin_iata') and this_flight.get('destination_iata'))
    if this_likely_commercial_flight and this_aircraft and not this_aircraft_length:
      LogMessage('%s used in a flight with defined origin & destination but yet is '
                 'missing length details' % this_aircraft, file=LOGFILE)

    likely_same_commercial_flight = (
        last_flight.get('origin_iata') == this_flight.get('origin_iata') and
        last_flight.get('destination_iata') == this_flight.get('destination_iata') and
        last_flight.get('airline_call_sign') == this_flight.get('airline_call_sign'))

    this_aircraft_bigger = False
    last_aircraft_bigger = False
    if (likely_same_commercial_flight and
        this_aircraft_length > last_aircraft_length * (1 + percent_size_difference)):
      this_aircraft_bigger = True
      comparative_text = 'larger'
    elif (likely_same_commercial_flight and
          last_aircraft_length > this_aircraft_length * (1 + percent_size_difference)):
      last_aircraft_bigger = True
      comparative_text = 'smaller'

    last_flight_time_string = DisplayTime(last_flight, '%b %-d')
    if this_aircraft and last_aircraft:
      if this_aircraft_bigger or last_aircraft_bigger:
        message = ('%s used a %s plane today compared with last, on %s '
                   '(%s @ %dft vs. %s @ %dft)' % (
                       this_flight_number, comparative_text, last_flight_time_string,
                       RemoveParentheticals(this_aircraft),
                       this_aircraft_length*FEET_IN_METER,
                       RemoveParentheticals(last_aircraft),
                       last_aircraft_length*FEET_IN_METER))
      elif last_aircraft and this_aircraft and last_aircraft != this_aircraft:
        message = (
            '%s used a different aircraft today compared with last, on %s (%s vs. %s)' % (
                this_flight_number, last_flight_time_string, this_aircraft, last_aircraft))

  return message


def FlightInsightNthFlight(flights, hours=1, min_multiple_flights=2):
  """Generates string about seeing many flights to the same destination in a short period.

  Generates text of the following form for the "focus" flight in the data.
  - ASA1337 was the 4th flight to PHX in the last 53 minutes, served by Alaska Airlines,
    American Airlines, Southwest and United
  - SWA3102 was the 2nd flight to SAN in the last 25 minutes, both with Southwest

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    hours: the time horizon over which to look for flights with the same destination.
    min_multiple_flights: the minimum number of flights to that same destination to
        warrant generating an insight.

  Returns:
    Printable string message; if no message or insights to generate, then an empty string.
  """
  message = ''
  this_flight = flights[-1]
  this_flight_number = this_flight.get('flight_number', 'This')
  this_destination = this_flight.get('destination_iata', '')
  this_airline = this_flight.get('airline_short_name', KEY_NOT_PRESENT_STRING)
  if not this_airline:
    this_airline = KEY_NOT_PRESENT_STRING # in case airline was stored as, say, ''
  this_timestamp = this_flight['now']
  if this_destination and this_destination not in ['SFO', 'LAX']:
    similar_flights = [f for f in flights[:-1] if
                       this_timestamp - f['now'] < SECONDS_IN_HOUR*hours and
                       this_destination == f.get('destination_iata', '')]
    similar_flights_count = len(similar_flights) + 1  # +1 for this_flight
    similar_flights_airlines = list(
        {f.get('airline_short_name', KEY_NOT_PRESENT_STRING) for f in similar_flights})

    same_airline = [this_airline] == similar_flights_airlines

    if similar_flights_count >= min_multiple_flights:
      n_minutes = (
          (this_flight['now'] - similar_flights[0]['now'])
          / SECONDS_IN_MINUTE)
      message = ('%s was the %s flight to %s in the last %d minutes' % (
          this_flight_number, Ordinal(similar_flights_count),
          this_destination, n_minutes))
      if same_airline and similar_flights_count == 2:
        message += ', both with %s' % this_airline
      elif same_airline:
        message += ', all with %s' % this_airline
      else:
        similar_flights_airlines.append(this_airline)
        similar_flights_airlines.sort()
        message += ', served by %s and %s' % (
            ', '.join(similar_flights_airlines[:-1]),
            similar_flights_airlines[-1])

  return message


def FlightInsightSuperlativeAttribute(
    flights,
    key,
    label,
    units,
    absolute_list,
    insight_min=True,
    insight_max=True,
    hours=24):
  """Generates string about a numeric attribute of the flight being an extreme value.

  Generates text of the following form for the "focus" flight in the data.
  - N5286C has the slowest groundspeed (113mph vs. 163mph) in last 24 hours
  - CKS828 has the highest altitude (40000ft vs. 16575ft) in last 24 hours

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    key: the key of the attribute of interest - i.e.: 'speed'.
    label: the human-readable string that should be displayed in the message - i.e.:
        'groundspeed'.
    units: the string units that should be used to label the value of the key - i.e.:
        'MPH'.
    absolute_list: a 2-tuple of strings that is used to label the min and the max - i.e.:
        ('lowest', 'highest'), or ('slowest', 'fastest').
    insight_min: boolean indicating whether to generate an insight about the min value.
    insight_max: boolean indicating whether to generate an insight about the max value.
    hours: the time horizon over which to look for superlative flights.

  Returns:
    Printable string message; if no message or insights to generate, then an empty string.
  """
  message = ''
  this_flight = flights[-1]
  this_flight_number = this_flight.get('flight_number', 'The last flight')
  first_timestamp = flights[0]['now']
  last_timestamp = flights[-1]['now']
  included_seconds = last_timestamp - first_timestamp

  if included_seconds > SECONDS_IN_HOUR * hours:
    relevant_flights = [
        f for f in flights[:-1]
        if last_timestamp - f['now'] < SECONDS_IN_HOUR * hours]
    value_min = min(
        [f.get(key) for f in relevant_flights if isinstance(f.get(key), numbers.Number)])
    value_max = max(
        [f.get(key) for f in relevant_flights if isinstance(f.get(key), numbers.Number)])
    values_other = len(
        [1 for f in relevant_flights if isinstance(f.get(key), numbers.Number)])

    this_value = this_flight.get(key)

    if this_value and values_other:

      superlative = True
      if (
          isinstance(this_value, numbers.Number) and
          isinstance(value_max, numbers.Number) and
          this_value > value_max and
          insight_max):
        absolute_string = absolute_list[1]
        other_value = value_max
      elif (
          isinstance(this_value, numbers.Number) and
          isinstance(value_min, numbers.Number) and
          this_value < value_min and
          insight_min):
        absolute_string = absolute_list[0]
        other_value = value_min
      else:
        superlative = False

      if superlative:
        message = '%s has the %s %s (%d%s vs. %d%s) in last %d hours' % (
            this_flight_number, absolute_string, label,
            this_value, units, other_value, units, hours)

  return message


def FlightInsightNextFlight(flights):
  """Generates string about estimated wait until next flight.

  Generates text of the following form for the "focus" flight in the data.
  - Last flight at 2:53a; avg wait is 1h58m & median is 42m, but could be as long as
    8h43m, based on last 20 days

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.

  Returns:
    Printable string message; if no message because not enough history, then an
    empty string.
  """
  msg = ''
  # m = min of day of this flight
  # find minute of day of prior flights st
  # -- that flight not seen in last 12 hrs
  # -- that min of day >= this
  this_flight = flights[-1]
  this_hour = int(DisplayTime(this_flight, '%-H'))
  this_minute = int(DisplayTime(this_flight, '%-M'))
  this_date = DisplayTime(this_flight, '%x')

  #DEBUG: need to further filter based on flightcriteria

  # Flights that we've already seen in the last few hours we do not expect to see
  # again for another few hours, so let's exclude them from the calculation
  exclude_flights_hours = 12
  flight_numbers_seen_in_last_n_hours = [
      f['flight_number'] for f in flights
      if f['now'] > this_flight['now'] - exclude_flights_hours*SECONDS_IN_HOUR
      and 'flight_number' in f]
  still_to_come_flights = [
      f for f in flights[:-1]
      if f.get('flight_number') not in flight_numbers_seen_in_last_n_hours
      and this_date != DisplayTime(f, '%x')]

  minimum_minutes_next_flight = {}  # min minutes to next flight by day
  for flight in still_to_come_flights:
    date = DisplayTime(flight, '%x')
    hour = int(DisplayTime(flight, '%-H'))
    minutes = int(DisplayTime(flight, '%-M'))
    minutes_after = (hour - this_hour) * MINUTES_IN_HOUR +(minutes - this_minute)
    if minutes_after < 0:
      minutes_after += MINUTES_IN_DAY
    minimum_minutes_next_flight[date] = min(
        minimum_minutes_next_flight.get(date, minutes_after), minutes_after)

  minutes = list(minimum_minutes_next_flight.values())
  if len(minutes) > 1:
    average_seconds = (sum(minutes) / len(minutes)) * SECONDS_IN_MINUTE
    max_seconds = max(minutes) * SECONDS_IN_MINUTE

    median_seconds = statistics.median(minutes) * SECONDS_IN_MINUTE
    minimum_percent_diff = 0.5
    median_different = (
        median_seconds > average_seconds * (1 + minimum_percent_diff) or
        average_seconds > median_seconds * (1+ minimum_percent_diff))
    median_text = ''
    if median_different:
      median_text = ' & median is %s' % SecondsToHhMm(median_seconds)

    msg = ('Last flight at %s; avg wait is %s%s, but could '
           'be as long as %s, based on last %d days' % (
               DisplayTime(this_flight, '%-I:%M%p'), SecondsToHhMm(average_seconds),
               median_text, SecondsToHhMm(max_seconds), len(minutes)))

  return msg


def PercentileScore(scores, value):
  """Returns the percentile that a particular value is in a list of numbers.

  Roughly inverts numpy.percentile. That is, numpy.percentile(scores_list, percentile)
  to get the value of the list that is at that percentile;
  PercentileScore(scores_list, value) will yield back approximately that percentile.

  If the value matches identical elements in the list, this function takes the average
  position of those identical values to compute a percentile. Thus, for some lists
  (i.e.: where there are lots of flights that have a 0 second delay, or a 100% delay
  frequency), you may not get a percentile of 0 or 100 even with values equal to the
  min or max element in the list.

  Args:
    scores: the list of numbers.
    value: the value for which we want to determine the percentile.

  Returns:
    Returns an integer percentile in the range [0, 100] inclusive.
  """
  scores = sorted(list(scores))
  count_values_below_score = len([1 for s in scores if s < value])
  count_values_at_score = len([1 for s in scores if s == value])
  percentile = (count_values_below_score + count_values_at_score / 2) / len(scores)
  return round(percentile*100)


def FlightInsightGroupPercentile(
    flights,
    group_function,
    value_function,
    value_string_function,
    group_label,
    value_label,
    min_days=1,
    lookback_days=30,
    min_this_group_size=0,
    min_comparison_group_size=0,
    min_group_qty=0,
    percentile_low=float('-inf'),
    percentile_high=float('inf')):
  """Generates a string about extreme values of groups of flights.

  Generates text of the following form for the "focus" flight in the data.
  - flight SIA31 (n=7) has a delay frequency in the 95th %tile, with 100% of flights
    delayed an average of 6m over the last 4d1h
  - flight UAL300 (n=5) has a delay time in the 1st %tile, with an average delay of 0m
  over the last 4d5h

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    group_function: function that, when called with a flight, returns the grouping key.
        That is, for example, group_function(flight) = 'B739'
    value_function: function that, when called with a list of flights, returns the
        value to be used for the comparison to identify min / max. Typically, the count,
        but could also be a sum, standard deviation, etc. - for perhaps the greatest
        range in flight altitude. If the group does not have a valid value and so
        should be excluded from comparison - i.e.: average delay of a group of flights
        which did not have a calculable_delay on any flight, this function should
        return None.
    value_string_function: function that, when called with the two parameters flights
        and value, returns a string (inclusive of units and label) that should be
        displayed to describe the quantity. For instance, if value_function returns
        seconds, value_string_function could convert that to a string '3h5m'. Or if
        value_function returns an altitude range, value_string_function could return
        a string 'altitude range of 900ft (1100ft - 2000ft)'.
    group_label: string to identify the group type - i.e.: 'aircraft' or 'flight' in
        the examples above.
    value_label: string to identify the value - i.e.: 'flights' in the examples above,
        but might also be i.e.: longest *delay*, or other quantity descriptor.
    min_days: the minimum amount of history required to start generating insights
        about delays.
    lookback_days: the maximum amount of history which will be considered in generating
        insights about delays.
    min_this_group_size: even if this group has, say, the maximum average delay, if its
        a group of size 1, that is not necessarily very interesting. This sets the
        minimum group size for the focus flight.
    min_comparison_group_size: similarly, comparing the focus group to groups of size
        one does not necessarily produce a meaningful comparison; this sets to minimum
        size for the other groups.
    min_group_qty: when generating a percentile, if there are only 3 or 4 groups among
        which to generate a percentile (i.e.: only a handful of destinations have been
        seen so far, etc.) then it is not necessarily very interesting to generate a
        message; this sets the minimum quantity of groups necessary (including the
        focus group) to generate a message.
    percentile_low: number [0, 100] inclusive that indicates the percentile that the focus
        flight group must equal or be less than for the focus group to trigger an insight.
    percentile_high: number [0, 100] inclusive that indicates the percentile that the
        focus flight group must equal or be greater than for the focus group to trigger an
        insight.

  Returns:
    Printable string message; if no message or insights to generate, then an empty string.
  """
  message = ''
  this_flight = flights[-1]
  first_timestamp = flights[0]['now']
  last_timestamp = this_flight['now']
  included_seconds = last_timestamp - first_timestamp

  if (included_seconds > SECONDS_IN_DAY * min_days and
      group_function(this_flight) != KEY_NOT_PRESENT_STRING):

    relevant_flights = [
        f for f in flights if
        last_timestamp - f['now'] < SECONDS_IN_DAY * lookback_days]

    grouped_flights = {}
    for flight in relevant_flights:
      group = group_function(flight)
      grouping = grouped_flights.get(group, [])
      grouping.append(flight)
      grouped_flights[group] = grouping
    # we will exclude "UNKNOWN" since that is not a coherent group
    if KEY_NOT_PRESENT_STRING in grouped_flights:
      grouped_flights.pop(KEY_NOT_PRESENT_STRING)

    grouped_values = {g: value_function(grouped_flights[g]) for g in grouped_flights}
    this_group = group_function(relevant_flights[-1])
    this_value = grouped_values[this_group]
    this_group_size = len(grouped_flights[this_group])

    # we will exclude groups that are not big enough
    grouped_flights = {
        k: grouped_flights[k] for k in grouped_flights
        if len(grouped_flights[k]) > min_comparison_group_size or k == this_group}

    # Remove those for which no value could be calculated or which are too small
    grouped_values = {
        g: grouped_values[g] for g in grouped_values
        if grouped_values[g] is not None and g in grouped_flights}

    if this_value and len(grouped_values) > min_group_qty:

      time_horizon_string = ' over the last %s' % SecondsToDdHh(
          last_timestamp - relevant_flights[0]['now'])
      min_comparison_group_size_string = ''
      if min_comparison_group_size > 1:
        min_comparison_group_size_string = ' amongst those with >%d flights' % (
            min_comparison_group_size - 1)

      # FLIGHT X (n=7) is has the Xth percentile of DELAYS, with an average delay of
      # 80 MINUTES
      this_percentile = PercentileScore(grouped_values.values(), this_value)
      if this_group_size > min_this_group_size and (
          this_percentile <= percentile_low or this_percentile >= percentile_high):

        if False:  #debug comparison cohorts
          print('Comparison cohorts for %s (%s)' % (group_label, str(this_group)))
          print('This percentile: %f; min: %f; max: %f' % (
              this_percentile, percentile_low, percentile_high))
          keys = list(grouped_values.keys())
          values = [grouped_values[k] for k in keys]
          print(keys)
          print(values)
          (values, keys) = SortByValues(values, keys)
          for n, value in enumerate(values):
            print('%s: %f (group size: %d)' % (
                keys[n], value, len(grouped_flights[keys[n]])))

        def TrialMessage():
          message = '%s %s (n=%d) has a %s in the %s %%tile, with %s%s%s' % (
              group_label,
              this_group,
              this_group_size,
              value_label,
              Ordinal(this_percentile),
              value_string_function(grouped_flights[this_group], this_value),
              time_horizon_string,
              min_comparison_group_size_string)
          line_count = len(textwrap.wrap(message, width=SPLITFLAP_CHARS_PER_LINE))
          return (line_count, message)

        (line_count, message) = TrialMessage()
        if line_count > SPLITFLAP_LINE_COUNT:
          min_comparison_group_size_string = ''
          (line_count, message) = TrialMessage()
          if line_count > SPLITFLAP_LINE_COUNT:
            time_horizon_string = ''
            (line_count, message) = TrialMessage()

  return message


def FlightInsightSuperlativeGroup(
    flights,
    group_function,
    value_function,
    value_string_function,
    group_label,
    value_label,
    absolute_list,
    min_days=1,
    lookback_days=30,
    min_this_group_size=0,
    min_comparison_group_size=0,
    insight_min=True,
    insight_max=True):
  """Generates a string about extreme values of groups of flights.

  Generates text of the following form for the "focus" flight in the data.
  - aircraft B739 (n=7) is tied with B738 and A303 for the most flights at 7 flights
    over the last 3d7h amongst aircraft with a least 5 flights
  - aircraft B739 (n=7) is tied with 17 others for the most flights at 7 flights over
    the last 3d7h amongst aircraft with a least 5 flights
  - flight UAL1075 (n=12) has the most flights with 12 flights; the next most flights
    is 11 flights over the last 7d5h

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    group_function: function that, when called with a flight, returns the grouping key.
        That is, for example, group_function(flight) = 'B739'
    value_function: function that, when called with a list of flights, returns the
        value to be used for the comparison to identify min / max. Typically, the count,
        but could also be a sum, standard deviation, etc. - for perhaps the greatest
        range in flight altitude. If the group does not have a valid value and so
        should be excluded from comparison - i.e.: average delay of a group of flights
        which did not have a calculable_delay on any flight, this function should
        return None.
    value_string_function: function that, when called with the two parameters flights
        and value, returns a string (inclusive of units and label) that should be
        displayed to describe the quantity. For instance, if value_function returns
        seconds, value_string_function could convert that to a string '3h5m'. Or if
        value_function returns an altitude range, value_string_function could return
        a string 'altitude range of 900ft (1100ft - 2000ft)'.
    group_label: string to identify the group type - i.e.: 'aircraft' or 'flight' in
        the examples above.
    value_label: string to identify the value - i.e.: 'flights' in the examples above,
        but might also be i.e.: longest *delay*, or other quantity descriptor.
    absolute_list: a 2-tuple of strings that is used to label the min and the max - i.e.:
        ('most', 'least'), or ('lowest average', 'highest average').
    min_days: the minimum amount of history required to start generating insights
        about delays.
    lookback_days: the maximum amount of history which will be considered in generating
        insights about delays.
    min_this_group_size: even if this group has, say, the maximum average delay, if its
        a group of size 1, that is not necessarily very interesting. This sets the
        minimum group size for the focus flight.
    min_comparison_group_size: similarly, comparing the focus group to groups of size
        one does not necessarily produce a meaningful comparison; this sets to minimum
        size for the other groups.
    insight_min: boolean indicating whether to possibly generate insight based on the
        occurrence of the min value.
    insight_max: boolean indicating whether to possibly generate insight based on the
        occurrence of the max value.

  Returns:
    Printable string message; if no message or insights to generate, then an empty string.
  """
  message = ''
  first_timestamp = flights[0]['now']
  last_timestamp = flights[-1]['now']
  included_seconds = last_timestamp - first_timestamp

  if included_seconds > SECONDS_IN_DAY * min_days:

    relevant_flights = [
        f for f in flights if
        last_timestamp - f['now'] < SECONDS_IN_DAY * lookback_days]

    grouped_flights = {}
    for flight in relevant_flights:
      group = group_function(flight)
      grouping = grouped_flights.get(group, [])
      grouping.append(flight)
      grouped_flights[group] = grouping

    grouped_values = {g: value_function(grouped_flights[g]) for g in grouped_flights}
    this_group = group_function(relevant_flights[-1])
    this_value = grouped_values[this_group]
    this_group_size = len(grouped_flights[this_group])

    # we will exclude groups that are not big enough
    grouped_flights = {
        k: grouped_flights[k] for k in grouped_flights
        if len(grouped_flights[k]) > min_comparison_group_size}

    # Remove those for which no value could be calculated or which are too small
    grouped_values = {
        g: grouped_values[g] for g in grouped_values
        if grouped_values[g] is not None and g in grouped_flights}

    other_values = list(grouped_values.values())
    if this_value in other_values:
      other_values.remove(this_value)

    if other_values:
      min_value = min(other_values)
      max_value = max(other_values)

      if this_value:
        if this_value > max_value and insight_max:
          superlative = True
          equality = False
          superlative_string = absolute_list[1]
          next_value = max_value
        elif this_value == max_value and insight_max:
          superlative = False
          equality = True
          superlative_string = absolute_list[1]
        elif this_value < min_value and insight_min:
          superlative = True
          equality = False
          superlative_string = absolute_list[0]
          next_value = min_value
        elif this_value == min_value and insight_min:
          superlative = False
          equality = True
          superlative_string = absolute_list[0]
        else:
          superlative = False
          equality = False

        time_horizon_string = SecondsToDdHh(
            last_timestamp - relevant_flights[0]['now'])
        min_comparison_group_size_string = ''
        if min_comparison_group_size > 1:
          min_comparison_group_size_string = (
              ' amongst %s with at least %d flights' %
              (group_label, min_comparison_group_size))

        # flight x (n=7) is tied with a, b, and c for the (longest average, shortest
        # average) delay at 80 minutes
        # flight x is tied with a, b, and c for the (most frequent, least frequent)
        # delay at 30%
        if equality and this_group_size > min_this_group_size:

          identical_groups = sorted([
              str(g) for g in grouped_values
              if grouped_values[g] == this_value and g != this_group])
          if len(identical_groups) > 4:
            identical_string = '%d others' % len(identical_groups)
          elif len(identical_groups) > 1:
            identical_string = (
                '%s and %s' % (', '.join(identical_groups[:-1]), identical_groups[-1]))
          else:
            identical_string = str(identical_groups[0])

          message = (
              '%s %s (n=%d) is tied with %s for the %s %s at %s over the last %s%s' % (
                  group_label,
                  this_group,
                  this_group_size,
                  identical_string,
                  superlative_string,
                  value_label,
                  value_string_function(flights, this_value),
                  time_horizon_string,
                  min_comparison_group_size_string))

        elif superlative and this_group_size > min_this_group_size:
          message = (
              '%s %s (n=%d) has the %s %s with %s; the next '
              '%s %s is %s over the last %s%s' % (
                  group_label,
                  this_group,
                  this_group_size,
                  superlative_string,
                  value_label,
                  value_string_function(flights, this_value),
                  superlative_string,
                  value_label,
                  value_string_function(flights, next_value),
                  time_horizon_string,
                  min_comparison_group_size_string))

  return message


def AverageDelay(flights):
  """Returns the average delay time for a list of flights.

  Args:
    flights: the list of the raw flight data.

  Returns:
    Average seconds of flight delay, calculated as the total seconds delayed amongst
    all the flights that have a positive delay, divided by the total number of flights
    that have a calculable delay. If no flights have a calculable delay, returns None.
  """
  calculable_delay_seconds = [
      DisplayDepartureTimes(f)['delay_seconds'] for f in flights
      if DisplayDepartureTimes(f)['calculable_delay'] and
      DisplayDepartureTimes(f)['delay_seconds'] > 0]
  average_delay = None
  if calculable_delay_seconds:
    average_delay = sum(calculable_delay_seconds) / len(calculable_delay_seconds)
  return average_delay



def PercentDelay(flights):
  """Returns the percentage of flights that have a positive delay for a list of flights.

  Args:
    flights: the list of the raw flight data.

  Returns:
    Percentage of flights with a delay, calculated as the count of flights with a
    positive delay divided by the total number of flights that have a calculable delay.
    If no flights have a calculable delay, returns None.
  """
  calculable_delay_seconds = [
      DisplayDepartureTimes(f)['delay_seconds'] for f in flights
      if DisplayDepartureTimes(f)['calculable_delay']]
  delay_count = sum([1 for s in calculable_delay_seconds if s > 0])
  percent_delay = None
  if calculable_delay_seconds:
    percent_delay = delay_count / len(calculable_delay_seconds)
  return percent_delay


def FlightInsightFirstInstance(
    flights,
    key,
    label,
    days=7,
    additional_descriptor_fcn=''):
  """Generates string indicating the flight has the first instance of a particular key.

  Generates text of the following form for the "focus" flight in the data.
  - N311CG is the first time aircraft GLF6 (Gulfstream Aerospace Gulfstream G650
    (twin-jet)) has been seen since at least 7d5h ago
  - PCM8679 is the first time airline Westair Industries has been seen since 9d0h ago

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    key: the key of the attribute of interest - i.e.: 'destination_iata'.
    label: the human-readable string that should be displayed in the message - i.e.:
        'destination'.
    days: the minimum time of interest for an insight - i.e.: we probably see LAX every
        hour, but we are only interested in particular attributes that have not been
        seen for at least some number of days. Note, however, that the code will go back
        even further to find the last time that attribute was observed, or if never
        observed, indicating "at least".
    additional_descriptor_fcn: a function that, when passed a flight, returns an
        additional parenthetical notation to include about the attribute or flight
        observed - such as expanding the IATA airport code to its full name, etc.

  Returns:
    Printable string message; if no message or insights to generate, then an empty string.
  """
  message = ''
  this_flight = flights[-1]
  this_flight_number = DisplayFlightNumber(this_flight)
  first_timestamp = flights[0]['now']
  last_timestamp = flights[-1]['now']
  included_seconds = last_timestamp - first_timestamp

  if included_seconds > SECONDS_IN_DAY * days:
    this_instance = this_flight.get(key)
    matching = [f for f in flights[:-1] if f.get(key) == this_instance]

    last_potential_observation_sec = included_seconds
    if matching:
      last_potential_observation_sec = last_timestamp - matching[-1]['now']

    if this_instance and last_potential_observation_sec > SECONDS_IN_DAY * days:
      additional_descriptor = ''
      if additional_descriptor_fcn:
        additional_descriptor = ' (%s)' % additional_descriptor_fcn(this_flight)
      last_potential_observation_string = SecondsToDdHh(last_potential_observation_sec)
      if matching:
        message = '%s is the first time %s %s%s has been seen since %s ago' % (
            this_flight_number, label, this_instance, additional_descriptor,
            last_potential_observation_string)
      else:
        message = '%s is the first time %s %s%s has been seen since at least %s ago' % (
            this_flight_number, label, this_instance, additional_descriptor,
            last_potential_observation_string)

  return message


def FlightInsightSuperlativeVertrate(flights, hours=24):
  """Generates string about the climb rate of the flight being an extreme value.

  Generates text of the following form for the "focus" flight in the data.
  - UAL631   has the fastest ascent rate (5248fpm, 64fpm faster than next fastest) in
    last 24 hours
  - CKS1820 has the fastest descent rate (-1152fpm, -1088fpm faster than next fastest)
    in last 24 hours

  While this is conceptually similar to the more generic FlightInsightSuperlativeVertrate
  function, vert_rate - because it can be either positive or negative, with different
  signs requiring different labeling and comparisons - it needs its own special handling.

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    hours: the time horizon over which to look for superlative flights.

  Returns:
    Printable string message; if no message or insights to generate, then an empty string.
  """
  message = ''
  this_flight = flights[-1]
  this_flight_number = this_flight.get('flight_number')
  first_timestamp = flights[0]['now']
  last_timestamp = flights[-1]['now']
  sufficient_data = (last_timestamp - first_timestamp) > SECONDS_IN_HOUR * hours
  pinf = float('inf')
  ninf = float('-inf')

  if sufficient_data:
    relevant_flights = [
        f for f in flights[:-1]
        if last_timestamp - f['now'] < SECONDS_IN_HOUR * hours]

    def AscentRate(f, default):
      vert_rate = f.get('vert_rate')
      if isinstance(vert_rate, numbers.Number) and vert_rate > 0:
        return vert_rate
      return default

    other_ascents = len([
        1 for f in relevant_flights
        if isinstance(f.get('vert_rate'), numbers.Number) and AscentRate(f, ninf) > 0])
    if other_ascents:
      ascent_min = min(
          [AscentRate(f, pinf) for f in relevant_flights if AscentRate(f, ninf) > 0])
      ascent_max = max(
          [AscentRate(f, ninf) for f in relevant_flights if AscentRate(f, ninf) > 0])

    def DescentRate(f, default):
      vert_rate = f.get('vert_rate')
      if isinstance(vert_rate, numbers.Number) and vert_rate < 0:
        return vert_rate
      return default

    other_descents = len([
        1 for f in relevant_flights
        if isinstance(f.get('vert_rate'), numbers.Number) and DescentRate(f, pinf) < 0])
    if other_descents:
      descent_min = min(
          [DescentRate(f, pinf) for f in relevant_flights if DescentRate(f, pinf) < 0])
      descent_max = max(
          [DescentRate(f, ninf) for f in relevant_flights if DescentRate(f, pinf) < 0])

    this_vert_rate = this_flight.get('vert_rate')

    if isinstance(this_vert_rate, numbers.Number):
      if this_vert_rate >= 0:
        this_ascent = this_vert_rate
        this_descent = None
      else:
        this_descent = this_vert_rate
        this_ascent = None

      if this_ascent and other_ascents and this_ascent > ascent_max:
        message = ('%s has the fastest ascent rate (%d%s, %d%s faster '
                   'than next fastest) in last %d hours' % (
                       this_flight_number, this_ascent, CLIMB_RATE_UNITS,
                       this_ascent - ascent_max, CLIMB_RATE_UNITS, hours))
      elif this_ascent and other_ascents and this_ascent < ascent_min:
        message = ('%s has the slowest ascent rate (%d%s, %d%s slower '
                   'than next slowest) in last %d hours' % (
                       this_flight_number, this_ascent, CLIMB_RATE_UNITS,
                       ascent_min - this_ascent, CLIMB_RATE_UNITS, hours))
      elif this_descent and other_descents and this_descent < descent_min:
        message = ('%s has the fastest descent rate (%d%s, %d%s faster '
                   'than next fastest) in last %d hours' % (
                       this_flight_number, this_descent, CLIMB_RATE_UNITS,
                       this_descent - descent_min, CLIMB_RATE_UNITS, hours))
      elif this_descent and other_descents and this_descent > descent_max:
        message = ('%s has the slowest descent rate (%d%s, %d%s slower '
                   'than next slowest) in last %d hours' % (
                       this_flight_number, this_descent, CLIMB_RATE_UNITS,
                       descent_max - this_descent, CLIMB_RATE_UNITS, hours))

  return message


def FlightInsightDelays(
    flights,
    min_days=1,
    lookback_days=30,
    min_late_percentage=0.75,
    min_this_delay_minutes=0,
    min_average_delay_minutes=0):
  """Generates string about the delays this flight has seen in the past.

  Only if this flight has a caclculable delay itself, this will generate text of the
  following form for the "focus" flight in the data.
  - This 8m delay is the longest UAL1175 has seen in the last 9 days (avg delay is 4m);
    overall stats: 1 early; 9 late; 10 total
  - With todays delay of 7m, UAL1175 is delayed 88% of the time in the last 8 days for
    avg delay of 4m; overall stats: 1 early; 8 late; 9 total

  Args:
    flights: the list of the raw data from which the insights will be generated,
        where the flights are listed in order of observation - i.e.: flights[0] was the
        earliest seen, and flights[-1] is the most recent flight for which we are
        attempting to generate an insight.
    min_days: the minimum amount of history required to start generating insights
        about delays.
    lookback_days: the maximum amount of history which will be considered in generating
        insights about delays.
    min_late_percentage: flights that are not very frequently delayed are not
        necessarily very interesting to generate insights about; this specifies the
        minimum percentage the flight must be late to generate a message that focuses
        on the on-time percentage.
    min_this_delay_minutes: a delay of 1 minute is not necessarily interesting; this
        specifies the minimum delay time this instance of the flight must be late to
        generate a message that focuses on this flight's delay.
    min_average_delay_minutes: an average delay of only 1 minute, even if it happens
        every day, is not necessarily very interesting; this specifies the minimum
        average delay time to generate either type of delay message.

  Returns:
    Printable string message; if no message or insights to generate, then an empty string.
  """
  message = ''
  this_flight = flights[-1]
  this_flight_number = this_flight.get('flight_number', '')
  first_timestamp = flights[0]['now']
  last_timestamp = flights[-1]['now']
  included_seconds = last_timestamp - first_timestamp

  if (included_seconds > SECONDS_IN_DAY * min_days
      and DisplayDepartureTimes(this_flight)['calculable_delay']):
    this_delay_seconds = DisplayDepartureTimes(this_flight)['delay_seconds']
    relevant_flights = [
        f for f in flights if
        last_timestamp - f['now'] < SECONDS_IN_DAY * lookback_days and
        this_flight_number == f.get('flight_number', '')]

    if (
        len(relevant_flights) > 1 and
        this_delay_seconds >= min_this_delay_minutes*SECONDS_IN_MINUTE):
      delay_seconds_list = [
          DisplayDepartureTimes(f)['delay_seconds'] for f in relevant_flights
          if DisplayDepartureTimes(f)['calculable_delay']]

      delay_unknown_count = len(relevant_flights) - len(delay_seconds_list)
      delay_ontime_count = len([d for d in delay_seconds_list if not d])
      delay_early_count = len([d for d in delay_seconds_list if d < 0])
      delay_late_count = len([d for d in delay_seconds_list if d > 0])

      delay_late_avg_sec = 0
      delay_late_max_sec = 0

      superlative = False
      if delay_late_count > 1:
        delay_late_avg_sec = sum(
            [d for d in delay_seconds_list if d > 0]) / delay_late_count

        # max / min excluding this flight
        delay_late_max_sec = max([d for d in delay_seconds_list[:-1] if d > 0])
        delay_late_min_sec = min([d for d in delay_seconds_list[:-1] if d > 0])

        if delay_late_max_sec > 0:
          if this_delay_seconds > delay_late_max_sec:
            delay_keyword = 'longest'
            superlative = True
          if this_delay_seconds < delay_late_min_sec:
            delay_keyword = 'shortest'
            superlative = True

      overall_stats_elements = []
      if delay_early_count:
        overall_stats_elements.append('%d ER' % delay_early_count)
      if delay_ontime_count:
        overall_stats_elements.append('%d OT' % delay_ontime_count)
      if delay_late_count:
        overall_stats_elements.append('%d LT' % delay_late_count)
      if delay_unknown_count:
        overall_stats_elements.append('%d UNK' % delay_unknown_count)
      overall_stats_text = '; '.join(overall_stats_elements)

      days_history = (int(
          round(last_timestamp - relevant_flights[0]['now']) / SECONDS_IN_DAY)
                      + 1)

      late_percentage = delay_late_count / len(relevant_flights)

      if (superlative and
          delay_late_avg_sec >= min_average_delay_minutes * SECONDS_IN_MINUTE):
        message = (
            'This %s delay is the %s %s has seen in the last %d days (avg delay is %s);'
            ' overall stats: %s' % (
                SecondsToHhMm(this_delay_seconds),
                delay_keyword,
                this_flight_number,
                days_history,
                SecondsToHhMm(delay_late_avg_sec),
                overall_stats_text))
      elif (late_percentage > min_late_percentage and
            delay_late_avg_sec >= min_average_delay_minutes * SECONDS_IN_MINUTE):
        # it's just been delayed frequently!
        message = (
            'With today''s delay of %s, %s is delayed %d%% of the time in the last %d '
            'days for avg delay of %s; overall stats: %s' % (
                SecondsToHhMm(this_delay_seconds),
                this_flight_number,
                int(100 * late_percentage),
                days_history,
                SecondsToHhMm(delay_late_avg_sec),
                overall_stats_text))
  return message


def FlightInsights(flights):
  """Identifies all the insight messages about the most recently seen flight.

  Generates a possibly-empty list of messages about the flight.

  Args:
    flights: List of all flights where the last flight in the list is the focus flight
        for which we are trying to identify something interesting.

  Returns:
    List of 2-tuples, where the first element in the tuple is a flag indicating the type
    of insight message, and the second selement is the printable strings (with embedded
    new line characters) for something interesting about the flight; if there isn't
    anything interesting, returns an empty list.
  """
  messages = []

  def AppendMessageType(message_type, message):
    if message:
      messages.append((message_type, message))

  # This flight number was last seen x days ago
  AppendMessageType(FLAG_INSIGHT_LAST_SEEN, FlightInsightLastSeen(flights, days_ago=2))

  # Yesterday this same flight flew a materially different type of aircraft
  AppendMessageType(
      FLAG_INSIGHT_DIFF_AIRCRAFT,
      FlightInsightDifferentAircraft(flights, percent_size_difference=0.1))

  # This is the 3rd flight to the same destination in the last hour
  AppendMessageType(
      FLAG_INSIGHT_NTH_FLIGHT,
      FlightInsightNthFlight(flights, hours=1, min_multiple_flights=2))

  # This is the [lowest / highest] [speed / altitude / climbrate] in the last 24 hours
  AppendMessageType(FLAG_INSIGHT_GROUNDSPEED, FlightInsightSuperlativeAttribute(
      flights, 'speed', 'groundspeed', SPEED_UNITS, ['slowest', 'fastest'], hours=24))
  AppendMessageType(FLAG_INSIGHT_ALTITUDE, FlightInsightSuperlativeAttribute(
      flights, 'altitude', 'altitude', DISTANCE_UNITS, ['lowest', 'highest'], hours=24))
  AppendMessageType(FLAG_INSIGHT_VERTRATE, FlightInsightSuperlativeVertrate(flights))

  # First instances: destination, first aircraft, etc.
  AppendMessageType(FLAG_INSIGHT_FIRST_DEST, FlightInsightFirstInstance(
      flights, 'destination_iata', 'destination', days=7,
      additional_descriptor_fcn=lambda f: f['destination_friendly']))
  AppendMessageType(FLAG_INSIGHT_FIRST_ORIGIN, FlightInsightFirstInstance(
      flights, 'origin_iata', 'origin', days=7,
      additional_descriptor_fcn=lambda f: f['origin_friendly']))
  AppendMessageType(FLAG_INSIGHT_FIRST_AIRLINE, FlightInsightFirstInstance(
      flights, 'airline_short_name', 'airline', days=7))
  AppendMessageType(FLAG_INSIGHT_FIRST_AIRCRAFT, FlightInsightFirstInstance(
      flights, 'aircraft_type_code', 'aircraft', days=7,
      additional_descriptor_fcn=lambda f: f['aircraft_type_friendly']))

  # This is the longest / shortest delay this flight has seen in the last 30 days at
  # 2h5m; including today, this flight has been delayed x of the last y times.
  AppendMessageType(FLAG_INSIGHT_LONGEST_DELAY, FlightInsightDelays(
      flights, min_late_percentage=0.75,
      min_this_delay_minutes=0,
      min_average_delay_minutes=0))

  def DelayTimeAndFrequencyMessage(
      types_tuple,
      group_function,
      group_label,
      min_days=1,
      lookback_days=30,
      min_this_group_size=0,
      min_comparison_group_size=0,
      min_group_qty=0,
      percentile_low=float('-inf'),
      percentile_high=float('inf')):
    value_function_tuple = (PercentDelay, AverageDelay)
    value_string_function_tuple = (
        lambda flights, value: '%d%% of flights delayed an average of %s' % (
            round(value*100), SecondsToHhMm(AverageDelay(flights))),
        lambda flights, value: 'average delay of %s' % SecondsToHhMm(value))
    value_label_tuple = ('delay frequency', 'delay time')
    for n in range(2):
      if types_tuple[n]:
        AppendMessageType(types_tuple[n], FlightInsightGroupPercentile(
            flights,
            group_function=group_function,
            value_function=value_function_tuple[n],
            value_string_function=value_string_function_tuple[n],
            group_label=group_label,
            value_label=value_label_tuple[n],
            min_days=min_days,
            min_this_group_size=min_this_group_size,
            min_comparison_group_size=min_comparison_group_size,
            min_group_qty=min_group_qty,
            lookback_days=lookback_days,
            percentile_low=percentile_low,
            percentile_high=percentile_high))

  # flight UAL1 (n=5) has a delay frequency in the 72nd %tile, with 100% of flights
  # delayed an average of 44m over the last 4d13h
  DelayTimeAndFrequencyMessage(
      (FLAG_INSIGHT_FLIGHT_DELAY_FREQUENCY, FLAG_INSIGHT_FLIGHT_DELAY_TIME),
      group_function=lambda flight: flight.get('flight_number', KEY_NOT_PRESENT_STRING),
      group_label='flight',
      min_days=1,
      min_this_group_size=4,
      min_comparison_group_size=0,
      min_group_qty=0,
      lookback_days=30,
      percentile_low=10,
      percentile_high=90)

  # Airline United (n=5) has a delay frequency in the 72nd %tile, with 100% of flights
  # delayed an average of 44m over the last 4d13h
  DelayTimeAndFrequencyMessage(
      (FLAG_INSIGHT_AIRLINE_DELAY_FREQUENCY, FLAG_INSIGHT_AIRLINE_DELAY_TIME),
      group_function=DisplayAirline,
      group_label='airline',
      min_days=1,
      min_this_group_size=10,
      min_comparison_group_size=5,
      min_group_qty=5,
      lookback_days=30,
      percentile_low=10,
      percentile_high=80)

  # Destination LAX (n=5) has a delay frequency in the 72nd %tile, with 100% of flights
  # delayed an average of 44m over the last 4d13h
  DelayTimeAndFrequencyMessage(
      (FLAG_INSIGHT_DESTINATION_DELAY_FREQUENCY, FLAG_INSIGHT_DESTINATION_DELAY_TIME),
      group_function=DisplayDestinationFriendly,
      group_label='destination',
      min_days=1,
      min_this_group_size=10,
      min_comparison_group_size=5,
      min_group_qty=5,
      lookback_days=30,
      percentile_low=10,
      percentile_high=90)

  # we only want to do this if we're already at ~75% of the number of flights we'd
  # expect to see for the hour
  flight_hours = {}
  for flight in flights:
    if flights[-1]['now'] - flight['now'] < 8.5 * SECONDS_IN_DAY and DisplayTime(
        flight, format_string='%-I%p') == DisplayTime(flights[-1], format_string='%-I%p'):
      flight_hours[DisplayTime(flight, format_string='%-d')] = flight_hours.get(
          DisplayTime(flight, format_string='%-d'), 0) + 1
  min_this_hour_flights = max(5, 0.75 * max(flight_hours.values()))

  # Once we've commented on the insights for an hour or day, we don't want to do it again
  hour_delay_frequency_flag = FLAG_INSIGHT_HOUR_DELAY_FREQUENCY
  hour_delay_time_flag = FLAG_INSIGHT_HOUR_DELAY_TIME
  date_delay_frequency_flag = FLAG_INSIGHT_DATE_DELAY_FREQUENCY
  date_delay_time_flag = FLAG_INSIGHT_DATE_DELAY_TIME
  for flight in flights[:-1]:
    insights = flight['insight_types']
    this_hour = DisplayTime(flights[-1], format_string='%x %-I%p')
    this_day = DisplayTime(flights[-1], format_string='%x')
    if (this_hour == DisplayTime(flight, format_string='%x %-I%p') and
        FLAG_INSIGHT_HOUR_DELAY_FREQUENCY in insights):
      hour_delay_frequency_flag = None
    if (this_hour == DisplayTime(flight, format_string='%x %-I%p') and
        FLAG_INSIGHT_HOUR_DELAY_TIME in insights):
      hour_delay_time_flag = None
    if (this_day == DisplayTime(flight, format_string='%x') and
        FLAG_INSIGHT_DATE_DELAY_FREQUENCY in insights):
      date_delay_frequency_flag = None
    if (this_day == DisplayTime(flight, format_string='%x') and
        FLAG_INSIGHT_DATE_DELAY_TIME in insights):
      date_delay_time_flag = None

  # 7a flights have a delay frequency in the 72nd %tile, with 100% of flights
  # delayed an average of 44m over the last 4d13h
  DelayTimeAndFrequencyMessage(
      (hour_delay_frequency_flag, hour_delay_time_flag),
      group_function=lambda f: DisplayTime(f, format_string='%-I%p') + ' hour',
      group_label='The',
      min_days=3,
      min_this_group_size=min_this_hour_flights,
      min_comparison_group_size=10,
      min_group_qty=10,
      lookback_days=7,
      percentile_low=10,
      percentile_high=90)

  # we only want to do this if we're already at ~75% of the number of flights we'd
  # expect to see for the day
  flight_days = {}
  for flight in flights:
    if flights[-1]['now'] - flight['now'] < 8.5 * SECONDS_IN_DAY:
      flight_days[DisplayTime(flight, format_string='%-d')] = flight_days.get(
          DisplayTime(flight, format_string='%-d'), 0) + 1
  min_this_day_flights = max(40, 0.75 * max(flight_days.values()))

  # Today (31st) has a delay frequency in the 72nd %tile, with 100% of flights
  # delayed an average of 44m over the last 4d13h
  DelayTimeAndFrequencyMessage(
      (date_delay_frequency_flag, date_delay_time_flag),
      group_function=lambda f:
      '(' + Ordinal(int(DisplayTime(f, format_string='%-d'))) + ')',
      group_label='Today',
      min_days=7,
      min_this_group_size=min_this_day_flights,
      min_comparison_group_size=40,
      min_group_qty=7,
      lookback_days=28,
      percentile_low=10,
      percentile_high=90)

  messages = [
      (t, textwrap.wrap(m, width=SPLITFLAP_CHARS_PER_LINE))
      for (t, m) in messages]

  return messages


def CreateFlightInsights(
    flights, flight_insights_enabled_string, insight_message_distribution):
  """Returns the desired quantity of flight insight messages.

  Though the function FlightInsights generates all possible insight messages about a
  flight, the user may have only wanted one. Depending on the setting of
  flight_insights_enabled_string, this function reduces the set of all insights by
  selecting the least-frequently reported type of insight message.

  In order to choose the least-frequently reported type, we need to keep track of what
  has been reported so far, which we do here in insight_message_distribution, and which
  we then update with each pass through this function.

  Args:
    flights: List of all flights where the last flight in the list is the focus flight
      for which we are trying to identify something interesting.
    flight_insights_enabled_string: string indicating how many insights are desired,
      which may be one of 'all', 'one', or 'hide'.
    insight_message_distribution: dictionary, where the keys are one of the flags
      indicating message type, and the values are how frequently that type of insight
      has been displayed in flights.  The dictionary is updated in place.

  Returns:
    Possibly-empty list of messages - the list may be empty if there are no insights,
    or if the setting selected for flight_insights_enabled_string is neither all or one.
    The messages, if included, are printable strings (with embedded new line characters).
  """
  naked_messages = []

  this_flight_insights = []

  if flight_insights_enabled_string not in ('all', 'one'):
    return naked_messages

  insight_messages = FlightInsights(flights)

  if flight_insights_enabled_string == 'all' and insight_messages:
    for (t, m) in insight_messages:
      insight_message_distribution[t] = insight_message_distribution.get(t, 0) + 1
      this_flight_insights.append(t)
      naked_messages.append(m)

  if flight_insights_enabled_string == 'one' and insight_messages:
    types_of_messages = [t for (t, unused_m) in insight_messages]
    frequencies_of_insights = [
        insight_message_distribution.get(t, 0) for t in types_of_messages]
    min_frequency = min(frequencies_of_insights)
    for t in sorted(types_of_messages):
      if insight_message_distribution.get(t, 0) == min_frequency:
        break

    insight_message_distribution[t] = insight_message_distribution.get(t, 0) + 1
    for message_tuple in insight_messages:
      if message_tuple[0] == t:
        naked_messages.append(message_tuple[1])
        this_flight_insights.append(t)
        break

  # Save the distribution displayed for this flight so we needn't regen it in future
  flights[-1]['insight_types'] = this_flight_insights

  return naked_messages


def MessageboardHistogramsTestHarness(
    flights=None,
    hist_type='day_of_month',
    hist_history='30d',
    max_screens='all',
    summary=False):
  """Test harness to generate messageboard histograms."""
  if flights is None:
    flights = UnpickleObjectFromFile(PICKLE_FLIGHTS_30D)

  messages = MessageboardHistograms(
      flights, hist_type, hist_history, max_screens, summary)

  for message in messages:
    print(message)

  return messages


def FlightCriteriaHistogramPng(
    flights,
    max_distance_feet,
    max_altitude_feet,
    max_days,
    filename=HOURLY_IMAGE_FILE,
    last_max_distance_feet=None,
    last_max_altitude_feet=None):
  """Saves as a png file the histogram of the hourly flight data for the given filters.

  Generates a png histogram of the count of flights by hour that meet the specified
  criteria: max altitude, max distance, and within the last number of days. Also
  optionally generates as a separate data series in same chart a histogram with a
  different max altitude and distance. Saves this histogram to disk.

  Args:
    flights: list of the flights.
    max_distance_feet: max distance for which to include flights in the histogram.
    max_altitude_feet: max altitude for which to include flights in the histogram.
    max_days: maximum number of days as described.
    filename: file into which to save the csv.
    last_max_distance_feet: if provided, along with last_max_altitude_feet, generates
        a second data series with different criteria for distance and altitude, for
        which the histogram data will be plotted alongside the first series.
    last_max_altitude_feet: see above.
  """
  (values, keys, unused_filtered_data) = GenerateHistogramData(
      flights,
      HourString,
      HOURS,
      hours=max_days*HOURS_IN_DAY,
      max_distance_feet=max_distance_feet,
      max_altitude_feet=max_altitude_feet,
      normalize_factor=max_days,
      exhaustive=True)

  comparison = last_max_distance_feet is not None and last_max_altitude_feet is not None
  if comparison:
    (last_values, unused_last_keys, unused_filtered_data) = GenerateHistogramData(
        flights,
        HourString,
        HOURS,
        hours=max_days*HOURS_IN_DAY,
        max_distance_feet=last_max_distance_feet,
        max_altitude_feet=last_max_altitude_feet,
        normalize_factor=max_days,
        exhaustive=True)

  x = numpy.arange(len(keys))
  unused_fig, ax = matplotlib.pyplot.subplots()
  width = 0.35
  ax.bar(
      x - width/2, values, width,
      label='Current - alt: %d; dist: %d' % (max_altitude_feet, max_distance_feet))
  title = 'Daily Flights Expected: %d / day' % sum(values)
  if comparison:
    ax.bar(
        x + width/2, last_values, width,
        label='Prior - alt: %d; dist: %d' % (
            last_max_altitude_feet, last_max_distance_feet))
    title += ' (%+d)' % (round(sum(values) - sum(last_values)))

  ax.set_title(title)
  ax.set_ylabel('Average Observed Flights')
  if comparison:
    ax.legend()
  matplotlib.pyplot.xticks(
      x, keys, rotation='vertical', wrap=True,
      horizontalalignment='right',
      verticalalignment='center')

  matplotlib.pyplot.savefig(filename)
  matplotlib.pyplot.close()


def GenerateHistogramData(
    data,
    keyfunction,
    sort_type,
    truncate=float('inf'),
    hours=float('inf'),
    max_distance_feet=float('inf'),
    max_altitude_feet=float('inf'),
    normalize_factor=0,
    exhaustive=False):
  """Generates sorted data for a histogram from a description of the flights.

  Given an iterable describing the flights, this function generates the label (or key),
  and the frequency (or value) from which a histogram can be rendered.

  Args:
    data: the iterable of the raw data from which the histogram will be generated;
        each element of the iterable is a dictionary, that contains at least the key
        'now', and depending on other parameters, also potentially
        'min_feet' amongst others.
    keyfunction: the function that determines how the key or label of the histogram
        should be generated; it is called for each element of the data iterable. For
        instance, to simply generate a histogram on the attribute 'heading',
        keyfunction would be lambda a: a['heading'].
    sort_type: determines how the keys (and the corresponding values) are sorted:
        'key': the keys are sorted by a simple comparison operator between them, which
            sorts strings alphabetically and numbers numerically.
        'value': the keys are sorted by a comparison between the values, which means
            that more frequency-occurring keys are listed first.
        list: if instead of the strings a list is passed, the keys are then sorted in
            the sequence enumerated in the list. This is useful for, say, ensuring that
            the days of the week (Tues, Wed, Thur, ...) are listed in sequence. Keys
            that are generated by keyfunction but that are not in the given list are
            sorted last (and then amongst those, alphabetically).
    truncate: integer indicating the maximum number of keys to return; if set to 0, or if
        set to a value larger than the number of keys, no truncation occurs. But if set
        to a value less than the number of keys, then the keys with the lowest frequency
        are combined into one key named OTHER_STRING so that the number of keys
        in the resulting histogram (together with OTHER_STRING) is equal to truncate.
    hours: integer indicating the number of hours of history to include. Flights with a
        calcd_display_time more than this many hours in the past are excluded from the
        histogram generation. Note that this is timezone aware, so that if the histogram
        data is generated on a machine with a different timezone than that that recorded
        the original data, the correct number of hours is still honored.
    max_distance_feet: number indicating the geo fence outside of which flights should
        be ignored for the purposes of including the flight data in the histogram.
    max_altitude_feet: number indicating the maximum altitude outside of which flights
        should be ignored for the purposes of including the flight data in the histogram.
    normalize_factor: divisor to apply to all the values, so that we can easily
        renormalize the histogram to display on a percentage or daily basis; if zero,
        no renormalization is applied.
    exhaustive: boolean only relevant if sort_type is a list, in which case, this ensures
        that the returned set of keys (and matching values) contains all the elements in
        the list, including potentially those with a frequency of zero, within the
        restrictions of truncate.

  Returns:
    2-tuple of lists cut and sorted as indicated by parameters above:
    - list of values (or frequency) of the histogram elements
    - list of keys (or labels) of the histogram elements
  """
  histogram_dict = {}
  filtered_data = []

  # get timezone & now so that we can generate a timestamp for comparison just once
  if hours:
    now = datetime.datetime.now(TZ)
  for element in data:
    if (
        element.get('min_feet', float('inf')) <= max_distance_feet and
        element.get('altitude', float('inf')) <= max_altitude_feet and
        HoursSinceFlight(now, element['now']) <= hours):
      filtered_data.append(element)
      key = keyfunction(element)
      if key is None or key == '':
        key = KEY_NOT_PRESENT_STRING
      if key in histogram_dict:
        histogram_dict[key] += 1
      else:
        histogram_dict[key] = 1
  values = list(histogram_dict.values())
  keys = list(histogram_dict.keys())

  if normalize_factor:
    values = [v / normalize_factor for v in values]

  sort_by_enumerated_list = isinstance(sort_type, list)
  if exhaustive and sort_by_enumerated_list:
    missing_keys = set(sort_type).difference(set(keys))
    missing_values = [0 for unused_k in missing_keys]
    keys.extend(missing_keys)
    values.extend(missing_values)

  if keys:  # filters could potentially have removed all data
    if not truncate or len(keys) <= truncate:
      if sort_by_enumerated_list:
        (values, keys) = SortByDefinedList(values, keys, sort_type)
      elif sort_type == 'value':
        (values, keys) = SortByValues(values, keys)
      else:
        (values, keys) = SortByKeys(values, keys)
    else: #Unknown might fall in the middle, and so shouldn't be truncated
      (values, keys) = SortByValues(values, keys, ignore_sort_at_end_strings=True)

      truncated_values = list(values[:truncate-1])
      truncated_keys = list(keys[:truncate-1])
      other_value = sum(values[truncate-1:])
      truncated_values.append(other_value)
      truncated_keys.append(OTHER_STRING)
      if sort_by_enumerated_list:
        (values, keys) = SortByDefinedList(
            truncated_values, truncated_keys, sort_type)
      elif sort_type == 'value':
        (values, keys) = SortByValues(
            truncated_values, truncated_keys, ignore_sort_at_end_strings=False)
      else:
        (values, keys) = SortByKeys(truncated_values, truncated_keys)
  else:
    values = []
    keys = []
  return (values, keys, filtered_data)


def SortByValues(values, keys, ignore_sort_at_end_strings=False):
  """Sorts the list of values in descending sequence, applying same resorting to keys.

  Given a list of keys and values representing a histogram, returns two new lists that
  are sorted so that the values occur in descending sequence and the keys are moved
  around in the same way. This allows the printing of a histogram with the largest
  keys listed first - i.e.: top five airlines.

  Keys identified by SORT_AT_END_STRINGS - such as, perhaps, 'Other' - will optionally
  be placed at the end of the sequence. And where values are identical, the secondary
  sort is based on the keys.

  Args:
    values: list of values for the histogram to be used as the primary sort key.
    keys: list of keys for the histogram that will be moved in the same way as the values.
    ignore_sort_at_end_strings: boolean indicating whether specially-defined keys will be
        sorted at the end.





                            <----SKIPPED LINES---->




    keyfunction,
    sort_type,
    title,
    position=None,
    truncate=0,
    hours=float('inf'),
    max_distance_feet=float('inf'),
    max_altitude_feet=float('inf'),
    normalize_factor=0,
    exhaustive=False,
    figsize_inches=(9, 6)):
  """Creates matplotlib.pyplot of histogram that can then be saved or printed.

  Args:
    data: the iterable (i.e.: list) of flight details, where each element in the list is
        a dictionary of the flight attributes.
    keyfunction: a function that when applied to a single flight (i.e.:
        keyfunction(data[0]) returns the key to be used for the histogram.
    data: the iterable of the raw data from which the histogram will be generated;
        each element of the iterable is a dictionary, that contains at least the key
        'now', and depending on other parameters, also potentially
        'min_feet' amongst others.
    keyfunction: the function that determines how the key or label of the histogram
        should be generated; it is called for each element of the data iterable. For
        instance, to simply generate a histogram on the attribute 'heading',
        keyfunction would be lambda a: a['heading'].
    title: the "base" title to include on the histogram; it will additionally be
        augmented with the details about the date range.
    position: Either a 3-digit integer or an iterable of three separate integers
        describing the position of the subplot. If the three integers are nrows, ncols,
        and index in order, the subplot will take the index position on a grid with nrows
        rows and ncols columns. index starts at 1 in the upper left corner and increases
        to the right.
    sort_type: determines how the keys (and the corresponding values) are sorted:
        'key': the keys are sorted by a simple comparison operator between them, which
            sorts strings alphabetically and numbers numerically.
        'value': the keys are sorted by a comparison between the values, which means
            that more frequency-occurring keys are listed first.
        list: if instead of the strings a list is passed, the keys are then sorted in
            the sequence enumerated in the list. This is useful for, say, ensuring that
            the days of the week (Tues, Wed, Thur, ...) are listed in sequence. Keys
            that are generated by keyfunction but that are not in the given list are




                            <----SKIPPED LINES---->




        the list, including potentially those with a frequency of zero, within the
        restrictions of truncate.
    figsize_inches: a 2-tuple of width, height indicating the size of the histogram.
  """
  (values, keys, filtered_data) = GenerateHistogramData(
      data,
      keyfunction,
      sort_type,
      truncate=truncate,
      hours=hours,
      max_distance_feet=max_distance_feet,
      max_altitude_feet=max_altitude_feet,
      normalize_factor=normalize_factor,
      exhaustive=exhaustive)
  if position:
    matplotlib.pyplot.subplot(*position)
  matplotlib.pyplot.figure(figsize=figsize_inches)
  values_coordinates = numpy.arange(len(keys))
  matplotlib.pyplot.bar(values_coordinates, values)

  earliest_flight_time = int(filtered_data[0]['now'])
  last_flight_time = int(filtered_data[-1]['now'])
  date_range_string = ' (%d flights over last %s hours)' % (
      sum(values), SecondsToDdHh(last_flight_time - earliest_flight_time))

  matplotlib.pyplot.title(title + date_range_string)

  matplotlib.pyplot.subplots_adjust(bottom=0.15, left=0.09, right=0.99, top=0.92)

  matplotlib.pyplot.xticks(
      values_coordinates, keys, rotation='vertical', wrap=True,
      horizontalalignment='right',
      verticalalignment='center')













































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































def HistogramSettingsHours(how_much_history):
  """Extracts the desired history (in hours) from the histogram configuration string.

  Args:
    how_much_history: string from the histogram config file.

  Returns:
    Number of hours of history to include in the histogram.
  """
  if how_much_history == 'today':
    hours = HoursSinceMidnight()
  elif how_much_history == '24h':
    hours = 24
  elif how_much_history == '7d':
    hours = 7 * HOURS_IN_DAY
  elif how_much_history == '30d':
    hours = 30 * HOURS_IN_DAY
  else:




                            <----SKIPPED LINES---->




  Args:
    max_screens: string from the histogram config file.

  Returns:
    Number of maximum number of screens to display for a splitflap histogram.
  """
  if max_screens == '_1':
    screen_limit = 1
  elif max_screens == '_2':
    screen_limit = 2
  elif max_screens == '_5':
    screen_limit = 5
  elif max_screens == 'all':
    screen_limit = 0  # no limit on screens
  else:
    LogMessage('Histogram form has invalid value for max_screens: %s' % max_screens)
    screen_limit = 1
  return screen_limit


def HistogramSettingsKeySortTitle(which, max_altitude=45000):
  """Provides the arguments necessary to generate a histogram from the config string.

  The same parameters are used to generate either a splitflap text or web-rendered
  histogram in terms of the histogram title, the keyfunction, and how to sort the keys.
  For a given histogram name (based on the names defined in the histogram config file),
  this provides those parameters.

  Args:
    which: string from the histogram config file indicating the histogram to provide
        settings for.
    max_altitude: indicates the maximum altitude that should be included on the
        altitude labels.



  Returns:
    A 3-tuple of the parameters used by either CreateSingleHistogramChart or
    MessageboardHistogram, of the keyfunction, sort, and title.
  """
  def DivideAndFormat(dividend, divisor):
    if isinstance(dividend, numbers.Number):
      return '%2d' % round(dividend / divisor)
    return dividend[:2]

  if which == 'destination':
    key = lambda k: k.get('destination_iata', KEY_NOT_PRESENT_STRING)
    sort = 'value'
    title = 'Destination'
  elif which == 'origin':
    key = lambda k: k.get('origin_iata', KEY_NOT_PRESENT_STRING)
    sort = 'value'
    title = 'Origin'
  elif which == 'hour':
    key = lambda k: DisplayTime(k, '%H')

    sort = 'key'
    title = 'Hour'
  elif which == 'airline':
    key = DisplayAirline
    sort = 'value'
    title = 'Airline'
  elif which == 'aircraft':
    key = lambda k: k.get('aircraft_type_code', KEY_NOT_PRESENT_STRING)
    sort = 'value'
    title = 'Aircraft'
  elif which == 'altitude':
    key = lambda k: DivideAndFormat(k.get('altitude', KEY_NOT_PRESENT_STRING), 1000)
    sort = ['%2d'%x for x in range(0, round((max_altitude+1)/1000))]
    title = 'Altitude (1000ft)'
  elif which == 'bearing':
    key = lambda k: ConvertBearingToCompassDirection(
        k.get('track', KEY_NOT_PRESENT_STRING), pad=True, length=3)
    sort = [d.rjust(3) for d in DIRECTIONS_16]
    title = 'Bearing'
  elif which == 'distance':
    key = lambda k: DivideAndFormat(k.get('min_feet', KEY_NOT_PRESENT_STRING), 100)
    sort = ['%2d'%x for x in range(0, round((MIN_METERS*FEET_IN_METER)/100)+1)]
    title = 'Min Dist (100ft)'
  elif which == 'day_of_week':
    key = lambda k: DisplayTime(k, '%a')

    sort = DAYS_OF_WEEK
    title = 'Day of Week'
  elif which == 'day_of_month':
    key = lambda k: DisplayTime(k, '%-d').rjust(2)

    today_day = datetime.datetime.now(TZ).day
    days = list(range(today_day, 0, -1))  # today down to the first of the month
    days.extend(range(31, today_day, -1))  # 31st of the month down to day after today
    days = [str(d).rjust(2) for d in days]
    sort = days
    title = 'Day of Month'
  else:
    LogMessage(
        'Histogram form has invalid value for which_histograms: %s' % which)
    return HistogramSettingsKeySortTitle(
        'destination', max_altitude=max_altitude)

  return (key, sort, title)


def ImageHistograms(
    flights,
    which_histograms,
    how_much_history,
    filename_prefix=HISTOGRAM_IMAGE_PREFIX,
    filename_suffix=HISTOGRAM_IMAGE_SUFFIX):
  """Generates multiple split histogram images.

  Args:
    flights: the iterable of the raw data from which the histogram will be generated;
        each element of the iterable is a dictionary, that contains at least the key
        'now', and depending on other parameters, also potentially
        'min_feet' amongst others.
    which_histograms: string paramater indicating which histogram(s) to generate, which
        can be either the special string 'all', or a string linked to a specific
        histogram.
    how_much_history: string parameter taking a value among ['today', '24h', '7d', '30d].
    filename_prefix: this string indicates the file path and name prefix for the images
        that are created. File names are created in the form [prefix]name.[suffix], i.e.:
        if the prefix is histogram_ and the suffix is png, then the file name might be
        histogram_aircraft.png.
    filename_suffix: see above; also interpreted by savefig to generate the correct
        format.

  Returns:
    List of the names of the histograms generated.
  """
  hours = HistogramSettingsHours(how_much_history)

  histograms_to_generate = []
  if which_histograms in ['destination', 'all']:
    histograms_to_generate.append({'generate': 'destination'})
  if which_histograms in ['origin', 'all']:




                            <----SKIPPED LINES---->




        exhaustive=histogram.get('exhaustive', False))
    filename = filename_prefix + histogram['generate'] + '.' + filename_suffix
    matplotlib.pyplot.savefig(filename)
    matplotlib.pyplot.close()

  histograms_generated = [h['generate'] for h in histograms_to_generate]
  return histograms_generated


def MessageboardHistograms(
    flights,
    which_histograms,
    how_much_history,
    max_screens,
    data_summary):
  """Generates multiple split flap screen histograms.

  Args:
    flights: the iterable of the raw data from which the histogram will be generated;
        each element of the iterable is a dictionary, that contains at least the key
        'now', and depending on other parameters, also potentially
        'min_feet' amongst others.
    which_histograms: string paramater indicating which histogram(s) to generate, which
        can be either the special string 'all', or a string linked to a specific
        histogram.
    how_much_history: string parameter taking a value among ['today', '24h', '7d', '30d].
    max_screens: string parameter taking a value among ['_1', '_2', '_5', or 'all'].
    data_summary: parameter that evaluates to a boolean indicating whether the data
        summary screen in the histogram should be displayed.

  Returns:
    Returns a list of printable strings (with embedded new line characters) representing
    the histogram, for each screen in the histogram.
  """
  messages = []

  hours = HistogramSettingsHours(how_much_history)
  screen_limit = HistogramSettingsScreens(max_screens)

  histograms_to_generate = []
  if which_histograms in ['destination', 'all']:
    histograms_to_generate.append({
        'generate': 'destination',
        'suppress_percent_sign': True,
        'columns': 3})
  if which_histograms in ['origin', 'all']:
    histograms_to_generate.append({
        'generate': 'origin',
        'suppress_percent_sign': True,
        'columns': 3})
  if which_histograms in ['hour', 'all']:
    histograms_to_generate.append({
        'generate': 'hour',
        'columns': 3,
        'suppress_percent_sign': True,
        'column_divider': '|'})
  if which_histograms in ['airline', 'all']:
    histograms_to_generate.append({
        'generate': 'airline'})
  if which_histograms in ['aircraft', 'all']:
    histograms_to_generate.append({
        'generate': 'aircraft'})
  if which_histograms in ['altitude', 'all']:
    histograms_to_generate.append({
        'generate': 'altitude',
        'columns': 3})
  if which_histograms in ['bearing', 'all']:
    histograms_to_generate.append({
        'generate': 'bearing',
        'suppress_percent_sign': True,
        'columns': 3})
  if which_histograms in ['distance', 'all']:
    histograms_to_generate.append({
        'generate': 'distance',
        'columns': 3})
  if ((which_histograms == 'all' and how_much_history == '7d')
      or which_histograms == 'day_of_week'):
    histograms_to_generate.append({
        'generate': 'day_of_week',
        'columns': 3,
        'absolute': True})
  if ((which_histograms == 'all' and how_much_history == '30d')
      or which_histograms == 'day_of_month'):
    histograms_to_generate.append({
        'generate': 'day_of_month',
        'columns': 3,
        'suppress_percent_sign': True,
        'column_divider': '|',
        'absolute': True})

  for histogram in histograms_to_generate:
    this_histogram = which_histograms
    if this_histogram == 'all':
      this_histogram = histogram['generate']
    (key, sort, title) = HistogramSettingsKeySortTitle(this_histogram)

    histogram = MessageboardHistogram(
        flights,
        key,
        sort,
        title,
        screen_limit=screen_limit,
        columns=histogram.get('columns', 2),
        suppress_percent_sign=histogram.get('suppress_percent_sign', False),
        column_divider=histogram.get('column_divider', ' '),
        data_summary=data_summary,
        hours=hours,
        absolute=histogram.get('absolute', False))
    messages.extend(histogram)

  return messages


def MessageboardHistogram(
    data,
    keyfunction,
    sort_type,
    title,
    screen_limit=1,
    columns=2,
    column_divider=' ',
    data_summary=False,
    hours=0,
    suppress_percent_sign=False,
    absolute=False):
  """Generates a text representation of one histogram that can be rendered on the display.

  Args:
    data: the iterable of the raw data from which the histogram will be generated;
        each element of the iterable is a dictionary, that contains at least the key
        'now', and depending on other parameters, also potentially
        'min_feet' amongst others.
    keyfunction: the function that determines how the key or label of the histogram
        should be generated; it is called for each element of the data iterable. For
        instance, to simply generate a histogram on the attribute 'heading',
        keyfunction would be lambda a: a['heading'].
    sort_type: determines how the keys (and the corresponding values) are sorted; see
        GenerateHistogramData docstring for details
    title: string title, potentially truncated to fit, to be displayed for the histogram
    screen_limit: maximum number of screens to be displayed for the histogram; a value
        of zero is interpreted to mean no limit on screens.
    columns: number of columns of data to be displayed for the histogram; note that the
        keys of the histogram may need to be truncated in length to fit the display
        as more columns are squeezed into the space
    column_divider: string for the character(s) to be used to divide the columns
    data_summary: boolean indicating whether to augment the title with a second header
        line about the data presented in the histogram
    hours: integer indicating the oldest data to be included in the histogram
    suppress_percent_sign: boolean indicating whether to suppress the percent sign
        in the data (but to add it to the title) to reduce the amount of string
        truncation potentially necessary for display of the keys
    absolute: boolean indicating whether to values should be presented as percentage or
        totals; if True, suppress_percent_sign is irrelevant.

  Returns:
    Returns a list of printable strings (with embedded new line characters) representing
    the histogram.
  """
  title_lines = 1
  if data_summary:
    title_lines += 1
  available_entries_per_screen = (SPLITFLAP_LINE_COUNT - title_lines) *  columns
  available_entries_total = available_entries_per_screen * screen_limit
  (values, keys, unused_filtered_data) = GenerateHistogramData(
      data, keyfunction, sort_type, truncate=available_entries_total, hours=hours)

  screen_count = math.ceil(len(keys) / available_entries_per_screen)

  column_width = int(
      (SPLITFLAP_CHARS_PER_LINE - len(column_divider)*(columns - 1)) / columns)
  leftover_space = SPLITFLAP_CHARS_PER_LINE - (
      column_width*columns + len(column_divider)*(columns - 1))
  extra_divider_chars = math.floor(leftover_space / (columns - 1))
  column_divider = column_divider.ljust(len(column_divider) + extra_divider_chars)

  # i.e.: ' 10%' or ' 10', depending on suppress_percent_sign

  printed_percent_sign = ''
  if absolute:
    digits = math.floor(math.log10(max(values))) + 1
    value_size = digits + 1
    augment_title_units = ' #'
    format_string = '%%%dd' % digits
  else:
    value_size = 3
    augment_title_units = ' %'
    if not suppress_percent_sign:
      value_size += 1
      printed_percent_sign = '%'
      augment_title_units = ''
  column_key_width = column_width - value_size

  total = sum(values)

  if data_summary:
    if hours:
      hours_of_data = min(hours, DataHistoryHours(data))
    else:
      hours_of_data = DataHistoryHours(data)
    time_horizon_text = 'Last %s' % SecondsToDdHh(hours_of_data * SECONDS_IN_HOUR)

    summary_text = '%s (n=%d)' % (time_horizon_text, sum(values))
    summary_text = summary_text.center(SPLITFLAP_CHARS_PER_LINE)

  split_flap_boards = []
  for screen in range(screen_count):
    if screen_count == 1:
      counter = ''
    else:
      counter = ' %d/%d' % (screen+1, screen_count)
    screen_title = '%s%s%s' % (
        title[:SPLITFLAP_CHARS_PER_LINE - len(counter) - len(augment_title_units)],
        augment_title_units, counter)

    screen_title = screen_title.center(SPLITFLAP_CHARS_PER_LINE)
    start_index = screen*available_entries_per_screen
    end_index = min((screen+1)*available_entries_per_screen-1, len(keys)-1)
    number_of_entries = end_index - start_index + 1
    number_of_lines = math.ceil(number_of_entries / columns)

    lines = []

    lines.append(screen_title.upper())
    if data_summary:
      lines.append(summary_text.upper())
    for line_index in range(number_of_lines):
      key_value = []
      for column_index in range(columns):
        index = start_index + column_index*number_of_lines + line_index
        if index <= end_index:
          if absolute:
            value_string = format_string % values[index]




          else:
            # If the % is >=1%, display right-justified 2 digit percent, i.e. ' 5%'
            # Otherwise, if it rounds to at least 0.1%, display i.e. '.5%'
            if round(values[index]/total*100) >= 1:
              value_string = '%2d' % round(values[index]/total*100)
            elif round(values[index]/total*1000)/10 >= 0.1:
              value_string = ('%.1f' % (round(values[index]/total*1000)/10))[1:]
            else:
              value_string = ' 0'
          key_value.append('%s %s%s' % (
              str(keys[index])[:column_key_width].ljust(column_key_width),
              value_string,
              printed_percent_sign))

      line = (column_divider.join(key_value)).upper()
      lines.append(line)

    split_flap_boards.append(lines)

  return split_flap_boards





































































































































































































































































































































































































































def TriggerHistograms(flights, histogram_settings):
  """Triggers the text-based or web-based histograms.

  Based on the histogram settings, determines whether to generate text or image histograms
  (or both). For image histograms, also generates empty images for the histograms not
  created so that broken image links are not displayed in the webpage.

  Args:
    flights: List of flight attribute dictionaries.
    histogram_settings: Dictionary of histogram parameters.

  Returns:
    List of histogram messages, if text-based histograms are selected; empty list
    otherwise.
  """
  histogram_messages = []

  if histogram_settings['type'] in ('messageboard', 'both'):




                            <----SKIPPED LINES---->




        histogram_settings['histogram_history'],
        histogram_settings['histogram_max_screens'],
        histogram_settings.get('histogram_data_summary', False))
  if histogram_settings['type'] in ('images', 'both'):
    histograms_generated = ImageHistograms(
        flights,
        histogram_settings['histogram'],
        histogram_settings['histogram_history'])
    all_available_histograms = [
        'destination', 'origin', 'hour', 'airline', 'aircraft', 'altitude',
        'bearing', 'distance', 'day_of_week', 'day_of_month']
    for histogram in all_available_histograms:
      if histogram not in histograms_generated:
        missing_filename = (
            HISTOGRAM_IMAGE_PREFIX + histogram + '.' + HISTOGRAM_IMAGE_SUFFIX)
        shutil.copyfile(HISTOGRAM_EMPTY_IMAGE_FILE, missing_filename)

  return histogram_messages


def SaveFlightsByAltitudeDistanceCSV(
    flights,
    max_days=0,
    filename='flights_by_alt_dist.csv',
    precision=100):
  """Extracts hourly histogram into text file for a variety of altitudes and distances.

  Generates a csv with 26 columns:
  - col#1: altitude (in feet)
  - col#2: distance (in feet)
  - cols#3-26: hour of the day

  The first row is a header row; subsequent rows list the number of flights that have
  occurred in the last max_days with an altitude and min distance less than that identified
  in the first two columns. Each row increments elevation or altitude by precision feet,
  up to the max determined by the max altitude and max distance amongst all the flights.

  Args:
    flights: list of the flights.
    max_days: maximum number of days as described.
    filename: file into which to save the csv.
    precision: number of feet to increment the altitude or distance.
  """
  max_altitude = int(round(max([flight.get('altitude', -1) for flight in flights])))
  max_distance = int(round(max([flight.get('min_feet', -1) for flight in flights])))
  min_altitude = int(round(
      min([flight.get('altitude', float('inf')) for flight in flights])))
  min_distance = int(round(
      min([flight.get('min_feet', float('inf')) for flight in flights])))
  max_hours = max_days * HOURS_IN_DAY

  lines = []
  now = datetime.datetime.now()

  header_elements = ['altitude_feet', 'min_distance_feet', *[str(h) for h in HOURS]]
  line = ','.join(header_elements)
  lines.append(line)

  altitudes = list(range(
      precision * int(min_altitude / precision),
      precision * (int(max_altitude / precision) + 2),
      precision))
  distances = list(range(
      precision * int(min_distance / precision),
      precision * (int(max_distance / precision) + 2),
      precision))

  # Flight counts where either the altitude or min_feet is unknown
  line_elements = ['undefined', 'undefined']
  for hour in HOURS:
    line_elements.append(str(len([
        1 for f in flights if
        (not max_hours or HoursSinceFlight(now, f['now']) < max_hours) and
        (f.get('altitude') is None or f.get('min_feet') is None) and
        HourString(f) == hour])))
  line = ','.join(line_elements)
  lines.append(line)

  d = {}
  for flight in flights:
    if 'altitude' in flight and 'min_feet' in flight:
      this_altitude = flight['altitude']
      this_distance = flight['min_feet']
      hour = HourString(flight)
      for altitude in [a for a in altitudes if a >= this_altitude]:
        for distance in [d for d in distances if d >= this_distance]:
          d[(altitude, distance, hour)] = d.get((altitude, distance, hour), 0) + 1
  for altitude in altitudes:
    for distance in distances:
      line_elements = [str(altitude), str(distance)]
      for hour in HOURS:
        line_elements.append(str(d.get((altitude, distance, hour), 0)))
      line = ','.join(line_elements)
      lines.append(line)
  try:
    with open(filename, 'w') as f:
      for line in lines:
        f.write(line+'\n')
  except IOError:
    LogMessage('Unable to write hourly histogram data file ' + filename)


def SaveFlightsToCSV(flights=None, filename='flights.csv'):
  """Saves all the attributes about the flight to a CSV, including on-the-fly attributes.

  Args:
    flights: dictionary of flight attributes; if not provided, loaded from
      PICKLE_FLIGHTS_30D.
    filename: name of desired csv file; if not provided, defaults to flights.csv.
  """
  if not flights:
    flights = UnpickleObjectFromFile(PICKLE_FLIGHTS_30D)

  print('='*80)
  print('Number of flights to save to %s: %d' % (filename, len(flights)))

  # list of functions in 2-tuple, where second element is a function that generates
  # something about the flight, and the first element is the name to give that value
  # when extended into the flight definition
  functions = [
      ('display_flight_number', DisplayFlightNumber),
      ('display_airline', DisplayAirline),
      ('display_aircraft', DisplayAircraft),
      ('display_origin_iata', DisplayOriginIata),
      ('display_destination_iata', DisplayDestinationIata),
      ('display_origin_friendly', DisplayOriginFriendly),
      ('display_destination_friendly', DisplayDestinationFriendly),
      ('display_origin_destination_pair', DisplayOriginDestinationPair),
      ('display_seconds_remaining', DisplaySecondsRemaining),
      ('now_datetime', DisplayTime),
      ('now_date', lambda flight: DisplayTime(flight, '%x')),
      ('now_time', lambda flight: DisplayTime(flight, '%X'))]

  for function in functions:
    for flight in flights:
      flight[function[0]] = function[1](flight)

  # these functions return dictionary of values
  functions = [
      lambda f: FlightAnglesSecondsElapsed(f, 0, '_00s'),
      lambda f: FlightAnglesSecondsElapsed(f, 10, '_10s'),
      lambda f: FlightAnglesSecondsElapsed(f, 20, '_20s'),
      DisplayDepartureTimes]
  for function in functions:
    for flight in flights:
      flight.update(function(flight))

  all_keys = set()
  for f in flights:
    all_keys.update(f.keys())
  all_keys = list(all_keys)
  all_keys.sort()

  keys_logical_order = [
      'now_date', 'now_time', 'now_datetime', 'now', 'flight_number', 'origin_iata',
      'destination_iata', 'altitude', 'min_feet', 'vert_rate', 'speed', 'distance',
      'delay_seconds', 'airline_call_sign', 'aircraft_type_friendly',
      'azimuth_degrees_00s', 'azimuth_degrees_10s', 'azimuth_degrees_20s',
      'altitude_degrees_00s', 'altitude_degrees_10s', 'altitude_degrees_20s',
      'ground_distance_feet_00s', 'ground_distance_feet_10s', 'ground_distance_feet_20s',
      'crow_distance_feet_00s', 'crow_distance_feet_10s', 'crow_distance_feet_20s']
  for key in all_keys:
    if key not in keys_logical_order:
      keys_logical_order.append(key)

  f = open(filename, 'w')
  f.write(','.join(keys_logical_order)+'\n')
  for flight in flights:
    f.write(','.join(['"'+str(flight.get(k))+'"' for k in keys_logical_order])+'\n')
  f.close()


def SimulationSetup():
  """Updates global variable file names and loads in JSON data for simulation runs."""
  global SIMULATION
  SIMULATION = True

  global DUMP_JSONS
  DUMP_JSONS = UnpickleObjectFromFile(PICKLE_DUMP_JSON_FILE)

  global FA_JSONS
  FA_JSONS = UnpickleObjectFromFile(PICKLE_FA_JSON_FILE)

  global ALL_MESSAGE_FILE
  ALL_MESSAGE_FILE = SIMULATION_PREFIX + ALL_MESSAGE_FILE
  if os.path.exists(ALL_MESSAGE_FILE):
    os.remove(ALL_MESSAGE_FILE)

  global LOGFILE
  LOGFILE = SIMULATION_PREFIX + LOGFILE
  if os.path.exists(LOGFILE):
    os.remove(LOGFILE)

  global ROLLING_LOGFILE
  ROLLING_LOGFILE = SIMULATION_PREFIX + ROLLING_LOGFILE
  if os.path.exists(ROLLING_LOGFILE):
    os.remove(ROLLING_LOGFILE)


  global ROLLING_MESSAGE_FILE
  ROLLING_MESSAGE_FILE = SIMULATION_PREFIX + ROLLING_MESSAGE_FILE
  if os.path.exists(ROLLING_MESSAGE_FILE):
    os.remove(ROLLING_MESSAGE_FILE)

  global PICKLE_FLIGHTS_30D
  PICKLE_FLIGHTS_30D = SIMULATION_PREFIX + PICKLE_FLIGHTS_30D
  if os.path.exists(PICKLE_FLIGHTS_30D):
    os.remove(PICKLE_FLIGHTS_30D)

  global PICKLE_FLIGHTS_ARCHIVE
  PICKLE_FLIGHTS_ARCHIVE = SIMULATION_PREFIX + PICKLE_FLIGHTS_ARCHIVE
  if os.path.exists(PICKLE_FLIGHTS_ARCHIVE):
    os.remove(PICKLE_FLIGHTS_ARCHIVE)

  if os.path.exists(ARDUINO_FILE):
    os.remove(ARDUINO_FILE)


def SimulationEnd(message_queue, flights):
  """Clears message buffer, exercises histograms, and other misc test & status code.

  Args:
    message_queue: List of flight messages that have not yet been printed.
    flights: List of flights dictionaries.
  """
  if flights:
    histogram = {
        'type': 'both',
        'histogram':'all',
        'histogram_history':'30d',
        'histogram_max_screens': '_2',
        'histogram_data_summary': 'on'}
    histogram_messages = TriggerHistograms(flights, histogram)
    histogram_messages = [(FLAG_MSG_HISTOGRAM, m) for m in histogram_messages]
    message_queue.extend(histogram_messages)

    while message_queue:
      ManageMessageQueue(message_queue, 0, {'setting_delay': 0})
    SaveFlightsByAltitudeDistanceCSV(flights)
    SaveFlightsToCSV(flights)

    # repickle to a new .pk with full track info
  file_parts = PICKLE_FLIGHTS_30D.split('.')
  new_pickle_file = '.'.join([file_parts[0] + '_full_path', file_parts[1]])
  if os.path.exists(new_pickle_file):
    os.remove(new_pickle_file)
  for flight in flights:
    PickleObjectToFile(flight, new_pickle_file)

  print('Simulation complete after %s dump json messages processed' % len(DUMP_JSONS))


def DumpJsonChanges():
  """Identifies if sequential dump json files changes, for simulation optimization.

  If we are logging the radio output faster than it is updating, then there will be
  sequential log files in the json list that are identical; we only need to process the
  first of these, and can ignore subsequent ones, without any change of output in the
  simulation results. This function identifies whether the current active json changed
  from the prior one.

  Returns:
    Boolean - True if different (and processing needed), False if identical
  """
  if SIMULATION_COUNTER == 0:
    return True
  (this_json, unused_now) = DUMP_JSONS[SIMULATION_COUNTER]
  (last_json, unused_now) = DUMP_JSONS[SIMULATION_COUNTER - 1]
  return this_json != last_json


def UpdateArduinoFile(flights, json_desc_dict):
  """Saves a file that can be read by arduino.py with necessary flight attributes.

  The independently-running arduino python modules need basic information about the
  flight and radio in order to send useful information to be displayed by the digital
  alphanumeric displays.

  Args:
    flights: list of the flight dictionaries.
    json_desc_dict: dict with details about the radio range and similar radio details.

  Returns:
    String that is also written to disk.
  """
  # Start with radio_range_miles & radio_range_flights
  d = json_desc_dict

  if flights:
    flight = flights[-1]
    d['flight_number'] = DisplayFlightNumber(flight)
    d['flight_origin'] = DisplayOriginIata(flight)
    d['flight_destination'] = DisplayDestinationIata(flight)
    d['now'] = flight['now']

    requested_time = time.time()
    requested_elapsed_seconds = requested_time - flight['now']
    updated_loc = ClosestKnownLocation(flight, requested_elapsed_seconds)
    actual_elapsed_seconds = requested_elapsed_seconds - updated_loc[1]
    d['speed'] = updated_loc[0]['speed']
    d['lat'] = updated_loc[0]['lat']
    d['lon'] = updated_loc[0]['lon']
    d['track'] = updated_loc[0]['track']
    d['altitude'] = updated_loc[0]['altitude']
    d['vertrate'] = updated_loc[0]['vertrate']
    d['flight_loc_now'] = flight['now'] + actual_elapsed_seconds

  today = datetime.datetime.now(TZ).strftime('%x')
  flight_count_today = len([1 for f in flights if DisplayTime(f, '%x') == today])

  d['flight_count_today'] = flight_count_today

  settings_string = BuildSettings(d)
  if os.path.exists(ARDUINO_FILE):
    existing_data = ReadAndParseSettings(ARDUINO_FILE)
    if d != existing_data:
      WriteFile(ARDUINO_FILE, settings_string)
  else:
    WriteFile(ARDUINO_FILE, settings_string)

  return settings_string


def PublishMessage(
    s,
    subscription_id='12fd73cd-75ef-4cae-bbbf-29b2678692c1',
    key='c5f62d44-e30d-4c43-a43e-d4f65f4eb399',
    secret='b00aeb24-72f3-467c-aad2-82ba5e5266ca',
    timeout=3):
  """Publishes a text string to a Vestaboard.

  The message is pushed to the vestaboard splitflap display by way of its web services;
  see https://docs.vestaboard.com/introduction for more details.

  Args:
    s: String to publish.
    subscription_id: string subscription id from Vestaboard.
    key: string key from Vestaboard.
    secret: string secret from Vestaboard.
    timeout: Max duration in seconds that we should wait to establish a connection.
  """
  # See https://docs.vestaboard.com/characters: any chars needing to be replaced
  special_characters = ((u'\u00b0', '{62}'),)  # degree symbol '°'

  for special_character in special_characters:
    s = s.replace(*(special_character))
  curl = pycurl.Curl()

  # See https://stackoverflow.com/questions/31826814/curl-post-request-into-pycurl-code
  # Set URL value
  curl.setopt(
      pycurl.URL,
      'https://platform.vestaboard.com/subscriptions/%s/message' % subscription_id)
  curl.setopt(pycurl.HTTPHEADER, [
      'X-Vestaboard-Api-Key:%s' % key, 'X-Vestaboard-Api-Secret:%s' % secret])
  curl.setopt(pycurl.TIMEOUT_MS, timeout*1000)
  curl.setopt(pycurl.POST, 1)

  curl.setopt(pycurl.WRITEFUNCTION, lambda x: None) # to keep stdout clean

  # preparing body the way pycurl.READDATA wants it
  body_as_dict = {'text': s}
  body_as_json_string = json.dumps(body_as_dict) # dict to json
  body_as_file_object = io.StringIO(body_as_json_string)

  # prepare and send. See also: pycurl.READFUNCTION to pass function instead
  curl.setopt(pycurl.READDATA, body_as_file_object)
  curl.setopt(pycurl.POSTFIELDSIZE, len(body_as_json_string))
  try:
    curl.perform()
  except pycurl.error as e:
    LogMessage('curl.perform() failed with message %s' % e)
  else:
    # you may want to check HTTP response code, e.g.
    status_code = curl.getinfo(pycurl.RESPONSE_CODE)
    if status_code != 200:
      LogMessage('Server returned HTTP status code %d for message %s' % (status_code, s))

  curl.close()


def ManageMessageQueue(message_queue, next_message_time, configuration):
  """Check time & if appropriate, display next message from queue.

  Args:
    message_queue: FIFO list of message tuples of (message type, message string).
    next_message_time: epoch at which next message should be displayed
    configuration: dictionary of configuration attributes.

  Returns:
    Next_message_time, potentially updated if a message has been displayed, or unchanged
    if no message was displayed.
  """
  if message_queue and (time.time() >= next_message_time or SIMULATION):
    if SIMULATION:  # drain the queue because the messages come so fast
      messages_to_display = list(message_queue)
      # passed by reference, so clear it out since we drained it to the display
      del message_queue[:]
    else:  # display only one message, being mindful of the display timing
      messages_to_display = [message_queue.pop(0)]

    for message in messages_to_display:
      message_text = message[1]
      if isinstance(message_text, str):
        message_text = textwrap.wrap(message_text, width=SPLITFLAP_CHARS_PER_LINE)
      display_message = Screenify(message_text, False)
      LogMessage(display_message, file=ALL_MESSAGE_FILE)
      MaintainRollingWebLog(display_message, 25)
      if not SIMULATION:
        splitflap_message = Screenify(message_text, True)
        PublishMessage(splitflap_message)

    next_message_time = time.time() + configuration['setting_delay']
  return next_message_time


def BootstrapInsightList(filename_tuple=(PICKLE_FLIGHTS_30D, PICKLE_FLIGHTS_ARCHIVE)):
  """(Re)populate flight pickle files with flight insight distributions.

  The set of insights generated for each flight is created at the time the flight was
  first identified, and saved on the flight pickle. This saving allows the current
  running distribution to be recalculated very quickly, but it means that as code
  enabling new insights gets added, those historical distributions may not necessarily
  be considered correct.

  They are "correct" in the sense that that new insight was not available at the time
  that older flight was seen, but it is not correct in the sense that, because this new
  insight is starting out with an incidence in the historical data of zero, this
  new insight may be reported more frequently than desired until it "catches up".

  So this method replays the flight history with the latest insight code, regenerating
  the insight distribution for each flight.
  """
  for filename in filename_tuple:
    print('Bootstrapping %s' % filename)
    configuration = ReadAndParseSettings(CONFIG_FILE)
    flights = []
    tmp_filename = filename + 'tmp'

    if os.path.exists(tmp_filename):
      os.remove(tmp_filename)

    if os.path.exists(filename):
      mtime = os.path.getmtime(filename)
      flights = UnpickleObjectFromFile(filename)
      for (n, flight) in enumerate(flights):
        if n/25 == int(n/25):
          print(' - %d' % n)
        CreateFlightInsights(flights[:n+1], configuration.get('insights', 'hide'), {})
        PickleObjectToFile(flight, tmp_filename)

      if mtime == os.path.getmtime(filename):
        shutil.move(tmp_filename, filename)
      else:
        print('Failed to bootstrap %s: file changed while in process' % filename)


def main():
  """Traffic cop between incoming radio flight messages, configuration, and messageboard.

  This is the main logic, checking for new flights, augmenting the radio signal with
  additional web-scraped data, and generating messages in a form presentable to the
  messageboard.
  """
  LogMessage('Starting up')
  if '-s' in sys.argv:
    global SIMULATION_COUNTER
    SimulationSetup()

  already_running_id = CheckIfProcessRunning()
  if already_running_id:
    os.kill(already_running_id, signal.SIGKILL)



  configuration = ReadAndParseSettings(CONFIG_FILE)
  last_distance = configuration.get('distance')
  last_altitude = configuration.get('altitude')
  startup_time = time.time()
  json_desc_dict = {}

  # If we're displaying just a single insight message, we want it to be something
  # unique, to the extent possible; this dict holds a count of the diff types of messages
  # displayed so far
  insight_message_distribution = {}

  flights = []
  if os.path.exists(PICKLE_FLIGHTS_30D):
    flights = TruncatePickledDictionaries(PICKLE_FLIGHTS_30D)

    # Clear the loaded flight of any cached data since code fixes may change
    # the values for some of those cached elements
    for flight in flights:
      for key in list(flight.keys()):
        if key[:len(CACHED_ELEMENT_PREFIX)] == CACHED_ELEMENT_PREFIX:
          flight.pop(key)

    # bootstrap the flight insights distribution
    for (n, flight) in enumerate(flights):
      if 'insight_types' in flight:
        distribution = flight['insight_types']
        for key in distribution:
          insight_message_distribution[key] = (
              insight_message_distribution.get(key, 0) + 1)
      # # - but if it doesn't exist, we still have a path forward
      else:
        CreateFlightInsights(
            flights[:n+1],
            configuration.get('insights', 'hide'),
            insight_message_distribution)

  # used in simulation to print the hour of simulation once per simulated hour
  prev_simulated_hour = ''

  persistent_nearby_aircraft = {} # key = flight number; value = last seen
  persistent_path = {}
  histogram = {}

  # Next up to print is element 0; this is a list of tuples:
  # Element#1: flag indicating the type of message that this is
  # Element#2: the message itself
  message_queue = []
  next_message_time = time.time()


  # We repeat the loop every x seconds; this ensures that if the processing time is long,
  # we don't wait another x seconds after processing completes
  next_loop_time = time.time()

  # These files are read only if the version on disk has been modified more recently
  # than the last time it was read
  last_dump_json_timestamp = 0

  LogMessage('Finishing initialization; starting radio polling loop')
  while not SIMULATION or SIMULATION_COUNTER < len(DUMP_JSONS):

    new_configuration = ReadAndParseSettings(CONFIG_FILE)
    if (new_configuration.get('setting_max_distance')
        != configuration.get('setting_max_distance') or
        new_configuration.get('setting_max_altitude')
        != configuration.get('setting_max_altitude')):
      last_distance = configuration.get('setting_max_distance')
      last_altitude = configuration.get('setting_max_altitude')
      FlightCriteriaHistogramPng(
          flights,
          new_configuration['setting_max_distance'],
          new_configuration['setting_max_altitude'],
          7,
          last_max_distance_feet=last_distance,
          last_max_altitude_feet=last_altitude)
    configuration = new_configuration

    # if this is a SIMULATION, then process every diff dump. But if it isn't a simulation,
    # then only read & do related processing for the next dump if the last-modified
    # timestamp indicates the file has been updated since it was last read.
    tmp_timestamp = 0
    if not SIMULATION:
      tmp_timestamp = os.path.getmtime(DUMP_JSON_FILE)
    if (SIMULATION and DumpJsonChanges()) or (
        not SIMULATION and tmp_timestamp > last_dump_json_timestamp):
      last_dump_json_timestamp = tmp_timestamp

      # a command might request info about flight to be (re)displayed, irrespective of
      # whether the screen is on; if so, let's put that message at the front of the message
      # queue, and delete any subsequent messages in queue because presumably the button
      # was pushed either a) when the screen was off (so no messages in queue), or b)
      # because the screen was on, but the last flight details got lost after other screens
      if os.path.exists(LAST_FLIGHT_FILE):
        messageboard_flight_index = IdentifyFlightDisplayed(
            flights, configuration, display_all_hours=True)
        if messageboard_flight_index is not None:
          message_queue = [m for m in message_queue if m[0] != FLAG_MSG_INTERESTING]
          flight_message = CreateMessageAboutFlight(flights[messageboard_flight_index])
          message_queue = [FLAG_MSG_FLIGHT, flight_message]
          next_message_time = time.time()
        os.remove(LAST_FLIGHT_FILE)

      (persistent_nearby_aircraft,
       unused_current_nearby_aircraft,
       flight, now,
       json_desc_dict,
       persistent_path) = ScanForNewFlights(persistent_nearby_aircraft, persistent_path)

      if flight:
        flights.append(flight)
        UpdateArduinoFile(
            flights, json_desc_dict)

        flight_meets_display_criteria = FlightMeetsDisplayCriteria(flight, configuration)
        if flight_meets_display_criteria:
          flight_message = (FLAG_MSG_FLIGHT, CreateMessageAboutFlight(flight))

          # display the next message about this flight now!
          next_message_time = time.time()
          message_queue.insert(0, flight_message)
          # and delete any queued insight messages about other flights that have
          # not yet displayed, since a newer flight has taken precedence
          messages_to_delete = [m for m in message_queue if m[0] == FLAG_MSG_INTERESTING]
          if messages_to_delete:
            LogMessage(
                'Deleting messages from queue due to new-found plane: %s' %
                messages_to_delete)
          message_queue = [m for m in message_queue if m[0] != FLAG_MSG_INTERESTING]

          # Though we also manage the message queue outside this conditional as well,
          # because it can take a half second to generate the flight insights, this allows
          # this message to start displaying on the board immediately, so it's up there
          # when it's most relevant
          next_message_time = ManageMessageQueue(
              message_queue, next_message_time, configuration)

          insight_messages = CreateFlightInsights(
              flights, configuration.get('insights'), insight_message_distribution)
          if configuration.get('next_flight', 'off') == 'on':
            next_flight_text = FlightInsightNextFlight(flights)
            if next_flight_text:
              insight_messages.insert(0, next_flight_text)

          insight_messages = [(FLAG_MSG_INTERESTING, m) for m in insight_messages]

          for insight_message in insight_messages:
            message_queue.insert(0, insight_message)

        else:  # flight didn't meet display criteria
          flight['insight_types'] = []

        PickleObjectToFile(flight, PICKLE_FLIGHTS_30D)
        PickleObjectToFile(flight, PICKLE_FLIGHTS_ARCHIVE)

      else:
        UpdateArduinoFile(flights, json_desc_dict)

    if SIMULATION:
      if now:
        simulated_hour = EpochDisplayTime(now, format_string='%Y-%m-%d %H:00%z')
      if simulated_hour != prev_simulated_hour:
        print(simulated_hour)
        prev_simulated_hour = simulated_hour

    histogram = ReadAndParseSettings(HISTOGRAM_CONFIG_FILE)
    if os.path.exists(HISTOGRAM_CONFIG_FILE):
      os.remove(HISTOGRAM_CONFIG_FILE)

    # We also need to make sure there are flights on which to generate a histogram! Why
    # might there not be any flights? Primarily during a simulation, if there's a
    # lingering histogram file at the time of history restart.
    if histogram and flights:
      histogram_messages = TriggerHistograms(flights, histogram)
      histogram_messages = [(FLAG_MSG_HISTOGRAM, m) for m in histogram_messages]
      message_queue.extend(histogram_messages)











































    # check time & if appropriate, display next message from queue
    next_message_time = ManageMessageQueue(message_queue, next_message_time, configuration)

    # if we've been running a long time, and everything else is quiet, reboot
    running_hours = (time.time() - startup_time) / SECONDS_IN_HOUR
    if (
        running_hours >= HOURS_IN_DAY and
        not message_queue and
        not json_desc_dict.get('radio_range_flights')):
      LogMessage('About to reboot after running for %.2f hours' % running_hours)
      os.system('sudo reboot')

    if not SIMULATION:
      time.sleep(max(0, next_loop_time - time.time()))
      next_loop_time = time.time() + LOOP_DELAY_SECONDS
    else:
      SIMULATION_COUNTER += 1

  if SIMULATION:
    SimulationEnd(message_queue, flights)


if __name__ == "__main__":
  if '-i' in sys.argv:
    BootstrapInsightList()
  else:
    main()