A window on the sky, and flags on the routes
The terminal board says what is overhead. This says where: a real map with the aircraft moving on it as the frames arrive, and beside each one a box carrying everything known about the flight -- type and registration, who operates it, where it came from and where it is going, height with a rate of climb, speed and heading, how far away and on what bearing, its position, how many frames it has sent and how long since the last one. Qt is asked for and not required. Four bindings are tried, the module imports on a machine with none of them, and asking for the window without one gets the instructions rather than a traceback -- before the receiver is opened, since nothing is gained by taking the dongle for a window that cannot be drawn. In the menu, "listen now" is now "passive capture" with a realtime display beside it. Closing the window leaves exactly the files pressing control-C leaves, because listen and watch share one read loop and one finishing step; the receiver runs on its own thread, so a slow repaint cannot cost a frame and a slow tile fetch cannot stall the picture. The animation's labels grew to match: flight level and speed, type and registration, and both ends of the route, each with a small flag of the country its airport is in. The flags are a table rather than a network -- twelve pixels by eight, where a flag is the arrangement that makes one recognisable rather than a rendering of the real thing -- and a country not in the table is named by its two letters, since a flag that is nearly another country's is worse than none. Where a route arrives as bare codes the country comes from the ICAO prefix. Four things found on the way. The window ignored --seconds, so "listen for ten minutes" meant something different with a window open; it closes itself now. The register was being asked twice per aircraft, once for labels and once for airport positions. Cached routes had no country in them, so the first real redraw drew no flags at all -- routes are versioned now. And past fourteen aircraft on one frame the labels go back to the callsign, the height and the speed, because five lines beside each of three hundred aircraft is a page of overlapping text with a map somewhere behind it. Long names are folded rather than allowed to stretch a box, breaking at the arrow of a route so the two ends stay whole; and the animation's label placement gained the same ring search the window uses, having only ever tried four spots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
parent
e50d43d6e2
commit
df080f6571
21 changed files with 2848 additions and 145 deletions
|
|
@ -733,3 +733,192 @@ def test_nothing_survives_that_needed_an_impossible_speed():
|
|||
if 0 < b.at - a.at <= 300 and distance_nm(
|
||||
a.latitude, a.longitude, b.latitude, b.longitude) > 2:
|
||||
assert implied_speed_kt(a, b) <= MAX_GROUND_SPEED_KT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What is written beside each aircraft
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _Entry:
|
||||
"""A register's answer, in the shape flightmap reads it."""
|
||||
|
||||
def __init__(self, **over):
|
||||
self.type_code = over.get("type_code", "B739")
|
||||
self.model = over.get("model", "737-932ER")
|
||||
self.registration = over.get("registration", "N904DN")
|
||||
self.origin_code = over.get("origin_code", "KATL")
|
||||
self.origin = over.get("origin", "Atlanta")
|
||||
self.origin_country = over.get("origin_country", "US")
|
||||
self.destination_code = over.get("destination_code", "EGLL")
|
||||
self.destination = over.get("destination", "London Heathrow")
|
||||
self.destination_country = over.get("destination_country", "GB")
|
||||
# The map draws an airport where a lookup gave it a position.
|
||||
self.origin_lat = over.get("origin_lat", 33.6367)
|
||||
self.origin_lon = over.get("origin_lon", -84.4281)
|
||||
self.destination_lat = over.get("destination_lat", 51.4706)
|
||||
self.destination_lon = over.get("destination_lon", -0.4619)
|
||||
|
||||
|
||||
def test_the_label_says_height_speed_type_and_both_ends_of_the_route():
|
||||
track = straight()
|
||||
rows = fm.label_lines(track, track.fixes[0], "knots", _Entry())
|
||||
text = [line for line, _flag in rows]
|
||||
assert text[0].startswith("350") # flight level
|
||||
assert "480KT" in text[0]
|
||||
assert "B739 N904DN" in text
|
||||
assert "KATL" in text and "EGLL" in text
|
||||
|
||||
|
||||
def test_each_end_of_the_route_carries_its_own_country():
|
||||
track = straight()
|
||||
rows = dict((line, flag) for line, flag in
|
||||
fm.label_lines(track, track.fixes[0], "knots", _Entry()))
|
||||
assert rows["KATL"] == "US"
|
||||
assert rows["EGLL"] == "GB"
|
||||
|
||||
|
||||
def test_with_no_register_the_label_is_what_the_aircraft_itself_said():
|
||||
track = straight()
|
||||
rows = fm.label_lines(track, track.fixes[0], "knots", None)
|
||||
assert len(rows) == 1 and rows[0][1] == ""
|
||||
|
||||
|
||||
def test_an_airport_with_no_code_is_named_short_rather_than_in_full():
|
||||
track = straight()
|
||||
entry = _Entry(origin_code="", origin="Hartsfield Jackson Atlanta "
|
||||
"International Airport")
|
||||
said = [line for line, _ in fm.label_lines(track, track.fixes[0],
|
||||
"knots", entry)]
|
||||
assert "Hartsfield Jackson" in said
|
||||
assert not any(len(line) > 20 for line in said), said
|
||||
|
||||
|
||||
def test_the_flag_is_drawn_in_the_flag_colours():
|
||||
from bandsaunter.flags import COLOUR_ORDER, FLAG_H, FLAG_W
|
||||
|
||||
img = np.full((40, 60), fm.BG, dtype=np.uint8)
|
||||
fm.draw_flag(img, 5, 5, "JP")
|
||||
patch = img[5:5 + FLAG_H, 5:5 + FLAG_W]
|
||||
assert (patch >= fm.FLAG).all()
|
||||
assert (patch < fm.FLAG + len(COLOUR_ORDER)).all()
|
||||
# White at the corner and red in the middle: the flag of Japan.
|
||||
assert patch[0, 0] == fm.flag_index("w")
|
||||
assert patch[4, 6] == fm.flag_index("r")
|
||||
|
||||
|
||||
def test_a_country_with_no_flag_is_named_in_letters_instead():
|
||||
img = np.full((40, 60), fm.BG, dtype=np.uint8)
|
||||
fm.draw_flag(img, 5, 5, "ZZ")
|
||||
assert (img == fm.GRID).any() # the letters, in grey
|
||||
assert not (img >= fm.FLAG).any() # and no flag pretending to be one
|
||||
|
||||
|
||||
def test_a_flag_off_the_edge_of_the_picture_paints_nothing():
|
||||
for x, y in ((-40, 5), (5, -40), (200, 5), (5, 200)):
|
||||
img = np.full((40, 60), fm.BG, dtype=np.uint8)
|
||||
fm.draw_flag(img, x, y, "US")
|
||||
assert (img == fm.BG).all(), (x, y)
|
||||
|
||||
|
||||
def test_the_flag_colours_fit_in_the_palette_beside_everything_else():
|
||||
from bandsaunter.flags import COLOUR_ORDER
|
||||
|
||||
assert fm.FLAG + len(COLOUR_ORDER) < fm.TRANSPARENT
|
||||
assert fm.PALETTE.shape == (256, 3)
|
||||
# And each index really is the colour it claims.
|
||||
from bandsaunter.flags import COLOURS
|
||||
|
||||
for letter in COLOUR_ORDER:
|
||||
assert tuple(int(v) for v in fm.PALETTE[fm.flag_index(letter)]) == \
|
||||
COLOURS[letter]
|
||||
|
||||
|
||||
def test_the_register_is_asked_once_per_aircraft_not_once_per_frame(tmp_path):
|
||||
"""A five-hundred-frame animation asking the same question five hundred
|
||||
times would be five hundred times as rude."""
|
||||
asked = []
|
||||
|
||||
class _Book:
|
||||
def get(self, icao, callsign=""):
|
||||
asked.append(icao)
|
||||
return _Entry()
|
||||
|
||||
fm.animate(two_aircraft(), tmp_path / "counted.gif", fps=8, seconds=3,
|
||||
width=400, book=_Book())
|
||||
assert asked, "the register was never asked at all"
|
||||
assert len(asked) == len(set(asked)), f"asked twice about the same: {asked}"
|
||||
|
||||
|
||||
def test_the_route_reaches_the_drawn_picture(tmp_path):
|
||||
"""End to end: a register with a route, and the flags on the frame."""
|
||||
class _Book:
|
||||
def get(self, icao, callsign=""):
|
||||
return _Entry()
|
||||
|
||||
tracks = two_aircraft()
|
||||
view = fm.fit(tracks, width=700)
|
||||
base = fm.background(view)
|
||||
known = {t.icao: _Entry() for t in tracks}
|
||||
frame = fm.render_frame(base, view, tracks, tracks[0].first_seen + 60,
|
||||
known=known)
|
||||
assert (frame >= fm.FLAG).any(), "no flag was drawn"
|
||||
assert _has_text(frame, "KATL")
|
||||
assert _has_text(frame, "B739")
|
||||
|
||||
|
||||
def test_a_crowded_frame_goes_back_to_the_short_label():
|
||||
"""Five lines beside each of three hundred aircraft is not more
|
||||
information, it is a page of overlapping text with a map behind it."""
|
||||
many = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
|
||||
lat=51.0 + i * 0.02, lon=-1.0 + i * 0.02)
|
||||
for i in range(fm.CROWDED + 4)]
|
||||
view = fm.fit(many, width=900)
|
||||
base = fm.background(view)
|
||||
known = {t.icao: _Entry() for t in many}
|
||||
frame = fm.render_frame(base, view, many, many[0].first_seen + 60,
|
||||
known=known)
|
||||
assert not (frame >= fm.FLAG).any(), "still drawing flags when crowded"
|
||||
assert not _has_text(frame, "B739")
|
||||
|
||||
|
||||
def test_a_quiet_frame_keeps_every_detail():
|
||||
tracks = two_aircraft()
|
||||
view = fm.fit(tracks, width=700)
|
||||
base = fm.background(view)
|
||||
known = {t.icao: _Entry() for t in tracks}
|
||||
frame = fm.render_frame(base, view, tracks, tracks[0].first_seen + 60,
|
||||
known=known)
|
||||
assert (frame >= fm.FLAG).any()
|
||||
assert _has_text(frame, "B739")
|
||||
|
||||
|
||||
def test_the_callsign_and_height_survive_a_crowd():
|
||||
"""Whatever else goes, what the aircraft itself said stays."""
|
||||
many = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
|
||||
lat=51.0 + i * 0.02, lon=-1.0 + i * 0.02)
|
||||
for i in range(fm.CROWDED + 4)]
|
||||
view = fm.fit(many, width=900)
|
||||
frame = fm.render_frame(fm.background(view), view, many,
|
||||
many[0].first_seen + 60,
|
||||
known={t.icao: _Entry() for t in many})
|
||||
assert _has_text(frame, "FLT0")
|
||||
|
||||
|
||||
def test_labels_step_aside_rather_than_landing_on_each_other():
|
||||
"""Two aircraft passing close together is exactly the moment somebody is
|
||||
looking at that part of the picture."""
|
||||
close = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
|
||||
lat=51.0 + i * 0.004, lon=-1.0 + i * 0.004, seconds=60)
|
||||
for i in range(6)]
|
||||
view = fm.fit(close, width=800, box=(50.8, -1.4, 51.3, -0.6))
|
||||
taken = []
|
||||
base = fm.background(view)
|
||||
fm.render_frame(base, view, close, close[0].first_seen, labels=True)
|
||||
# Place them by hand so the boxes can be compared.
|
||||
for track in close:
|
||||
now = track.at(track.first_seen)
|
||||
x, y = view.xy(now.latitude, now.longitude)
|
||||
fm._label(base, x, y, track, now, fm.RAMP, taken)
|
||||
for i, one in enumerate(taken):
|
||||
for two in taken[i + 1:]:
|
||||
assert not fm._overlaps(one, two), f"{one} overlaps {two}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue