Put APRS stations on the live map, where they stay
The same window the aircraft use, given marks that are not aeroplanes. It already knew how to fetch a map, place an information box where it covers nothing, glide it when its owner moves, draw range rings, put a flag where the aerial stands and a scale along the bottom -- and not one of those has anything to do with aviation. What it did not know is that a mark might not fade, might not point anywhere, and might be coloured by what it is rather than by how high it is. Those are three hooks rather than a second window, and the aircraft map is untouched: every one of them defaults to exactly what an aeroplane does. Nothing fades, which is the difference asked for and the right one. An aeroplane that stops transmitting has flown out of range, and drawing it an hour later where it was would be drawing something that is certainly not there. A fixed amateur station that stops transmitting is still exactly where it was -- it beacons every half hour, and the gaps are silence rather than absence. So the picture accumulates and an evening of listening fills a map. Marks are ordered most-recently-heard first, because that is the order the boxes are laid out in and an accumulating map has more marks on it than it has room for boxes. Marks are drawn by what they are: something moving as a body with a stalk pointing where it is going, and anything fixed as a diamond, which is the one shape on the picture with no front -- a house that beacons twice an hour is a place, and a triangle would have it pointing north for no reason. Colours come off the altitude ramp, not because a station has an altitude but because that ramp is the one set of colours all five themes define: warm to cold on the default map, dim to bright on the phosphor ones, so a digipeater stays distinguishable from a car everywhere without a colour being named here. The box says what the station is, how far off and in which bearing, what it is doing if it is moving, its altitude, its weather, its status, the digipeaters it came through, how many packets and how many of those arrived directly, and how strongly. The heading prints the callsign once: an aeroplane has two names and the heading was built for that. Reachable both ways, as everything here is -- --window on the command line, w in the menu -- with the same eight map settings the aircraft side has. Forty new tests against nine deliberately broken builds. Two of them survived the first attempt, and both for the same reason: they asked whether the rendered frames differed rather than whether the symbol did, and the strip along the top carries a running clock, so two frames taken a millisecond apart differ by a few hundred pixels whatever is on the map. They now crop to the mark. That is the second time this session that a ticking header has made a test pass for the wrong reason. Full suite 2652 passed. Built as 2026-09-21_01. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
parent
14ba77da9a
commit
2c7b1ba25e
10 changed files with 1049 additions and 19 deletions
|
|
@ -816,3 +816,192 @@ def test_a_frequency_can_be_typed_straight_into_the_channel_screen(monkeypatch):
|
|||
tui._aprs_channel(console, options)
|
||||
assert options.frequency == pytest.approx(144_500_000.0)
|
||||
assert "not a region" in cap.get()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The realtime map
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def station_of(info="=4903.50N/07201.75W>088/036 mobile", source="W1AW-9",
|
||||
at=1_000.0, snr=24.0, path=("WIDE1-1*",), destination="APRS",
|
||||
times=1):
|
||||
net = ap.Net()
|
||||
for i in range(times):
|
||||
net.add(packet(info, source, at + i * 60, snr, path, destination))
|
||||
return net.stations[next(iter(net.stations))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("info,expected", [
|
||||
("=4903.50N/07201.75W>088/036 mobile", "moving"),
|
||||
("=4903.50N/07201.75W#wide digi", "digipeater"),
|
||||
("@092345z4903.50N/07201.75W_220/004g005t077h50b09900", "weather"),
|
||||
("=4903.50N/07201.75W-home station", "fixed"),
|
||||
])
|
||||
def test_a_station_is_sorted_by_what_sort_of_thing_it_is(info, expected):
|
||||
assert ap.station_shade(station_of(info)) == expected
|
||||
|
||||
|
||||
def test_an_object_is_sorted_as_an_object_whatever_it_draws_itself_as():
|
||||
net = ap.Net()
|
||||
net.add(packet(packets.object_report("EVENT1", 49.0, -72.0, "/:"),
|
||||
source="W1AW-9", at=1_000.0))
|
||||
assert ap.station_shade(net.stations["EVENT1"]) == "object"
|
||||
|
||||
|
||||
def test_each_sort_gets_its_own_colour_off_the_ramp():
|
||||
"""The ramp because it is the one set of colours every theme defines: on
|
||||
the default map it runs warm to cold and on the phosphor themes dim to
|
||||
bright, so a digipeater stays distinguishable from a car on all five."""
|
||||
from bandsaunter.flightmap import RAMP, RAMP_STEPS
|
||||
|
||||
shades = {kind: ap.blip_for(station_of(info)).colour_index
|
||||
for kind, info in (
|
||||
("moving", "=4903.50N/07201.75W>088/036 x"),
|
||||
("digi", "=4903.50N/07201.75W#x"),
|
||||
("weather", "@092345z4903.50N/07201.75W_220/004t077"),
|
||||
("fixed", "=4903.50N/07201.75W-x"))}
|
||||
assert len(set(shades.values())) == 4
|
||||
for index in shades.values():
|
||||
assert RAMP <= index < RAMP + RAMP_STEPS
|
||||
assert shades["moving"] > shades["fixed"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("info,shape", [
|
||||
("=4903.50N/07201.75W>088/036 mobile", "vehicle"),
|
||||
("=4903.50N/07201.75W-still here", "station"),
|
||||
])
|
||||
def test_something_going_somewhere_is_drawn_differently_from_a_place(info,
|
||||
shape):
|
||||
assert ap.blip_for(station_of(info)).shape == shape
|
||||
|
||||
|
||||
def test_the_box_heading_says_the_callsign_once():
|
||||
"""An aeroplane has two names, a flight number and a 24-bit address, and
|
||||
the heading prints both. A station has one."""
|
||||
blip = ap.blip_for(station_of())
|
||||
assert blip.icao == "W1AW-9" and blip.callsign == ""
|
||||
assert blip.name == "W1AW-9"
|
||||
|
||||
|
||||
def test_the_box_says_what_a_station_is_and_where_and_what_it_said():
|
||||
rows = dict((label, value) for label, value, _flag
|
||||
in ap.station_lines(station_of(), home=(49.0, -72.0)))
|
||||
assert rows["symbol"] == "car"
|
||||
assert "km" in rows["away"]
|
||||
assert "km/h" in rows["moving"]
|
||||
assert rows["via"] == "WIDE1-1*"
|
||||
assert rows["packets"] == "1"
|
||||
assert rows["signal"] == "24 dB"
|
||||
|
||||
|
||||
def test_a_weather_station_puts_its_weather_in_the_box():
|
||||
station = station_of("@092345z4903.50N/07201.75W_220/004g005t077h50b09900",
|
||||
source="KB1XYZ")
|
||||
rows = dict((label, value) for label, value, _f
|
||||
in ap.station_lines(station))
|
||||
assert "temperature" in rows and "humidity" in rows
|
||||
assert rows["temperature"].endswith(" C")
|
||||
|
||||
|
||||
def test_the_box_says_who_placed_an_object():
|
||||
net = ap.Net()
|
||||
net.add(packet(packets.object_report("EVENT1", 49.0, -72.0),
|
||||
source="W1AW-9", at=1_000.0))
|
||||
rows = dict((label, value) for label, value, _f
|
||||
in ap.station_lines(net.stations["EVENT1"]))
|
||||
assert rows["placed by"] == "W1AW-9"
|
||||
|
||||
|
||||
def test_the_box_says_how_much_arrived_without_a_relay():
|
||||
net = ap.Net()
|
||||
net.add(packet(at=1_000.0, path=("WIDE1-1*",)))
|
||||
net.add(packet(at=1_060.0, path=()))
|
||||
rows = dict((label, value) for label, value, _f
|
||||
in ap.station_lines(net.stations["W1AW-9"]))
|
||||
assert rows["packets"] == "2 (1 direct)"
|
||||
|
||||
|
||||
def test_the_box_reads_in_either_system():
|
||||
station = station_of()
|
||||
metric = dict((l, v) for l, v, _f in ap.station_lines(station,
|
||||
(49.0, -72.0)))
|
||||
imperial = dict((l, v) for l, v, _f in ap.station_lines(station,
|
||||
(49.0, -72.0),
|
||||
imperial=True))
|
||||
assert "km/h" in metric["moving"] and "mph" in imperial["moving"]
|
||||
assert "km" in metric["away"] and "mi" in imperial["away"]
|
||||
|
||||
|
||||
def test_a_station_with_no_position_is_not_put_on_the_map():
|
||||
"""A pin at nowhere puts a station off the west coast of Africa."""
|
||||
net = ap.Net()
|
||||
net.add(packet(">Monitoring 146.52", at=1_000.0))
|
||||
assert [s for s in net.all() if s.position is not None] == []
|
||||
|
||||
|
||||
# -- the map options --------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("flags,key,value", [
|
||||
(["--radius", "25"], "radius", 25.0),
|
||||
(["--theme", "phosphor"], "theme", "phosphor"),
|
||||
(["--map-brightness", "40"], "map_brightness", 40),
|
||||
(["--no-basemap"], "basemap", False),
|
||||
(["--no-window-rings"], "window_rings", False),
|
||||
(["--box-opacity", "0"], "box_opacity", 0),
|
||||
(["--no-trails"], "trails", False),
|
||||
(["--tiles", "http://x/{z}/{x}/{y}.png"], "tile_url",
|
||||
"http://x/{z}/{x}/{y}.png"),
|
||||
])
|
||||
def test_every_map_flag_reaches_the_option_it_names(flags, key, value):
|
||||
from bandsaunter.cli import build_parser
|
||||
|
||||
args = build_parser().parse_args(["aprs"] + flags)
|
||||
options = ap.AprsOptions()
|
||||
for flag, name in (("radius", "radius"), ("theme", "theme"),
|
||||
("map_brightness", "map_brightness"),
|
||||
("basemap", "basemap"),
|
||||
("window_rings", "window_rings"),
|
||||
("box_opacity", "box_opacity"),
|
||||
("trails", "trails"), ("tile_url", "tile_url")):
|
||||
got = getattr(args, flag, None)
|
||||
if got is not None:
|
||||
setattr(options, name, got)
|
||||
assert getattr(options, key) == value
|
||||
|
||||
|
||||
def test_the_window_is_asked_for_by_a_flag_and_not_a_setting():
|
||||
"""Like the aircraft window: whether to open one is a thing you say when
|
||||
you run it, not a thing you keep."""
|
||||
from bandsaunter.cli import build_parser
|
||||
|
||||
assert build_parser().parse_args(["aprs", "--window"]).window is True
|
||||
assert build_parser().parse_args(["aprs"]).window is False
|
||||
assert not hasattr(ap.AprsOptions(), "window")
|
||||
|
||||
|
||||
def test_the_menu_offers_the_map_and_says_the_marks_stay(monkeypatch):
|
||||
import bandsaunter.tui as tui
|
||||
from bandsaunter.config import ScanConfig
|
||||
|
||||
answers = iter(["b"])
|
||||
monkeypatch.setattr(tui, "_ask",
|
||||
lambda console, prompt, default="": next(answers))
|
||||
console = Console(width=120, force_terminal=False)
|
||||
with console.capture() as cap:
|
||||
tui.aprs_menu(console, ScanConfig())
|
||||
out = cap.get()
|
||||
assert "Realtime map" in out
|
||||
assert "they stay" in out
|
||||
assert "the map" in out # the option group
|
||||
|
||||
|
||||
def test_the_window_says_so_rather_than_failing_when_qt_is_missing(
|
||||
tmp_path, monkeypatch):
|
||||
from bandsaunter import livemap
|
||||
|
||||
monkeypatch.setattr(livemap, "available", lambda: False)
|
||||
console = Console(width=120, force_terminal=False)
|
||||
with console.capture() as cap:
|
||||
heard = ap.watch(console, ap.AprsOptions(), str(tmp_path))
|
||||
assert heard.stations == 0
|
||||
assert "no window to open" in cap.get()
|
||||
|
|
|
|||
|
|
@ -2036,3 +2036,222 @@ def test_the_window_keeps_asking_for_frames_while_anything_is_alive(app):
|
|||
_rendered(still)
|
||||
assert not still._glide_timer.isActive(), \
|
||||
"it is repainting with nothing to animate"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Marks that are not aeroplanes
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The window draws an APRS station as well as an aircraft. Everything it
|
||||
# knows how to do -- fetch a map, place a box where it covers nothing, glide
|
||||
# it when its owner moves, draw rings and put a flag where the aerial is --
|
||||
# has nothing to do with aviation, so the three things that do are hooks
|
||||
# rather than a second window.
|
||||
|
||||
def test_an_aircraft_still_fades_when_it_stops_transmitting():
|
||||
"""The guard on the other side of the hook: nothing about aircraft moved."""
|
||||
sky = Sky(hold=10.0, fade=10.0)
|
||||
old = Blip(icao="A1", latitude=47.0, longitude=-122.0,
|
||||
last_seen=now() - 15.0)
|
||||
sky.update([old], 1, 1)
|
||||
assert 0.0 < sky.strength(old) < 1.0
|
||||
gone = Blip(icao="A2", latitude=47.0, longitude=-122.0,
|
||||
last_seen=now() - 400.0)
|
||||
sky.update([gone], 1, 1)
|
||||
assert [b.icao for b in sky.flying()] == ["A1"]
|
||||
|
||||
|
||||
def test_a_station_never_fades_and_never_leaves_the_picture():
|
||||
"""A fixed amateur station that stops transmitting is still where it was.
|
||||
|
||||
It beacons every half hour, so the gaps are silence rather than absence,
|
||||
and an evening's listening should fill a map rather than empty one.
|
||||
"""
|
||||
sky = Sky(hold=10.0, fade=10.0, fades=False)
|
||||
ancient = Blip(icao="W1AW", latitude=47.0, longitude=-122.0,
|
||||
last_seen=now() - 86_400.0)
|
||||
sky.update([ancient], 1, 1)
|
||||
assert sky.strength(ancient) == 1.0
|
||||
assert [b.icao for b in sky.flying()] == ["W1AW"]
|
||||
|
||||
|
||||
def test_an_accumulating_map_puts_the_most_recent_first():
|
||||
"""Boxes are laid out in this order and a map that has been filling all
|
||||
evening has more marks on it than it has room for boxes, so the ones
|
||||
worth reading are the ones that just spoke."""
|
||||
sky = Sky(fades=False)
|
||||
sky.update([Blip(icao="OLD", latitude=47.0, longitude=-122.0,
|
||||
last_seen=now() - 3_600.0),
|
||||
Blip(icao="NEW", latitude=47.1, longitude=-122.1,
|
||||
last_seen=now() - 5.0)], 2, 2)
|
||||
assert [b.icao for b in sky.flying()] == ["NEW", "OLD"]
|
||||
|
||||
|
||||
def test_a_mark_may_be_given_its_own_colour_instead_of_an_altitude():
|
||||
"""There is nothing about a weather station that an altitude ramp says."""
|
||||
from bandsaunter.flightmap import RAMP
|
||||
|
||||
assert Blip(icao="X").colour_index is None # an aeroplane
|
||||
assert Blip(icao="X", colour_index=RAMP + 4).colour_index == RAMP + 4
|
||||
|
||||
|
||||
def test_a_mark_may_be_given_its_own_box_contents():
|
||||
rows = (("symbol", "weather station", ""), ("away", "5 km NNE", ""))
|
||||
assert Blip(icao="X", details=rows).lines("kph") == list(rows)
|
||||
|
||||
|
||||
def test_an_aircraft_with_no_details_still_builds_its_box_from_the_registers():
|
||||
lines = Blip(icao="A835AF", callsign="UAL1", altitude_ft=35_000).lines("knots")
|
||||
assert lines and any("UAL1" in str(row) or "35" in str(row)
|
||||
for row in lines)
|
||||
|
||||
|
||||
@qt
|
||||
def test_every_shape_draws_something(app):
|
||||
"""Three shapes: a triangle that points, a body with a stalk that also
|
||||
points, and a diamond that does not, because a place has no front."""
|
||||
from bandsaunter.livemap import _build
|
||||
|
||||
for shape in ("aircraft", "vehicle", "station"):
|
||||
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
|
||||
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
|
||||
shape=shape, last_seen=now())], 1, 1)
|
||||
view = _build()["SkyView"](sky)
|
||||
painted = _painted(_rendered(view))
|
||||
assert painted > 500, f"{shape} drew almost nothing"
|
||||
|
||||
|
||||
@qt
|
||||
def test_a_station_map_draws_the_stations_and_their_boxes(app):
|
||||
from bandsaunter import aprs, packets
|
||||
from bandsaunter.livemap import _build
|
||||
|
||||
net = aprs.Net()
|
||||
when = now()
|
||||
for source, info, at in [
|
||||
("W1AW-1", "=4736.37N/12219.93W#Seattle wide digi", when - 74),
|
||||
("KB1XYZ", "@092345z4735.40N/12216.20W_220/004g011t058h62b10132",
|
||||
when - 22),
|
||||
("N0ABC-7", "=4737.00N/12221.00Wb out for a ride", when - 3_600)]:
|
||||
packet = packets.parse_info(info)
|
||||
packet.source, packet.at, packet.snr = source, at, 28.0
|
||||
net.add(packet)
|
||||
home = (47.55, -122.30)
|
||||
sky = Sky(unit="kph", home=home, radius_nm=40 / 1.852, fades=False,
|
||||
rings=True, channel="144.39 MHz", subject="on the map",
|
||||
counted="packets")
|
||||
sky.started = when - 600
|
||||
sky.update([aprs.blip_for(s, home) for s in net.all() if s.position],
|
||||
148, len(net))
|
||||
view = _build()["SkyView"](sky)
|
||||
assert _painted(_rendered(view)) > 5_000
|
||||
# Including the one heard an hour ago, which has not faded.
|
||||
assert {b.icao for b in sky.flying()} == {"W1AW-1", "KB1XYZ", "N0ABC-7"}
|
||||
|
||||
|
||||
@qt
|
||||
def test_the_strip_along_the_top_says_what_it_is_looking_at(app):
|
||||
"""It said 1090 MHz and "overhead" and "frames" whatever it was drawing."""
|
||||
from bandsaunter.livemap import _build
|
||||
|
||||
sky = Sky(fades=False, channel="144.39 MHz", subject="on the map",
|
||||
counted="packets", home=(47.55, -122.30))
|
||||
sky.update([Blip(icao="W1AW", latitude=47.55, longitude=-122.30,
|
||||
last_seen=now())], 9, 1)
|
||||
view = _build()["SkyView"](sky)
|
||||
assert _painted(_rendered(view)) > 500
|
||||
assert sky.channel == "144.39 MHz" and sky.counted == "packets"
|
||||
|
||||
|
||||
def _has_colour(picture, rgb_wanted) -> bool:
|
||||
"""Whether a given colour was actually painted.
|
||||
|
||||
Exactly, not nearly: a mark is filled at full alpha, so its own colour
|
||||
appears unblended in the middle of it however the edges are softened.
|
||||
"""
|
||||
want = np.array(rgb_wanted, dtype=np.uint8)
|
||||
# Rendered as BGRA, so the first three channels are blue, green, red.
|
||||
body = picture[:, :, :3][:, :, ::-1]
|
||||
return bool((body == want).all(axis=2).any())
|
||||
|
||||
|
||||
@qt
|
||||
def test_a_given_colour_is_the_one_that_reaches_the_pixels(app):
|
||||
"""Not merely stored on the mark and then ignored in favour of an
|
||||
altitude the mark has not got."""
|
||||
from bandsaunter.flightmap import PALETTE, RAMP
|
||||
from bandsaunter.livemap import _build
|
||||
|
||||
chosen = RAMP + 25
|
||||
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
|
||||
sky.update([Blip(icao="W1AW", latitude=47.55, longitude=-122.30,
|
||||
shape="station", colour_index=chosen,
|
||||
last_seen=now())], 1, 1)
|
||||
picture = _rendered(_build()["SkyView"](sky))
|
||||
assert _has_colour(picture, tuple(int(v) for v in PALETTE[chosen]))
|
||||
|
||||
|
||||
@qt
|
||||
def test_two_sorts_of_station_are_not_painted_the_same_colour(app):
|
||||
from bandsaunter.flightmap import PALETTE, RAMP
|
||||
from bandsaunter.livemap import _build
|
||||
|
||||
seen = []
|
||||
for index in (RAMP + 3, RAMP + 28):
|
||||
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
|
||||
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
|
||||
shape="station", colour_index=index,
|
||||
last_seen=now())], 1, 1)
|
||||
seen.append(_rendered(_build()["SkyView"](sky)))
|
||||
for picture, index in zip(seen, (RAMP + 3, RAMP + 28)):
|
||||
assert _has_colour(picture, tuple(int(v) for v in PALETTE[index]))
|
||||
|
||||
|
||||
def _around_the_mark(shape, track=0.0, span=15):
|
||||
"""Just the pixels the symbol itself occupies.
|
||||
|
||||
Cropped, and cropped for a reason: the strip along the top carries a
|
||||
running clock, so two renders taken a millisecond apart differ by a few
|
||||
hundred pixels whatever is on the map. Comparing whole frames would
|
||||
pass whatever the symbol did.
|
||||
"""
|
||||
from bandsaunter.livemap import _build
|
||||
|
||||
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
|
||||
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
|
||||
shape=shape, colour_index=20, track_deg=track,
|
||||
last_seen=now())], 1, 1)
|
||||
view = _build()["SkyView"](sky)
|
||||
picture = _rendered(view, 400, 300)
|
||||
where = view.projection()
|
||||
x, y = where.xy(47.55, -122.30)
|
||||
return picture[y - span:y + span, x - span:x + span, :3]
|
||||
|
||||
|
||||
@qt
|
||||
def test_a_place_is_not_drawn_with_the_same_outline_as_an_aeroplane(app):
|
||||
"""A diamond has no front, which is the point of using one for a house
|
||||
that beacons twice an hour: a triangle would have it pointing north for
|
||||
no reason at all."""
|
||||
plane = _around_the_mark("aircraft")
|
||||
place = _around_the_mark("station")
|
||||
assert plane.shape == place.shape and plane.size > 0
|
||||
differing = int((plane != place).any(axis=2).sum())
|
||||
assert differing > 20, f"only {differing} pixels differ between the two"
|
||||
|
||||
|
||||
@qt
|
||||
def test_something_moving_is_drawn_differently_again(app):
|
||||
moving = _around_the_mark("vehicle", track=70.0)
|
||||
place = _around_the_mark("station", track=70.0)
|
||||
differing = int((moving != place).any(axis=2).sum())
|
||||
assert differing > 20, f"only {differing} pixels differ between the two"
|
||||
|
||||
|
||||
@qt
|
||||
def test_a_symbol_that_points_turns_with_its_heading(app):
|
||||
"""Which is what tells a mark that is going somewhere from one that is
|
||||
not, and is the whole reason a moving station is not a diamond."""
|
||||
north = _around_the_mark("vehicle", track=0.0)
|
||||
east = _around_the_mark("vehicle", track=90.0)
|
||||
assert int((north != east).any(axis=2).sum()) > 20
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue