A sharper map, and aircraft that fade rather than blink out

Three things asked for, and a fourth found while doing them.

The map looked like a photograph of a map, and did so twice over.  The
window fetched at its own pixel size but for a box a third larger in each
direction -- the margin added to stop the ground blinking -- and then cut
the middle out, so every pixel was enlarged by two thirds.  It now asks
for enough pixels to cover the bigger box at the window's own detail.
Underneath that, both the window and the animation took the nearest source
pixel: the tile mosaic is commonly half again the size of the picture, so
most of every tile was thrown away and what survived was the aliasing.
Both now average the source pixels that fall in each output cell, done as
the difference of a running total rather than a loop.

An aircraft that goes quiet now fades instead of vanishing.  Taking it off
between one frame and the next says it stopped existing; fading says it
stopped talking, which is what happened.  It fades where it was last
actually seen and never along a reckoned track, because the reason for
giving up on it is that where it would be by now is a guess.  --fade sets
how long, and it is in the menu.  The window has alpha and fades smoothly;
an indexed picture cannot blend, so the animation gained a fourth ramp at
a seventh of full and fades in four steps, which at a second apart reads
as a fade.  A trail fades with the aircraft it belongs to, and the box
goes before the symbol does.

The aircraft's country of registration carries a flag now as well as the
two ends of its route, from the register where one answered and from the
address block otherwise.

And the fourth: the window's header counted an aircraft that had gone
quiet as overhead, which was saying more than had been heard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-04 19:04:09 -07:00
parent df080f6571
commit 87b0954f0c
17 changed files with 1609 additions and 101 deletions

View file

@ -55,7 +55,7 @@ def a_blip(icao="A76154", callsign="DAL538", lat=32.95, lon=-110.95,
def a_sky(*blips, **over) -> Sky:
settings = dict(unit="knots", hold=45.0, home=(32.4325, -111.0841),
radius_nm=100.0)
radius_nm=100.0, brightness=0.70)
settings.update(over)
sky = Sky(**settings)
sky.started = now() - 600
@ -297,11 +297,60 @@ def test_nothing_heard_yet_has_no_middle():
assert a_sky(home=None).centre() is None
def test_a_map_is_only_used_for_the_view_it_was_fetched_for():
def test_a_map_is_used_for_any_view_it_covers():
"""The whole point of fetching more than is shown: a view that has
drifted a little is still inside what was fetched."""
sky = a_sky()
sky.set_ground(np.zeros((4, 4), dtype=np.uint8), "a")
assert sky.ground("a") is not None
assert sky.ground("b") is None
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k",
(31.0, -113.0, 34.0, -109.0))
levels, box = sky.ground_covering(32.0, -112.0, 33.0, -110.0)
assert levels is not None and box == (31.0, -113.0, 34.0, -109.0)
def test_a_map_is_not_used_for_a_view_that_has_left_it():
sky = a_sky()
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k",
(31.0, -113.0, 34.0, -109.0))
assert sky.ground_covering(40.0, -113.0, 42.0, -109.0)[0] is None
assert sky.ground_covering(31.0, -120.0, 34.0, -109.0)[0] is None
@qt
def test_the_map_is_fetched_at_the_size_the_bigger_box_needs(app):
"""Fetching a third more world into the same pixels and then cutting the
middle out of it is how a sharp map ends up looking like a photograph of
a map."""
from bandsaunter.livemap import GROUND_MARGIN, SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
view._draw_ground(_NoPainter(), view.projection())
wanted = sky.wanted_ground()
assert wanted is not None
_key, _box, (width, height) = wanted
assert width >= 900 * (1 + 2 * GROUND_MARGIN) - 2
assert height >= 650 * (1 + 2 * GROUND_MARGIN) - 2
class _NoPainter:
"""Stands in for a painter for the one call that only asks for a map."""
def drawImage(self, *a):
raise AssertionError("nothing should be drawn without a map")
def test_more_is_fetched_than_is_shown(app):
"""A window that fetched exactly what it needed would fetch again on
every resize and every small drift of the middle."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(900, 650)
projection = view.projection()
box = view.ground_box(projection)
assert box[0] < projection.south and box[1] < projection.west
assert box[2] > projection.north and box[3] > projection.east
def test_a_map_that_could_not_be_fetched_is_not_asked_for_again():
@ -314,6 +363,84 @@ def test_a_map_that_could_not_be_fetched_is_not_asked_for_again():
assert sky.wanted_ground() is None
# ---------------------------------------------------------------------------
# The map staying still
# ---------------------------------------------------------------------------
def test_the_middle_settles_and_then_stays_put():
"""Recomputing it on every paint moves the map a fraction of a mile
whenever an aircraft appears or leaves, which throws away the tiles
fetched for the old view and blinks the ground out several times a
minute."""
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0),
a_blip(icao="2", lat=32.5, lon=-111.1),
a_blip(icao="3", lat=32.6, lon=-111.2), home=None)
settled = sky.centre()
assert settled is not None
# One more aircraft, a little to one side: the middle must not follow it.
sky.update([a_blip(icao="4", lat=32.9, lon=-110.7)], 1, 4)
assert sky.centre() == settled
def test_the_middle_does_move_if_it_is_really_somewhere_else():
"""Settling must not mean stuck: the first few aircraft can be anywhere."""
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0), home=None)
first = sky.centre()
for i in range(6):
sky.update([a_blip(icao=f"far{i}", lat=40.0 + i * 0.1, lon=-90.0)],
1, i)
assert sky.centre() != first
assert abs(sky.centre()[0] - 40.0) < 2
def test_a_receiver_that_was_told_where_it_is_never_drifts_at_all():
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0))
for i in range(5):
sky.update([a_blip(icao=f"x{i}", lat=35.0 + i, lon=-100.0)], 1, i)
assert sky.centre() == (32.4325, -111.0841)
@qt
def test_a_small_drift_does_not_throw_the_map_away(app):
"""The failure this is here for: the ground blinking out because the
view moved by less than a mile."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="1", lat=32.40, lon=-111.00),
a_blip(icao="2", lat=32.50, lon=-111.10), home=None)
view = SkyView(sky)
view.resize(900, 650)
first = view.projection()
sky.set_ground(np.full((650, 900), 20, dtype=np.uint8),
view.ground_key(first), view.ground_box(first))
# Another aircraft arrives a few miles away.
sky.update([a_blip(icao="3", lat=32.44, lon=-111.06)], 1, 3)
moved = view.projection()
assert sky.ground_covering(moved.south, moved.west,
moved.north, moved.east)[0] is not None
@qt
def test_the_crop_lines_the_map_up_with_the_view(app):
"""A map fetched for a bigger box has to be cut to the right piece, or
the coast is drawn in the wrong place and the aircraft with it."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(100, 80)
projection = view.projection()
box = (projection.south - 1.0, projection.west - 1.0,
projection.north + 1.0, projection.east + 1.0)
# A map that is dark in the north and bright in the south.
levels = np.repeat(np.linspace(0, 31, 200).astype(np.uint8)[:, None],
200, axis=1)
cropped = view._crop(levels, box, projection)
assert cropped.shape == (projection.height, projection.width)
assert cropped[0].mean() < cropped[-1].mean() # north still on top
# And the piece taken is the middle of the fetched one, not all of it.
assert cropped.min() > levels.min() and cropped.max() < levels.max()
def test_the_map_is_fetched_off_the_painting_thread():
asked = []
@ -477,12 +604,18 @@ def test_the_map_underneath_is_drawn_when_there_is_one(app):
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
key = view.ground_key(view.projection())
sky.set_ground(np.full((650, 900), 31, dtype=np.uint8), key)
projection = view.projection()
sky.set_ground(np.full((650, 900), 31, dtype=np.uint8),
view.ground_key(projection),
view.ground_box(projection))
lit = _rendered(view)[:, :, :3]
from bandsaunter.flightmap import GROUND, GROUND_SHADES, PALETTE
from bandsaunter.flightmap import (GROUND, GROUND_SHADES, PALETTE,
dim_ground)
r, g, b = (int(v) for v in PALETTE[GROUND + GROUND_SHADES - 1])
# The brightest shade a drawing reaches is what the brightness allows,
# not the top of the palette.
top = int(dim_ground(np.array([[GROUND_SHADES - 1]]), sky.brightness)[0, 0])
r, g, b = (int(v) for v in PALETTE[GROUND + top])
covered = ((lit[:, :, 2] == r) & (lit[:, :, 1] == g) & (lit[:, :, 0] == b))
assert covered.mean() > 0.5, "the map was not painted under the aircraft"
@ -652,3 +785,185 @@ def test_the_widest_box_stays_within_reason(app):
width, _height = view._box_size(
view._wrapped(blip.lines("mph", home=(32.4325, -111.0841))))
assert width < 320, width
# ---------------------------------------------------------------------------
# How bright the map is
# ---------------------------------------------------------------------------
def test_the_map_brightness_reaches_the_window():
from bandsaunter import aircraft as air
sky = Sky(brightness=0.9)
assert sky.brightness == 0.9
assert air.AircraftOptions().map_brightness == 70
@qt
def test_turning_the_brightness_down_darkens_the_map(app):
from bandsaunter.livemap import SkyView
def painted(brightness):
sky = a_sky(a_blip(), brightness=brightness)
view = SkyView(sky)
view.resize(400, 300)
projection = view.projection()
sky.set_ground(np.full((300, 400), 31, dtype=np.uint8),
view.ground_key(projection),
view.ground_box(projection))
return _rendered(view, 400, 300)[:, :, :3].astype(float).mean()
assert painted(0.3) < painted(0.7) < painted(1.0)
@qt
def test_the_keys_change_the_brightness(app):
from bandsaunter.livemap import Window, _qt
_name, core, gui, _widgets, _signal = _qt()
window = Window(a_sky(a_blip()))
try:
def press(letter):
window.keyPressEvent(gui.QKeyEvent(
_get(core.QEvent, "Type", "KeyPress"), ord(letter),
_get(core.Qt, "KeyboardModifier", "NoModifier"), letter))
was = window.sky.brightness
press("]")
assert window.sky.brightness > was
press("[")
press("[")
assert window.sky.brightness < was
for _ in range(20): # and never off either end
press("[")
assert 0.0 < window.sky.brightness <= 1.0
finally:
window.close()
def test_the_country_of_registration_carries_a_flag():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
craft = Aircraft(icao="4CA1FA", callsign="RYR1234")
blip = blip_for(craft, Flight(icao="4CA1FA", owner_country="Ireland"))
assert blip.country == "Ireland" and blip.country_code == "IE"
rows = {label: (value, flag) for label, value, flag in blip.lines("knots")}
assert rows["reg'd"] == ("Ireland", "IE")
def test_a_country_with_no_code_gets_no_flag_rather_than_a_wrong_one():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
blip = blip_for(Aircraft(icao="4CA1FA"),
Flight(icao="4CA1FA", owner_country="Atlantis"))
assert blip.country_code == ""
# ---------------------------------------------------------------------------
# Fading out rather than blinking out
# ---------------------------------------------------------------------------
def test_an_aircraft_still_being_heard_is_drawn_in_full():
sky = a_sky(hold=10.0, fade=20.0)
assert sky.strength(a_blip(last_seen=now() - 1)) == 1.0
assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0
def test_one_that_has_gone_quiet_fades_rather_than_vanishing():
"""Taking it off between one frame and the next says it stopped
existing; fading says it stopped talking, which is what happened."""
sky = a_sky(hold=10.0, fade=20.0)
at = now()
strengths = [sky.strength(a_blip(last_seen=at - age), at)
for age in (5, 15, 20, 25, 29)]
assert strengths == sorted(strengths, reverse=True)
assert strengths[0] == 1.0
assert 0.0 < strengths[-1] < 0.1
def test_it_is_gone_once_it_has_finished_fading():
sky = a_sky(hold=10.0, fade=20.0)
old = a_blip(last_seen=now() - 40)
assert sky.strength(old) == 0.0
sky.update([old], 1, 1)
assert old.icao not in [b.icao for b in sky.flying()]
def test_it_stays_on_the_picture_for_the_whole_fade():
sky = a_sky(hold=10.0, fade=20.0)
quiet = a_blip(last_seen=now() - 25)
sky.update([quiet], 1, 1)
assert [b.icao for b in sky.flying()] == [quiet.icao]
def test_no_fade_at_all_takes_it_away_at_once():
sky = a_sky(hold=10.0, fade=0.0)
assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0
assert sky.strength(a_blip(last_seen=now() - 11)) == 0.0
def test_how_long_the_fade_takes_is_a_setting():
from bandsaunter import aircraft as air
assert air.AircraftOptions().fade == 20.0
quick = a_sky(hold=10.0, fade=4.0)
slow = a_sky(hold=10.0, fade=60.0)
faded = a_blip(last_seen=now() - 20)
assert quick.strength(faded) == 0.0
assert slow.strength(faded) > 0.5
def test_the_setting_reaches_the_window():
assert Sky(fade=7.5).fade == 7.5
@qt
def test_a_fading_aircraft_is_drawn_fainter(app):
from bandsaunter.livemap import SkyView
def brightness(age):
sky = a_sky(hold=5.0, fade=20.0)
sky.update([a_blip(last_seen=now() - age)], 1, 1)
view = SkyView(sky)
view.detail = 0
picture = _rendered(view, 500, 400)
lit = _lit(picture)
if not len(lit):
return 0.0
return float(picture[lit[:, 0], lit[:, 1], :3].mean())
assert brightness(1) > brightness(20)
@qt
def test_a_faded_aircraft_keeps_its_symbol_and_loses_its_box(app):
"""A box at a tenth of its colour is something in the way of the
aircraft still flying."""
from bandsaunter.livemap import SkyView
sky = a_sky(hold=5.0, fade=20.0)
sky.update([a_blip(last_seen=now() - 22)], 1, 1) # strength about 0.15
view = SkyView(sky)
fresh = _painted(_rendered(view, 600, 450))
sky2 = a_sky(hold=5.0, fade=20.0)
sky2.update([a_blip(last_seen=now() - 1)], 1, 1)
view2 = SkyView(sky2)
full = _painted(_rendered(view2, 600, 450))
assert fresh < full, "the box was still drawn on a nearly faded aircraft"
@qt
def test_one_that_has_gone_quiet_is_not_counted_as_overhead(app):
"""It is on the picture, fading; saying it is overhead says more than
was heard."""
from bandsaunter.livemap import SkyView
sky = a_sky(hold=5.0, fade=30.0)
sky.update([a_blip(icao="AAA111", last_seen=now() - 1),
a_blip(icao="BBB222", lat=32.6, last_seen=now() - 20)], 9, 2)
picture = _rendered(SkyView(sky), 900, 650)
assert _painted(picture) > 100 # both are drawn
assert len(sky.flying()) == 2
assert sum(1 for b in sky.flying() if sky.strength(b) >= 1.0) == 1