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

@ -212,6 +212,7 @@ def _listen_and_draw(monkeypatch, console, tmp_path, picture="png"):
number("picture"), picture, # what to draw
number("lookup"), "no", # no lookups: no network in a test
number("basemap"), "no", # nor a tile server
number("airports"), "no", # nor the map data
"l", # listen now
"m", "1", # draw the newest log
"b"], cfg)
@ -266,7 +267,7 @@ def test_listening_can_draw_as_soon_as_it_stops(monkeypatch, console,
run(monkeypatch, console,
[number("simulate"), "yes", number("seconds"), "3",
number("lookup"), "no", number("basemap"), "no",
number("draw_after"), "yes",
number("airports"), "no", number("draw_after"), "yes",
number("picture"), "png", "l", "b"], cfg)
assert list(tmp_path.glob("adsb_*.png"))

View file

@ -4,7 +4,9 @@ Nothing here touches the network. The tiles are made up in the test and fed
in through the same door the real ones come through, and the PNGs are built
here from the specification rather than by the decoder they are testing.
"""
import json
import struct
import time
import zlib
import numpy as np
@ -418,7 +420,7 @@ def test_the_tile_server_can_be_pointed_somewhere_else(tmp_path, monkeypatch):
def test_the_map_goes_under_the_picture_and_is_credited(tmp_path):
out = fm.animate(two_aircraft(), tmp_path / "on-the-map.png", width=400,
ground=True, fetch=gradient_tile)
ground=True, fetch=gradient_tile, airports=False)
assert out is not None and out.ground
assert "on the map" in out.summary()
from bandsaunter.images import PNG_SIGNATURE
@ -457,3 +459,221 @@ def test_the_credit_is_written_on_the_picture_itself():
levels, credit = fm.ground_for(view, fetch=gradient_tile)
base = fm.background(view, ground=levels, attribution=credit)
assert _has_text(base, "OPENSTREETMAP")
# ---------------------------------------------------------------------------
# What aerodromes are under the picture
# ---------------------------------------------------------------------------
def _overpass(elements):
"""Stand in for the map data, in the shape it really answers with."""
def ask(box, url, timeout):
return {"elements": elements}
return ask
def _node(code=None, name="", lat=32.1, lon=-110.9, ref=None, kind="node"):
tags = {"aeroway": "aerodrome", "name": name}
if code:
tags["icao"] = code
if ref:
tags["ref"] = ref
element = {"type": kind, "tags": tags}
if kind == "node":
element.update({"lat": lat, "lon": lon})
else:
element["center"] = {"lat": lat, "lon": lon}
return element
def test_the_aerodromes_in_a_box_come_back_with_a_code_and_a_place(tmp_path):
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node("KTUS", "Tucson International")]))
assert len(got) == 1
assert got[0]["code"] == "KTUS"
assert got[0]["latitude"] == pytest.approx(32.1)
def test_the_big_airports_are_relations_and_must_be_asked_for():
"""Tucson International and Davis-Monthan are both relations; asking
only for nodes and ways finds every airstrip in the county and misses
the two the county is known for."""
import inspect
source = inspect.getsource(bm._ask_overpass)
for kind in ("node", "way", "relation"):
assert f'{kind}["aeroway"="aerodrome"]' in source, kind
def test_a_relation_is_placed_by_its_middle(tmp_path):
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node("KDMA", "Davis-Monthan",
kind="relation")]))
assert got and got[0]["code"] == "KDMA"
def test_a_landing_strip_with_no_real_code_is_left_off(tmp_path):
"""A local identifier like 14AZ names nothing anybody would recognise,
and turns a map into a list of airstrips."""
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node(None, "Ruby Star", ref="14AZ"),
_node(None, "Nowhere",
ref="MX-0492"),
_node(None, "Unnamed strip")]))
assert got == []
def test_a_four_letter_reference_is_good_enough(tmp_path):
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node(None, "Somewhere", ref="EGLL")]))
assert [a["code"] for a in got] == ["EGLL"]
def test_one_airport_tagged_twice_is_marked_once(tmp_path):
"""A point for the terminal and an outline for the field: marking both
writes the name over itself."""
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node("KFHU", "Sierra Vista"),
_node("KFHU", "Sierra Vista",
kind="relation")]))
assert [a["code"] for a in got] == ["KFHU"]
def test_the_ones_with_real_codes_come_first(tmp_path):
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass([_node(None, "Strip", ref="ZZZZ"),
_node("KTUS", "Tucson")]))
assert [a["code"] for a in got][0] == "KTUS"
def test_a_view_full_of_airstrips_is_capped(tmp_path):
many = [_node(f"K{i:03d}"[:4], f"Strip {i}") for i in range(200)]
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
ask=_overpass(many))
assert len(got) <= bm.MOST_AIRPORTS
def test_the_answer_is_kept_so_the_question_is_asked_once(tmp_path):
"""A runway does not move, and the service being asked is a volunteer
one."""
asked = []
def ask(box, url, timeout):
asked.append(box)
return {"elements": [_node("KTUS", "Tucson")]}
where = tmp_path / "a.json"
first = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where, ask=ask)
second = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where, ask=ask)
assert first == second
assert len(asked) == 1, "asked twice for the same piece of the world"
def test_an_answer_from_an_older_question_is_asked_again(tmp_path):
where = tmp_path / "a.json"
where.write_text(json.dumps({"fetched_at": time.time(), "version": 1,
"airports": [{"code": "OLD"}]}))
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where,
ask=_overpass([_node("KTUS", "Tucson")]))
assert [a["code"] for a in got] == ["KTUS"]
def test_no_network_means_no_airports_rather_than_no_map(tmp_path):
def refuse(box, url, timeout):
raise OSError("no route to host")
assert bm.airports_in(32.0, -111.2, 32.4, -110.7,
cache=tmp_path / "a.json", ask=refuse) == []
def test_nonsense_from_the_service_is_survived(tmp_path):
for answer in ({}, {"elements": None}, {"elements": [{"tags": None}]},
{"elements": [{"tags": {"icao": "KTUS"}}]}):
got = bm.airports_in(32.0, -111.2, 32.4, -110.7,
cache=tmp_path / f"{id(answer)}.json",
ask=lambda b, u, t, a=answer: a)
assert got == []
def test_the_map_asks_for_the_airports_under_it():
"""A route names where its aircraft are going; the airports underneath
are what say where on the map you are looking."""
view = fm.fit(two_aircraft(), width=500)
got = fm.local_airports(view, ask=_overpass([_node("EGLL", "Heathrow",
lat=view.south + 0.1,
lon=view.west + 0.1)]))
assert got and got[0][0] == "EGLL"
def test_a_service_that_is_not_there_costs_the_airports_and_nothing_else():
def refuse(box, url, timeout):
raise OSError("no")
view = fm.fit(two_aircraft(), width=500)
assert fm.local_airports(view, ask=refuse) == []
# ---------------------------------------------------------------------------
# How sharp the map is
# ---------------------------------------------------------------------------
def test_detail_is_averaged_down_rather_than_thrown_away():
"""Taking the nearest source pixel keeps a fraction of a tile and turns
the rest into aliasing: hard, broken lettering and roads that come and
go along their length. A checkerboard is half black and half white, so
averaging it lands in the middle and sampling it lands at one end."""
fine = np.tile(np.array([0.0, 255.0], dtype=np.float32), 128)[None, :]
out = bm._resample(fine, np.linspace(0, 256, 41), axis=1)
assert out.shape == (1, 40)
assert abs(float(out.mean()) - 127.5) < 4.0
assert float(out.max()) < 160.0 and float(out.min()) > 95.0
def test_averaging_takes_the_whole_cell_and_no_more():
"""Each output cell is the mean of the source pixels under it."""
values = np.arange(100, dtype=np.float32)[None, :]
out = bm._resample(values, np.linspace(0, 100, 11), axis=1)
assert out.shape == (1, 10)
for i in range(10):
assert abs(float(out[0, i]) - (i * 10 + 4.5)) < 0.01
def test_averaging_works_the_other_way_up_too():
values = np.arange(60, dtype=np.float32)[:, None]
out = bm._resample(values, np.linspace(0, 60, 7), axis=0)
assert out.shape == (6, 1)
assert float(out[0, 0]) < float(out[-1, 0])
def test_a_cell_smaller_than_a_source_pixel_takes_that_pixel():
"""Zoomed in past the tiles, a cell covers less than one of them."""
values = np.array([[10.0, 20.0, 30.0]], dtype=np.float32)
out = bm._resample(values, np.linspace(0, 3, 10), axis=1)
assert out.shape == (1, 9)
assert set(np.round(out[0]).astype(int)) <= {10, 20, 30}
def test_a_gradient_still_comes_out_as_a_gradient():
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 64, 64, shades=32,
fetch=gradient_tile, zoom=9, pause=0)
rows = levels.mean(axis=1)
assert (np.diff(rows) <= 0.51).all(), "the gradient came out lumpy"
def test_averaging_is_the_same_shape_as_the_picture_asked_for():
south, west, north, east = tile_bounds(9, 81, 178)
for width, height in ((40, 40), (137, 91), (300, 200), (17, 5)):
levels = bm.ground_under(south, west, north, east, width, height,
shades=32, fetch=gradient_tile, zoom=9,
pause=0)
assert levels.shape == (height, width)
def test_a_map_asked_for_at_more_detail_than_the_tiles_hold_still_works():
"""Zoomed in past the tiles, a cell covers less than one source pixel."""
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 2000, 2000, shades=32,
fetch=gradient_tile, zoom=9, pause=0)
assert levels.shape == (2000, 2000)
assert levels[0].mean() != levels[-1].mean()

View file

@ -922,3 +922,267 @@ def test_labels_step_aside_rather_than_landing_on_each_other():
for i, one in enumerate(taken):
for two in taken[i + 1:]:
assert not fm._overlaps(one, two), f"{one} overlaps {two}"
# ---------------------------------------------------------------------------
# The country an aircraft is registered in
# ---------------------------------------------------------------------------
def test_the_registration_carries_the_country_it_is_registered_in():
track = straight()
entry = _Entry()
entry.owner_country = "United Kingdom"
rows = dict((text, flag) for text, flag in
fm.label_lines(track, track.fixes[0], "knots", entry))
assert rows["B739 N904DN"] == "GB"
def test_the_address_block_stands_in_when_a_register_says_nothing():
"""The 24-bit address says which country issued it, by treaty, with no
website involved at all."""
track = straight()
entry = _Entry()
entry.owner_country = ""
entry.country = "United States"
rows = dict(fm.label_lines(track, track.fixes[0], "knots", entry))
assert rows["B739 N904DN"] == "US"
def test_a_country_nobody_named_gets_no_flag():
track = straight()
entry = _Entry()
entry.owner_country = ""
entry.country = ""
rows = dict(fm.label_lines(track, track.fixes[0], "knots", entry))
assert rows["B739 N904DN"] == ""
# ---------------------------------------------------------------------------
# Airports on the map
# ---------------------------------------------------------------------------
def test_an_airport_a_route_gave_a_position_for_is_marked():
known = {"A": _Entry()}
found = fm._airports_from(known)
assert {code for code, _, _ in found} == {"KATL", "EGLL"}
def test_an_airport_with_only_a_code_is_looked_up_so_it_can_be_marked():
"""A route names two airports and often gives a position for neither; an
airport that cannot be placed cannot be drawn."""
asked = []
class _Book:
def airports(self, codes):
asked.extend(codes)
return [{"code": code, "latitude": 32.1, "longitude": -110.9}
for code in codes]
entry = _Entry(origin_lat=0.0, origin_lon=0.0,
destination_lat=0.0, destination_lon=0.0)
found = fm._airports_from({"A": entry}, _Book())
assert sorted(asked) == ["EGLL", "KATL"]
assert len(found) == 2
def test_an_airport_that_cannot_be_looked_up_is_simply_not_drawn():
class _Book:
def airports(self, codes):
raise OSError("no network")
entry = _Entry(origin_lat=0.0, origin_lon=0.0,
destination_lat=0.0, destination_lon=0.0)
assert fm._airports_from({"A": entry}, _Book()) == []
def test_an_airport_already_placed_is_not_looked_up_again():
asked = []
class _Book:
def airports(self, codes):
asked.extend(codes)
return []
fm._airports_from({"A": _Entry()}, _Book())
assert asked == [], "asked about airports it already had"
def test_an_airport_is_drawn_where_it_is_and_named():
view = fm.fit(two_aircraft(), width=600)
middle = ((view.south + view.north) / 2, (view.west + view.east) / 2)
base = fm.background(view, airports=[("EGLL", middle[0], middle[1])])
x, y = view.xy(*middle)
marker = (base[y - 4:y + 5, x - 4:x + 5] == fm.AIRPORT)
assert marker.any(), "no marker where the airport is"
# And its name beside it, in the same colour, to the right of the marker.
beside = (base[y - 6:y + 8, x + 5:x + 60] == fm.AIRPORT)
assert beside.sum() > 10, "the airport was marked but not named"
def test_an_airport_off_the_edge_is_not_drawn():
view = fm.fit(two_aircraft(), width=600)
plain = fm.background(view)
away = fm.background(view, airports=[("KSEA", 47.4, -122.3)])
assert np.array_equal(plain, away)
# ---------------------------------------------------------------------------
# How dark the ground is
# ---------------------------------------------------------------------------
def _brightest_drawn(brightness):
"""The lightest the map actually gets at one brightness setting."""
top = int(fm.dim_ground(np.array([[fm.GROUND_SHADES - 1]]),
brightness)[0, 0])
return fm.PALETTE[fm.GROUND + top].astype(float).mean()
def test_the_map_is_light_enough_to_read():
"""It was too dark to make out a coastline at all."""
assert _brightest_drawn(fm.GROUND_BRIGHTNESS) > 110
def test_the_aircraft_stay_brighter_than_the_ground_they_are_over():
"""Whatever else, the picture has to stay about the aeroplanes."""
ground = _brightest_drawn(fm.GROUND_BRIGHTNESS)
for feet in (0, 10_000, 25_000, 40_000):
marker = fm.PALETTE[fm.RAMP + fm.altitude_step(feet)]
assert marker.astype(float).mean() > ground, feet
def test_there_is_room_to_turn_it_up_and_down():
assert _brightest_drawn(1.0) > _brightest_drawn(fm.GROUND_BRIGHTNESS)
assert _brightest_drawn(0.3) < _brightest_drawn(fm.GROUND_BRIGHTNESS)
@pytest.mark.parametrize("brightness", [0.0, -1.0, 5.0, 1.0, 0.1])
def test_a_brightness_outside_the_range_is_brought_back_into_it(brightness):
shades = fm.dim_ground(np.arange(fm.GROUND_SHADES, dtype=np.uint8),
brightness)
assert shades.min() >= 0 and shades.max() <= fm.GROUND_SHADES - 1
def test_turning_it_down_really_does_darken_the_picture():
levels = np.full((20, 20), fm.GROUND_SHADES - 1, dtype=np.uint8)
assert fm.dim_ground(levels, 0.3).max() < fm.dim_ground(levels, 0.9).max()
def test_the_darkest_ground_is_no_darker_than_the_background():
darkest = fm.PALETTE[fm.GROUND].astype(float).mean()
background = fm.PALETTE[fm.BG].astype(float).mean()
assert darkest >= background
# ---------------------------------------------------------------------------
# Fading out rather than blinking out
# ---------------------------------------------------------------------------
def test_an_aircraft_still_being_heard_is_at_full_strength():
track = straight(seconds=120)
seen = fm.showing(track, track.fixes[-1].at, stale=300.0, fade=20.0)
assert seen is not None and seen[1] == 1.0
def test_one_that_has_gone_quiet_fades_instead_of_disappearing():
track = straight(seconds=120)
last = track.fixes[-1].at
strengths = []
for gone in (301, 305, 310, 315, 319):
seen = fm.showing(track, last + gone, stale=300.0, fade=20.0)
assert seen is not None, gone
strengths.append(seen[1])
assert strengths == sorted(strengths, reverse=True)
assert strengths[0] > 0.9 and strengths[-1] < 0.1
def test_it_is_gone_once_the_fade_is_over():
track = straight(seconds=120)
last = track.fixes[-1].at
assert fm.showing(track, last + 321, stale=300.0, fade=20.0) is None
def test_no_fade_takes_it_away_the_moment_it_is_given_up_on():
track = straight(seconds=120)
last = track.fixes[-1].at
assert fm.showing(track, last + 301, stale=300.0, fade=0.0) is None
def test_it_fades_where_it_was_last_seen_and_not_where_it_might_be():
"""The whole reason for giving up on an aircraft is that where it would
be by now is a guess; fading it along that guess would be inventing an
aeroplane slowly instead of quickly."""
track = straight(seconds=120)
last = track.fixes[-1]
seen = fm.showing(track, last.at + 310, stale=300.0, fade=20.0)
assert (seen[0].latitude, seen[0].longitude) == (last.latitude,
last.longitude)
def test_before_it_was_ever_heard_it_is_still_not_drawn():
track = straight(seconds=120)
assert fm.showing(track, track.fixes[0].at - 60, fade=20.0) is None
def test_the_fading_aircraft_is_drawn_in_fainter_colours():
track = straight(seconds=120)
view = fm.fit([track], width=500)
base = fm.background(view)
last = track.fixes[-1].at
def drawn(when):
"""The aircraft's own pixels: what changed, not what was already
there. The altitude key along the bottom is painted in the same
colours and belongs to the background."""
frame = fm.render_frame(base, view, [track], when, stale=300.0,
fade=20.0, labels=False)
return frame[frame != base]
fresh = drawn(last)
faint = drawn(last + 318)
assert fresh.size and faint.size
# A live aircraft has its marker in the full colours; its trail behind it
# is dimmer on purpose, so only the brightest pixels are the test.
assert (fresh < fm.TRAIL).any(), "a live aircraft was drawn faintly"
# A nearly gone one has nothing in them at all.
assert not (faint < fm.FAINT).any(), "a fading aircraft was drawn brightly"
def test_a_faded_aircraft_keeps_its_symbol_and_loses_its_label():
track = straight(seconds=120)
view = fm.fit([track], width=500)
base = fm.background(view)
last = track.fixes[-1].at
known = {track.icao: _Entry()}
faint = fm.render_frame(base, view, [track], last + 318, stale=300.0,
fade=20.0, known=known)
assert (faint != base).any(), "nothing was drawn at all"
assert not _has_text(faint, "B739"), "the label survived the fade"
def test_the_trail_fades_with_the_aircraft_it_belongs_to():
"""The two are one thing on the picture, and half of it lingering would
be worse than either."""
track = straight(seconds=120)
view = fm.fit([track], width=500)
base = fm.background(view)
last = track.fixes[-1].at
faint = fm.render_frame(base, view, [track], last + 318, stale=300.0,
fade=20.0, labels=False)
drawn = faint[faint != base]
assert drawn.size
assert drawn.min() >= fm.FAINT, "the trail stayed behind when it faded"
def test_the_faint_colours_are_the_same_hues_only_dimmer():
for step in (0, 15, 31):
bright = fm.PALETTE[fm.RAMP + step].astype(float)
faint = fm.PALETTE[fm.FAINT + step].astype(float)
assert faint.sum() < bright.sum()
# The same colour, turned down: the ratios between the channels hold.
assert np.allclose(faint / max(1e-9, faint.sum()),
bright / bright.sum(), atol=0.03)
def test_the_palette_still_has_room_after_the_fade_colours():
assert fm.FAINT + fm.RAMP_STEPS < fm.TRANSPARENT
assert fm.PALETTE.shape == (256, 3)

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