01234567890123456789012345678901234567890123456789012345678901234567890123456789
123456 789 101112131415161718 19202122232425262728 2930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778 7980818283848586878889909192939495969798 148149150151152153154155156157158159160161162163164165166167 168169170171172173174175176177178179180181182183184185186187188189190 191192193194195196197198199200201202203204205206207208209210 237238239240241242243244245246247248249250251252253254255256 257258259260261262263264265266267268269270271272273 274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 314315316317318319320321322323 324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909 955956957958959960961962963964965966967968969970971972973974975976977978979980981 9829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015 1016101710181019 102010211022102310241025102610271028102910301031103210331034103510361037 1038 103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095 10961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145 11461147 114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174 12281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268 128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319 132013211322132313241325132613271328132913301331133213331334133513361337133813391340 13761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416 16921693169416951696169716981699170017011702170317041705170617071708170917101711 17121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812 19631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099 2105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150 21712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220 2245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287 23642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449 256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644 278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840 2852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894 295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012 30743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114 337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444 347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550 360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816 38283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910 39343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004 40254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074 4135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198 43794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431 445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568 45694570457145724573457445754576457745784579458045814582458345844585458645874588 45934594459545964597459845994600460146024603460446054606460746084609461046114612 46134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641 4642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672 46734674467546764677 46784679 46804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735 473647374738473947404741474247434744474547464747474847494750475147524753475447554756 475747584759476047614762 4763476447654766476747684769477047714772477347744775477647774778477947804781478247834784 47854786 478747884789479047914792479347944795479647974798 4799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856 485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892 489348944895 489648974898489949004901490249034904490549064907490849094910491149124913491449154916 49174918491949204921 492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966 496749684969497049714972497349744975497649774978497949804981 49824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004 50055006500750085009 5010 5011501250135014 | #!/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/readsb/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 <----SKIPPED LINES----> 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 = 19 FLAG_INSIGHT_DATE_DELAY_TIME = 20 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 STDERR_FILE = WEBSERVER_PATH + STDERR_FILE BACKUP_FILE = WEBSERVER_PATH + BACKUP_FILE SERVICE_VERIFICATION_FILE = WEBSERVER_PATH + SERVICE_VERIFICATION_FILE 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 <----SKIPPED LINES----> 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 170/175 (twin-jet)'] = (29.90 + 31.68) / 2 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 Super King Air 350 (twin-turboprop)'] = 14.22 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(str(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----> 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 <----SKIPPED LINES----> 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 - (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, 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. <----SKIPPED LINES----> 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 altitude = aircraft.get('altitude', aircraft.get('alt_baro')) if altitude is not None: simplified_aircraft['altitude'] = altitude speed = aircraft.get('speed', aircraft.get('gs')) if speed is not None: simplified_aircraft['speed'] = speed vert_rate = aircraft.get('vert_rate', aircraft.get('baro_rate')) if vert_rate is not None: simplified_aircraft['vert_rate'] = vert_rate if aircraft.get('squawk') is not None: 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: <----SKIPPED LINES----> 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: flight_script = script 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. <----SKIPPED LINES----> 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) flight[cached_key] = epoch_display_time return epoch_display_time <----SKIPPED LINES----> remaining_seconds = flight['now'] - arrival else: remaining_seconds = None return remaining_seconds def FlightMeetsDisplayCriteria(flight, configuration, display_all_hours=False, log=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 if log: LogMessage( '%s not displayed because it fails altitude criteria - flight altitude: ' '%.0f; required altitude: %.0f' % ( DisplayFlightNumber(flight), flight_altitude, config_max_altitude)) 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 log: LogMessage( '%s not displayed because it fails distance criteria - flight distance: ' '%.0f; required distance: %.0f' % ( DisplayFlightNumber(flight), flight_distance, config_max_distance)) 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']: flight_meets_criteria = False if log: LogMessage( '%s not displayed because it occurs too early - minute_of_day: ' '%d; setting_on_time: %d' % ( DisplayFlightNumber(flight), minute_of_day, configuration['setting_on_time'])) elif minute_of_day > configuration['setting_off_time'] + 1: flight_meets_criteria = False if log: LogMessage( '%s not displayed because it occurs too late - minute_of_day: ' '%d; setting_off_time: %d' % ( DisplayFlightNumber(flight), minute_of_day, configuration['setting_off_time'])) elif configuration.get('setting_screen_enabled', 'off') == 'off': flight_meets_criteria = False if log: LogMessage( '%s not displayed because screen disabled' % DisplayFlightNumber(flight)) 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 <----SKIPPED LINES----> 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(Screenify(flight_message, False)) messages.append(flight_message) insights = CreateFlightInsights( flights[:n+1], flight_insights_enabled_string, distribution) FlightInsightNextFlight(flights[:n+1]) if display: for insight in insights: print(Screenify(insight, False)) 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: <----SKIPPED LINES----> 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}) <----SKIPPED LINES----> return message def FlightInsightSuperlativeAttribute( flights, key, label, units, absolute_list, insight_min=True, insight_max=True, hours=HOURS_IN_DAY): """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)]) <----SKIPPED LINES----> 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') # 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 <----SKIPPED LINES----> group_label, value_label, filter_function=lambda this, other: True, 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. filter_function: an optional function that, when called with the most recent flight and another flight filter_function(flights[-1], flight[n]), returns a value interpreted as a boolean indicating whether flight n should be included in determining the percentile. 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. """ debug = False 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 and filter_function(this_flight, f)] grouped_flights = {} <----SKIPPED LINES----> 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) <----SKIPPED LINES----> 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: <----SKIPPED LINES----> last_potential_observation_string) return message def FlightInsightSuperlativeVertrate(flights, hours=HOURS_IN_DAY): """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): <----SKIPPED LINES----> 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 ( <----SKIPPED LINES----> # 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)) <----SKIPPED LINES----> 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, <----SKIPPED LINES----> 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 = [] def IfNoneReturnInf(f, key): value = f.get(key) if not value: value = float('inf') return value # 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 ( <----SKIPPED LINES----> 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. Returns: 2-tuple of (values, keys) lists sorted as described above """ if ignore_sort_at_end_strings: sort_at_end_strings = [] else: sort_at_end_strings = SORT_AT_END_STRINGS return SortZipped( values, keys, True, lambda a: ( False, False, a[1]) if a[1] in sort_at_end_strings else (True, a[0], a[1])) def SortByKeys(values, keys, ignore_sort_at_end_strings=False): """Sorts the list of keys in ascending sequence, applying same resorting to values. Given a list of keys and values representing a histogram, returns two new lists that are sorted so that the keys occur in ascending alpha sequence and the values are moved around in the same way. This allows the printing of a histogram with the first keys alphabetically listed first - i.e.: 7am, 8am, 9am. Keys identified by SORT_AT_END_STRINGS - such as, perhaps, 'Other' - will optionally be placed at the end of the sequence. Args: values: list of values for the histogram that will be moved in the same way as the keys. keys: list of keys for the histogram to be used as the primary sort key. ignore_sort_at_end_strings: boolean indicating whether specially-defined keys will be sorted at the end. Returns: 2-tuple of (values, keys) lists sorted as described above """ if ignore_sort_at_end_strings: sort_at_end_strings = [] else: sort_at_end_strings = SORT_AT_END_STRINGS return SortZipped( values, keys, False, lambda a: (True, a[1]) if a[1] in sort_at_end_strings else (False, a[1])) def SortByDefinedList(values, keys, sort_sequence): """Sorts the keys in user-enumerated sequence, applying same resorting to values. Given a list of keys and values representing a histogram, returns two new lists that are sorted so that the keys occur in the specific sequence identified in the list sort_sequence, while the values are moved around in the same way. This allows the printing of a histogram with the keys occurring in a canonical order - i.e.: Tuesday, Wednesday, Thursday. Keys present in keys but not existing in sort_sequence are then sorted at the end, but amongst them, sorted based on the value. Args: values: list of values for the histogram that will be moved in the same way as the keys. keys: list of keys for the histogram to be used as the primary sort key. sort_sequence: list - which need not be exhaustive - of the keys in their desired order. Returns: 2-tuple of (values, keys) lists sorted as described above """ return SortZipped( values, keys, False, lambda a: ( False, sort_sequence.index(a[1])) if a[1] in sort_sequence else (True, a[0])) def SortZipped(x, y, reverse, key): """Sorts lists x & y via the function defined in key. Applies the same reordering to the two lists x and y, where the reordering is given by the function defined in the key applied to the tuple (x[n], y[n]). That is, suppose - x = [3, 2, 1] - y = ['b', 'c', 'a'] Then the sort to both lists is done based on how the key is applied to the tuples: - [(3, 'b'), (2, 'c'), (1, 'a')] If key = lambda a: a[0], then the sort is done based on 3, 2, 1, so the sorted lists are - x = [1, 2, 3] - y = ['a', 'c', 'b'] If key = lambda a: a[1], then the sort is done based on ['b', 'c', 'a'], so the sorted lists are - x = [1, 3, 2] - y = ['a', 'b', 'c'] Args: x: First list y: Second list reverse: Boolean indicating whether the sort should be ascending (True) or descending (False) key: function applied to the 2-tuple constructed by taking the corresponding values of the lists x & y, used to generate the key on which the sort is applied Returns: 2-tuple of (x, y) lists sorted as described above """ zipped_xy = zip(x, y) sorted_xy = sorted(zipped_xy, reverse=reverse, key=key) # unzip (x, y) = list(zip(*sorted_xy)) return (x, y) def CreateSingleHistogramChart( data, 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 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. 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']) <----SKIPPED LINES----> 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 = HOURS_IN_DAY elif how_much_history == '7d': hours = 7 * HOURS_IN_DAY elif how_much_history == '30d': hours = 30 * HOURS_IN_DAY else: LogMessage( 'Histogram form has invalid value for how_much_history: %s' % how_much_history) hours = 7 * HOURS_IN_DAY return hours def HistogramSettingsScreens(max_screens): """Extracts the desired number of text screens from the histogram configuration string. 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, hours, 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. hours: how many hours of histogram data have been requested. max_altitude: indicates the maximum altitude that should be included on the altitude labels. Returns: A 4-tuple of the parameters used by either CreateSingleHistogramChart or MessageboardHistogram, of the keyfunction, sort, title, and hours. """ def DivideAndFormat(dividend, divisor): if dividend is None: return KEY_NOT_PRESENT_STRING 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' <----SKIPPED LINES----> 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' # if less than one week, as requested; if more than one week, in full week multiples hours_in_week = 7 * HOURS_IN_DAY weeks = hours / hours_in_week if weeks > 1: hours = hours_in_week * int(hours / hours_in_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', hours, max_altitude=max_altitude) return (key, sort, title, hours) 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']: histograms_to_generate.append({'generate': 'origin'}) if which_histograms in ['hour', 'all']: histograms_to_generate.append({'generate': 'hour'}) if which_histograms in ['airline', 'all']: histograms_to_generate.append({'generate': 'airline', 'truncate': int(TRUNCATE/2)}) if which_histograms in ['aircraft', 'all']: histograms_to_generate.append({'generate': 'aircraft'}) if which_histograms in ['altitude', 'all']: histograms_to_generate.append({'generate': 'altitude', 'exhaustive': True}) if which_histograms in ['bearing', 'all']: <----SKIPPED LINES----> hours=hours, 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, <----SKIPPED LINES----> 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)) <----SKIPPED LINES----> 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'))] <----SKIPPED LINES----> '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: <----SKIPPED LINES----> 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 not SIMULATION: 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 ResetLogs(config): """Clears the non-scrolling logs if reset_logs in config.""" if 'reset_logs' in config: LogMessage('Reset logs') if os.path.exists(STDERR_FILE): os.remove(STDERR_FILE) LogMessage('', STDERR_FILE) if os.path.exists(BACKUP_FILE): os.remove(BACKUP_FILE) open(BACKUP_FILE, 'a').close() if os.path.exists(SERVICE_VERIFICATION_FILE): os.remove(SERVICE_VERIFICATION_FILE) open(SERVICE_VERIFICATION_FILE, 'a').close() config.pop('reset_logs') config = BuildSettings(config) WriteFile(CONFIG_FILE, config) return config 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() if already_running_id: os.kill(already_running_id, signal.SIGKILL) LogMessage('Starting up') if '-s' in sys.argv: global SIMULATION_COUNTER SimulationSetup() # Redirect any errors to a log file instead of the screen, and add a datestamp if not SIMULATION: sys.stderr = open(STDERR_FILE, 'a') sys.stdout = open(STDERR_FILE, 'a') LogMessage('', STDERR_FILE) 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() + LOOP_DELAY_SECONDS # 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) n_c = new_configuration c = configuration if (n_c.get('setting_max_distance') != c.get('setting_max_distance') or n_c.get('setting_max_altitude') != c.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) if (n_c.get('setting_max_distance') != c.get('setting_max_distance') or n_c.get('setting_max_altitude') != c.get('setting_max_altitude') or n_c.get('setting_off_time') != c.get('setting_off_time') or n_c.get('setting_on_time') != c.get('setting_on_time')): next_flight_message = FlightInsightNextFlight(flights) if next_flight_message: message_queue.append((FLAG_MSG_INTERESTING, next_flight_message)) configuration = new_configuration ResetLogs(configuration) # clear the logs if requested # 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, 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, log=True) 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, '%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') return # exit execution 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() |
01234567890123456789012345678901234567890123456789012345678901234567890123456789
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081 828384858687888990919293949596979899100101102103104105106107108 158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 194195196197198199200201 202 203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416 901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941 987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017 10181019 10201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210 12641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304 1323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377 14131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453 172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851 20022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138 2144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189 22102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259 2284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326 24032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488 260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683 282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879 2891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933 299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051 31133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153 340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483 351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589 364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855 38673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949 39733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043 40644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113 4174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237 44184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470 4497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536 453745384539454045414542454345444545 454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656 466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846 4847 4848484948504851485248534854485548564857485848594860486148624863486448654866 4867486848694870487148724873 48744875487648774878487948804881 488248834884488548864887488848894890 489148924893489448954896 48974898 4899490049014902490349044905 49064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934 4935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019 5020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053 505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200 | #!/usr/bin/python3 import datetime import io import json import math import multiprocessing import numbers import os import pickle import queue import shutil import signal import statistics import sys import textwrap import time import bs4 import dateutil.relativedelta import filelock import numpy import matplotlib import matplotlib.pyplot import psutil import pycurl import pytz import requests import tzlocal import unidecode import arduino RASPBERRY_PI = psutil.sys.platform.title() == 'Linux' if RASPBERRY_PI: import gpiozero # pylint: disable=E0401 import RPi.GPIO # pylint: disable=E0401 SHUTDOWN_SIGNAL = False SIMULATION = False SIMULATION_COUNTER = 0 SIMULATION_PREFIX = 'SIM_' PICKLE_DUMP_JSON_FILE = 'pickle/dump_json.pk' PICKLE_FA_JSON_FILE = 'pickle/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/readsb/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 = 'pickle/flights.pk' 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' LOGFILE_LOCK = 'log.txt.lock' # 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 <----SKIPPED LINES----> 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 = 19 FLAG_INSIGHT_DATE_DELAY_TIME = 20 INSIGHT_TYPES = 21 # error lights - value is the RPi pin number for the error light GPIO_ERROR_VESTABOARD_CONNECTION = (22, None, None) # error messages printed by main GPIO_ERROR_FLIGHT_AWARE_CONNECTION = (23, None, None) # error messages printed by main GPIO_ERROR_ARDUINO_SERVO_CONNECTION = ( 24, 'lost connection with servos', 'restored connection with servos') GPIO_ERROR_ARDUINO_REMOTE_CONNECTION = ( 25, 'lost connection with remote', 'restored connection with remote') GPIO_ERROR_BATTERY_CHARGE = (26, 'low battery', 'battery recharged') GPIO_FAN = (27, None, None) # error messages printed by main TEMP_FAN_TURN_ON_CELSIUS = 65 TEMP_FAN_TURN_OFF_CELSIUS = 55 #if running on raspberry, then need to prepend path to file names if RASPBERRY_PI: PICKLE_FLIGHTS = MESSAGEBOARD_PATH + PICKLE_FLIGHTS 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 ROLLING_MESSAGE_FILE = WEBSERVER_PATH + ROLLING_MESSAGE_FILE ALL_MESSAGE_FILE = WEBSERVER_PATH + ALL_MESSAGE_FILE ROLLING_LOGFILE = WEBSERVER_PATH + ROLLING_LOGFILE STDERR_FILE = WEBSERVER_PATH + STDERR_FILE BACKUP_FILE = WEBSERVER_PATH + BACKUP_FILE SERVICE_VERIFICATION_FILE = WEBSERVER_PATH + SERVICE_VERIFICATION_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) HOURLY_IMAGE_FILE = WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HOURLY_IMAGE_FILE 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 <----SKIPPED LINES----> 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 (twin-jet)'] = (63.73 + 73.86) / 2 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 170/175 (twin-jet)'] = (29.90 + 31.68) / 2 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 Citation X (twin-jet)'] = 22.04 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 Super King Air 350 (twin-turboprop)'] = 14.22 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 Log(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 if file == LOGFILE: lock = filelock.FileLock(LOGFILE_LOCK) lock.acquire() 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(str(message)+'\n') except IOError: Log('Unable to append to ' + file) if file == LOGFILE: lock.release() 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: Log('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----> 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: Log('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 <----SKIPPED LINES----> 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: Log('Unable to write to '+filename) return False return True def PrependFileName(full_path, prefix): """Converts /dir/file.png to /dir/prefixfile.png.""" directory, file_name = os.path.split(full_path) file_name = prefix+file_name return os.path.join(directory, file_name) def UnpickleObjectFromFile(full_path, date_segmentation, max_days=None): """Load a repository of pickled flight data into memory. Args: full_path: name (potentially including path) of the pickled file date_segmentation: If true, searches for all files that have a prefix of yyyy-mm-dd as a prefix to the file name specified in the full path, and loads them in sequence for unpickling; if false, uses the full_path as is and loads just that single file. max_days: Integer that, if specified, indicates maximum number of days of files to load back in; otherwise, loads all. Returns: Return a list of all the flights, in the same sequence as written to the file. """ if date_segmentation: directory, file = os.path.split(full_path) files = os.listdir(directory) if max_days: earliest_date = EpochDisplayTime(time.time() - max_days*SECONDS_IN_DAY, '%Y-%m-%d') files = [f for f in files if f[:10] >= earliest_date] files = sorted([os.path.join(directory, f) for f in files if file in f]) else: if os.path.exists(full_path): files = [full_path] else: return [] data = [] for file in files: try: with open(file, 'rb') as f: while True: data.append(pickle.load(f)) except (EOFError, pickle.UnpicklingError): pass return data def PickleObjectToFile(data, full_path, date_segmentation): """Append one pickled flight to the end of binary file. Args: data: data to pickle full_path: name (potentially including path) of the pickled file date_segmentation: boolean indicating whether the date string yyyy-mm-dd should be prepended to the file name in full_path based on the current date, so that pickled files are segmented by date. """ if date_segmentation: full_path = PrependFileName(full_path, EpochDisplayTime(time.time(), '%Y-%m-%d-')) try: with open(full_path, 'ab') as f: f.write(pickle.dumps(data)) except IOError: Log('Unable to append pickle ' + full_path) 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, log_jsons): """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. log_jsons: boolean indicating whether we should pickle the JSONs. Returns: A tuple: - updated persistent_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 and log_jsons: PickleObjectToFile((dump_json, now), PICKLE_DUMP_JSON_FILE, True) 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]) Log('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) UpdateStatusLight(GPIO_ERROR_FLIGHT_AWARE_CONNECTION, False) if not flight_aware_json: Log('No json returned from Flightaware for flight: %s' % flight_number) UpdateStatusLight(GPIO_ERROR_FLIGHT_AWARE_CONNECTION, True) flight_details = {} if flight_aware_json: flight_details = ParseFlightAwareJson(flight_aware_json) if not SIMULATION and log_jsons: PickleObjectToFile((flight_aware_json, now), PICKLE_FA_JSON_FILE, True) # 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, 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. <----SKIPPED LINES----> 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 altitude = aircraft.get('altitude', aircraft.get('alt_baro')) if isinstance(altitude, numbers.Number): simplified_aircraft['altitude'] = altitude speed = aircraft.get('speed', aircraft.get('gs')) if speed is not None: simplified_aircraft['speed'] = speed vert_rate = aircraft.get('vert_rate', aircraft.get('baro_rate')) if vert_rate is not None: simplified_aircraft['vert_rate'] = vert_rate if aircraft.get('squawk') is not None: 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: <----SKIPPED LINES----> 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: Log('Unable to query FA for URL due to %s: %s' % (e, url)) return '' soup = bs4.BeautifulSoup(response.text, "html.parser") l = soup.find_all('script') flight_script = None for script in l: if "trackpollBootstrap" in str(script): flight_script = str(script) break if not flight_script: Log('Unable to find trackpollBootstrap script in page: ' + response.text) return '' first_open_curly_brace = flight_script.find('{') last_close_curly_brace = flight_script.rfind('}') flight_json = flight_script[first_open_curly_brace:last_close_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. <----SKIPPED LINES----> 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: Log('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) flight[cached_key] = epoch_display_time return epoch_display_time <----SKIPPED LINES----> remaining_seconds = flight['now'] - arrival else: remaining_seconds = None return remaining_seconds def FlightMeetsDisplayCriteria(flight, configuration, display_all_hours=False, log=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) log: optional boolean indicating whether a flight that fails the criteria should be logged with the reason 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 if log: Log( '%s not displayed because it fails altitude criteria - flight altitude: ' '%.0f; required altitude: %.0f' % ( DisplayFlightNumber(flight), flight_altitude, config_max_altitude)) 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 log: Log( '%s not displayed because it fails distance criteria - flight distance: ' '%.0f; required distance: %.0f' % ( DisplayFlightNumber(flight), flight_distance, config_max_distance)) 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']: flight_meets_criteria = False if log: Log( '%s not displayed because it occurs too early - minute_of_day: ' '%d; setting_on_time: %d' % ( DisplayFlightNumber(flight), minute_of_day, configuration['setting_on_time'])) elif minute_of_day > configuration['setting_off_time'] + 1: flight_meets_criteria = False if log: Log( '%s not displayed because it occurs too late - minute_of_day: ' '%d; setting_off_time: %d' % ( DisplayFlightNumber(flight), minute_of_day, configuration['setting_off_time'])) elif configuration.get('setting_screen_enabled', 'off') == 'off': flight_meets_criteria = False if log: Log( '%s not displayed because screen disabled' % DisplayFlightNumber(flight)) 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, True) 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 <----SKIPPED LINES----> 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, True) distribution = {} messages = [] for (n, flight) in enumerate(flights): flight_message = CreateMessageAboutFlight(flight) if display: print('='*25) print(Screenify(flight_message, False)) messages.append(flight_message) insights = CreateFlightInsights( flights[:n+1], flight_insights_enabled_string, distribution) FlightInsightNextFlight(flights[:n+1]) if display: for insight in insights: print(Screenify(insight, False)) 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: Log('%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: <----SKIPPED LINES----> 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}) <----SKIPPED LINES----> return message def FlightInsightSuperlativeAttribute( flights, key, label, units, absolute_list, insight_min=True, insight_max=True, hours=HOURS_IN_DAY): """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)]) <----SKIPPED LINES----> 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') # 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 <----SKIPPED LINES----> group_label, value_label, filter_function=lambda this, other: True, 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. filter_function: an optional function that, when called with the most recent flight and another flight filter_function(flights[-1], flight[n]), returns a value interpreted as a boolean indicating whether flight n should be included in determining the percentile. 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. """ debug = False 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 and filter_function(this_flight, f)] grouped_flights = {} <----SKIPPED LINES----> 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) <----SKIPPED LINES----> 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: <----SKIPPED LINES----> last_potential_observation_string) return message def FlightInsightSuperlativeVertrate(flights, hours=HOURS_IN_DAY): """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): <----SKIPPED LINES----> 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 ( <----SKIPPED LINES----> # 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)) <----SKIPPED LINES----> 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, True) 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, <----SKIPPED LINES----> 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 = [] def IfNoneReturnInf(f, key): value = f.get(key) if not value: value = float('inf') return value # 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 ( <----SKIPPED LINES----> 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. Returns: 2-tuple of (values, keys) lists sorted as described above """ if ignore_sort_at_end_strings: sort_at_end_strings = [] else: sort_at_end_strings = SORT_AT_END_STRINGS return SortZipped( values, keys, True, lambda a: ( False, False, a[1]) if a[1] in sort_at_end_strings else (True, a[0], a[1])) def SortByKeys(values, keys, ignore_sort_at_end_strings=False): """Sorts the list of keys in ascending sequence, applying same resorting to values. Given a list of keys and values representing a histogram, returns two new lists that are sorted so that the keys occur in ascending alpha sequence and the values are moved around in the same way. This allows the printing of a histogram with the first keys alphabetically listed first - i.e.: 7am, 8am, 9am. Keys identified by SORT_AT_END_STRINGS - such as, perhaps, 'Other' - will optionally be placed at the end of the sequence. Args: values: list of values for the histogram that will be moved in the same way as the keys. keys: list of keys for the histogram to be used as the primary sort key. ignore_sort_at_end_strings: boolean indicating whether specially-defined keys will be sorted at the end. Returns: 2-tuple of (values, keys) lists sorted as described above """ if ignore_sort_at_end_strings: sort_at_end_strings = [] else: sort_at_end_strings = SORT_AT_END_STRINGS return SortZipped( values, keys, False, lambda a: (True, a[1]) if a[1] in sort_at_end_strings else (False, a[1])) def SortByDefinedList(values, keys, sort_sequence): """Sorts the keys in user-enumerated sequence, applying same resorting to values. Given a list of keys and values representing a histogram, returns two new lists that are sorted so that the keys occur in the specific sequence identified in the list sort_sequence, while the values are moved around in the same way. This allows the printing of a histogram with the keys occurring in a canonical order - i.e.: Tuesday, Wednesday, Thursday. Keys present in keys but not existing in sort_sequence are then sorted at the end, but amongst them, sorted based on the value. Args: values: list of values for the histogram that will be moved in the same way as the keys. keys: list of keys for the histogram to be used as the primary sort key. sort_sequence: list - which need not be exhaustive - of the keys in their desired order. Returns: 2-tuple of (values, keys) lists sorted as described above """ return SortZipped( values, keys, False, lambda a: ( False, sort_sequence.index(a[1])) if a[1] in sort_sequence else (True, a[0])) def SortZipped(x, y, reverse, key): """Sorts lists x & y via the function defined in key. Applies the same reordering to the two lists x and y, where the reordering is given by the function defined in the key applied to the tuple (x[n], y[n]). That is, suppose - x = [3, 2, 1] - y = ['b', 'c', 'a'] Then the sort to both lists is done based on how the key is applied to the tuples: - [(3, 'b'), (2, 'c'), (1, 'a')] If key = lambda a: a[0], then the sort is done based on 3, 2, 1, so the sorted lists are - x = [1, 2, 3] - y = ['a', 'c', 'b'] If key = lambda a: a[1], then the sort is done based on ['b', 'c', 'a'], so the sorted lists are - x = [1, 3, 2] - y = ['a', 'b', 'c'] Args: x: First list y: Second list reverse: Boolean indicating whether the sort should be ascending (True) or descending (False) key: function applied to the 2-tuple constructed by taking the corresponding values of the lists x & y, used to generate the key on which the sort is applied Returns: 2-tuple of (x, y) lists sorted as described above """ zipped_xy = zip(x, y) sorted_xy = sorted(zipped_xy, reverse=reverse, key=key) # unzip (x, y) = list(zip(*sorted_xy)) return (x, y) def CreateSingleHistogramChart( data, 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 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. 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']) <----SKIPPED LINES----> 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 = HOURS_IN_DAY elif how_much_history == '7d': hours = 7 * HOURS_IN_DAY elif how_much_history == '30d': hours = 30 * HOURS_IN_DAY else: Log( 'Histogram form has invalid value for how_much_history: %s' % how_much_history) hours = 7 * HOURS_IN_DAY return hours def HistogramSettingsScreens(max_screens): """Extracts the desired number of text screens from the histogram configuration string. 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: Log('Histogram form has invalid value for max_screens: %s' % max_screens) screen_limit = 1 return screen_limit def HistogramSettingsKeySortTitle(which, hours, 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. hours: how many hours of histogram data have been requested. max_altitude: indicates the maximum altitude that should be included on the altitude labels. Returns: A 4-tuple of the parameters used by either CreateSingleHistogramChart or MessageboardHistogram, of the keyfunction, sort, title, and hours. """ def DivideAndFormat(dividend, divisor): if dividend is None: return KEY_NOT_PRESENT_STRING 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' <----SKIPPED LINES----> 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' # if less than one week, as requested; if more than one week, in full week multiples hours_in_week = 7 * HOURS_IN_DAY weeks = hours / hours_in_week if weeks > 1: hours = hours_in_week * int(hours / hours_in_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: Log( 'Histogram form has invalid value for which_histograms: %s' % which) return HistogramSettingsKeySortTitle( 'destination', hours, max_altitude=max_altitude) return (key, sort, title, hours) 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']: histograms_to_generate.append({'generate': 'origin'}) if which_histograms in ['hour', 'all']: histograms_to_generate.append({'generate': 'hour'}) if which_histograms in ['airline', 'all']: histograms_to_generate.append({'generate': 'airline', 'truncate': int(TRUNCATE/2)}) if which_histograms in ['aircraft', 'all']: histograms_to_generate.append({'generate': 'aircraft'}) if which_histograms in ['altitude', 'all']: histograms_to_generate.append({'generate': 'altitude', 'exhaustive': True}) if which_histograms in ['bearing', 'all']: <----SKIPPED LINES----> hours=hours, 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, <----SKIPPED LINES----> 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)) <----SKIPPED LINES----> 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: Log('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. filename: name of desired csv file; if not provided, defaults to flights.csv. """ if not flights: flights = UnpickleObjectFromFile(PICKLE_FLIGHTS, True) 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'))] <----SKIPPED LINES----> '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, True) global FA_JSONS FA_JSONS = UnpickleObjectFromFile(PICKLE_FA_JSON_FILE, True) global ALL_MESSAGE_FILE ALL_MESSAGE_FILE = PrependFileName(ALL_MESSAGE_FILE, SIMULATION_PREFIX) if os.path.exists(ALL_MESSAGE_FILE): os.remove(ALL_MESSAGE_FILE) global LOGFILE LOGFILE = PrependFileName(LOGFILE, SIMULATION_PREFIX) if os.path.exists(LOGFILE): os.remove(LOGFILE) global ROLLING_LOGFILE ROLLING_LOGFILE = PrependFileName(ROLLING_LOGFILE, SIMULATION_PREFIX) if os.path.exists(ROLLING_LOGFILE): os.remove(ROLLING_LOGFILE) global ROLLING_MESSAGE_FILE ROLLING_MESSAGE_FILE = PrependFileName(ROLLING_MESSAGE_FILE, SIMULATION_PREFIX) if os.path.exists(ROLLING_MESSAGE_FILE): os.remove(ROLLING_MESSAGE_FILE) global PICKLE_FLIGHTS PICKLE_FLIGHTS = PrependFileName(PICKLE_FLIGHTS, SIMULATION_PREFIX) if os.path.exists(PICKLE_FLIGHTS): os.remove(PICKLE_FLIGHTS) 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.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, False) 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 EnqueueArduinos(flights, json_desc_dict, to_servo_q, to_remote_q): last_flight = None if flights: last_flight = flights[-1] message = (last_flight, json_desc_dict) to_servo_q.put(message) to_remote_q.put(message) def ValidateRunning(start_function, p=None, args=()): if p is None or not p.is_alive(): Log('Process (%s) for %s is not alive; restarting with args %s' % ( str(p), str(start_function), str(args))) p = multiprocessing.Process(target=start_function, args=args) p.daemon = True p.start() return p def RefreshArduinos(remote, servo, to_remote_q, to_servo_q, to_main_q, flights, json_desc_dict): remote = ValidateRunning(arduino_remote.main, args=(to_remote_q, to_main_q)) servo = ValidateRunning(arduino_servo.main, args=(to_servo_q, to_main_q)) EnqueueArduinos(flights, json_desc_dict, to_servo_q, to_remote_q) return(remote, servo) def InitArduinos(): to_remote_q = multiprocessing.Queue() to_servo_q = multiprocessing.Queue() to_main_q = multiprocessing.Queue() remote = ValidateRunning(arduino_remote.main, args=(to_remote_q, to_main_q)) servo = ValidateRunning(arduino_servo.main, args=(to_servo_q, to_main_q)) return (remote, servo, to_remote_q, to_servo_q, to_main_q) 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: <----SKIPPED LINES----> 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 False: #if not SIMULATION: 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. """ error_code = False # 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: Log('curl.perform() failed with message %s' % e) UpdateStatusLight(GPIO_ERROR_VESTABOARD_CONNECTION, True) error_code = True else: # you may want to check HTTP response code, e.g. status_code = curl.getinfo(pycurl.RESPONSE_CODE) if status_code != 200: Log('Server returned HTTP status code %d for message %s' % (status_code, s)) UpdateStatusLight(GPIO_ERROR_VESTABOARD_CONNECTION, True) error_code = True curl.close() if not error_code: UpdateStatusLight(GPIO_ERROR_VESTABOARD_CONNECTION, False) 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) Log(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(full_path=PICKLE_FLIGHTS): """(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. """ directory, file = os.path.split(full_path) all_files = os.listdir(directory) files = sorted([os.path.join(directory, f) for f in all_files if file in f]) for f in files: print('Bootstrapping %s' % f) configuration = ReadAndParseSettings(CONFIG_FILE) flights = [] tmp_f = f + 'tmp' if os.path.exists(tmp_f): os.remove(tmp_f) if os.path.exists(f): mtime = os.path.getmtime(f) flights = UnpickleObjectFromFile(f, False) 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_f, False) if mtime == os.path.getmtime(f): shutil.move(tmp_f, f) else: print('Aborted: failed to bootstrap %s: file changed while in process' % full_path) return def ResetLogs(config): """Clears the non-scrolling logs if reset_logs in config.""" if 'reset_logs' in config: Log('Reset logs') for f in (STDERR_FILE, BACKUP_FILE, SERVICE_VERIFICATION_FILE): if os.path.exists(f): os.remove(f) open(f, 'a').close() config.pop('reset_logs') config = BuildSettings(config) WriteFile(CONFIG_FILE, config) return config 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. """ Log('Starting up process %d' % os.getpid()) already_running_id = CheckIfProcessRunning() if already_running_id: Log('Sending termination signal to %d' % already_running_id) os.kill(already_running_id, signal.SIGTERM) if '-s' in sys.argv: global SIMULATION_COUNTER SimulationSetup() # Redirect any errors to a log file instead of the screen, and add a datestamp if not SIMULATION: sys.stderr = open(STDERR_FILE, 'a') Log('', STDERR_FILE) if RASPBERRY_PI: RPi.GPIO.setmode(RPi.GPIO.BCM) SetPinsAsOutput() fan_power = False configuration = ReadAndParseSettings(CONFIG_FILE) 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 = UnpickleObjectFromFile(PICKLE_FLIGHTS, True, max_days=30) # 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 flight in flights: distribution = flight['insight_types'] for key in distribution: insight_message_distribution[key] = ( insight_message_distribution.get(key, 0) + 1) if False: remote, servo, to_remote_q, to_servo_q, to_main_q = InitArduinos() # 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() + LOOP_DELAY_SECONDS # 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 WaitUntilKillComplete(already_running_id) Log('Finishing initialization of %d; starting radio polling loop' % os.getpid()) while not SIMULATION or SIMULATION_COUNTER < len(DUMP_JSONS): new_configuration = ReadAndParseSettings(CONFIG_FILE) CheckForNewFilterCriteria(configuration, new_configuration, message_queue, flights) configuration = new_configuration ResetLogs(configuration) # clear the logs if requested # 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: dump_json_exists = os.path.exists(DUMP_JSON_FILE) if dump_json_exists: tmp_timestamp = os.path.getmtime(DUMP_JSON_FILE) if (SIMULATION and DumpJsonChanges()) or ( not SIMULATION and dump_json_exists 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, flight, now, json_desc_dict, persistent_path) = ScanForNewFlights( persistent_nearby_aircraft, persistent_path, configuration.get('log_jsons', False)) if flight: flights.append(flight) if False: remote, servo = RefreshArduinos( remote, servo, to_remote_q, to_servo_q, to_main_q, flights, json_desc_dict) flight_meets_display_criteria = FlightMeetsDisplayCriteria( flight, configuration, log=True) 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: Log( '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, not SIMULATION) else: if False: remote, servo = RefreshArduinos( remote, servo, to_remote_q, to_servo_q, to_main_q, flights, json_desc_dict) if SIMULATION: if now: simulated_hour = EpochDisplayTime(now, '%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 not flights: Log('Histogram requested (%s) but no flights in memory' % histogram) 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) CheckRebootNeeded(startup_time, message_queue, json_desc_dict) fan_power = CheckTemperature(fan_power) 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 SHUTDOWN_SIGNAL: # do a graceful exit sys.exit() if SIMULATION: SimulationEnd(message_queue, flights) def CheckForNewFilterCriteria(prev, new, message_queue, flights): """If filter criteria changed, generate new image and perhaps new message.""" if (new.get('setting_max_distance') != prev.get('setting_max_distance') or new.get('setting_max_altitude') != prev.get('setting_max_altitude')): FlightCriteriaHistogramPng( flights, new['setting_max_distance'], new['setting_max_altitude'], 7, last_max_distance_feet=prev.get('setting_max_distance'), last_max_altitude_feet=prev.get('setting_max_altitude')) if (new.get('setting_max_distance') != prev.get('setting_max_distance') or new.get('setting_max_altitude') != prev.get('setting_max_altitude') or new.get('setting_off_time') != prev.get('setting_off_time') or new.get('setting_on_time') != prev.get('setting_on_time')): next_flight_message = FlightInsightNextFlight(flights) if next_flight_message: message_queue.append((FLAG_MSG_INTERESTING, next_flight_message)) def CheckRebootNeeded(startup_time, message_queue, json_desc_dict): """Reboot if running for over 24 hours and all is quiet.""" 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')): Log('About to reboot after running for %.2f hours' % running_hours) os.system('sudo reboot') sys.exit() def CheckTemperature(fan_power): """Turn on fan if temperature exceeds threshold.""" if RASPBERRY_PI: temperature = gpiozero.CPUTemperature().temperature if temperature > TEMP_FAN_TURN_ON_CELSIUS and not fan_power: fan_power = True RPi.GPIO.output(GPIO_FAN, RPi.GPIO.HIGH) Log('Fan turned on at temperature %.1f' % temperature) elif temperature < TEMP_FAN_TURN_OFF_CELSIUS and fan_power: fan_power = False RPi.GPIO.output(GPIO_FAN, RPi.GPIO.LOW) Log('Fan turned off at temperature %.1f' % temperature) return fan_power pin_values = {} def SetPinsAsOutput(): """Initialize output GPIO pins for output on Raspberry Pi.""" global pin_values for pin in ( GPIO_ERROR_VESTABOARD_CONNECTION, GPIO_ERROR_FLIGHT_AWARE_CONNECTION, GPIO_ERROR_ARDUINO_SERVO_CONNECTION, GPIO_ERROR_ARDUINO_REMOTE_CONNECTION, GPIO_ERROR_BATTERY_CHARGE, GPIO_FAN): if RASPBERRY_PI: RPi.GPIO.setup(pin[0], RPi.GPIO.OUT) RPi.GPIO.output(pin[0], RPi.GPIO.LOW) pin_values[pin] = False def UpdateStatusLight(pin, value): """Sets the Raspberry Pi GPIO pin high (True) or low (False) based on value.""" global pin_values if RASPBERRY_PI: if value: RPi.GPIO.output(pin[0], RPi.GPIO.HIGH) else: RPi.GPIO.output(pin[0], RPi.GPIO.LOW) if pin_values[pin] != value: if value: message = pin[1] else: message = pin[2] if message: Log('Error light on pin %d set to %s: %s' % (pin, value, message)) pin_values[pin] = value def TerminateProcess(signalNumber, unused_frame): """Sets flag so that the main loop will terminate when it completes the iteration.""" global SHUTDOWN_SIGNAL SHUTDOWN_SIGNAL = True Log('%d received termination signal %d (%s)' % ( os.getpid(), signalNumber, signal.Signals(signalNumber).name)) # pylint: disable=E1101 def WaitUntilKillComplete(already_running_id, max_seconds=10): """Prevents main loop from starting until other instance, if any, completes shutdown. A termination command send to any other identically-named process may take a few seconds to complete because that other process is allowed to finish the current iteration in the main loop. Typically, that iteration in the other process will complete before this process finishes the initialization and starts. But in limited scenarios, that might not happen, such as if the other process is in the middle of generating a lot of histogram images, or if this process does not have much data to load. This function ensures that this process does not start the main loop until the other process terminates. If it detects that the other process is still running, it waits for up to max_seconds. If the other process does not terminate before that time limit, then this process terminates. """ still_running_id = CheckIfProcessRunning() if still_running_id and still_running_id != already_running_id: # uh-oh! another process started up in the interim? exit! Log('Kill signal sent to %d from this process %d, but it seems like there is ' 'another process running, %d!' % ( already_running_id, os.getpid(), still_running_id)) sys.exit() elif still_running_id and still_running_id == already_running_id: # wait a few seconds for other process to terminate n = 0 while CheckIfProcessRunning(): if n == max_seconds: Log('Kill signal sent from this process %d to other %d, but other still ' 'running; exiting after %d seconds' % ( os.getpid(), already_running_id, n+1)) sys.exit() n += 1 time.sleep(1) Log('Kill signal sent from this process %d to other %d, but other still ' 'running; waiting %d seconds' % (os.getpid(), already_running_id, n+1)) if __name__ == "__main__": signal.signal(signal.SIGINT, TerminateProcess) #interrupt, as in ctrl-c signal.signal(signal.SIGTERM, TerminateProcess) #terminate, when another instance found if '-i' in sys.argv: BootstrapInsightList() else: main() |