Make the aircraft breathe, and send a ring out from each of them

Two things that move on their own, on the window and in the animated
pictures alike, and both turned off with a flag.

An aircraft swells from bright to dim and back.  At the top of the swell it
burns: drawn in a set of peak colours and wearing a halo, which is what a
phosphor does when the beam sits in one place a little too long.  The halo
is the aeroplane's own shape spread outward a pixel at a time rather than a
circle drawn round it -- the first attempt did draw a circle and it looked
exactly like what it was, a ring of dots -- so the glow has the shape of the
thing casting it.  It goes only where the picture was still empty, over the
ground and the grid and the background and never over another aircraft,
since a halo is what light does to the dark around a thing.

And a ring leaves each aircraft and travels outward, growing and dimming as
it goes.  What a radar repeater does, and what the eye reads as this thing
is transmitting -- which is exactly what an aeroplane on this picture is
doing, twice a second, and is how it got on the picture at all.  One ring at
a time per aircraft: a new one leaves as the last reaches the end of its
reach, so the sky has one ring per aeroplane rather than a stack of them to
read through.

Five settings, in a group of their own because the others were already at
eight: whether to pulse, how long a swell takes, whether to echo, how long
between rings, and how far a ring gets.  Both times are seconds of watching
rather than of flying, so the rhythm looks the same whatever speed an
evening is being run through.

Two decisions worth naming.  Each aircraft is offset by its own address, so
a sky full of them swells and rings separately rather than beating as one,
which would read as a display flashing rather than as a lot of separate
things transmitting; the offset comes from the address, so an aeroplane
keeps its rhythm from one frame to the next and from one drawing of the same
log to the next.  And only an aircraft still being heard pulses: one that
has gone quiet is already fading, and a thing that is fading and beating at
once says two contradictory things about itself.

The window can blend and its swell is continuous.  The animation cannot, a
GIF being indexed colour, so there the swell is the steps a palette allows
-- which family of colours the aeroplane is drawn from, and how far its halo
reaches, which is six between them and reads as a swell at a couple of
seconds a cycle.  The peak has sixteen colours of its own, sixteen being
exactly what was left of the palette once 255 is set aside as the
transparent index.

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-06 17:03:16 -07:00
parent 8ed01f991f
commit f01de4117f
10 changed files with 907 additions and 16 deletions

View file

@ -1097,6 +1097,49 @@ which leaves the coastline faintly visible through it, where a flat wash would
not. The animated pictures had **no card at all** before this, so `0` is what not. The animated pictures had **no card at all** before this, so `0` is what
they used to look like. they used to look like.
### Pulsing and echoes
Two things that move on their own, both on the window and in the animated
pictures, and both turned off with a flag.
**`--pulse`** swells each aircraft from bright to dim and back. At the top of
the swell it burns: drawn in a set of peak colours and wearing a halo grown
out of its own shape, which is what a phosphor does when the beam sits in one
place a little too long. A halo drawn as a *circle* round it would be a ring
of dots; this one is the aeroplane itself, spread outward a pixel at a time,
so the glow has the shape of the thing casting it. It goes only where the
picture was still empty — over the ground, the grid and the background, and
never over another aircraft.
**`--echo`** sends a ring travelling outward from each aircraft, growing and
dimming as it goes. What a radar repeater does, and what the eye reads as
*this thing is transmitting* — which is exactly what an aeroplane on this
picture is doing, twice a second, and is how it got on the picture at all.
One ring at a time per aircraft: a new one leaves as the last reaches the end
of its reach.
`--pulse-rate SECONDS` (2.2), `--echo-every SECONDS` (3) and `--echo-size
PIXELS` (46) set the rest. The two times are **seconds of watching, not of
flying**, so a pulse looks the same whatever speed an evening is being run
through.
**Each aircraft is offset by its own address**, so a sky full of them swells
and rings separately rather than beating as one — which would read as a
display flashing rather than as a lot of separate things transmitting. The
offset comes from the address, so an aeroplane keeps its own rhythm from one
frame to the next and from one drawing of the same log to the next.
**Only an aircraft still being heard pulses.** One that has gone quiet is
fading, and a thing that is fading and beating at once says two contradictory
things about itself.
The window can blend, so its swell is continuous. The animation cannot — a GIF
is indexed colour — so there the swell is the handful of steps a palette
allows: which family of colours the aeroplane is drawn from, and how far its
halo reaches. Between them that is six steps, which at a couple of seconds a
cycle reads as a swell rather than as a blink. The peak has sixteen colours of
its own, sixteen being what was left of the palette.
**Range rings** put faint discs at a quarter, a half and three quarters of the **Range rings** put faint discs at a quarter, a half and three quarters of the
radius, concentric on the receiver and each labelled with its distance. They radius, concentric on the receiver and each labelled with its distance. They
are translucent and they stack, so the ground inside the innermost is lifted are translucent and they stack, so the ground inside the innermost is lifted
@ -1657,7 +1700,7 @@ entirely.
## Every ADS-B option ## Every ADS-B option
Thirty-four of them, in the six groups the menu shows. Each is a flag on the Thirty-nine of them, in the seven groups the menu shows. Each is a flag on the
command line and a line in `bandsaunter` → 5, and both are generated from one command line and a line in `bandsaunter` → 5, and both are generated from one
table in the source, so they cannot disagree. `man bandsaunter` has the long table in the source, so they cannot disagree. `man bandsaunter` has the long
form of every one. form of every one.
@ -1726,6 +1769,16 @@ form of every one.
| Label the aircraft | `--labels` `--no-labels` | `yes` | write the callsign, height and speed beside each aircraft | | Label the aircraft | `--labels` `--no-labels` | `yes` | write the callsign, height and speed beside each aircraft |
| Speed in | `--speed-unit` | `knots` | what to show speeds and distances in | | Speed in | `--speed-unit` | `knots` | what to show speeds and distances in |
**effects**
| option | flag | default | what it does |
| --- | --- | --- | --- |
| Pulsing aircraft | `--pulse` `--no-pulse` | `yes` | swell each aircraft from bright to dim and back, over and over |
| Pulse takes | `--pulse-rate` | `2.2 s` | how long one swell from dim to bright and back again takes |
| Echo circles | `--echo` `--no-echo` | `yes` | rings travelling outward from each aircraft, growing and dimming |
| Echo every | `--echo-every` | `3 s` | how long between one ring leaving an aircraft and the next |
| Echo reaches | `--echo-size` | `46 px` | how far a ring gets from its aircraft before it has faded away |
**Where they live.** Saved with `s` in the menu to **Where they live.** Saved with `s` in the menu to
`~/.config/bandsaunter/aircraft.yaml`, which is plain YAML you can edit or `~/.config/bandsaunter/aircraft.yaml`, which is plain YAML you can edit or
copy between machines. API keys are deliberately **not** kept there — see copy between machines. API keys are deliberately **not** kept there — see

View file

@ -9,7 +9,7 @@ and transcribing speech.
# 2026-08-21_02 is the second build made on the 21st. The revision is padded # 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text. # to two digits so versions sort as text.
VERSION_DATE = "2026-09-06" VERSION_DATE = "2026-09-06"
VERSION_REVISION = 3 VERSION_REVISION = 4
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -122,6 +122,11 @@ class AircraftOptions:
window_rings: bool = True window_rings: bool = True
theme: str = "night" theme: str = "night"
box_opacity: int = 85 box_opacity: int = 85
pulse: bool = True
pulse_rate: float = 2.2
echo: bool = True
echo_every: float = 3.0
echo_size: int = 46
map_brightness: int = 70 map_brightness: int = 70
tile_url: str = "" tile_url: str = ""
@ -452,9 +457,66 @@ OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
flags=("--speed-unit",), metavar="UNIT", flags=("--speed-unit",), metavar="UNIT",
guidance="knots is what aviation uses and what the aircraft actually " guidance="knots is what aviation uses and what the aircraft actually "
"said. mph or kph if that is what means something to you."), "said. mph or kph if that is what means something to you."),
O("pulse", "Pulsing aircraft", "Effects", "bool",
"swell each aircraft from bright to dim and back, over and over",
"At the top of the swell an aeroplane burns: it is drawn in the peak "
"colours with a halo grown out of its own shape, which is what a "
"phosphor does when the beam sits in one place a little too long. At "
"the bottom it is merely there. Each aircraft is offset by its own "
"address so that a sky full of them swells separately rather than "
"beating as one, which would read as a display flashing rather than "
"as a lot of separate things transmitting. Only aircraft still being "
"heard pulse: one that has gone quiet is fading, and a thing that is "
"fading and beating at once says two contradictory things about "
"itself.",
flags=("--pulse",), off_flags=("--no-pulse",),
guidance="Turn it off for a still picture, or if a moving screen in "
"the corner of the room is a distraction."),
O("pulse_rate", "Pulse takes", "Effects", "float",
"how long one swell from dim to bright and back again takes",
"Seconds of watching rather than of flying, so a pulse looks the "
"same whatever speed an evening is being run through. Long enough "
"and it is a slow breath; short enough and it is a blink, which is "
"the one thing it should not be.",
unit="s", minimum=0.2, maximum=30.0, flags=("--pulse-rate",),
metavar="SECONDS", example="2.2",
guidance="Two or three seconds reads as breathing. Under one is a "
"strobe."),
O("echo", "Echo circles", "Effects", "bool",
"rings travelling outward from each aircraft, growing and dimming",
"What a radar repeater does, and what the eye reads as this thing is "
"transmitting -- which is exactly what an aeroplane on this picture "
"is doing, twice a second, and is how it got on the picture at all. "
"One ring at a time per aircraft: a new one leaves as the last "
"reaches the end of its reach, so the sky has one ring per aeroplane "
"rather than a stack of them to read through.",
flags=("--echo",), off_flags=("--no-echo",),
guidance="Handsome on a quiet sky. Turn it off when a hundred "
"aircraft are overhead."),
O("echo_every", "Echo every", "Effects", "float",
"how long between one ring leaving an aircraft and the next",
"Also how long a ring takes to cross its whole reach, since there is "
"only ever one in flight at a time. Seconds of watching rather than "
"of flying.",
unit="s", minimum=0.3, maximum=60.0, flags=("--echo-every",),
metavar="SECONDS", example="3",
guidance="Longer for a calmer picture, shorter for a busier one."),
O("echo_size", "Echo reaches", "Effects", "int",
"how far a ring gets from its aircraft before it has faded away",
"In pixels rather than miles, because it is a mark on a picture "
"rather than a distance in the sky: a ring measured in miles would "
"be a claim about range, and this is not one. Bigger than the space "
"between two aircraft and the rings cross each other; smaller than "
"the aeroplane and there is nothing to see.",
unit="px", minimum=6, maximum=400, flags=("--echo-size",),
metavar="PIXELS", example="46",
guidance="About forty on a nine-hundred-pixel picture. Scale it up "
"with the width."),
) )
OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map", "Labels") OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map",
"Labels", "Effects")
def in_group(group: str) -> list[Setting]: def in_group(group: str) -> list[Setting]:
@ -768,7 +830,10 @@ def watch(console, options: AircraftOptions, output_dir: str,
fade=max(0.0, options.fade), fade=max(0.0, options.fade),
airports=options.airports, airports=options.airports,
rings=options.window_rings, rings=options.window_rings,
box_opacity=max(0, options.box_opacity) / 100.0) box_opacity=max(0, options.box_opacity) / 100.0,
pulse=options.pulse_rate if options.pulse else 0.0,
echo=options.echo_every if options.echo else 0.0,
echo_reach=max(1, options.echo_size))
sky.started = started sky.started = started
sky.log_name = log.path.name if log is not None else "" sky.log_name = log.path.name if log is not None else ""
if options.simulate: if options.simulate:
@ -980,6 +1045,9 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None):
airports=options.airports, airports=options.airports,
rings=options.rings, rings=options.rings,
box_opacity=max(0, options.box_opacity) / 100.0, box_opacity=max(0, options.box_opacity) / 100.0,
pulse=options.pulse_rate if options.pulse else 0.0,
echo=options.echo_every if options.echo else 0.0,
echo_reach=max(1, options.echo_size),
radius_nm=radius_in_nm(options), radius_nm=radius_in_nm(options),
centre=read_position(options.location)) centre=read_position(options.location))
except (OSError, RuntimeError, ValueError) as exc: except (OSError, RuntimeError, ValueError) as exc:

View file

