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
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue