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:
parent
8ed01f991f
commit
f01de4117f
10 changed files with 907 additions and 16 deletions
55
README.md
55
README.md
|
|
@ -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
|
||||
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
|
||||
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
|
||||
|
|
@ -1657,7 +1700,7 @@ entirely.
|
|||
|
||||
## 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
|
||||
table in the source, so they cannot disagree. `man bandsaunter` has the long
|
||||
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 |
|
||||
| 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
|
||||
`~/.config/bandsaunter/aircraft.yaml`, which is plain YAML you can edit or
|
||||
copy between machines. API keys are deliberately **not** kept there — see
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ and transcribing speech.
|
|||
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
||||
# to two digits so versions sort as text.
|
||||
VERSION_DATE = "2026-09-06"
|
||||
VERSION_REVISION = 3
|
||||
VERSION_REVISION = 4
|
||||
|
||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,11 @@ class AircraftOptions:
|
|||
window_rings: bool = True
|
||||
theme: str = "night"
|
||||
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
|
||||
tile_url: str = ""
|
||||
|
||||
|
|
@ -452,9 +457,66 @@ OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
|
|||
flags=("--speed-unit",), metavar="UNIT",
|
||||
guidance="knots is what aviation uses and what the aircraft actually "
|
||||
"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]:
|
||||
|
|
@ -768,7 +830,10 @@ def watch(console, options: AircraftOptions, output_dir: str,
|
|||
fade=max(0.0, options.fade),
|
||||
airports=options.airports,
|
||||
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.log_name = log.path.name if log is not None else ""
|
||||
if options.simulate:
|
||||
|
|
@ -980,6 +1045,9 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None):
|
|||
airports=options.airports,
|
||||
rings=options.rings,
|
||||
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),
|
||||
centre=read_position(options.location))
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
|
|
|
|||
|
|
@ -260,6 +260,25 @@ examples:
|
|||
ad.add_argument("--no-labels", dest="labels", action="store_false",
|
||||
default=None,
|
||||
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",
|
||||
choices=("night", "digital", "phosphor", "amber", "red",
|
||||
"blue", "green", "orange", "wargames", "norad",
|
||||
|
|
@ -339,6 +358,25 @@ examples:
|
|||
metavar="PERCENT",
|
||||
help="how solid the card behind each information box is "
|
||||
"(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",
|
||||
choices=("night", "digital", "phosphor", "amber", "red",
|
||||
"blue", "green", "orange", "wargames", "norad",
|
||||
|
|
@ -1200,6 +1238,10 @@ def cmd_adsb(args) -> int:
|
|||
options.theme = args.theme
|
||||
if getattr(args, "box_opacity", None) is not None:
|
||||
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:
|
||||
options.rings = args.rings
|
||||
if getattr(args, "window_rings", None) is not None:
|
||||
|
|
@ -1270,6 +1312,10 @@ def cmd_flights(args) -> int:
|
|||
options.theme = args.theme
|
||||
if getattr(args, "box_opacity", None) is not None:
|
||||
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:
|
||||
options.rings = args.rings
|
||||
if getattr(args, "window_rings", None) is not None:
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ __all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
|
|||
"ground_for", "background", "label_lines", "draw_flag",
|
||||
"set_theme", "theme", "THEME", "bloom", "draw_home",
|
||||
"draw_rings", "RING_STEPS", "ring_labels",
|
||||
"pulse_at", "phase_of", "echo_age", "hot_step",
|
||||
"FLAG", "FAINT", "flag_index", "dim_ground", "local_airports",
|
||||
"showing", "fade_ramp",
|
||||
"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
|
||||
# furthest from every altitude colour in every theme.
|
||||
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
|
||||
GROUND_SHADES = 32
|
||||
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):
|
||||
table += [colour, _dimmed([colour], 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))
|
||||
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,
|
||||
fade: float = 0.0, places: "LabelPlaces | None" = 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.
|
||||
|
||||
``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)
|
||||
colour = faded + altitude_step(now.altitude_ft)
|
||||
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)
|
||||
# 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:
|
||||
_label(img, x, y, track, now, colour, taken, unit,
|
||||
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)
|
||||
|
||||
|
||||
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,
|
||||
colour: int) -> None:
|
||||
"""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,
|
||||
brightness: float = GROUND_BRIGHTNESS,
|
||||
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:
|
||||
"""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,
|
||||
fade=fade, places=places, step=step,
|
||||
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)
|
||||
|
||||
path = Path(out_path)
|
||||
|
|
|
|||
|
|
@ -361,7 +361,8 @@ class Sky:
|
|||
home=None, radius_nm: float = 100.0,
|
||||
brightness: float = 0.70, fade: float = 20.0,
|
||||
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
|
||||
|
||||
self.unit = unit
|
||||
|
|
@ -379,6 +380,10 @@ class Sky:
|
|||
# all puts the words straight on the map, which is readable over
|
||||
# water and not over a city.
|
||||
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.aircraft_seen = 0
|
||||
self.started = time.time()
|
||||
|
|
@ -694,6 +699,28 @@ def _build():
|
|||
def craft_colour(feet, alpha=255) -> QColor:
|
||||
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,
|
||||
dash=None) -> None:
|
||||
"""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]
|
||||
taken: list[tuple[int, int, int, int]] = []
|
||||
for blip, (x, y), strength in placed:
|
||||
self._draw_symbol(painter, x, y, blip.track_deg,
|
||||
craft_colour(blip.altitude_ft,
|
||||
_alpha(strength)), strength)
|
||||
colour = craft_colour(blip.altitude_ft, _alpha(strength))
|
||||
# The echo goes down before the aeroplane, so the ring
|
||||
# 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))
|
||||
labels = [self._lay_out(blip, x, y, taken, strength)
|
||||
for blip, (x, y), strength in placed]
|
||||
|
|
@ -890,7 +921,13 @@ def _build():
|
|||
painter.end()
|
||||
# Only while something is actually moving: the rest of the time
|
||||
# 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)
|
||||
|
||||
def _draw_waiting(self, painter) -> None:
|
||||
|
|
@ -1238,8 +1275,49 @@ def _build():
|
|||
self._box_moving = True
|
||||
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,
|
||||
strength: float = 1.0) -> None:
|
||||
strength: float = 1.0, icao: str = "") -> None:
|
||||
angle = math.radians(heading % 360.0)
|
||||
sin, cos = math.sin(angle), math.cos(angle)
|
||||
|
||||
|
|
@ -1247,6 +1325,14 @@ def _build():
|
|||
return QPointF(x + side * cos + ahead * sin,
|
||||
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),
|
||||
point(-3, 0), point(-6, -5)])
|
||||
# The halo is stroked round the outline rather than filled, so
|
||||
|
|
@ -1254,13 +1340,27 @@ def _build():
|
|||
# making it bigger.
|
||||
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):
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(_NO_BRUSH)
|
||||
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.setBrush(colour)
|
||||
painter.drawPolygon(shape)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.\" 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
|
||||
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
||||
.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.
|
||||
.RE
|
||||
.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
|
||||
.B \-\-rings
|
||||
puts faint discs at a quarter, a half and three quarters of the radius,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
the program, so they cannot disagree.
|
||||
.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
|
||||
.B \-\-rings
|
||||
puts faint discs at a quarter, a half and three quarters of the radius,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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 all(is_flag(x, y - up) for up in range(HOME_POLE)), \
|
||||
"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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue