Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

![](data/assets/flymoon2-0-0.png)
![](data/assets/flymoon2-0-0-p1.png)

![](data/assets/flymoon2-0-0-p2.png)

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:

Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |

Expand Down
Binary file added data/assets/flymoon2-0-0-p1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added data/assets/flymoon2-0-0-p2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed data/assets/flymoon2-0-0.png
Binary file not shown.
Binary file modified data/assets/transit_example.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions src/astro.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,33 @@ 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)
alt, az, distance = astrometric.apparent().altaz()

self.altitude = alt
self.azimuthal = az
self._position_cache[ref_datetime] = (alt, az)

def __str__(self):
return f"{self.name=}, {self.altitude=}, {self.azimuthal=}"
Expand Down
4 changes: 2 additions & 2 deletions src/config_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("")

Expand All @@ -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",
},
]
Expand Down
2 changes: 1 addition & 1 deletion src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions src/flight_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
}

Expand Down Expand Up @@ -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"]),
}

Expand All @@ -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)

Expand Down
94 changes: 67 additions & 27 deletions src/transit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
dbetm marked this conversation as resolved.
Dismissed
)

return flight_ref_datetime


def check_transit(
flight: dict,
window_time: list,
Expand All @@ -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).
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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"],
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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"
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading