01234567890123456789012345678901234567890123456789012345678901234567890123456789
3637383940414243444546474849505152535455565758596061626364656667686970717273747576 117118119120121122123124125126127128129130131132133134135136 137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 184185186187188189190191192193194195196197198199200201202203 241242243244245246247248249250251252253254255256257258259260 261262263264265266267268269270271 272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695 734735736737738739740741742743744745746747748749750751752753 754755756757758759760761762763764765766767768769770771772773 892893894895896897898899900901902903904905906907908909910911 912913914915916917918919920921922923924925926927928929930931 10681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108 11331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173 12171218121912201221122212231224122512261227122812291230123112321233123412351236 1237 1238 1239 124012411242124312441245124612471248124912501251125212531254125512561257125812591260 127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314 13731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413 16581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701 17021703170417051706 17071708170917101711171217131714171517161717 1718 1719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767 1930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984 21242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164 2225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258 22592260226122622263226422652266226722682269227022712272227322742275227622772278 2286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313 231423152316231723182319232023212322232323242325 232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364 236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388 238923902391239223932394239523962397239823992400 24012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426 2427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454 24552456245724582459246024612462246324642465246624672468246924702471247224732474 24752476247724782479248024812482248324842485248624872488248924902491249224932494 27552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795 302130223023302430253026302730283029303030313032303330343035303630373038303930403041 30423043 30443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069 3070307130723073307430753076307730783079308030813082308330843085308630873088308930903091 3092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173 317431753176317731783179 3180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228 34213422342334243425342634273428342934303431343234333434343534363437343834393440 344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467 371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782 37833784378537863787378837893790 37913792379337943795379637973798379938003801380238033804380538063807380838093810 38123813381438153816381738183819382038213822382338243825382638273828382938303831 38323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866 38843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924 39873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027 41274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167 44804481448244834484448544864487448844894490449144924493449444954496449744984499 450045014502 45034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525 45614562456345644565456645674568456945704571457245734574457545764577457845794580 45814582458345844585458645874588458945904591459245934594459545964597459845994600 46244625462646274628462946304631463246334634463546364637463846394640464146424643 4644464546464647464846494650 46514652465346544655465646574658 46594660466146624663466446654666466746684669467046714672467346744675467646774678 4691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724 472547264727472847294730473147324733 4734 47354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772 47734774477547764777477847794780478147824783478447854786478747884789479047914792 479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845 4846484748484849485048514852485348544855485648574858485948604861 | <----SKIPPED LINES----> 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 <----SKIPPED LINES----> # 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 <----SKIPPED LINES----> 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. """ <----SKIPPED LINES----> 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 <----SKIPPED LINES----> 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: <----SKIPPED LINES----> 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: <----SKIPPED LINES----> 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, <----SKIPPED LINES----> 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) <----SKIPPED LINES----> for aircraft in parsed['aircraft']: simplified_aircraft = {} simplified_aircraft['now'] = now # flight_number flight_number = aircraft.get('flight') if flight_number: flight_number = flight_number.strip() if flight_number: simplified_aircraft['flight_number'] = flight_number if 'lat' in aircraft and 'lon' in aircraft: lat = aircraft['lat'] lon = aircraft['lon'] if isinstance(lat, numbers.Number) and isinstance(lon, numbers.Number): simplified_aircraft['lat'] = lat simplified_aircraft['lon'] = lon 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) <----SKIPPED LINES----> 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 <----SKIPPED LINES----> 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: <----SKIPPED LINES----> 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 <----SKIPPED LINES----> 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. <----SKIPPED LINES----> 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. <----SKIPPED LINES----> 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 <----SKIPPED LINES----> 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. <----SKIPPED LINES----> 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: <----SKIPPED LINES----> 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 <----SKIPPED LINES----> 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) <----SKIPPED LINES----> 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: LogMessage( 'Histogram form has invalid value for how_much_history: %s' % how_much_history) hours = 7 * HOURS_IN_DAY return hours def HistogramSettingsScreens(max_screens): """Extracts the desired number of text screens from the histogram configuration string. Args: max_screens: string from the histogram config file. Returns: Number of maximum number of screens to display for a splitflap histogram. """ if max_screens == '_1': screen_limit = 1 elif max_screens == '_2': screen_limit = 2 elif max_screens == '_5': screen_limit = 5 elif max_screens == 'all': screen_limit = 0 # no limit on screens else: LogMessage('Histogram form has invalid value for max_screens: %s' % max_screens) screen_limit = 1 return screen_limit def HistogramSettingsKeySortTitle(which, 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' <----SKIPPED LINES----> 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 <----SKIPPED LINES----> histograms_to_generate.append({'generate': 'hour'}) if which_histograms in ['airline', 'all']: histograms_to_generate.append({'generate': 'airline', 'truncate': int(TRUNCATE/2)}) if which_histograms in ['aircraft', 'all']: histograms_to_generate.append({'generate': 'aircraft'}) if which_histograms in ['altitude', 'all']: histograms_to_generate.append({'generate': 'altitude', 'exhaustive': True}) if which_histograms in ['bearing', 'all']: histograms_to_generate.append({'generate': 'bearing'}) if which_histograms in ['distance', 'all']: histograms_to_generate.append({'generate': 'distance', 'exhaustive': True}) if which_histograms in ['day_of_week', 'all']: histograms_to_generate.append({'generate': 'day_of_week'}) if which_histograms in ['day_of_month', 'all']: histograms_to_generate.append({'generate': 'day_of_month'}) for histogram in histograms_to_generate: this_histogram = which_histograms if this_histogram == 'all': this_histogram = histogram['generate'] (key, sort, title) = HistogramSettingsKeySortTitle(this_histogram) CreateSingleHistogramChart( flights, key, sort, title, truncate=histogram.get('truncate', TRUNCATE), hours=hours, exhaustive=histogram.get('exhaustive', False)) filename = filename_prefix + histogram['generate'] + '.' + filename_suffix matplotlib.pyplot.savefig(filename) matplotlib.pyplot.close() histograms_generated = [h['generate'] for h in histograms_to_generate] return histograms_generated def MessageboardHistograms( flights, which_histograms, <----SKIPPED LINES----> '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, <----SKIPPED LINES----> 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. <----SKIPPED LINES----> d['now'] = flight['now'] requested_time = time.time() requested_elapsed_seconds = requested_time - flight['now'] updated_loc = ClosestKnownLocation(flight, requested_elapsed_seconds) actual_elapsed_seconds = requested_elapsed_seconds - updated_loc[1] d['speed'] = updated_loc[0]['speed'] d['lat'] = updated_loc[0]['lat'] d['lon'] = updated_loc[0]['lon'] d['track'] = updated_loc[0]['track'] d['altitude'] = updated_loc[0]['altitude'] d['vertrate'] = updated_loc[0]['vertrate'] d['flight_loc_now'] = flight['now'] + actual_elapsed_seconds today = datetime.datetime.now(TZ).strftime('%x') flight_count_today = len([1 for f in flights if DisplayTime(f, '%x') == today]) d['flight_count_today'] = flight_count_today settings_string = BuildSettings(d) if 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. <----SKIPPED LINES----> 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 <----SKIPPED LINES----> 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()): <----SKIPPED LINES----> 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( <----SKIPPED LINES----> 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() |
01234567890123456789012345678901234567890123456789012345678901234567890123456789
3637383940414243444546474849505152535455565758596061626364656667686970717273747576 117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 664665666667668669670671672673674675676677678679680681682683 684685686687688689690691692693694695696697698699700701702703 742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783 902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943 10801081108210831084108510861087108810891090109110921093109410951096109710981099 11001101110211031104110511061107110811091110111111121113111411151116111711181119 11441145114611471148114911501151115211531154115511561157115811591160116111621163 11641165116611671168116911701171117211731174117511761177117811791180118111821183 122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280 129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334 13931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433 1678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741 1742 17431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789 17901791179217931794179517961797179817991800180118021803180418051806180718081809 19721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997 1998 199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022 21622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202 22632264226522662267226822692270227122722273227422752276227722782279228022812282 228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318 23262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351 235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576 28372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877 31033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203 3204320532063207320832093210321132123213321432153216 3217321832193220322132223223322432253226322732283229 323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283 32843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327 35203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572 3822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918 392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979 39973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037 41004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140 42404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280 459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619 46204621462246234624462546264627462846294630463146324633463446354636463746384639 46754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715 47394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818 48314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913 4914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944 49504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014 | <----SKIPPED LINES----> HOME_LAT = 37.64406 HOME_LON = -122.43463 HOME = (HOME_LAT, HOME_LON) # lat / lon tuple of antenna HOME_ALT = 29 #altitude in meters RADIUS = 6371.0e3 # radius of earth in meters FEET_IN_METER = 3.28084 FEET_IN_MILE = 5280 METERS_PER_SECOND_IN_KNOTS = 0.514444 MIN_METERS = 5000/FEET_IN_METER # only planes within this distance will be detailed # planes not seen within MIN_METERS in PERSISTENCE_SECONDS seconds will be dropped from # the nearby list PERSISTENCE_SECONDS = 10 TRUNCATE = 50 # max number of keys to include in a histogram image file # number of seconds to pause between each radio poll / command processing loop LOOP_DELAY_SECONDS = 1 # This file is where the radio drops its json file DUMP_JSON_FILE = '/run/readsb/aircraft.json' # This is the directory that stores all the ancillary messageboard configuration files # that do not need to be exposed via the webserver MESSAGEBOARD_PATH = '/home/pi/splitflap/' # This is the directory of the webserver; files placed here are available at # http://adsbx-custom.local/; files placed in this directly are visible via a browser WEBSERVER_PATH = '/var/www/html/' # At the time a flight is first identified as being of interest (in that it falls # within MIN_METERS meters of HOME), it - and core attributes derived from FlightAware, # if any - is appended to the end of this pickle file. However, since this file is # cached in working memory, flights older than 30 days are flushed from this periodically. PICKLE_FLIGHTS_30D = 'flights_30d.pk' #pickled list of up to about 30d of flights # This is the same concept as the 30d pickle file, except it is not auto-flushed, and # so will grow indefinitely. PICKLE_FLIGHTS_ARCHIVE = 'flights_archive.pk' #pickled list of all flights CACHED_ELEMENT_PREFIX = 'cached_' # This web-exposed file is used for non-error messages that might highlight data or <----SKIPPED LINES----> # 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' STDERR_FILE = 'stderr.txt' BACKUP_FILE = 'backup.txt' SERVICE_VERIFICATION_FILE = 'service-verification.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 = 19 FLAG_INSIGHT_DATE_DELAY_TIME = 20 INSIGHT_TYPES = 21 #if running on raspberry, then need to prepend path to file names if psutil.sys.platform.title() == 'Linux': PICKLE_FLIGHTS_30D = MESSAGEBOARD_PATH + PICKLE_FLIGHTS_30D PICKLE_FLIGHTS_ARCHIVE = MESSAGEBOARD_PATH + PICKLE_FLIGHTS_ARCHIVE LOGFILE = MESSAGEBOARD_PATH + LOGFILE PICKLE_DUMP_JSON_FILE = MESSAGEBOARD_PATH + PICKLE_DUMP_JSON_FILE PICKLE_FA_JSON_FILE = MESSAGEBOARD_PATH + PICKLE_FA_JSON_FILE LAST_FLIGHT_FILE = MESSAGEBOARD_PATH + LAST_FLIGHT_FILE ARDUINO_FILE = MESSAGEBOARD_PATH + ARDUINO_FILE HISTOGRAM_CONFIG_FILE = WEBSERVER_PATH + HISTOGRAM_CONFIG_FILE CONFIG_FILE = WEBSERVER_PATH + CONFIG_FILE HOURLY_IMAGE_FILE = WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HOURLY_IMAGE_FILE ROLLING_MESSAGE_FILE = WEBSERVER_PATH + ROLLING_MESSAGE_FILE HISTOGRAM_IMAGE_PREFIX = ( WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_IMAGE_PREFIX) HISTOGRAM_EMPTY_IMAGE_FILE = ( WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_EMPTY_IMAGE_FILE) ALL_MESSAGE_FILE = WEBSERVER_PATH + ALL_MESSAGE_FILE ROLLING_LOGFILE = WEBSERVER_PATH + ROLLING_LOGFILE STDERR_FILE = WEBSERVER_PATH + STDERR_FILE BACKUP_FILE = WEBSERVER_PATH + BACKUP_FILE SERVICE_VERIFICATION_FILE = WEBSERVER_PATH + SERVICE_VERIFICATION_FILE TIMEZONE = 'US/Pacific' # timezone of display TZ = pytz.timezone(TIMEZONE) KNOWN_AIRPORTS = ('SJC', 'SFO', 'OAK') # iata codes that we don't need to expand SPLITFLAP_CHARS_PER_LINE = 22 SPLITFLAP_LINE_COUNT = 6 DIRECTIONS_4 = ['N', 'E', 'S', 'W'] DIRECTIONS_8 = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] DIRECTIONS_16 = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] HOURS = ['12a', ' 1a', ' 2a', ' 3a', ' 4a', ' 5a', ' 6a', ' 7a', ' 8a', ' 9a', '10a', '11a', '12p', ' 1p', ' 2p', ' 3p', ' 4p', ' 5p', ' 6p', ' 7p', ' 8p', ' 9p', '10p', '11p'] SECONDS_IN_MINUTE = 60 MINUTES_IN_HOUR = 60 <----SKIPPED LINES----> aircraft_length['Boeing 737-700 (twin-jet)'] = 33.63 aircraft_length['Boeing 737-800 (twin-jet)'] = 39.47 aircraft_length['Boeing 737-900 (twin-jet)'] = 42.11 aircraft_length['Boeing 747-400 (quad-jet)'] = 36.4 aircraft_length['Boeing 747-8 (quad-jet)'] = 76.25 aircraft_length['Boeing 757-200 (twin-jet)'] = 47.3 aircraft_length['Boeing 757-300 (twin-jet)'] = 54.4 aircraft_length['Boeing 767-200 (twin-jet)'] = 48.51 aircraft_length['Boeing 767-300 (twin-jet)'] = 54.94 aircraft_length['Boeing 777-200 (twin-jet)'] = 63.73 aircraft_length['Boeing 777-200LR/F (twin-jet)'] = 63.73 aircraft_length['Boeing 777-300ER (twin-jet)'] = 73.86 aircraft_length['Boeing 787-10 (twin-jet)'] = 68.28 aircraft_length['Boeing 787-8 (twin-jet)'] = 56.72 aircraft_length['Boeing 787-9 (twin-jet)'] = 62.81 aircraft_length['Canadair Regional Jet CRJ-200 (twin-jet)'] = 26.77 aircraft_length['Canadair Regional Jet CRJ-700 (twin-jet)'] = 32.3 aircraft_length['Canadair Regional Jet CRJ-900 (twin-jet)'] = 36.2 aircraft_length['Canadair Challenger 350 (twin-jet)'] = 20.9 aircraft_length['Bombardier Challenger 300 (twin-jet)'] = 20.92 aircraft_length['Embraer 170/175 (twin-jet)'] = (29.90 + 31.68) / 2 aircraft_length['EMBRAER 175 (long wing) (twin-jet)'] = 31.68 aircraft_length['Embraer ERJ-135 (twin-jet)'] = 26.33 aircraft_length['Cessna Caravan (single-turboprop)'] = 11.46 aircraft_length['Cessna Citation II (twin-jet)'] = 14.54 aircraft_length['Cessna Citation V (twin-jet)'] = 14.91 aircraft_length['Cessna Skyhawk (piston-single)'] = 8.28 aircraft_length['Cessna Skylane (piston-single)'] = 8.84 aircraft_length['Cessna Citation Sovereign (twin-jet)'] = 19.35 aircraft_length['Cessna T206 Turbo Stationair (piston-single)'] = 8.61 aircraft_length['Beechcraft Bonanza (33) (piston-single)'] = 7.65 aircraft_length['Beechcraft Super King Air 200 (twin-turboprop)'] = 13.31 aircraft_length['Beechcraft Super King Air 350 (twin-turboprop)'] = 14.22 aircraft_length['Beechcraft King Air 90 (twin-turboprop)'] = 10.82 aircraft_length['Pilatus PC-12 (single-turboprop)'] = 14.4 def CheckIfProcessRunning(): """Returns proc id if process with identically-named python file running; else None.""" this_process_id = os.getpid() this_process_name = os.path.basename(sys.argv[0]) for proc in psutil.process_iter(): try: # Check if process name contains this_process_name. commands = proc.as_dict(attrs=['cmdline', 'pid']) if commands['cmdline']: command_running = any( [this_process_name in s for s in commands['cmdline']]) if command_running and commands['pid'] != this_process_id: return commands['pid'] except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): pass return None def LogMessage(message, file=None): """Write a message to a logfile along with a timestamp. Args: message: string message to write file: string representing file name and, if needed, path to the file to write to """ # can't define as a default parameter because LOGFILE name is potentially # modified based on SIMULATION flag if not file: file = LOGFILE try: with open(file, 'a') as f: if not SIMULATION: # by excluding the timestamp, file diffs become easy between runs f.write('='*80+'\n') f.write(str(datetime.datetime.now(TZ))+'\n') f.write('\n') f.write(str(message)+'\n') except IOError: LogMessage('Unable to append to ' + file) existing_log_lines = ReadFile(LOGFILE).splitlines() with open(ROLLING_LOGFILE, 'w') as f: f.write('\n'.join(existing_log_lines[-1000:])) def MaintainRollingWebLog(message, max_count, filename=None): """Maintains a rolling text file of at most max_count printed messages. Newest data at top and oldest data at the end, of at most max_count messages, where the delimiter between each message is identified by a special fixed string. Args: message: text message to prepend to the file. max_count: maximum number of messages to keep in the file; the max_count+1st message is deleted. filename: the file to update. """ <----SKIPPED LINES----> 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} 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 <----SKIPPED LINES----> 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. """ if seconds is None: return KEY_NOT_PRESENT_STRING[:3] 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: <----SKIPPED LINES----> 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) if s: # add terminating semicolon s += ';' 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: <----SKIPPED LINES----> def ScanForNewFlights(persistent_nearby_aircraft, persistent_path): """Determines if there are any new aircraft in the radio message. The radio is continuously dumping new json messages to the Raspberry pi with all the flights currently observed. This function picks up the latest radio json, and for any new nearby flights - there should generally be at most one new flight on each pass through - gets additional flight data from FlightAware and augments the flight definition with the relevant fields to keep. Args: persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values are the time the flight was last seen. persistent_path: dictionary where keys are flight numbers, and the values are a sequential list of the location-attributes in the json file; allows for tracking the flight path over time. Returns: A tuple: - updated persistent_nearby_aircraft - (possibly empty) dictionary of flight attributes of the new flight upon its first observation. - the time of the radio observation if present; None if no radio dump - a dictionary of attributes about the dump itself (i.e.: # of flights; furthest observed flight, etc.) - persistent_path, a data structure containing past details of a flight's location as described in ParseDumpJson """ flight_details = {} now = time.time() if SIMULATION: (dump_json, json_time) = DUMP_JSONS[SIMULATION_COUNTER] else: dump_json = ReadFile(DUMP_JSON_FILE, log_exception=True) json_desc_dict = {} current_nearby_aircraft = {} if dump_json: (current_nearby_aircraft, now, <----SKIPPED LINES----> elif flight_number: flight_aware_json = GetFlightAwareJson(flight_number) if not flight_aware_json: LogMessage('No json returned from Flightaware for flight: %s' % flight_number) flight_details = {} if flight_aware_json: flight_details = ParseFlightAwareJson(flight_aware_json) if not SIMULATION: PickleObjectToFile((flight_aware_json, now), PICKLE_FA_JSON_FILE) # Augment FlightAware details with radio / radio-derived details flight_details.update(current_nearby_aircraft[flight_number]) # Augment with the past location data flight_details['persistent_path'] = persistent_path[flight_number][1] return ( persistent_nearby_aircraft, flight_details, now, json_desc_dict, persistent_path) def DescribeDumpJson(parsed): """Generates a dictionary with descriptive attributes about the dump json file. Args: parsed: The parsed json file. 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) <----SKIPPED LINES----> for aircraft in parsed['aircraft']: simplified_aircraft = {} simplified_aircraft['now'] = now # flight_number flight_number = aircraft.get('flight') if flight_number: flight_number = flight_number.strip() if flight_number: simplified_aircraft['flight_number'] = flight_number if 'lat' in aircraft and 'lon' in aircraft: lat = aircraft['lat'] lon = aircraft['lon'] if isinstance(lat, numbers.Number) and isinstance(lon, numbers.Number): simplified_aircraft['lat'] = lat simplified_aircraft['lon'] = lon altitude = aircraft.get('altitude', aircraft.get('alt_baro')) if altitude is not None: simplified_aircraft['altitude'] = altitude speed = aircraft.get('speed', aircraft.get('gs')) if speed is not None: simplified_aircraft['speed'] = speed vert_rate = aircraft.get('vert_rate', aircraft.get('baro_rate')) if vert_rate is not None: simplified_aircraft['vert_rate'] = vert_rate if aircraft.get('squawk') is not None: simplified_aircraft['squawk'] = aircraft.get('squawk') track = aircraft.get('track') if isinstance(track, numbers.Number): min_meters = MinMetersToHome((lat, lon), track) simplified_aircraft['track'] = track simplified_aircraft['min_feet'] = min_meters * FEET_IN_METER if HaversineDistanceMeters(HOME, (lat, lon)) < MIN_METERS: 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) <----SKIPPED LINES----> Given a flight number, loads the corresponding FlightAware webpage for that flight and extracts the relevant script that contains all the flight details from that page. Args: flight_number: text flight number (i.e.: SWA1234) Returns: Text representation of the json message from FlightAware. """ url = 'https://flightaware.com/live/flight/' + flight_number try: response = requests.get(url) except requests.exceptions.RequestException as e: LogMessage('Unable to query FA for URL due to %s: %s' % (e, url)) return '' soup = bs4.BeautifulSoup(response.text, "html.parser") l = soup.findAll('script') flight_script = None for script in l: if "trackpollBootstrap" in script: flight_script = script break if not flight_script: LogMessage('Unable to find trackpollBootstrap script in page: ' + response.text) return '' first_open_curly_brace = flight_script.find('{') flight_json = flight_script[first_open_curly_brace:-1] return flight_json def Unidecode(s): """Convert a special unicode characters to closest ASCII representation.""" if s is not None: s = unidecode.unidecode(s) return s def ParseFlightAwareJson(flight_json): """Strips relevant data about the flight from FlightAware feed. The FlightAware json has hundreds of fields about a flight, only a fraction of which <----SKIPPED LINES----> flight['airline_full_name'] = Unidecode(airline.get('fullName')) if len(parsed_json['flights'].keys()) > 1: LogMessage('There are multiple flights in the FlightAware json: ' + parsed_json) return flight def EpochDisplayTime(epoch, format_string='%Y-%m-%d %H:%M:%S.%f%z'): """Converts epoch in seconds to formatted time string.""" return datetime.datetime.fromtimestamp(epoch, TZ).strftime(format_string) def DisplayTime(flight, format_string='%Y-%m-%d %H:%M:%S.%f%z'): """Converts flight 'now' to formatted time string, caching results on flight.""" cached_key = CACHED_ELEMENT_PREFIX + 'now-' + format_string cached_time = flight.get(cached_key) if cached_time: return cached_time epoch_display_time = EpochDisplayTime(flight['now'], format_string) flight[cached_key] = epoch_display_time return epoch_display_time 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: <----SKIPPED LINES----> 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, log=False): """Returns boolean indicating whether the screen is currently accepting new flight data. Based on the configuration file, determines whether the flight data should be displayed. Specifically, the configuration: - may include 'enabled' indicating whether screen should be driven at all - should include 'on' & 'off' parameters indicating minute (from midnight) of operation - should include altitude & elevation parameters indicating max values of interest Args: flight: dictionary of flight attributes. configuration: dictionary of configuration attributes. display_all_hours: a boolean indicating whether we should ignore whether the screen is turned off (either via the enabling, or via the hour settings) Returns: Boolean as described. """ flight_altitude = flight.get('altitude', float('inf')) config_max_altitude = configuration['setting_max_altitude'] flight_meets_criteria = True if flight_altitude > config_max_altitude: flight_meets_criteria = False if log: LogMessage( '%s not displayed because it fails altitude criteria - flight altitude: ' '%.0f; required altitude: %.0f' % ( DisplayFlightNumber(flight), flight_altitude, config_max_altitude)) else: flight_distance = flight.get('min_feet', float('inf')) config_max_distance = configuration['setting_max_distance'] if flight_distance > config_max_distance: flight_meets_criteria = False if log: LogMessage( '%s not displayed because it fails distance criteria - flight distance: ' '%.0f; required distance: %.0f' % ( DisplayFlightNumber(flight), flight_distance, config_max_distance)) if not display_all_hours and flight_meets_criteria: flight_timestamp = flight['now'] dt = datetime.datetime.fromtimestamp(flight_timestamp, TZ) minute_of_day = dt.hour * MINUTES_IN_HOUR + dt.minute if minute_of_day <= configuration['setting_on_time']: flight_meets_criteria = False if log: LogMessage( '%s not displayed because it occurs too early - minute_of_day: ' '%d; setting_on_time: %d' % ( DisplayFlightNumber(flight), minute_of_day, configuration['setting_on_time'])) elif minute_of_day > configuration['setting_off_time'] + 1: flight_meets_criteria = False if log: LogMessage( '%s not displayed because it occurs too late - minute_of_day: ' '%d; setting_off_time: %d' % ( DisplayFlightNumber(flight), minute_of_day, configuration['setting_off_time'])) elif configuration.get('setting_screen_enabled', 'off') == 'off': flight_meets_criteria = False if log: LogMessage( '%s not displayed because screen disabled' % DisplayFlightNumber(flight)) return flight_meets_criteria def IdentifyFlightDisplayed(flights, configuration, display_all_hours=False): """Finds the most recent flight in flights that meet the display criteria. Args: flights: list of flight dictionaries. configuration: dictionary of settings. display_all_hours: boolean indicating whether we should ignore the time constraints (i.e.: whether the screen is enabled, and its turn-on or turn-off times) in identifying the most recent flight. That is, if False, then this will only return flights that would have been displayed in the ordinarily usage, vs. if True, a flight irrespective of the time it would be displayed. Returns: A flight dictionary if one can be found; None otherwise. """ for n in range(len(flights)-1, -1, -1): # traverse the flights in reverse if FlightMeetsDisplayCriteria( flights[n], configuration, display_all_hours=display_all_hours): return n return None def FlightMessageTestHarness(flights=None, display=True): """Simulates what flight messages might be displayed by replaying past flights.""" if flights is None: flights = UnpickleObjectFromFile(PICKLE_FLIGHTS_30D) messages = [] for flight in flights: flight_message = Screenify(CreateMessageAboutFlight(flight), False) if display: print(flight_message) messages.append(flight_message) return messages def CreateMessageAboutFlight(flight): """Creates a message to describe interesting attributes about a single flight. Generates a multi-line description of a flight. A typical message might look like: UAL300 - UNITED <- Flight number and airline <----SKIPPED LINES----> lines.append(divider) return append_character.join(lines) def FlightInsightsTestHarness( flights=None, display=True, flight_insights_enabled_string='all'): """Simulates what insightful messages might be displayed by replaying past flights.""" if flights is None: flights = UnpickleObjectFromFile(PICKLE_FLIGHTS_30D) distribution = {} messages = [] for (n, flight) in enumerate(flights): flight_message = CreateMessageAboutFlight(flight) if display: print('='*25) print(Screenify(flight_message, False)) messages.append(flight_message) insights = CreateFlightInsights( flights[:n+1], flight_insights_enabled_string, distribution) FlightInsightNextFlight(flights[:n+1]) if display: for insight in insights: print(Screenify(insight, False)) messages.extend(insights) return messages def FlightInsightLastSeen(flights, days_ago=2): """Generates string indicating when flight was last seen. Generates text of the following form. - KAL214 was last seen 2d0h ago Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. days_ago: the minimum time difference for which a message should be generated - i.e.: many flights are daily, and so we are not necessarily interested to see about every daily flight that it was seen yesterday. However, more infrequent flights might be of interest. <----SKIPPED LINES----> 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=HOURS_IN_DAY): """Generates string about a numeric attribute of the flight being an extreme value. Generates text of the following form for the "focus" flight in the data. - N5286C has the slowest groundspeed (113mph vs. 163mph) in last 24 hours - CKS828 has the highest altitude (40000ft vs. 16575ft) in last 24 hours Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. key: the key of the attribute of interest - i.e.: 'speed'. label: the human-readable string that should be displayed in the message - i.e.: 'groundspeed'. units: the string units that should be used to label the value of the key - i.e.: 'MPH'. absolute_list: a 2-tuple of strings that is used to label the min and the max - i.e.: ('lowest', 'highest'), or ('slowest', 'fastest'). insight_min: boolean indicating whether to generate an insight about the min value. insight_max: boolean indicating whether to generate an insight about the max value. <----SKIPPED LINES----> Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. Returns: Printable string message; if no message because not enough history, then an empty string. """ msg = '' # m = min of day of this flight # find minute of day of prior flights st # -- that flight not seen in last 12 hrs # -- that min of day >= this this_flight = flights[-1] this_hour = int(DisplayTime(this_flight, '%-H')) this_minute = int(DisplayTime(this_flight, '%-M')) this_date = DisplayTime(this_flight, '%x') # Flights that we've already seen in the last few hours we do not expect to see # again for another few hours, so let's exclude them from the calculation exclude_flights_hours = 12 flight_numbers_seen_in_last_n_hours = [ f['flight_number'] for f in flights 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')] # exclude flights that would be filtered out configuration = ReadAndParseSettings(CONFIG_FILE) still_to_come_flights = [ f for f in still_to_come_flights if FlightMeetsDisplayCriteria(f, configuration)] 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 <----SKIPPED LINES----> 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, including value. value: the value for which we want to determine the percentile. Returns: Returns an integer percentile in the range [0, 100] inclusive. """ count_values_below_score = len([1 for s in scores if s < value]) # -1 is because value is already in scores count_values_at_score = len([1 for s in scores if s == value]) - 1 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, filter_function=lambda this, other: True, min_days=1, lookback_days=30, min_this_group_size=0, min_comparison_group_size=0, min_group_qty=0, percentile_low=float('-inf'), percentile_high=float('inf')): """Generates a string about extreme values of groups of flights. Generates text of the following form for the "focus" flight in the data. - flight SIA31 (n=7) has a delay frequency in the 95th %tile, with 100% of flights delayed an average of 6m over the last 4d1h - flight UAL300 (n=5) has a delay time in the 1st %tile, with an average delay of 0m over the last 4d5h Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. group_function: function that, when called with a flight, returns the grouping key. That is, for example, group_function(flight) = 'B739' value_function: function that, when called with a list of flights, returns the value to be used for the comparison to identify min / max. Typically, the count, but could also be a sum, standard deviation, etc. - for perhaps the greatest range in flight altitude. If the group does not have a valid value and so should be excluded from comparison - i.e.: average delay of a group of flights which did not have a calculable_delay on any flight, this function should return None. value_string_function: function that, when called with the two parameters flights and value, returns a string (inclusive of units and label) that should be displayed to describe the quantity. For instance, if value_function returns seconds, value_string_function could convert that to a string '3h5m'. Or if value_function returns an altitude range, value_string_function could return a string 'altitude range of 900ft (1100ft - 2000ft)'. group_label: string to identify the group type - i.e.: 'aircraft' or 'flight' in the examples above. value_label: string to identify the value - i.e.: 'flights' in the examples above, but might also be i.e.: longest *delay*, or other quantity descriptor. filter_function: an optional function that, when called with the most recent flight and another flight filter_function(flights[-1], flight[n]), returns a value interpreted as a boolean indicating whether flight n should be included in determining the percentile. min_days: the minimum amount of history required to start generating insights about delays. lookback_days: the maximum amount of history which will be considered in generating insights about delays. min_this_group_size: even if this group has, say, the maximum average delay, if its a group of size 1, that is not necessarily very interesting. This sets the minimum group size for the focus flight. min_comparison_group_size: similarly, comparing the focus group to groups of size one does not necessarily produce a meaningful comparison; this sets to minimum size for the other groups. min_group_qty: when generating a percentile, if there are only 3 or 4 groups among which to generate a percentile (i.e.: only a handful of destinations have been seen so far, etc.) then it is not necessarily very interesting to generate a message; this sets the minimum quantity of groups necessary (including the focus group) to generate a message. percentile_low: number [0, 100] inclusive that indicates the percentile that the focus flight group must equal or be less than for the focus group to trigger an insight. percentile_high: number [0, 100] inclusive that indicates the percentile that the focus flight group must equal or be greater than for the focus group to trigger an insight. Returns: Printable string message; if no message or insights to generate, then an empty string. """ debug = False message = '' this_flight = flights[-1] first_timestamp = flights[0]['now'] last_timestamp = this_flight['now'] included_seconds = last_timestamp - first_timestamp if (included_seconds > SECONDS_IN_DAY * min_days and group_function(this_flight) != KEY_NOT_PRESENT_STRING): relevant_flights = [ f for f in flights if last_timestamp - f['now'] < SECONDS_IN_DAY * lookback_days and filter_function(this_flight, f)] grouped_flights = {} 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 debug: print() print('len(relevant_flights): %d' % len(relevant_flights)) print('len(grouped_flights): %d' % len(grouped_flights)) print('grouped_flights.keys(): %s' % sorted(list(grouped_flights.keys()))) for key in sorted(list(grouped_flights.keys())): print(' len(grouped_flights[%s]) = %d' % (key, len(grouped_flights[key]))) if this_value is not None 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 debug: 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]]))) if group_label: group_label += ' ' 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() elif debug: print('Not an outlying group because A and either B or C needed to be true:') if not this_group_size >= min_this_group_size: print('A this_group_size %d >= min_this_group_size %d' % ( this_group_size, min_this_group_size)) else: print('A passed') if not this_percentile <= percentile_low: print('B this_percentile %d <= percentile_low %d' % ( this_percentile, percentile_low)) if not this_percentile >= percentile_high: print('C this_percentile %d >= percentile_high %d' % ( this_percentile, percentile_high)) elif debug: print('Not an outlying group because A or B failed:') if this_value is None: print('A this_value %s' % str(this_value)) elif len(grouped_values) < min_group_qty: print('A passed') print('B len(grouped_values) %d >= min_group_qty %d' % ( len(grouped_values), min_group_qty)) print('grouped_values: %s' % grouped_values) 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. <----SKIPPED LINES----> 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=HOURS_IN_DAY): """Generates string about the climb rate of the flight being an extreme value. Generates text of the following form for the "focus" flight in the data. - UAL631 has the fastest ascent rate (5248fpm, 64fpm faster than next fastest) in last 24 hours - CKS1820 has the fastest descent rate (-1152fpm, -1088fpm faster than next fastest) in last 24 hours While this is conceptually similar to the more generic FlightInsightSuperlativeVertrate function, vert_rate - because it can be either positive or negative, with different signs requiring different labeling and comparisons - it needs its own special handling. Args: flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. hours: the time horizon over which to look for superlative flights. Returns: <----SKIPPED LINES----> 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=HOURS_IN_DAY)) AppendMessageType(FLAG_INSIGHT_ALTITUDE, FlightInsightSuperlativeAttribute( flights, 'altitude', 'altitude', DISTANCE_UNITS, ['lowest', 'highest'], hours=HOURS_IN_DAY)) 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, filter_function=lambda this, other: True, min_days=1, lookback_days=30, min_this_group_size=0, min_comparison_group_size=0, min_group_qty=0, percentile_low=float('-inf'), percentile_high=float('inf')): 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], filter_function=filter_function, 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, 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, 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, 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, '%-I%p') == DisplayTime(flights[-1], '%-I%p'): flight_hours[DisplayTime(flight, '%-d')] = flight_hours.get( DisplayTime(flight, '%-d'), 0) + 1 min_this_hour_flights = max(3, 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], '%x %-I%p') this_day = DisplayTime(flights[-1], '%x') if (this_hour == DisplayTime(flight, '%x %-I%p') and FLAG_INSIGHT_HOUR_DELAY_FREQUENCY in insights): hour_delay_frequency_flag = None if (this_hour == DisplayTime(flight, '%x %-I%p') and FLAG_INSIGHT_HOUR_DELAY_TIME in insights): hour_delay_time_flag = None if (this_day == DisplayTime(flight, '%x') and FLAG_INSIGHT_DATE_DELAY_FREQUENCY in insights): date_delay_frequency_flag = None if (this_day == DisplayTime(flight, '%x') and FLAG_INSIGHT_DATE_DELAY_TIME in insights): date_delay_time_flag = None def TodaysHour(f): f_date = DisplayTime(f, '%x') f_hour = DisplayTime(f, '%-I%p') if f_date == DisplayTime(flights[-1], '%x'): return '%s flights today' % f_hour return '%s %s' % (f_date, f_hour) # Today's 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=TodaysHour, group_label='', filter_function=lambda this, other: DisplayTime(this, '%-I%p') == DisplayTime(other, '%-I%p'), min_days=3, min_this_group_size=min_this_hour_flights, min_comparison_group_size=min_this_hour_flights, min_group_qty=5, 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, '%-d')] = flight_days.get( DisplayTime(flight, '%-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, '%-d'))) + ')', group_label='Today', min_days=7, min_this_group_size=min_this_day_flights, min_comparison_group_size=min_this_day_flights, min_group_qty=7, lookback_days=28, # Otherwise, there might be two 1st's of the month to compare 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 <----SKIPPED LINES----> max_distance_feet: number indicating the geo fence outside of which flights should be ignored for the purposes of including the flight data in the histogram. max_altitude_feet: number indicating the maximum altitude outside of which flights should be ignored for the purposes of including the flight data in the histogram. normalize_factor: divisor to apply to all the values, so that we can easily renormalize the histogram to display on a percentage or daily basis; if zero, no renormalization is applied. exhaustive: boolean only relevant if sort_type is a list, in which case, this ensures that the returned set of keys (and matching values) contains all the elements in the list, including potentially those with a frequency of zero, within the restrictions of truncate. Returns: 2-tuple of lists cut and sorted as indicated by parameters above: - list of values (or frequency) of the histogram elements - list of keys (or labels) of the histogram elements """ histogram_dict = {} filtered_data = [] def IfNoneReturnInf(f, key): value = f.get(key) if not value: value = float('inf') return value # get timezone & now so that we can generate a timestamp for comparison just once if hours: now = datetime.datetime.now(TZ) for element in data: if ( IfNoneReturnInf(element, 'min_feet') <= max_distance_feet and IfNoneReturnInf(element, 'altitude') <= 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) <----SKIPPED LINES----> 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 = HOURS_IN_DAY elif how_much_history == '7d': hours = 7 * HOURS_IN_DAY elif how_much_history == '30d': hours = 30 * HOURS_IN_DAY else: LogMessage( 'Histogram form has invalid value for how_much_history: %s' % how_much_history) hours = 7 * HOURS_IN_DAY return hours def HistogramSettingsScreens(max_screens): """Extracts the desired number of text screens from the histogram configuration string. Args: max_screens: string from the histogram config file. Returns: Number of maximum number of screens to display for a splitflap histogram. """ if max_screens == '_1': screen_limit = 1 elif max_screens == '_2': screen_limit = 2 elif max_screens == '_5': screen_limit = 5 elif max_screens == 'all': screen_limit = 0 # no limit on screens else: LogMessage('Histogram form has invalid value for max_screens: %s' % max_screens) screen_limit = 1 return screen_limit def HistogramSettingsKeySortTitle(which, hours, max_altitude=45000): """Provides the arguments necessary to generate a histogram from the config string. The same parameters are used to generate either a splitflap text or web-rendered histogram in terms of the histogram title, the keyfunction, and how to sort the keys. For a given histogram name (based on the names defined in the histogram config file), this provides those parameters. Args: which: string from the histogram config file indicating the histogram to provide settings for. hours: how many hours of histogram data have been requested. max_altitude: indicates the maximum altitude that should be included on the altitude labels. Returns: A 4-tuple of the parameters used by either CreateSingleHistogramChart or MessageboardHistogram, of the keyfunction, sort, title, and hours. """ def DivideAndFormat(dividend, divisor): if dividend is None: return KEY_NOT_PRESENT_STRING if isinstance(dividend, numbers.Number): return '%2d' % round(dividend / divisor) return dividend[:2] if which == 'destination': key = lambda k: k.get('destination_iata', KEY_NOT_PRESENT_STRING) sort = 'value' title = 'Destination' elif which == 'origin': key = lambda k: k.get('origin_iata', KEY_NOT_PRESENT_STRING) sort = 'value' title = 'Origin' elif which == 'hour': key = lambda k: DisplayTime(k, '%H') sort = 'key' title = 'Hour' elif which == 'airline': key = DisplayAirline sort = 'value' title = 'Airline' <----SKIPPED LINES----> 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' # if less than one week, as requested; if more than one week, in full week multiples hours_in_week = 7 * HOURS_IN_DAY weeks = hours / hours_in_week if weeks > 1: hours = hours_in_week * int(hours / hours_in_week) elif which == 'day_of_month': key = lambda k: DisplayTime(k, '%-d').rjust(2) today_day = datetime.datetime.now(TZ).day days = list(range(today_day, 0, -1)) # today down to the first of the month days.extend(range(31, today_day, -1)) # 31st of the month down to day after today days = [str(d).rjust(2) for d in days] sort = days title = 'Day of Month' else: LogMessage( 'Histogram form has invalid value for which_histograms: %s' % which) return HistogramSettingsKeySortTitle( 'destination', hours, max_altitude=max_altitude) return (key, sort, title, hours) def ImageHistograms( flights, which_histograms, how_much_history, filename_prefix=HISTOGRAM_IMAGE_PREFIX, filename_suffix=HISTOGRAM_IMAGE_SUFFIX): """Generates multiple split histogram images. Args: flights: the iterable of the raw data from which the histogram will be generated; each element of the iterable is a dictionary, that contains at least the key 'now', and depending on other parameters, also potentially 'min_feet' amongst others. which_histograms: string paramater indicating which histogram(s) to generate, which can be either the special string 'all', or a string linked to a specific histogram. how_much_history: string parameter taking a value among ['today', '24h', '7d', '30d]. filename_prefix: this string indicates the file path and name prefix for the images <----SKIPPED LINES----> histograms_to_generate.append({'generate': 'hour'}) if which_histograms in ['airline', 'all']: histograms_to_generate.append({'generate': 'airline', 'truncate': int(TRUNCATE/2)}) if which_histograms in ['aircraft', 'all']: histograms_to_generate.append({'generate': 'aircraft'}) if which_histograms in ['altitude', 'all']: histograms_to_generate.append({'generate': 'altitude', 'exhaustive': True}) if which_histograms in ['bearing', 'all']: histograms_to_generate.append({'generate': 'bearing'}) if which_histograms in ['distance', 'all']: histograms_to_generate.append({'generate': 'distance', 'exhaustive': True}) if which_histograms in ['day_of_week', 'all']: histograms_to_generate.append({'generate': 'day_of_week'}) if which_histograms in ['day_of_month', 'all']: histograms_to_generate.append({'generate': 'day_of_month'}) for histogram in histograms_to_generate: this_histogram = which_histograms if this_histogram == 'all': this_histogram = histogram['generate'] (key, sort, title, hours) = HistogramSettingsKeySortTitle(this_histogram, hours) CreateSingleHistogramChart( flights, key, sort, title, truncate=histogram.get('truncate', TRUNCATE), hours=hours, exhaustive=histogram.get('exhaustive', False)) filename = filename_prefix + histogram['generate'] + '.' + filename_suffix matplotlib.pyplot.savefig(filename) matplotlib.pyplot.close() histograms_generated = [h['generate'] for h in histograms_to_generate] return histograms_generated def MessageboardHistograms( flights, which_histograms, <----SKIPPED LINES----> '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, hours) = HistogramSettingsKeySortTitle(this_histogram, hours) 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, <----SKIPPED LINES----> 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 values[index]/total*100 >= 0.95: 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. <----SKIPPED LINES----> d['now'] = flight['now'] requested_time = time.time() requested_elapsed_seconds = requested_time - flight['now'] updated_loc = ClosestKnownLocation(flight, requested_elapsed_seconds) actual_elapsed_seconds = requested_elapsed_seconds - updated_loc[1] d['speed'] = updated_loc[0]['speed'] d['lat'] = updated_loc[0]['lat'] d['lon'] = updated_loc[0]['lon'] d['track'] = updated_loc[0]['track'] d['altitude'] = updated_loc[0]['altitude'] d['vertrate'] = updated_loc[0]['vertrate'] d['flight_loc_now'] = flight['now'] + actual_elapsed_seconds today = datetime.datetime.now(TZ).strftime('%x') flight_count_today = len([1 for f in flights if DisplayTime(f, '%x') == today]) d['flight_count_today'] = flight_count_today settings_string = BuildSettings(d) if not SIMULATION: if os.path.exists(ARDUINO_FILE): existing_data = ReadAndParseSettings(ARDUINO_FILE) if d != existing_data: WriteFile(ARDUINO_FILE, settings_string) else: WriteFile(ARDUINO_FILE, settings_string) return settings_string def PublishMessage( s, subscription_id='12fd73cd-75ef-4cae-bbbf-29b2678692c1', key='c5f62d44-e30d-4c43-a43e-d4f65f4eb399', secret='b00aeb24-72f3-467c-aad2-82ba5e5266ca', timeout=3): """Publishes a text string to a Vestaboard. The message is pushed to the vestaboard splitflap display by way of its web services; see https://docs.vestaboard.com/introduction for more details. Args: s: String to publish. subscription_id: string subscription id from Vestaboard. key: string key from Vestaboard. secret: string secret from Vestaboard. <----SKIPPED LINES----> 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 <----SKIPPED LINES----> tmp_filename = filename + 'tmp' if os.path.exists(tmp_filename): os.remove(tmp_filename) if os.path.exists(filename): mtime = os.path.getmtime(filename) flights = UnpickleObjectFromFile(filename) for (n, flight) in enumerate(flights): if n/25 == int(n/25): print(' - %d' % n) CreateFlightInsights(flights[:n+1], configuration.get('insights', 'hide'), {}) PickleObjectToFile(flight, tmp_filename) if mtime == os.path.getmtime(filename): shutil.move(tmp_filename, filename) else: print('Failed to bootstrap %s: file changed while in process' % filename) def ResetLogs(config): """Clears the non-scrolling logs if reset_logs in config.""" if 'reset_logs' in config: LogMessage('Reset logs') if os.path.exists(STDERR_FILE): os.remove(STDERR_FILE) LogMessage('', STDERR_FILE) if os.path.exists(BACKUP_FILE): os.remove(BACKUP_FILE) open(BACKUP_FILE, 'a').close() if os.path.exists(SERVICE_VERIFICATION_FILE): os.remove(SERVICE_VERIFICATION_FILE) open(SERVICE_VERIFICATION_FILE, 'a').close() config.pop('reset_logs') config = BuildSettings(config) WriteFile(CONFIG_FILE, config) return config def main(): """Traffic cop between incoming radio flight messages, configuration, and messageboard. This is the main logic, checking for new flights, augmenting the radio signal with additional web-scraped data, and generating messages in a form presentable to the messageboard. """ already_running_id = CheckIfProcessRunning() if already_running_id: os.kill(already_running_id, signal.SIGKILL) LogMessage('Starting up') if '-s' in sys.argv: global SIMULATION_COUNTER SimulationSetup() # Redirect any errors to a log file instead of the screen, and add a datestamp if not SIMULATION: sys.stderr = open(STDERR_FILE, 'a') sys.stdout = open(STDERR_FILE, 'a') LogMessage('', STDERR_FILE) configuration = ReadAndParseSettings(CONFIG_FILE) last_distance = configuration.get('distance') last_altitude = configuration.get('altitude') startup_time = time.time() json_desc_dict = {} # If we're displaying just a single insight message, we want it to be something # unique, to the extent possible; this dict holds a count of the diff types of messages # displayed so far insight_message_distribution = {} flights = [] if os.path.exists(PICKLE_FLIGHTS_30D): flights = TruncatePickledDictionaries(PICKLE_FLIGHTS_30D) # Clear the loaded flight of any cached data since code fixes may change # the values for some of those cached elements for flight in flights: for key in list(flight.keys()): <----SKIPPED LINES----> CreateFlightInsights( flights[:n+1], configuration.get('insights', 'hide'), insight_message_distribution) # used in simulation to print the hour of simulation once per simulated hour prev_simulated_hour = '' persistent_nearby_aircraft = {} # key = flight number; value = last seen persistent_path = {} histogram = {} # Next up to print is element 0; this is a list of tuples: # Element#1: flag indicating the type of message that this is # Element#2: the message itself message_queue = [] next_message_time = time.time() # We repeat the loop every x seconds; this ensures that if the processing time is long, # we don't wait another x seconds after processing completes next_loop_time = time.time() + LOOP_DELAY_SECONDS # These files are read only if the version on disk has been modified more recently # than the last time it was read last_dump_json_timestamp = 0 LogMessage('Finishing initialization; starting radio polling loop') while not SIMULATION or SIMULATION_COUNTER < len(DUMP_JSONS): new_configuration = ReadAndParseSettings(CONFIG_FILE) n_c = new_configuration c = configuration if (n_c.get('setting_max_distance') != c.get('setting_max_distance') or n_c.get('setting_max_altitude') != c.get('setting_max_altitude')): last_distance = configuration.get('setting_max_distance') last_altitude = configuration.get('setting_max_altitude') FlightCriteriaHistogramPng( flights, new_configuration['setting_max_distance'], new_configuration['setting_max_altitude'], 7, last_max_distance_feet=last_distance, last_max_altitude_feet=last_altitude) if (n_c.get('setting_max_distance') != c.get('setting_max_distance') or n_c.get('setting_max_altitude') != c.get('setting_max_altitude') or n_c.get('setting_off_time') != c.get('setting_off_time') or n_c.get('setting_on_time') != c.get('setting_on_time')): next_flight_message = FlightInsightNextFlight(flights) if next_flight_message: message_queue.append((FLAG_MSG_INTERESTING, next_flight_message)) configuration = new_configuration ResetLogs(configuration) # clear the logs if requested # if this is a SIMULATION, then process every diff dump. But if it isn't a simulation, # then only read & do related processing for the next dump if the last-modified # timestamp indicates the file has been updated since it was last read. tmp_timestamp = 0 if not SIMULATION: tmp_timestamp = os.path.getmtime(DUMP_JSON_FILE) if (SIMULATION and DumpJsonChanges()) or ( not SIMULATION and tmp_timestamp > last_dump_json_timestamp): last_dump_json_timestamp = tmp_timestamp # a command might request info about flight to be (re)displayed, irrespective of # whether the screen is on; if so, let's put that message at the front of the message # queue, and delete any subsequent messages in queue because presumably the button # was pushed either a) when the screen was off (so no messages in queue), or b) # because the screen was on, but the last flight details got lost after other screens if os.path.exists(LAST_FLIGHT_FILE): messageboard_flight_index = IdentifyFlightDisplayed( flights, configuration, display_all_hours=True) if messageboard_flight_index is not None: message_queue = [m for m in message_queue if m[0] != FLAG_MSG_INTERESTING] flight_message = CreateMessageAboutFlight(flights[messageboard_flight_index]) message_queue = [FLAG_MSG_FLIGHT, flight_message] next_message_time = time.time() os.remove(LAST_FLIGHT_FILE) (persistent_nearby_aircraft, flight, now, json_desc_dict, persistent_path) = ScanForNewFlights(persistent_nearby_aircraft, persistent_path) if flight: flights.append(flight) UpdateArduinoFile( flights, json_desc_dict) flight_meets_display_criteria = FlightMeetsDisplayCriteria( flight, configuration, log=True) if flight_meets_display_criteria: flight_message = (FLAG_MSG_FLIGHT, CreateMessageAboutFlight(flight)) # display the next message about this flight now! next_message_time = time.time() message_queue.insert(0, flight_message) # and delete any queued insight messages about other flights that have # not yet displayed, since a newer flight has taken precedence messages_to_delete = [m for m in message_queue if m[0] == FLAG_MSG_INTERESTING] if messages_to_delete: LogMessage( 'Deleting messages from queue due to new-found plane: %s' % messages_to_delete) message_queue = [m for m in message_queue if m[0] != FLAG_MSG_INTERESTING] # Though we also manage the message queue outside this conditional as well, # because it can take a half second to generate the flight insights, this allows # this message to start displaying on the board immediately, so it's up there # when it's most relevant next_message_time = ManageMessageQueue( <----SKIPPED LINES----> next_flight_text = FlightInsightNextFlight(flights) if next_flight_text: insight_messages.insert(0, next_flight_text) insight_messages = [(FLAG_MSG_INTERESTING, m) for m in insight_messages] for insight_message in insight_messages: message_queue.insert(0, insight_message) else: # flight didn't meet display criteria flight['insight_types'] = [] PickleObjectToFile(flight, PICKLE_FLIGHTS_30D) PickleObjectToFile(flight, PICKLE_FLIGHTS_ARCHIVE) else: UpdateArduinoFile(flights, json_desc_dict) if SIMULATION: if now: simulated_hour = EpochDisplayTime(now, '%Y-%m-%d %H:00%z') if simulated_hour != prev_simulated_hour: print(simulated_hour) prev_simulated_hour = simulated_hour histogram = ReadAndParseSettings(HISTOGRAM_CONFIG_FILE) if os.path.exists(HISTOGRAM_CONFIG_FILE): os.remove(HISTOGRAM_CONFIG_FILE) # We also need to make sure there are flights on which to generate a histogram! Why # might there not be any flights? Primarily during a simulation, if there's a # lingering histogram file at the time of history restart. if histogram and flights: histogram_messages = TriggerHistograms(flights, histogram) histogram_messages = [(FLAG_MSG_HISTOGRAM, m) for m in histogram_messages] message_queue.extend(histogram_messages) # check time & if appropriate, display next message from queue next_message_time = ManageMessageQueue(message_queue, next_message_time, configuration) # if we've been running a long time, and everything else is quiet, reboot running_hours = (time.time() - startup_time) / SECONDS_IN_HOUR if ( running_hours >= HOURS_IN_DAY and not message_queue and not json_desc_dict.get('radio_range_flights')): LogMessage('About to reboot after running for %.2f hours' % running_hours) os.system('sudo reboot') return # exit execution if not SIMULATION: time.sleep(max(0, next_loop_time - time.time())) next_loop_time = time.time() + LOOP_DELAY_SECONDS else: SIMULATION_COUNTER += 1 if SIMULATION: SimulationEnd(message_queue, flights) if __name__ == "__main__": if '-i' in sys.argv: BootstrapInsightList() else: main() |