@ -260,6 +260,25 @@ examples:
ad.add_argument("--no-labels", dest="labels", action="store_false", ad.add_argument("--no-labels", dest="labels", action="store_false",
default=None, default=None,
help="draw the aircraft without labels beside them") help="draw the aircraft without labels beside them")
ad.add_argument("--pulse", dest="pulse", action="store_true",
default=None, help="swell each aircraft from bright to "
"dim and back")
ad.add_argument("--no-pulse", dest="pulse", action="store_false",
default=None, help="draw the aircraft at a steady "
"brightness")
ad.add_argument("--pulse-rate", type=float, default=None,
metavar="SECONDS",
help="how long one swell takes, in seconds of watching")
ad.add_argument("--echo", dest="echo", action="store_true",
default=None, help="rings travelling outward from each "
"aircraft")
ad.add_argument("--no-echo", dest="echo", action="store_false",
default=None, help="no rings")
ad.add_argument("--echo-every", type=float, default=None,
metavar="SECONDS",
help="how long between one ring and the next")
ad.add_argument("--echo-size", type=int, default=None, metavar="PIXELS",
help="how far a ring gets before it has faded away")
ad.add_argument("--theme", default=None, metavar="NAME", ad.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red", choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad", "blue", "green", "orange", "wargames", "norad",
@ -339,6 +358,25 @@ examples:
metavar="PERCENT", metavar="PERCENT",
help="how solid the card behind each information box is " help="how solid the card behind each information box is "
"(0-100; 0 puts the words straight on the map)") "(0-100; 0 puts the words straight on the map)")
fl.add_argument("--pulse", dest="pulse", action="store_true",
default=None, help="swell each aircraft from bright to "
"dim and back")
fl.add_argument("--no-pulse", dest="pulse", action="store_false",
default=None, help="draw the aircraft at a steady "
"brightness")
fl.add_argument("--pulse-rate", type=float, default=None,
metavar="SECONDS",
help="how long one swell takes, in seconds of watching")
fl.add_argument("--echo", dest="echo", action="store_true",
default=None, help="rings travelling outward from each "
"aircraft")
fl.add_argument("--no-echo", dest="echo", action="store_false",
default=None, help="no rings")
fl.add_argument("--echo-every", type=float, default=None,
metavar="SECONDS",
help="how long between one ring and the next")
fl.add_argument("--echo-size", type=int, default=None, metavar="PIXELS",
help="how far a ring gets before it has faded away")
fl.add_argument("--theme", default=None, metavar="NAME", fl.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red", choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad", "blue", "green", "orange", "wargames", "norad",
@ -1200,6 +1238,10 @@ def cmd_adsb(args) -> int:
options.theme = args.theme options.theme = args.theme
if getattr(args, "box_opacity", None) is not None: if getattr(args, "box_opacity", None) is not None:
options.box_opacity = args.box_opacity options.box_opacity = args.box_opacity
for flag in ("pulse", "pulse_rate", "echo", "echo_every", "echo_size"):
value = getattr(args, flag, None)
if value is not None:
setattr(options, flag, value)
if getattr(args, "rings", None) is not None: if getattr(args, "rings", None) is not None:
options.rings = args.rings options.rings = args.rings
if getattr(args, "window_rings", None) is not None: if getattr(args, "window_rings", None) is not None:
@ -1270,6 +1312,10 @@ def cmd_flights(args) -> int:
options.theme = args.theme options.theme = args.theme
if getattr(args, "box_opacity", None) is not None: if getattr(args, "box_opacity", None) is not None:
options.box_opacity = args.box_opacity options.box_opacity = args.box_opacity
for flag in ("pulse", "pulse_rate", "echo", "echo_every", "echo_size"):
value = getattr(args, flag, None)
if value is not None:
setattr(options, flag, value)
if getattr(args, "rings", None) is not None: if getattr(args, "rings", None) is not None:
options.rings = args.rings options.rings = args.rings
if getattr(args, "window_rings", None) is not None: if getattr(args, "window_rings", None) is not None:

View file

@ -44,6 +44,7 @@ __all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
"ground_for", "background", "label_lines", "draw_flag", "ground_for", "background", "label_lines", "draw_flag",
"set_theme", "theme", "THEME", "bloom", "draw_home", "set_theme", "theme", "THEME", "bloom", "draw_home",
"draw_rings", "RING_STEPS", "ring_labels", "draw_rings", "RING_STEPS", "ring_labels",
"pulse_at", "phase_of", "echo_age", "hot_step",
"FLAG", "FAINT", "flag_index", "dim_ground", "local_airports", "FLAG", "FAINT", "flag_index", "dim_ground", "local_airports",
"showing", "fade_ramp", "showing", "fade_ramp",
"GROUND_BRIGHTNESS"] "GROUND_BRIGHTNESS"]
@ -87,6 +88,24 @@ HOME = LEADER + 3
# theme, to be taken for one; this is the brightest red there is and the one # theme, to be taken for one; this is the brightest red there is and the one
# furthest from every altitude colour in every theme. # furthest from every altitude colour in every theme.
HOME_RED = (255, 0, 0) HOME_RED = (255, 0, 0)
# The bright peak of a pulsing aircraft: the altitude colours pushed towards
# white, so that an aeroplane at the top of its pulse burns rather than
# merely being its own colour again.
#
# Sixteen of them rather than thirty-two, because sixteen is what is left of
# the palette -- 255 is the transparent index and can never be drawn with.
# Sixteen steps of altitude at the top of a pulse is plenty: it is a moment
# of a cycle, and the aeroplane spends the rest of the cycle in the full
# thirty-two.
HOT = HOME + 3
HOT_STEPS = 16
HOT_LIFT = 0.55 # how far towards white the peak is pushed
# How long one pulse of an aircraft takes, how often an echo leaves it, and
# how far an echo gets before it has faded to nothing.
PULSE_SECONDS = 2.2
ECHO_SECONDS = 3.0
ECHO_REACH = 46 # pixels
RAMP_STEPS = 32 RAMP_STEPS = 32
GROUND_SHADES = 32 GROUND_SHADES = 32
TRANSPARENT = 255 # never drawn with: it means "as the frame before" TRANSPARENT = 255 # never drawn with: it means "as the frame before"
@ -234,6 +253,11 @@ def _palette(theme=None) -> np.ndarray:
for colour in (theme.leader, HOME_RED): for colour in (theme.leader, HOME_RED):
table += [colour, _dimmed([colour], part)[0], table += [colour, _dimmed([colour], part)[0],
_dimmed([colour], part * part)[0]] _dimmed([colour], part * part)[0]]
# The pulse's bright peak: each altitude colour lifted towards white.
for i in range(HOT_STEPS):
base = ramp[min(RAMP_STEPS - 1,
round(i * (RAMP_STEPS - 1) / max(1, HOT_STEPS - 1)))]
table.append(tuple(int(round(c + (255 - c) * HOT_LIFT)) for c in base))
table += [(0, 0, 0)] * (256 - len(table)) table += [(0, 0, 0)] * (256 - len(table))
return np.array(table[:256], dtype=np.uint8) return np.array(table[:256], dtype=np.uint8)
@ -968,7 +992,9 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
project: bool = True, known=None, project: bool = True, known=None,
fade: float = 0.0, places: "LabelPlaces | None" = None, fade: float = 0.0, places: "LabelPlaces | None" = None,
step: float = 0.0, home=None, step: float = 0.0, home=None,
box_opacity: float = 0.0) -> np.ndarray: box_opacity: float = 0.0, beat: float = 0.0,
pulse: float = 0.0, echo: float = 0.0,
echo_reach: float = ECHO_REACH) -> np.ndarray:
"""The map at one moment: where everything was, and where it had been. """The map at one moment: where everything was, and where it had been.
``project`` is what makes an animation an animation: between reports an ``project`` is what makes an animation an animation: between reports an
@ -1011,7 +1037,19 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
_line(img, x0, y0, x1, y1, shade) _line(img, x0, y0, x1, y1, shade)
colour = faded + altitude_step(now.altitude_ft) colour = faded + altitude_step(now.altitude_ft)
x, y = view.xy(now.latitude, now.longitude) x, y = view.xy(now.latitude, now.longitude)
beats = phase_of(track.icao)
# The echo goes down before the aircraft, so the ring passes under
# the thing that sent it out rather than over it.
if echo > 0 and strength >= 1.0:
_echo(img, x, y, beat, echo, echo_reach, beats,
altitude_step(now.altitude_ft))
_marker(img, x, y, now.track_deg, colour) _marker(img, x, y, now.track_deg, colour)
# Only an aircraft still being heard pulses. One that has gone
# quiet is fading, and a thing that is fading and beating at once
# says two contradictory things about itself.
if pulse > 0 and strength >= 1.0:
_pulse(img, x, y, now.track_deg, now.altitude_ft,
pulse_at(beat, pulse, beats))
if labels and strength > LABEL_WHILE: if labels and strength > LABEL_WHILE:
_label(img, x, y, track, now, colour, taken, unit, _label(img, x, y, track, now, colour, taken, unit,
entry=(known.get(track.icao) entry=(known.get(track.icao)
@ -1062,6 +1100,166 @@ def showing(track: Track, when: float, stale: float = 300.0,
return last, max(0.0, 1.0 - gone / fade) return last, max(0.0, 1.0 - gone / fade)
def pulse_at(beat: float, rate: float = PULSE_SECONDS,
phase: float = 0.0) -> float:
"""How far up its pulse a thing is now: nought dimmest, one brightest.
A raised cosine rather than a sawtooth, so that the bright end is a
swell and not a flash -- what a phosphor does when the beam lingers, not
what a warning light does.
"""
if rate <= 0:
return 1.0
turn = (beat / rate + phase) * math.tau
return 0.5 - 0.5 * math.cos(turn)
def phase_of(icao: str) -> float:
"""Where in its cycle one aircraft is, so they do not all beat as one.
Taken from the address, which never changes, so an aeroplane keeps its
own rhythm from one frame to the next and from one drawing of the same
log to the next. Aircraft pulsing in step read as one flashing display
rather than as a sky full of separate things.
"""
return (int(icao[-4:], 16) % 997) / 997.0 if icao else 0.0
def echo_age(beat: float, every: float, phase: float = 0.0) -> float:
"""How long ago the echo now travelling outward left the aircraft.
One at a time: a new one leaves as the last reaches the end of its
reach, so the sky has one ring per aircraft rather than a stack of them
to draw and read.
"""
if every <= 0:
return 0.0
return ((beat / every + phase) % 1.0) * every
def hot_step(feet: float) -> int:
"""Which of the sixteen peak colours a height falls in."""
return int(min(HOT_STEPS - 1,
max(0, round(altitude_step(feet)
* (HOT_STEPS - 1) / (RAMP_STEPS - 1)))))
def _ring(img: np.ndarray, cx: int, cy: int, radius: int,
colour: int) -> None:
"""A circle, one pixel thick, clipped to the canvas.
The midpoint algorithm, which is the circle's answer to Bresenham: no
trigonometry per pixel and no gaps, since it steps one pixel at a time
round an eighth of the circle and mirrors that eighth into the other
seven.
"""
height, width = img.shape
r = int(radius)
if r < 1:
return
x, y, err = r, 0, 1 - r
while x >= y:
for px, py in ((cx + x, cy + y), (cx + y, cy + x),
(cx - y, cy + x), (cx - x, cy + y),
(cx - x, cy - y), (cx - y, cy - x),
(cx + y, cy - x), (cx + x, cy - y)):
if 0 <= px < width and 0 <= py < height:
img[py, px] = colour
y += 1
if err < 0:
err += 2 * y + 1
else:
x -= 1
err += 2 * (y - x) + 1
def _pulse(img: np.ndarray, x: int, y: int, heading: float, feet: float,
level: float) -> None:
"""Redraw one aircraft at whatever point of its pulse it has reached.
An indexed picture cannot dim a colour by a fraction, so a swell here is
two things at once: which family of colours the aeroplane is drawn from,
and how far the halo round it reaches. Between them they give six
steps rather than two, which at a couple of seconds a cycle reads as a
swell rather than as a blink.
At the top of it the aeroplane is drawn in the peak colours and carries
two rings of halo, which is the raster burn of a beam that has sat in
one place a little too long.
"""
step = altitude_step(feet)
if level >= 0.72:
_burn(img, x, y, heading, 2, TRAIL + step, OLD + step)
_marker(img, x, y, heading, HOT + hot_step(feet))
elif level >= 0.45:
_burn(img, x, y, heading, 1, TRAIL + step, OLD + step)
_marker(img, x, y, heading, RAMP + step)
elif level >= 0.22:
_marker(img, x, y, heading, RAMP + step)
else:
_marker(img, x, y, heading, TRAIL + step)
def _burn(img: np.ndarray, x: int, y: int, heading: float, rings: int,
near: int, far: int) -> None:
"""The halo round a burning aircraft: the shape itself, spread outwards.
Grown from the aeroplane rather than drawn as a circle round it, so the
glow has the shape of the thing casting it -- which is what a phosphor
does, and what a ring of dots emphatically does not. The marker is
stamped into a scrap of its own, spread a pixel at a time, and only the
spread part is painted back, and only where the picture was still empty.
Never over anything else that was drawn: a halo is what light does to
the dark around a thing, and painting it over a neighbouring aeroplane
would be light doing something light does not do.
"""
height, width = img.shape
pad = rings + 8
left, top = x - pad, y - pad
right, bottom = x + pad + 1, y + pad + 1
if right <= 0 or bottom <= 0 or left >= width or top >= height:
return
scrap = np.zeros((2 * pad + 1, 2 * pad + 1), dtype=np.uint8)
_marker(scrap, pad, pad, heading, 1)
grown = scrap.copy()
for ring in range(1, rings + 1):
spread = grown.copy()
for dy, dx in ((0, 1), (0, -1), (1, 0), (-1, 0)):
shifted = np.roll(grown, (dy, dx), axis=(0, 1))
spread = np.where((spread == 0) & (shifted != 0), ring + 1, spread)
grown = spread
patch = img[max(0, top):min(height, bottom),
max(0, left):min(width, right)]
cut = grown[max(0, -top):max(0, -top) + patch.shape[0],
max(0, -left):max(0, -left) + patch.shape[1]]
empty = ((patch == BG) | (patch == GRID)
| ((patch >= GROUND) & (patch < GROUND + GROUND_SHADES)))
for ring in range(rings, 0, -1):
colour = near if ring == 1 else far
patch[:] = np.where(empty & (cut == ring + 1), colour, patch)
def _echo(img: np.ndarray, x: int, y: int, beat: float, every: float,
reach: float, phase: float, step: int) -> None:
"""One ring travelling outward from an aircraft, dimming as it grows.
What a radar repeater does, and what the eye reads as "this thing is
transmitting" -- which is exactly what an aeroplane on this picture is
doing, twice a second, which is how it got here at all.
"""
age = echo_age(beat, every, phase)
part = age / max(1e-9, every)
radius = int(round(reach * part))
if radius < 3:
return
# Dimmer the further out it has got, in the three steps the palette
# holds: the ring is the aircraft's own colour, spent.
shade = TRAIL if part < 0.34 else (OLD if part < 0.67 else FAINT)
_ring(img, x, y, radius, shade + step)
def _marker(img: np.ndarray, x: int, y: int, heading: float, def _marker(img: np.ndarray, x: int, y: int, heading: float,
colour: int) -> None: colour: int) -> None:
"""A little arrowhead, pointing the way the aircraft is going. """A little arrowhead, pointing the way the aircraft is going.
@ -1673,7 +1871,8 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
radius_nm: float = 0.0, centre=None, radius_nm: float = 0.0, centre=None,
brightness: float = GROUND_BRIGHTNESS, brightness: float = GROUND_BRIGHTNESS,
airports: bool = False, ask=None, rings: bool = False, airports: bool = False, ask=None, rings: bool = False,
box_opacity: float = 0.0, box_opacity: float = 0.0, pulse: float = 0.0,
echo: float = 0.0, echo_reach: float = ECHO_REACH,
fade: float = 0.0) -> Animation | None: fade: float = 0.0) -> Animation | None:
"""Draw the whole log as a moving map. """Draw the whole log as a moving map.
@ -1764,7 +1963,14 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
clock=clock, unit=unit, known=known, clock=clock, unit=unit, known=known,
fade=fade, places=places, step=step, fade=fade, places=places, step=step,
home=centre, home=centre,
box_opacity=box_opacity), box_opacity=box_opacity,
# Seconds of watching, not of flying:
# a pulse is meant to look the same
# whatever speed the evening is being
# run through.
beat=i / fps if fps else 0.0,
pulse=pulse, echo=echo,
echo_reach=echo_reach),
canvas_w, canvas_h) canvas_w, canvas_h)
path = Path(out_path) path = Path(out_path)

View file

@ -361,7 +361,8 @@ class Sky:
home=None, radius_nm: float = 100.0, home=None, radius_nm: float = 100.0,
brightness: float = 0.70, fade: float = 20.0, brightness: float = 0.70, fade: float = 20.0,
airports: bool = False, rings: bool = False, airports: bool = False, rings: bool = False,
box_opacity: float = 0.85): box_opacity: float = 0.85, pulse: float = 0.0,
echo: float = 0.0, echo_reach: float = 0.0):
import threading import threading
self.unit = unit self.unit = unit
@ -379,6 +380,10 @@ class Sky:
# all puts the words straight on the map, which is readable over # all puts the words straight on the map, which is readable over
# water and not over a city. # water and not over a city.
self.box_opacity = max(0.0, min(1.0, float(box_opacity))) self.box_opacity = max(0.0, min(1.0, float(box_opacity)))
# Seconds a pulse takes and seconds between echoes; nought for off.
self.pulse = max(0.0, float(pulse))
self.echo = max(0.0, float(echo))
self.echo_reach = max(0.0, float(echo_reach))
self.frames = 0 self.frames = 0
self.aircraft_seen = 0 self.aircraft_seen = 0
self.started = time.time() self.started = time.time()
@ -694,6 +699,28 @@ def _build():
def craft_colour(feet, alpha=255) -> QColor: def craft_colour(feet, alpha=255) -> QColor:
return rgb(RAMP + altitude_step(feet), alpha) return rgb(RAMP + altitude_step(feet), alpha)
def _lifted(colour, level: float) -> QColor:
"""One colour walked towards white, and dimmed below the middle.
The bright end is a swell towards the peak colour the pictures use;
the dim end is the same colour with the life taken out of it, so
that a pulse is a change in brightness rather than in hue.
"""
from .flightmap import HOT_LIFT
out = QColor(colour)
if level >= 0.5:
part = (level - 0.5) * 2.0 * HOT_LIFT
out.setRgb(*(int(round(c + (255 - c) * part))
for c in (colour.red(), colour.green(),
colour.blue())), colour.alpha())
else:
part = 0.45 + 0.55 * (level * 2.0)
out.setRgb(*(int(round(c * part))
for c in (colour.red(), colour.green(),
colour.blue())), colour.alpha())
return out
def glow_line(painter, colour, width: float, draw, core=True, def glow_line(painter, colour, width: float, draw, core=True,
dash=None) -> None: dash=None) -> None:
"""Lay one line down two or three times, wider and fainter each pass. """Lay one line down two or three times, wider and fainter each pass.
@ -855,9 +882,13 @@ def _build():
self.sky.strength(blip, now)) for blip in flying] self.sky.strength(blip, now)) for blip in flying]
taken: list[tuple[int, int, int, int]] = [] taken: list[tuple[int, int, int, int]] = []
for blip, (x, y), strength in placed: for blip, (x, y), strength in placed:
self._draw_symbol(painter, x, y, blip.track_deg, colour = craft_colour(blip.altitude_ft, _alpha(strength))
craft_colour(blip.altitude_ft, # The echo goes down before the aeroplane, so the ring
_alpha(strength)), strength) # passes under the thing that sent it out.
if strength >= 1.0:
self._draw_echo(painter, x, y, colour, blip.icao)
self._draw_symbol(painter, x, y, blip.track_deg, colour,
strength, blip.icao)
taken.append((x - 11, y - 11, 22, 22)) taken.append((x - 11, y - 11, 22, 22))
labels = [self._lay_out(blip, x, y, taken, strength) labels = [self._lay_out(blip, x, y, taken, strength)
for blip, (x, y), strength in placed] for blip, (x, y), strength in placed]
@ -890,7 +921,13 @@ def _build():
painter.end() painter.end()
# Only while something is actually moving: the rest of the time # Only while something is actually moving: the rest of the time
# the ordinary five-a-second redraw is what runs. # the ordinary five-a-second redraw is what runs.
if self._box_moving and not self._glide_timer.isActive(): # A pulse and an echo are never finished, so while either is
# on the window keeps asking for frames the way it does while a
# box is on its way somewhere.
alive = (self._box_moving
or ((self.sky.pulse > 0 or self.sky.echo > 0)
and any(s >= 1.0 for _b, _xy, s in placed)))
if alive and not self._glide_timer.isActive():
self._glide_timer.start(BOX_GLIDE_MS) self._glide_timer.start(BOX_GLIDE_MS)
def _draw_waiting(self, painter) -> None: def _draw_waiting(self, painter) -> None:
@ -1238,8 +1275,49 @@ def _build():
self._box_moving = True self._box_moving = True
return int(round(x + here[0])), int(round(y + here[1])) return int(round(x + here[0])), int(round(y + here[1]))
def _draw_echo(self, painter, x, y, colour, icao: str) -> None:
"""One ring travelling outward from an aircraft, dimming as it
grows. What a radar repeater does, and what the eye reads as
"this thing is transmitting" -- which is what an aeroplane on
this picture is doing, twice a second."""
from .flightmap import echo_age, phase_of
every = self.sky.echo
reach = self.sky.echo_reach
if every <= 0 or reach <= 0:
return
age = echo_age(time.time() - self.sky.started, every,
phase_of(icao))
part = age / max(1e-9, every)
radius = reach * part
if radius < 2:
return
ring = QColor(colour)
# Dimmer the further out it has got, and gone by the time it
# arrives: a ring that vanished at full strength would read as
# something switching off rather than something spending itself.
ring.setAlpha(int(colour.alpha() * (1.0 - part) ** 1.6 * 0.75))
painter.setPen(QPen(ring, 1.2))
painter.setBrush(_NO_BRUSH)
painter.drawEllipse(QPointF(x, y), radius, radius)
def _pulse_level(self, icao: str):
"""How far up its pulse this aircraft is, or None for no pulse.
Each aeroplane is offset by its own address, so a sky full of
them swells and fades separately rather than beating as one --
which reads as a display flashing rather than as a lot of
separate things transmitting.
"""
from .flightmap import phase_of, pulse_at
if self.sky.pulse <= 0:
return None
return pulse_at(time.time() - self.sky.started, self.sky.pulse,
phase_of(icao))
def _draw_symbol(self, painter, x, y, heading, colour, def _draw_symbol(self, painter, x, y, heading, colour,
strength: float = 1.0) -> None: strength: float = 1.0, icao: str = "") -> None:
angle = math.radians(heading % 360.0) angle = math.radians(heading % 360.0)
sin, cos = math.sin(angle), math.cos(angle) sin, cos = math.sin(angle), math.cos(angle)
@ -1247,6 +1325,14 @@ def _build():
return QPointF(x + side * cos + ahead * sin, return QPointF(x + side * cos + ahead * sin,
y + side * sin - ahead * cos) y + side * sin - ahead * cos)
level = self._pulse_level(icao) if strength >= 1.0 else None
if level is not None:
# A painter can blend, so the swell here is continuous
# rather than the handful of steps an indexed picture is
# held to: the colour walks from dim towards white and the
# halo widens with it, which is a beam sitting in one place
# a little too long.
colour = _lifted(colour, level)
shape = QPolygonF([point(9, 0), point(-6, 5), shape = QPolygonF([point(9, 0), point(-6, 5),
point(-3, 0), point(-6, -5)]) point(-3, 0), point(-6, -5)])
# The halo is stroked round the outline rather than filled, so # The halo is stroked round the outline rather than filled, so
@ -1254,13 +1340,27 @@ def _build():
# making it bigger. # making it bigger.
from .flightmap import THEME from .flightmap import THEME
if THEME.glow: burn = 0.0 if level is None else max(0.0, level - 0.25) * 5.0
if THEME.glow or burn > 0:
def outline(pen): def outline(pen):
painter.setPen(pen) painter.setPen(pen)
painter.setBrush(_NO_BRUSH) painter.setBrush(_NO_BRUSH)
painter.drawPolygon(shape) painter.drawPolygon(shape)
glow_line(painter, colour, 0.1, outline, core=False) if burn > 0:
# The raster burn: the shape itself, laid down wide and
# faint under the aeroplane rather than a circle drawn
# round it, so the glow has the shape of the thing
# casting it.
for ring in (2.0, 1.0):
halo = QColor(colour)
halo.setAlpha(int(min(150, 60 * burn) / ring))
pen = QPen(halo, burn * 2.2 * ring)
pen.setJoinStyle(ROUND_JOIN)
pen.setCapStyle(ROUND_CAP)
outline(pen)
if THEME.glow:
glow_line(painter, colour, 0.1, outline, core=False)
painter.setPen(NO_PEN) painter.setPen(NO_PEN)
painter.setBrush(colour) painter.setBrush(colour)
painter.drawPolygon(shape) painter.drawPolygon(shape)

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand. .\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-06" "bandsaunter 2026-09-06_03" "User Commands" .TH BANDSAUNTER 1 "2026-09-06" "bandsaunter 2026-09-06_04" "User Commands"
.SH NAME .SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS .SH SYNOPSIS
@ -1951,6 +1951,90 @@ Accepts: one of: knots, mph, kph.
knots is what aviation uses and what the aircraft actually said. mph or kph if that is what means something to you. knots is what aviation uses and what the aircraft actually said. mph or kph if that is what means something to you.
.RE .RE
.PP .PP
.SS Effects
.TP
.B --pulse / --no-pulse
Pulsing aircraft \[em] swell each aircraft from bright to dim and back, over and over.
.br
Setting name \fBpulse\fR, default \fByes\fR.
.RS
.PP
Turn it off for a still picture, or if a moving screen in the corner of the room is a distraction.
.RE
.TP
.B --pulse-rate
Pulse takes \[em] how long one swell from dim to bright and back again takes (s).
.br
Setting name \fBpulse_rate\fR, default \fB2.2 s\fR.
.br
Accepts: at least 0.2, at most 30.
.RS
.PP
Two or three seconds reads as breathing. Under one is a strobe.
.RE
.TP
.B --echo / --no-echo
Echo circles \[em] rings travelling outward from each aircraft, growing and dimming.
.br
Setting name \fBecho\fR, default \fByes\fR.
.RS
.PP
Handsome on a quiet sky. Turn it off when a hundred aircraft are overhead.
.RE
.TP
.B --echo-every
Echo every \[em] how long between one ring leaving an aircraft and the next (s).
.br
Setting name \fBecho_every\fR, default \fB3 s\fR.
.br
Accepts: at least 0.3, at most 60.
.RS
.PP
Longer for a calmer picture, shorter for a busier one.
.RE
.TP
.B --echo-size
Echo reaches \[em] how far a ring gets from its aircraft before it has faded away (px).
.br
Setting name \fBecho_size\fR, default \fB46 px\fR.
.br
Accepts: at least 6, at most 400.
.RS
.PP
About forty on a nine-hundred-pixel picture. Scale it up with the width.
.RE
.PP
.SS Pulsing and echoes
.B \-\-pulse
swells each aircraft from bright to dim and back. At the top of the swell it
burns: drawn in a set of peak colours and wearing a halo grown out of its own
shape, which is what a phosphor does when the beam sits in one place a little
too long. The halo is the aeroplane itself spread outward a pixel at a time
rather than a circle drawn round it, so the glow has the shape of the thing
casting it, and it goes only where the picture was still empty.
.PP
.B \-\-echo
sends a ring travelling outward from each aircraft, growing and dimming as it
goes \[em] what a radar repeater does, and what the eye reads as this thing is
transmitting, which is exactly what an aeroplane on this picture is doing
twice a second. One ring at a time per aircraft.
.PP
.BI \-\-pulse\-rate " SECONDS" ,
.BI \-\-echo\-every " SECONDS"
and
.BI \-\-echo\-size " PIXELS"
set the rest. The two times are seconds of watching rather than of flying, so
a pulse looks the same whatever speed an evening is being run through. Each
aircraft is offset by its own address, so a sky full of them swells and rings
separately rather than beating as one, and an aeroplane keeps its own rhythm
from one drawing of the same log to the next. Only an aircraft still being
heard pulses: one that has gone quiet is fading, and a thing that is fading
and beating at once says two contradictory things about itself.
.PP
The window can blend and its swell is continuous. The animation cannot, a GIF
being indexed colour, so there the swell is the handful of steps a palette
allows \[em] which family of colours the aeroplane is drawn from, and how far
its halo reaches.
.SS Range rings .SS Range rings
.B \-\-rings .B \-\-rings
puts faint discs at a quarter, a half and three quarters of the radius, puts faint discs at a quarter, a half and three quarters of the radius,

View file

@ -1102,6 +1102,37 @@ Every option the ADS-B side takes, in the six groups the menu shows them in.
Each is a flag here and a line in the menu, and both come from one table in Each is a flag here and a line in the menu, and both come from one table in
the program, so they cannot disagree. the program, so they cannot disagree.
.AIRCRAFT_OPTIONS_HERE .AIRCRAFT_OPTIONS_HERE
.SS Pulsing and echoes
.B \-\-pulse
swells each aircraft from bright to dim and back. At the top of the swell it
burns: drawn in a set of peak colours and wearing a halo grown out of its own
shape, which is what a phosphor does when the beam sits in one place a little
too long. The halo is the aeroplane itself spread outward a pixel at a time
rather than a circle drawn round it, so the glow has the shape of the thing
casting it, and it goes only where the picture was still empty.
.PP
.B \-\-echo
sends a ring travelling outward from each aircraft, growing and dimming as it
goes \[em] what a radar repeater does, and what the eye reads as this thing is
transmitting, which is exactly what an aeroplane on this picture is doing
twice a second. One ring at a time per aircraft.
.PP
.BI \-\-pulse\-rate " SECONDS" ,
.BI \-\-echo\-every " SECONDS"
and
.BI \-\-echo\-size " PIXELS"
set the rest. The two times are seconds of watching rather than of flying, so
a pulse looks the same whatever speed an evening is being run through. Each
aircraft is offset by its own address, so a sky full of them swells and rings
separately rather than beating as one, and an aeroplane keeps its own rhythm
from one drawing of the same log to the next. Only an aircraft still being
heard pulses: one that has gone quiet is fading, and a thing that is fading
and beating at once says two contradictory things about itself.
.PP
The window can blend and its swell is continuous. The animation cannot, a GIF
being indexed colour, so there the swell is the handful of steps a palette
allows \[em] which family of colours the aeroplane is drawn from, and how far
its halo reaches.
.SS Range rings .SS Range rings
.B \-\-rings .B \-\-rings
puts faint discs at a quarter, a half and three quarters of the radius, puts faint discs at a quarter, a half and three quarters of the radius,

View file

@ -1947,3 +1947,189 @@ def test_the_card_fades_with_the_label_it_is_behind():
return int((base.astype(int) - img.astype(int)).clip(0).sum()) return int((base.astype(int) - img.astype(int)).clip(0).sum())
assert darkness(1.0) > darkness(0.5) > 0 assert darkness(1.0) > darkness(0.5) > 0
# ---------------------------------------------------------------------------
# Pulsing aircraft, and the rings that leave them
# ---------------------------------------------------------------------------
def test_a_pulse_swells_and_falls_rather_than_flashing():
"""A raised cosine, so the bright end is a swell and not a flash: what
a phosphor does when the beam lingers, not what a warning light does."""
over = [fm.pulse_at(t / 20.0 * 2.0, 2.0) for t in range(21)]
assert over[0] == pytest.approx(0.0, abs=1e-9)
assert over[10] == pytest.approx(1.0, abs=1e-9)
assert over[20] == pytest.approx(0.0, abs=1e-9)
assert over[:11] == sorted(over[:11]), "it does not rise smoothly"
assert over[10:] == sorted(over[10:], reverse=True), "nor fall smoothly"
assert all(0.0 <= v <= 1.0 for v in over)
def test_a_pulse_repeats_at_the_rate_it_was_given():
for rate in (1.0, 2.2, 7.5):
assert fm.pulse_at(0.0, rate) == pytest.approx(fm.pulse_at(rate, rate))
assert fm.pulse_at(0.3, rate) == pytest.approx(
fm.pulse_at(0.3 + 3 * rate, rate))
def test_a_rate_of_nothing_is_no_pulse_at_all():
assert fm.pulse_at(1.7, 0.0) == 1.0
def test_each_aircraft_keeps_its_own_place_in_the_cycle():
"""A sky full of them beating as one reads as a display flashing rather
than as a lot of separate things transmitting."""
seen = {fm.phase_of(f"A0{n:04X}") for n in range(200)}
assert len(seen) > 150, "too many aircraft share a phase"
assert all(0.0 <= p < 1.0 for p in seen)
# Stable: the same address is the same phase, every time.
assert fm.phase_of("AC4C44") == fm.phase_of("AC4C44")
assert fm.phase_of("") == 0.0
def test_an_echo_leaves_and_travels_out_and_is_replaced():
"""One at a time: a new one leaves as the last reaches its reach."""
ages = [fm.echo_age(t / 10.0, 3.0) for t in range(31)]
assert ages[0] == pytest.approx(0.0)
assert ages[:30] == sorted(ages[:30]), "it does not travel outward"
assert ages[30] == pytest.approx(0.0), "it does not start again"
assert all(0.0 <= a < 3.0 for a in ages)
assert fm.echo_age(1.0, 0.0) == 0.0
def test_a_ring_is_a_circle_and_not_a_scattering_of_dots():
img = np.full((80, 80), fm.BG, dtype=np.uint8)
fm._ring(img, 40, 40, 20, fm.INK)
ys, xs = np.where(img == fm.INK)
assert len(xs) > 90, "too few pixels for a circle of that size"
away = np.hypot(xs - 40, ys - 40)
assert away.min() >= 19 and away.max() <= 21, (away.min(), away.max())
# No gaps: every angle round the circle has a pixel near it.
import math as _math
for step in range(0, 360, 10):
angle = _math.radians(step)
want = (40 + 20 * _math.cos(angle), 40 + 20 * _math.sin(angle))
assert np.hypot(xs - want[0], ys - want[1]).min() < 1.6, step
def test_a_ring_of_nothing_draws_nothing():
img = np.full((40, 40), fm.BG, dtype=np.uint8)
fm._ring(img, 20, 20, 0, fm.INK)
assert not (img == fm.INK).any()
def test_the_burn_has_the_shape_of_the_aircraft_casting_it():
"""Grown from the aeroplane rather than drawn as a circle round it: a
ring of dots is what a halo is not."""
img = np.full((60, 60), fm.BG, dtype=np.uint8)
fm._pulse(img, 30, 30, 0.0, 30_000, 1.0)
step = fm.altitude_step(30_000)
halo = (img == fm.TRAIL + step) | (img == fm.OLD + step)
core = img >= fm.HOT
assert core.any(), "no aircraft at the top of its pulse"
assert halo.sum() > 20, "no halo round it"
# The halo hugs the shape: every halo pixel is within a few of a core one.
hy, hx = np.where(halo)
cy, cx = np.where(core)
for x, y in zip(hx[:60], hy[:60]):
assert np.hypot(cx - x, cy - y).min() <= 3.5, (x, y)
def test_a_burn_never_paints_over_anything_already_drawn():
"""A halo is what light does to the dark around a thing. Painting it
over a neighbouring aeroplane would be light doing something light does
not do -- so with nothing but other drawn things around, there is
nowhere for it to go and it goes nowhere."""
img = np.full((60, 60), fm.AIRPORT, dtype=np.uint8)
fm._pulse(img, 30, 30, 0.0, 30_000, 1.0)
step = fm.altitude_step(30_000)
assert not ((img == fm.TRAIL + step) | (img == fm.OLD + step)).any(), \
"the halo was painted over the aerodromes"
assert (img >= fm.HOT).any(), "the aircraft itself was not drawn"
def test_a_burn_does_go_on_the_ground_and_the_empty_background():
"""The other half of it: there is somewhere for the light to go."""
step = fm.altitude_step(30_000)
for under in (fm.BG, fm.GRID, fm.GROUND + 4):
img = np.full((60, 60), under, dtype=np.uint8)
fm._pulse(img, 30, 30, 0.0, 30_000, 1.0)
assert ((img == fm.TRAIL + step) | (img == fm.OLD + step)).any(), under
def test_the_dim_end_of_a_pulse_is_dimmer_than_the_bright_end():
def drawn(level):
img = np.full((60, 60), fm.BG, dtype=np.uint8)
fm._pulse(img, 30, 30, 0.0, 30_000, level)
return int(fm.PALETTE[img].astype(int).sum())
assert drawn(1.0) > drawn(0.55) > drawn(0.3) > drawn(0.0)
def _beat_frame(beat, **over):
track = straight()
view = fm.fit([track], width=600, box=(50.0, -3.0, 52.0, 3.0))
base = fm.background(view, unit="knots")
settings = dict(unit="knots", labels=False, beat=beat)
settings.update(over)
return base, fm.render_frame(base, view, [track], track.fixes[0].at,
**settings)
def test_a_frame_with_no_pulse_and_no_echo_draws_neither():
"""Which is what the pictures looked like before, and still can."""
base, img = _beat_frame(1.1)
assert not (img >= fm.HOT).any(), "an aircraft burned with no pulse asked"
plain = _beat_frame(0.0)[1]
assert np.array_equal(img, plain), "something moved with both turned off"
def _at_phase(rate, want):
"""The beat at which straight()'s aircraft is `want` of the way round.
Each aeroplane is offset by its own address, so "half a cycle in" is a
different moment for each of them and a test cannot assume it is at the
half-way mark of the clock.
"""
return rate * ((want - fm.phase_of(straight().icao)) % 1.0)
def test_a_pulsing_frame_burns_at_the_top_of_the_swell():
_base, peak = _beat_frame(_at_phase(2.2, 0.5), pulse=2.2)
_base, trough = _beat_frame(_at_phase(2.2, 0.0), pulse=2.2)
assert (peak >= fm.HOT).any(), "nothing burned at the top of the pulse"
assert not (trough >= fm.HOT).any(), "it burned at the bottom too"
assert fm.PALETTE[peak].astype(int).sum() > \
fm.PALETTE[trough].astype(int).sum()
def test_an_echoing_frame_sends_a_ring_out_and_a_bigger_one_later():
def reach(beat):
_base, img = _beat_frame(beat, echo=3.0, echo_reach=60)
step = fm.altitude_step(35_000)
ring = ((img == fm.TRAIL + step) | (img == fm.OLD + step)
| (img == fm.FAINT + step))
ys, xs = np.where(ring)
return 0 if not len(xs) else int(np.hypot(xs - xs.mean(),
ys - ys.mean()).max())
early, late = reach(_at_phase(3.0, 0.15)), reach(_at_phase(3.0, 0.8))
assert early > 0, "no ring at all"
assert late > early, f"the ring did not grow: {early} then {late}"
def test_an_aircraft_that_has_gone_quiet_does_not_pulse():
"""It is fading, and a thing that is fading and beating at once says
two contradictory things about itself."""
track = straight()
view = fm.fit([track], width=600, box=(50.0, -3.0, 52.0, 3.0))
base = fm.background(view, unit="knots")
gone = track.fixes[-1].at + 320.0 # past stale, inside the fade
peak = _at_phase(2.2, 0.5) # where it would burn
img = fm.render_frame(base, view, [track], gone, unit="knots",
labels=False, stale=300.0, fade=60.0, beat=peak,
pulse=2.2, echo=3.0)
assert not (img >= fm.HOT).any(), "a fading aircraft burned"
# And it is still on the picture, fading, rather than simply gone.
assert (img != base).any(), "the aircraft was not drawn at all"

