diff --git a/README.md b/README.md index 175d09c..fa8d186 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ Get flight data from an existing ADSB provider API. You need to set coordinates for an area to check flights as a bounding box, input your position, choose a target (Moon, Sun or both), and then the app will compute future flight positions and check intersections with the target, which is called a transit. - + + + The results show the future and minimum angular separation from aircraft and the chosen target. Typically, you can expect a likely transit when there's expected a lower angular separation, no change in elevation and the difference in altitude (alt diff) and azimuth (az diff, both not in all cases) is less than a few degrees for both. In such cases, the row of results will be highlighted: @@ -30,6 +32,14 @@ The results show the future and minimum angular separation from aircraft and the -------- +## ⚠️ Safety Warning + +> **Never look at the Sun directly or through any optical equipment (camera, telescope, binoculars) without certified solar filters.** Solar radiation can cause permanent eye damage or blindness in fractions of a second. Always attach a full-aperture front filter **before** pointing equipment at the Sun. + + +-------- + + ## Setup & Configuration See [SETUP.md](SETUP.md) for full installation and configuration instructions (interactive wizard and manual setup). @@ -104,7 +114,6 @@ python windows_monitor.py -------- - ## Limitations 1) Computing the moment when there is a minimum angular separation between a plane and the target is a numerical approach. Perhaps there could be an analytical way to optimize it. diff --git a/SETUP.md b/SETUP.md index 8474de1..47e1c6d 100644 --- a/SETUP.md +++ b/SETUP.md @@ -75,7 +75,7 @@ The wizard guides you through 4 steps: 1. **ADSB Provider API key** — At least one is required for real-time flight data. You can set one or both: - [FlightAware AeroAPI](https://flightaware.com/aeroapi/signup/personal) *(recommended, 100 free requests/month)* - - [AirLabs](https://airlabs.co/register) *(1000 free requests/month)* + - [AirLabs](https://airlabs.co/) *(1000 free requests/month)* 2. **Flight search area** — A bounding box covering roughly a 15-minute flight radius from your location. Use [MAPS.ie](https://www.maps.ie/coordinates.html) or Google Maps to find coordinates. You can also adjust it visually from the map view in the browser after launching the app. @@ -104,7 +104,7 @@ Open the `.env` file with any text editor. You may need to enable hidden files v | Variable | Description | |---|---| | `AEROAPI_API_KEY` | [FlightAware AeroAPI](https://www.flightaware.com/aeroapi/signup/personal) key — recommended, 100 free requests/month | -| `AIRLABS_API_KEY` | [AirLabs](https://airlabs.co/register) key — alternative, 1000 free requests/month | +| `AIRLABS_API_KEY` | [AirLabs](https://airlabs.co/) key — alternative, 1000 free requests/month | | `LAT_LOWER_LEFT` / `LONG_LOWER_LEFT` | Southwest corner of the flight search area | | `LAT_UPPER_RIGHT` / `LONG_UPPER_RIGHT` | Northeast corner of the flight search area | diff --git a/data/assets/flymoon2-0-0-p1.png b/data/assets/flymoon2-0-0-p1.png new file mode 100644 index 0000000..43b5858 Binary files /dev/null and b/data/assets/flymoon2-0-0-p1.png differ diff --git a/data/assets/flymoon2-0-0-p2.png b/data/assets/flymoon2-0-0-p2.png new file mode 100644 index 0000000..fb4f44f Binary files /dev/null and b/data/assets/flymoon2-0-0-p2.png differ diff --git a/data/assets/flymoon2-0-0.png b/data/assets/flymoon2-0-0.png deleted file mode 100644 index 32e7852..0000000 Binary files a/data/assets/flymoon2-0-0.png and /dev/null differ diff --git a/data/assets/transit_example.jpg b/data/assets/transit_example.jpg index d41c550..8d199d6 100644 Binary files a/data/assets/transit_example.jpg and b/data/assets/transit_example.jpg differ diff --git a/monitor.py b/monitor.py index f0ac192..dfbbe89 100755 --- a/monitor.py +++ b/monitor.py @@ -154,7 +154,9 @@ def check_transits(self): # Check if any targets are trackable if not tracking_targets: - logger.info("No targets trackable (below horizon, threshold or weather)") + logger.info( + "No targets trackable (below horizon, threshold alt. or weather)" + ) self.current_transits = [] return @@ -195,7 +197,7 @@ def check_transits(self): ["-" * 21] + [ f"{POSIBILITY_LEVEL_TO_COLOR[flight['possibility_level']]}" - f" {TARGET_TO_EMOJI[flight['target']]} {flight['id']} ({flight['aircraft_type']}) in {flight['time']} min." + f" {TARGET_TO_EMOJI[flight['target']]} {flight['id']} ({flight['aircraft_type']}) in {flight['eta']} min." f" {flight['origin']} -> {flight['destination']}." f" Angular separation: {flight['angular_separation']}°" for flight in possible_transits diff --git a/src/astro.py b/src/astro.py index 8233dbc..cf21d43 100644 --- a/src/astro.py +++ b/src/astro.py @@ -11,16 +11,25 @@ def __init__(self, name: str, observer_position): self.azimuthal = None self.observer_position = observer_position self.data_obj = ASTRO_EPHEMERIS[name] + self._position_cache: dict = {} - def update_position(self, ref_datetime: datetime): + def update_position(self, ref_datetime: datetime, use_cache: bool = False): """Get the position of celestial object given the datetime reference from the current observer position. + Results are cached so repeated calls for the same time (e.g. across + multiple aircraft in the same transit window) skip the skyfield computation. + Parameters ---------- ref_datetime : datetime - Python datetime object to get the future or past position of the celestial object, + Python datetime object to get the future or past position of the celestial object. + use_cache : bool + Check if the position was before calculeted for the same ref_datetime, then cache the coordinates """ + if use_cache and ref_datetime in self._position_cache: + self.altitude, self.azimuthal = self._position_cache[ref_datetime] + return time_ = EARTH_TIMESCALE.from_datetime(ref_datetime) astrometric = self.observer_position.at(time_).observe(self.data_obj) @@ -28,6 +37,7 @@ def update_position(self, ref_datetime: datetime): self.altitude = alt self.azimuthal = az + self._position_cache[ref_datetime] = (alt, az) def __str__(self): return f"{self.name=}, {self.altitude=}, {self.azimuthal=}" diff --git a/src/config_wizard.py b/src/config_wizard.py index 42308fc..b8544f5 100644 --- a/src/config_wizard.py +++ b/src/config_wizard.py @@ -212,7 +212,7 @@ def _run_interactive_setup(self): print("=" * 60) print(f"\nSettings saved to: {self.config_file}") print("\nTo start Flymoon:") - print(" python3 app.py") + print(" python3 app.py or python app.py for Windows") print("\nThen open: http://localhost:8000") print("") @@ -236,7 +236,7 @@ def _setup_adsb_api_keys(self): { "label": "AirLabs", "env_key": "AIRLABS_API_KEY", - "signup_url": "https://airlabs.co/register", + "signup_url": "https://airlabs.co/", "prompt_label": "AirLabs API key", }, ] diff --git a/src/constants.py b/src/constants.py index 8491aec..a2ac6eb 100644 --- a/src/constants.py +++ b/src/constants.py @@ -10,7 +10,7 @@ KM_TO_NAUTICAL_MILES = 0.539957 # Notifications -TARGET_TO_EMOJI = {"moon": "🌙", "sun": "☀️", "both": "🌙☀️"} +TARGET_TO_EMOJI = {"moon": "🌙", "sun": "☀️", "both": "🌙☀️", "auto": "🌙☀️"} MAX_NUM_ITEMS_TO_NOTIFY = 5 ALT_DIFF_THRESHOLD_TO_NOTIFY = 5.0 AZ_DIFF_THRESHOLD_TO_NOTIFY = 10.0 diff --git a/src/flight_data.py b/src/flight_data.py index 3a67be4..9f87ed0 100644 --- a/src/flight_data.py +++ b/src/flight_data.py @@ -69,9 +69,10 @@ def parse(self, flight: dict) -> dict: "elevation": int(flight["last_position"]["altitude"]) * 0.3048 * 100, # hundreds of feet to meters (for calculations) - "elevation_feet": int(flight["last_position"]["altitude"]) - * 100, # API returns hundreds of feet, multiply by 100 "elevation_change": flight["last_position"]["altitude_change"], + "waypoints": ( + flight["waypoints"] if len(flight.get("waypoints", [])) > 0 else None + ), "last_update": flight["last_position"]["timestamp"], } @@ -118,8 +119,8 @@ def parse(self, flight: dict) -> Optional[dict]: "direction": flight["dir"], "speed": flight["speed"], # km/h "elevation": flight["alt"], # meters - "elevation_feet": flight["alt"] * 3.28084, "elevation_change": "-" if v_speed == 0 else ("C" if v_speed > 0 else "D"), + "waypoints": None, "last_update": convert_unix_timestamp_to_datetime_str(flight["updated"]), } @@ -139,7 +140,7 @@ def sort_results(data: List[dict]) -> List[dict]: """Sort data flight results considering if it's possible transit, angular separation, ETA and time.""" def _custom_sort(a: dict) -> tuple: - return (a.get("angular_separation", 1000), a.get("time", 999)) + return (a.get("angular_separation", 1000), a.get("eta", 999)) return sorted(data, key=_custom_sort) diff --git a/src/transit.py b/src/transit.py index b0c3e86..812debc 100644 --- a/src/transit.py +++ b/src/transit.py @@ -107,6 +107,31 @@ def get_possibility_level(angular_separation: float) -> str: return PossibilityLevel.UNLIKELY.value +def resolve_flight_ref_datetime( + default_ref_datetime: datetime, flight: dict +) -> datetime: + """Use flight's last_update as the positional anchor when available, so the prediction + starts from the moment the position was actually recorded rather than the current time. + This reduces positional drift caused by processing lag.""" + flight_ref_datetime = default_ref_datetime + last_update_str = flight.get("last_update") + if last_update_str: + try: + # Normalize "Z" suffix to "+00:00" for Python < 3.11 compatibility + normalized = str(last_update_str).replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=default_ref_datetime.tzinfo) + flight_ref_datetime = parsed + except (ValueError, TypeError): + logger.warning( + f"Could not parse last_update='{last_update_str}' for flight {flight.get('name')}, " + "falling back to ref_datetime" + ) + + return flight_ref_datetime + + def check_transit( flight: dict, window_time: list, @@ -125,8 +150,8 @@ def check_transit( window_time : array_like Data points of time in minutes to compute ahead from reference datetime. ref_datetime: datetime - Reference datetime, deltas from window_time will be add to this reference to compute the future position - of plane and target. + Current datetime (timezone-aware). Used as the time origin for ETA reporting and as + fallback when flight.last_update is unavailable or unparseable. observer_position: Topos Object from skifield library which was instanced with current position of the observer ( latitude, longitude and elevation). @@ -141,7 +166,20 @@ def check_transit( Dictionary with the results data, completely filled when it's a possible transit. The data includes: id, origin, destination, time, target_alt, plane_alt, target_az, plane_az, alt_diff, az_diff, is_possible_transit, and change_elev. + + Notes + ----- + Position prediction is anchored to flight.last_update when available, which reduces positional + error caused by the lag between the data capture time and the moment this function runs. + The reported ``time`` (ETA) is always relative to ``ref_datetime`` (current time), regardless + of which anchor was used internally, so callers always receive a true ETA from now. """ + flight_ref_datetime = resolve_flight_ref_datetime(ref_datetime, flight) + + # Minutes elapsed between the flight's positional anchor and now. + # Subtracted from window_time offsets so the reported ETA is relative to ref_datetime. + lag_minutes = (ref_datetime - flight_ref_datetime).total_seconds() / 60.0 + min_angular_sep = float("inf") response = None no_decreasing_count = 0 @@ -169,7 +207,7 @@ def check_transit( minutes=minute, ) - future_time = ref_datetime + timedelta(minutes=minute) + future_time = flight_ref_datetime + timedelta(minutes=minute) # Convert future position of plane to alt-azimuthal coordinates future_alt, future_az = geographic_to_altaz( @@ -181,9 +219,9 @@ def check_transit( future_time, ) - if idx > 0 and idx % 20 == 0: - # Update target position every 20 data points (0.3 min, 20s) - target.update_position(future_time) + if idx > 0 and idx % 5 == 0: + # Update target position every 5 data points (0.1 min, 5s) + target.update_position(future_time, use_cache=True) alt_diff = abs(future_alt - target.altitude.degrees) az_diff_raw = abs(future_az - target.azimuthal.degrees) @@ -212,6 +250,10 @@ def check_transit( # Always track aircraft above horizon, will be classified by angular separation if update_response: possibility_level = get_possibility_level(angular_sep) + eta = max(0, float(minute - lag_minutes)) + is_possible_transit = ( + 1 if possibility_level in POSSIBLE_TRANSIT_LEVELS else 0 + ) response = { "id": flight["name"], @@ -222,14 +264,17 @@ def check_transit( "alt_diff": round(float(alt_diff), 2), "az_diff": round(float(az_diff), 2), "angular_separation": round(float(angular_sep), 2), - "time": round(float(minute), 2), + "eta": round(eta, 2), + "transit_datetime": ( + (ref_datetime + timedelta(minutes=eta)).strftime("%H:%M:%S") + if is_possible_transit == 1 + else None + ), "target_alt": round(float(target.altitude.degrees), 2), "plane_alt": round(float(future_alt), 2), "target_az": round(float(target.azimuthal.degrees), 2), "plane_az": round(float(future_az), 2), - "is_possible_transit": ( - 1 if possibility_level in POSSIBLE_TRANSIT_LEVELS else 0 - ), + "is_possible_transit": is_possible_transit, "possibility_level": possibility_level, "elevation_change": CHANGE_ELEVATION.get( flight["elevation_change"], None @@ -239,16 +284,12 @@ def check_transit( "target": target.name, "latitude": flight["latitude"], "longitude": flight["longitude"], - "aircraft_elevation": flight.get( - "elevation", 0 - ), # Actual altitude in meters - "aircraft_elevation_km": round( + "aircraft_elevation": round( flight.get("elevation", 0) / 1_000, 2 - ), # Actual altitude in kilometers - "aircraft_elevation_feet": flight.get( - "elevation_feet", 0 - ), # Actual altitude in feet # TODO: deprecate + ), # Current altitude in kilometers "distance_km": round(distance_km, 1), # Distance from observer in km + # "waypoints": flight.get("waypoints"), + "last_data_update": flight.get("last_update"), } update_response = False @@ -307,12 +348,14 @@ def get_transits( "Min altitude was changed to 0, no below horizon is tracking possible" ) - logger.info(f"Starting transit computation for target={target_name}") + logger.info( + f"Starting transit computation for {target_name} target, using {adsb_provider} ADS-B provider" + ) window_time = np.linspace( 0, TOP_MINUTE, TOP_MINUTE * (NUM_SECONDS_PER_MIN // INTERVAL_IN_SECS) ) - logger.info(f"number of times to check for each flight: {len(window_time)}") + # logger.info(f"number of times to check for each flight: {len(window_time)}") # Get the local timezone using tzlocal local_timezone = get_localzone_name() @@ -334,9 +377,6 @@ def get_transits( if coords["altitude"] >= min_altitude: targets_to_check.append(target) - logger.info( - f"{target} at {coords['altitude']}° az {coords['azimuthal']}° - tracking enabled" - ) else: reason = ( "below horizon or threshold" @@ -360,8 +400,6 @@ def get_transits( search_bbox = AREA_BBOX_FROM_ENV logger.info(f"Using bounding box as search area from ENV: {search_bbox}") - logger.info(f"{adsb_provider=}") - # Instanciate the ADSB provider client if adsb_provider == "flightaware-aeroapi": adsb_client = FlightAwareAeroAPIClient( @@ -379,7 +417,6 @@ def get_transits( is_clear, weather_info = get_weather_condition( latitude, longitude, WEATHER_API_KEY, test_mode ) - logger.info(f"Weather check: clear={is_clear}, {weather_info}") else: is_clear, weather_info = get_weather_condition( latitude, longitude, WEATHER_API_KEY, return_default_response=True @@ -410,8 +447,11 @@ def get_transits( celestial_obj = CelestialObject( name=target, observer_position=OBSERVER_POSITION ) - # celestial_obj.update_position(ref_datetime=ref_datetime) + naive_datetime_now = ( + datetime.now() + ) # get again datetime as reference, must be updated as possible + ref_datetime = naive_datetime_now.replace(tzinfo=ZoneInfo(local_timezone)) for flight in flight_data: celestial_obj.update_position(ref_datetime=ref_datetime) diff --git a/static/app.js b/static/app.js index d456611..efefaaa 100644 --- a/static/app.js +++ b/static/app.js @@ -2,7 +2,7 @@ const COLUMN_NAMES = [ "id", "origin", "destination", - "time", + "eta", "angular_separation", "target_alt", "plane_alt", @@ -10,7 +10,7 @@ const COLUMN_NAMES = [ "target_az", "plane_az", "az_diff", - "aircraft_elevation_km", + "aircraft_elevation", "elevation_change", "direction", "speed", @@ -68,12 +68,13 @@ function savePosition() { alert("Position saved in local storage!"); } -function loadPositionAndBbox() { +function loadParamsSaved() { const savedLat = localStorage.getItem("latitude"); const savedLon = localStorage.getItem("longitude"); const savedElev = localStorage.getItem("elevation"); const savedMinAlt = localStorage.getItem("minAltitude"); const savedBoundingBox = localStorage.getItem("boundingBox"); + const adsbProvider = localStorage.getItem("adsbProvider"); if (savedLat === null || savedLat === "" || savedLat === "null") { console.log("No position saved in local storage"); @@ -102,7 +103,10 @@ function loadPositionAndBbox() { const checkWeather = localStorage.getItem('checkWeather'); if (checkWeather === 'true') weatherCheckBox.checked = true; - console.log("Position loaded from local storage:", savedLat, savedLon, savedElev, "minAlt:", savedMinAlt); + // Load ASBD provider chosen + document.getElementById("adsbProvider").value = adsbProvider || "flightaware-aeroapi"; + + console.log("Conf. params loaded from local storage"); } function getLocalStorageItem(key, defaultValue) { @@ -327,14 +331,18 @@ function fetchFlights() { const aircraftType = item.aircraft_type || ""; val.textContent = aircraftType && aircraftType !== "N/A" ? `${value} (${aircraftType})` : value; } - else if (column === "time") { + else if (column === "eta") { // Format ETA as mm:ss const totalSeconds = Math.round(value * 60); const mins = Math.floor(totalSeconds / 60); const secs = totalSeconds % 60; val.textContent = `${mins}:${secs.toString().padStart(2, '0')}`; + // Displat transit datetime (expected) when passing the cursor over the ETA + if (item.transit_datetime != null) { + val.title = item.transit_datetime; + } } - else if (column === "aircraft_elevation_km") { + else if (column === "aircraft_elevation") { // Show GPS altitude in km with comma formatting val.textContent = value.toLocaleString('en-US') + " km"; } @@ -524,7 +532,7 @@ function updateAltitudeDisplay(flights) { // Create a thin line for each aircraft flights.forEach(flight => { - const altitude = flight.aircraft_elevation_km || 0; + const altitude = flight.aircraft_elevation || 0; // Skip if altitude is invalid or above max if (altitude > MAX_ALTITUDE_KM) return; @@ -622,3 +630,9 @@ function toggleCheckWeather() { localStorage.setItem('checkWeather', checkbox.checked); console.log('Check weather:', checkbox.checked); } + +function updateChosenADSBProvider() { + let adsbProvider = document.getElementById("adsbProvider").value; + localStorage.setItem('adsbProvider', adsbProvider); + console.log("Chosen ADSB provider: ", adsbProvider); +} diff --git a/templates/index.html b/templates/index.html index 5411b39..2ccde71 100644 --- a/templates/index.html +++ b/templates/index.html @@ -63,8 +63,8 @@ |