bandsaunter/bandsaunter/flightlog.py
The Dust Council 8eb3bdbb86 Ask a service that knows which leg it is, and fetch a map worth the screen
A callsign is a flight number rather than a leg, and the free registers hold
one route per number, so an aircraft over Arizona kept being handed a hop
between two airports in Texas.  Nothing on the air settles it: ADS-B carries
no origin or destination.  A commercial schedule service does know, because
it holds the day's actual movements.

Four are wired up and all four are optional: FlightAware AeroAPI,
Flightradar24, OAG and Cirium.  Each is asked before the free databases and
each answers for the moment the aircraft was overhead rather than for the
flight number in general, so the leg chosen is the one that was in the air.
With no keys set nothing changes at all: a source with no key is skipped
rather than asked and refused, and the free databases answer as before.

Keys come from the environment and are never written to the settings file,
because a settings file is meant to be copied between machines and pasted
into a message asking for help, and an API key is not.  There is a test that
holds that line.

None of the four has been run against its live service, since each wants a
paid account.  They were written from the published response shapes and are
tested against those shapes, so each reader finds what it recognises and
returns nothing otherwise: a service that has changed since costs a route
rather than a scan.  Cirium's plain departureTime is local and carries no
offset, so the UTC field is preferred where it is there -- reading the local
one as UTC is up to half a day out, which is exactly far enough to pick the
wrong leg of the same number.  Reading now happens inside the same guard as
asking, as an answer shaped differently from the documented one is the
failure most likely to actually happen.

And the map.  The zoom is now chosen from how wide the picture is rather
than from the area alone, with half again over the width fetched and
averaged down, since a downscaled tile is sharp and an upscaled one is not.
The window fetches a little more world than it shows so panning does not
leave the ground blank, and now fetches that bigger piece at the bigger
piece's own size: rendering it into the window's own pixels and stretching
it back was a fifth of an upscale over the whole map, which is what a sharp
map looks like when it looks blurred.  The comment in the fetcher said the
opposite of what the code did, which is how it stayed hidden.

At 1920 by 1080 over a hundred miles the tiles now hold about 1.6 times the
pixels the window wants.  At 3840 by 2160 the tile budget is reached, the
zoom stops climbing and the map is enlarged after all; a smaller radius buys
the detail back, and somebody else's tile server is not a thing to fetch a
thousand tiles from for one picture.  The README says so rather than
implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 20:19:53 -07:00

796 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Writing down what the aircraft said, and reading it back afterwards.
A receiver hears an aircraft for a few minutes as it crosses the sky and then
never again. What it heard is worth keeping: the position reports are a flight
path, and a flight path an hour old is the only way to see where anything went.
Two files come out of a listening session. The log is JSON Lines -- one object
per frame, in the order they arrived, with the raw hex of every frame kept
alongside what was read out of it, so nothing decoded here is the last word and
a better decoder can be run over the same evening later. The report is for
reading: one block per aircraft, saying what it is, who flies it, where it went
and how far.
The log is the input to the map. Everything the animation needs -- who, where,
when, how fast -- is in it, and nothing else is needed to draw the whole
evening again.
"""
from __future__ import annotations
import json
import math
import time
from statistics import median
from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
__all__ = ["Fix", "Track", "FlightLog", "read_logs", "report",
"write_kml", "LOG_VERSION", "EARTH_NM", "SPEED_UNITS",
"speed_label", "in_speed", "distance_label", "in_distance",
"DEFAULT_SPEED_UNIT", "centre_of", "read_position",
"box_around", "within", "recheck", "implied_speed_kt",
"MAX_GROUND_SPEED_KT"]
LOG_VERSION = 1
# One nautical mile is a minute of latitude, which is what makes it the unit
# every other number in this file is already in.
EARTH_NM = 3440.065
# What the aircraft says is knots and nautical miles, because that is what
# the standard sends and what the log therefore holds. Anything else is a
# conversion done at the moment of showing it to somebody, so the recorded
# data is never the one that had arithmetic applied to it.
#
# name: (speed label, knots -> this, distance label, nautical miles -> this)
SPEED_UNITS: dict[str, tuple[str, float, str, float]] = {
"knots": ("kt", 1.0, "nm", 1.0),
"mph": ("mph", 1.150779, "mi", 1.150779),
"kph": ("km/h", 1.852, "km", 1.852),
}
DEFAULT_SPEED_UNIT = "knots"
def speed_unit(name: str) -> tuple[str, float, str, float]:
"""The conversion for a unit name, falling back to what aircraft use."""
return SPEED_UNITS.get((name or "").strip().lower(),
SPEED_UNITS[DEFAULT_SPEED_UNIT])
def speed_label(name: str = DEFAULT_SPEED_UNIT) -> str:
"""What to write after a speed: kt, mph or km/h."""
return speed_unit(name)[0]
def in_speed(knots: float, name: str = DEFAULT_SPEED_UNIT) -> float:
"""A speed in knots, as the unit asked for."""
return float(knots) * speed_unit(name)[1]
def distance_label(name: str = DEFAULT_SPEED_UNIT) -> str:
"""The distance unit that goes with a speed unit: nm, mi or km.
Miles an hour with distances in nautical miles would be two different
miles on one picture, which is worse than either on its own.
"""
return speed_unit(name)[2]
def in_distance(nm: float, name: str = DEFAULT_SPEED_UNIT) -> float:
return float(nm) * speed_unit(name)[3]
@dataclass
class Fix:
"""One position report: where an aircraft was, and when."""
at: float = 0.0
latitude: float = 0.0
longitude: float = 0.0
altitude_ft: int = 0
ground_speed_kt: float = 0.0
track_deg: float = 0.0
vertical_rate_fpm: int = 0
def as_json(self) -> dict:
return {"t": round(self.at, 3),
"lat": round(self.latitude, 6),
"lon": round(self.longitude, 6),
"alt_ft": self.altitude_ft,
"gs_kt": round(self.ground_speed_kt, 1),
"track": round(self.track_deg, 1),
"vs_fpm": self.vertical_rate_fpm}
def distance_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Great-circle distance in nautical miles."""
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = p2 - p1
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * EARTH_NM * math.asin(min(1.0, math.sqrt(a)))
def bearing_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Initial bearing from one point to another, in degrees true."""
p1, p2 = math.radians(lat1), math.radians(lat2)
dl = math.radians(lon2 - lon1)
y = math.sin(dl) * math.cos(p2)
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
return math.degrees(math.atan2(y, x)) % 360.0
def move(lat: float, lon: float, bearing: float, nm: float) -> tuple[float, float]:
"""Where you get to going ``nm`` miles on a bearing: dead reckoning.
Used to fill the gaps. An aircraft heard once a minute is out of sight
for fifty-nine seconds of it, and it did not stop while nobody was
listening -- it carried on at the speed and heading it last reported.
"""
if nm == 0:
return lat, lon
p1 = math.radians(lat)
l1 = math.radians(lon)
d = nm / EARTH_NM
theta = math.radians(bearing)
p2 = math.asin(math.sin(p1) * math.cos(d)
+ math.cos(p1) * math.sin(d) * math.cos(theta))
l2 = l1 + math.atan2(math.sin(theta) * math.sin(d) * math.cos(p1),
math.cos(d) - math.sin(p1) * math.sin(p2))
return math.degrees(p2), (math.degrees(l2) + 540) % 360 - 180
def centre_of(tracks) -> tuple[float, float] | None:
"""Where the receiver most likely is, from everything it heard.
The median rather than the mean, because a handful of wrong positions
would drag an average halfway across a continent and cannot move a
median at all. A receiver hears aircraft all around it, so the middle
of what it heard is very close to where it is standing.
"""
lats = [f.latitude for t in tracks for f in t.fixes]
lons = [f.longitude for t in tracks for f in t.fixes]
if not lats:
return None
return median(lats), median(lons)
def read_position(text: str) -> tuple[float, float] | None:
"""A "lat,lon" pair as two numbers, or None if it is not one."""
try:
lat, lon = (float(x) for x in str(text).split(",", 1))
except (TypeError, ValueError):
return None
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
return None
return lat, lon
def box_around(lat: float, lon: float, radius_nm: float) -> tuple:
"""The south, west, north, east of a circle of this radius.
A degree of latitude is sixty nautical miles everywhere; a degree of
longitude is sixty times the cosine of the latitude, which is why the
box is wider in degrees the further north it is drawn.
"""
span_lat = radius_nm / 60.0
span_lon = radius_nm / 60.0 / max(0.02, math.cos(math.radians(lat)))
return (max(-90.0, lat - span_lat), lon - span_lon,
min(90.0, lat + span_lat), lon + span_lon)
# Above anything with a transponder on it, so a fast aircraft is never called
# an error. Concorde cruised at 1150 kt.
MAX_GROUND_SPEED_KT = 2000.0
# Past this, two positions are not worth comparing: an aircraft out of range
# for five minutes may legitimately reappear anywhere it could have flown.
TRUST_SECONDS = 300.0
# A move shorter than this is never called an error, however little time it
# took. Positions arrive twice a second and are stamped to the millisecond,
# so two of them a thousandth of a second apart imply thousands of knots
# across a few yards -- and a compact-position error is never a few yards,
# it is a different longitude zone. Measured over one night's recording the
# two are cleanly separated: every real jump was over fifty miles and every
# false one under one.
MIN_JUMP_NM = 2.0
# How many recent positions each one is weighed against. Positions arrive
# about twice a second, so this is a few seconds of history -- enough to step
# over a run of bad decodes, and short enough that the work stays linear in
# the length of the log.
_CHAIN_WINDOW = 40
def implied_speed_kt(a: "Fix", b: "Fix") -> float:
"""How fast something would have to move to be in both places."""
gap = b.at - a.at
if gap <= 0:
return 0.0
return distance_nm(a.latitude, a.longitude,
b.latitude, b.longitude) / gap * 3600.0
def _reachable(a: "Fix", b: "Fix") -> bool:
gap = b.at - a.at
if gap <= 0 or gap > TRUST_SECONDS:
return True # too long ago to argue with
if distance_nm(a.latitude, a.longitude,
b.latitude, b.longitude) <= MIN_JUMP_NM:
return True # too small a move to be a bad decode
return implied_speed_kt(a, b) <= MAX_GROUND_SPEED_KT
def recheck(tracks) -> tuple[list, int]:
"""Drop the positions an aircraft could not have been in.
Returns the tracks and how many fixes went. For logs recorded before
the decoder checked the age of a compact-position pair: an even frame
kept from ten minutes ago, read against a fresh odd one, decodes to a
place on the wrong side of the world, and that is written down as
confidently as a real position.
An aircraft that goes out of range and comes back is not an error, so
two positions are only ever compared while they are close in time; past
five minutes the aircraft may legitimately be anywhere it could have
flown to, and nothing is rejected. Inside that window, though, a
position that cannot be reached from the one before it is wrong however
many equally wrong ones follow it -- three bad decodes in a row can land
in the same wrong place and agree with each other perfectly.
"""
out, dropped = [], 0
for track in tracks:
kept = _consistent(track.fixes)
dropped += len(track.fixes) - len(kept)
out.append(track if len(kept) == len(track.fixes)
else replace(track, fixes=kept))
return out, dropped
def _consistent(fixes: list) -> list:
"""The longest run of positions that tell one story.
Walking forward and keeping whatever is reachable from the last position
kept is the obvious way and the wrong one: it only takes one bad fix to
become the reference, and then every real position afterwards is five
hundred miles from where the aircraft is supposed to be and gets thrown
away instead. Measured on a real recording that discarded a fifth of
everything, most of it the truth.
So no position is the reference. Every chain of positions that could
describe one aeroplane is considered, and the longest is the answer --
the errors are outnumbered by definition, because they are errors.
"""
real = [f for f in fixes
if -90.0 <= f.latitude <= 90.0 and -180.0 <= f.longitude <= 180.0]
if len(real) < 2:
return real
# Two positions that contradict each other are not two positions: one of
# them is wrong and there is nothing to say which, so the later one goes.
# What comes out is at least a story, which is the whole promise here.
# Each position, against the recent ones rather than all of them: they
# arrive twice a second and nothing further back than this window can
# still be argued with anyway.
best = [1] * len(real)
came_from = [-1] * len(real)
for i, fix in enumerate(real):
for j in range(max(0, i - _CHAIN_WINDOW), i):
if best[j] + 1 > best[i] and _reachable(real[j], fix):
best[i] = best[j] + 1
came_from[i] = j
end = max(range(len(real)), key=lambda i: best[i])
chain = []
while end >= 0:
chain.append(real[end])
end = came_from[end]
chain.reverse()
return chain
def within(tracks, lat: float, lon: float, radius_nm: float) -> list:
"""The tracks with everything outside the radius dropped.
Per fix rather than per aircraft, because a track is rarely all good or
all bad: one wrong position in the middle of a real flight would
otherwise take the whole flight off the map with it, or keep the whole
map stretched to reach it.
"""
out = []
for track in tracks:
kept = [f for f in track.fixes
if distance_nm(lat, lon, f.latitude, f.longitude) <= radius_nm]
if len(kept) == len(track.fixes):
out.append(track)
continue
near = replace(track, fixes=kept)
out.append(near)
return out
@dataclass
class Track:
"""One aircraft's evening: who it was and everywhere it was seen."""
icao: str = ""
callsign: str = ""
fixes: list[Fix] = field(default_factory=list)
frames: int = 0
first_seen: float = 0.0
last_seen: float = 0.0
altitudes: list[tuple[float, int]] = field(default_factory=list)
@property
def located(self) -> bool:
return bool(self.fixes)
@property
def name(self) -> str:
return self.callsign or self.icao
@property
def seconds(self) -> float:
return max(0.0, self.last_seen - self.first_seen)
@property
def distance_nm(self) -> float:
"""How far it was watched flying, along the path it actually took."""
total = 0.0
for a, b in zip(self.fixes, self.fixes[1:]):
total += distance_nm(a.latitude, a.longitude, b.latitude, b.longitude)
return total
@property
def altitude_range(self) -> tuple[int, int]:
heights = [f.altitude_ft for f in self.fixes if f.altitude_ft] or \
[alt for _, alt in self.altitudes if alt]
return (min(heights), max(heights)) if heights else (0, 0)
@property
def top_speed_kt(self) -> float:
speeds = [f.ground_speed_kt for f in self.fixes if f.ground_speed_kt]
return max(speeds) if speeds else 0.0
def bounds(self) -> tuple[float, float, float, float]:
"""South, west, north, east: the box this track needs on a map."""
lats = [f.latitude for f in self.fixes]
lons = [f.longitude for f in self.fixes]
return (min(lats), min(lons), max(lats), max(lons))
def at(self, when: float, stale: float = 300.0) -> Fix | None:
"""Where the aircraft was at a given moment, or None if unknown.
Between two reports the position is interpolated along the time
between them, which is what makes a stream of fixes into movement.
After the last report it is dead-reckoned from the speed and heading
it was last flying, but only for a while: an aircraft that has not
been heard for five minutes has flown forty miles and is a guess, not
a position, and a map that keeps drawing it is inventing an aeroplane.
"""
if not self.fixes or when < self.fixes[0].at - 1e-9:
return None
last = self.fixes[-1]
if when > last.at:
gap = when - last.at
if gap > stale:
return None
if last.ground_speed_kt <= 0:
return last
lat, lon = move(last.latitude, last.longitude, last.track_deg,
last.ground_speed_kt * gap / 3600.0)
return Fix(at=when, latitude=lat, longitude=lon,
altitude_ft=last.altitude_ft + int(
last.vertical_rate_fpm * gap / 60.0),
ground_speed_kt=last.ground_speed_kt,
track_deg=last.track_deg,
vertical_rate_fpm=last.vertical_rate_fpm)
before = self.fixes[0]
for after in self.fixes[1:]:
if after.at >= when:
span = after.at - before.at
if span <= 0:
return after
part = (when - before.at) / span
return _between(before, after, part)
before = after
return last
def trail(self, until: float, seconds: float = 0.0) -> list[Fix]:
"""The path flown up to a moment, for drawing behind the aircraft."""
start = until - seconds if seconds else float("-inf")
out = [f for f in self.fixes if start <= f.at <= until]
now = self.at(until)
if now is not None and (not out or out[-1].at < now.at):
out.append(now)
return out
def describe(self, unit: str = DEFAULT_SPEED_UNIT) -> str:
"""One line: who, where from and to, how far and how high."""
bits = [self.icao]
if self.callsign:
bits.append(self.callsign)
if self.located:
first, last = self.fixes[0], self.fixes[-1]
bits.append(f"{first.latitude:.3f},{first.longitude:.3f}"
f"{last.latitude:.3f},{last.longitude:.3f}")
bits.append(f"{in_distance(self.distance_nm, unit):.0f} "
f"{distance_label(unit)}")
low, high = self.altitude_range
if high:
bits.append(f"{low}{high} ft" if low != high else f"{high} ft")
if self.top_speed_kt:
bits.append(f"{in_speed(self.top_speed_kt, unit):.0f} "
f"{speed_label(unit)}")
bits.append(f"{self.frames} frames")
return " ".join(bits)
def _between(before: Fix, after: Fix, part: float) -> Fix:
"""A position part of the way from one fix to the next.
Straight-line in latitude and longitude rather than along a great circle:
over the fifty miles between two reports from the same aircraft the
difference is a few metres, and pretending otherwise would be arithmetic
for its own sake.
"""
def mix(a: float, b: float) -> float:
return a + (b - a) * part
lon_a, lon_b = before.longitude, after.longitude
if abs(lon_b - lon_a) > 180.0: # across the date line
lon_b += 360.0 if lon_b < lon_a else -360.0
lon = (mix(lon_a, lon_b) + 540.0) % 360.0 - 180.0
heading = before.track_deg or bearing_deg(before.latitude, before.longitude,
after.latitude, after.longitude)
return Fix(at=mix(before.at, after.at),
latitude=mix(before.latitude, after.latitude), longitude=lon,
altitude_ft=int(round(mix(before.altitude_ft or after.altitude_ft,
after.altitude_ft or before.altitude_ft))),
ground_speed_kt=mix(before.ground_speed_kt, after.ground_speed_kt),
track_deg=heading,
vertical_rate_fpm=before.vertical_rate_fpm)
# ---------------------------------------------------------------------------
# Writing, as the frames arrive
# ---------------------------------------------------------------------------
class FlightLog:
"""A JSON Lines record of everything heard, written frame by frame.
Flushed after every frame on purpose. A listening session ends when the
aeroplanes stop or the operator gets bored and presses control-C, and a
log that only reached the disk on a clean shutdown would be empty exactly
when it was most wanted.
"""
def __init__(self, path, receiver: str = "", frequency: float = 0.0,
sample_rate: float = 0.0, started: float = 0.0):
self.path = Path(path)
self.frames = 0
self.started = started or time.time()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._file = self.path.open("a", encoding="utf8")
self._write({"log": "bandsaunter-adsb", "version": LOG_VERSION,
"started": round(self.started, 3),
"started_local": datetime.fromtimestamp(
self.started).strftime("%Y-%m-%d %H:%M:%S"),
"frequency": frequency, "sample_rate": sample_rate,
"receiver": receiver})
def _write(self, body: dict) -> None:
self._file.write(json.dumps(body, separators=(",", ":"),
ensure_ascii=False) + "\n")
self._file.flush()
def append(self, frame, craft=None, when: float = 0.0) -> None:
"""Record one frame: what arrived, and what was made of it.
The raw hex goes down whatever was understood, because the frame is
the evidence and everything else in the line is an opinion about it.
"""
body: dict = {"t": round(when or time.time(), 3),
"icao": frame.icao, "df": frame.df,
"tc": frame.type_code,
"hex": frame.data.hex().upper()}
if frame.callsign:
body["callsign"] = frame.callsign
if frame.altitude_ft:
body["alt_ft"] = frame.altitude_ft
if frame.ground_speed_kt:
body["gs_kt"] = round(frame.ground_speed_kt, 1)
body["track"] = round(frame.track_deg, 1)
if frame.vertical_rate_fpm:
body["vs_fpm"] = frame.vertical_rate_fpm
# The position comes from the aircraft rather than the frame: it takes
# an even frame and an odd one, and this is the moment the pair became
# a place.
if craft is not None and craft.located:
body["lat"] = round(craft.latitude, 6)
body["lon"] = round(craft.longitude, 6)
if craft.callsign and "callsign" not in body:
body["callsign"] = craft.callsign
self.frames += 1
self._write(body)
def close(self) -> None:
try:
self._file.close()
except OSError:
pass
def __enter__(self) -> "FlightLog":
return self
def __exit__(self, *exc) -> None:
self.close()
# ---------------------------------------------------------------------------
# Reading, afterwards
# ---------------------------------------------------------------------------
def read_logs(paths) -> list[Track]:
"""Every aircraft in one or more logs, as tracks in time order.
A repeated position is dropped. An aircraft sitting on a stand reports
the same latitude and longitude twice a second for an hour, and a track
with seven thousand identical points in it is seven thousand times the
work to draw and no more informative than the one point.
"""
tracks: dict[str, Track] = {}
# What the aircraft last said about itself on a line that carried no
# position. Speed, heading and height arrive in their own frames, and
# they are facts about the aeroplane that the next position fix inherits.
said: dict[str, dict] = {}
for path in ([paths] if isinstance(paths, (str, Path)) else paths):
for line in _lines(Path(path)):
icao = str(line.get("icao") or "")
if not icao:
continue # the header, or a line about the run
track = tracks.get(icao)
if track is None:
track = Track(icao=icao, first_seen=float(line.get("t") or 0.0))
tracks[icao] = track
when = float(line.get("t") or 0.0)
track.frames += 1
track.first_seen = min(track.first_seen or when, when)
track.last_seen = max(track.last_seen, when)
if line.get("callsign"):
track.callsign = str(line["callsign"])
if line.get("alt_ft"):
track.altitudes.append((when, int(line["alt_ft"])))
# What it last said about itself, from whichever frame said it.
# Once an aircraft has been placed every line carries its
# position, so this cannot be gathered only from the lines that
# have none: it has to be updated from every line, or a velocity
# frame would be given the altitude the aircraft had when it was
# first heard and the track would flick between the two.
latest = said.setdefault(icao, {})
for key in ("alt_ft", "gs_kt", "track", "vs_fpm"):
if line.get(key):
latest[key] = line[key]
if "lat" not in line or "lon" not in line:
continue
fix = Fix(at=when, latitude=float(line["lat"]),
longitude=float(line["lon"]),
altitude_ft=int(line.get("alt_ft")
or latest.get("alt_ft") or 0),
ground_speed_kt=float(line.get("gs_kt")
or latest.get("gs_kt") or 0.0),
track_deg=float(line.get("track")
or latest.get("track") or 0.0),
vertical_rate_fpm=int(line.get("vs_fpm")
or latest.get("vs_fpm") or 0))
last = track.fixes[-1] if track.fixes else None
if last is not None and last.latitude == fix.latitude and \
last.longitude == fix.longitude:
_fill(last, fix)
continue
track.fixes.append(fix)
for track in tracks.values():
track.fixes.sort(key=lambda f: f.at)
_carry_forward(track)
return sorted(tracks.values(), key=lambda t: (t.first_seen, t.icao))
def _lines(path: Path):
try:
with path.open(encoding="utf8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
body = json.loads(line)
except ValueError:
continue # a half-written last line after a crash
if isinstance(body, dict):
yield body
except OSError:
return
def _fill(into: Fix, extra: Fix) -> None:
"""Fold a repeated position into the one already kept."""
into.altitude_ft = extra.altitude_ft or into.altitude_ft
into.ground_speed_kt = extra.ground_speed_kt or into.ground_speed_kt
into.track_deg = extra.track_deg or into.track_deg
into.vertical_rate_fpm = extra.vertical_rate_fpm or into.vertical_rate_fpm
def _carry_forward(track: Track) -> None:
"""Give every fix a speed, a heading and a height.
Position, velocity and altitude arrive in different frames, so a position
fix on its own often has no speed attached. What the aircraft last said
still applies -- it is the same aeroplane a second later -- and where it
never said anything at all the heading between one fix and the next is
what it was doing by definition.
"""
speed = heading = 0.0
altitude = climb = 0
for i, fix in enumerate(track.fixes):
if fix.ground_speed_kt:
speed, heading = fix.ground_speed_kt, fix.track_deg
else:
fix.ground_speed_kt, fix.track_deg = speed, heading
if fix.altitude_ft:
altitude = fix.altitude_ft
else:
fix.altitude_ft = altitude
if fix.vertical_rate_fpm:
climb = fix.vertical_rate_fpm
else:
fix.vertical_rate_fpm = climb
if not fix.track_deg and i + 1 < len(track.fixes):
nxt = track.fixes[i + 1]
fix.track_deg = bearing_deg(fix.latitude, fix.longitude,
nxt.latitude, nxt.longitude)
# ---------------------------------------------------------------------------
# The readable report
# ---------------------------------------------------------------------------
def report(tracks: list[Track], book=None, title: str = "",
unit: str = DEFAULT_SPEED_UNIT) -> list[str]:
"""One block per aircraft, in the order they were first heard.
``book`` is a FlightBook, or None. Everything it can add is printed under
what was heard rather than mixed into it, so it is always clear which
lines came off the air and which came from a website.
"""
out: list[str] = []
if title:
out += [title, "=" * len(title), ""]
heard = [t for t in tracks if t.frames]
if not heard:
return out + ["nothing heard."]
span = (min(t.first_seen for t in heard), max(t.last_seen for t in heard))
out += [f"{len(heard)} aircraft, {sum(t.frames for t in heard)} frames, "
f"{_clock(span[0])} to {_clock(span[1])}",
f"{sum(1 for t in heard if t.located)} of them placed on the map",
""]
for track in heard:
out.append(f"{track.icao}"
+ (f" {track.callsign}" if track.callsign else ""))
entry = book.get(track.icao, track.callsign) if book is not None else None
if entry is not None and track.located and hasattr(book, "resolve"):
here = track.fixes[len(track.fixes) // 2]
entry = book.resolve(entry, here.latitude, here.longitude)
if entry is not None:
for line in entry.details():
# The route is printed as one line further down: "from" and
# "to" mean the airports here and the ends of the track a few
# lines below, and one page cannot hold both meanings.
if not line.startswith(("address:", "callsign:", "from:", "to:")):
out.append(f" {line}")
if entry.country and not entry.owner_country:
out.append(f" registered in: {entry.country}")
if entry.route:
out.append(f" route: {entry.route}{_doubtful(entry, track)}")
out.append(f" heard: {_clock(track.first_seen)} to "
f"{_clock(track.last_seen)} "
f"({_span(track.seconds)}, {track.frames} frames)")
if track.located:
first, last = track.fixes[0], track.fixes[-1]
out.append(f" from: {first.latitude:.4f}, {first.longitude:.4f}")
out.append(f" to: {last.latitude:.4f}, {last.longitude:.4f}")
out.append(f" flew: {in_distance(track.distance_nm, unit):.1f} "
f"{distance_label(unit)} over "
f"{len(track.fixes)} positions")
low, high = track.altitude_range
if high:
out.append(f" altitude: {low:,} to {high:,} ft"
if low != high else f" altitude: {high:,} ft")
if track.top_speed_kt:
out.append(f" speed: up to "
f"{in_speed(track.top_speed_kt, unit):.0f} "
f"{speed_label(unit)}")
out.append("")
return out
def _doubtful(entry, track: Track) -> str:
"""A note where an aircraft cannot have been flying the route given.
Said rather than hidden. The route is what the register holds for that
flight number and is worth writing down; what it is not is a statement
about where this aeroplane was going, and the difference matters enough
to spell out.
"""
if not track.located:
return ""
from .flights import route_fits
here = track.fixes[len(track.fixes) // 2]
if route_fits(entry, here.latitude, here.longitude):
return ""
return " (scheduled for this flight number; this aircraft was " \
"nowhere near it)"
def _clock(when: float) -> str:
return datetime.fromtimestamp(when).strftime("%Y-%m-%d %H:%M:%S") \
if when else ""
def _span(seconds: float) -> str:
if seconds < 90:
return f"{seconds:.0f} s"
minutes, secs = divmod(int(seconds), 60)
if minutes < 90:
return f"{minutes} min {secs:02d} s"
hours, minutes = divmod(minutes, 60)
return f"{hours} h {minutes:02d} min"
# ---------------------------------------------------------------------------
# The same thing for Google Earth
# ---------------------------------------------------------------------------
def write_kml(path, tracks: list[Track], book=None,
title: str = "bandsaunter — aircraft heard",
unit: str = DEFAULT_SPEED_UNIT) -> Path | None:
"""Every track as a line on the globe, with a pin where it was last seen."""
from xml.sax.saxutils import escape as xml_escape
located = [t for t in tracks if t.located]
if not located:
return None
out = ['<?xml version="1.0" encoding="UTF-8"?>',
'<kml xmlns="http://www.opengis.net/kml/2.2">', " <Document>",
f" <name>{xml_escape(title)}</name>",
' <Style id="path"><LineStyle><color>ff40c0ff</color>'
"<width>2</width></LineStyle></Style>"]
for track in located:
entry = book.get(track.icao, track.callsign) if book is not None else None
told = "\n".join([track.describe(unit)]
+ (entry.details() if entry is not None else []))
name = f"{track.icao} {track.callsign}".strip()
line = " ".join(f"{f.longitude:.6f},{f.latitude:.6f},"
f"{f.altitude_ft * 0.3048:.0f}" for f in track.fixes)
out += [" <Placemark>",
f" <name>{xml_escape(name)}</name>",
f" <description>{xml_escape(told)}</description>",
" <styleUrl>#path</styleUrl>",
" <LineString><altitudeMode>absolute</altitudeMode>",
" <extrude>0</extrude><tessellate>1</tessellate>",
f" <coordinates>{line}</coordinates>",
" </LineString>", " </Placemark>",
" <Placemark>",
f" <name>{xml_escape(name)}</name>",
" <Point><coordinates>"
f"{track.fixes[-1].longitude:.6f},{track.fixes[-1].latitude:.6f},"
f"{track.fixes[-1].altitude_ft * 0.3048:.0f}</coordinates></Point>",
" </Placemark>"]
out += [" </Document>", "</kml>", ""]
try:
Path(path).write_text("\n".join(out))
except OSError:
return None
return Path(path)