Four things about the animated pictures, and the window kept in step with them. The aerodromes were amber, and so is an aeroplane at twelve thousand feet. Sixteen units of CIELAB apart is not two colours, it is one: an aircraft low over a field was drawn in the field's own colour and neither could be picked out from the other. They are magenta now, fifty-six units from the nearest altitude colour, which is what the ramp leaves free once red, amber, green, cyan and violet have gone on height -- and what an aeronautical chart marks an aerodrome in anyway. The test states that as the distance rather than as the colour, so that changing the ramp cannot quietly walk an aircraft back into the airports. The height beside an aircraft was the flight level, which is shorter and is what an aviator reads, but "376" is only a height to somebody who already knows it is one. It is feet with the unit on it now, rounded to the twenty-five feet Mode S reports altitude in: a real reading is a multiple of that and comes through untouched, while a moment interpolated between two reports stops claiming to know the height to the foot. The flag of the country of registration now comes off the address block where no register answered. Taking it from the register's answer alone left the flag off exactly the aircraft that had nothing else beside them either. Mexico was missing from the address table while we were in there, which a receiver in the American southwest notices; two registers independently give XA- registrations for that block. And what sort of aircraft it is, which is two facts and not one. What it is comes off the air: every identification message carries three bits under its type code saying whether it is light, large, heavy, a rotorcraft, a glider, a drone or a van on the apron, and that is the only word about what an aircraft *is* that needs no register. They were being decoded and thrown away. They are inside the identification frame the log already writes down in full, so every log this program has ever written has them, including the ones written before anything here knew to look. Whether it is military comes off no air at all -- a tanker calls itself heavy exactly as an airliner does -- and is read from the address block instead. On one evening here AE07D3 broadcast "heavy" and sat in the United States military block; the register, asked separately, came back with a C-17A Globemaster III, tail 90-0534, United States Air Force. Labels in an animation now behave as the boxes in the window do. One keeps its place for as long as that place still works and is moved only when something takes it, which on a real log is about half as many moves as deciding afresh every frame; the moves that are left are eased over half a second of playback; and what the next label is laid out against is where a moving one is going rather than where it has reached. A still has no frame before it and places its labels exactly as it always did. The fading was only half done. A label's name faded, because it is drawn in the aircraft's own colour, but its rows and its flag were fixed colours and stayed at full brightness -- so the brightest thing on that part of the picture was the one aeroplane nothing had been heard from. An indexed picture cannot blend, so the row grey and all twelve flag colours now have dimmed copies at each of the four fade steps, and the whole box goes together. A crowded frame, which drops back to the short label, drops the class and the flag with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""The live display: one line per aircraft, updated in place.
|
||
|
||
What matters here is not that it is pretty but that it says the right things
|
||
and stops saying them at the right time -- an aircraft that has gone must
|
||
leave the screen, and everything below it must move up.
|
||
"""
|
||
import time
|
||
|
||
import pytest
|
||
from rich.console import Console
|
||
|
||
from bandsaunter import aircraft as air
|
||
from bandsaunter.adsb import Aircraft, AircraftRegistry
|
||
from bandsaunter.ui import AircraftDisplay, age_style, altitude_style, compass
|
||
|
||
|
||
NOW = 1_000_000.0
|
||
|
||
|
||
def craft(icao="4CA1FA", callsign="RYR1234", seen=NOW, first=None, **over):
|
||
one = Aircraft(icao=icao, callsign=callsign,
|
||
first_seen=first if first is not None else seen,
|
||
last_seen=seen)
|
||
one.altitude_ft = over.pop("altitude_ft", 35_000)
|
||
one.ground_speed_kt = over.pop("ground_speed_kt", 420.0)
|
||
one.track_deg = over.pop("track_deg", 90.0)
|
||
one.latitude = over.pop("latitude", 51.5)
|
||
one.longitude = over.pop("longitude", -0.12)
|
||
one.messages = over.pop("messages", 7)
|
||
for key, value in over.items():
|
||
setattr(one, key, value)
|
||
return one
|
||
|
||
|
||
def registry(*aircraft) -> AircraftRegistry:
|
||
reg = AircraftRegistry()
|
||
for one in aircraft:
|
||
reg.aircraft[one.icao] = one
|
||
return reg
|
||
|
||
|
||
def shown(display, now=NOW, width=140) -> str:
|
||
console = Console(width=width, record=True, force_terminal=True)
|
||
console.print(display.render(now=now, width=width))
|
||
return console.export_text()
|
||
|
||
|
||
def display_for(*aircraft, frames=None, hold=45.0, book=None, width=140):
|
||
reg = registry(*aircraft)
|
||
console = Console(width=width, force_terminal=True)
|
||
d = AircraftDisplay(console, book=book, hold=hold)
|
||
d.started = NOW - 10
|
||
d.update(reg, frames if frames is not None else
|
||
sum(a.messages for a in aircraft))
|
||
return d
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# What is on the line
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_an_aircraft_appears_with_everything_it_has_said():
|
||
d = display_for(craft())
|
||
text = shown(d)
|
||
assert "RYR1234" in text # who
|
||
assert "4CA1FA" in text # its address
|
||
assert "35,000" in text # how high
|
||
assert "420" in text # how fast
|
||
assert "090°" in text and "E" in text # which way
|
||
assert "51.5000" in text and "-0.1200" in text
|
||
assert "7" in text # how many frames
|
||
|
||
|
||
def test_the_counter_is_the_frame_count_and_it_climbs():
|
||
one = craft(messages=3)
|
||
d = display_for(one)
|
||
assert " 3 " in shown(d).replace(" ", " ")
|
||
one.messages = 41
|
||
d.update(d.registry, 41)
|
||
assert "41" in shown(d)
|
||
|
||
|
||
def test_a_climb_and_a_descent_are_marked():
|
||
up = shown(display_for(craft(vertical_rate_fpm=1600)))
|
||
down = shown(display_for(craft(icao="A0B1C2", vertical_rate_fpm=-1600)))
|
||
assert "↑" in up and "↓" not in up
|
||
assert "↓" in down and "↑" not in down
|
||
|
||
|
||
def test_an_aircraft_that_has_not_said_its_name_still_gets_a_line():
|
||
text = shown(display_for(craft(callsign="")))
|
||
assert "4CA1FA" in text
|
||
assert "—" in text # the callsign it never gave
|
||
|
||
|
||
def test_an_aircraft_with_no_position_yet_says_so_rather_than_nothing():
|
||
one = craft(latitude=0.0, longitude=0.0)
|
||
assert "no fix yet" in shown(display_for(one))
|
||
|
||
|
||
def test_what_a_register_says_is_shown_beside_it():
|
||
class _Book:
|
||
def get(self, icao, callsign=""):
|
||
from bandsaunter.flights import Flight
|
||
return Flight(icao=icao, callsign=callsign, type_code="B738",
|
||
registration="EI-DYP", operator="Ryanair",
|
||
origin="Stansted", destination="East Midlands")
|
||
|
||
text = shown(display_for(craft(), book=_Book()))
|
||
assert "B738" in text and "EI-DYP" in text
|
||
assert "Ryanair" in text
|
||
assert "Stansted" in text
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Coming and going
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_a_second_aircraft_is_added_under_the_first():
|
||
first = craft(icao="4CA1FA", callsign="RYR1234", first=NOW - 100)
|
||
second = craft(icao="A0B1C2", callsign="UAL99", first=NOW - 50)
|
||
text = shown(display_for(first, second))
|
||
assert text.index("RYR1234") < text.index("UAL99")
|
||
|
||
|
||
def test_an_aircraft_nothing_has_been_heard_from_is_dropped():
|
||
"""It has gone out of range; its line would otherwise sit there all
|
||
evening saying the same thing."""
|
||
here = craft(icao="4CA1FA", callsign="RYR1234", seen=NOW - 2)
|
||
gone = craft(icao="A0B1C2", callsign="UAL99", seen=NOW - 120)
|
||
text = shown(display_for(here, gone, hold=45.0))
|
||
assert "RYR1234" in text
|
||
assert "UAL99" not in text
|
||
|
||
|
||
def test_the_ones_below_move_up_when_one_goes():
|
||
top = craft(icao="111111", callsign="FIRST", first=NOW - 300, seen=NOW - 300)
|
||
middle = craft(icao="222222", callsign="SECOND", first=NOW - 200, seen=NOW)
|
||
bottom = craft(icao="333333", callsign="THIRD", first=NOW - 100, seen=NOW)
|
||
d = display_for(top, middle, bottom, hold=45.0)
|
||
lines = [x for x in shown(d).splitlines() if "SECOND" in x or "THIRD" in x
|
||
or "FIRST" in x]
|
||
assert len(lines) == 2 # FIRST has gone
|
||
assert "SECOND" in lines[0] and "THIRD" in lines[1]
|
||
|
||
|
||
def test_how_long_they_stay_can_be_changed():
|
||
quiet = craft(seen=NOW - 30)
|
||
assert "RYR1234" in shown(display_for(quiet, hold=45.0))
|
||
assert "RYR1234" not in shown(display_for(quiet, hold=10.0))
|
||
|
||
|
||
def test_the_age_of_the_last_frame_is_shown_and_coloured():
|
||
text = shown(display_for(craft(seen=NOW - 12)))
|
||
assert "12s" in text
|
||
assert age_style(1.0) != age_style(12.0) != age_style(40.0)
|
||
|
||
|
||
def test_nothing_heard_yet_says_so_rather_than_drawing_an_empty_table():
|
||
d = AircraftDisplay(Console(width=120, force_terminal=True))
|
||
d.update(AircraftRegistry(), 0)
|
||
assert "nothing heard yet" in shown(d)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The heading
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_the_heading_counts_what_is_overhead_and_what_has_been_seen():
|
||
here = craft(icao="4CA1FA", callsign="RYR1234", seen=NOW)
|
||
gone = craft(icao="A0B1C2", callsign="UAL99", seen=NOW - 500)
|
||
text = shown(display_for(here, gone, frames=99, hold=45.0))
|
||
assert "1 overhead" in text
|
||
assert "2 seen" in text # nothing is forgotten
|
||
assert "99 frames" in text
|
||
|
||
|
||
def test_the_heading_names_the_log_being_written(tmp_path):
|
||
d = display_for(craft())
|
||
d.update(d.registry, 5, tmp_path / "adsb_2026-09-03_20_00_00.jsonl")
|
||
assert "adsb_2026-09-03_20_00_00.jsonl" in shown(d)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Colour, and a narrow terminal
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_height_is_a_colour_low_warm_to_high_cold():
|
||
low, high = altitude_style(800), altitude_style(38_000)
|
||
assert low != high
|
||
assert "red" in low and "blue" in high
|
||
|
||
|
||
def test_the_colours_reach_the_screen():
|
||
console = Console(width=140, record=True, force_terminal=True,
|
||
color_system="truecolor")
|
||
console.print(display_for(craft(altitude_ft=1_200)).render(now=NOW))
|
||
assert "\x1b[" in console.export_text(styles=True)
|
||
|
||
|
||
@pytest.mark.parametrize("degrees,point", [(0, "N"), (90, "E"), (180, "S"),
|
||
(270, "W"), (45, "NE"),
|
||
(359, "N"), (247, "WSW")])
|
||
def test_a_heading_is_also_given_as_a_point_of_the_compass(degrees, point):
|
||
assert compass(degrees) == point
|
||
|
||
|
||
def test_a_narrow_terminal_keeps_what_only_the_aircraft_can_say():
|
||
"""A website can be read later; the aeroplane cannot."""
|
||
text = shown(display_for(craft()), width=80)
|
||
assert "RYR1234" in text and "35,000" in text and "420" in text
|
||
for line in text.splitlines():
|
||
assert len(line) <= 80
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# When there is no terminal to draw on
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_a_pipe_or_a_log_file_gets_no_live_display():
|
||
"""Four redraws a second is unreadable as a stream and useless in a file."""
|
||
console = Console(width=100, file=open("/dev/null", "w"),
|
||
force_terminal=False)
|
||
display, live = air._open_display(console, air.AircraftOptions(), None,
|
||
time.time())
|
||
assert display is None and live is None
|
||
|
||
|
||
def test_asking_for_every_frame_turns_the_table_off():
|
||
console = Console(width=100, force_terminal=True)
|
||
options = air.AircraftOptions(frames=True)
|
||
display, live = air._open_display(console, options, None, time.time())
|
||
assert display is None and live is None
|
||
|
||
|
||
def test_a_terminal_gets_one():
|
||
console = Console(width=100, force_terminal=True,
|
||
file=open("/dev/null", "w"))
|
||
display, live = air._open_display(console, air.AircraftOptions(), None,
|
||
time.time())
|
||
try:
|
||
assert display is not None and live is not None
|
||
assert display.hold == air.AircraftOptions().hold
|
||
finally:
|
||
if live is not None:
|
||
live.stop()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Speeds in whatever the user reads
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.mark.parametrize("unit,heading,shown_speed", [
|
||
("knots", "speed kt", "420"),
|
||
("mph", "speed mph", "483"),
|
||
("kph", "speed km/h", "778"),
|
||
])
|
||
def test_the_heading_and_the_number_change_together(unit, heading, shown_speed):
|
||
"""A number with the wrong unit over it is worse than no number."""
|
||
d = display_for(craft(ground_speed_kt=420.0))
|
||
d.unit = unit
|
||
text = shown(d)
|
||
assert heading in text
|
||
assert shown_speed in text
|
||
|
||
|
||
def test_knots_are_what_it_shows_unless_told_otherwise():
|
||
assert AircraftDisplay(Console(width=100)).unit == "knots"
|
||
assert air.AircraftOptions().speed_unit == "knots"
|
||
|
||
|
||
def test_the_unit_reaches_the_display_from_the_options():
|
||
console = Console(width=100, force_terminal=True, file=open("/dev/null", "w"))
|
||
options = air.AircraftOptions(speed_unit="kph")
|
||
display, live = air._open_display(console, options, None, time.time())
|
||
try:
|
||
assert display.unit == "kph"
|
||
finally:
|
||
if live is not None:
|
||
live.stop()
|
||
|
||
|
||
def test_the_emitter_category_is_kept_as_the_frames_arrive():
|
||
"""It comes off the air in the identification message, alongside the
|
||
callsign, and is the only word about what an aircraft *is* that does
|
||
not need a register."""
|
||
import binascii
|
||
|
||
from bandsaunter.adsb import _read, category_name
|
||
|
||
# A real identification frame: type code 4, category 3.
|
||
raw = binascii.unhexlify("8DA80A97234CB5F5CF4C604EB016")
|
||
frame = _read("".join(f"{b:08b}" for b in raw), raw)
|
||
assert (frame.type_code, frame.category) == (4, 3)
|
||
assert category_name(4, 3) == "large"
|
||
|
||
reg = AircraftRegistry()
|
||
craft = reg.add(frame, when=1.0)
|
||
assert craft.category == "large"
|
||
|
||
|
||
def test_an_aircraft_that_declines_to_say_is_not_given_a_category():
|
||
from bandsaunter.adsb import Frame, category_name
|
||
|
||
assert category_name(4, 0) == "" # zero means "not saying"
|
||
assert category_name(9, 3) == "" # not an identification message
|
||
reg = AircraftRegistry()
|
||
craft = reg.add(Frame(icao="ABCDEF", type_code=4, category=0), when=1.0)
|
||
assert craft.category == ""
|
||
|
||
|
||
def test_the_same_three_bits_mean_different_things_under_different_codes():
|
||
"""Which list they index depends on the type code, which is why it is a
|
||
table of tables: category 1 is a light aeroplane under type 4 and a
|
||
glider under type 3."""
|
||
from bandsaunter.adsb import category_name
|
||
|
||
assert category_name(4, 1) == "light"
|
||
assert category_name(3, 1) == "glider"
|
||
assert category_name(2, 1) == "emergency vehicle"
|
||
assert category_name(4, 7) == "rotorcraft"
|
||
assert category_name(3, 7) == "spacecraft"
|