View file

@ -1919,3 +1919,120 @@ def test_nothing_in_the_window_is_drawn_over_the_flag(app):
assert is_flag(x, y), "the foot of the pole was painted over" assert is_flag(x, y), "the foot of the pole was painted over"
assert all(is_flag(x, y - up) for up in range(HOME_POLE)), \ assert all(is_flag(x, y - up) for up in range(HOME_POLE)), \
"part of the pole was painted over" "part of the pole was painted over"
# ---------------------------------------------------------------------------
# Pulsing aircraft, and the rings that leave them
# ---------------------------------------------------------------------------
def test_a_sky_draws_neither_effect_unless_it_is_asked_to():
plain = a_sky()
assert plain.pulse == 0.0 and plain.echo == 0.0
lively = Sky(pulse=2.2, echo=3.0, echo_reach=46)
assert lively.pulse == 2.2 and lively.echo == 3.0
def test_the_effect_settings_are_kept_inside_their_range():
quiet = Sky(pulse=-1.0, echo=-2.0, echo_reach=-5.0)
assert quiet.pulse == 0.0 and quiet.echo == 0.0 and quiet.echo_reach == 0.0
def _at_own_peak(sky, icao, want=0.5):
"""Wind the clock so this aircraft is `want` of the way round its pulse.
Each is offset by its own address, so a test cannot assume that half a
cycle on the clock is half a cycle for any particular aeroplane.
"""
from bandsaunter.flightmap import phase_of
rate = sky.pulse or sky.echo
sky.started = time.time() - rate * ((want - phase_of(icao)) % 1.0)
@qt
def test_an_aircraft_burns_at_the_top_of_its_pulse(app):
"""Brighter, and wearing a halo grown out of its own shape: what a
phosphor does when the beam sits in one place a little too long."""
from bandsaunter.livemap import SkyView
def drawn(want):
sky = a_sky(a_blip(), pulse=2.2)
_at_own_peak(sky, "A76154", want)
view = SkyView(sky)
view.show_ground = False
view.detail = 0 # the symbol alone
picture = _rendered(view)
return int(picture[:, :, :3].astype(int).sum())
assert drawn(0.5) > drawn(0.0), "it did not brighten at the top"
@qt
def test_a_pulse_turned_off_leaves_the_aircraft_alone(app):
"""Asked of the level rather than of the pixels: the header carries a
running clock, so two whole frames are never identical anyway."""
from bandsaunter.livemap import SkyView
off = SkyView(a_sky(a_blip()))
assert off._pulse_level("A76154") is None
on = SkyView(a_sky(a_blip(), pulse=2.2))
for want in (0.0, 0.25, 0.5, 0.75):
_at_own_peak(on.sky, "A76154", want)
level = on._pulse_level("A76154")
assert level is not None and 0.0 <= level <= 1.0
_at_own_peak(on.sky, "A76154", 0.5)
assert on._pulse_level("A76154") > 0.98, "it does not reach the top"
_at_own_peak(on.sky, "A76154", 0.0)
assert on._pulse_level("A76154") < 0.02, "nor the bottom"
@qt
def test_a_ring_leaves_an_aircraft_and_grows(app):
from bandsaunter.livemap import SkyView
def spread(want):
"""How far the ring has got from its aircraft.
Measured against the same view with the echo turned off, and only
within a box round the aeroplane: the header carries a running
clock and the box a running age, and neither is a ring.
"""
sky = a_sky(a_blip(), echo=3.0, echo_reach=60)
_at_own_peak(sky, "A76154", want)
view = SkyView(sky)
view.show_ground = False
view.detail = 0
with_it = _rendered(view)
sky.echo = 0.0
without = _rendered(view)
x, y = view.projection().xy(a_blip().latitude, a_blip().longitude)
top, left = max(0, y - 90), max(0, x - 90)
near = slice(top, y + 90), slice(left, x + 90)
differs = (with_it[near] != without[near]).any(axis=2)
ys, xs = np.where(differs)
if not len(xs):
return 0
return int(np.hypot(xs + left - x, ys + top - y).max())
early, late = spread(0.15), spread(0.8)
assert early > 0, "no ring at all"
assert late > early, f"the ring did not grow: {early} then {late}"
@qt
def test_the_window_keeps_asking_for_frames_while_anything_is_alive(app):
"""A pulse and an echo are never finished, so the window has to keep
repainting the way it does while a box is on its way somewhere."""
from bandsaunter.livemap import SkyView
lively = SkyView(a_sky(a_blip(), pulse=2.2))
lively.show_ground = False
_rendered(lively)
assert lively._glide_timer.isActive(), "it stopped asking for frames"
still = SkyView(a_sky(a_blip()))
still.show_ground = False
_rendered(still)
assert not still._glide_timer.isActive(), \
"it is repainting with nothing to animate"