01234567890123456789012345678901234567890123456789012345678901234567890123456789
123 456789 1011 121314151617181920 212223 24 252627 28293031323334 35 36 37 3839 40 41 42 43 4445 464748495051525354 5556 5758596061 6263646566 6768697071727374757677787980818283 84858687888990919293949596979899100101102103104 105106107108 109110111112113114115116117118119120121122123124 125126127128129130131132133134135136137138139140141142143144145146147148149150151152153 154155 156157158159160 161162163164165166167 168169170171172173174175176177178179180181 182183 184185186187188189190191192193 194195196197198199200201202203204205206207208209210211212213 270271272273274275276277278279280281282283284285286287288289 290291292293294295296297298299300301302303304305306307308309 322323324325326327328329330331332333334335336337338339340341 342 343344345346347348349350 351352353354355356357358359360361362 363364365 366367368369370371372373374375376377378379380381382383384385 401402403404405406407408409410411412413414415416417418419420421422423424425 426427428429430431432433 434435 436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469 470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522 523 524525526527528529530531532533534535536537538539540541542543 654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695 7167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635 2645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684 268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711 27122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773 2805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847 2848284928502851 28522853285428552856285728582859286028612862286328642865286628672868286928702871 2872287328742875287628772878287928802881 2882288328842885288628872888 288928902891289228932894 28952896289728982899290029012902290329042905 2906290729082909291029112912291329142915291629172918291929202921 29222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947 29482949295029512952295329542955295629572958295929602961296229632964 2965296629672968 296929702971297229732974 297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019 3020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470 34743475347634773478347934803481348234833484348534863487348834893490349134923493 3494349534963497349834993500 350135023503350435053506350735083509 35103511 351235133514351535163517351835193520 3521 352235233524 35253526352735283529353035313532353335343535 35363537 353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585 358635873588 358935903591 3592359335943595 359635973598 3599 | #!/usr/bin/python3 import datetime import json import random import math import numbers import os import pickle import signal import shutil import textwrap import time import bs4 import dateutil.relativedelta import numpy import matplotlib import matplotlib.pyplot import psutil import pytz import requests import tzlocal HOME_LAT = 37.64406 HOME_LON = -122.43463 HOME_ALT = 29 #altitude in meters RADIUS = 6371.0e3 # radius of earth in meters HOME = (HOME_LAT, HOME_LON) # lat / lon tuple of antenna MIN_METERS = 1000 # only planes within this distance will be detailed # planes not seen within MIN_METERS in 5 seconds will be dropped from the nearby list PERSISTENCE_SECONDS = 5 FEET_IN_METER = 3.28084 METERS_PER_SECOND_IN_KNOTS = 0.514444 TRUNCATE = 50 # max number of keys to include in a histogram image file DUMP_JSON_FILE = '/run/dump1090-mutability/aircraft.json' MESSAGEBOARD_PATH = '/home/pi/splitflap/' PICKLEFILE_30D = 'flights_30d.pk' #pickled list of up to about 30d of flights PICKLEFILE_ARCHIVE = 'flights_archive.pk' #pickled list of all flights LOGFILE = 'log.txt' WEBSERVER_PATH = '/var/www/html/' WEBSERVER_IMAGE_FOLDER = 'images/' HOURLY_HISTOGRAM_FILE = 'histogram.txt' CONFIG_FILE = 'settings.txt' HOURLY_IMAGE_FILE = 'hours.png' HOURLY_DATA_FILE = 'hours.txt' ALL_MESSAGE_FILE = 'all_messages.txt' #enumeration of all messages sent to board ROLLING_MESSAGE_FILE = 'rolling_messages.txt' HISTOGRAM_IMAGE_PREFIX = 'histogram_' HISTOGRAM_IMAGE_SUFFIX = 'png' HISTOGRAM_EMPTY_IMAGE_FILE = 'empty.png' ROLLING_LOGFILE = 'rolling_log.txt' #file for error messages FLAG_MSG_FLIGHT = 1 # basic flight details FLAG_MSG_INTERESTING = 2 # random tidbit about a flight FLAG_MSG_HISTOGRAM = 3 # histogram message #if running on raspberry, then need to prepend path to file names if psutil.sys.platform.title() == 'Linux': PICKLEFILE_30D = MESSAGEBOARD_PATH + PICKLEFILE_30D PICKLEFILE_ARCHIVE = MESSAGEBOARD_PATH + PICKLEFILE_ARCHIVE LOGFILE = MESSAGEBOARD_PATH + LOGFILE HOURLY_HISTOGRAM_FILE = WEBSERVER_PATH + HOURLY_HISTOGRAM_FILE CONFIG_FILE = WEBSERVER_PATH + CONFIG_FILE HOURLY_IMAGE_FILE = WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HOURLY_IMAGE_FILE HOURLY_DATA_FILE = WEBSERVER_PATH + HOURLY_DATA_FILE ROLLING_MESSAGE_FILE = WEBSERVER_PATH + ROLLING_MESSAGE_FILE HISTOGRAM_IMAGE_PREFIX = ( WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_IMAGE_PREFIX) HISTOGRAM_EMPTY_IMAGE_FILE = ( WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_EMPTY_IMAGE_FILE) ALL_MESSAGE_FILE = WEBSERVER_PATH + ALL_MESSAGE_FILE ROLLING_LOGFILE = WEBSERVER_PATH + ROLLING_LOGFILE TIMEZONE = 'US/Pacific' # timezone of display TZ = pytz.timezone(TIMEZONE) SCREENIFY_SPLIT_FLAP = True SPLITFLAP_CHARS_PER_LINE = 23 SPLITFLAP_LINE_COUNT = 7 SPLITFLAP_HEADER = '+' + '-'*SPLITFLAP_CHARS_PER_LINE + '+' if SCREENIFY_SPLIT_FLAP: BORDER_CHARACTER = '|' else: BORDER_CHARACTER = '' DIRECTIONS_4 = ['N', 'E', 'S', 'W'] DIRECTIONS_8 = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] DIRECTIONS_16 = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] HOURS = ['12a', ' 1a', ' 2a', ' 3a', ' 4a', ' 5a', ' 6a', ' 7a', ' 8a', ' 9a', '10a', '11a', '12p', ' 1p', ' 2p', ' 3p', ' 4p', ' 5p', ' 6p', ' 7p', ' 8p', ' 9p', '10p', '11p'] SECONDS_IN_MINUTE = 60 MINUTES_IN_HOUR = 60 HOURS_IN_DAY = 24 SECONDS_IN_HOUR = SECONDS_IN_MINUTE * MINUTES_IN_HOUR SECONDS_IN_DAY = SECONDS_IN_HOUR*HOURS_IN_DAY #debug: still need to validate vert_rate units! CLIMB_RATE_UNITS = 'fpm' # need to confirm #speed units from tracker are knots, based on dump-1090/track.c #https://github.com/SDRplay/dump1090/blob/master/track.c SPEED_UNITS = 'kn' DISTANCE_UNITS = 'ft' # For displaying histograms # If a key is not present, how should it be displayed in histograms? KEY_NOT_PRESENT_STRING = 'Unknown' OTHER_STRING = 'Other' # What key strings should be listed last in sequence? # What key strings should be listed last in sequence? SORT_AT_END_STRINGS = [OTHER_STRING, KEY_NOT_PRESENT_STRING] # What is the sorted sequence of keys for days of week? DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] aircraft_length = {} # in meters aircraft_length['Airbus A220-100 (twin-jet)'] = 35 aircraft_length['Airbus A319 (twin-jet)'] = 33.84 aircraft_length['Airbus A320 (twin-jet)'] = 37.57 aircraft_length['Airbus A320neo (twin-jet)'] = 37.57 aircraft_length['Airbus A321 (twin-jet)'] = 44.51 aircraft_length['Airbus A321neo (twin-jet)'] = 44.51 aircraft_length['Airbus A330-200 (twin-jet)'] = 58.82 aircraft_length['Airbus A330-300 (twin-jet)'] = 63.67 aircraft_length['Airbus A340-300 (quad-jet)'] = 63.69 aircraft_length['Airbus A350-1000 (twin-jet)'] = 73.79 aircraft_length['Airbus A350-900 (twin-jet)'] = 66.8 aircraft_length['Airbus A380-800 (quad-jet)'] = 72.72 aircraft_length['Boeing 737-400 (twin-jet)'] = 36.4 aircraft_length['Boeing 737-700 (twin-jet)'] = 33.63 aircraft_length['Boeing 737-800 (twin-jet)'] = 39.47 aircraft_length['Boeing 737-900 (twin-jet)'] = 42.11 aircraft_length['Boeing 747-400 (quad-jet)'] = 36.4 aircraft_length['Boeing 747-8 (quad-jet)'] = 76.25 aircraft_length['Boeing 757-200 (twin-jet)'] = 47.3 aircraft_length['Boeing 757-300 (twin-jet)'] = 54.4 aircraft_length['Boeing 767-200 (twin-jet)'] = 48.51 aircraft_length['Boeing 767-300 (twin-jet)'] = 54.94 aircraft_length['Boeing 777-200 (twin-jet)'] = 63.73 aircraft_length['Boeing 777-200LR/F (twin-jet)'] = 63.73 aircraft_length['Boeing 777-300ER (twin-jet)'] = 73.86 aircraft_length['Boeing 787-10 (twin-jet)'] = 68.28 aircraft_length['Boeing 787-8 (twin-jet)'] = 56.72 aircraft_length['Boeing 787-9 (twin-jet)'] = 62.81 aircraft_length['Canadair Regional Jet CRJ-200 (twin-jet)'] = 26.77 aircraft_length['Canadair Regional Jet CRJ-700 (twin-jet)'] = 32.3 aircraft_length['Bombardier Challenger 300 (twin-jet)'] = 20.92 aircraft_length['EMBRAER 175 (long wing) (twin-jet)'] = 31.68 def CheckIfProcessRunning(processName): # Check if there is any running process that contains the given name processName. this_process_id = os.getpid() for proc in psutil.process_iter(): try: # Check if process name contains the given name string. commands = proc.as_dict(attrs=['cmdline', 'pid']) if commands['cmdline']: command_running = any([processName.lower() in s.lower() for s in commands['cmdline']]) if command_running and commands['pid'] != this_process_id: return commands['pid'] except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): pass return None def LogMessage(message, file=LOGFILE): """Write a message to a logfile along with a timestamp. Args: message: string message to write file: string representing file name and, if needed, path to the file to write to """ try: with open(file, 'a') as f: f.write('='*80+'\n') f.write(str(datetime.datetime.now(TZ))+'\n') f.write('\n') f.write(message+'\n') except IOError: LogMessage('Unable to append to ' + file) existing_log_lines = ReadFile(LOGFILE).splitlines() with open(ROLLING_LOGFILE, 'w') as f: f.write('\n'.join(existing_log_lines[-1000:])) def UtcToLocalTimeDifference(timezone=TIMEZONE): """Calculates number of seconds between UTC and given timezone. Returns number of seconds between UTC and given timezone; if no timezone given, uses TIMEZONE defined in global variable. Args: timezone: string representing a valid pytz timezone in pytz.all_timezones. Returns: Integer number of seconds. """ utcnow = pytz.timezone('utc').localize(datetime.datetime.utcnow()) home_time = utcnow.astimezone(pytz.timezone(timezone)).replace(tzinfo=None) system_time = utcnow.astimezone(tzlocal.get_localzone()).replace(tzinfo=None) offset = dateutil.relativedelta.relativedelta(home_time, system_time) offset_seconds = offset.hours * SECONDS_IN_HOUR <----SKIPPED LINES----> lambda3 = lambda1 + dlambda13 intersection = (degrees(phi3), degrees(lambda3)) return intersection def ConvertBearingToCompassDirection(bearing, length=3, pad=False): """Converts a bearing (in degrees) to a compass dir of 1, 2, or 3 chars (N, NW, NNW). Args: bearing: degrees to be converted length: if 1, 2, or 3, converts to one of 4, 8, or 16 headings: - 1: N, S, E, W - 2: SE, SW, etc. also valid - 3: NWN, ESE, etc. also valid pad: boolean indicating whether the direction should be right-justified to length characters Returns: String representation of the compass heading. """ divisions = 2**(length+1) # i.e.: 4, 8, or 16 division_size = 360 / divisions # i.e.: 90, 45, or 22.5 bearing_number = round(bearing / division_size) if length == 1: directions = DIRECTIONS_4 elif length == 2: directions = DIRECTIONS_8 else: directions = DIRECTIONS_16 direction = directions[bearing_number%divisions] if pad: direction = direction.rjust(length) return direction def HaversineDistanceMeters(pos1, pos2): """Calculate the distance between two points on a sphere (e.g. Earth). <----SKIPPED LINES----> pos2: a 2-tuple defining (lat, lon) in decimal degrees Returns: Distance between two points in meters. """ is_numeric = [isinstance(x, numbers.Number) for x in (*pos1, *pos2)] if False in is_numeric: return None lat1, lon1, lat2, lon2 = [math.radians(x) for x in (*pos1, *pos2)] hav = (math.sin((lat2 - lat1) / 2.0)**2 + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2.0)**2) distance = 2 * RADIUS * math.asin(math.sqrt(hav)) # Note: though pyproj has this, having trouble installing on rpi #az12, az21, distance = g.inv(lon1, lat1, lon2, lat2) return distance def Angles(pos1, elevation1, pos2, elevation2): sin = math.sin cos = math.cos atan2 = math.atan2 atan = math.atan sqrt = math.sqrt radians = math.radians degrees = math.degrees distance = HaversineDistanceMeters(pos1, pos2) # from home to plumb line of plane lat1, lon1, lat2, lon2 = [radians(x) for x in (*pos1, *pos2)] d_lon = lon2 - lon1 # azimuth calc from https://www.omnicalculator.com/other/azimuth az = atan2((sin(d_lon)*cos(lat2)), (cos(lat1)*sin(lat2)-sin(lat1)*cos(lat2)*cos(d_lon))) az_degrees = degrees(az) elevation = elevation2 - elevation1 alt = atan(elevation / distance) alt_degrees = degrees(alt) crow_distance = sqrt(elevation**2 + distance**2) # from home to the plane return (az_degrees, alt_degrees, distance, crow_distance) def TrajectoryLatLon(pos, distance, track): #distance in meters #track in degrees sin = math.sin cos = math.cos atan2 = math.atan2 asin = math.asin radians = math.radians degrees = math.degrees track = radians(track) lat1 = radians(pos[0]) lon1 = radians(pos[1]) d_div_R = distance/RADIUS lat2 = asin(sin(lat1)*cos(d_div_R) + cos(lat1)*sin(d_div_R)*cos(track)) lon2 = lon1 + atan2(sin(track)*sin(d_div_R)*cos(lat1), cos(d_div_R)-sin(lat1)*sin(lat2)) lat2_degrees = degrees(lat2) lon2_degrees = degrees(lon2) return (lat2_degrees, lon2_degrees) <----SKIPPED LINES----> is_numeric = [isinstance(x, numbers.Number) for x in (*pos, bearing)] if False in is_numeric: return None # To find the minimum distance, we must first find the point at which the minimum # distance will occur, which in turn is accomplished by finding the intersection # between that trajectory and a trajectory orthogonal (+90 degrees, or -90 degrees) # to it but intersecting HOME. potential_intersection1 = IntersectionForTwoPaths(pos, bearing, HOME, bearing + 90) potential_intersection2 = IntersectionForTwoPaths(pos, bearing, HOME, bearing - 90) potential_distance1 = HaversineDistanceMeters(potential_intersection1, HOME) potential_distance2 = HaversineDistanceMeters(potential_intersection2, HOME) # Since one of those two potential intersection points (i.e.: +90 or -90 degrees) will # create an irrational result, and given the strong locality to HOME that is expected # from the initial position, the "correct" result is identified by simply taking the # minimum distance of the two candidate. return min(potential_distance1, potential_distance2) def SecondsToHhMm(seconds): """Converts integer number of seconds to xhym string (i.e.: 7h17m). Args: seconds: number of seconds Returns: String representation of hours and minutes. """ minutes = int(abs(seconds) / SECONDS_IN_MINUTE) if minutes > MINUTES_IN_HOUR: hours = int(minutes / MINUTES_IN_HOUR) minutes = minutes % MINUTES_IN_HOUR text = str(hours) + 'h' + str(minutes) + 'm' else: text = str(minutes) + 'm' return text def SecondsToHours(seconds): """Converts integer number of seconds to xh string (i.e.: 7h). Args: seconds: number of seconds Returns: String representation of hours. """ minutes = int(abs(seconds) / SECONDS_IN_MINUTE) hours = round(minutes / MINUTES_IN_HOUR) return hours def SecondsToDdHh(seconds): """Converts integer number of seconds to xdyh string (i.e.: 7d17h). Args: seconds: number of seconds Returns: String representation of days and hours. """ days = int(abs(seconds) / SECONDS_IN_DAY) hours = SecondsToHours(seconds - days*SECONDS_IN_DAY) if hours == HOURS_IN_DAY: hours = 0 days += 1 text = '%dd%dh' % (days, hours) return text def HoursSinceMidnight(timezone=TIMEZONE): """Returns the float number of hours elapsed since midnight in the given timezone.""" tz = pytz.timezone(timezone) now = datetime.datetime.now(tz) seconds_since_midnight = ( now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() hours = seconds_since_midnight / SECONDS_IN_HOUR return hours def HoursSinceFlight(now, then): """Returns the number of hours between a timestamp and a flight. Args: now: timezone-aware datetime representation of timestamp then: string representation of a timestamp in format '%Y-%m-%d %H:%M:%S.%f%z' (i.e.: '2020-03-22 00:05:44.026630-07:00' Returns: Number of hours between now and then (i.e.: now - then; a positive return value means now occurred after then). """ then_time = datetime.datetime.strptime(then, '%Y-%m-%d %H:%M:%S.%f%z') delta = now - then_time delta_hours = delta.days * HOURS_IN_DAY + delta.seconds / SECONDS_IN_HOUR return delta_hours def DataHistoryHours(flights): """Calculates the number of hours between the earliest flight in data and now. flights: List of all flights in sequential order, so that the first in list is earliest in time. Returns: Return time difference in hours between the first flight in data and now. """ min_time_string = flights[0]['calcd_display_time'] min_time = datetime.datetime.strptime(min_time_string, '%Y-%m-%d %H:%M:%S.%f%z') tz = min_time.tzinfo now = datetime.datetime.now(tz) delta = now - min_time delta_hours = delta.days * HOURS_IN_DAY + delta.seconds / SECONDS_IN_HOUR return round(delta_hours) def DictGetReplaceNone(d, key, default_value=''): """d[key] if key exists and is not None; default_value otherwise.""" value = d.get(key) if value is None: value = default_value return value def SortByValues(values, keys, ignore_sort_at_end_strings=False): """Sorts the list of values in descending sequence, applying same resorting to keys. Given a list of keys and values representing a histogram, returns two new lists that are sorted so that the values occur in descending sequence and the keys are moved around in the same way. This allows the printing of a histogram with the largest keys listed first - i.e.: top five airlines. Keys identified by SORT_AT_END_STRINGS - such as, perhaps, 'Other' - will optionally be placed at the end of the sequence. And where values are identical, the secondary sort is based on the keys. Args: values: list of values for the histogram to be used as the primary sort key. keys: list of keys for the histogram that will be moved in the same way as the values. ignore_sort_at_end_strings: boolean indicating whether specially-defined keys will be sorted at the end. <----SKIPPED LINES----> keyfunction, sort_type, title, position=None, truncate=0, hours=float('inf'), max_distance_feet=float('inf'), max_altitude_feet=float('inf'), normalize_factor=0, exhaustive=False, figsize_inches=(9, 6)): """Creates matplotlib.pyplot of histogram that can then be saved or printed. Args: data: the iterable (i.e.: list) of flight details, where each element in the list is a dictionary of the flight attributes. keyfunction: a function that when applied to a single flight (i.e.: keyfunction(data[0]) returns the key to be used for the histogram. data: the iterable of the raw data from which the histogram will be generated; each element of the iterable is a dictionary, that contains at least the key 'calcd_display_time', and depending on other parameters, also potentially 'calcd_min_feet' amongst others. keyfunction: the function that determines how the key or label of the histogram should be generated; it is called for each element of the data iterable. For instance, to simply generate a histogram on the attribute 'heading', keyfunction would be lambda a: a['heading']. title: the "base" title to include on the histogram; it will additionally be augmented with the details about the date range. position: Either a 3-digit integer or an iterable of three separate integers describing the position of the subplot. If the three integers are nrows, ncols, and index in order, the subplot will take the index position on a grid with nrows rows and ncols columns. index starts at 1 in the upper left corner and increases to the right. sort_type: determines how the keys (and the corresponding values) are sorted: 'key': the keys are sorted by a simple comparison operator between them, which sorts strings alphabetically and numbers numerically. 'value': the keys are sorted by a comparison between the values, which means that more frequency-occurring keys are listed first. list: if instead of the strings a list is passed, the keys are then sorted in the sequence enumerated in the list. This is useful for, say, ensuring that the days of the week (Tues, Wed, Thur, ...) are listed in sequence. Keys that are generated by keyfunction but that are not in the given list are <----SKIPPED LINES----> the list, including potentially those with a frequency of zero, within the restrictions of truncate. figsize_inches: a 2-tuple of width, height indicating the size of the histogram. """ (values, keys, filtered_data) = GenerateHistogramData( data, keyfunction, sort_type, truncate=truncate, hours=hours, max_distance_feet=max_distance_feet, max_altitude_feet=max_altitude_feet, normalize_factor=normalize_factor, exhaustive=exhaustive) if position: matplotlib.pyplot.subplot(*position) matplotlib.pyplot.figure(figsize=figsize_inches) values_coordinates = numpy.arange(len(keys)) matplotlib.pyplot.bar(values_coordinates, values) earliest_flight_time = int(filtered_data[0]['calcd_timestamp']) last_flight_time = int(filtered_data[-1]['calcd_timestamp']) date_range_string = ' (%d flights over last %s hours)' % ( sum(values), SecondsToDdHh(last_flight_time - earliest_flight_time)) matplotlib.pyplot.title(title + date_range_string) matplotlib.pyplot.subplots_adjust(bottom=0.15, left=0.09, right=0.99, top=0.92) matplotlib.pyplot.xticks( values_coordinates, keys, rotation='vertical', wrap=True, horizontalalignment='right', verticalalignment='center') def SaveTimeOfDayDataFile(flights, max_days, filename=HOURLY_DATA_FILE, precision=100): """Extracts hourly histogram into text file for a variety of altitudes and distances. Generates a csv with 26 columns: - col#1: altitude (in feet) - col#2: distance (in feet) - cols#3-26: hour of the day The first row is a header row; subsequent rows list the number of flights that have occurred in the last max_days with an altitude and distance less than that identified in the first two columns. Each row increments elevation or altitude by precision feet, up to the max determined by the max altitude and max distance amongst all the flights. Args: flights: list of the flights. max_days: maximum number of days as described. filename: file into which to save the csv. precision: number of feet to increment the altitude or distance. """ max_altitude = int(max([flight.get('altitude', -1) for flight in flights])) max_distance = int(max([ flight.get('distance_meters', -1)*FEET_IN_METER for flight in flights])) header_printed = False for flight in flights: flight['calcd_hour'] = ConvertHourStringTimeString(flight) lines = [] for altitude in range(0, max_altitude, precision): for distance in range(0, max_distance, precision): (values, keys, unused_filtered_data) = GenerateHistogramData( flights, lambda a: a['calcd_hour'], HOURS, hours=max_days*HOURS_IN_DAY, max_distance_feet=distance, max_altitude_feet=altitude, normalize_factor=1, #debug: max_days exhaustive=True) if not header_printed: header_elements = ['altitude', 'distance', *keys] header_elements = [str(v) for v in header_elements] header = ','.join(header_elements) lines.append(header) header_printed = True line_elements = [altitude, distance] line_elements.extend(values) line_elements = [str(v) for v in line_elements] line = ','.join(line_elements) lines.append(line) try: with open(filename, 'w') as f: for line in lines: f.write(line+'\n') except IOError: LogMessage('Unable to write hourly histogram data file ' + filename) def SaveTimeOfDayHistogramPng( flights, max_distance_feet, max_altitude_feet, max_days, filename=HOURLY_IMAGE_FILE, last_max_distance_feet=None, last_max_altitude_feet=None): """Saves as a png file the histogram of the hourly flight data for the given filters. Generates a png histogram of the count of flights by hour that meet the specified criteria: max altitude, max distance, and within the last number of days. Saves this histogram to disk, as well as potentially moving the previously saved image to a new location. Args: flights: list of the flights. max_distance_feet: max distance for which to include flights in the histogram. max_altitude_feet: max altitude for which to include flights in the histogram. max_days: maximum number of days as described. filename: file into which to save the csv. last_max_distance_feet: if provided, along with last_max_altitude_feet, generates a second data series with different criteria for distance and altitude, for which the histogram data will be plotted alongside the first series. last_max_altitude_feet: see above. """ (values, keys, unused_filtered_data) = GenerateHistogramData( flights, ConvertHourStringTimeString, HOURS, hours=max_days*HOURS_IN_DAY, max_distance_feet=max_distance_feet, max_altitude_feet=max_altitude_feet, normalize_factor=max_days, exhaustive=True) comparison = last_max_distance_feet is not None and last_max_altitude_feet is not None if comparison: (last_values, unused_last_keys, unused_filtered_data) = GenerateHistogramData( flights, ConvertHourStringTimeString, HOURS, hours=max_days*HOURS_IN_DAY, max_distance_feet=last_max_distance_feet, max_altitude_feet=last_max_altitude_feet, normalize_factor=max_days, exhaustive=True) x = numpy.arange(len(keys)) unused_fig, ax = matplotlib.pyplot.subplots() width = 0.35 ax.bar( x - width/2, values, width, label='Current - alt: %d; dist: %d' % (max_altitude_feet, max_distance_feet)) title = 'Daily Flights Expected: %d / day' % sum(values) if comparison: ax.bar( x + width/2, last_values, width, label='Prior - alt: %d; dist: %d' % ( last_max_altitude_feet, last_max_distance_feet)) title += ' (%+d)' % (round(sum(values) - sum(last_values))) ax.set_title(title) ax.set_ylabel('Average Observed Flights') if comparison: ax.legend() matplotlib.pyplot.xticks( x, keys, rotation='vertical', wrap=True, horizontalalignment='right', verticalalignment='center') matplotlib.pyplot.savefig(filename) matplotlib.pyplot.close() def GenerateHistogramData( data, keyfunction, sort_type, truncate=float('inf'), hours=float('inf'), max_distance_feet=float('inf'), max_altitude_feet=float('inf'), normalize_factor=0, exhaustive=False): """Generates sorted data for a histogram from a description of the flights. Given an iterable describing the flights, this function generates the label (or key), and the frequency (or value) from which a histogram can be rendered. Args: data: the iterable of the raw data from which the histogram will be generated; each element of the iterable is a dictionary, that contains at least the key 'calcd_display_time', and depending on other parameters, also potentially 'calcd_min_feet' amongst others. keyfunction: the function that determines how the key or label of the histogram should be generated; it is called for each element of the data iterable. For instance, to simply generate a histogram on the attribute 'heading', keyfunction would be lambda a: a['heading']. sort_type: determines how the keys (and the corresponding values) are sorted: 'key': the keys are sorted by a simple comparison operator between them, which sorts strings alphabetically and numbers numerically. 'value': the keys are sorted by a comparison between the values, which means that more frequency-occurring keys are listed first. list: if instead of the strings a list is passed, the keys are then sorted in the sequence enumerated in the list. This is useful for, say, ensuring that the days of the week (Tues, Wed, Thur, ...) are listed in sequence. Keys that are generated by keyfunction but that are not in the given list are sorted last (and then amongst those, alphabetically). truncate: integer indicating the maximum number of keys to return; if set to 0, or if set to a value larger than the number of keys, no truncation occurs. But if set to a value less than the number of keys, then the keys with the lowest frequency are combined into one key named OTHER_STRING so that the number of keys in the resulting histogram (together with OTHER_STRING) is equal to truncate. hours: integer indicating the number of hours of history to include. Flights with a calcd_display_time more than this many hours in the past are excluded from the histogram generation. Note that this is timezone aware, so that if the histogram data is generated on a machine with a different timezone than that that recorded the original data, the correct number of hours is still honored. max_distance_feet: number indicating the geo fence outside of which flights should be ignored for the purposes of including the flight data in the histogram. max_altitude_feet: number indicating the maximum altitude outside of which flights should be ignored for the purposes of including the flight data in the histogram. normalize_factor: divisor to apply to all the values, so that we can easily renormalize the histogram to display on a percentage or daily basis; if zero, no renormalization is applied. exhaustive: boolean only relevant if sort_type is a list, in which case, this ensures that the returned set of keys (and matching values) contains all the elements in the list, including potentially those with a frequency of zero, within the restrictions of truncate. Returns: 2-tuple of lists cut and sorted as indicated by parameters above: - list of values (or frequency) of the histogram elements - list of keys (or labels) of the histogram elements """ histogram_dict = {} filtered_data = [] # get timezone & now so that we can generate a timestamp for comparison just once if hours: now = GetNowInTimeZoneOfArbtraryFlight(data) for element in data: if ( DictGetReplaceNone( element, 'calcd_min_feet', float('inf')) < max_distance_feet and DictGetReplaceNone(element, 'altitude', float('inf')) < max_altitude_feet and HoursSinceFlight(now, element['calcd_display_time']) <= hours): filtered_data.append(element) key = keyfunction(element) if key is None or key == '': key = KEY_NOT_PRESENT_STRING if key in histogram_dict: histogram_dict[key] += 1 else: histogram_dict[key] = 1 values = list(histogram_dict.values()) keys = list(histogram_dict.keys()) if normalize_factor: values = [v / normalize_factor for v in values] sort_by_enumerated_list = isinstance(sort_type, list) if exhaustive and sort_by_enumerated_list: missing_keys = set(sort_type).difference(set(keys)) missing_values = [0 for unused_k in missing_keys] keys.extend(missing_keys) values.extend(missing_values) if keys: # filters could potentially have removed all data if not truncate or len(keys) <= truncate: if sort_by_enumerated_list: (values, keys) = SortByDefinedList(values, keys, sort_type) elif sort_type == 'value': (values, keys) = SortByValues(values, keys) else: (values, keys) = SortByKeys(values, keys) else: #Unknown might fall in the middle, and so shouldn't be truncated (values, keys) = SortByValues(values, keys, ignore_sort_at_end_strings=True) truncated_values = list(values[:truncate-1]) truncated_keys = list(keys[:truncate-1]) other_value = sum(values[truncate-1:]) truncated_values.append(other_value) truncated_keys.append(OTHER_STRING) if sort_by_enumerated_list: (values, keys) = SortByDefinedList( truncated_values, truncated_keys, sort_type) elif sort_type == 'value': (values, keys) = SortByValues( truncated_values, truncated_keys, ignore_sort_at_end_strings=False) else: (values, keys) = SortByKeys(truncated_values, truncated_keys) else: values = [] keys = [] return (values, keys, filtered_data) def ReadFile(filename, log_exception=False): """Returns text from the given file name if available, empty string if not available. Args: filename: string of the filename to open, potentially also including the full path. log_exception: boolean indicating whether to log an exception if file not found. Returns: Return text string of file contents. """ try: with open(filename, 'r') as content_file: file_contents = content_file.read() except IOError: if log_exception: LogMessage('Unable to read '+filename) return '' return file_contents def ParseDumpJson(dump_json): """Identifies all airplanes within given distance of home from the dump1090 file. Since the dump1090 json will have messages from all flights that the antenna has picked up, we want to keep only flights that are within a relevant distance to us, and also to extract from the full set of data in the json to just the relevant fields for additional analysis. Args: dump_json: The text representation of the json message from dump1090-mutability Returns: Return 2-tuple, with first element being a dictionary of all airplanes, where keys are flight numbers (i.e.: 'SWA7543'), and the value is itself a dictionary of attributes. The included attributes are: speed, altitude, vert_rate, distance_meters, lat / lon, and track (or bearing / heading). The second element is the time stamp in the json file. """ parsed = json.loads(dump_json) nearby_aircraft = {} for aircraft in parsed['aircraft']: flight_number = aircraft.get('flight', KEY_NOT_PRESENT_STRING).strip() if 'lat' in aircraft and 'lon' in aircraft: now = datetime.datetime.now(TZ) lat = aircraft.get('lat', KEY_NOT_PRESENT_STRING) lon = aircraft.get('lon', KEY_NOT_PRESENT_STRING) track = aircraft.get('track', KEY_NOT_PRESENT_STRING) distance_meters = round( HaversineDistanceMeters(HOME, (aircraft['lat'], aircraft['lon']))) min_meters = MinMetersToHome((lat, lon), track) if min_meters: min_feet = min_meters * FEET_IN_METER else: min_feet = None # simplified_aircraft is all attributes from radio or derivable from radio & math simplified_aircraft = { 'distance_meters': distance_meters, # altitude is unknown units, but believed to be feet? 'altitude': aircraft.get('altitude', KEY_NOT_PRESENT_STRING), # speed is is in knots 'speed': aircraft.get('speed', KEY_NOT_PRESENT_STRING), # vert_rate in unknown units 'vert_rate': aircraft.get('vert_rate', KEY_NOT_PRESENT_STRING), 'lat': lat, 'lon': lon, 'track': track, 'squawk': aircraft.get('squawk', KEY_NOT_PRESENT_STRING), 'radio_now': parsed['now'], 'dump_flight_number': flight_number, 'calcd_display_time': now.strftime('%Y-%m-%d %H:%M:%S.%f%z'), 'calcd_timestamp': datetime.datetime.timestamp(now), 'calcd_min_feet': min_feet} if distance_meters < MIN_METERS: nearby_aircraft[flight_number] = simplified_aircraft if not flight_number: LogMessage('Dump JSON does not include a flight number: %s' % str(aircraft), file=LOGFILE) return (nearby_aircraft, parsed['now']) def GetFlightAwareJson(flight_number): """Scrapes the text json message from FlightAware for a given flight number. Given a flight number, loads the corresponding FlightAware webpage for that flight and extracts the relevant script that contains all the flight details from that page. Args: flight_number: text flight number (i.e.: SWA1234) Returns: Text representation of the json message from FlightAware. """ url = 'https://flightaware.com/live/flight/' + flight_number try: response = requests.get(url) except: LogMessage('Unable to query FA for URL: ' + url) return '' soup = bs4.BeautifulSoup(response.text, "html.parser") l = soup.findAll('script') flight_script = None for script in l: if "trackpollBootstrap" in script.text: flight_script = script.text break if not flight_script: LogMessage('Unable to find trackpollBootstrap script in page: ' + response.text) return '' first_open_curly_brace = flight_script.find('{') flight_json = flight_script[first_open_curly_brace:-1] return flight_json def ParseFlightAwareJson(flight_json): """Strips relevant data about the flight from FlightAware feed. The FlightAware json has hundreds of fields about a flight, only a fraction of which are relevant to extract. Note that some of the fields are inconsistently populated (i.e.: scheduled and actual times for departure and take-off). Args: flight_json: Text representation of the FlightAware json about a single flight. Returns: Dictionary of flight attributes extracted from the FlightAware json. """ flight_details = {} parsed_json = json.loads(flight_json) fa_flight_number = list(parsed_json['flights'].keys())[0] parsed_flight_details = parsed_json['flights'][fa_flight_number] flight_details['fa_flight_number'] = fa_flight_number origin = parsed_flight_details.get('origin', '') if origin: origin_friendly = origin.get('friendlyLocation', '') origin_iata = origin.get('iata', '') else: origin_friendly = '' origin_iata = '' flight_details['origin_friendly'] = origin_friendly flight_details['origin_iata'] = origin_iata destination = parsed_flight_details.get('destination', '') if destination: destination_friendly = destination.get('friendlyLocation', '') destination_iata = destination.get('iata', '') else: destination_friendly = '' destination_iata = '' flight_details['destination_friendly'] = destination_friendly flight_details['destination_iata'] = destination_iata if origin_iata and destination_iata == origin_iata: LogMessage('Origin & destination both %s in FA JSON: %s' % (origin_iata, str(parsed_json)), file=LOGFILE) # perhaps interesting for private planes flight_details['owner_location'] = parsed_flight_details.get('ownerLocation') flight_details['owner'] = parsed_flight_details.get('owner') flight_details['tail'] = parsed_flight_details.get('tail') aircraft_type = parsed_flight_details.get('aircraft') if aircraft_type: aircraft_type_code = aircraft_type.get('type', '') aircraft_type_friendly = aircraft_type.get('friendlyType', '') else: aircraft_type_code = '' aircraft_type_friendly = '' flight_details['aircraft_type_code'] = aircraft_type_code flight_details['aircraft_type_friendly'] = aircraft_type_friendly takeoff_time = parsed_flight_details.get('takeoffTimes', '') if takeoff_time: scheduled_takeoff_time = takeoff_time.get('scheduled', '') actual_takeoff_time = takeoff_time.get('actual', '') else: scheduled_takeoff_time = '' actual_takeoff_time = '' flight_details['scheduled_takeofftime'] = scheduled_takeoff_time flight_details['actual_takeoff_time'] = actual_takeoff_time gate_departure_time = parsed_flight_details.get('gateDepartureTimes', '') if gate_departure_time: scheduled_departure_time = gate_departure_time.get('scheduled', '') actual_departure_time = gate_departure_time.get('actual', '') else: scheduled_departure_time = '' actual_departure_time = '' flight_details['scheduled_departure_time'] = scheduled_departure_time flight_details['actual_departure_time'] = actual_departure_time gate_arrival_time = parsed_flight_details.get('gateArrivalTimes', '') if gate_arrival_time: scheduled_arrival_time = gate_arrival_time.get('scheduled', '') estimated_arrival_time = gate_arrival_time.get('estimated', '') else: scheduled_arrival_time = '' estimated_arrival_time = '' flight_details['scheduled_arrival_time'] = scheduled_arrival_time flight_details['estimated_arrival_time'] = estimated_arrival_time landing_time = parsed_flight_details.get('landingTimes', '') if landing_time: scheduled_landing_time = landing_time.get('scheduled', '') estimated_landing_time = landing_time.get('estimated', '') else: scheduled_landing_time = '' estimated_landing_time = '' flight_details['scheduled_landing_time'] = scheduled_landing_time flight_details['estimated_landing_time'] = estimated_landing_time airline = parsed_flight_details.get('airline', '') if airline: airline_call_sign = airline.get('callsign', '') airline_short_name = airline.get('shortName', '') airline_full_name = airline.get('fullName', '') else: airline_call_sign = '' airline_short_name = '' airline_full_name = '' flight_details['airline_call_sign'] = airline_call_sign flight_details['airline_short_name'] = airline_short_name flight_details['airline_full_name'] = airline_full_name if len(parsed_json['flights'].keys()) > 1: LogMessage('There are multiple flights in the FlightAware json: ' + parsed_json) return flight_details def AugmentWithDisplayableAirline(flight_details): """Augments flight details with display-ready airline attributes. Args: flight_details: dictionary with key-value attributes about the flight, but not yet containing all the necessary print-ready flight attributes. """ # LINE1: UAL1425 - UNITED airline = DictGetReplaceNone(flight_details, 'airline_short_name') if not airline: airline = DictGetReplaceNone(flight_details, 'airline_full_name') if not airline: airline = KEY_NOT_PRESENT_STRING flight_details['calcd_airline'] = airline def AugmentWithDisplayableAircraft(flight_details): """Augments flight details with display-ready aircraft attributes. Args: flight_details: dictionary with key-value attributes about the flight, but not yet containing all the necessary print-ready flight attributes. """ # LINE2: Boeing 737-800 (twin-jet) aircraft_type = DictGetReplaceNone(flight_details, 'aircraft_type_friendly') aircraft_type = aircraft_type.replace('(twin-jet)', '(twin)') aircraft_type = aircraft_type.replace('(quad-jet)', '(quad)') aircraft_type = aircraft_type.replace('Regional Jet ', '') aircraft_type = aircraft_type[:SPLITFLAP_CHARS_PER_LINE] flight_details['calcd_aircraft_type'] = aircraft_type def AugmentWithDisplayableOriginDestination(flight_details): """Augments flight details with display-ready origin and destination attributes. If the origin or destination is among a few key airports where the IATA code is well-known, then we can display only that code. Otherwise, we'll want to display both the code and a longer description of the airport. But we need to be mindful of the overall length of the display. So, for instance, these might be produced as valid origin-destination pairs: SFO-CLT Charlotte <- Known origin Charlotte CLT-SFO <- Known destination Charl CLT-SAN San Diego <- Neither origin nor destination known Args: flight_details: dictionary with key-value attributes about the flight, but not yet containing all the necessary print-ready flight attributes. """ iata_length = 3 known_airports = ('SJC', 'SFO', 'OAK') # Airport codes we don't need to expand origin = (DictGetReplaceNone(flight_details, 'origin_iata') + ' ' + DictGetReplaceNone(flight_details, 'origin_friendly').split(',')[0]) destination = (DictGetReplaceNone(flight_details, 'destination_iata') + ' ' + DictGetReplaceNone(flight_details, 'destination_friendly').split(',')[0]) max_line_length_after_divider = SPLITFLAP_CHARS_PER_LINE - len('-') if (origin[:iata_length] not in known_airports and destination[:iata_length] not in known_airports): max_origin_length = int(max_line_length_after_divider/2) max_destination_length = max_line_length_after_divider - max_origin_length if len(origin) > max_origin_length and len(destination) > max_destination_length: origin_length = max_origin_length destination_length = max_destination_length elif len(origin) > max_origin_length: destination_length = len(destination) origin_length = max_line_length_after_divider - destination_length elif len(destination) > max_destination_length: origin_length = len(origin) destination_length = max_line_length_after_divider - origin_length else: origin_length = max_origin_length destination_length = max_destination_length elif origin[:iata_length] in known_airports: origin_length = iata_length destination_length = max_line_length_after_divider - origin_length elif destination[:iata_length] in known_airports: destination_length = iata_length origin_length = max_line_length_after_divider - destination_length else: destination_length = iata_length origin_length = iata_length origin = origin[:origin_length] destination = destination[:destination_length] flight_details['calcd_origin'] = origin flight_details['calcd_destination'] = destination origin_destination_pair = '' if origin and destination: origin_destination_pair = '-'.join([origin, destination]) flight_details['calcd_origin_destination_pair'] = origin_destination_pair def AugmentWithDisplayableDelay(flight_details): """Augments flight details with display-ready departure time and delay attributes. Args: flight_details: dictionary with key-value attributes about the flight, but not yet containing all the necessary print-ready flight attributes. """ # LINE4: Dep: 08:18 (10m early) # Dep: Unknown actual_departure = flight_details.get('actual_departure_time') scheduled_departure = flight_details.get('scheduled_departure_time') actual_takeoff_time = flight_details.get('actual_takeoff_time') scheduled_takeoff_time = flight_details.get('scheduled_takeofftime') calculable_delay = False scheduled = None delay_seconds = None delay_text = '' if actual_departure and scheduled_departure: actual = actual_departure scheduled = scheduled_departure departure_label = 'Dep' calculable_delay = True elif actual_takeoff_time and scheduled_takeoff_time: actual = actual_takeoff_time scheduled = scheduled_takeoff_time departure_label = 'T-O' calculable_delay = True elif actual_departure: actual = actual_departure departure_label = 'Act Dep' elif scheduled_departure: actual = scheduled_departure departure_label = 'Sch Dep' elif actual_takeoff_time: actual = actual_takeoff_time departure_label = 'Act T-O' elif scheduled_takeoff_time: actual = scheduled_takeoff_time departure_label = 'Sch T-O' else: actual = 0 departure_time_text = 'Dep: Unknown' if actual: tz_corrected_actual = actual + UtcToLocalTimeDifference() departure_time_text = ( departure_label + ': ' + datetime.datetime.fromtimestamp(tz_corrected_actual).strftime('%I:%M')) if calculable_delay: delay_seconds = actual - scheduled if int(delay_seconds / SECONDS_IN_MINUTE) == 0: delay_text = 'on time' else: if delay_seconds < 0: delay_direction = ' early' else: delay_direction = ' late' delay_text = SecondsToHhMm(delay_seconds) + delay_direction delay_text = ' (' + delay_text + ')' flight_details['calcd_actual_departure'] = actual flight_details['calcd_departure_time_text'] = departure_time_text flight_details['calcd_calculable_delay'] = calculable_delay flight_details['calcd_scheduled_departure'] = scheduled flight_details['calcd_delay_seconds'] = delay_seconds flight_details['calcd_delay_text'] = delay_text def AugmentWithDisplayableTimeRemaining(flight_details): """Augments flight details with display-ready flight time remaining attributes. Args: flight_details: dictionary with key-value attributes about the flight, but not yet containing all the necessary print-ready flight attributes. """ # LINE5: Rem: 4h18m # Rem: Unk arrival = flight_details.get('estimated_arrival_time') if not arrival: arrival = flight_details.get('estimated_landing_time') if not arrival: arrival = flight_details.get('scheduled_arrival_time') if not arrival: arrival = flight_details.get('scheduled_landing_time') actual = flight_details['calcd_actual_departure'] if actual and arrival: remaining_seconds = arrival - actual else: remaining_seconds = None flight_details['calcd_remaining_seconds'] = remaining_seconds def Screenify(lines): """Transforms a list of lines to a single text string either for printing or sending. Given a list of lines that is a fully-formed message to send to the splitflap display, this function transforms the list of strings to a single string that is an easier-to-read and more faithful representation of how the message will be displayed. The transformations are to add blank lines to the message to make it consistent number of lines, and to add border to the sides & top / bottom of the message. Args: lines: list of strings that comprise the message Returns: String - which includes embedded new line characters, borders, etc. as described above, that can be printed to screen as the message. """ if SCREENIFY_SPLIT_FLAP: for unused_n in range(SPLITFLAP_LINE_COUNT-len(lines)): lines.append('') lines = [BORDER_CHARACTER + line.ljust(SPLITFLAP_CHARS_PER_LINE) + BORDER_CHARACTER for line in lines] lines.insert(0, SPLITFLAP_HEADER) lines.append(SPLITFLAP_HEADER) return '\n'.join(lines) def CreateMessageAboutFlight(flight_details): """Creates a message to describe interesting attributes about a single flight. Generates a multi-line description of a flight. A typical message might look like: UAL300 - UNITED <- Flight number and airline BOEING 777-200 (TWIN) <- Aircraft type SFO-HNL HONOLULU <- Origin & destination DEP: 02:08 (12M EARLY) <- Departure time details REM: 5H14M <- Remaining flight time 185MPH 301DEG D:117FT <- Trajectory details: speed; bearing; forecast min dist to HOME 1975FT (+2368FPM) <- Altitude details: current altitude & rate or ascent / descent However, not all of these details are always present, so some may be listed as unknown, or entire lines may be left out. Args: flight_details: dictionary of flight attributes. Returns: Printable string (with embedded new line characters) """ lines = [] # LINE1: UAL1425 - UNITED # ======================= airline = flight_details['calcd_airline'] flight_number = DictGetReplaceNone(flight_details, 'dump_flight_number') line = (flight_number + ' - ' + airline)[:SPLITFLAP_CHARS_PER_LINE] if airline or flight_number: lines.append(line.upper()) # LINE2: Boeing 737-800 (twin-jet) # ======================= aircraft_type = flight_details['calcd_aircraft_type'] if aircraft_type: lines.append(aircraft_type.upper()) # LINE3: SFO-CLT Charlotte # Charlotte CLT-SFO # ======================= origin_destination_pair = flight_details.get('calcd_origin_destination_pair') if origin_destination_pair: lines.append(origin_destination_pair.upper()) # LINE4: Dep: 08:18 (10m early) # Dep: Unknown # ======================= departure_time_text = DictGetReplaceNone(flight_details, 'calcd_departure_time_text') if departure_time_text: delay_text = flight_details['calcd_delay_text'] line = departure_time_text + delay_text lines.append(line.upper()) # LINE5: Rem: 4h18m # Rem: Unk # ======================= remaining_seconds = flight_details['calcd_remaining_seconds'] if remaining_seconds is not None: line = 'Rem: ' + SecondsToHhMm(remaining_seconds) else: line = 'Rem: Unknown' lines.append(line.upper()) # LINE6: 123mph 297deg D:1383ft # ======================= speed = flight_details.get('speed') heading = flight_details.get('track') if heading is None: line = str(speed) + 'mph' else: min_feet = flight_details['calcd_min_feet'] line = '%d%s %sdeg D:%d%s' % (speed, SPEED_UNITS, heading, min_feet, DISTANCE_UNITS) lines.append(line.upper()) # LINE7: Alt: 12345ft +1234fpm # ======================= altitude = flight_details.get('altitude') vert_rate = flight_details.get('vert_rate') line = '' if altitude: line += '%d%s' % (altitude, DISTANCE_UNITS) if vert_rate: line += ' (%+d%s)' % (vert_rate, CLIMB_RATE_UNITS) if line: lines.append(line.upper()) return Screenify(lines) def FlightInsightsTestHarness(flights, display=True): """Simulates what insightful messages might be displayed by replaying past flights.""" messages = [] for n in range(len(flights)): flight_message = CreateMessageAboutFlight(flights[n]) if display: print(flight_message) messages.append(flight_message) interesting_things = FlightInsights(flights[:n+1]) if display: for thing in interesting_things: print(thing) messages.extend(interesting_things) return messages def FlightInsightLastSeen(flights, days_ago=2): """Generates string indicating when flight was last seen. Generates text of the following form. - KAL214 was last seen 2d0h ago Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. days_ago: the minimum time difference for which a message should be generated - i.e.: many flights are daily, and so we are not necessarily interested to see about every daily flight that it was seen yesterday. However, more infrequent flights might be of interest. Returns: List of printable string messages; if no messages or insights to generate, then the list will be empty. """ messages = [] this_flight = flights[-1] this_flight_number = this_flight['dump_flight_number'] this_timestamp = flights[-1]['calcd_timestamp'] last_seen = [f for f in flights[:-1] if f['dump_flight_number'] == this_flight_number] messages = [] if last_seen and this_flight_number: last_timestamp = last_seen[-1]['calcd_timestamp'] if this_timestamp - last_timestamp > days_ago*SECONDS_IN_DAY: messages = [ '%s was last seen %s ago' % (this_flight_number.strip(), SecondsToDdHh(this_timestamp - last_timestamp))] return messages def FlightInsightDifferentAircraft(flights, percent_size_difference=0.1): """Generates string indicating changes in aircraft for the most recent flight. Generates text of the following form for the "focus" flight in the data. - Last time ASA1964 was seen on Mar 16, it was with a much larger plane (Airbus A320 (twin-jet) @ 123ft vs. Airbus A319 (twin-jet) @ 111ft) - Last time ASA743 was seen on Mar 19, it was with a different type of airpline (Boeing 737-900 (twin-jet) vs. Boeing 737-800 (twin-jet)) Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. percent_size_difference: the minimum size (i.e.: length) difference for the insight to warrant including the size details. Returns: List of printable string messages; if no messages or insights to generate, then the list will be empty. """ messages = [] this_flight = flights[-1] this_flight_number = this_flight['dump_flight_number'] last_seen = [f for f in flights[:-1] if f['dump_flight_number'] == this_flight_number] # Yesterday this same flight flew a materially different type of aircraft if last_seen and this_flight_number: last_flight = last_seen[-1] last_aircraft = last_flight.get('aircraft_type_friendly') last_aircraft_length = aircraft_length.get(last_aircraft, 0) this_aircraft = this_flight.get('aircraft_type_friendly') this_aircraft_length = aircraft_length.get(this_aircraft, 0) this_likely_commercial_flight = ( this_flight.get('origin_iata') and this_flight.get('destination_iata')) if this_likely_commercial_flight and not this_aircraft_length: LogMessage('%s appears to be used in a commercial flight but yet is missing length' 'details' % this_aircraft, file=LOGFILE) likely_same_commercial_flight = ( last_flight.get('origin_iata') == this_flight.get('origin_iata') and last_flight.get('destination_iata') == this_flight.get('destination_iata') and last_flight.get('airline_call_sign') == this_flight.get('airline_call_sign')) this_aircraft_bigger = False last_aircraft_bigger = False if (likely_same_commercial_flight and this_aircraft_length > last_aircraft_length * (1 + percent_size_difference)): this_aircraft_bigger = True comparative_text = 'larger' elif (likely_same_commercial_flight and last_aircraft_length > this_aircraft_length * (1 + percent_size_difference)): last_aircraft_bigger = True comparative_text = 'smaller' last_flight_time_string = datetime.datetime.fromtimestamp( last_flight['calcd_timestamp']).strftime('%b %-d') if this_aircraft_bigger or last_aircraft_bigger: messages = [ 'Last time %s was seen on %s, it was with a much %s ' 'plane (%s @ %dft vs. %s @ %dft)' % ( this_flight_number.strip(), last_flight_time_string, comparative_text, this_aircraft, this_aircraft_length*FEET_IN_METER, last_aircraft, last_aircraft_length*FEET_IN_METER)] elif last_aircraft and this_aircraft and last_aircraft != this_aircraft: messages = [ 'Last time %s was seen on %s, it was with a different ' 'type of airpline (%s vs. %s)' % ( this_flight_number.strip(), last_flight_time_string, last_aircraft, this_aircraft)] return messages def FlightInsightNthFlight(flights, hours=1, min_multiple_flights=2): """Generates string about seeing many flights to the same destination in a short period. Generates text of the following form for the "focus" flight in the data. - ASA1337 was the 4th flight to PHX in the last 53 minutes, served by Alaska Airlines, American Airlines, Southwest and United - SWA3102 was the 2nd flight to SAN in the last 25 minutes, both with Southwest Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. hours: the time horizon over which to look for flights with the same destination. min_multiple_flights: the minimum number of flights to that same destination to warrant generating an insight. Returns: List of printable string messages; if no messages or insights to generate, then the list will be empty. """ messages = [] this_flight = flights[-1] this_flight_number = DictGetReplaceNone(this_flight, 'dump_flight_number').strip() this_destination = this_flight.get('destination_iata', '') this_airline = DictGetReplaceNone( this_flight, 'airline_short_name', default_value=KEY_NOT_PRESENT_STRING) if not this_airline: this_airline = KEY_NOT_PRESENT_STRING # in case airline was stored as, say, '' this_timestamp = this_flight['calcd_timestamp'] if this_destination and this_destination not in ['SFO', 'LAX']: similar_flights = [f for f in flights[:-1] if this_timestamp - f['calcd_timestamp'] < SECONDS_IN_HOUR*hours and this_destination == f.get('destination_iata', '')] similar_flights_count = len(similar_flights) + 1 # +1 for this_flight similar_flights_airlines = list(set([ DictGetReplaceNone(f, 'airline_short_name', default_value=KEY_NOT_PRESENT_STRING) for f in similar_flights])) same_airline = [this_airline] == similar_flights_airlines if similar_flights_count >= min_multiple_flights: ordinal = lambda n: '%d%s' % ( n, 'tsnrhtdd'[(math.floor(n/10)%10 != 1)*(n%10 < 4)*n%10::4]) n_minutes = ( (this_flight['calcd_timestamp'] - similar_flights[0]['calcd_timestamp']) / SECONDS_IN_MINUTE) message = ('%s was the %s flight to %s in the last %d minutes' % ( this_flight_number, ordinal(similar_flights_count), this_destination, n_minutes)) if same_airline and similar_flights_count == 2: message += ', both with %s' % this_airline elif same_airline: message += ', all with %s' % this_airline else: similar_flights_airlines.append(this_airline) similar_flights_airlines.sort() message += ', served by %s and %s' % ( ', '.join(similar_flights_airlines[:-1]), similar_flights_airlines[-1]) messages = [message] return messages def FlightInsightSuperlativeAttribute( flights, key, label, units, absolute_list, insight_min=True, insight_max=True, hours=24): """Generates string about a numeric attribute of the flight being an extreme value. Generates text of the following form for the "focus" flight in the data. - N5286C has the slowest groundspeed (113mph vs. 163mph) in last 24 hours - CKS828 has the highest altitude (40000ft vs. 16575ft) in last 24 hours Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. key: the key of the attribute of interest - i.e.: 'speed'. label: the human-readable string that should be displayed in the message - i.e.: 'groundspeed'. units: the string units that should be used to label the value of the key - i.e.: 'MPH'. absolute_list: a 2-tuple of strings that is used to label the min and the max - i.e.: ('lowest', 'highest'), or ('slowest', 'fastest'). insight_min: boolean indicating whether to generate an insight about the min value. insight_max: boolean indicating whether to generate an insight about the max value. hours: the time horizon over which to look for superlative flights. Returns: List of printable string messages; if no messages or insights to generate, then the list will be empty. """ messages = [] this_flight = flights[-1] this_flight_number = DictGetReplaceNone( this_flight, 'dump_flight_number', 'The last flight').strip() first_timestamp = flights[0]['calcd_timestamp'] last_timestamp = flights[-1]['calcd_timestamp'] included_seconds = last_timestamp - first_timestamp if included_seconds > SECONDS_IN_HOUR * hours: relevant_flights = [ f for f in flights[:-1] if last_timestamp - f['calcd_timestamp'] < SECONDS_IN_HOUR * hours] value_min = min([DictGetReplaceNone( f, key, default_value=float('inf')) for f in relevant_flights]) value_max = max([DictGetReplaceNone( f, key, default_value=float('-inf')) for f in relevant_flights]) values_other = len( [1 for f in relevant_flights if isinstance(f.get(key), numbers.Number)]) this_value = this_flight.get(key) if this_value and values_other: superlative = True if this_value > value_max and insight_max: absolute_string = absolute_list[1] other_value = value_max elif this_value < value_min and insight_min: absolute_string = absolute_list[0] other_value = value_min else: superlative = False if superlative: messages.append('%s has the %s %s (%d%s vs. %d%s) in last %d hours' % ( this_flight_number, absolute_string, label, this_value, units, other_value, units, hours)) return messages def PercentileScore(scores, value): """Returns the percentile that a particular value is in a list of numbers. Roughly inverts numpy.percentile. That is, numpy.percentile(scores_list, percentile) to get the value of the list that is at that percentile; PercentileScore(scores_list, value) will yield back approximately that percentile. If the value matches identical elements in the list, this function takes the average position of those identical values to compute a percentile. Thus, for some lists (i.e.: where there are lots of flights that have a 0 second delay, or a 100% delay frequency), you may not get a percentile of 0 or 100 even with values equal to the min or max element in the list. Args: scores: the list of numbers. value: the value for which we want to determine the percentile. Returns: Returns an integer percentile in the range [0, 100] inclusive. """ scores = sorted(list(scores)) count_values_below_score = len([1 for s in scores if s < value]) count_values_at_score = len([1 for s in scores if s == value]) percentile = (count_values_below_score + count_values_at_score / 2) / len(scores) return round(percentile*100) def FlightInsightGroupPercentile( flights, group_function, value_function, value_string_function, group_label, value_label, min_days=1, lookback_days=30, min_this_group_size=0, min_comparison_group_size=0, min_group_qty=0, percentile_low=10, percentile_high=90): """Generates a string about extreme values of groups of flights. Generates text of the following form for the "focus" flight in the data. - flight SIA31 (n=7) has a delay frequency in the 95th %tile, with 100% of flights delayed an average of 6m over the last 4d1h - flight UAL300 (n=5) has a delay time in the 1st %tile, with an average delay of 0m over the last 4d5h Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. group_function: function that, when called with a flight, returns the grouping key. That is, for example, group_function(flight) = 'B739' value_function: function that, when called with a list of flights, returns the value to be used for the comparison to identify min / max. Typically, the count, but could also be a sum, standard deviation, etc. - for perhaps the greatest range in flight altitude. If the group does not have a valid value and so should be excluded from comparison - i.e.: average delay of a group of flights which did not have a calculable_delay on any flight, this function should return None. value_string_function: function that, when called with the two parameters flights and value, returns a string (inclusive of units and label) that should be displayed to describe the quantity. For instance, if value_function returns seconds, value_string_function could convert that to a string '3h5m'. Or if value_function returns an altitude range, value_string_function could return a string 'altitude range of 900ft (1100ft - 2000ft)'. group_label: string to identify the group type - i.e.: 'aircraft' or 'flight' in the examples above. value_label: string to identify the value - i.e.: 'flights' in the examples above, but might also be i.e.: longest *delay*, or other quantity descriptor. min_days: the minimum amount of history required to start generating insights about delays. lookback_days: the maximum amount of history which will be considered in generating insights about delays. min_this_group_size: even if this group has, say, the maximum average delay, if its a group of size 1, that is not necessarily very interesting. This sets the minimum group size for the focus flight. min_comparison_group_size: similarly, comparing the focus group to groups of size one does not necessarily produce a meaningful comparison; this sets to minimum size for the other groups. min_group_qty: when generating a percentile, if there are only 3 or 4 groups among which to generate a percentile (i.e.: only a handful of destinations have been seen so far, etc.) then it is not necessarily very interesting to generate a message; this sets the minimum quantity of groups necessary (including the focus group) to generate a message. percentile_low: number [0, 100] inclusive that indicates the percentile that the focus flight group must equal or be less than for the focus group to trigger an insight; if None, no high percentile insight will be generated. percentile_high: number [0, 100] inclusive that indicates the percentile that the focus flight group must equal or be greater than for the focus group to trigger an insight; if None, no high percentile insight will be generated. Returns: List of printable string messages; if no messages or insights to generate, then the list will be empty. """ messages = [] first_timestamp = flights[0]['calcd_timestamp'] last_timestamp = flights[-1]['calcd_timestamp'] included_seconds = last_timestamp - first_timestamp if included_seconds > SECONDS_IN_DAY * min_days: relevant_flights = [ f for f in flights if last_timestamp - f['calcd_timestamp'] < SECONDS_IN_DAY * lookback_days] grouped_flights = {} for flight in relevant_flights: group = group_function(flight) grouping = grouped_flights.get(group, []) grouping.append(flight) grouped_flights[group] = grouping grouped_values = {g: value_function(grouped_flights[g]) for g in grouped_flights} this_group = group_function(relevant_flights[-1]) this_value = grouped_values[this_group] # Remove those for which no value could be calculated grouped_values = { g: grouped_values[g] for g in grouped_values if grouped_values[g] is not None} min_value_percentile = float('-inf') if percentile_low is not None: min_value_percentile = numpy.percentile( list(grouped_values.values()), percentile_low) max_value_percentile = float('inf') if percentile_high is not None: max_value_percentile = numpy.percentile( list(grouped_values.values()), percentile_high) if this_value and len(grouped_values) > min_group_qty: this_group_size = len(grouped_flights[this_group]) time_horizon_string = SecondsToDdHh( last_timestamp - relevant_flights[0]['calcd_timestamp']) min_comparison_group_size_string = '' if min_comparison_group_size > 1: min_comparison_group_size_string = ' amongst %s with at least %d flights' % ( group_label, min_comparison_group_size) ordinal = lambda n: '%d%s' % ( n, 'tsnrhtdd'[(math.floor(n/10)%10 != 1)*(n%10 < 4)*n%10::4]) # FLIGHT X (n=7) is has the Xth percentile of DELAYS, with an average delay of # 80 MINUTES if this_group_size > min_this_group_size and ( this_value <= min_value_percentile or this_value >= max_value_percentile): messages = [ '%s %s (n=%d) has a %s in the %s %%tile, with %s over the last %s%s' % ( group_label, this_group, this_group_size, value_label, ordinal(PercentileScore(grouped_values.values(), this_value)), value_string_function(grouped_flights[this_group], this_value), time_horizon_string, min_comparison_group_size_string)] return messages def FlightInsightSuperlativeGroup( flights, group_function, value_function, value_string_function, group_label, value_label, absolute_list, min_days=1, lookback_days=30, min_this_group_size=0, min_comparison_group_size=0, insight_min=True, insight_max=True): """Generates a string about extreme values of groups of flights. Generates text of the following form for the "focus" flight in the data. - aircraft B739 (n=7) is tied with B738 and A303 for the most flights at 7 flights over the last 3d7h amongst aircraft with a least 5 flights - aircraft B739 (n=7) is tied with 17 others for the most flights at 7 flights over the last 3d7h amongst aircraft with a least 5 flights - flight UAL1075 (n=12) has the most flights with 12 flights; the next most flights is 11 flights over the last 7d5h Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. group_function: function that, when called with a flight, returns the grouping key. That is, for example, group_function(flight) = 'B739' value_function: function that, when called with a list of flights, returns the value to be used for the comparison to identify min / max. Typically, the count, but could also be a sum, standard deviation, etc. - for perhaps the greatest range in flight altitude. If the group does not have a valid value and so should be excluded from comparison - i.e.: average delay of a group of flights which did not have a calculable_delay on any flight, this function should return None. value_string_function: function that, when called with the two parameters flights and value, returns a string (inclusive of units and label) that should be displayed to describe the quantity. For instance, if value_function returns seconds, value_string_function could convert that to a string '3h5m'. Or if value_function returns an altitude range, value_string_function could return a string 'altitude range of 900ft (1100ft - 2000ft)'. group_label: string to identify the group type - i.e.: 'aircraft' or 'flight' in the examples above. value_label: string to identify the value - i.e.: 'flights' in the examples above, but might also be i.e.: longest *delay*, or other quantity descriptor. absolute_list: a 2-tuple of strings that is used to label the min and the max - i.e.: ('most', 'least'), or ('lowest average', 'highest average'). min_days: the minimum amount of history required to start generating insights about delays. lookback_days: the maximum amount of history which will be considered in generating insights about delays. min_this_group_size: even if this group has, say, the maximum average delay, if its a group of size 1, that is not necessarily very interesting. This sets the minimum group size for the focus flight. min_comparison_group_size: similarly, comparing the focus group to groups of size one does not necessarily produce a meaningful comparison; this sets to minimum size for the other groups. insight_min: boolean indicating whether to possibly generate insight based on the occurrence of the min value. insight_max: boolean indicating whether to possibly generate insight based on the occurrence of the max value. Returns: List of printable string messages; if no messages or insights to generate, then the list will be empty. """ messages = [] first_timestamp = flights[0]['calcd_timestamp'] last_timestamp = flights[-1]['calcd_timestamp'] included_seconds = last_timestamp - first_timestamp if included_seconds > SECONDS_IN_DAY * min_days: relevant_flights = [ f for f in flights if last_timestamp - f['calcd_timestamp'] < SECONDS_IN_DAY * lookback_days] grouped_flights = {} for flight in relevant_flights: group = group_function(flight) grouping = grouped_flights.get(group, []) grouping.append(flight) grouped_flights[group] = grouping grouped_values = {g: value_function(grouped_flights[g]) for g in grouped_flights} grouped_values = {g: value_function(grouped_flights[g]) for g in grouped_flights} this_group = group_function(relevant_flights[-1]) this_value = grouped_values[this_group] # Remove those for which no value could be calculated grouped_values = { g: grouped_values[g] for g in grouped_values if grouped_values[g] is not None} other_values = list(grouped_values.values()) other_values = [v for v in other_values if v > min_comparison_group_size] if this_value in other_values: other_values.remove(this_value) if other_values: min_value = min(other_values) max_value = max(other_values) if this_value: if this_value > max_value and insight_max: superlative = True equality = False superlative_string = absolute_list[1] next_value = max_value elif this_value == max_value and insight_max: superlative = False equality = True superlative_string = absolute_list[1] elif this_value < min_value and insight_min: superlative = True equality = False superlative_string = absolute_list[0] next_value = min_value elif this_value == min_value and insight_min: superlative = False equality = True superlative_string = absolute_list[0] else: superlative = False equality = False this_group_size = len(grouped_flights[this_group]) time_horizon_string = SecondsToDdHh( last_timestamp - relevant_flights[0]['calcd_timestamp']) min_comparison_group_size_string = '' if min_comparison_group_size > 1: min_comparison_group_size_string = ( ' amongst %s with at least %d flights' % (group_label, min_comparison_group_size)) # flight x (n=7) is tied with a, b, and c for the (longest average, shortest # average) delay at 80 minutes # flight x is tied with a, b, and c for the (most frequent, least frequent) # delay at 30% if equality and this_group_size > min_this_group_size: identical_groups = sorted([ str(g) for g in grouped_values if grouped_values[g] == this_value and g != this_group]) if len(identical_groups) > 4: identical_string = '%d others' % len(identical_groups) elif len(identical_groups) > 1: identical_string = ( '%s and %s' % (', '.join(identical_groups[:-1]), identical_groups[-1])) else: identical_string = str(identical_groups[0]) messages = [ '%s %s (n=%d) is tied with %s for the %s %s at %s over the last %s%s' % ( group_label, this_group, this_group_size, identical_string, superlative_string, value_label, value_string_function(flights, this_value), time_horizon_string, min_comparison_group_size_string)] elif superlative and this_group_size > min_this_group_size: messages = [ '%s %s (n=%d) has the %s %s with %s; the next ' '%s %s is %s over the last %s%s' % ( group_label, this_group, this_group_size, superlative_string, value_label, value_string_function(flights, this_value), superlative_string, value_label, value_string_function(flights, next_value), time_horizon_string, min_comparison_group_size_string)] return messages def AverageDelay(flights): """Returns the average delay time for a list of flights. Args: flights: the list of the raw flight data. Returns: Average seconds of flight delay, calculated as the total seconds delayed amongst all the flights that have a positive delay, divided by the total number of flights that have a calculable delay. If no flights have a calculable delay, returns None. """ delay_seconds = [ f['calcd_delay_seconds'] if f['calcd_delay_seconds'] > 0 else 0 for f in flights if f['calcd_calculable_delay']] average_delay = None if delay_seconds: average_delay = sum(delay_seconds) / len(delay_seconds) return average_delay def PercentDelay(flights): """Returns the percentage of flights that have a positive delay for a list of flights. Args: flights: the list of the raw flight data. Returns: Percentage of flights with a delay, calculated as the count of flights with a positive delay divided by the total number of flights that have a calculable delay. If no flights have a calculable delay, returns None. """ calculable_delay_seconds = [ f['calcd_delay_seconds'] for f in flights if f['calcd_calculable_delay']] delay_count = sum([1 for s in calculable_delay_seconds if s > 0]) percent_delay = None if calculable_delay_seconds: percent_delay = delay_count / len(calculable_delay_seconds) return percent_delay def FlightInsightFirstInstance( flights, key, label, days=7, additional_descriptor_fcn=''): """Generates string indicating the flight has the first instance of a particular key. Generates text of the following form for the "focus" flight in the data. - N311CG is the first time aircraft GLF6 (Gulfstream Aerospace Gulfstream G650 (twin-jet)) has been seen since at least 7d5h ago - PCM8679 is the first time airline Westair Industries has been seen since 9d0h ago Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. key: the key of the attribute of interest - i.e.: 'destination_iata'. label: the human-readable string that should be displayed in the message - i.e.: 'destination'. days: the minimum time of interest for an insight - i.e.: we probably see LAX every hour, but we are only interested in particular attributes that have not been seen for at least some number of days. Note, however, that the code will go back even further to find the last time that attribute was observed, or if never observed, indicating "at least". additional_descriptor_fcn: a function that, when passed a flight, returns an additional parenthetical notation to include about the attribute or flight observed - such as expanding the IATA airport code to its full name, etc. Returns: List of printable string messages; if no messages or insights to generate, then the list will be empty. """ messages = [] this_flight = flights[-1] this_flight_number = this_flight['dump_flight_number'] first_timestamp = flights[0]['calcd_timestamp'] last_timestamp = flights[-1]['calcd_timestamp'] included_seconds = last_timestamp - first_timestamp if included_seconds > SECONDS_IN_DAY * days: this_instance = DictGetReplaceNone(this_flight, key) matching = [f for f in flights[:-1] if DictGetReplaceNone(f, key) == this_instance] last_potential_observation_sec = included_seconds if matching: last_potential_observation_sec = last_timestamp - matching[-1]['calcd_timestamp'] if this_instance and last_potential_observation_sec > SECONDS_IN_DAY * days: additional_descriptor = '' if additional_descriptor_fcn: additional_descriptor = ' (%s)' % additional_descriptor_fcn(this_flight) last_potential_observation_string = SecondsToDdHh(last_potential_observation_sec) if matching: messages.append( '%s is the first time %s %s%s has been seen since %s ago' % (this_flight_number, label, this_instance, additional_descriptor, last_potential_observation_string)) else: messages.append( '%s is the first time %s %s%s has been seen since at least %s ago' % (this_flight_number, label, this_instance, additional_descriptor, last_potential_observation_string)) return messages def FlightInsightSuperlativeVertrate(flights, hours=24): """Generates string about the climb rate of the flight being an extreme value. Generates text of the following form for the "focus" flight in the data. - UAL631 has the fastest ascent rate (5248fpm, 64fpm faster than next fastest) in last 24 hours - CKS1820 has the fastest descent rate (-1152fpm, -1088fpm faster than next fastest) in last 24 hours While this is conceptually similar to the more generic FlightInsightSuperlativeVertrate function, vert_rate - because it can be either positive or negative, with different signs requiring different labeling and comparisons - it needs its own special handling. Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. hours: the time horizon over which to look for superlative flights. Returns: List of printable string messages; if no messages or insights to generate, then the list will be empty. """ messages = [] this_flight = flights[-1] this_flight_number = this_flight['dump_flight_number'] first_timestamp = flights[0]['calcd_timestamp'] last_timestamp = flights[-1]['calcd_timestamp'] sufficient_data = (last_timestamp - first_timestamp) > SECONDS_IN_HOUR * hours pinf = float('inf') ninf = float('-inf') if sufficient_data: relevant_flights = [ f for f in flights[:-1] if last_timestamp - f['calcd_timestamp'] < SECONDS_IN_HOUR * hours] def AscentRate(f, default): vert_rate = DictGetReplaceNone(f, 'vert_rate', default_value=default) if vert_rate < 0: vert_rate = default return vert_rate other_ascents = len([ 1 for f in relevant_flights if isinstance(f.get('vert_rate'), numbers.Number) and AscentRate(f, ninf) > 0]) if other_ascents: ascent_min = min( [AscentRate(f, pinf) for f in relevant_flights if AscentRate(f, ninf) > 0]) ascent_max = max( [AscentRate(f, ninf) for f in relevant_flights if AscentRate(f, ninf) > 0]) def DescentRate(f, default): vert_rate = DictGetReplaceNone(f, 'vert_rate', default_value=default) if vert_rate > 0: vert_rate = default return vert_rate other_descents = len([ 1 for f in relevant_flights if isinstance(f.get('vert_rate'), numbers.Number) and DescentRate(f, pinf) < 0]) if other_descents: descent_min = min( [DescentRate(f, pinf) for f in relevant_flights if DescentRate(f, pinf) < 0]) descent_max = max( [DescentRate(f, ninf) for f in relevant_flights if DescentRate(f, pinf) < 0]) this_vert_rate = this_flight.get('vert_rate') if this_vert_rate is not None and this_vert_rate >= 0: this_ascent = this_vert_rate this_descent = None else: this_descent = this_vert_rate this_ascent = None if this_ascent and other_ascents and this_ascent > ascent_max: messages.append('%s has the fastest ascent rate (%d%s, %d%s faster ' 'than next fastest) in last %d hours' % ( this_flight_number, this_ascent, CLIMB_RATE_UNITS, this_ascent - ascent_max, CLIMB_RATE_UNITS, hours)) elif this_ascent and other_ascents and this_ascent < ascent_min: messages.append('%s has the slowest ascent rate (%d%s, %d%s slower ' 'than next slowest) in last %d hours' % ( this_flight_number, this_ascent, CLIMB_RATE_UNITS, ascent_min - this_ascent, CLIMB_RATE_UNITS, hours)) elif this_descent and other_descents and this_descent < descent_min: messages.append('%s has the fastest descent rate (%d%s, %d%s faster ' 'than next fastest) in last %d hours' % ( this_flight_number, this_descent, CLIMB_RATE_UNITS, this_descent - descent_min, CLIMB_RATE_UNITS, hours)) elif this_descent and other_descents and this_descent > descent_max: messages.append('%s has the slowest descent rate (%d%s, %d%s slower ' 'than next slowest) in last %d hours' % ( this_flight_number, this_descent, CLIMB_RATE_UNITS, descent_max - this_descent, CLIMB_RATE_UNITS, hours)) return messages def FlightInsightDelays( flights, min_days=1, lookback_days=30, min_late_percentage=0.75, min_this_delay_minutes=0, min_average_delay_minutes=0): """Generates string about the delays this flight has seen in the past. Only if this flight has a caclculable delay itself, this will generate text of the following form for the "focus" flight in the data. - This 8m delay is the longest UAL1175 has seen in the last 9 days (avg delay is 4m); overall stats: 1 early; 9 late; 10 total - With todays delay of 7m, UAL1175 is delayed 88% of the time in the last 8 days for avg delay of 4m; overall stats: 1 early; 8 late; 9 total Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. min_days: the minimum amount of history required to start generating insights about delays. lookback_days: the maximum amount of history which will be considered in generating insights about delays. min_late_percentage: flights that are not very frequently delayed are not necessarily very interesting to generate insights about; this specifies the minimum percentage the flight must be late to generate a message that focuses on the on-time percentage. min_this_delay_minutes: a delay of 1 minute is not necessarily interesting; this specifies the minimum delay time this instance of the flight must be late to generate a message that focuses on this flight's delay. min_average_delay_minutes: an average delay of only 1 minute, even if it happens every day, is not necessarily very interesting; this specifies the minimum average delay time to generate either type of delay message. Returns: List of printable string messages; if no messages or insights to generate, then the list will be empty. """ messages = [] this_flight = flights[-1] this_flight_number = this_flight.get('dump_flight_number', '') first_timestamp = flights[0]['calcd_timestamp'] last_timestamp = flights[-1]['calcd_timestamp'] included_seconds = last_timestamp - first_timestamp if (included_seconds > SECONDS_IN_DAY * min_days and this_flight['calcd_calculable_delay']): this_delay_seconds = this_flight['calcd_delay_seconds'] relevant_flights = [ f for f in flights if last_timestamp - f['calcd_timestamp'] < SECONDS_IN_DAY * lookback_days and this_flight_number == f.get('dump_flight_number', '')] if ( len(relevant_flights) > 1 and this_delay_seconds >= min_this_delay_minutes*SECONDS_IN_MINUTE): delay_seconds_list = [ f['calcd_delay_seconds'] for f in relevant_flights if f.get('calcd_calculable_delay')] delay_unknown_count = len(relevant_flights) - len(delay_seconds_list) delay_ontime_count = len([d for d in delay_seconds_list if not d]) delay_early_count = len([d for d in delay_seconds_list if d < 0]) delay_late_count = len([d for d in delay_seconds_list if d > 0]) delay_late_avg_sec = 0 delay_late_max_sec = 0 superlative = False if delay_late_count > 1: delay_late_avg_sec = sum( [d for d in delay_seconds_list if d > 0]) / delay_late_count # max / min excluding this flight delay_late_max_sec = max([d for d in delay_seconds_list[:-1] if d > 0]) delay_late_min_sec = min([d for d in delay_seconds_list[:-1] if d > 0]) if delay_late_max_sec > 0: if this_delay_seconds > delay_late_max_sec: delay_keyword = 'longest' superlative = True if this_delay_seconds < delay_late_min_sec: delay_keyword = 'shortest' superlative = True overall_stats_elements = [] if delay_early_count: overall_stats_elements.append('%d early' % delay_early_count) if delay_ontime_count: overall_stats_elements.append('%d ontime' % delay_ontime_count) if delay_late_count: overall_stats_elements.append('%d late' % delay_late_count) if delay_unknown_count: overall_stats_elements.append('%d unknown' % delay_unknown_count) overall_stats_elements.append('%d total' % len(relevant_flights)) overall_stats_text = '; '.join(overall_stats_elements) days_history = (int( round(last_timestamp - relevant_flights[0]['calcd_timestamp']) / SECONDS_IN_DAY) + 1) late_percentage = delay_late_count / len(relevant_flights) if (superlative and delay_late_avg_sec >= min_average_delay_minutes * SECONDS_IN_MINUTE): messages = [ 'This %s delay is the %s %s has seen in the last %d days (avg delay is %s);' ' overall stats: %s' % ( SecondsToHhMm(this_delay_seconds), delay_keyword, this_flight_number.strip(), days_history, SecondsToHhMm(delay_late_avg_sec), overall_stats_text)] elif (late_percentage > min_late_percentage and delay_late_avg_sec >= min_average_delay_minutes * SECONDS_IN_MINUTE): # it's just been delayed frequently! messages = [ 'With today''s delay of %s, %s is delayed %d%% of the time in the last %d ' 'days for avg delay of %s; overall stats: %s' % ( SecondsToHhMm(this_delay_seconds), this_flight_number.strip(), int(100 * late_percentage), days_history, SecondsToHhMm(delay_late_avg_sec), overall_stats_text)] return messages def FlightInsights(flights): """Identifies an interesting attribute or pattern about the most recently seen flight. Generates a possibly-empty list of messages about the flight. Args: flights: List of all flights where the last flight in the list is the focus flight for which we are trying to identify something interesting. Returns: List of printable strings (with embedded new line characters) for something interesting about the flight; if there isn't anything interesting, returns an empty list. """ messages = [] # This flight number was last seen x days ago messages.extend(FlightInsightLastSeen(flights, days_ago=2)) # Yesterday this same flight flew a materially different type of aircraft messages.extend(FlightInsightDifferentAircraft(flights, percent_size_difference=0.1)) # This is the 3rd flight to the same destination in the last hour messages.extend(FlightInsightNthFlight(flights, hours=1, min_multiple_flights=2)) # This is the [lowest / highest] [speed / altitude / climbrate] in the last 24 hours messages.extend(FlightInsightSuperlativeAttribute( flights, 'speed', 'groundspeed', SPEED_UNITS, ['slowest', 'fastest'], hours=24)) messages.extend(FlightInsightSuperlativeAttribute( flights, 'altitude', 'altitude', DISTANCE_UNITS, ['lowest', 'highest'], hours=24)) messages.extend(FlightInsightSuperlativeVertrate(flights)) # First instances: destination, first aircraft, etc. messages.extend(FlightInsightFirstInstance( flights, 'destination_iata', 'destination', days=7, additional_descriptor_fcn=lambda f: f['destination_friendly'])) messages.extend(FlightInsightFirstInstance( flights, 'origin_iata', 'origin', days=7, additional_descriptor_fcn=lambda f: f['origin_friendly'])) messages.extend(FlightInsightFirstInstance( flights, 'airline_short_name', 'airline', days=7)) messages.extend(FlightInsightFirstInstance( flights, 'aircraft_type_code', 'aircraft', days=7, additional_descriptor_fcn=lambda f: f['aircraft_type_friendly'])) # This is the longest / shortest delay this flight has seen in the last 30 days at # 2h5m; including today, this flight has been delayed x of the last y times. messages.extend(FlightInsightDelays( flights, min_late_percentage=0.75, min_this_delay_minutes=0, min_average_delay_minutes=0)) # flight UAL1 (n=5) has a delay frequency in the 72nd %tile, with 100% of flights # delayed an average of 44m over the last 4d13h messages.extend(FlightInsightGroupPercentile( flights, group_function=lambda flight: DictGetReplaceNone( flight, 'dump_flight_number', KEY_NOT_PRESENT_STRING).strip(), value_function=PercentDelay, value_string_function= lambda flights, value: '%d%% of flights delayed an average of %s' % (round(value*100), SecondsToHhMm(AverageDelay(flights))), group_label='flight', value_label='delay frequency', min_days=1, min_this_group_size=4, min_comparison_group_size=0, min_group_qty=0, lookback_days=30, percentile_low=None, percentile_high=70)) # flight UAL1 (n=5) has a delay time in the 90th %tile, with average delay of # 2h17m over the last 4d13h messages.extend(FlightInsightGroupPercentile( flights, group_function=lambda flight: DictGetReplaceNone( flight, 'dump_flight_number', KEY_NOT_PRESENT_STRING).strip(), value_function=AverageDelay, value_string_function= lambda flights, value: 'average delay of %s' % SecondsToHhMm(value), group_label='flight', value_label='delay time', min_days=1, min_this_group_size=4, min_comparison_group_size=0, min_group_qty=0, lookback_days=30, percentile_low=10, percentile_high=90)) messages = [ Screenify(textwrap.wrap(t.upper(), width=SPLITFLAP_CHARS_PER_LINE)) for t in messages] return messages def MessageboardHistogramsTestHarness(): """Test harness to generate messageboard histograms.""" flights = UnpickleFlights(PICKLEFILE_30D) messages = MessageboardHistograms(flights, 'aircraft', '30d', 'all', False) for message in messages: print(message) def HistogramSettingsHours(how_much_history): """Extracts the desired history (in hours) from the histogram configuration string. Args: how_much_history: string from the histogram config file. Returns: Number of hours of history to include in the histogram. """ if how_much_history == 'today': hours = HoursSinceMidnight() elif how_much_history == '24h': hours = 24 elif how_much_history == '7d': hours = 7 * HOURS_IN_DAY elif how_much_history == '30d': hours = 30 * HOURS_IN_DAY else: <----SKIPPED LINES----> Args: max_screens: string from the histogram config file. Returns: Number of maximum number of screens to display for a splitflap histogram. """ if max_screens == '_1': screen_limit = 1 elif max_screens == '_2': screen_limit = 2 elif max_screens == '_5': screen_limit = 5 elif max_screens == 'all': screen_limit = 0 # no limit on screens else: LogMessage('Histogram form has invalid value for max_screens: %s' % max_screens) screen_limit = 1 return screen_limit def HistogramSettingsKeySortTitle(which, max_altitude=45000, max_distance=3300): """Provides the arguments necessary to generate a histogram from the config string. The same parameters are used to generate either a splitflap text or web-rendered histogram in terms of the histogram title, the keyfunction, and how to sort the keys. For a given histogram name (based on the names defined in the histogram config file), this provides those parameters. Args: which: string from the histogram config file indicating the histogram to provide settings for. max_altitude: indicates the maximum altitude that should be included on the altitude labels. max_distance: indicates the maximum distance that should be included on the distance labels. Returns: A 3-tuple of the parameters used by either CreateSingleHistogramChart or MessageboardHistogram, of the keyfunction, sort, and title. """ if which == 'destination': key = lambda k: k.get('destination_iata', KEY_NOT_PRESENT_STRING) sort = 'value' title = 'Destination' elif which == 'origin': key = lambda k: k.get('origin_iata', KEY_NOT_PRESENT_STRING) sort = 'value' title = 'Origin' elif which == 'hour': key = lambda k: datetime.datetime.fromtimestamp( k.get('calcd_timestamp', KEY_NOT_PRESENT_STRING), TZ).strftime('%H') sort = 'key' title = 'Hour' elif which == 'airline': key = lambda k: k.get('airline_short_name', KEY_NOT_PRESENT_STRING) sort = 'value' title = 'Airline' elif which == 'aircraft': key = lambda k: k.get('aircraft_type_code', KEY_NOT_PRESENT_STRING) sort = 'value' title = 'Aircraft' elif which == 'altitude': key = lambda k: '%2d'%int(k['altitude']/1000) sort = ['%2d'%x for x in range(0, int((max_altitude+1)/1000))] title = 'Altitude (1000s of ft)' elif which == 'bearing': key = lambda k: ConvertBearingToCompassDirection(k['track'], pad=True, length=3) sort = [d.rjust(3) for d in DIRECTIONS_16] title = 'Bearing' elif which == 'distance': key = lambda k: '%2d'%int(k['calcd_min_feet']/100) sort = ['%2d'%x for x in range(0, int((max_distance+1)/100))] title = 'Min Dist (100s of ft)' elif which == 'day_of_week': key = lambda k: datetime.datetime.fromtimestamp( k.get('calcd_timestamp', KEY_NOT_PRESENT_STRING), TZ).strftime('%a') sort = DAYS_OF_WEEK title = 'Day of Week' elif which == 'day_of_month': key = lambda k: datetime.datetime.fromtimestamp( k.get('calcd_timestamp', KEY_NOT_PRESENT_STRING), TZ).strftime('%d') today_day = datetime.datetime.now(TZ).day days = list(range(today_day, 0, -1)) # today down to the first of the month days.extend(range(31, today_day, -1)) # 31st of the month down to day after today days = [str(d).rjust(2) for d in days] sort = days title = 'Day of Month' else: LogMessage( 'Histogram form has invalid value for which_histograms: %s' % which) return HistogramSettingsKeySortTitle( 'destination', max_altitude=max_altitude, max_distance=max_distance) return (key, sort, title) def ImageHistograms( flights, which_histograms, how_much_history, filename_prefix=HISTOGRAM_IMAGE_PREFIX, filename_suffix=HISTOGRAM_IMAGE_SUFFIX): """Generates multiple split histogram images. Args: flights: the iterable of the raw data from which the histogram will be generated; each element of the iterable is a dictionary, that contains at least the key 'calcd_display_time', and depending on other parameters, also potentially 'calcd_min_feet' amongst others. which_histograms: string paramater indicating which histogram(s) to generate, which can be either the special string 'all', or a string linked to a specific histogram. how_much_history: string parameter taking a value among ['today', '24h', '7d', '30d]. filename_prefix: this string indicates the file path and name prefix for the images that are created. File names are created in the form [prefix]name.[suffix], i.e.: if the prefix is histogram_ and the suffix is png, then the file name might be histogram_aircraft.png. filename_suffix: see above; also interpreted by savefig to generate the correct format. Returns: List of the names of the histograms generated. """ hours = HistogramSettingsHours(how_much_history) histograms_to_generate = [] if which_histograms in ['destination', 'all']: histograms_to_generate.append({'generate': 'destination'}) if which_histograms in ['origin', 'all']: <----SKIPPED LINES----> exhaustive=histogram.get('exhaustive', False)) filename = filename_prefix + histogram['generate'] + '.' + filename_suffix matplotlib.pyplot.savefig(filename) matplotlib.pyplot.close() histograms_generated = [h['generate'] for h in histograms_to_generate] return histograms_generated def MessageboardHistograms( flights, which_histograms, how_much_history, max_screens, data_summary): """Generates multiple split flap screen histograms. Args: flights: the iterable of the raw data from which the histogram will be generated; each element of the iterable is a dictionary, that contains at least the key 'calcd_display_time', and depending on other parameters, also potentially 'calcd_min_feet' amongst others. which_histograms: string paramater indicating which histogram(s) to generate, which can be either the special string 'all', or a string linked to a specific histogram. how_much_history: string parameter taking a value among ['today', '24h', '7d', '30d]. max_screens: string parameter taking a value among ['_1', '_2', '_5', or 'all']. data_summary: parameter that evaluates to a boolean indicating whether the data summary screen in the histogram should be displayed. Returns: Returns a list of printable strings (with embedded new line characters) representing the histogram, for each screen in the histogram. """ messages = [] hours = HistogramSettingsHours(how_much_history) screen_limit = HistogramSettingsScreens(max_screens) histograms_to_generate = [] if which_histograms in ['destination', 'all']: histograms_to_generate.append({ 'generate': 'destination', 'columns': 3}) if which_histograms in ['origin', 'all']: histograms_to_generate.append({ 'generate': 'origin', 'columns': 3}) if which_histograms in ['hour', 'all']: histograms_to_generate.append({ 'generate': 'hour', 'columns': 4, 'suppress_percent_sign': True, 'column_divider': '|'}) if which_histograms in ['airline', 'all']: histograms_to_generate.append({ 'generate': 'airline'}) if which_histograms in ['aircraft', 'all']: histograms_to_generate.append({ 'generate': 'aircraft'}) if which_histograms in ['altitude', 'all']: histograms_to_generate.append({ 'generate': 'altitude', 'columns': 3}) if which_histograms in ['bearing', 'all']: histograms_to_generate.append({ 'generate': 'bearing', 'columns': 3}) if which_histograms in ['distance', 'all']: histograms_to_generate.append({ 'generate': 'distance', 'columns': 3}) if ((which_histograms == 'all' and how_much_history == '7d') or which_histograms == 'day_of_week'): histograms_to_generate.append({ 'generate': 'day_of_week', 'columns': 3}) if ((which_histograms == 'all' and how_much_history == '30d') or which_histograms == 'day_of_month'): histograms_to_generate.append({ 'generate': 'day_of_month', 'columns': 4, 'suppress_percent_sign': True, 'column_divider': '|'}) for histogram in histograms_to_generate: this_histogram = which_histograms if this_histogram == 'all': this_histogram = histogram['generate'] (key, sort, title) = HistogramSettingsKeySortTitle(this_histogram) histogram = MessageboardHistogram( flights, key, sort, title, screen_limit=screen_limit, columns=histogram.get('columns', 2), suppress_percent_sign=histogram.get('suppress_percent_sign', False), column_divider=histogram.get('column_divider', ' '), data_summary=data_summary, hours=hours) messages.extend(histogram) return messages def MessageboardHistogram( data, keyfunction, sort_type, title, screen_limit=1, columns=2, column_divider=' ', data_summary=False, hours=0, suppress_percent_sign=False): """Generates a text representation of one histogram that can be rendered on the display. Args: data: the iterable of the raw data from which the histogram will be generated; each element of the iterable is a dictionary, that contains at least the key 'calcd_display_time', and depending on other parameters, also potentially 'calcd_min_feet' amongst others. keyfunction: the function that determines how the key or label of the histogram should be generated; it is called for each element of the data iterable. For instance, to simply generate a histogram on the attribute 'heading', keyfunction would be lambda a: a['heading']. sort_type: determines how the keys (and the corresponding values) are sorted; see GenerateHistogramData docstring for details title: string title, potentially truncated to fit, to be displayed for the histogram screen_limit: maximum number of screens to be displayed for the histogram; a value of zero is interpreted to mean no limit on screens. columns: number of columns of data to be displayed for the histogram; note that the keys of the histogram may need to be truncated in length to fit the display as more columns are squeezed into the space column_divider: string for the character(s) to be used to divide the columns data_summary: boolean indicating whether to augment the title with a second header line about the data presented in the histogram hours: integer indicating the oldest data to be included in the histogram suppress_percent_sign: boolean indicating whether to suppress the percent sign in the data (but to add it to the title) to reduce the amount of string truncation potentially necessary for display of the keys Returns: Returns a list of printable strings (with embedded new line characters) representing the histogram. """ title_lines = 1 if data_summary: title_lines += 1 available_entries_per_screen = (SPLITFLAP_LINE_COUNT - title_lines) * columns available_entries_total = available_entries_per_screen * screen_limit (values, keys, unused_filtered_data) = GenerateHistogramData( data, keyfunction, sort_type, truncate=available_entries_total, hours=hours) screen_count = math.ceil(len(keys) / available_entries_per_screen) column_width = int( (SPLITFLAP_CHARS_PER_LINE - len(column_divider)*(columns - 1)) / columns) # i.e.: ' 10%' or ' 10', depending on suppress_percent_sign value_size = 3 printed_percent_sign = '' augment_title_units = ' %' if not suppress_percent_sign: value_size += 1 printed_percent_sign = '%' augment_title_units = '' column_key_width = column_width - value_size total = sum(values) if data_summary: if hours: hours_of_data = min(hours, DataHistoryHours(data)) else: hours_of_data = DataHistoryHours(data) time_horizon_text = 'Last %s' % SecondsToDdHh(hours_of_data * SECONDS_IN_HOUR) summary_text = '%s (n=%d)' % (time_horizon_text, sum(values)) summary_text = summary_text.center(SPLITFLAP_CHARS_PER_LINE) split_flap_boards = [] for screen in range(screen_count): if screen_count == 1: counter = '' else: counter = ' (%d/%d)' % (screen+1, screen_count) screen_title = '%s%s%s' % ( title[:SPLITFLAP_CHARS_PER_LINE - len(counter) - len(augment_title_units)], augment_title_units, counter) screen_title = screen_title.center(SPLITFLAP_CHARS_PER_LINE) start_index = screen*available_entries_per_screen end_index = min((screen+1)*available_entries_per_screen-1, len(keys)-1) number_of_entries = end_index - start_index + 1 number_of_lines = math.ceil(number_of_entries / columns) lines = [] lines.append(screen_title.upper()) if data_summary: lines.append(summary_text.upper()) for line_index in range(number_of_lines): key_value = [] for column_index in range(columns): index = start_index + column_index*number_of_lines + line_index if index <= end_index: # If the % is >=1%, display right-justified 2 digit percent, i.e. ' 5%' # Otherwise, if it rounds to at least 0.1%, display i.e. '.5%' if round(values[index]/total*100) >= 1: value_string = '%2d' % round(values[index]/total*100) elif round(values[index]/total*1000)/10 >= 0.1: value_string = ('%.1f' % (round(values[index]/total*1000)/10))[1:] else: value_string = ' 0' key_value.append('%s %s%s' % ( str(keys[index])[:column_key_width].ljust(column_key_width), value_string, printed_percent_sign)) line = (column_divider.join(key_value)).upper() lines.append(line) split_flap_boards.append(Screenify(lines)) return split_flap_boards def GetNowInTimeZoneOfArbtraryFlight(flights): """Returns datetime now in the timezone of an arbitrarily selected flight. Args: flights: iterable with dictionaries of the flight details. Returns: Timezone-aware datetime. """ arbitrary_time_string = flights[0]['calcd_display_time'] arbitrary_time = datetime.datetime.strptime( arbitrary_time_string, '%Y-%m-%d %H:%M:%S.%f%z') tz = arbitrary_time.tzinfo now = datetime.datetime.now(tz) return now def ConvertHourStringTimeString(flight): """Convert calcd_display_time on flight to a string like '12a' or ' 1p'.""" time_string = flight.get('calcd_display_time', '') if time_string: hour_string = time_string[11:13] hour_0_23 = int(hour_string) is_pm = int(hour_0_23/12) == 1 hour_number = hour_0_23 % 12 if hour_number == 0: hour_number = 12 out_string = str(hour_number).rjust(2) if is_pm: out_string += 'p' else: out_string += 'a' else: out_string = KEY_NOT_PRESENT_STRING return out_string def TruncatePickledFlights(file=PICKLEFILE_30D, days=30): """Truncate the pickled flights file to have at most some number of days of history. Delete old flights from the given pickle repository. Args: file: name (potentially including path) of the pickled file days: maximum number of days (measured from now) to include in the file """ flights = UnpickleFlights(file) now = GetNowInTimeZoneOfArbtraryFlight(flights) # We want to be cautious, not overwriting the original file until we're sure this has # completed; this is why we use the tmp file tmp_file = file+'.tmp' for flight in flights: if HoursSinceFlight(now, flight['calcd_display_time']) <= days*HOURS_IN_DAY: PickleFlight(flight, tmp_file) shutil.move(tmp_file, file) def UnpickleFlights(file): """Load a repository of pickled flight data into memory. Args: file: name (potentially including path) of the pickled file Returns: Return a list of all the flights, in the same sequence as written to the file. """ flights = [] f = open(file, 'rb') try: while True: data = pickle.load(f) flights.append(data) except EOFError: pass f.close() return flights def PickleFlight(flight, file): """Append one pickled flight to the end of binary file. Args: flight: data to pickle file: name (potentially including path) of the pickled file """ try: with open(file, 'ab') as f: f.write(pickle.dumps(flight)) except IOError: LogMessage('Unable to append pickle ' + file) def UpdateAircraftList(persistent_nearby_aircraft, current_nearby_aircraft, now): """Identifies newly seen aircraft and removes aircraft that haven't been seen recently. Updates persistent_nearby_aircraft as follows: flights that have been last seen more than PERSISTENCE_SECONDS seconds ago are removed; new flights in current_nearby_aircraft are added. Also identifies newly-seen aircraft and updates the last-seen timestamp of flights that have been seen again. Args: persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values are the time the flight was last seen. current_nearby_aircraft: dictionary where keys are flight numbers, and the values are themselves dictionaries with key-value pairs about that flight, with at least one of the kv-pairs being the time the flight was seen. now: the timestamp of the flights in the current_nearby_aircraft. Returns: A list of newly-nearby flight numbers. """ newly_nearby_flight_numbers = [] for flight_number in current_nearby_aircraft: if flight_number not in persistent_nearby_aircraft: newly_nearby_flight_numbers.append(flight_number) persistent_nearby_aircraft[flight_number] = now flights_to_delete = [] for flight_number in persistent_nearby_aircraft: if (flight_number not in current_nearby_aircraft and (now - persistent_nearby_aircraft[flight_number]) > PERSISTENCE_SECONDS): flights_to_delete.append(flight_number) for flight_number in flights_to_delete: del persistent_nearby_aircraft[flight_number] return newly_nearby_flight_numbers def ScanForNewFlights(persistent_nearby_aircraft): """Determines if there are any new aircraft in the radio message. The radio is continuously dumping new json messages to the Raspberry pi with all the flights currently observed. This function picks up the latest radio json, and for any new nearby flights - there should generally be at most one new flight on each pass through - gets additional flight data from FlightAware and augments the flight definition with the relevant fields to keep. Args: persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values are the time the flight was last seen. Returns: A 3-tuple: - updated persistent_nearby_aircraft - current_nearby_aircraft - (possibly empty) dictionary of flight attributes of the new flight upon its first observation. """ flight_details = {} dump_json = ReadFile(DUMP_JSON_FILE, log_exception=True) if dump_json: (current_nearby_aircraft, now) = ParseDumpJson(dump_json) newly_nearby_flight_numbers = UpdateAircraftList( persistent_nearby_aircraft, current_nearby_aircraft, now) if newly_nearby_flight_numbers: if len(newly_nearby_flight_numbers) > 1: newly_nearby_flight_numbers_str = ', '.join(newly_nearby_flight_numbers) newly_nearby_flight_details_str = '\n'.join( [str(current_nearby_aircraft[f]) for f in newly_nearby_flight_numbers]) LogMessage('Multiple newly-nearby flights: %s\n%s' % ( newly_nearby_flight_numbers_str, newly_nearby_flight_details_str)) flight_number = newly_nearby_flight_numbers[0] if flight_number: flight_aware_json = GetFlightAwareJson(flight_number) else: flight_aware_json = {} if flight_aware_json: flight_details = ParseFlightAwareJson(flight_aware_json) else: LogMessage('No json returned from Flightaware for flight: %s' % flight_number) flight_details = {} # Augment FlightAware details with radio / radio-derived details flight_details.update(current_nearby_aircraft[flight_number]) # Augment FlightAware details with useful displayable attributes AugmentWithDisplayableAirline(flight_details) AugmentWithDisplayableAircraft(flight_details) AugmentWithDisplayableOriginDestination(flight_details) AugmentWithDisplayableDelay(flight_details) AugmentWithDisplayableTimeRemaining(flight_details) return (persistent_nearby_aircraft, current_nearby_aircraft, flight_details) def ReadSettings(filename): """Parse delimited string of settings in file to a dict of key value pairs. Parses a string like 'distance=1426;altitude=32559;on=23;off=24;delay=15;insights=all;' into key value pairs. Args: filename: string of the filename to open, potentially also including the full path. Returns: Dict of key value pairs contained in the setting file; empty dict if file not available or if delimiters missing. """ settings_dict = {} settings = ReadFile(filename) for setting in settings.split(';'): if '=' in setting: kv_list = setting.split('=') k = kv_list[0] v = kv_list[1] if v.isdigit(): v = int(v) settings_dict[k] = v return settings_dict def FlightMeetsDisplayCriteria(flight, configuration): """Returns boolean indicating whether the screen is currently accepting new flight data. Based on the configuration file, determines whether the flight data should be displayed. Specifically, the configuration: - may include 'enabled' indicating whether screen should be driven at all - should include 'on' & 'off' parameters indicating minute (from midnight) of operation - should include altitude & elevation parameters indicating max values of interest Args: flight: dictionary of flight attributes. configuration: dictionary of configuration attributes. Returns: Boolean as described. """ flight_meets_criteria = 'enabled' in configuration flight_altitude = DictGetReplaceNone(flight, 'altitude', float('inf')) config_max_altitude = configuration['altitude'] if flight_altitude > config_max_altitude: flight_meets_criteria = False else: flight_distance = DictGetReplaceNone(flight, 'calcd_min_feet', float('inf')) config_max_distance = configuration['distance'] if flight_distance > config_max_distance: flight_meets_criteria = False else: flight_timestamp = flight['calcd_timestamp'] dt = datetime.datetime.fromtimestamp(flight_timestamp, TZ) minute_of_day = dt.hour * MINUTES_IN_HOUR + dt.minute if minute_of_day < configuration['on'] or minute_of_day > configuration['off']: flight_meets_criteria = False return flight_meets_criteria def MaintainRollingWebLog(message, max_count, filename=ROLLING_MESSAGE_FILE): """Maintains a rolling text file of at most max_count printed messages. Newest data at top and oldest data at the end, of at most max_count messages, where the delimiter between each message is identified by a special fixed string. Args: message: text message to prepend to the file. max_count: maximum number of messages to keep in the file; the max_count+1st message is deleted. filename: the file to update. """ rolling_log_header = '='*len(SPLITFLAP_HEADER) existing_file = ReadFile(filename) log_message_count = existing_file.count(rolling_log_header) if log_message_count >= max_count: message_start_list = [i for i in range(0, len(existing_file)) if existing_file[i:].startswith(rolling_log_header)] existing_file_to_keep = existing_file[:message_start_list[max_count - 1]] else: existing_file_to_keep = existing_file t = datetime.datetime.now(TZ).strftime('%m/%d/%Y, %H:%M:%S') new_message = ( '\n'.join([rolling_log_header, t, '', message]) + '\n' + existing_file_to_keep) try: with open(filename, 'w') as f: f.write(new_message) except IOError: LogMessage('Unable to maintain rolling log at ' + filename) def CurrentFlightPosition(flights, last_location_detail): focus_flight = flights[-1] focus_flight_number = focus_flight['dump_flight_number'] lines = [] new_flight = ( not last_location_detail or last_location_detail.get('dump_flight_number') != focus_flight_number) def TrackMissingElements(key, missing): value = focus_flight.get(key) if value is None: missing.append(key) return value, missing missing_elements = [] if new_flight: calculations_count = 0 (track, missing_elements) = TrackMissingElements('track', missing_elements) (altitude, missing_elements) = TrackMissingElements('altitude', missing_elements) (speed, missing_elements) = TrackMissingElements('speed', missing_elements) (lat, missing_elements) = TrackMissingElements('lat', missing_elements) (lon, missing_elements) = TrackMissingElements('lon', missing_elements) (vert_rate, missing_elements) = TrackMissingElements('vert_rate', missing_elements) (last_timestamp, missing_elements) = TrackMissingElements( 'calcd_timestamp', missing_elements) else: # this is a flight we've already started tracking loc of, so we know # all elements are present in last_location_detail calculations_count = last_location_detail['calculations_count'] + 1 track = last_location_detail['track'] altitude = last_location_detail['altitude'] speed = last_location_detail['speed'] lat = last_location_detail['lat'] lon = last_location_detail['lon'] vert_rate = last_location_detail['vert_rate'] last_timestamp = last_location_detail['calcd_timestamp'] if not missing_elements: now = time.time() meters_per_second = speed*METERS_PER_SECOND_IN_KNOTS elapsed_sec = now - last_timestamp meters_traveled = meters_per_second * elapsed_sec plane_pos = TrajectoryLatLon((lat, lon), meters_traveled, track) (az_degrees, alt_degrees, distance, crow_distance) = Angles( HOME, HOME_ALT, plane_pos, altitude / FEET_IN_METER) if new_flight: lines.append('='*80) lines.append('New flight: %s' % focus_flight_number) lines.append('='*80) else: lines.append('Existing flight: %s' % focus_flight_number) lines.append( 'track: %.2fdg; speed: %.2f m/s; t since last pos: %.4fsec; covered %.2f meters' % (track, meters_per_second, elapsed_sec, meters_traveled)) if new_flight: lines.append('Orig pos: (%.4f, %.4f); Alt: %d; hdist: %d' % ( focus_flight.get('lat', 0), focus_flight.get('lon', 0), focus_flight.get('altitude', 0), focus_flight.get('distance_meters', 0))) #WHY = 0 on first data pull? else: lines.append( 'Last calc: (%.4f, %.4f); Alt: %d; hdist: %d; ' 'dist: %d; Az: %.4fdg; El: %.4fdg' % ( last_location_detail.get('lat', 0), last_location_detail.get('lon', 0), last_location_detail.get('altitude', 0), last_location_detail.get('distance_meters', 0), last_location_detail.get('crow_distance', 0), last_location_detail.get('azimuth', 0), last_location_detail.get('elevation', 0))) # save the calc'd location at the calc'd time last_location_detail = { 'track': track, 'altitude': altitude, 'speed': speed, 'lat': plane_pos[0], 'lon': plane_pos[1], 'vert_rate': vert_rate, 'calcd_timestamp': now, 'azimuth': az_degrees, 'elevation': alt_degrees, 'distance_meters': distance, 'crow_distance': crow_distance, 'dump_flight_number': focus_flight_number, 'calculations_count': calculations_count} lines.append( 'Curr calc: (%.4f, %.4f); Alt: %d; hdist: %d; ' 'dist: %d; Az: %.4fdg; El: %.4fdg' % ( last_location_detail.get('lat', 0), last_location_detail.get('lon', 0), last_location_detail.get('altitude', 0), last_location_detail.get('distance_meters', 0), last_location_detail.get('crow_distance', 0), last_location_detail.get('azimuth', 0), last_location_detail.get('elevation', 0))) LogMessage('\n'.join(lines)) elif focus_flight == last_location_detail['dump_flight_number']: LogMessage('Flight %s missing %s in calculating the current position' % ( focus_flight, str(missing_elements))) return last_location_detail def TriggerHistograms(flights, histogram_settings): """Triggers the text-based or web-based histograms. Based on the histogram settings, determines whether to generate text or image histograms (or both). For image histograms, also generates empty images for the histograms not created so that broken image links are not displayed in the webpage. Args: flights: List of flight attribute dictionaries. histogram_settings: Dictionary of histogram parameters. Returns: List of histogram messages, if text-based histograms are selected; empty list otherwise. """ histogram_messages = [] if histogram_settings['type'] in ('messageboard', 'both'): <----SKIPPED LINES----> histogram_settings['histogram_history'], histogram_settings['histogram_max_screens'], histogram_settings.get('histogram_data_summary', False)) if histogram_settings['type'] in ('images', 'both'): histograms_generated = ImageHistograms( flights, histogram_settings['histogram'], histogram_settings['histogram_history']) all_available_histograms = [ 'destination', 'origin', 'hour', 'airline', 'aircraft', 'altitude', 'bearing', 'distance', 'day_of_week', 'day_of_month'] for histogram in all_available_histograms: if histogram not in histograms_generated: missing_filename = ( HISTOGRAM_IMAGE_PREFIX + histogram + '.' + HISTOGRAM_IMAGE_SUFFIX) shutil.copyfile(HISTOGRAM_EMPTY_IMAGE_FILE, missing_filename) return histogram_messages def main(): """Traffic cop between incoming radio flight messages, configuration, and messageboard. This is the main logic, checking for new flights, augmenting the radio signal with additional web-scraped data, and generating messages in a form presentable to the messageboard. """ already_running_id = CheckIfProcessRunning('messageboard.py') if already_running_id: os.kill(already_running_id, signal.SIGKILL) TruncatePickledFlights(file=PICKLEFILE_30D) flights = UnpickleFlights(PICKLEFILE_30D) configuration = ReadSettings(CONFIG_FILE) last_distance = configuration.get('distance') last_altitude = configuration.get('altitude') persistent_nearby_aircraft = {} # key = flight number; value = last seen # Next up to print is element 0; this is a list of tuples: # Element#1: flag indicating the type of message that this is # Element#2: the message itself message_queue = [] next_message_time = time.time() last_location_detail = {} while True: new_configuration = ReadSettings(CONFIG_FILE) if (new_configuration.get('distance') != configuration.get('distance') or new_configuration.get('altitude') != configuration.get('altitude')): last_distance = configuration.get('distance') last_altitude = configuration.get('altitude') SaveTimeOfDayHistogramPng( flights, new_configuration['distance'], new_configuration['altitude'], 7, last_max_distance_feet=last_distance, last_max_altitude_feet=last_altitude) configuration = new_configuration histogram = ReadSettings(HOURLY_HISTOGRAM_FILE) if histogram: histogram_messages = TriggerHistograms(flights, histogram) histogram_messages = [(FLAG_MSG_HISTOGRAM, m) for m in histogram_messages] message_queue.extend(histogram_messages) os.remove(HOURLY_HISTOGRAM_FILE) (persistent_nearby_aircraft, current_nearby_aircraft, flight_details) = ( ScanForNewFlights(persistent_nearby_aircraft)) if flight_details: flights.append(flight_details) PickleFlight(flight_details, PICKLEFILE_30D) PickleFlight(flight_details, PICKLEFILE_ARCHIVE) flight_messages = [] if FlightMeetsDisplayCriteria(flight_details, configuration): flight_messages.append(( FLAG_MSG_FLIGHT, CreateMessageAboutFlight(flight_details))) next_message_time = time.time() # display the next message about this flight now! # and delete any queued "interesting" messages about other flights that have # not yet displayed, since a newer flight has taken precedence message_queue = [m for m in message_queue if m[0] != FLAG_MSG_INTERESTING] flight_insights_enabled_string = configuration.get('insights', 'hide') if flight_insights_enabled_string in ('one', 'all'): insight_messages = FlightInsights(flights) if flight_insights_enabled_string == 'one' and insight_messages: insight_messages = [random.choice(insight_messages)] insight_messages = [(FLAG_MSG_INTERESTING, m) for m in insight_messages] flight_messages.extend(insight_messages) # i.e.: we need to make sure the final step is to insert the flight message # as the first message flight_messages.reverse() for flight_message in flight_messages: message_queue.insert(0, flight_message) new_plane = flights[-1]['dump_flight_number'] != last_location_detail.get( 'dump_flight_number') if flights and (new_plane or last_location_detail.get('calculations_count', 0) <= 10): last_location_detail = CurrentFlightPosition( flights, last_location_detail) # check time & if appropriate, display next message from queue if time.time() >= next_message_time: if message_queue: next_message_time += configuration['delay'] next_message = message_queue.pop(0) LogMessage(next_message[1], file=ALL_MESSAGE_FILE) MaintainRollingWebLog(next_message[1], 25) else: next_message_time += 1 time.sleep(1) if __name__ == "__main__": main() |
01234567890123456789012345678901234567890123456789012345678901234567890123456789
12345 6789101112131415161718192021222324252627282930313233343536373839404142 434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990 919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 177178179180181182183184185186187188189190191 192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282 283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708 724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837 838839840841842843844845846847848849850851852853854855856857858859860 8618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517 362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669 369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722 37233724372537263727372837293730373137323733373437353736373737383739374037413742 375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784 37853786378737883789379037913792379337943795379637973798379938003801380238033804 3805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829 3830383138323833 383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881 39133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088 4089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143 41444145414641474148414941504151415241534154415541564157415841594160416141624163 41644165416641674168416941704171417241734174417541764177417841794180418141824183 41874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659 466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707 470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833 4834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861 | #!/usr/bin/python3 import datetime import io import json import math import numbers import os import pickle import shutil import signal import statistics import sys import textwrap import time import bs4 import dateutil.relativedelta import numpy import matplotlib import matplotlib.pyplot import psutil import pycurl import pytz import requests import tzlocal import unidecode SIMULATION = False SIMULATION_COUNTER = 0 SIMULATION_PREFIX = 'SIM_' PICKLE_DUMP_JSON_FILE = 'dump_json.pk' PICKLE_FA_JSON_FILE = 'fa_json.pk' DUMP_JSONS = None # loaded only if in simulation mode FA_JSONS = None # loaded only if in simulation mode HOME_LAT = 37.64406 HOME_LON = -122.43463 HOME = (HOME_LAT, HOME_LON) # lat / lon tuple of antenna HOME_ALT = 29 #altitude in meters RADIUS = 6371.0e3 # radius of earth in meters FEET_IN_METER = 3.28084 FEET_IN_MILE = 5280 METERS_PER_SECOND_IN_KNOTS = 0.514444 MIN_METERS = 5000/FEET_IN_METER # only planes within this distance will be detailed # planes not seen within MIN_METERS in PERSISTENCE_SECONDS seconds will be dropped from # the nearby list PERSISTENCE_SECONDS = 10 TRUNCATE = 50 # max number of keys to include in a histogram image file # number of seconds to pause between each radio poll / command processing loop LOOP_DELAY_SECONDS = 1 # This file is where the radio drops its json file DUMP_JSON_FILE = '/run/dump1090-mutability/aircraft.json' # This is the directory that stores all the ancillary messageboard configuration files # that do not need to be exposed via the webserver MESSAGEBOARD_PATH = '/home/pi/splitflap/' # This is the directory of the webserver; files placed here are available at # http://adsbx-custom.local/; files placed in this directly are visible via a browser WEBSERVER_PATH = '/var/www/html/' # At the time a flight is first identified as being of interest (in that it falls # within MIN_METERS meters of HOME), it - and core attributes derived from FlightAware, # if any - is appended to the end of this pickle file. However, since this file is # cached in working memory, flights older than 30 days are flushed from this periodically. PICKLE_FLIGHTS_30D = 'flights_30d.pk' #pickled list of up to about 30d of flights # This is the same concept as the 30d pickle file, except it is not auto-flushed, and # so will grow indefinitely. PICKLE_FLIGHTS_ARCHIVE = 'flights_archive.pk' #pickled list of all flights CACHED_ELEMENT_PREFIX = 'cached_' # This web-exposed file is used for non-error messages that might highlight data or # code logic to check into. It is only cleared out manually. LOGFILE = 'log.txt' # Identical to the LOGFILE, except it includes just the most recent n lines. Newest # lines are at the end. ROLLING_LOGFILE = 'rolling_log.txt' #file for error messages # Users can trigger .png histograms analogous to the text ones from the web interface; # this is the folder (within WEBSERVER_PATH) where those files are placed WEBSERVER_IMAGE_FOLDER = 'images/' # Multiple histograms can be generated, i.e. for airline, aircraft, day of week, etc. # The output files are named by the prefix & suffix, i.e.: prefix + type + . + suffix, # as in histogram_aircraft.png. These names match up to the names expected by the html # page that displays the images. Also, note that the suffix is interpreted by matplotlib # to identify the image format to create. HISTOGRAM_IMAGE_PREFIX = 'histogram_' HISTOGRAM_IMAGE_SUFFIX = 'png' # For those of the approximately ten different types of histograms _not_ generated, # an empty image is copied into the location expected by the webpage instead; this is # the location of that "empty" image file. HISTOGRAM_EMPTY_IMAGE_FILE = 'empty.png' # This file indicates a pending request for histograms - either png, text-based, or # both - that may have come from either the web or Arduino interfaces; regardless, # once it is processed, this file is deleted. The contents are concatenated key-value # pairs, histogram=all;histogram_history=24h; etc. HISTOGRAM_CONFIG_FILE = 'histogram.txt' # This contains concatenated key-value configuration attributes in a similar format # to the HISTOGRAM_CONFIG_FILE that are exposed to the user via the web interface or, # for a subset of them, through the Arduino interface. They are polled at every iteration # so that the most current value is always leveraged by the running software. CONFIG_FILE = 'settings.txt' # A few key settings for the messageboard are its sensitivity to displaying flights - # though it logs all flights within range, it may not be desirable to display all # flights to the user. Two key parameters are the maximum altitude, and the furthest # away we anticipate the flight being at its closest point to HOME. As those two # parameters are manipulated in the settings, a histogram is displayed with one or # potentially two series, showing the present and potentially prior-set distribution # of flights, by hour throughout the day, over the last seven days, normalized to # flights per day. This allows those parameters to be fine-tuned in a useful way. # This file is the location, on the webserver, of that image, which needs to be in # alignment with the html page that displays it. HOURLY_IMAGE_FILE = 'hours.png' # Communication with the asynchronously-running Arduino interface is done thru files; # this file includes the superset of key-value pairs that could potentially be sent # directly, or after some manipulation, to the Arduino. ARDUINO_FILE = 'arduino.txt' # One potential command from the Arduino is to "display the last flight"; this request # is communicated to the Arduino by the presence of this file. After that request is # processed, this file is deleted. The contents of the file are not used in any way. LAST_FLIGHT_FILE = 'last_flight.txt' # This is all messages that have been sent to the board since the last time the # file was manually cleared. Newest messages are at the bottom. It is visible at the # webserver. ALL_MESSAGE_FILE = 'all_messages.txt' #enumeration of all messages sent to board # This shows the most recent n messages sent to the board. Newest messages are at the # top for easier viewing of "what did I miss". ROLLING_MESSAGE_FILE = 'rolling_messages.txt' FLAG_MSG_FLIGHT = 1 # basic flight details FLAG_MSG_INTERESTING = 2 # random tidbit about a flight FLAG_MSG_HISTOGRAM = 3 # histogram message FLAG_INSIGHT_LAST_SEEN = 0 FLAG_INSIGHT_DIFF_AIRCRAFT = 1 FLAG_INSIGHT_NTH_FLIGHT = 2 FLAG_INSIGHT_GROUNDSPEED = 3 FLAG_INSIGHT_ALTITUDE = 4 FLAG_INSIGHT_VERTRATE = 5 FLAG_INSIGHT_FIRST_DEST = 6 FLAG_INSIGHT_FIRST_ORIGIN = 7 FLAG_INSIGHT_FIRST_AIRLINE = 8 FLAG_INSIGHT_FIRST_AIRCRAFT = 9 FLAG_INSIGHT_LONGEST_DELAY = 10 FLAG_INSIGHT_FLIGHT_DELAY_FREQUENCY = 11 FLAG_INSIGHT_FLIGHT_DELAY_TIME = 12 FLAG_INSIGHT_AIRLINE_DELAY_FREQUENCY = 13 FLAG_INSIGHT_AIRLINE_DELAY_TIME = 14 FLAG_INSIGHT_DESTINATION_DELAY_FREQUENCY = 15 FLAG_INSIGHT_DESTINATION_DELAY_TIME = 16 FLAG_INSIGHT_HOUR_DELAY_FREQUENCY = 17 FLAG_INSIGHT_HOUR_DELAY_TIME = 18 FLAG_INSIGHT_DATE_DELAY_FREQUENCY = 17 FLAG_INSIGHT_DATE_DELAY_TIME = 18 INSIGHT_TYPES = 21 #if running on raspberry, then need to prepend path to file names if psutil.sys.platform.title() == 'Linux': PICKLE_FLIGHTS_30D = MESSAGEBOARD_PATH + PICKLE_FLIGHTS_30D PICKLE_FLIGHTS_ARCHIVE = MESSAGEBOARD_PATH + PICKLE_FLIGHTS_ARCHIVE LOGFILE = MESSAGEBOARD_PATH + LOGFILE PICKLE_DUMP_JSON_FILE = MESSAGEBOARD_PATH + PICKLE_DUMP_JSON_FILE PICKLE_FA_JSON_FILE = MESSAGEBOARD_PATH + PICKLE_FA_JSON_FILE LAST_FLIGHT_FILE = MESSAGEBOARD_PATH + LAST_FLIGHT_FILE ARDUINO_FILE = MESSAGEBOARD_PATH + ARDUINO_FILE HISTOGRAM_CONFIG_FILE = WEBSERVER_PATH + HISTOGRAM_CONFIG_FILE CONFIG_FILE = WEBSERVER_PATH + CONFIG_FILE HOURLY_IMAGE_FILE = WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HOURLY_IMAGE_FILE ROLLING_MESSAGE_FILE = WEBSERVER_PATH + ROLLING_MESSAGE_FILE HISTOGRAM_IMAGE_PREFIX = ( WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_IMAGE_PREFIX) HISTOGRAM_EMPTY_IMAGE_FILE = ( WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_EMPTY_IMAGE_FILE) ALL_MESSAGE_FILE = WEBSERVER_PATH + ALL_MESSAGE_FILE ROLLING_LOGFILE = WEBSERVER_PATH + ROLLING_LOGFILE TIMEZONE = 'US/Pacific' # timezone of display TZ = pytz.timezone(TIMEZONE) KNOWN_AIRPORTS = ('SJC', 'SFO', 'OAK') # iata codes that we don't need to expand SPLITFLAP_CHARS_PER_LINE = 22 SPLITFLAP_LINE_COUNT = 6 DIRECTIONS_4 = ['N', 'E', 'S', 'W'] DIRECTIONS_8 = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] DIRECTIONS_16 = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] HOURS = ['12a', ' 1a', ' 2a', ' 3a', ' 4a', ' 5a', ' 6a', ' 7a', ' 8a', ' 9a', '10a', '11a', '12p', ' 1p', ' 2p', ' 3p', ' 4p', ' 5p', ' 6p', ' 7p', ' 8p', ' 9p', '10p', '11p'] SECONDS_IN_MINUTE = 60 MINUTES_IN_HOUR = 60 HOURS_IN_DAY = 24 SECONDS_IN_HOUR = SECONDS_IN_MINUTE * MINUTES_IN_HOUR MINUTES_IN_DAY = MINUTES_IN_HOUR * HOURS_IN_DAY SECONDS_IN_DAY = SECONDS_IN_HOUR * HOURS_IN_DAY # Units confirmed here: # www.adsbexchange.com/forum/threads/units-in-the-dump1090-json-file.630617/#post-639541 CLIMB_RATE_UNITS = 'fpm' #speed units from tracker are knots, based on dump-1090/track.c #https://github.com/SDRplay/dump1090/blob/master/track.c SPEED_UNITS = 'kn' DISTANCE_UNITS = 'ft' # altitude # For displaying histograms # If a key is not present, how should it be displayed in histograms? KEY_NOT_PRESENT_STRING = 'Unknown' OTHER_STRING = 'Other' # What key strings should be listed last in sequence? # What key strings should be listed last in sequence? SORT_AT_END_STRINGS = [OTHER_STRING, KEY_NOT_PRESENT_STRING] # What is the sorted sequence of keys for days of week? DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] aircraft_length = {} # in meters aircraft_length['Airbus A220-100 (twin-jet)'] = 35 aircraft_length['Airbus A300F4-600 (twin-jet)'] = 54.08 aircraft_length['Airbus A319 (twin-jet)'] = 33.84 aircraft_length['Airbus A320 (twin-jet)'] = 37.57 aircraft_length['Airbus A320neo (twin-jet)'] = 37.57 aircraft_length['Airbus A321 (twin-jet)'] = 44.51 aircraft_length['Airbus A321neo (twin-jet)'] = 44.51 aircraft_length['Airbus A330-200 (twin-jet)'] = 58.82 aircraft_length['Airbus A330-300 (twin-jet)'] = 63.67 aircraft_length['Airbus A340-300 (quad-jet)'] = 63.69 aircraft_length['Airbus A350-1000 (twin-jet)'] = 73.79 aircraft_length['Airbus A350-900 (twin-jet)'] = 66.8 aircraft_length['Airbus A380-800 (quad-jet)'] = 72.72 aircraft_length['Boeing 737-400 (twin-jet)'] = 36.4 aircraft_length['Boeing 737-700 (twin-jet)'] = 33.63 aircraft_length['Boeing 737-800 (twin-jet)'] = 39.47 aircraft_length['Boeing 737-900 (twin-jet)'] = 42.11 aircraft_length['Boeing 747-400 (quad-jet)'] = 36.4 aircraft_length['Boeing 747-8 (quad-jet)'] = 76.25 aircraft_length['Boeing 757-200 (twin-jet)'] = 47.3 aircraft_length['Boeing 757-300 (twin-jet)'] = 54.4 aircraft_length['Boeing 767-200 (twin-jet)'] = 48.51 aircraft_length['Boeing 767-300 (twin-jet)'] = 54.94 aircraft_length['Boeing 777-200 (twin-jet)'] = 63.73 aircraft_length['Boeing 777-200LR/F (twin-jet)'] = 63.73 aircraft_length['Boeing 777-300ER (twin-jet)'] = 73.86 aircraft_length['Boeing 787-10 (twin-jet)'] = 68.28 aircraft_length['Boeing 787-8 (twin-jet)'] = 56.72 aircraft_length['Boeing 787-9 (twin-jet)'] = 62.81 aircraft_length['Canadair Regional Jet CRJ-200 (twin-jet)'] = 26.77 aircraft_length['Canadair Regional Jet CRJ-700 (twin-jet)'] = 32.3 aircraft_length['Canadair Regional Jet CRJ-900 (twin-jet)'] = 36.2 aircraft_length['Canadair Challenger 350 (twin-jet)'] = 20.9 aircraft_length['Bombardier Challenger 300 (twin-jet)'] = 20.92 aircraft_length['EMBRAER 175 (long wing) (twin-jet)'] = 31.68 aircraft_length['Embraer ERJ-135 (twin-jet)'] = 26.33 aircraft_length['Cessna Caravan (single-turboprop)'] = 11.46 aircraft_length['Cessna Citation II (twin-jet)'] = 14.54 aircraft_length['Cessna Citation V (twin-jet)'] = 14.91 aircraft_length['Cessna Skyhawk (piston-single)'] = 8.28 aircraft_length['Cessna Skylane (piston-single)'] = 8.84 aircraft_length['Cessna Citation Sovereign (twin-jet)'] = 19.35 aircraft_length['Cessna T206 Turbo Stationair (piston-single)'] = 8.61 aircraft_length['Beechcraft Bonanza (33) (piston-single)'] = 7.65 aircraft_length['Beechcraft Super King Air 200 (twin-turboprop)'] = 13.31 aircraft_length['Beechcraft King Air 90 (twin-turboprop)'] = 10.82 aircraft_length['Pilatus PC-12 (single-turboprop)'] = 14.4 def CheckIfProcessRunning(): """Returns proc id if process with identically-named python file running; else None.""" this_process_id = os.getpid() this_process_name = os.path.basename(sys.argv[0]) for proc in psutil.process_iter(): try: # Check if process name contains this_process_name. commands = proc.as_dict(attrs=['cmdline', 'pid']) if commands['cmdline']: command_running = any( [this_process_name in s for s in commands['cmdline']]) if command_running and commands['pid'] != this_process_id: return commands['pid'] except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): pass return None def LogMessage(message, file=None): """Write a message to a logfile along with a timestamp. Args: message: string message to write file: string representing file name and, if needed, path to the file to write to """ # can't define as a default parameter because LOGFILE name is potentially # modified based on SIMULATION flag if not file: file = LOGFILE try: with open(file, 'a') as f: if not SIMULATION: # by excluding the timestamp, file diffs become easy between runs f.write('='*80+'\n') f.write(str(datetime.datetime.now(TZ))+'\n') f.write('\n') f.write(message+'\n') except IOError: LogMessage('Unable to append to ' + file) existing_log_lines = ReadFile(LOGFILE).splitlines() with open(ROLLING_LOGFILE, 'w') as f: f.write('\n'.join(existing_log_lines[-1000:])) def MaintainRollingWebLog(message, max_count, filename=None): """Maintains a rolling text file of at most max_count printed messages. Newest data at top and oldest data at the end, of at most max_count messages, where the delimiter between each message is identified by a special fixed string. Args: message: text message to prepend to the file. max_count: maximum number of messages to keep in the file; the max_count+1st message is deleted. filename: the file to update. """ # can't define as a default parameter because ROLLING_MESSAGE_FILE name is potentially # modified based on SIMULATION flag if not filename: filename = ROLLING_MESSAGE_FILE rolling_log_header = '='*(SPLITFLAP_CHARS_PER_LINE + 2) existing_file = ReadFile(filename) log_message_count = existing_file.count(rolling_log_header) if log_message_count >= max_count: message_start_list = [i for i in range(0, len(existing_file)) if existing_file[i:].startswith(rolling_log_header)] existing_file_to_keep = existing_file[:message_start_list[max_count - 1]] else: existing_file_to_keep = existing_file t = datetime.datetime.now(TZ).strftime('%m/%d/%Y, %H:%M:%S') new_message = ( '\n'.join([rolling_log_header, t, '', message]) + '\n' + existing_file_to_keep) try: with open(filename, 'w') as f: f.write(new_message) except IOError: LogMessage('Unable to maintain rolling log at ' + filename) def UtcToLocalTimeDifference(timezone=TIMEZONE): """Calculates number of seconds between UTC and given timezone. Returns number of seconds between UTC and given timezone; if no timezone given, uses TIMEZONE defined in global variable. Args: timezone: string representing a valid pytz timezone in pytz.all_timezones. Returns: Integer number of seconds. """ utcnow = pytz.timezone('utc').localize(datetime.datetime.utcnow()) home_time = utcnow.astimezone(pytz.timezone(timezone)).replace(tzinfo=None) system_time = utcnow.astimezone(tzlocal.get_localzone()).replace(tzinfo=None) offset = dateutil.relativedelta.relativedelta(home_time, system_time) offset_seconds = offset.hours * SECONDS_IN_HOUR <----SKIPPED LINES----> lambda3 = lambda1 + dlambda13 intersection = (degrees(phi3), degrees(lambda3)) return intersection def ConvertBearingToCompassDirection(bearing, length=3, pad=False): """Converts a bearing (in degrees) to a compass dir of 1, 2, or 3 chars (N, NW, NNW). Args: bearing: degrees to be converted length: if 1, 2, or 3, converts to one of 4, 8, or 16 headings: - 1: N, S, E, W - 2: SE, SW, etc. also valid - 3: NWN, ESE, etc. also valid pad: boolean indicating whether the direction should be right-justified to length characters Returns: String representation of the compass heading. """ if not isinstance(bearing, numbers.Number): return bearing divisions = 2**(length+1) # i.e.: 4, 8, or 16 division_size = 360 / divisions # i.e.: 90, 45, or 22.5 bearing_number = round(bearing / division_size) if length == 1: directions = DIRECTIONS_4 elif length == 2: directions = DIRECTIONS_8 else: directions = DIRECTIONS_16 direction = directions[bearing_number%divisions] if pad: direction = direction.rjust(length) return direction def HaversineDistanceMeters(pos1, pos2): """Calculate the distance between two points on a sphere (e.g. Earth). <----SKIPPED LINES----> pos2: a 2-tuple defining (lat, lon) in decimal degrees Returns: Distance between two points in meters. """ is_numeric = [isinstance(x, numbers.Number) for x in (*pos1, *pos2)] if False in is_numeric: return None lat1, lon1, lat2, lon2 = [math.radians(x) for x in (*pos1, *pos2)] hav = (math.sin((lat2 - lat1) / 2.0)**2 + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2.0)**2) distance = 2 * RADIUS * math.asin(math.sqrt(hav)) # Note: though pyproj has this, having trouble installing on rpi #az12, az21, distance = g.inv(lon1, lat1, lon2, lat2) return distance def SpeedInMeters(speed_in_knots): """Converts speed in knots to speed in meters per second.""" return speed_in_knots * METERS_PER_SECOND_IN_KNOTS def MetersTraveled(speed_in_knots, seconds): """Converts speed in knots to distance traveled in meters given an elapsed seconds.""" return SpeedInMeters(speed_in_knots) * seconds def ClosestKnownLocation(flight, seconds): """Using the path in the flight, returns the most recent location observations. Flights in the flight dictionary have their path maintained over all the time that the radio continues to observe the flight. This function identifies the closest in time observation in the path, given number of seconds after the canonical time (or before, if sec is negative). Args: flight: Flight dictionary of interest. seconds: Number of seconds after the canonical time of the flight (i.e.: now). Returns: Tuple: - Dictionary of location attributes including the following keys: speed, lat, lon, track, altitude, vertrate, now (which is a timestamp reflecting when these observations were made) - seconds in the past (as compared to the seconds requested) that this observation was made. That is, if a location at seconds=10 was requested, if the closest found location attributes were at time of 8 seconds, then this would be +2. Since the closest time is found, this can also be negative. Or alternatively, this can be thought of as the number of seconds still to project the movement for, where positive is the future. """ now = flight['now'] if 'persistent_path' not in flight: location = { 'speed': flight.get('speed'), 'lat': flight.get('lat'), 'lon': flight.get('lon'), 'track': flight.get('track'), 'altitude': flight.get('altitude'), 'vertrate': flight.get('vertrate'), 'now': now} return (location, seconds) path = flight['persistent_path'] path_timestamps = [p['now'] for p in path] absolute_deltas = [abs(seconds - (t - now)) for t in path_timestamps] min_delta = min(absolute_deltas) index = absolute_deltas.index(min_delta) closest_now_to_request = path[index]['now'] closest_observation = { 'speed': path[index].get('speed'), 'lat': path[index].get('lat'), 'lon': path[index].get('lon'), 'track': path[index].get('track'), 'altitude': path[index].get('altitude'), 'vertrate': path[index].get('vertrate'), 'now': closest_now_to_request} # i.e.: suppose: # now = 15000 # closest_to_now = 15008 # request seconds was for 10 # So there's still 2 more seconds to elapse until the flight is here time_delta_from_request = seconds - (closest_now_to_request - now) return (closest_observation, time_delta_from_request) def FlightAnglesSecondsElapsed(flight, seconds, key_suffix='', canonical_loc=False): """Returns angular position of flight given a certain amount of time elapsing from sight. As time elapses after the flight was first observed, it will be in a new position. That new position is based on the most up-to-date location details observed, as it may have been seen more recently than the original location details. Then, based on those most recent location details, we can estimate its new location at any given time by projecting the bearing, speed, etc. out in time. Args: flight: Flight dictionary of interest. seconds: Number of seconds after the canonical time of the flight (i.e.: now). key_suffix: Appended to the keys that are returned in the return dictionary. canonical_loc: Boolean indicating whether we should only examine the location details stored at seconds=0 in the path, which would be identical to that stored in the base dictionary itself. This provides access to the "original" reported loc details in the same format as the updated or more current values, primarily so that comparisons can be easily made between calculations that might fall back to the original values vs. the updated values. Returns: Dictionary of location attributes including the following keys: azimuth_degrees; altitude_degrees; ground_distance_feet; crow_distance_feet; lat; lon. """ seconds_ahead_to_find_loc = seconds if canonical_loc: seconds_ahead_to_find_loc = 0 (location, time_to_project) = ClosestKnownLocation(flight, seconds_ahead_to_find_loc) if not all([isinstance(x, numbers.Number) for x in ( location.get('speed'), location.get('lat'), location.get('lon'), location.get('track'), location.get('altitude'))]): return {} if canonical_loc: time_to_project = seconds meters_traveled = MetersTraveled(location['speed'], time_to_project) new_position = TrajectoryLatLon( (location['lat'], location['lon']), meters_traveled, location['track']) angles = Angles(HOME, HOME_ALT, new_position, location['altitude'] / FEET_IN_METER) d = {} for key in angles: d[key + key_suffix] = angles[key] d['lat' + key_suffix] = location['lat'] d['lon' + key_suffix] = location['lon'] return d def Angles(pos1, altitude1, pos2, altitude2): """Calculates the angular position of pos 2 from pos 1. Calculates the azimuth and the angular altitude to see point 2 from point 1, as well as two distance metrics: the "ground distance" and "crow distance". Ground is the distance between a plumb line to sea level for the two points; crow also takes into account the difference in altitude or elevation, and is the distance a bird would have to fly to reach the second point from the first. Args: pos1: a 2-tuple of lat-lon for the first point (i.e.: HOME), in degrees. altitude1: height above sea level of pos1, in meters pos2: a 2-tuple of lat-lon for the first point (i.e.: the plane), in degrees. altitude2: height above sea level of pos2, in meters Returns: Dictionary of location attributes including the following keys: azimuth_degrees; altitude_degrees; ground_distance_feet; crow_distance_feet. """ sin = math.sin cos = math.cos atan2 = math.atan2 atan = math.atan sqrt = math.sqrt radians = math.radians degrees = math.degrees if not all([isinstance(x, numbers.Number) for x in ( *pos1, altitude1, *pos2, altitude2)]): return None distance = HaversineDistanceMeters(pos1, pos2) # from home to plumb line of plane lat1, lon1, lat2, lon2 = [radians(x) for x in (*pos1, *pos2)] d_lon = lon2 - lon1 # azimuth calc from https://www.omnicalculator.com/other/azimuth az = atan2((sin(d_lon)*cos(lat2)), (cos(lat1)*sin(lat2)-sin(lat1)*cos(lat2)*cos(d_lon))) az_degrees = degrees(az) altitude = altitude2 - altitude1 alt = atan(altitude / distance) alt_degrees = degrees(alt) crow_distance = sqrt(altitude**2 + distance**2) # from home to the plane return {'azimuth_degrees': az_degrees, 'altitude_degrees': alt_degrees, 'ground_distance_feet': distance, 'crow_distance_feet': crow_distance} #### NEED TO WORK OUT THESE UNITS - feet or meters? DEBUG def TrajectoryLatLon(pos, distance, track): """Calculates lat/lon a plane will be given its starting point and direction / speed. Args: pos: a 2-tuple of lat-lon for the flight, in degrees. distance: the distance, in meters, the flight is traveling from its current lat/lon. track: the track or bearing of the plane, in degrees. Returns: Updated lat/lon for the given trajectory. """ #distance in meters #track in degrees sin = math.sin cos = math.cos atan2 = math.atan2 asin = math.asin radians = math.radians degrees = math.degrees track = radians(track) lat1 = radians(pos[0]) lon1 = radians(pos[1]) d_div_R = distance/RADIUS lat2 = asin(sin(lat1)*cos(d_div_R) + cos(lat1)*sin(d_div_R)*cos(track)) lon2 = lon1 + atan2(sin(track)*sin(d_div_R)*cos(lat1), cos(d_div_R)-sin(lat1)*sin(lat2)) lat2_degrees = degrees(lat2) lon2_degrees = degrees(lon2) return (lat2_degrees, lon2_degrees) <----SKIPPED LINES----> is_numeric = [isinstance(x, numbers.Number) for x in (*pos, bearing)] if False in is_numeric: return None # To find the minimum distance, we must first find the point at which the minimum # distance will occur, which in turn is accomplished by finding the intersection # between that trajectory and a trajectory orthogonal (+90 degrees, or -90 degrees) # to it but intersecting HOME. potential_intersection1 = IntersectionForTwoPaths(pos, bearing, HOME, bearing + 90) potential_intersection2 = IntersectionForTwoPaths(pos, bearing, HOME, bearing - 90) potential_distance1 = HaversineDistanceMeters(potential_intersection1, HOME) potential_distance2 = HaversineDistanceMeters(potential_intersection2, HOME) # Since one of those two potential intersection points (i.e.: +90 or -90 degrees) will # create an irrational result, and given the strong locality to HOME that is expected # from the initial position, the "correct" result is identified by simply taking the # minimum distance of the two candidate. return min(potential_distance1, potential_distance2) def SecondsToHhMm(seconds, colon=False): """Converts integer number of seconds to xhym string (i.e.: 7h17m) or to 7:17. Args: seconds: number of seconds colon: controls format; if False, format is 7h17m; if True, format is 7:17. Returns: String representation of hours and minutes. """ minutes = int(abs(seconds) / SECONDS_IN_MINUTE) if minutes > MINUTES_IN_HOUR: hours = int(minutes / MINUTES_IN_HOUR) minutes = minutes % MINUTES_IN_HOUR if colon: text = str(hours) + ':' + str(minutes) else: text = str(hours) + 'h' + str(minutes) + 'm' else: if colon: text = ':' + str(minutes) else: text = str(minutes) + 'm' return text def SecondsToHours(seconds): """Converts integer number of seconds to xh string (i.e.: 7h). Args: seconds: number of seconds Returns: String representation of hours. """ minutes = int(abs(seconds) / SECONDS_IN_MINUTE) hours = round(minutes / MINUTES_IN_HOUR) return hours def SecondsToDdHh(seconds): """Converts integer number of seconds to xdyh string (i.e.: 7d17h). Args: seconds: number of seconds Returns: String representation of days and hours. """ days = int(abs(seconds) / SECONDS_IN_DAY) hours = SecondsToHours(seconds - days*SECONDS_IN_DAY) if hours == HOURS_IN_DAY: hours = 0 days += 1 text = '%dd%dh' % (days, hours) return text def HourString(flight): """Formats now on flight into a a 3-digit string like '12a' or ' 1p'.""" time_string = DisplayTime(flight) if time_string: hour_string = time_string[11:13] hour_0_23 = int(hour_string) is_pm = int(hour_0_23/12) == 1 hour_number = hour_0_23 % 12 if hour_number == 0: hour_number = 12 out_string = str(hour_number).rjust(2) if is_pm: out_string += 'p' else: out_string += 'a' else: out_string = KEY_NOT_PRESENT_STRING return out_string def HoursSinceMidnight(timezone=TIMEZONE): """Returns the float number of hours elapsed since midnight in the given timezone.""" tz = pytz.timezone(timezone) now = datetime.datetime.now(tz) seconds_since_midnight = ( now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() hours = seconds_since_midnight / SECONDS_IN_HOUR return hours def HoursSinceFlight(now, then): """Returns the number of hours between a timestamp and a flight. Args: now: timezone-aware datetime representation of timestamp then: epoch (float) Returns: Number of hours between now and then (i.e.: now - then; a positive return value means now occurred after then). """ then = datetime.datetime.fromtimestamp(then, TZ) delta = now - then delta_hours = delta.days * HOURS_IN_DAY + delta.seconds / SECONDS_IN_HOUR return delta_hours def DataHistoryHours(flights): """Calculates the number of hours between the earliest & last flight in data. flights: List of all flights in sequential order, so that the first in list is earliest in time. Returns: Return time difference in hours between the first flight and last flight. """ min_time = flights[0]['now'] max_time = flights[-1]['now'] delta_hours = (max_time - min_time) / SECONDS_IN_HOUR return round(delta_hours) def ReadFile(filename, log_exception=False): """Returns text from the given file name if available, empty string if not available. Args: filename: string of the filename to open, potentially also including the full path. log_exception: boolean indicating whether to log an exception if file not found. Returns: Return text string of file contents. """ try: with open(filename, 'r') as content_file: file_contents = content_file.read() except IOError: if log_exception: LogMessage('Unable to read '+filename) return '' return file_contents # because reading is ~25x more expensive than getmtime, we will only read & parse if # the getmtime is more recent than last call for this file. So this dict stores the # a tuple, the last time read & the resulting parsed return value CACHED_FILES = {} def ReadAndParseSettings(filename): """Reads given filename and then parses the resulting key-value pairs into a dict.""" global CACHED_FILES (last_read_time, settings) = CACHED_FILES.get(filename, (0, {})) if os.path.exists(filename): last_modified = os.path.getmtime(filename) if last_modified > last_read_time: setting_str = ReadFile(filename) settings = ParseSettings(setting_str) CACHED_FILES[filename] = (last_modified, settings) return settings # File does not - or at least no longer - exists; so remove the cache if filename in CACHED_FILES: CACHED_FILES.pop(filename) return {} def BuildSettings(d): """Converts a dict to a string of form key1=value1;...;keyn=valuen; keys alpha sorted.""" kv_pairs = [] for key in sorted(list(d.keys())): kv_pairs.append('%s=%s' % (key, d[key])) s = ';'.join(kv_pairs) return s def ParseSettings(settings): """Parse delimited string of settings in file to a dict of key value pairs. Parses a string like 'distance=1426;altitude=32559;on=23;off=24;delay=15;insights=all;' into key value pairs. Args: settings: semicolon-delimited sequence of equal-sign delimited key-value pairs, i.e.: key1=value1;key2=value2;....;keyn=valuen. Returns: Dict of key value pairs contained in the setting file; empty dict if file not available or if delimiters missing. """ settings_dict = {} for setting in settings.split(';'): if '=' in setting: kv_list = setting.split('=') k = kv_list[0] v = kv_list[1] if v.isdigit(): v = int(v) else: try: v = float(v) except ValueError: pass settings_dict[k] = v return settings_dict def WriteFile(filename, text, log_exception=False): """Writes the text to the file, returning boolean indicating success. Args: filename: string of the filename to open, potentially also including the full path. text: the text to write log_exception: boolean indicating whether to log an exception if file not found. Returns: Boolean indicating whether the write was successful. """ try: with open(filename, 'w') as content_file: content_file.write(text) except IOError: if log_exception: LogMessage('Unable to write to '+filename) return False return True def TruncatePickledDictionaries(file, days=30): """Truncate the pickled flights file to have at most some number of days of history. Delete old data from the given pickle repository; the data must be sequentially pickled dictionaries where each dictionary has (at minimum) a key named 'now', which is the timestamp of the record. Args: file: name (potentially including path) of the pickled file days: maximum number of days (measured from now) to include in the file Returns: Data remaining after any truncation. """ flights = UnpickleObjectFromFile(file) flights_passing_criteria = [] now = datetime.datetime.now(TZ) # We want to be cautious, not overwriting the original file until we're sure this has # completed; this is why we use the tmp file tmp_file = file+'.tmp' for flight in flights: if HoursSinceFlight(now, flight['now']) <= days*HOURS_IN_DAY: PickleObjectToFile(flight, tmp_file) flights_passing_criteria.append(flight) shutil.move(tmp_file, file) return flights_passing_criteria def UnpickleObjectFromFile(file): """Load a repository of pickled flight data into memory. Args: file: name (potentially including path) of the pickled file Returns: Return a list of all the flights, in the same sequence as written to the file. """ data = [] if os.path.exists(file): try: with open(file, 'rb') as f: while True: data.append(pickle.load(f)) except (EOFError, pickle.UnpicklingError): pass return data def PickleObjectToFile(data, file): """Append one pickled flight to the end of binary file. Args: data: data to pickle file: name (potentially including path) of the pickled file """ try: with open(file, 'ab') as f: f.write(pickle.dumps(data)) except IOError: LogMessage('Unable to append pickle ' + file) def UpdateAircraftList(persistent_nearby_aircraft, current_nearby_aircraft, now): """Identifies newly seen aircraft and removes aircraft that haven't been seen recently. Updates persistent_nearby_aircraft as follows: flights that have been last seen more than PERSISTENCE_SECONDS seconds ago are removed; new flights in current_nearby_aircraft are added. Also identifies newly-seen aircraft and updates the last-seen timestamp of flights that have been seen again. Args: persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values are the time the flight was last seen. current_nearby_aircraft: dictionary where keys are flight numbers, and the values are themselves dictionaries with key-value pairs about that flight, with at least one of the kv-pairs being the time the flight was seen. now: the timestamp of the flights in the current_nearby_aircraft. Returns: A list of newly-nearby flight numbers. """ newly_nearby_flight_numbers = [] for flight_number in current_nearby_aircraft: if flight_number not in persistent_nearby_aircraft: newly_nearby_flight_numbers.append(flight_number) persistent_nearby_aircraft[flight_number] = now flights_to_delete = [] for flight_number in persistent_nearby_aircraft: if (flight_number not in current_nearby_aircraft and (now - persistent_nearby_aircraft[flight_number]) > PERSISTENCE_SECONDS): flights_to_delete.append(flight_number) for flight_number in flights_to_delete: del persistent_nearby_aircraft[flight_number] return newly_nearby_flight_numbers def ScanForNewFlights(persistent_nearby_aircraft, persistent_path): """Determines if there are any new aircraft in the radio message. The radio is continuously dumping new json messages to the Raspberry pi with all the flights currently observed. This function picks up the latest radio json, and for any new nearby flights - there should generally be at most one new flight on each pass through - gets additional flight data from FlightAware and augments the flight definition with the relevant fields to keep. Args: persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values are the time the flight was last seen. persistent_path: dictionary where keys are flight numbers, and the values are a sequential list of the location-attributes in the json file; allows for tracking the flight path over time. Returns: A tuple: - updated persistent_nearby_aircraft - current_nearby_aircraft - (possibly empty) dictionary of flight attributes of the new flight upon its first observation. - the time of the radio observation if present; None if no radio dump - a dictionary of attributes about the dump itself (i.e.: # of flights; furthest observed flight, etc.) - persistent_path, a data structure containing past details of a flight's location as described in ParseDumpJson """ flight_details = {} now = time.time() if SIMULATION: (dump_json, json_time) = DUMP_JSONS[SIMULATION_COUNTER] else: dump_json = ReadFile(DUMP_JSON_FILE, log_exception=True) json_desc_dict = {} current_nearby_aircraft = {} if dump_json: (current_nearby_aircraft, now, json_desc_dict, persistent_path) = ParseDumpJson(dump_json, persistent_path) if not SIMULATION: PickleObjectToFile((dump_json, now), PICKLE_DUMP_JSON_FILE) newly_nearby_flight_numbers = UpdateAircraftList( persistent_nearby_aircraft, current_nearby_aircraft, now) if newly_nearby_flight_numbers: if len(newly_nearby_flight_numbers) > 1: newly_nearby_flight_numbers_str = ', '.join(newly_nearby_flight_numbers) newly_nearby_flight_details_str = '\n'.join( [str(current_nearby_aircraft[f]) for f in newly_nearby_flight_numbers]) LogMessage('Multiple newly-nearby flights: %s\n%s' % ( newly_nearby_flight_numbers_str, newly_nearby_flight_details_str)) flight_number = newly_nearby_flight_numbers[0] flight_aware_json = {} if SIMULATION: json_times = [j[1] for j in FA_JSONS] if json_time in json_times: flight_aware_json = FA_JSONS[json_times.index(json_time)][0] elif flight_number: flight_aware_json = GetFlightAwareJson(flight_number) if not flight_aware_json: LogMessage('No json returned from Flightaware for flight: %s' % flight_number) flight_details = {} if flight_aware_json: flight_details = ParseFlightAwareJson(flight_aware_json) if not SIMULATION: PickleObjectToFile((flight_aware_json, now), PICKLE_FA_JSON_FILE) # Augment FlightAware details with radio / radio-derived details flight_details.update(current_nearby_aircraft[flight_number]) # Augment with the past location data flight_details['persistent_path'] = persistent_path[flight_number][1] return ( persistent_nearby_aircraft, current_nearby_aircraft, flight_details, now, json_desc_dict, persistent_path) def DescribeDumpJson(parsed): """Generates a dictionary with descriptive attributes about the dump json file. Args: parsed: The parsed json file. Returns: Dictionary with attributes about radio range, number of flights seen, etc. """ json_desc_dict = {} json_desc_dict['now'] = parsed['now'] aircraft = [a for a in parsed['aircraft'] if a['seen'] < PERSISTENCE_SECONDS] json_desc_dict['radio_range_flights'] = len(aircraft) aircraft_with_pos = [a for a in aircraft if 'lat' in a and 'lon' in a] current_distances = [HaversineDistanceMeters( HOME, (a['lat'], a['lon'])) for a in aircraft_with_pos] current_distances = [ d * FEET_IN_METER / FEET_IN_MILE for d in current_distances if d is not None] if current_distances: json_desc_dict['radio_range_miles'] = max(current_distances) return json_desc_dict def ParseDumpJson(dump_json, persistent_path): """Identifies all airplanes within given distance of home from the dump1090 file. Since the dump1090 json will have messages from all flights that the antenna has picked up, we want to keep only flights that are within a relevant distance to us, and also to extract from the full set of data in the json to just the relevant fields for additional analysis. Args: dump_json: The text representation of the json message from dump1090-mutability persistent_path: dictionary where keys are flight numbers, and the values are a sequential list of the location-attributes in the json file; allows for tracking the flight path over time. Returns: Return tuple: - dictionary of all nearby planes, where keys are flight numbers (i.e.: 'SWA7543'), and the value is itself a dictionary of attributes. - time stamp in the json file. - dictionary of attributes about the radio range - persistent dictionary of the track of recent flights, where keys are the flight numbers and the value is a tuple, the first element being when the flight was last seen in this radio, and the second is a list of dictionaries with past location info from the radio where it's been seen, i.e.: d[flight] = (timestamp, [{}, {}, {}]) """ parsed = json.loads(dump_json) now = parsed['now'] nearby_aircraft = {} # Build dictionary summarizing characteristics of the dump_json itself json_desc_dict = DescribeDumpJson(parsed) for aircraft in parsed['aircraft']: simplified_aircraft = {} simplified_aircraft['now'] = now # flight_number flight_number = aircraft.get('flight') if flight_number: flight_number = flight_number.strip() if flight_number: simplified_aircraft['flight_number'] = flight_number if 'lat' in aircraft and 'lon' in aircraft: lat = aircraft['lat'] lon = aircraft['lon'] if isinstance(lat, numbers.Number) and isinstance(lon, numbers.Number): simplified_aircraft['lat'] = lat simplified_aircraft['lon'] = lon simplified_aircraft['altitude'] = aircraft.get('altitude') simplified_aircraft['speed'] = aircraft.get('speed') simplified_aircraft['vert_rate'] = aircraft.get('vert_rate') simplified_aircraft['squawk'] = aircraft.get('squawk') track = aircraft.get('track') if isinstance(track, numbers.Number): min_meters = MinMetersToHome((lat, lon), track) simplified_aircraft['track'] = track simplified_aircraft['min_feet'] = min_meters * FEET_IN_METER if HaversineDistanceMeters(HOME, (lat, lon)) < MIN_METERS: nearby_aircraft[flight_number] = simplified_aircraft # keep all that track info - once we start reporting on a nearby flight, it will # become part of the flight's persistent record. Also, note that as we are # building a list of tracks for each flight, and we are later assigning the # flight dictionary to point to the list, we just simply need to continue # updating this list to keep the dictionary up to date (i.e.: we don't need # to directly touch the flights dictionary in main). (last_seen, current_path) = persistent_path.get(flight_number, (None, [])) if simplified_aircraft not in current_path: current_path.append(simplified_aircraft) persistent_path[flight_number] = (now, current_path) # if the flight was last seen too far in the past, remove the track info for flight_number in list(persistent_path.keys()): (last_seen, current_path) = persistent_path[flight_number] if last_seen < now - PERSISTENCE_SECONDS: persistent_path.pop(flight_number) return (nearby_aircraft, now, json_desc_dict, persistent_path) def GetFlightAwareJson(flight_number): """Scrapes the text json message from FlightAware for a given flight number. Given a flight number, loads the corresponding FlightAware webpage for that flight and extracts the relevant script that contains all the flight details from that page. Args: flight_number: text flight number (i.e.: SWA1234) Returns: Text representation of the json message from FlightAware. """ url = 'https://flightaware.com/live/flight/' + flight_number try: response = requests.get(url) except requests.exceptions.RequestException as e: LogMessage('Unable to query FA for URL due to %s: %s' % (e, url)) return '' soup = bs4.BeautifulSoup(response.text, "html.parser") l = soup.findAll('script') flight_script = None for script in l: if "trackpollBootstrap" in script.text: flight_script = script.text break if not flight_script: LogMessage('Unable to find trackpollBootstrap script in page: ' + response.text) return '' first_open_curly_brace = flight_script.find('{') flight_json = flight_script[first_open_curly_brace:-1] return flight_json def Unidecode(s): """Convert a special unicode characters to closest ASCII representation.""" if s is not None: s = unidecode.unidecode(s) return s def ParseFlightAwareJson(flight_json): """Strips relevant data about the flight from FlightAware feed. The FlightAware json has hundreds of fields about a flight, only a fraction of which are relevant to extract. Note that some of the fields are inconsistently populated (i.e.: scheduled and actual times for departure and take-off). Args: flight_json: Text representation of the FlightAware json about a single flight. Returns: Dictionary of flight attributes extracted from the FlightAware json. """ flight = {} parsed_json = json.loads(flight_json) fa_flight_number = list(parsed_json['flights'].keys())[0] parsed_flight_details = parsed_json['flights'][fa_flight_number] flight['fa_flight_number'] = fa_flight_number origin = parsed_flight_details.get('origin') if origin: flight['origin_friendly'] = origin.get('friendlyLocation') flight['origin_iata'] = origin.get('iata') destination = parsed_flight_details.get('destination') if destination: flight['destination_friendly'] = destination.get('friendlyLocation') flight['destination_iata'] = destination.get('iata') aircraft_type = parsed_flight_details.get('aircraft') if aircraft_type: flight['aircraft_type_code'] = aircraft_type.get('type') flight['aircraft_type_friendly'] = aircraft_type.get('friendlyType') flight['owner_location'] = Unidecode(aircraft_type.get('ownerLocation')) flight['owner'] = Unidecode(aircraft_type.get('owner')) flight['tail'] = Unidecode(aircraft_type.get('tail')) takeoff_time = parsed_flight_details.get('takeoffTimes') if takeoff_time: flight['scheduled_takeofftime'] = takeoff_time.get('scheduled') flight['actual_takeoff_time'] = takeoff_time.get('actual') gate_departure_time = parsed_flight_details.get('gateDepartureTimes') if gate_departure_time: flight['scheduled_departure_time'] = gate_departure_time.get('scheduled') flight['actual_departure_time'] = gate_departure_time.get('actual') gate_arrival_time = parsed_flight_details.get('gateArrivalTimes') if gate_arrival_time: flight['scheduled_arrival_time'] = gate_arrival_time.get('scheduled') flight['estimated_arrival_time'] = gate_arrival_time.get('estimated') landing_time = parsed_flight_details.get('landingTimes') if landing_time: flight['scheduled_landing_time'] = landing_time.get('scheduled') flight['estimated_landing_time'] = landing_time.get('estimated') airline = parsed_flight_details.get('airline') if airline: flight['airline_call_sign'] = Unidecode(airline.get('callsign')) flight['airline_short_name'] = Unidecode(airline.get('shortName')) flight['airline_full_name'] = Unidecode(airline.get('fullName')) if len(parsed_json['flights'].keys()) > 1: LogMessage('There are multiple flights in the FlightAware json: ' + parsed_json) return flight def EpochDisplayTime(epoch, format_string='%Y-%m-%d %H:%M:%S.%f%z'): """Converts epoch in seconds to formatted time string.""" return datetime.datetime.fromtimestamp(epoch, TZ).strftime(format_string) def DisplayTime(flight, format_string='%Y-%m-%d %H:%M:%S.%f%z'): """Converts flight 'now' to formatted time string, caching results on flight.""" cached_key = CACHED_ELEMENT_PREFIX + 'now-' + format_string cached_time = flight.get(cached_key) if cached_time: return cached_time epoch_display_time = EpochDisplayTime(flight['now'], format_string=format_string) flight[cached_key] = epoch_display_time return epoch_display_time def DisplayAirline(flight): """Augments flight details with display-ready airline attributes. Args: flight: dictionary with key-value attributes about the flight. Returns: String identifying either the airline, or Unknown if not available. """ airline = flight.get('airline_short_name', flight.get('airline_full_name')) # Some names are very similar to others and so appear identical on splitflap replacement_names = ( ('Delta Private Jets', 'DPJ'), ('United Parcel Service', 'UPS')) for (old, new) in replacement_names: if airline and old.upper() == airline.upper(): airline = new break if not airline: airline = KEY_NOT_PRESENT_STRING return airline def DisplayAircraft(flight): """Provides a display-ready string about the aircraft used. Args: flight: dictionary with key-value attributes about the flight. Returns: Aircraft string if available; empty string otherwise. """ aircraft = flight.get('aircraft_type_friendly') if aircraft: aircraft = aircraft.replace('(twin-jet)', '(twin)') aircraft = aircraft.replace('(quad-jet)', '(quad)') aircraft = aircraft.replace('Regional Jet ', '') aircraft = aircraft[:SPLITFLAP_CHARS_PER_LINE] else: aircraft = '' return aircraft def DisplayFlightNumber(flight): """Generate a displayable string for flight number, falling back to SQUAWK.""" squawk = flight.get('squawk', '') flight_number = flight.get('flight_number') identifier = flight_number if not identifier and squawk: identifier = 'SQK ' + str(squawk) if not identifier: identifier = KEY_NOT_PRESENT_STRING return identifier def DisplayAirportCodeIata(flight, key): """Returns key if it is present and not evaluating to False; 'Unknown' otherwise.""" airport_code = flight.get(key) if not airport_code: airport_code = KEY_NOT_PRESENT_STRING return airport_code def DisplayOriginIata(flight): """Generates displayable string for origin airport code.""" return DisplayAirportCodeIata(flight, 'origin_iata') def DisplayDestinationIata(flight): """Generates displayable string for destination airport code.""" return DisplayAirportCodeIata(flight, 'destination_iata') def DisplayAirportCodeFriendly(flight, iata_key, friendly_key): """Generates displayable longer name of airport including city if available.""" airport = flight.get(iata_key) if not airport: return KEY_NOT_PRESENT_STRING if airport in KNOWN_AIRPORTS: return airport airport += ' ' + flight.get(friendly_key, '').split(',')[0] return airport def DisplayOriginFriendly(flight): """Generates displayable longer name of origin airport including city if available.""" return DisplayAirportCodeFriendly(flight, 'origin_iata', 'origin_friendly') def DisplayDestinationFriendly(flight): """Generates displayable longer name of dest airport including city if available.""" return DisplayAirportCodeFriendly(flight, 'destination_iata', 'destination_friendly') def DisplayOriginDestinationPair(flight): """Generates displayble origin-destination airport code mindful of screen width. If the origin or destination is among a few key airports where the IATA code is well-known, then we can display only that code. Otherwise, we'll want to display both the code and a longer description of the airport. But we need to be mindful of the overall length of the display. So, for instance, these might be produced as valid origin-destination pairs: SFO-CLT Charlotte <- Known origin Charlotte CLT-SFO <- Known destination Charl CLT-SAN San Diego <- Neither origin nor destination known Args: flight: dictionary with key-value attributes about the flight. Returns: String as described. """ origin_iata = DisplayOriginIata(flight) destination_iata = DisplayDestinationIata(flight) origin_friendly = DisplayOriginFriendly(flight) destination_friendly = DisplayDestinationFriendly(flight) max_pair_length = SPLITFLAP_CHARS_PER_LINE - len('-') if ( origin_iata not in KNOWN_AIRPORTS and destination_iata not in KNOWN_AIRPORTS and origin_iata != destination_iata): max_origin_length = int(max_pair_length/2) max_destination_length = max_pair_length - max_origin_length if ( len(origin_friendly) > max_origin_length and len(destination_friendly) > max_destination_length): origin_length = max_origin_length destination_length = max_destination_length elif len(origin_friendly) > max_origin_length: destination_length = len(destination_friendly) origin_length = max_pair_length - destination_length elif len(destination_friendly) > max_destination_length: origin_length = len(origin_friendly) destination_length = max_pair_length - origin_length else: origin_length = max_origin_length destination_length = max_destination_length elif origin_iata in KNOWN_AIRPORTS and destination_iata not in KNOWN_AIRPORTS: origin_length = len(origin_iata) destination_length = max_pair_length - origin_length elif destination_iata in KNOWN_AIRPORTS and origin_iata not in KNOWN_AIRPORTS: destination_length = len(destination_iata) origin_length = max_pair_length - destination_length elif destination_iata == origin_iata: origin_length = len(origin_iata) destination_length = max_pair_length - origin_length else: destination_length = len(destination_iata) origin_length = len(origin_iata) if origin_iata == KEY_NOT_PRESENT_STRING and destination_iata == KEY_NOT_PRESENT_STRING: origin_destination_pair = KEY_NOT_PRESENT_STRING else: origin_destination_pair = ( '%s-%s' % (origin_friendly[:origin_length], destination_friendly[:destination_length])) return origin_destination_pair def DisplayDepartureTimes(flight): """Generates displayable fields about the flight times including details about the delay. Attempts to first find matching "pairs" of flight departure time details (departure vs. takeoff) in the belief that aligned nomenclature in the source data reflects an aligned concept of time where a flight delay can be best calculated. Without a matching pair (or if perhaps no departure time information is provided), then a delay cannot be calculated at all. Args: flight: dictionary with key-value attributes about the flight. Returns: Dictionary with the following keys: - departure_timestamp: taken from one of potentially four timestamps indicating departure - departure_time_text: departure time formatted to HH:MM string - calculable_delay: boolean indicating whether sufficient data available to calc delay - delay_seconds: integer number of seconds of delay - delay_text: text of the format "7H16M early", where the descriptor early or late is abbreviated if needed to stay within the display width """ cached_key = CACHED_ELEMENT_PREFIX + 'departure_times' cached_value = flight.get(cached_key) if cached_value: return cached_value actual_departure = flight.get('actual_departure_time') scheduled_departure = flight.get('scheduled_departure_time') actual_takeoff_time = flight.get('actual_takeoff_time') scheduled_takeoff_time = flight.get('scheduled_takeofftime') calculable_delay = False scheduled = None delay_seconds = None delay_text = '' if actual_departure and scheduled_departure: actual = actual_departure scheduled = scheduled_departure departure_label = 'Dep' calculable_delay = True elif actual_takeoff_time and scheduled_takeoff_time: actual = actual_takeoff_time scheduled = scheduled_takeoff_time departure_label = 'T-O' calculable_delay = True elif actual_departure: actual = actual_departure departure_label = 'ADP' elif scheduled_departure: actual = scheduled_departure departure_label = 'SDP' elif actual_takeoff_time: actual = actual_takeoff_time departure_label = 'ATO' elif scheduled_takeoff_time: actual = scheduled_takeoff_time departure_label = 'STO' else: actual = 0 departure_time_text = 'Dep: Unknown' if actual: tz_corrected_actual = actual + UtcToLocalTimeDifference() departure_time_text = ' '.join([ departure_label, datetime.datetime.fromtimestamp(tz_corrected_actual).strftime('%I:%M')]) if calculable_delay: delay_seconds = actual - scheduled if int(delay_seconds / SECONDS_IN_MINUTE) == 0: delay_text = 'OT' elif delay_seconds < 0: delay_text = 'ER' else: delay_text = 'LT' else: delay_text = '' return_value = { 'departure_time_text': departure_time_text, 'calculable_delay': calculable_delay, 'delay_seconds': delay_seconds, 'delay_text': delay_text} flight[cached_key] = return_value return return_value def DisplaySecondsRemaining(flight): """Calculates the number of seconds of travel time left in a flight. Args: flight: dictionary with key-value attributes about the flight. Returns: Seconds, if the remaining time is calculable; None otherwise. """ arrival = flight.get('estimated_arrival_time') if not arrival: arrival = flight.get('estimated_landing_time') if not arrival: arrival = flight.get('scheduled_arrival_time') if not arrival: arrival = flight.get('scheduled_landing_time') if arrival: remaining_seconds = flight['now'] - arrival else: remaining_seconds = None return remaining_seconds def FlightMeetsDisplayCriteria(flight, configuration, display_all_hours=False): """Returns boolean indicating whether the screen is currently accepting new flight data. Based on the configuration file, determines whether the flight data should be displayed. Specifically, the configuration: - may include 'enabled' indicating whether screen should be driven at all - should include 'on' & 'off' parameters indicating minute (from midnight) of operation - should include altitude & elevation parameters indicating max values of interest Args: flight: dictionary of flight attributes. configuration: dictionary of configuration attributes. display_all_hours: a boolean indicating whether we should ignore whether the screen is turned off (either via the enabling, or via the hour settings) Returns: Boolean as described. """ flight_altitude = flight.get('altitude', float('inf')) config_max_altitude = configuration['setting_max_altitude'] flight_meets_criteria = True if flight_altitude > config_max_altitude: flight_meets_criteria = False else: flight_distance = flight.get('min_feet', float('inf')) config_max_distance = configuration['setting_max_distance'] if flight_distance > config_max_distance: flight_meets_criteria = False if not display_all_hours and flight_meets_criteria: flight_timestamp = flight['now'] dt = datetime.datetime.fromtimestamp(flight_timestamp, TZ) minute_of_day = dt.hour * MINUTES_IN_HOUR + dt.minute if ( minute_of_day < configuration['setting_on_time'] or minute_of_day > configuration['setting_off_time']): flight_meets_criteria = False if configuration.get('setting_screen_enabled', 'off') == 'off': print(configuration.get('setting_screen_enabled')) flight_meets_criteria = False return flight_meets_criteria def IdentifyFlightDisplayed(flights, configuration, display_all_hours=False): """Finds the most recent flight in flights that meet the display criteria. Args: flights: list of flight dictionaries. configuration: dictionary of settings. display_all_hours: boolean indicating whether we should ignore the time constraints (i.e.: whether the screen is enabled, and its turn-on or turn-off times) in identifying the most recent flight. That is, if False, then this will only return flights that would have been displayed in the ordinarily usage, vs. if True, a flight irrespective of the time it would be displayed. Returns: A flight dictionary if one can be found; None otherwise. """ for n in range(len(flights)-1, -1, -1): # traverse the flights in reverse if FlightMeetsDisplayCriteria( flights[n], configuration, display_all_hours=display_all_hours): return n return None def FlightMessageTestHarness( flights=None, display=True): """Simulates what flight messages might be displayed by replaying past flights.""" if flights is None: flights = UnpickleObjectFromFile(PICKLE_FLIGHTS_30D) messages = [] for flight in flights: flight_message = Screenify(CreateMessageAboutFlight(flight), False) if display: print(flight_message) messages.append(flight_message) return messages def CreateMessageAboutFlight(flight): """Creates a message to describe interesting attributes about a single flight. Generates a multi-line description of a flight. A typical message might look like: UAL300 - UNITED <- Flight number and airline BOEING 777-200 (TWIN) <- Aircraft type SFO-HNL HONOLULU <- Origin & destination DEP 02:08 ER REM 5:14 <- Time details: departure time; early / late / ontime; remaining 185MPH 301DEG D:117FT <- Trajectory details: speed; bearing; forecast min dist to HOME 1975FT (+2368FPM) <- Altitude details: current altitude & rate or ascent / descent However, not all of these details are always present, so some may be listed as unknown, or entire lines may be left out. Args: flight: dictionary of flight attributes. Returns: Printable string (with embedded new line characters) """ lines = [] # LINE1: UAL1425 - UNITED # ====================== flight_number = DisplayFlightNumber(flight) second_element = DisplayAirline(flight) if second_element == KEY_NOT_PRESENT_STRING: second_element = flight.get('owner', KEY_NOT_PRESENT_STRING) if second_element is None: second_element = KEY_NOT_PRESENT_STRING if flight_number == KEY_NOT_PRESENT_STRING and second_element == KEY_NOT_PRESENT_STRING: line = 'Unknown Flight' else: line = (flight_number + ' - ' + second_element)[:SPLITFLAP_CHARS_PER_LINE] lines.append(line) # LINE2: Boeing 737-800 (twin-jet) # ====================== aircraft_type = DisplayAircraft(flight) if aircraft_type: lines.append(aircraft_type) # LINE3: SFO-CLT Charlotte # Charlotte CLT-SFO # ====================== origin_destination_pair = DisplayOriginDestinationPair(flight) if origin_destination_pair: lines.append(origin_destination_pair) # LINE4: DEP 02:08 ER REM 5:14 # Dep: Unknown # ====================== departure_time_details = DisplayDepartureTimes(flight) line_elements = [] if departure_time_details: if departure_time_details.get('departure_time_text'): line_elements.append(departure_time_details['departure_time_text']) if departure_time_details.get('delay_text'): line_elements.append(departure_time_details['delay_text']) remaining_seconds = DisplaySecondsRemaining(flight) if remaining_seconds is not None: line_elements.append('Rem ' + SecondsToHhMm(remaining_seconds, colon=True)) if line_elements: lines.append(EvenlySpace(line_elements)) # LINE5: 123mph 297deg D:1383ft # ====================== speed = flight.get('speed') heading = flight.get('track') min_feet = flight.get('min_feet') line_elements = [] if speed is not None: line_elements.append(str(round(speed)) + SPEED_UNITS) if heading is not None: line_elements.append(str(heading) + u'\u00b0') # degrees deg unicode if min_feet is not None: line_elements.append('D:' + str(round(min_feet)) + DISTANCE_UNITS) if line_elements: lines.append(EvenlySpace(line_elements)) # LINE6: Alt: 12345ft +1234fpm # ====================== altitude = flight.get('altitude') vert_rate = flight.get('vert_rate') line_elements = [] if altitude: line_elements.append('Alt:%d%s' % (altitude, DISTANCE_UNITS)) if vert_rate: line_elements.append('%+d%s' % (vert_rate, CLIMB_RATE_UNITS)) if line_elements: lines.append(EvenlySpace(line_elements)) return lines def EvenlySpace(l): """Converts list to string with equal space between each element in list.""" if not l: return '' if len(l) == 1: return l[0] extra_space = SPLITFLAP_CHARS_PER_LINE - sum([len(str(s)) for s in l]) last_gap = round(extra_space / (len(l) - 1)) return EvenlySpace([*l[:-2], str(l[-2]) + ' '*last_gap + str(l[-1])]) def RemoveParentheticals(s): """Removes all instances of () - and the text contained within - from a string.""" if not s: return s if '(' in s and ')' in s: open_paren = s.find('(') close_paren = s.find(')') else: return s if close_paren < open_paren: return s s = s.replace(s[open_paren:close_paren+1], '').strip().replace(' ', ' ') return RemoveParentheticals(s) def Ordinal(n): """Converts integer n to an ordinal string - i.e.: 2 -> 2nd; 5 -> 5th.""" return '%d%s' % (n, 'tsnrhtdd'[(math.floor(n/10)%10 != 1)*(n%10 < 4)*n%10::4]) def Screenify(lines, splitflap): """Transforms a list of lines to a single text string either for printing or sending. Given a list of lines that is a fully-formed message to send to the splitflap display, this function transforms the list of strings to a single string that is an easier-to-read and more faithful representation of how the message will be displayed. The transformations are to add blank lines to the message to make it consistent number of lines, and to add border to the sides & top / bottom of the message. Args: lines: list of strings that comprise the message splitflap: boolean, True if directed for splitflap display; false if directed to screen Returns: String - which includes embedded new line characters, borders, etc. as described above, that can be printed to screen as the message. """ divider = '+' + '-'*SPLITFLAP_CHARS_PER_LINE + '+' border_character = '|' append_character = '\n' if splitflap: border_character = '' append_character = '' for unused_n in range(SPLITFLAP_LINE_COUNT-len(lines)): lines.append('') lines = [ border_character + line.ljust(SPLITFLAP_CHARS_PER_LINE).upper() + border_character for line in lines] if not splitflap: lines.insert(0, divider) lines.append(divider) return append_character.join(lines) def FlightInsightsTestHarness( flights=None, display=True, flight_insights_enabled_string='all'): """Simulates what insightful messages might be displayed by replaying past flights.""" if flights is None: flights = UnpickleObjectFromFile(PICKLE_FLIGHTS_30D) distribution = {} messages = [] for (n, flight) in enumerate(flights): flight_message = CreateMessageAboutFlight(flight) if display: print('='*25) print(flight_message) messages.append(flight_message) insights = CreateFlightInsights( flights[:n+1], flight_insights_enabled_string, distribution) #DEBUG FlightInsightNextFlight( flights[:n+1]) if display: for insight in insights: print(insight) messages.extend(insights) return messages def FlightInsightLastSeen(flights, days_ago=2): """Generates string indicating when flight was last seen. Generates text of the following form. - KAL214 was last seen 2d0h ago Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. days_ago: the minimum time difference for which a message should be generated - i.e.: many flights are daily, and so we are not necessarily interested to see about every daily flight that it was seen yesterday. However, more infrequent flights might be of interest. Returns: Printable string message; if no message or insights to generate, then an empty string. """ message = '' this_flight = flights[-1] this_flight_number = DisplayFlightNumber(this_flight) this_timestamp = flights[-1]['now'] last_seen = [f for f in flights[:-1] if DisplayFlightNumber(f) == this_flight_number] if last_seen and 'flight_number' in this_flight: last_timestamp = last_seen[-1]['now'] if this_timestamp - last_timestamp > days_ago*SECONDS_IN_DAY: message = '%s was last seen %s ago' % ( this_flight_number, SecondsToDdHh(this_timestamp - last_timestamp)) return message def FlightInsightDifferentAircraft(flights, percent_size_difference=0.1): """Generates string indicating changes in aircraft for the most recent flight. Generates text of the following form for the "focus" flight in the data. - Last time ASA1964 was seen on Mar 16, it was with a much larger plane (Airbus A320 (twin-jet) @ 123ft vs. Airbus A319 (twin-jet) @ 111ft) - Last time ASA743 was seen on Mar 19, it was with a different type of airpline (Boeing 737-900 (twin-jet) vs. Boeing 737-800 (twin-jet)) Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. percent_size_difference: the minimum size (i.e.: length) difference for the insight to warrant including the size details. Returns: Printable string message; if no message or insights to generate, then an empty string. """ message = '' this_flight = flights[-1] this_flight_number = DisplayFlightNumber(this_flight) last_seen = [f for f in flights[:-1] if DisplayFlightNumber(f) == this_flight_number] # Last time this same flight flew a materially different type of aircraft if last_seen and 'flight_number' in this_flight: last_flight = last_seen[-1] last_aircraft = last_flight.get('aircraft_type_friendly') last_aircraft_length = aircraft_length.get(last_aircraft, 0) this_aircraft = this_flight.get('aircraft_type_friendly') this_aircraft_length = aircraft_length.get(this_aircraft, 0) this_likely_commercial_flight = ( this_flight.get('origin_iata') and this_flight.get('destination_iata')) if this_likely_commercial_flight and this_aircraft and not this_aircraft_length: LogMessage('%s used in a flight with defined origin & destination but yet is ' 'missing length details' % this_aircraft, file=LOGFILE) likely_same_commercial_flight = ( last_flight.get('origin_iata') == this_flight.get('origin_iata') and last_flight.get('destination_iata') == this_flight.get('destination_iata') and last_flight.get('airline_call_sign') == this_flight.get('airline_call_sign')) this_aircraft_bigger = False last_aircraft_bigger = False if (likely_same_commercial_flight and this_aircraft_length > last_aircraft_length * (1 + percent_size_difference)): this_aircraft_bigger = True comparative_text = 'larger' elif (likely_same_commercial_flight and last_aircraft_length > this_aircraft_length * (1 + percent_size_difference)): last_aircraft_bigger = True comparative_text = 'smaller' last_flight_time_string = DisplayTime(last_flight, '%b %-d') if this_aircraft and last_aircraft: if this_aircraft_bigger or last_aircraft_bigger: message = ('%s used a %s plane today compared with last, on %s ' '(%s @ %dft vs. %s @ %dft)' % ( this_flight_number, comparative_text, last_flight_time_string, RemoveParentheticals(this_aircraft), this_aircraft_length*FEET_IN_METER, RemoveParentheticals(last_aircraft), last_aircraft_length*FEET_IN_METER)) elif last_aircraft and this_aircraft and last_aircraft != this_aircraft: message = ( '%s used a different aircraft today compared with last, on %s (%s vs. %s)' % ( this_flight_number, last_flight_time_string, this_aircraft, last_aircraft)) return message def FlightInsightNthFlight(flights, hours=1, min_multiple_flights=2): """Generates string about seeing many flights to the same destination in a short period. Generates text of the following form for the "focus" flight in the data. - ASA1337 was the 4th flight to PHX in the last 53 minutes, served by Alaska Airlines, American Airlines, Southwest and United - SWA3102 was the 2nd flight to SAN in the last 25 minutes, both with Southwest Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. hours: the time horizon over which to look for flights with the same destination. min_multiple_flights: the minimum number of flights to that same destination to warrant generating an insight. Returns: Printable string message; if no message or insights to generate, then an empty string. """ message = '' this_flight = flights[-1] this_flight_number = this_flight.get('flight_number', 'This') this_destination = this_flight.get('destination_iata', '') this_airline = this_flight.get('airline_short_name', KEY_NOT_PRESENT_STRING) if not this_airline: this_airline = KEY_NOT_PRESENT_STRING # in case airline was stored as, say, '' this_timestamp = this_flight['now'] if this_destination and this_destination not in ['SFO', 'LAX']: similar_flights = [f for f in flights[:-1] if this_timestamp - f['now'] < SECONDS_IN_HOUR*hours and this_destination == f.get('destination_iata', '')] similar_flights_count = len(similar_flights) + 1 # +1 for this_flight similar_flights_airlines = list( {f.get('airline_short_name', KEY_NOT_PRESENT_STRING) for f in similar_flights}) same_airline = [this_airline] == similar_flights_airlines if similar_flights_count >= min_multiple_flights: n_minutes = ( (this_flight['now'] - similar_flights[0]['now']) / SECONDS_IN_MINUTE) message = ('%s was the %s flight to %s in the last %d minutes' % ( this_flight_number, Ordinal(similar_flights_count), this_destination, n_minutes)) if same_airline and similar_flights_count == 2: message += ', both with %s' % this_airline elif same_airline: message += ', all with %s' % this_airline else: similar_flights_airlines.append(this_airline) similar_flights_airlines.sort() message += ', served by %s and %s' % ( ', '.join(similar_flights_airlines[:-1]), similar_flights_airlines[-1]) return message def FlightInsightSuperlativeAttribute( flights, key, label, units, absolute_list, insight_min=True, insight_max=True, hours=24): """Generates string about a numeric attribute of the flight being an extreme value. Generates text of the following form for the "focus" flight in the data. - N5286C has the slowest groundspeed (113mph vs. 163mph) in last 24 hours - CKS828 has the highest altitude (40000ft vs. 16575ft) in last 24 hours Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. key: the key of the attribute of interest - i.e.: 'speed'. label: the human-readable string that should be displayed in the message - i.e.: 'groundspeed'. units: the string units that should be used to label the value of the key - i.e.: 'MPH'. absolute_list: a 2-tuple of strings that is used to label the min and the max - i.e.: ('lowest', 'highest'), or ('slowest', 'fastest'). insight_min: boolean indicating whether to generate an insight about the min value. insight_max: boolean indicating whether to generate an insight about the max value. hours: the time horizon over which to look for superlative flights. Returns: Printable string message; if no message or insights to generate, then an empty string. """ message = '' this_flight = flights[-1] this_flight_number = this_flight.get('flight_number', 'The last flight') first_timestamp = flights[0]['now'] last_timestamp = flights[-1]['now'] included_seconds = last_timestamp - first_timestamp if included_seconds > SECONDS_IN_HOUR * hours: relevant_flights = [ f for f in flights[:-1] if last_timestamp - f['now'] < SECONDS_IN_HOUR * hours] value_min = min( [f.get(key) for f in relevant_flights if isinstance(f.get(key), numbers.Number)]) value_max = max( [f.get(key) for f in relevant_flights if isinstance(f.get(key), numbers.Number)]) values_other = len( [1 for f in relevant_flights if isinstance(f.get(key), numbers.Number)]) this_value = this_flight.get(key) if this_value and values_other: superlative = True if ( isinstance(this_value, numbers.Number) and isinstance(value_max, numbers.Number) and this_value > value_max and insight_max): absolute_string = absolute_list[1] other_value = value_max elif ( isinstance(this_value, numbers.Number) and isinstance(value_min, numbers.Number) and this_value < value_min and insight_min): absolute_string = absolute_list[0] other_value = value_min else: superlative = False if superlative: message = '%s has the %s %s (%d%s vs. %d%s) in last %d hours' % ( this_flight_number, absolute_string, label, this_value, units, other_value, units, hours) return message def FlightInsightNextFlight(flights): """Generates string about estimated wait until next flight. Generates text of the following form for the "focus" flight in the data. - Last flight at 2:53a; avg wait is 1h58m & median is 42m, but could be as long as 8h43m, based on last 20 days Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. Returns: Printable string message; if no message because not enough history, then an empty string. """ msg = '' # m = min of day of this flight # find minute of day of prior flights st # -- that flight not seen in last 12 hrs # -- that min of day >= this this_flight = flights[-1] this_hour = int(DisplayTime(this_flight, '%-H')) this_minute = int(DisplayTime(this_flight, '%-M')) this_date = DisplayTime(this_flight, '%x') #DEBUG: need to further filter based on flightcriteria # Flights that we've already seen in the last few hours we do not expect to see # again for another few hours, so let's exclude them from the calculation exclude_flights_hours = 12 flight_numbers_seen_in_last_n_hours = [ f['flight_number'] for f in flights if f['now'] > this_flight['now'] - exclude_flights_hours*SECONDS_IN_HOUR and 'flight_number' in f] still_to_come_flights = [ f for f in flights[:-1] if f.get('flight_number') not in flight_numbers_seen_in_last_n_hours and this_date != DisplayTime(f, '%x')] minimum_minutes_next_flight = {} # min minutes to next flight by day for flight in still_to_come_flights: date = DisplayTime(flight, '%x') hour = int(DisplayTime(flight, '%-H')) minutes = int(DisplayTime(flight, '%-M')) minutes_after = (hour - this_hour) * MINUTES_IN_HOUR +(minutes - this_minute) if minutes_after < 0: minutes_after += MINUTES_IN_DAY minimum_minutes_next_flight[date] = min( minimum_minutes_next_flight.get(date, minutes_after), minutes_after) minutes = list(minimum_minutes_next_flight.values()) if len(minutes) > 1: average_seconds = (sum(minutes) / len(minutes)) * SECONDS_IN_MINUTE max_seconds = max(minutes) * SECONDS_IN_MINUTE median_seconds = statistics.median(minutes) * SECONDS_IN_MINUTE minimum_percent_diff = 0.5 median_different = ( median_seconds > average_seconds * (1 + minimum_percent_diff) or average_seconds > median_seconds * (1+ minimum_percent_diff)) median_text = '' if median_different: median_text = ' & median is %s' % SecondsToHhMm(median_seconds) msg = ('Last flight at %s; avg wait is %s%s, but could ' 'be as long as %s, based on last %d days' % ( DisplayTime(this_flight, '%-I:%M%p'), SecondsToHhMm(average_seconds), median_text, SecondsToHhMm(max_seconds), len(minutes))) return msg def PercentileScore(scores, value): """Returns the percentile that a particular value is in a list of numbers. Roughly inverts numpy.percentile. That is, numpy.percentile(scores_list, percentile) to get the value of the list that is at that percentile; PercentileScore(scores_list, value) will yield back approximately that percentile. If the value matches identical elements in the list, this function takes the average position of those identical values to compute a percentile. Thus, for some lists (i.e.: where there are lots of flights that have a 0 second delay, or a 100% delay frequency), you may not get a percentile of 0 or 100 even with values equal to the min or max element in the list. Args: scores: the list of numbers. value: the value for which we want to determine the percentile. Returns: Returns an integer percentile in the range [0, 100] inclusive. """ scores = sorted(list(scores)) count_values_below_score = len([1 for s in scores if s < value]) count_values_at_score = len([1 for s in scores if s == value]) percentile = (count_values_below_score + count_values_at_score / 2) / len(scores) return round(percentile*100) def FlightInsightGroupPercentile( flights, group_function, value_function, value_string_function, group_label, value_label, min_days=1, lookback_days=30, min_this_group_size=0, min_comparison_group_size=0, min_group_qty=0, percentile_low=float('-inf'), percentile_high=float('inf')): """Generates a string about extreme values of groups of flights. Generates text of the following form for the "focus" flight in the data. - flight SIA31 (n=7) has a delay frequency in the 95th %tile, with 100% of flights delayed an average of 6m over the last 4d1h - flight UAL300 (n=5) has a delay time in the 1st %tile, with an average delay of 0m over the last 4d5h Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. group_function: function that, when called with a flight, returns the grouping key. That is, for example, group_function(flight) = 'B739' value_function: function that, when called with a list of flights, returns the value to be used for the comparison to identify min / max. Typically, the count, but could also be a sum, standard deviation, etc. - for perhaps the greatest range in flight altitude. If the group does not have a valid value and so should be excluded from comparison - i.e.: average delay of a group of flights which did not have a calculable_delay on any flight, this function should return None. value_string_function: function that, when called with the two parameters flights and value, returns a string (inclusive of units and label) that should be displayed to describe the quantity. For instance, if value_function returns seconds, value_string_function could convert that to a string '3h5m'. Or if value_function returns an altitude range, value_string_function could return a string 'altitude range of 900ft (1100ft - 2000ft)'. group_label: string to identify the group type - i.e.: 'aircraft' or 'flight' in the examples above. value_label: string to identify the value - i.e.: 'flights' in the examples above, but might also be i.e.: longest *delay*, or other quantity descriptor. min_days: the minimum amount of history required to start generating insights about delays. lookback_days: the maximum amount of history which will be considered in generating insights about delays. min_this_group_size: even if this group has, say, the maximum average delay, if its a group of size 1, that is not necessarily very interesting. This sets the minimum group size for the focus flight. min_comparison_group_size: similarly, comparing the focus group to groups of size one does not necessarily produce a meaningful comparison; this sets to minimum size for the other groups. min_group_qty: when generating a percentile, if there are only 3 or 4 groups among which to generate a percentile (i.e.: only a handful of destinations have been seen so far, etc.) then it is not necessarily very interesting to generate a message; this sets the minimum quantity of groups necessary (including the focus group) to generate a message. percentile_low: number [0, 100] inclusive that indicates the percentile that the focus flight group must equal or be less than for the focus group to trigger an insight. percentile_high: number [0, 100] inclusive that indicates the percentile that the focus flight group must equal or be greater than for the focus group to trigger an insight. Returns: Printable string message; if no message or insights to generate, then an empty string. """ message = '' this_flight = flights[-1] first_timestamp = flights[0]['now'] last_timestamp = this_flight['now'] included_seconds = last_timestamp - first_timestamp if (included_seconds > SECONDS_IN_DAY * min_days and group_function(this_flight) != KEY_NOT_PRESENT_STRING): relevant_flights = [ f for f in flights if last_timestamp - f['now'] < SECONDS_IN_DAY * lookback_days] grouped_flights = {} for flight in relevant_flights: group = group_function(flight) grouping = grouped_flights.get(group, []) grouping.append(flight) grouped_flights[group] = grouping # we will exclude "UNKNOWN" since that is not a coherent group if KEY_NOT_PRESENT_STRING in grouped_flights: grouped_flights.pop(KEY_NOT_PRESENT_STRING) grouped_values = {g: value_function(grouped_flights[g]) for g in grouped_flights} this_group = group_function(relevant_flights[-1]) this_value = grouped_values[this_group] this_group_size = len(grouped_flights[this_group]) # we will exclude groups that are not big enough grouped_flights = { k: grouped_flights[k] for k in grouped_flights if len(grouped_flights[k]) > min_comparison_group_size or k == this_group} # Remove those for which no value could be calculated or which are too small grouped_values = { g: grouped_values[g] for g in grouped_values if grouped_values[g] is not None and g in grouped_flights} if this_value and len(grouped_values) > min_group_qty: time_horizon_string = ' over the last %s' % SecondsToDdHh( last_timestamp - relevant_flights[0]['now']) min_comparison_group_size_string = '' if min_comparison_group_size > 1: min_comparison_group_size_string = ' amongst those with >%d flights' % ( min_comparison_group_size - 1) # FLIGHT X (n=7) is has the Xth percentile of DELAYS, with an average delay of # 80 MINUTES this_percentile = PercentileScore(grouped_values.values(), this_value) if this_group_size > min_this_group_size and ( this_percentile <= percentile_low or this_percentile >= percentile_high): if False: #debug comparison cohorts print('Comparison cohorts for %s (%s)' % (group_label, str(this_group))) print('This percentile: %f; min: %f; max: %f' % ( this_percentile, percentile_low, percentile_high)) keys = list(grouped_values.keys()) values = [grouped_values[k] for k in keys] print(keys) print(values) (values, keys) = SortByValues(values, keys) for n, value in enumerate(values): print('%s: %f (group size: %d)' % ( keys[n], value, len(grouped_flights[keys[n]]))) def TrialMessage(): message = '%s %s (n=%d) has a %s in the %s %%tile, with %s%s%s' % ( group_label, this_group, this_group_size, value_label, Ordinal(this_percentile), value_string_function(grouped_flights[this_group], this_value), time_horizon_string, min_comparison_group_size_string) line_count = len(textwrap.wrap(message, width=SPLITFLAP_CHARS_PER_LINE)) return (line_count, message) (line_count, message) = TrialMessage() if line_count > SPLITFLAP_LINE_COUNT: min_comparison_group_size_string = '' (line_count, message) = TrialMessage() if line_count > SPLITFLAP_LINE_COUNT: time_horizon_string = '' (line_count, message) = TrialMessage() return message def FlightInsightSuperlativeGroup( flights, group_function, value_function, value_string_function, group_label, value_label, absolute_list, min_days=1, lookback_days=30, min_this_group_size=0, min_comparison_group_size=0, insight_min=True, insight_max=True): """Generates a string about extreme values of groups of flights. Generates text of the following form for the "focus" flight in the data. - aircraft B739 (n=7) is tied with B738 and A303 for the most flights at 7 flights over the last 3d7h amongst aircraft with a least 5 flights - aircraft B739 (n=7) is tied with 17 others for the most flights at 7 flights over the last 3d7h amongst aircraft with a least 5 flights - flight UAL1075 (n=12) has the most flights with 12 flights; the next most flights is 11 flights over the last 7d5h Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. group_function: function that, when called with a flight, returns the grouping key. That is, for example, group_function(flight) = 'B739' value_function: function that, when called with a list of flights, returns the value to be used for the comparison to identify min / max. Typically, the count, but could also be a sum, standard deviation, etc. - for perhaps the greatest range in flight altitude. If the group does not have a valid value and so should be excluded from comparison - i.e.: average delay of a group of flights which did not have a calculable_delay on any flight, this function should return None. value_string_function: function that, when called with the two parameters flights and value, returns a string (inclusive of units and label) that should be displayed to describe the quantity. For instance, if value_function returns seconds, value_string_function could convert that to a string '3h5m'. Or if value_function returns an altitude range, value_string_function could return a string 'altitude range of 900ft (1100ft - 2000ft)'. group_label: string to identify the group type - i.e.: 'aircraft' or 'flight' in the examples above. value_label: string to identify the value - i.e.: 'flights' in the examples above, but might also be i.e.: longest *delay*, or other quantity descriptor. absolute_list: a 2-tuple of strings that is used to label the min and the max - i.e.: ('most', 'least'), or ('lowest average', 'highest average'). min_days: the minimum amount of history required to start generating insights about delays. lookback_days: the maximum amount of history which will be considered in generating insights about delays. min_this_group_size: even if this group has, say, the maximum average delay, if its a group of size 1, that is not necessarily very interesting. This sets the minimum group size for the focus flight. min_comparison_group_size: similarly, comparing the focus group to groups of size one does not necessarily produce a meaningful comparison; this sets to minimum size for the other groups. insight_min: boolean indicating whether to possibly generate insight based on the occurrence of the min value. insight_max: boolean indicating whether to possibly generate insight based on the occurrence of the max value. Returns: Printable string message; if no message or insights to generate, then an empty string. """ message = '' first_timestamp = flights[0]['now'] last_timestamp = flights[-1]['now'] included_seconds = last_timestamp - first_timestamp if included_seconds > SECONDS_IN_DAY * min_days: relevant_flights = [ f for f in flights if last_timestamp - f['now'] < SECONDS_IN_DAY * lookback_days] grouped_flights = {} for flight in relevant_flights: group = group_function(flight) grouping = grouped_flights.get(group, []) grouping.append(flight) grouped_flights[group] = grouping grouped_values = {g: value_function(grouped_flights[g]) for g in grouped_flights} this_group = group_function(relevant_flights[-1]) this_value = grouped_values[this_group] this_group_size = len(grouped_flights[this_group]) # we will exclude groups that are not big enough grouped_flights = { k: grouped_flights[k] for k in grouped_flights if len(grouped_flights[k]) > min_comparison_group_size} # Remove those for which no value could be calculated or which are too small grouped_values = { g: grouped_values[g] for g in grouped_values if grouped_values[g] is not None and g in grouped_flights} other_values = list(grouped_values.values()) if this_value in other_values: other_values.remove(this_value) if other_values: min_value = min(other_values) max_value = max(other_values) if this_value: if this_value > max_value and insight_max: superlative = True equality = False superlative_string = absolute_list[1] next_value = max_value elif this_value == max_value and insight_max: superlative = False equality = True superlative_string = absolute_list[1] elif this_value < min_value and insight_min: superlative = True equality = False superlative_string = absolute_list[0] next_value = min_value elif this_value == min_value and insight_min: superlative = False equality = True superlative_string = absolute_list[0] else: superlative = False equality = False time_horizon_string = SecondsToDdHh( last_timestamp - relevant_flights[0]['now']) min_comparison_group_size_string = '' if min_comparison_group_size > 1: min_comparison_group_size_string = ( ' amongst %s with at least %d flights' % (group_label, min_comparison_group_size)) # flight x (n=7) is tied with a, b, and c for the (longest average, shortest # average) delay at 80 minutes # flight x is tied with a, b, and c for the (most frequent, least frequent) # delay at 30% if equality and this_group_size > min_this_group_size: identical_groups = sorted([ str(g) for g in grouped_values if grouped_values[g] == this_value and g != this_group]) if len(identical_groups) > 4: identical_string = '%d others' % len(identical_groups) elif len(identical_groups) > 1: identical_string = ( '%s and %s' % (', '.join(identical_groups[:-1]), identical_groups[-1])) else: identical_string = str(identical_groups[0]) message = ( '%s %s (n=%d) is tied with %s for the %s %s at %s over the last %s%s' % ( group_label, this_group, this_group_size, identical_string, superlative_string, value_label, value_string_function(flights, this_value), time_horizon_string, min_comparison_group_size_string)) elif superlative and this_group_size > min_this_group_size: message = ( '%s %s (n=%d) has the %s %s with %s; the next ' '%s %s is %s over the last %s%s' % ( group_label, this_group, this_group_size, superlative_string, value_label, value_string_function(flights, this_value), superlative_string, value_label, value_string_function(flights, next_value), time_horizon_string, min_comparison_group_size_string)) return message def AverageDelay(flights): """Returns the average delay time for a list of flights. Args: flights: the list of the raw flight data. Returns: Average seconds of flight delay, calculated as the total seconds delayed amongst all the flights that have a positive delay, divided by the total number of flights that have a calculable delay. If no flights have a calculable delay, returns None. """ calculable_delay_seconds = [ DisplayDepartureTimes(f)['delay_seconds'] for f in flights if DisplayDepartureTimes(f)['calculable_delay'] and DisplayDepartureTimes(f)['delay_seconds'] > 0] average_delay = None if calculable_delay_seconds: average_delay = sum(calculable_delay_seconds) / len(calculable_delay_seconds) return average_delay def PercentDelay(flights): """Returns the percentage of flights that have a positive delay for a list of flights. Args: flights: the list of the raw flight data. Returns: Percentage of flights with a delay, calculated as the count of flights with a positive delay divided by the total number of flights that have a calculable delay. If no flights have a calculable delay, returns None. """ calculable_delay_seconds = [ DisplayDepartureTimes(f)['delay_seconds'] for f in flights if DisplayDepartureTimes(f)['calculable_delay']] delay_count = sum([1 for s in calculable_delay_seconds if s > 0]) percent_delay = None if calculable_delay_seconds: percent_delay = delay_count / len(calculable_delay_seconds) return percent_delay def FlightInsightFirstInstance( flights, key, label, days=7, additional_descriptor_fcn=''): """Generates string indicating the flight has the first instance of a particular key. Generates text of the following form for the "focus" flight in the data. - N311CG is the first time aircraft GLF6 (Gulfstream Aerospace Gulfstream G650 (twin-jet)) has been seen since at least 7d5h ago - PCM8679 is the first time airline Westair Industries has been seen since 9d0h ago Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. key: the key of the attribute of interest - i.e.: 'destination_iata'. label: the human-readable string that should be displayed in the message - i.e.: 'destination'. days: the minimum time of interest for an insight - i.e.: we probably see LAX every hour, but we are only interested in particular attributes that have not been seen for at least some number of days. Note, however, that the code will go back even further to find the last time that attribute was observed, or if never observed, indicating "at least". additional_descriptor_fcn: a function that, when passed a flight, returns an additional parenthetical notation to include about the attribute or flight observed - such as expanding the IATA airport code to its full name, etc. Returns: Printable string message; if no message or insights to generate, then an empty string. """ message = '' this_flight = flights[-1] this_flight_number = DisplayFlightNumber(this_flight) first_timestamp = flights[0]['now'] last_timestamp = flights[-1]['now'] included_seconds = last_timestamp - first_timestamp if included_seconds > SECONDS_IN_DAY * days: this_instance = this_flight.get(key) matching = [f for f in flights[:-1] if f.get(key) == this_instance] last_potential_observation_sec = included_seconds if matching: last_potential_observation_sec = last_timestamp - matching[-1]['now'] if this_instance and last_potential_observation_sec > SECONDS_IN_DAY * days: additional_descriptor = '' if additional_descriptor_fcn: additional_descriptor = ' (%s)' % additional_descriptor_fcn(this_flight) last_potential_observation_string = SecondsToDdHh(last_potential_observation_sec) if matching: message = '%s is the first time %s %s%s has been seen since %s ago' % ( this_flight_number, label, this_instance, additional_descriptor, last_potential_observation_string) else: message = '%s is the first time %s %s%s has been seen since at least %s ago' % ( this_flight_number, label, this_instance, additional_descriptor, last_potential_observation_string) return message def FlightInsightSuperlativeVertrate(flights, hours=24): """Generates string about the climb rate of the flight being an extreme value. Generates text of the following form for the "focus" flight in the data. - UAL631 has the fastest ascent rate (5248fpm, 64fpm faster than next fastest) in last 24 hours - CKS1820 has the fastest descent rate (-1152fpm, -1088fpm faster than next fastest) in last 24 hours While this is conceptually similar to the more generic FlightInsightSuperlativeVertrate function, vert_rate - because it can be either positive or negative, with different signs requiring different labeling and comparisons - it needs its own special handling. Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. hours: the time horizon over which to look for superlative flights. Returns: Printable string message; if no message or insights to generate, then an empty string. """ message = '' this_flight = flights[-1] this_flight_number = this_flight.get('flight_number') first_timestamp = flights[0]['now'] last_timestamp = flights[-1]['now'] sufficient_data = (last_timestamp - first_timestamp) > SECONDS_IN_HOUR * hours pinf = float('inf') ninf = float('-inf') if sufficient_data: relevant_flights = [ f for f in flights[:-1] if last_timestamp - f['now'] < SECONDS_IN_HOUR * hours] def AscentRate(f, default): vert_rate = f.get('vert_rate') if isinstance(vert_rate, numbers.Number) and vert_rate > 0: return vert_rate return default other_ascents = len([ 1 for f in relevant_flights if isinstance(f.get('vert_rate'), numbers.Number) and AscentRate(f, ninf) > 0]) if other_ascents: ascent_min = min( [AscentRate(f, pinf) for f in relevant_flights if AscentRate(f, ninf) > 0]) ascent_max = max( [AscentRate(f, ninf) for f in relevant_flights if AscentRate(f, ninf) > 0]) def DescentRate(f, default): vert_rate = f.get('vert_rate') if isinstance(vert_rate, numbers.Number) and vert_rate < 0: return vert_rate return default other_descents = len([ 1 for f in relevant_flights if isinstance(f.get('vert_rate'), numbers.Number) and DescentRate(f, pinf) < 0]) if other_descents: descent_min = min( [DescentRate(f, pinf) for f in relevant_flights if DescentRate(f, pinf) < 0]) descent_max = max( [DescentRate(f, ninf) for f in relevant_flights if DescentRate(f, pinf) < 0]) this_vert_rate = this_flight.get('vert_rate') if isinstance(this_vert_rate, numbers.Number): if this_vert_rate >= 0: this_ascent = this_vert_rate this_descent = None else: this_descent = this_vert_rate this_ascent = None if this_ascent and other_ascents and this_ascent > ascent_max: message = ('%s has the fastest ascent rate (%d%s, %d%s faster ' 'than next fastest) in last %d hours' % ( this_flight_number, this_ascent, CLIMB_RATE_UNITS, this_ascent - ascent_max, CLIMB_RATE_UNITS, hours)) elif this_ascent and other_ascents and this_ascent < ascent_min: message = ('%s has the slowest ascent rate (%d%s, %d%s slower ' 'than next slowest) in last %d hours' % ( this_flight_number, this_ascent, CLIMB_RATE_UNITS, ascent_min - this_ascent, CLIMB_RATE_UNITS, hours)) elif this_descent and other_descents and this_descent < descent_min: message = ('%s has the fastest descent rate (%d%s, %d%s faster ' 'than next fastest) in last %d hours' % ( this_flight_number, this_descent, CLIMB_RATE_UNITS, this_descent - descent_min, CLIMB_RATE_UNITS, hours)) elif this_descent and other_descents and this_descent > descent_max: message = ('%s has the slowest descent rate (%d%s, %d%s slower ' 'than next slowest) in last %d hours' % ( this_flight_number, this_descent, CLIMB_RATE_UNITS, descent_max - this_descent, CLIMB_RATE_UNITS, hours)) return message def FlightInsightDelays( flights, min_days=1, lookback_days=30, min_late_percentage=0.75, min_this_delay_minutes=0, min_average_delay_minutes=0): """Generates string about the delays this flight has seen in the past. Only if this flight has a caclculable delay itself, this will generate text of the following form for the "focus" flight in the data. - This 8m delay is the longest UAL1175 has seen in the last 9 days (avg delay is 4m); overall stats: 1 early; 9 late; 10 total - With todays delay of 7m, UAL1175 is delayed 88% of the time in the last 8 days for avg delay of 4m; overall stats: 1 early; 8 late; 9 total Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. min_days: the minimum amount of history required to start generating insights about delays. lookback_days: the maximum amount of history which will be considered in generating insights about delays. min_late_percentage: flights that are not very frequently delayed are not necessarily very interesting to generate insights about; this specifies the minimum percentage the flight must be late to generate a message that focuses on the on-time percentage. min_this_delay_minutes: a delay of 1 minute is not necessarily interesting; this specifies the minimum delay time this instance of the flight must be late to generate a message that focuses on this flight's delay. min_average_delay_minutes: an average delay of only 1 minute, even if it happens every day, is not necessarily very interesting; this specifies the minimum average delay time to generate either type of delay message. Returns: Printable string message; if no message or insights to generate, then an empty string. """ message = '' this_flight = flights[-1] this_flight_number = this_flight.get('flight_number', '') first_timestamp = flights[0]['now'] last_timestamp = flights[-1]['now'] included_seconds = last_timestamp - first_timestamp if (included_seconds > SECONDS_IN_DAY * min_days and DisplayDepartureTimes(this_flight)['calculable_delay']): this_delay_seconds = DisplayDepartureTimes(this_flight)['delay_seconds'] relevant_flights = [ f for f in flights if last_timestamp - f['now'] < SECONDS_IN_DAY * lookback_days and this_flight_number == f.get('flight_number', '')] if ( len(relevant_flights) > 1 and this_delay_seconds >= min_this_delay_minutes*SECONDS_IN_MINUTE): delay_seconds_list = [ DisplayDepartureTimes(f)['delay_seconds'] for f in relevant_flights if DisplayDepartureTimes(f)['calculable_delay']] delay_unknown_count = len(relevant_flights) - len(delay_seconds_list) delay_ontime_count = len([d for d in delay_seconds_list if not d]) delay_early_count = len([d for d in delay_seconds_list if d < 0]) delay_late_count = len([d for d in delay_seconds_list if d > 0]) delay_late_avg_sec = 0 delay_late_max_sec = 0 superlative = False if delay_late_count > 1: delay_late_avg_sec = sum( [d for d in delay_seconds_list if d > 0]) / delay_late_count # max / min excluding this flight delay_late_max_sec = max([d for d in delay_seconds_list[:-1] if d > 0]) delay_late_min_sec = min([d for d in delay_seconds_list[:-1] if d > 0]) if delay_late_max_sec > 0: if this_delay_seconds > delay_late_max_sec: delay_keyword = 'longest' superlative = True if this_delay_seconds < delay_late_min_sec: delay_keyword = 'shortest' superlative = True overall_stats_elements = [] if delay_early_count: overall_stats_elements.append('%d ER' % delay_early_count) if delay_ontime_count: overall_stats_elements.append('%d OT' % delay_ontime_count) if delay_late_count: overall_stats_elements.append('%d LT' % delay_late_count) if delay_unknown_count: overall_stats_elements.append('%d UNK' % delay_unknown_count) overall_stats_text = '; '.join(overall_stats_elements) days_history = (int( round(last_timestamp - relevant_flights[0]['now']) / SECONDS_IN_DAY) + 1) late_percentage = delay_late_count / len(relevant_flights) if (superlative and delay_late_avg_sec >= min_average_delay_minutes * SECONDS_IN_MINUTE): message = ( 'This %s delay is the %s %s has seen in the last %d days (avg delay is %s);' ' overall stats: %s' % ( SecondsToHhMm(this_delay_seconds), delay_keyword, this_flight_number, days_history, SecondsToHhMm(delay_late_avg_sec), overall_stats_text)) elif (late_percentage > min_late_percentage and delay_late_avg_sec >= min_average_delay_minutes * SECONDS_IN_MINUTE): # it's just been delayed frequently! message = ( 'With today''s delay of %s, %s is delayed %d%% of the time in the last %d ' 'days for avg delay of %s; overall stats: %s' % ( SecondsToHhMm(this_delay_seconds), this_flight_number, int(100 * late_percentage), days_history, SecondsToHhMm(delay_late_avg_sec), overall_stats_text)) return message def FlightInsights(flights): """Identifies all the insight messages about the most recently seen flight. Generates a possibly-empty list of messages about the flight. Args: flights: List of all flights where the last flight in the list is the focus flight for which we are trying to identify something interesting. Returns: List of 2-tuples, where the first element in the tuple is a flag indicating the type of insight message, and the second selement is the printable strings (with embedded new line characters) for something interesting about the flight; if there isn't anything interesting, returns an empty list. """ messages = [] def AppendMessageType(message_type, message): if message: messages.append((message_type, message)) # This flight number was last seen x days ago AppendMessageType(FLAG_INSIGHT_LAST_SEEN, FlightInsightLastSeen(flights, days_ago=2)) # Yesterday this same flight flew a materially different type of aircraft AppendMessageType( FLAG_INSIGHT_DIFF_AIRCRAFT, FlightInsightDifferentAircraft(flights, percent_size_difference=0.1)) # This is the 3rd flight to the same destination in the last hour AppendMessageType( FLAG_INSIGHT_NTH_FLIGHT, FlightInsightNthFlight(flights, hours=1, min_multiple_flights=2)) # This is the [lowest / highest] [speed / altitude / climbrate] in the last 24 hours AppendMessageType(FLAG_INSIGHT_GROUNDSPEED, FlightInsightSuperlativeAttribute( flights, 'speed', 'groundspeed', SPEED_UNITS, ['slowest', 'fastest'], hours=24)) AppendMessageType(FLAG_INSIGHT_ALTITUDE, FlightInsightSuperlativeAttribute( flights, 'altitude', 'altitude', DISTANCE_UNITS, ['lowest', 'highest'], hours=24)) AppendMessageType(FLAG_INSIGHT_VERTRATE, FlightInsightSuperlativeVertrate(flights)) # First instances: destination, first aircraft, etc. AppendMessageType(FLAG_INSIGHT_FIRST_DEST, FlightInsightFirstInstance( flights, 'destination_iata', 'destination', days=7, additional_descriptor_fcn=lambda f: f['destination_friendly'])) AppendMessageType(FLAG_INSIGHT_FIRST_ORIGIN, FlightInsightFirstInstance( flights, 'origin_iata', 'origin', days=7, additional_descriptor_fcn=lambda f: f['origin_friendly'])) AppendMessageType(FLAG_INSIGHT_FIRST_AIRLINE, FlightInsightFirstInstance( flights, 'airline_short_name', 'airline', days=7)) AppendMessageType(FLAG_INSIGHT_FIRST_AIRCRAFT, FlightInsightFirstInstance( flights, 'aircraft_type_code', 'aircraft', days=7, additional_descriptor_fcn=lambda f: f['aircraft_type_friendly'])) # This is the longest / shortest delay this flight has seen in the last 30 days at # 2h5m; including today, this flight has been delayed x of the last y times. AppendMessageType(FLAG_INSIGHT_LONGEST_DELAY, FlightInsightDelays( flights, min_late_percentage=0.75, min_this_delay_minutes=0, min_average_delay_minutes=0)) def DelayTimeAndFrequencyMessage( types_tuple, group_function, group_label, min_days=1, lookback_days=30, min_this_group_size=0, min_comparison_group_size=0, min_group_qty=0, percentile_low=float('-inf'), percentile_high=float('inf')): value_function_tuple = (PercentDelay, AverageDelay) value_string_function_tuple = ( lambda flights, value: '%d%% of flights delayed an average of %s' % ( round(value*100), SecondsToHhMm(AverageDelay(flights))), lambda flights, value: 'average delay of %s' % SecondsToHhMm(value)) value_label_tuple = ('delay frequency', 'delay time') for n in range(2): if types_tuple[n]: AppendMessageType(types_tuple[n], FlightInsightGroupPercentile( flights, group_function=group_function, value_function=value_function_tuple[n], value_string_function=value_string_function_tuple[n], group_label=group_label, value_label=value_label_tuple[n], min_days=min_days, min_this_group_size=min_this_group_size, min_comparison_group_size=min_comparison_group_size, min_group_qty=min_group_qty, lookback_days=lookback_days, percentile_low=percentile_low, percentile_high=percentile_high)) # flight UAL1 (n=5) has a delay frequency in the 72nd %tile, with 100% of flights # delayed an average of 44m over the last 4d13h DelayTimeAndFrequencyMessage( (FLAG_INSIGHT_FLIGHT_DELAY_FREQUENCY, FLAG_INSIGHT_FLIGHT_DELAY_TIME), group_function=lambda flight: flight.get('flight_number', KEY_NOT_PRESENT_STRING), group_label='flight', min_days=1, min_this_group_size=4, min_comparison_group_size=0, min_group_qty=0, lookback_days=30, percentile_low=10, percentile_high=90) # Airline United (n=5) has a delay frequency in the 72nd %tile, with 100% of flights # delayed an average of 44m over the last 4d13h DelayTimeAndFrequencyMessage( (FLAG_INSIGHT_AIRLINE_DELAY_FREQUENCY, FLAG_INSIGHT_AIRLINE_DELAY_TIME), group_function=DisplayAirline, group_label='airline', min_days=1, min_this_group_size=10, min_comparison_group_size=5, min_group_qty=5, lookback_days=30, percentile_low=10, percentile_high=80) # Destination LAX (n=5) has a delay frequency in the 72nd %tile, with 100% of flights # delayed an average of 44m over the last 4d13h DelayTimeAndFrequencyMessage( (FLAG_INSIGHT_DESTINATION_DELAY_FREQUENCY, FLAG_INSIGHT_DESTINATION_DELAY_TIME), group_function=DisplayDestinationFriendly, group_label='destination', min_days=1, min_this_group_size=10, min_comparison_group_size=5, min_group_qty=5, lookback_days=30, percentile_low=10, percentile_high=90) # we only want to do this if we're already at ~75% of the number of flights we'd # expect to see for the hour flight_hours = {} for flight in flights: if flights[-1]['now'] - flight['now'] < 8.5 * SECONDS_IN_DAY and DisplayTime( flight, format_string='%-I%p') == DisplayTime(flights[-1], format_string='%-I%p'): flight_hours[DisplayTime(flight, format_string='%-d')] = flight_hours.get( DisplayTime(flight, format_string='%-d'), 0) + 1 min_this_hour_flights = max(5, 0.75 * max(flight_hours.values())) # Once we've commented on the insights for an hour or day, we don't want to do it again hour_delay_frequency_flag = FLAG_INSIGHT_HOUR_DELAY_FREQUENCY hour_delay_time_flag = FLAG_INSIGHT_HOUR_DELAY_TIME date_delay_frequency_flag = FLAG_INSIGHT_DATE_DELAY_FREQUENCY date_delay_time_flag = FLAG_INSIGHT_DATE_DELAY_TIME for flight in flights[:-1]: insights = flight['insight_types'] this_hour = DisplayTime(flights[-1], format_string='%x %-I%p') this_day = DisplayTime(flights[-1], format_string='%x') if (this_hour == DisplayTime(flight, format_string='%x %-I%p') and FLAG_INSIGHT_HOUR_DELAY_FREQUENCY in insights): hour_delay_frequency_flag = None if (this_hour == DisplayTime(flight, format_string='%x %-I%p') and FLAG_INSIGHT_HOUR_DELAY_TIME in insights): hour_delay_time_flag = None if (this_day == DisplayTime(flight, format_string='%x') and FLAG_INSIGHT_DATE_DELAY_FREQUENCY in insights): date_delay_frequency_flag = None if (this_day == DisplayTime(flight, format_string='%x') and FLAG_INSIGHT_DATE_DELAY_TIME in insights): date_delay_time_flag = None # 7a flights have a delay frequency in the 72nd %tile, with 100% of flights # delayed an average of 44m over the last 4d13h DelayTimeAndFrequencyMessage( (hour_delay_frequency_flag, hour_delay_time_flag), group_function=lambda f: DisplayTime(f, format_string='%-I%p') + ' hour', group_label='The', min_days=3, min_this_group_size=min_this_hour_flights, min_comparison_group_size=10, min_group_qty=10, lookback_days=7, percentile_low=10, percentile_high=90) # we only want to do this if we're already at ~75% of the number of flights we'd # expect to see for the day flight_days = {} for flight in flights: if flights[-1]['now'] - flight['now'] < 8.5 * SECONDS_IN_DAY: flight_days[DisplayTime(flight, format_string='%-d')] = flight_days.get( DisplayTime(flight, format_string='%-d'), 0) + 1 min_this_day_flights = max(40, 0.75 * max(flight_days.values())) # Today (31st) has a delay frequency in the 72nd %tile, with 100% of flights # delayed an average of 44m over the last 4d13h DelayTimeAndFrequencyMessage( (date_delay_frequency_flag, date_delay_time_flag), group_function=lambda f: '(' + Ordinal(int(DisplayTime(f, format_string='%-d'))) + ')', group_label='Today', min_days=7, min_this_group_size=min_this_day_flights, min_comparison_group_size=40, min_group_qty=7, lookback_days=28, percentile_low=10, percentile_high=90) messages = [ (t, textwrap.wrap(m, width=SPLITFLAP_CHARS_PER_LINE)) for (t, m) in messages] return messages def CreateFlightInsights( flights, flight_insights_enabled_string, insight_message_distribution): """Returns the desired quantity of flight insight messages. Though the function FlightInsights generates all possible insight messages about a flight, the user may have only wanted one. Depending on the setting of flight_insights_enabled_string, this function reduces the set of all insights by selecting the least-frequently reported type of insight message. In order to choose the least-frequently reported type, we need to keep track of what has been reported so far, which we do here in insight_message_distribution, and which we then update with each pass through this function. Args: flights: List of all flights where the last flight in the list is the focus flight for which we are trying to identify something interesting. flight_insights_enabled_string: string indicating how many insights are desired, which may be one of 'all', 'one', or 'hide'. insight_message_distribution: dictionary, where the keys are one of the flags indicating message type, and the values are how frequently that type of insight has been displayed in flights. The dictionary is updated in place. Returns: Possibly-empty list of messages - the list may be empty if there are no insights, or if the setting selected for flight_insights_enabled_string is neither all or one. The messages, if included, are printable strings (with embedded new line characters). """ naked_messages = [] this_flight_insights = [] if flight_insights_enabled_string not in ('all', 'one'): return naked_messages insight_messages = FlightInsights(flights) if flight_insights_enabled_string == 'all' and insight_messages: for (t, m) in insight_messages: insight_message_distribution[t] = insight_message_distribution.get(t, 0) + 1 this_flight_insights.append(t) naked_messages.append(m) if flight_insights_enabled_string == 'one' and insight_messages: types_of_messages = [t for (t, unused_m) in insight_messages] frequencies_of_insights = [ insight_message_distribution.get(t, 0) for t in types_of_messages] min_frequency = min(frequencies_of_insights) for t in sorted(types_of_messages): if insight_message_distribution.get(t, 0) == min_frequency: break insight_message_distribution[t] = insight_message_distribution.get(t, 0) + 1 for message_tuple in insight_messages: if message_tuple[0] == t: naked_messages.append(message_tuple[1]) this_flight_insights.append(t) break # Save the distribution displayed for this flight so we needn't regen it in future flights[-1]['insight_types'] = this_flight_insights return naked_messages def MessageboardHistogramsTestHarness( flights=None, hist_type='day_of_month', hist_history='30d', max_screens='all', summary=False): """Test harness to generate messageboard histograms.""" if flights is None: flights = UnpickleObjectFromFile(PICKLE_FLIGHTS_30D) messages = MessageboardHistograms( flights, hist_type, hist_history, max_screens, summary) for message in messages: print(message) return messages def FlightCriteriaHistogramPng( flights, max_distance_feet, max_altitude_feet, max_days, filename=HOURLY_IMAGE_FILE, last_max_distance_feet=None, last_max_altitude_feet=None): """Saves as a png file the histogram of the hourly flight data for the given filters. Generates a png histogram of the count of flights by hour that meet the specified criteria: max altitude, max distance, and within the last number of days. Also optionally generates as a separate data series in same chart a histogram with a different max altitude and distance. Saves this histogram to disk. Args: flights: list of the flights. max_distance_feet: max distance for which to include flights in the histogram. max_altitude_feet: max altitude for which to include flights in the histogram. max_days: maximum number of days as described. filename: file into which to save the csv. last_max_distance_feet: if provided, along with last_max_altitude_feet, generates a second data series with different criteria for distance and altitude, for which the histogram data will be plotted alongside the first series. last_max_altitude_feet: see above. """ (values, keys, unused_filtered_data) = GenerateHistogramData( flights, HourString, HOURS, hours=max_days*HOURS_IN_DAY, max_distance_feet=max_distance_feet, max_altitude_feet=max_altitude_feet, normalize_factor=max_days, exhaustive=True) comparison = last_max_distance_feet is not None and last_max_altitude_feet is not None if comparison: (last_values, unused_last_keys, unused_filtered_data) = GenerateHistogramData( flights, HourString, HOURS, hours=max_days*HOURS_IN_DAY, max_distance_feet=last_max_distance_feet, max_altitude_feet=last_max_altitude_feet, normalize_factor=max_days, exhaustive=True) x = numpy.arange(len(keys)) unused_fig, ax = matplotlib.pyplot.subplots() width = 0.35 ax.bar( x - width/2, values, width, label='Current - alt: %d; dist: %d' % (max_altitude_feet, max_distance_feet)) title = 'Daily Flights Expected: %d / day' % sum(values) if comparison: ax.bar( x + width/2, last_values, width, label='Prior - alt: %d; dist: %d' % ( last_max_altitude_feet, last_max_distance_feet)) title += ' (%+d)' % (round(sum(values) - sum(last_values))) ax.set_title(title) ax.set_ylabel('Average Observed Flights') if comparison: ax.legend() matplotlib.pyplot.xticks( x, keys, rotation='vertical', wrap=True, horizontalalignment='right', verticalalignment='center') matplotlib.pyplot.savefig(filename) matplotlib.pyplot.close() def GenerateHistogramData( data, keyfunction, sort_type, truncate=float('inf'), hours=float('inf'), max_distance_feet=float('inf'), max_altitude_feet=float('inf'), normalize_factor=0, exhaustive=False): """Generates sorted data for a histogram from a description of the flights. Given an iterable describing the flights, this function generates the label (or key), and the frequency (or value) from which a histogram can be rendered. Args: data: the iterable of the raw data from which the histogram will be generated; each element of the iterable is a dictionary, that contains at least the key 'now', and depending on other parameters, also potentially 'min_feet' amongst others. keyfunction: the function that determines how the key or label of the histogram should be generated; it is called for each element of the data iterable. For instance, to simply generate a histogram on the attribute 'heading', keyfunction would be lambda a: a['heading']. sort_type: determines how the keys (and the corresponding values) are sorted: 'key': the keys are sorted by a simple comparison operator between them, which sorts strings alphabetically and numbers numerically. 'value': the keys are sorted by a comparison between the values, which means that more frequency-occurring keys are listed first. list: if instead of the strings a list is passed, the keys are then sorted in the sequence enumerated in the list. This is useful for, say, ensuring that the days of the week (Tues, Wed, Thur, ...) are listed in sequence. Keys that are generated by keyfunction but that are not in the given list are sorted last (and then amongst those, alphabetically). truncate: integer indicating the maximum number of keys to return; if set to 0, or if set to a value larger than the number of keys, no truncation occurs. But if set to a value less than the number of keys, then the keys with the lowest frequency are combined into one key named OTHER_STRING so that the number of keys in the resulting histogram (together with OTHER_STRING) is equal to truncate. hours: integer indicating the number of hours of history to include. Flights with a calcd_display_time more than this many hours in the past are excluded from the histogram generation. Note that this is timezone aware, so that if the histogram data is generated on a machine with a different timezone than that that recorded the original data, the correct number of hours is still honored. max_distance_feet: number indicating the geo fence outside of which flights should be ignored for the purposes of including the flight data in the histogram. max_altitude_feet: number indicating the maximum altitude outside of which flights should be ignored for the purposes of including the flight data in the histogram. normalize_factor: divisor to apply to all the values, so that we can easily renormalize the histogram to display on a percentage or daily basis; if zero, no renormalization is applied. exhaustive: boolean only relevant if sort_type is a list, in which case, this ensures that the returned set of keys (and matching values) contains all the elements in the list, including potentially those with a frequency of zero, within the restrictions of truncate. Returns: 2-tuple of lists cut and sorted as indicated by parameters above: - list of values (or frequency) of the histogram elements - list of keys (or labels) of the histogram elements """ histogram_dict = {} filtered_data = [] # get timezone & now so that we can generate a timestamp for comparison just once if hours: now = datetime.datetime.now(TZ) for element in data: if ( element.get('min_feet', float('inf')) <= max_distance_feet and element.get('altitude', float('inf')) <= max_altitude_feet and HoursSinceFlight(now, element['now']) <= hours): filtered_data.append(element) key = keyfunction(element) if key is None or key == '': key = KEY_NOT_PRESENT_STRING if key in histogram_dict: histogram_dict[key] += 1 else: histogram_dict[key] = 1 values = list(histogram_dict.values()) keys = list(histogram_dict.keys()) if normalize_factor: values = [v / normalize_factor for v in values] sort_by_enumerated_list = isinstance(sort_type, list) if exhaustive and sort_by_enumerated_list: missing_keys = set(sort_type).difference(set(keys)) missing_values = [0 for unused_k in missing_keys] keys.extend(missing_keys) values.extend(missing_values) if keys: # filters could potentially have removed all data if not truncate or len(keys) <= truncate: if sort_by_enumerated_list: (values, keys) = SortByDefinedList(values, keys, sort_type) elif sort_type == 'value': (values, keys) = SortByValues(values, keys) else: (values, keys) = SortByKeys(values, keys) else: #Unknown might fall in the middle, and so shouldn't be truncated (values, keys) = SortByValues(values, keys, ignore_sort_at_end_strings=True) truncated_values = list(values[:truncate-1]) truncated_keys = list(keys[:truncate-1]) other_value = sum(values[truncate-1:]) truncated_values.append(other_value) truncated_keys.append(OTHER_STRING) if sort_by_enumerated_list: (values, keys) = SortByDefinedList( truncated_values, truncated_keys, sort_type) elif sort_type == 'value': (values, keys) = SortByValues( truncated_values, truncated_keys, ignore_sort_at_end_strings=False) else: (values, keys) = SortByKeys(truncated_values, truncated_keys) else: values = [] keys = [] return (values, keys, filtered_data) def SortByValues(values, keys, ignore_sort_at_end_strings=False): """Sorts the list of values in descending sequence, applying same resorting to keys. Given a list of keys and values representing a histogram, returns two new lists that are sorted so that the values occur in descending sequence and the keys are moved around in the same way. This allows the printing of a histogram with the largest keys listed first - i.e.: top five airlines. Keys identified by SORT_AT_END_STRINGS - such as, perhaps, 'Other' - will optionally be placed at the end of the sequence. And where values are identical, the secondary sort is based on the keys. Args: values: list of values for the histogram to be used as the primary sort key. keys: list of keys for the histogram that will be moved in the same way as the values. ignore_sort_at_end_strings: boolean indicating whether specially-defined keys will be sorted at the end. <----SKIPPED LINES----> keyfunction, sort_type, title, position=None, truncate=0, hours=float('inf'), max_distance_feet=float('inf'), max_altitude_feet=float('inf'), normalize_factor=0, exhaustive=False, figsize_inches=(9, 6)): """Creates matplotlib.pyplot of histogram that can then be saved or printed. Args: data: the iterable (i.e.: list) of flight details, where each element in the list is a dictionary of the flight attributes. keyfunction: a function that when applied to a single flight (i.e.: keyfunction(data[0]) returns the key to be used for the histogram. data: the iterable of the raw data from which the histogram will be generated; each element of the iterable is a dictionary, that contains at least the key 'now', and depending on other parameters, also potentially 'min_feet' amongst others. keyfunction: the function that determines how the key or label of the histogram should be generated; it is called for each element of the data iterable. For instance, to simply generate a histogram on the attribute 'heading', keyfunction would be lambda a: a['heading']. title: the "base" title to include on the histogram; it will additionally be augmented with the details about the date range. position: Either a 3-digit integer or an iterable of three separate integers describing the position of the subplot. If the three integers are nrows, ncols, and index in order, the subplot will take the index position on a grid with nrows rows and ncols columns. index starts at 1 in the upper left corner and increases to the right. sort_type: determines how the keys (and the corresponding values) are sorted: 'key': the keys are sorted by a simple comparison operator between them, which sorts strings alphabetically and numbers numerically. 'value': the keys are sorted by a comparison between the values, which means that more frequency-occurring keys are listed first. list: if instead of the strings a list is passed, the keys are then sorted in the sequence enumerated in the list. This is useful for, say, ensuring that the days of the week (Tues, Wed, Thur, ...) are listed in sequence. Keys that are generated by keyfunction but that are not in the given list are <----SKIPPED LINES----> the list, including potentially those with a frequency of zero, within the restrictions of truncate. figsize_inches: a 2-tuple of width, height indicating the size of the histogram. """ (values, keys, filtered_data) = GenerateHistogramData( data, keyfunction, sort_type, truncate=truncate, hours=hours, max_distance_feet=max_distance_feet, max_altitude_feet=max_altitude_feet, normalize_factor=normalize_factor, exhaustive=exhaustive) if position: matplotlib.pyplot.subplot(*position) matplotlib.pyplot.figure(figsize=figsize_inches) values_coordinates = numpy.arange(len(keys)) matplotlib.pyplot.bar(values_coordinates, values) earliest_flight_time = int(filtered_data[0]['now']) last_flight_time = int(filtered_data[-1]['now']) date_range_string = ' (%d flights over last %s hours)' % ( sum(values), SecondsToDdHh(last_flight_time - earliest_flight_time)) matplotlib.pyplot.title(title + date_range_string) matplotlib.pyplot.subplots_adjust(bottom=0.15, left=0.09, right=0.99, top=0.92) matplotlib.pyplot.xticks( values_coordinates, keys, rotation='vertical', wrap=True, horizontalalignment='right', verticalalignment='center') def HistogramSettingsHours(how_much_history): """Extracts the desired history (in hours) from the histogram configuration string. Args: how_much_history: string from the histogram config file. Returns: Number of hours of history to include in the histogram. """ if how_much_history == 'today': hours = HoursSinceMidnight() elif how_much_history == '24h': hours = 24 elif how_much_history == '7d': hours = 7 * HOURS_IN_DAY elif how_much_history == '30d': hours = 30 * HOURS_IN_DAY else: <----SKIPPED LINES----> Args: max_screens: string from the histogram config file. Returns: Number of maximum number of screens to display for a splitflap histogram. """ if max_screens == '_1': screen_limit = 1 elif max_screens == '_2': screen_limit = 2 elif max_screens == '_5': screen_limit = 5 elif max_screens == 'all': screen_limit = 0 # no limit on screens else: LogMessage('Histogram form has invalid value for max_screens: %s' % max_screens) screen_limit = 1 return screen_limit def HistogramSettingsKeySortTitle(which, max_altitude=45000): """Provides the arguments necessary to generate a histogram from the config string. The same parameters are used to generate either a splitflap text or web-rendered histogram in terms of the histogram title, the keyfunction, and how to sort the keys. For a given histogram name (based on the names defined in the histogram config file), this provides those parameters. Args: which: string from the histogram config file indicating the histogram to provide settings for. max_altitude: indicates the maximum altitude that should be included on the altitude labels. Returns: A 3-tuple of the parameters used by either CreateSingleHistogramChart or MessageboardHistogram, of the keyfunction, sort, and title. """ def DivideAndFormat(dividend, divisor): if isinstance(dividend, numbers.Number): return '%2d' % round(dividend / divisor) return dividend[:2] if which == 'destination': key = lambda k: k.get('destination_iata', KEY_NOT_PRESENT_STRING) sort = 'value' title = 'Destination' elif which == 'origin': key = lambda k: k.get('origin_iata', KEY_NOT_PRESENT_STRING) sort = 'value' title = 'Origin' elif which == 'hour': key = lambda k: DisplayTime(k, '%H') sort = 'key' title = 'Hour' elif which == 'airline': key = DisplayAirline sort = 'value' title = 'Airline' elif which == 'aircraft': key = lambda k: k.get('aircraft_type_code', KEY_NOT_PRESENT_STRING) sort = 'value' title = 'Aircraft' elif which == 'altitude': key = lambda k: DivideAndFormat(k.get('altitude', KEY_NOT_PRESENT_STRING), 1000) sort = ['%2d'%x for x in range(0, round((max_altitude+1)/1000))] title = 'Altitude (1000ft)' elif which == 'bearing': key = lambda k: ConvertBearingToCompassDirection( k.get('track', KEY_NOT_PRESENT_STRING), pad=True, length=3) sort = [d.rjust(3) for d in DIRECTIONS_16] title = 'Bearing' elif which == 'distance': key = lambda k: DivideAndFormat(k.get('min_feet', KEY_NOT_PRESENT_STRING), 100) sort = ['%2d'%x for x in range(0, round((MIN_METERS*FEET_IN_METER)/100)+1)] title = 'Min Dist (100ft)' elif which == 'day_of_week': key = lambda k: DisplayTime(k, '%a') sort = DAYS_OF_WEEK title = 'Day of Week' elif which == 'day_of_month': key = lambda k: DisplayTime(k, '%-d').rjust(2) today_day = datetime.datetime.now(TZ).day days = list(range(today_day, 0, -1)) # today down to the first of the month days.extend(range(31, today_day, -1)) # 31st of the month down to day after today days = [str(d).rjust(2) for d in days] sort = days title = 'Day of Month' else: LogMessage( 'Histogram form has invalid value for which_histograms: %s' % which) return HistogramSettingsKeySortTitle( 'destination', max_altitude=max_altitude) return (key, sort, title) def ImageHistograms( flights, which_histograms, how_much_history, filename_prefix=HISTOGRAM_IMAGE_PREFIX, filename_suffix=HISTOGRAM_IMAGE_SUFFIX): """Generates multiple split histogram images. Args: flights: the iterable of the raw data from which the histogram will be generated; each element of the iterable is a dictionary, that contains at least the key 'now', and depending on other parameters, also potentially 'min_feet' amongst others. which_histograms: string paramater indicating which histogram(s) to generate, which can be either the special string 'all', or a string linked to a specific histogram. how_much_history: string parameter taking a value among ['today', '24h', '7d', '30d]. filename_prefix: this string indicates the file path and name prefix for the images that are created. File names are created in the form [prefix]name.[suffix], i.e.: if the prefix is histogram_ and the suffix is png, then the file name might be histogram_aircraft.png. filename_suffix: see above; also interpreted by savefig to generate the correct format. Returns: List of the names of the histograms generated. """ hours = HistogramSettingsHours(how_much_history) histograms_to_generate = [] if which_histograms in ['destination', 'all']: histograms_to_generate.append({'generate': 'destination'}) if which_histograms in ['origin', 'all']: <----SKIPPED LINES----> exhaustive=histogram.get('exhaustive', False)) filename = filename_prefix + histogram['generate'] + '.' + filename_suffix matplotlib.pyplot.savefig(filename) matplotlib.pyplot.close() histograms_generated = [h['generate'] for h in histograms_to_generate] return histograms_generated def MessageboardHistograms( flights, which_histograms, how_much_history, max_screens, data_summary): """Generates multiple split flap screen histograms. Args: flights: the iterable of the raw data from which the histogram will be generated; each element of the iterable is a dictionary, that contains at least the key 'now', and depending on other parameters, also potentially 'min_feet' amongst others. which_histograms: string paramater indicating which histogram(s) to generate, which can be either the special string 'all', or a string linked to a specific histogram. how_much_history: string parameter taking a value among ['today', '24h', '7d', '30d]. max_screens: string parameter taking a value among ['_1', '_2', '_5', or 'all']. data_summary: parameter that evaluates to a boolean indicating whether the data summary screen in the histogram should be displayed. Returns: Returns a list of printable strings (with embedded new line characters) representing the histogram, for each screen in the histogram. """ messages = [] hours = HistogramSettingsHours(how_much_history) screen_limit = HistogramSettingsScreens(max_screens) histograms_to_generate = [] if which_histograms in ['destination', 'all']: histograms_to_generate.append({ 'generate': 'destination', 'suppress_percent_sign': True, 'columns': 3}) if which_histograms in ['origin', 'all']: histograms_to_generate.append({ 'generate': 'origin', 'suppress_percent_sign': True, 'columns': 3}) if which_histograms in ['hour', 'all']: histograms_to_generate.append({ 'generate': 'hour', 'columns': 3, 'suppress_percent_sign': True, 'column_divider': '|'}) if which_histograms in ['airline', 'all']: histograms_to_generate.append({ 'generate': 'airline'}) if which_histograms in ['aircraft', 'all']: histograms_to_generate.append({ 'generate': 'aircraft'}) if which_histograms in ['altitude', 'all']: histograms_to_generate.append({ 'generate': 'altitude', 'columns': 3}) if which_histograms in ['bearing', 'all']: histograms_to_generate.append({ 'generate': 'bearing', 'suppress_percent_sign': True, 'columns': 3}) if which_histograms in ['distance', 'all']: histograms_to_generate.append({ 'generate': 'distance', 'columns': 3}) if ((which_histograms == 'all' and how_much_history == '7d') or which_histograms == 'day_of_week'): histograms_to_generate.append({ 'generate': 'day_of_week', 'columns': 3, 'absolute': True}) if ((which_histograms == 'all' and how_much_history == '30d') or which_histograms == 'day_of_month'): histograms_to_generate.append({ 'generate': 'day_of_month', 'columns': 3, 'suppress_percent_sign': True, 'column_divider': '|', 'absolute': True}) for histogram in histograms_to_generate: this_histogram = which_histograms if this_histogram == 'all': this_histogram = histogram['generate'] (key, sort, title) = HistogramSettingsKeySortTitle(this_histogram) histogram = MessageboardHistogram( flights, key, sort, title, screen_limit=screen_limit, columns=histogram.get('columns', 2), suppress_percent_sign=histogram.get('suppress_percent_sign', False), column_divider=histogram.get('column_divider', ' '), data_summary=data_summary, hours=hours, absolute=histogram.get('absolute', False)) messages.extend(histogram) return messages def MessageboardHistogram( data, keyfunction, sort_type, title, screen_limit=1, columns=2, column_divider=' ', data_summary=False, hours=0, suppress_percent_sign=False, absolute=False): """Generates a text representation of one histogram that can be rendered on the display. Args: data: the iterable of the raw data from which the histogram will be generated; each element of the iterable is a dictionary, that contains at least the key 'now', and depending on other parameters, also potentially 'min_feet' amongst others. keyfunction: the function that determines how the key or label of the histogram should be generated; it is called for each element of the data iterable. For instance, to simply generate a histogram on the attribute 'heading', keyfunction would be lambda a: a['heading']. sort_type: determines how the keys (and the corresponding values) are sorted; see GenerateHistogramData docstring for details title: string title, potentially truncated to fit, to be displayed for the histogram screen_limit: maximum number of screens to be displayed for the histogram; a value of zero is interpreted to mean no limit on screens. columns: number of columns of data to be displayed for the histogram; note that the keys of the histogram may need to be truncated in length to fit the display as more columns are squeezed into the space column_divider: string for the character(s) to be used to divide the columns data_summary: boolean indicating whether to augment the title with a second header line about the data presented in the histogram hours: integer indicating the oldest data to be included in the histogram suppress_percent_sign: boolean indicating whether to suppress the percent sign in the data (but to add it to the title) to reduce the amount of string truncation potentially necessary for display of the keys absolute: boolean indicating whether to values should be presented as percentage or totals; if True, suppress_percent_sign is irrelevant. Returns: Returns a list of printable strings (with embedded new line characters) representing the histogram. """ title_lines = 1 if data_summary: title_lines += 1 available_entries_per_screen = (SPLITFLAP_LINE_COUNT - title_lines) * columns available_entries_total = available_entries_per_screen * screen_limit (values, keys, unused_filtered_data) = GenerateHistogramData( data, keyfunction, sort_type, truncate=available_entries_total, hours=hours) screen_count = math.ceil(len(keys) / available_entries_per_screen) column_width = int( (SPLITFLAP_CHARS_PER_LINE - len(column_divider)*(columns - 1)) / columns) leftover_space = SPLITFLAP_CHARS_PER_LINE - ( column_width*columns + len(column_divider)*(columns - 1)) extra_divider_chars = math.floor(leftover_space / (columns - 1)) column_divider = column_divider.ljust(len(column_divider) + extra_divider_chars) # i.e.: ' 10%' or ' 10', depending on suppress_percent_sign printed_percent_sign = '' if absolute: digits = math.floor(math.log10(max(values))) + 1 value_size = digits + 1 augment_title_units = ' #' format_string = '%%%dd' % digits else: value_size = 3 augment_title_units = ' %' if not suppress_percent_sign: value_size += 1 printed_percent_sign = '%' augment_title_units = '' column_key_width = column_width - value_size total = sum(values) if data_summary: if hours: hours_of_data = min(hours, DataHistoryHours(data)) else: hours_of_data = DataHistoryHours(data) time_horizon_text = 'Last %s' % SecondsToDdHh(hours_of_data * SECONDS_IN_HOUR) summary_text = '%s (n=%d)' % (time_horizon_text, sum(values)) summary_text = summary_text.center(SPLITFLAP_CHARS_PER_LINE) split_flap_boards = [] for screen in range(screen_count): if screen_count == 1: counter = '' else: counter = ' %d/%d' % (screen+1, screen_count) screen_title = '%s%s%s' % ( title[:SPLITFLAP_CHARS_PER_LINE - len(counter) - len(augment_title_units)], augment_title_units, counter) screen_title = screen_title.center(SPLITFLAP_CHARS_PER_LINE) start_index = screen*available_entries_per_screen end_index = min((screen+1)*available_entries_per_screen-1, len(keys)-1) number_of_entries = end_index - start_index + 1 number_of_lines = math.ceil(number_of_entries / columns) lines = [] lines.append(screen_title.upper()) if data_summary: lines.append(summary_text.upper()) for line_index in range(number_of_lines): key_value = [] for column_index in range(columns): index = start_index + column_index*number_of_lines + line_index if index <= end_index: if absolute: value_string = format_string % values[index] else: # If the % is >=1%, display right-justified 2 digit percent, i.e. ' 5%' # Otherwise, if it rounds to at least 0.1%, display i.e. '.5%' if round(values[index]/total*100) >= 1: value_string = '%2d' % round(values[index]/total*100) elif round(values[index]/total*1000)/10 >= 0.1: value_string = ('%.1f' % (round(values[index]/total*1000)/10))[1:] else: value_string = ' 0' key_value.append('%s %s%s' % ( str(keys[index])[:column_key_width].ljust(column_key_width), value_string, printed_percent_sign)) line = (column_divider.join(key_value)).upper() lines.append(line) split_flap_boards.append(lines) return split_flap_boards def TriggerHistograms(flights, histogram_settings): """Triggers the text-based or web-based histograms. Based on the histogram settings, determines whether to generate text or image histograms (or both). For image histograms, also generates empty images for the histograms not created so that broken image links are not displayed in the webpage. Args: flights: List of flight attribute dictionaries. histogram_settings: Dictionary of histogram parameters. Returns: List of histogram messages, if text-based histograms are selected; empty list otherwise. """ histogram_messages = [] if histogram_settings['type'] in ('messageboard', 'both'): <----SKIPPED LINES----> histogram_settings['histogram_history'], histogram_settings['histogram_max_screens'], histogram_settings.get('histogram_data_summary', False)) if histogram_settings['type'] in ('images', 'both'): histograms_generated = ImageHistograms( flights, histogram_settings['histogram'], histogram_settings['histogram_history']) all_available_histograms = [ 'destination', 'origin', 'hour', 'airline', 'aircraft', 'altitude', 'bearing', 'distance', 'day_of_week', 'day_of_month'] for histogram in all_available_histograms: if histogram not in histograms_generated: missing_filename = ( HISTOGRAM_IMAGE_PREFIX + histogram + '.' + HISTOGRAM_IMAGE_SUFFIX) shutil.copyfile(HISTOGRAM_EMPTY_IMAGE_FILE, missing_filename) return histogram_messages def SaveFlightsByAltitudeDistanceCSV( flights, max_days=0, filename='flights_by_alt_dist.csv', precision=100): """Extracts hourly histogram into text file for a variety of altitudes and distances. Generates a csv with 26 columns: - col#1: altitude (in feet) - col#2: distance (in feet) - cols#3-26: hour of the day The first row is a header row; subsequent rows list the number of flights that have occurred in the last max_days with an altitude and min distance less than that identified in the first two columns. Each row increments elevation or altitude by precision feet, up to the max determined by the max altitude and max distance amongst all the flights. Args: flights: list of the flights. max_days: maximum number of days as described. filename: file into which to save the csv. precision: number of feet to increment the altitude or distance. """ max_altitude = int(round(max([flight.get('altitude', -1) for flight in flights]))) max_distance = int(round(max([flight.get('min_feet', -1) for flight in flights]))) min_altitude = int(round( min([flight.get('altitude', float('inf')) for flight in flights]))) min_distance = int(round( min([flight.get('min_feet', float('inf')) for flight in flights]))) max_hours = max_days * HOURS_IN_DAY lines = [] now = datetime.datetime.now() header_elements = ['altitude_feet', 'min_distance_feet', *[str(h) for h in HOURS]] line = ','.join(header_elements) lines.append(line) altitudes = list(range( precision * int(min_altitude / precision), precision * (int(max_altitude / precision) + 2), precision)) distances = list(range( precision * int(min_distance / precision), precision * (int(max_distance / precision) + 2), precision)) # Flight counts where either the altitude or min_feet is unknown line_elements = ['undefined', 'undefined'] for hour in HOURS: line_elements.append(str(len([ 1 for f in flights if (not max_hours or HoursSinceFlight(now, f['now']) < max_hours) and (f.get('altitude') is None or f.get('min_feet') is None) and HourString(f) == hour]))) line = ','.join(line_elements) lines.append(line) d = {} for flight in flights: if 'altitude' in flight and 'min_feet' in flight: this_altitude = flight['altitude'] this_distance = flight['min_feet'] hour = HourString(flight) for altitude in [a for a in altitudes if a >= this_altitude]: for distance in [d for d in distances if d >= this_distance]: d[(altitude, distance, hour)] = d.get((altitude, distance, hour), 0) + 1 for altitude in altitudes: for distance in distances: line_elements = [str(altitude), str(distance)] for hour in HOURS: line_elements.append(str(d.get((altitude, distance, hour), 0))) line = ','.join(line_elements) lines.append(line) try: with open(filename, 'w') as f: for line in lines: f.write(line+'\n') except IOError: LogMessage('Unable to write hourly histogram data file ' + filename) def SaveFlightsToCSV(flights=None, filename='flights.csv'): """Saves all the attributes about the flight to a CSV, including on-the-fly attributes. Args: flights: dictionary of flight attributes; if not provided, loaded from PICKLE_FLIGHTS_30D. filename: name of desired csv file; if not provided, defaults to flights.csv. """ if not flights: flights = UnpickleObjectFromFile(PICKLE_FLIGHTS_30D) print('='*80) print('Number of flights to save to %s: %d' % (filename, len(flights))) # list of functions in 2-tuple, where second element is a function that generates # something about the flight, and the first element is the name to give that value # when extended into the flight definition functions = [ ('display_flight_number', DisplayFlightNumber), ('display_airline', DisplayAirline), ('display_aircraft', DisplayAircraft), ('display_origin_iata', DisplayOriginIata), ('display_destination_iata', DisplayDestinationIata), ('display_origin_friendly', DisplayOriginFriendly), ('display_destination_friendly', DisplayDestinationFriendly), ('display_origin_destination_pair', DisplayOriginDestinationPair), ('display_seconds_remaining', DisplaySecondsRemaining), ('now_datetime', DisplayTime), ('now_date', lambda flight: DisplayTime(flight, '%x')), ('now_time', lambda flight: DisplayTime(flight, '%X'))] for function in functions: for flight in flights: flight[function[0]] = function[1](flight) # these functions return dictionary of values functions = [ lambda f: FlightAnglesSecondsElapsed(f, 0, '_00s'), lambda f: FlightAnglesSecondsElapsed(f, 10, '_10s'), lambda f: FlightAnglesSecondsElapsed(f, 20, '_20s'), DisplayDepartureTimes] for function in functions: for flight in flights: flight.update(function(flight)) all_keys = set() for f in flights: all_keys.update(f.keys()) all_keys = list(all_keys) all_keys.sort() keys_logical_order = [ 'now_date', 'now_time', 'now_datetime', 'now', 'flight_number', 'origin_iata', 'destination_iata', 'altitude', 'min_feet', 'vert_rate', 'speed', 'distance', 'delay_seconds', 'airline_call_sign', 'aircraft_type_friendly', 'azimuth_degrees_00s', 'azimuth_degrees_10s', 'azimuth_degrees_20s', 'altitude_degrees_00s', 'altitude_degrees_10s', 'altitude_degrees_20s', 'ground_distance_feet_00s', 'ground_distance_feet_10s', 'ground_distance_feet_20s', 'crow_distance_feet_00s', 'crow_distance_feet_10s', 'crow_distance_feet_20s'] for key in all_keys: if key not in keys_logical_order: keys_logical_order.append(key) f = open(filename, 'w') f.write(','.join(keys_logical_order)+'\n') for flight in flights: f.write(','.join(['"'+str(flight.get(k))+'"' for k in keys_logical_order])+'\n') f.close() def SimulationSetup(): """Updates global variable file names and loads in JSON data for simulation runs.""" global SIMULATION SIMULATION = True global DUMP_JSONS DUMP_JSONS = UnpickleObjectFromFile(PICKLE_DUMP_JSON_FILE) global FA_JSONS FA_JSONS = UnpickleObjectFromFile(PICKLE_FA_JSON_FILE) global ALL_MESSAGE_FILE ALL_MESSAGE_FILE = SIMULATION_PREFIX + ALL_MESSAGE_FILE if os.path.exists(ALL_MESSAGE_FILE): os.remove(ALL_MESSAGE_FILE) global LOGFILE LOGFILE = SIMULATION_PREFIX + LOGFILE if os.path.exists(LOGFILE): os.remove(LOGFILE) global ROLLING_LOGFILE ROLLING_LOGFILE = SIMULATION_PREFIX + ROLLING_LOGFILE if os.path.exists(ROLLING_LOGFILE): os.remove(ROLLING_LOGFILE) global ROLLING_MESSAGE_FILE ROLLING_MESSAGE_FILE = SIMULATION_PREFIX + ROLLING_MESSAGE_FILE if os.path.exists(ROLLING_MESSAGE_FILE): os.remove(ROLLING_MESSAGE_FILE) global PICKLE_FLIGHTS_30D PICKLE_FLIGHTS_30D = SIMULATION_PREFIX + PICKLE_FLIGHTS_30D if os.path.exists(PICKLE_FLIGHTS_30D): os.remove(PICKLE_FLIGHTS_30D) global PICKLE_FLIGHTS_ARCHIVE PICKLE_FLIGHTS_ARCHIVE = SIMULATION_PREFIX + PICKLE_FLIGHTS_ARCHIVE if os.path.exists(PICKLE_FLIGHTS_ARCHIVE): os.remove(PICKLE_FLIGHTS_ARCHIVE) if os.path.exists(ARDUINO_FILE): os.remove(ARDUINO_FILE) def SimulationEnd(message_queue, flights): """Clears message buffer, exercises histograms, and other misc test & status code. Args: message_queue: List of flight messages that have not yet been printed. flights: List of flights dictionaries. """ if flights: histogram = { 'type': 'both', 'histogram':'all', 'histogram_history':'30d', 'histogram_max_screens': '_2', 'histogram_data_summary': 'on'} histogram_messages = TriggerHistograms(flights, histogram) histogram_messages = [(FLAG_MSG_HISTOGRAM, m) for m in histogram_messages] message_queue.extend(histogram_messages) while message_queue: ManageMessageQueue(message_queue, 0, {'setting_delay': 0}) SaveFlightsByAltitudeDistanceCSV(flights) SaveFlightsToCSV(flights) # repickle to a new .pk with full track info file_parts = PICKLE_FLIGHTS_30D.split('.') new_pickle_file = '.'.join([file_parts[0] + '_full_path', file_parts[1]]) if os.path.exists(new_pickle_file): os.remove(new_pickle_file) for flight in flights: PickleObjectToFile(flight, new_pickle_file) print('Simulation complete after %s dump json messages processed' % len(DUMP_JSONS)) def DumpJsonChanges(): """Identifies if sequential dump json files changes, for simulation optimization. If we are logging the radio output faster than it is updating, then there will be sequential log files in the json list that are identical; we only need to process the first of these, and can ignore subsequent ones, without any change of output in the simulation results. This function identifies whether the current active json changed from the prior one. Returns: Boolean - True if different (and processing needed), False if identical """ if SIMULATION_COUNTER == 0: return True (this_json, unused_now) = DUMP_JSONS[SIMULATION_COUNTER] (last_json, unused_now) = DUMP_JSONS[SIMULATION_COUNTER - 1] return this_json != last_json def UpdateArduinoFile(flights, json_desc_dict): """Saves a file that can be read by arduino.py with necessary flight attributes. The independently-running arduino python modules need basic information about the flight and radio in order to send useful information to be displayed by the digital alphanumeric displays. Args: flights: list of the flight dictionaries. json_desc_dict: dict with details about the radio range and similar radio details. Returns: String that is also written to disk. """ # Start with radio_range_miles & radio_range_flights d = json_desc_dict if flights: flight = flights[-1] d['flight_number'] = DisplayFlightNumber(flight) d['flight_origin'] = DisplayOriginIata(flight) d['flight_destination'] = DisplayDestinationIata(flight) d['now'] = flight['now'] requested_time = time.time() requested_elapsed_seconds = requested_time - flight['now'] updated_loc = ClosestKnownLocation(flight, requested_elapsed_seconds) actual_elapsed_seconds = requested_elapsed_seconds - updated_loc[1] d['speed'] = updated_loc[0]['speed'] d['lat'] = updated_loc[0]['lat'] d['lon'] = updated_loc[0]['lon'] d['track'] = updated_loc[0]['track'] d['altitude'] = updated_loc[0]['altitude'] d['vertrate'] = updated_loc[0]['vertrate'] d['flight_loc_now'] = flight['now'] + actual_elapsed_seconds today = datetime.datetime.now(TZ).strftime('%x') flight_count_today = len([1 for f in flights if DisplayTime(f, '%x') == today]) d['flight_count_today'] = flight_count_today settings_string = BuildSettings(d) if os.path.exists(ARDUINO_FILE): existing_data = ReadAndParseSettings(ARDUINO_FILE) if d != existing_data: WriteFile(ARDUINO_FILE, settings_string) else: WriteFile(ARDUINO_FILE, settings_string) return settings_string def PublishMessage( s, subscription_id='12fd73cd-75ef-4cae-bbbf-29b2678692c1', key='c5f62d44-e30d-4c43-a43e-d4f65f4eb399', secret='b00aeb24-72f3-467c-aad2-82ba5e5266ca', timeout=3): """Publishes a text string to a Vestaboard. The message is pushed to the vestaboard splitflap display by way of its web services; see https://docs.vestaboard.com/introduction for more details. Args: s: String to publish. subscription_id: string subscription id from Vestaboard. key: string key from Vestaboard. secret: string secret from Vestaboard. timeout: Max duration in seconds that we should wait to establish a connection. """ # See https://docs.vestaboard.com/characters: any chars needing to be replaced special_characters = ((u'\u00b0', '{62}'),) # degree symbol '°' for special_character in special_characters: s = s.replace(*(special_character)) curl = pycurl.Curl() # See https://stackoverflow.com/questions/31826814/curl-post-request-into-pycurl-code # Set URL value curl.setopt( pycurl.URL, 'https://platform.vestaboard.com/subscriptions/%s/message' % subscription_id) curl.setopt(pycurl.HTTPHEADER, [ 'X-Vestaboard-Api-Key:%s' % key, 'X-Vestaboard-Api-Secret:%s' % secret]) curl.setopt(pycurl.TIMEOUT_MS, timeout*1000) curl.setopt(pycurl.POST, 1) curl.setopt(pycurl.WRITEFUNCTION, lambda x: None) # to keep stdout clean # preparing body the way pycurl.READDATA wants it body_as_dict = {'text': s} body_as_json_string = json.dumps(body_as_dict) # dict to json body_as_file_object = io.StringIO(body_as_json_string) # prepare and send. See also: pycurl.READFUNCTION to pass function instead curl.setopt(pycurl.READDATA, body_as_file_object) curl.setopt(pycurl.POSTFIELDSIZE, len(body_as_json_string)) try: curl.perform() except pycurl.error as e: LogMessage('curl.perform() failed with message %s' % e) else: # you may want to check HTTP response code, e.g. status_code = curl.getinfo(pycurl.RESPONSE_CODE) if status_code != 200: LogMessage('Server returned HTTP status code %d for message %s' % (status_code, s)) curl.close() def ManageMessageQueue(message_queue, next_message_time, configuration): """Check time & if appropriate, display next message from queue. Args: message_queue: FIFO list of message tuples of (message type, message string). next_message_time: epoch at which next message should be displayed configuration: dictionary of configuration attributes. Returns: Next_message_time, potentially updated if a message has been displayed, or unchanged if no message was displayed. """ if message_queue and (time.time() >= next_message_time or SIMULATION): if SIMULATION: # drain the queue because the messages come so fast messages_to_display = list(message_queue) # passed by reference, so clear it out since we drained it to the display del message_queue[:] else: # display only one message, being mindful of the display timing messages_to_display = [message_queue.pop(0)] for message in messages_to_display: message_text = message[1] if isinstance(message_text, str): message_text = textwrap.wrap(message_text, width=SPLITFLAP_CHARS_PER_LINE) display_message = Screenify(message_text, False) LogMessage(display_message, file=ALL_MESSAGE_FILE) MaintainRollingWebLog(display_message, 25) if not SIMULATION: splitflap_message = Screenify(message_text, True) PublishMessage(splitflap_message) next_message_time = time.time() + configuration['setting_delay'] return next_message_time def BootstrapInsightList(filename_tuple=(PICKLE_FLIGHTS_30D, PICKLE_FLIGHTS_ARCHIVE)): """(Re)populate flight pickle files with flight insight distributions. The set of insights generated for each flight is created at the time the flight was first identified, and saved on the flight pickle. This saving allows the current running distribution to be recalculated very quickly, but it means that as code enabling new insights gets added, those historical distributions may not necessarily be considered correct. They are "correct" in the sense that that new insight was not available at the time that older flight was seen, but it is not correct in the sense that, because this new insight is starting out with an incidence in the historical data of zero, this new insight may be reported more frequently than desired until it "catches up". So this method replays the flight history with the latest insight code, regenerating the insight distribution for each flight. """ for filename in filename_tuple: print('Bootstrapping %s' % filename) configuration = ReadAndParseSettings(CONFIG_FILE) flights = [] tmp_filename = filename + 'tmp' if os.path.exists(tmp_filename): os.remove(tmp_filename) if os.path.exists(filename): mtime = os.path.getmtime(filename) flights = UnpickleObjectFromFile(filename) for (n, flight) in enumerate(flights): if n/25 == int(n/25): print(' - %d' % n) CreateFlightInsights(flights[:n+1], configuration.get('insights', 'hide'), {}) PickleObjectToFile(flight, tmp_filename) if mtime == os.path.getmtime(filename): shutil.move(tmp_filename, filename) else: print('Failed to bootstrap %s: file changed while in process' % filename) def main(): """Traffic cop between incoming radio flight messages, configuration, and messageboard. This is the main logic, checking for new flights, augmenting the radio signal with additional web-scraped data, and generating messages in a form presentable to the messageboard. """ LogMessage('Starting up') if '-s' in sys.argv: global SIMULATION_COUNTER SimulationSetup() already_running_id = CheckIfProcessRunning() if already_running_id: os.kill(already_running_id, signal.SIGKILL) configuration = ReadAndParseSettings(CONFIG_FILE) last_distance = configuration.get('distance') last_altitude = configuration.get('altitude') startup_time = time.time() json_desc_dict = {} # If we're displaying just a single insight message, we want it to be something # unique, to the extent possible; this dict holds a count of the diff types of messages # displayed so far insight_message_distribution = {} flights = [] if os.path.exists(PICKLE_FLIGHTS_30D): flights = TruncatePickledDictionaries(PICKLE_FLIGHTS_30D) # Clear the loaded flight of any cached data since code fixes may change # the values for some of those cached elements for flight in flights: for key in list(flight.keys()): if key[:len(CACHED_ELEMENT_PREFIX)] == CACHED_ELEMENT_PREFIX: flight.pop(key) # bootstrap the flight insights distribution for (n, flight) in enumerate(flights): if 'insight_types' in flight: distribution = flight['insight_types'] for key in distribution: insight_message_distribution[key] = ( insight_message_distribution.get(key, 0) + 1) # # - but if it doesn't exist, we still have a path forward else: CreateFlightInsights( flights[:n+1], configuration.get('insights', 'hide'), insight_message_distribution) # used in simulation to print the hour of simulation once per simulated hour prev_simulated_hour = '' persistent_nearby_aircraft = {} # key = flight number; value = last seen persistent_path = {} histogram = {} # Next up to print is element 0; this is a list of tuples: # Element#1: flag indicating the type of message that this is # Element#2: the message itself message_queue = [] next_message_time = time.time() # We repeat the loop every x seconds; this ensures that if the processing time is long, # we don't wait another x seconds after processing completes next_loop_time = time.time() # These files are read only if the version on disk has been modified more recently # than the last time it was read last_dump_json_timestamp = 0 LogMessage('Finishing initialization; starting radio polling loop') while not SIMULATION or SIMULATION_COUNTER < len(DUMP_JSONS): new_configuration = ReadAndParseSettings(CONFIG_FILE) if (new_configuration.get('setting_max_distance') != configuration.get('setting_max_distance') or new_configuration.get('setting_max_altitude') != configuration.get('setting_max_altitude')): last_distance = configuration.get('setting_max_distance') last_altitude = configuration.get('setting_max_altitude') FlightCriteriaHistogramPng( flights, new_configuration['setting_max_distance'], new_configuration['setting_max_altitude'], 7, last_max_distance_feet=last_distance, last_max_altitude_feet=last_altitude) configuration = new_configuration # if this is a SIMULATION, then process every diff dump. But if it isn't a simulation, # then only read & do related processing for the next dump if the last-modified # timestamp indicates the file has been updated since it was last read. tmp_timestamp = 0 if not SIMULATION: tmp_timestamp = os.path.getmtime(DUMP_JSON_FILE) if (SIMULATION and DumpJsonChanges()) or ( not SIMULATION and tmp_timestamp > last_dump_json_timestamp): last_dump_json_timestamp = tmp_timestamp # a command might request info about flight to be (re)displayed, irrespective of # whether the screen is on; if so, let's put that message at the front of the message # queue, and delete any subsequent messages in queue because presumably the button # was pushed either a) when the screen was off (so no messages in queue), or b) # because the screen was on, but the last flight details got lost after other screens if os.path.exists(LAST_FLIGHT_FILE): messageboard_flight_index = IdentifyFlightDisplayed( flights, configuration, display_all_hours=True) if messageboard_flight_index is not None: message_queue = [m for m in message_queue if m[0] != FLAG_MSG_INTERESTING] flight_message = CreateMessageAboutFlight(flights[messageboard_flight_index]) message_queue = [FLAG_MSG_FLIGHT, flight_message] next_message_time = time.time() os.remove(LAST_FLIGHT_FILE) (persistent_nearby_aircraft, unused_current_nearby_aircraft, flight, now, json_desc_dict, persistent_path) = ScanForNewFlights(persistent_nearby_aircraft, persistent_path) if flight: flights.append(flight) UpdateArduinoFile( flights, json_desc_dict) flight_meets_display_criteria = FlightMeetsDisplayCriteria(flight, configuration) if flight_meets_display_criteria: flight_message = (FLAG_MSG_FLIGHT, CreateMessageAboutFlight(flight)) # display the next message about this flight now! next_message_time = time.time() message_queue.insert(0, flight_message) # and delete any queued insight messages about other flights that have # not yet displayed, since a newer flight has taken precedence messages_to_delete = [m for m in message_queue if m[0] == FLAG_MSG_INTERESTING] if messages_to_delete: LogMessage( 'Deleting messages from queue due to new-found plane: %s' % messages_to_delete) message_queue = [m for m in message_queue if m[0] != FLAG_MSG_INTERESTING] # Though we also manage the message queue outside this conditional as well, # because it can take a half second to generate the flight insights, this allows # this message to start displaying on the board immediately, so it's up there # when it's most relevant next_message_time = ManageMessageQueue( message_queue, next_message_time, configuration) insight_messages = CreateFlightInsights( flights, configuration.get('insights'), insight_message_distribution) if configuration.get('next_flight', 'off') == 'on': next_flight_text = FlightInsightNextFlight(flights) if next_flight_text: insight_messages.insert(0, next_flight_text) insight_messages = [(FLAG_MSG_INTERESTING, m) for m in insight_messages] for insight_message in insight_messages: message_queue.insert(0, insight_message) else: # flight didn't meet display criteria flight['insight_types'] = [] PickleObjectToFile(flight, PICKLE_FLIGHTS_30D) PickleObjectToFile(flight, PICKLE_FLIGHTS_ARCHIVE) else: UpdateArduinoFile(flights, json_desc_dict) if SIMULATION: if now: simulated_hour = EpochDisplayTime(now, format_string='%Y-%m-%d %H:00%z') if simulated_hour != prev_simulated_hour: print(simulated_hour) prev_simulated_hour = simulated_hour histogram = ReadAndParseSettings(HISTOGRAM_CONFIG_FILE) if os.path.exists(HISTOGRAM_CONFIG_FILE): os.remove(HISTOGRAM_CONFIG_FILE) # We also need to make sure there are flights on which to generate a histogram! Why # might there not be any flights? Primarily during a simulation, if there's a # lingering histogram file at the time of history restart. if histogram and flights: histogram_messages = TriggerHistograms(flights, histogram) histogram_messages = [(FLAG_MSG_HISTOGRAM, m) for m in histogram_messages] message_queue.extend(histogram_messages) # check time & if appropriate, display next message from queue next_message_time = ManageMessageQueue(message_queue, next_message_time, configuration) # if we've been running a long time, and everything else is quiet, reboot running_hours = (time.time() - startup_time) / SECONDS_IN_HOUR if ( running_hours >= HOURS_IN_DAY and not message_queue and not json_desc_dict.get('radio_range_flights')): LogMessage('About to reboot after running for %.2f hours' % running_hours) os.system('sudo reboot') if not SIMULATION: time.sleep(max(0, next_loop_time - time.time())) next_loop_time = time.time() + LOOP_DELAY_SECONDS else: SIMULATION_COUNTER += 1 if SIMULATION: SimulationEnd(message_queue, flights) if __name__ == "__main__": if '-i' in sys.argv: BootstrapInsightList() else: main() |