diff --git a/README.md b/README.md index b0b0b52..efe6a3c 100644 --- a/README.md +++ b/README.md @@ -1014,8 +1014,19 @@ the arrow first, so the two ends of the flight stay whole and sit under one another where they read as a pair. `d` cycles the detail — full box, just height and speed, or symbols alone — -for when the sky is busy. `t` toggles trails, `g` the map underneath, `+`/`-` -the range, `q` closes it. +for when the sky is busy. `t` toggles trails, `g` the map underneath, `[`/`]` +its brightness, `+`/`-` the range, `q` closes it. + +Aircraft fade out here too, over `--fade` seconds, keeping their symbol and +losing their box as they go — a box at a tenth of its colour is something in +the way of the aircraft still flying. One that has gone quiet stops being +counted as overhead, since saying it is would be saying more than was heard. + +The map holds still. It is fetched a third larger than the window in every +direction, and the middle of the view settles once and then stays put rather +than being recomputed as aircraft come and go — otherwise the view shifts by a +fraction of a mile every few seconds, throws away the tiles fetched for the +old one, and the ground blinks out while new ones arrive. **Closing the window leaves exactly the files a passive capture does**: the same log, the same report, the same KML and animation, because it is the same @@ -1138,6 +1149,14 @@ the twenty minutes the data says it took. Nothing moves at a constant speed for the look of the thing, and an aircraft not heard from for five minutes stops being drawn rather than being flown on by guesswork. +**An aircraft that goes quiet fades rather than vanishing.** Taking it off the +picture between one frame and the next says it stopped existing; fading it +says it stopped talking, which is what happened. It fades where it was last +actually seen and never along a reckoned track — the reason for giving up on +it in the first place is that where it would be by now is a guess. `--fade +SECONDS` (20 by default, and in the options menu) sets how long that takes; +zero takes it away at once, as before. + Time runs at `--speed` seconds of flying per second of animation, or give `--seconds` and let it work the speed out. Altitude is the colour, low warm to high cold, with the key along the bottom; the trail behind each aircraft is the @@ -1165,6 +1184,16 @@ at all. Where a route arrives as nothing but a pair of airport codes, the country comes from the code itself: the first letter or two of an ICAO code is a region, so `EGLL` is British and `KSEA` American with nothing else to go on. +The aircraft's own **country of registration** gets a flag too, beside the +type and registration — from the register where one answered, and otherwise +from the 24-bit address, which says by treaty who issued it. + +**Every aerodrome under the picture is marked**, not only the ones being flown +between: a receiver hears aircraft over its own county, and the county's +airports are what say where on the map you are looking. They come from the +same OpenStreetMap data the tiles are drawn from, asked as a question rather +than a picture, once per area and kept for a month — a runway does not move. +`--no-airports` turns it off. The GIF is written here from first principles — a palette, an LZW stream, frame differencing with a transparent index — in the same spirit as the PNGs @@ -1255,6 +1284,17 @@ on their own, `--tiles URL` points at another server (your own, if you run one), and when there is no network and nothing cached the picture falls back to the plain grid it drew before. +The map is drawn at the resolution it was fetched at, not stretched: tiles are +averaged down to the picture rather than point-sampled, so lettering and roads +stay whole instead of breaking up, and the window fetches enough pixels to +cover its margin at full detail rather than enlarging what it has. + +`--map-brightness PERCENT` (70 by default) is how far up its range the map is +drawn. The ground has to stay dark enough that the aircraft are the brightest +thing on the picture and light enough that a coastline can be made out at all, +and which way to err depends on the screen you are looking at. In the window, +`[` and `]` change it while it runs. + ## Meters and weather sensors Two things on the ISM bands are worth naming rather than reporting as hex. diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index fba1582..35611de 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -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-04" -VERSION_REVISION = 4 +VERSION_REVISION = 6 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/aircraft.py b/bandsaunter/aircraft.py index 3ef762d..089adbb 100644 --- a/bandsaunter/aircraft.py +++ b/bandsaunter/aircraft.py @@ -110,11 +110,14 @@ class AircraftOptions: width: int = 960 trail: float = 0.0 stale: float = 300.0 + fade: float = 20.0 labels: bool = True radius: float = 100.0 location: str = "" recheck: bool = False basemap: bool = True + airports: bool = True + map_brightness: int = 70 tile_url: str = "" def validate(self) -> list[str]: @@ -268,6 +271,19 @@ OPTIONS: tuple[Setting, ...] = ( "leaves a comet tail instead, which is easier to follow when many " "aircraft cross the same piece of sky.", unit="s", minimum=0.0, flags=("--trail",), example="0"), + O("fade", "Fade out over", "Drawing", "float", + "how long an aircraft takes to fade away once it has gone quiet", + "An aircraft that stops transmitting has not stopped existing, and " + "taking it off the picture between one frame and the next says that it " + "did. Instead it is left where it was last actually seen and fades from " + "there, which reads as an aircraft going quiet rather than as a blink. " + "Nothing is invented by it: the fading happens at the last known " + "position, never at a reckoned one, because the reason for giving up on " + "an aircraft in the first place is that where it would be by now is a " + "guess. Zero takes it away the moment it is given up on.", + unit="s", minimum=0.0, flags=("--fade",), example="20", + guidance="Long enough to notice, short enough that a busy sky is not " + "half ghosts."), O("stale", "Forget after", "Drawing", "float", "stop drawing an aircraft this long after its last report", "Between reports an aircraft is dead-reckoned from the speed and " @@ -311,6 +327,28 @@ OPTIONS: tuple[Setting, ...] = ( guidance="Worth turning on for anything recorded before this version. " "Newer logs have the check applied as they are written, so it " "finds almost nothing."), + O("airports", "Mark the airports", "Drawing", "bool", + "mark every aerodrome on the map, not only the ones flown between", + "A route names the two airports its aircraft is flying between, and " + "those are almost never the ones underneath: a receiver hears aircraft " + "over its own county, and the county's airports are what say where on " + "the map you are looking. They are asked for once per area from the " + "same map data the tiles are drawn from, and kept on disk for a month " + "afterwards, since a runway does not move.", + flags=("--airports",), off_flags=("--no-airports",), + guidance="Turn it off for a picture with nothing but the aircraft on " + "it, or where there is no network and nothing cached."), + O("map_brightness", "Map brightness", "Drawing", "int", + "how bright the map under the aircraft is drawn, as a percentage", + "The map is the ground, not the subject, so it is drawn dark enough " + "that the aircraft and their trails stay the brightest things on the " + "picture. Too dark and a coastline cannot be made out at all; too " + "bright and a city washes out the aircraft crossing it. This is which " + "way to err on the screen you are actually looking at.", + unit="%", minimum=10, maximum=100, flags=("--map-brightness",), + metavar="PERCENT", example="70", + guidance="Turn it up until the coast and the roads are readable, and " + "no further."), O("basemap", "Map underneath", "Drawing", "bool", "draw a real map under the flight paths", "A flight path over a black rectangle says how the aircraft moved and " @@ -641,7 +679,9 @@ def watch(console, options: AircraftOptions, output_dir: str, book = FlightBook(online=options.lookup) sky = livemap.Sky(unit=options.speed_unit, hold=options.hold, home=read_position(options.location), - radius_nm=radius_in_nm(options) or 100.0) + radius_nm=radius_in_nm(options) or 100.0, + brightness=max(10, options.map_brightness) / 100.0, + fade=max(0.0, options.fade)) sky.started = started sky.log_name = log.path.name if log is not None else "" if options.simulate: @@ -826,8 +866,11 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None): seconds=options.length, speed=options.speed, width=options.width, trail_seconds=options.trail, stale=options.stale, labels=options.labels, + fade=max(0.0, options.fade), unit=options.speed_unit, ground=options.basemap, tile_url=options.tile_url, + brightness=max(10, options.map_brightness) / 100.0, + airports=options.airports, radius_nm=radius_in_nm(options), centre=read_position(options.location)) except (OSError, RuntimeError, ValueError) as exc: diff --git a/bandsaunter/basemap.py b/bandsaunter/basemap.py index dc885d4..1d7b87a 100644 --- a/bandsaunter/basemap.py +++ b/bandsaunter/basemap.py @@ -22,10 +22,12 @@ numpy, which is all this needs. from __future__ import annotations +import json import math import os import struct import time +import urllib.parse import urllib.request import zlib from pathlib import Path @@ -36,7 +38,7 @@ from . import __version__ __all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic", "ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM", - "cache_dir", "PNGError"] + "cache_dir", "PNGError", "airports_in", "AIRPORTS_URL"] # The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here # instead; nothing below knows anything about this one in particular. @@ -48,8 +50,8 @@ ATTRIBUTION = "MAP DATA (C) OPENSTREETMAP CONTRIBUTORS" # A drawing is worth a few dozen tiles and no more. Past that the zoom is # reduced instead: a coarser map still says where the coastline is. -MAX_TILES = 40 -MAX_ZOOM = 12 +MAX_TILES = 90 +MAX_ZOOM = 13 MIN_ZOOM = 2 TILE_PIXELS = 256 @@ -61,6 +63,24 @@ USER_AGENT = (f"bandsaunter/{__version__} " FETCH_PAUSE = 0.12 +# Where to ask what aerodromes are in a piece of the world. The same OSM +# data the tiles are drawn from, asked as a question rather than a picture. +AIRPORTS_URL = "https://overpass-api.de/api/interpreter" + +# One question covers a whole view and is kept for a month, because runways +# do not move. The same politeness as the tiles: cache it, say who is +# asking, and do not ask twice for the same thing. +AIRPORT_CACHE_DAYS = 30 + +# Bumped when the question changes, so that answers to the old one are asked +# again rather than believed. +AIRPORT_CACHE_VERSION = 2 + +# Enough to name what is under an aircraft and not so many that the map is a +# list of airstrips. +MOST_AIRPORTS = 40 + + class PNGError(ValueError): """A PNG this decoder cannot read.""" @@ -294,6 +314,137 @@ def mosaic(south: float, west: float, north: float, east: float, zoom: int, return canvas, left * TILE_PIXELS, top * TILE_PIXELS +# --------------------------------------------------------------------------- +# What is on the ground +# --------------------------------------------------------------------------- + +def _resample(values: np.ndarray, edges: np.ndarray, axis: int) -> np.ndarray: + """Average each output cell over the source pixels that fall in it. + + A box filter, done as the difference of a running total so the whole + axis is two passes rather than a loop. Where the source is coarser than + the output -- a map zoomed in further than the tiles go -- a cell covers + less than one source pixel, and it takes that one. + """ + length = values.shape[axis] + starts = np.clip(np.floor(edges[:-1]).astype(np.int64), 0, length - 1) + ends = np.clip(np.ceil(edges[1:]).astype(np.int64), 1, length) + ends = np.maximum(ends, starts + 1) + running = np.cumsum(values, axis=axis, dtype=np.float64) + pad = np.zeros_like(np.take(running, [0], axis=axis)) + running = np.concatenate([pad, running], axis=axis) + total = (np.take(running, ends, axis=axis) + - np.take(running, starts, axis=axis)) + counts = (ends - starts).astype(np.float64) + shape = [1] * values.ndim + shape[axis] = counts.size + return (total / counts.reshape(shape)).astype(np.float32) + + +def _airport_cache(box) -> Path: + root = os.environ.get("XDG_CACHE_HOME") or "~/.cache" + name = "_".join(f"{round(v, 1):+06.1f}" for v in box) + return Path(root).expanduser() / "bandsaunter" / "airports" / f"{name}.json" + + +def airports_in(south: float, west: float, north: float, east: float, + url: str = AIRPORTS_URL, timeout: float = 45.0, + cache: Path | None = None, ask=None) -> list[dict]: + """Every aerodrome in a piece of the world, with a code and a position. + + Asked of the same OpenStreetMap data the tiles are drawn from, as a + question rather than a picture, and kept on disk afterwards: a runway + does not move, so one question covers a view for a month. + + An aerodrome with no code is left out. A map wants to say *which* + airport an aircraft is over, and there are a great many landing strips + with a name and nothing else; the ones worth marking have a code. + """ + box = (round(south, 1), round(west, 1), round(north, 1), round(east, 1)) + path = cache if cache is not None else _airport_cache(box) + try: + body = json.loads(path.read_text()) + if int(body.get("version") or 0) >= AIRPORT_CACHE_VERSION and \ + time.time() - float(body.get("fetched_at") or 0) < \ + AIRPORT_CACHE_DAYS * 86_400: + return body.get("airports") or [] + except (OSError, ValueError): + pass + + found: list[dict] = [] + try: + raw = (ask or _ask_overpass)(box, url, timeout) + for element in (raw or {}).get("elements", []): + tags = element.get("tags") or {} + code = _airport_code(tags) + lat = element.get("lat") or (element.get("center") or {}).get("lat") + lon = element.get("lon") or (element.get("center") or {}).get("lon") + if not code or lat is None or lon is None: + continue + found.append({"code": code, + "name": (tags.get("name") or "").strip(), + "icao": bool(tags.get("icao")), + "latitude": float(lat), "longitude": float(lon)}) + except Exception: + return [] # a map with no airports on it, not a crash + + # The ones with a real ICAO code first, since those are the ones an + # aircraft is likely to be flying to or from. + found.sort(key=lambda a: (0 if a.get("icao") else 1, a["code"])) + # One airport is often tagged twice -- a point for the terminal and an + # outline for the field -- and marking it twice writes its name over + # itself. + once: dict[str, dict] = {} + for one in found: + once.setdefault(one["code"], one) + found = list(once.values())[:MOST_AIRPORTS] + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"fetched_at": time.time(), + "version": AIRPORT_CACHE_VERSION, + "airports": found})) + except OSError: + pass + return found + + +def _airport_code(tags: dict) -> str: + """What to call an aerodrome, or nothing if it has no code at all. + + The ICAO code where it has one. Otherwise a reference, and only when it + is four letters: a great many landing strips carry a local identifier + like "14AZ" or "MX-0492", which names nothing anybody would recognise + and turns a map into a list of airstrips. + """ + icao = (tags.get("icao") or "").strip().upper() + if len(icao) == 4 and icao.isalpha(): + return icao + ref = (tags.get("ref") or "").strip().upper() + if len(ref) == 4 and ref.isalpha(): + return ref + return "" + + +def _ask_overpass(box, url: str, timeout: float) -> dict: + """The one question this program asks Overpass.""" + south, west, north, east = box + where = f"({south},{west},{north},{east})" + # Relations as well as nodes and ways: a big airport is a relation more + # often than not -- Tucson International and Davis-Monthan both are -- + # so asking only for the other two finds every airstrip in the county + # and misses the two the county is known for. + query = ("[out:json][timeout:25];" + f'(node["aeroway"="aerodrome"]{where};' + f' way["aeroway"="aerodrome"]{where};' + f' relation["aeroway"="aerodrome"]{where};);' + "out center tags;") + request = urllib.request.Request( + url, data=urllib.parse.urlencode({"data": query}).encode(), + headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(request, timeout=timeout) as answer: + return json.loads(answer.read(4_000_000).decode("utf8", "replace")) + + # --------------------------------------------------------------------------- # Putting it under the picture # --------------------------------------------------------------------------- @@ -323,16 +474,18 @@ def ground_under(south: float, west: float, north: float, east: float, if tiles is None: return None - lons = west + (east - west) * (np.arange(width) + 0.5) / width - lats = north - (north - south) * (np.arange(height) + 0.5) / height + # The edges of each output pixel rather than its middle, so that what + # lands in it can be averaged. Taking the nearest source pixel instead + # throws away most of a tile -- the mosaic is commonly half again the + # size of the picture -- and what survives is the aliasing: hard, broken + # lettering and roads that come and go along their length. + lons = west + (east - west) * np.arange(width + 1) / width + lats = north - (north - south) * np.arange(height + 1) / height span = float(2 ** zoom) * TILE_PIXELS xs = (lons + 180.0) / 360.0 * span - origin_x clipped = np.clip(lats, -85.05112878, 85.05112878) ys = (1.0 - np.arcsinh(np.tan(np.radians(clipped))) / math.pi) / 2.0 \ * span - origin_y - cols = np.clip(np.rint(xs).astype(np.int64), 0, tiles.shape[1] - 1) - rows = np.clip(np.rint(ys).astype(np.int64), 0, tiles.shape[0] - 1) - picture = tiles[rows[:, None], cols[None, :]] # Brightness only, inverted, and dimmed. Inverted because a printed map # is ink on white paper and this picture is the other way round: the @@ -341,8 +494,9 @@ def ground_under(south: float, west: float, north: float, east: float, # night background. Dimmed because the map is the ground under the # aircraft rather than the subject: anything drawn on top has to stay the # brightest thing on the picture. - luma = (0.299 * picture[:, :, 0] + 0.587 * picture[:, :, 1] - + 0.114 * picture[:, :, 2]) + whole = (0.299 * tiles[:, :, 0] + 0.587 * tiles[:, :, 1] + + 0.114 * tiles[:, :, 2]).astype(np.float32) + luma = _resample(_resample(whole, ys, axis=0), xs, axis=1) low, high = float(luma.min()), float(luma.max()) if high - low < 1.0: levels = np.zeros_like(luma) diff --git a/bandsaunter/cli.py b/bandsaunter/cli.py index 11a1e36..b34fdab 100755 --- a/bandsaunter/cli.py +++ b/bandsaunter/cli.py @@ -215,6 +215,9 @@ examples: "(default: all of it)") fl.add_argument("--stale", type=float, default=300.0, metavar="SECONDS", help="drop an aircraft this long after its last report") + fl.add_argument("--fade", type=float, default=None, metavar="SECONDS", + help="how long an aircraft takes to fade away once it " + "has gone quiet (0 to remove it at once)") fl.add_argument("--no-labels", dest="labels", action="store_false", help="draw the aircraft without callsigns beside them") fl.add_argument("--no-map", dest="draw", action="store_false", @@ -234,6 +237,12 @@ examples: help="draw the tracks on their own, with no map under them") fl.add_argument("--tiles", default=None, metavar="URL", help="where map tiles come from ({z}/{x}/{y}.png)") + fl.add_argument("--map-brightness", type=int, default=None, + metavar="PERCENT", + help="how bright the map under the aircraft is (10-100)") + fl.add_argument("--no-airports", dest="airports", action="store_false", + default=None, + help="do not mark the aerodromes under the flight paths") fl.add_argument("--radius", type=float, default=None, metavar="MILES", help="how far around the receiver the map reaches, in the " "same unit as the speeds (0 = fit what was heard)") @@ -1163,10 +1172,16 @@ def cmd_flights(args) -> int: options.trail = args.trail options.stale = args.stale options.labels = args.labels + if args.fade is not None: + options.fade = args.fade if args.basemap is not None: options.basemap = args.basemap if args.tiles: options.tile_url = args.tiles + if args.map_brightness is not None: + options.map_brightness = args.map_brightness + if args.airports is not None: + options.airports = args.airports if args.radius is not None: options.radius = args.radius if args.at: diff --git a/bandsaunter/flags.py b/bandsaunter/flags.py index 47ba4b9..5a7f1a0 100644 --- a/bandsaunter/flags.py +++ b/bandsaunter/flags.py @@ -22,7 +22,8 @@ from __future__ import annotations import numpy as np __all__ = ["FLAG_W", "FLAG_H", "COLOURS", "COLOUR_ORDER", "FLAGS", - "flag_for", "pixels_for", "known", "country_of_icao"] + "flag_for", "pixels_for", "known", "country_of_icao", + "COUNTRY_ISO", "iso_for"] FLAG_W, FLAG_H = 12, 8 @@ -169,6 +170,79 @@ FLAGS: dict[str, tuple[str, ...]] = { } +# The names this program writes for countries, and the codes they answer to. +# Two sources spell a country in words rather than letters -- the treaty +# table of address blocks, and the registers -- and a flag needs the letters. +COUNTRY_ISO: dict[str, str] = { + "Afghanistan": "AF", "Albania": "AL", "Algeria": "DZ", "Angola": "AO", + "Argentina": "AR", "Armenia": "AM", "Australia": "AU", "Austria": "AT", + "Azerbaijan": "AZ", "Bahrain": "BH", "Bangladesh": "BD", "Belarus": "BY", + "Belgium": "BE", "Bhutan": "BT", "Bolivia": "BO", + "Bosnia and Herzegovina": "BA", "Botswana": "BW", "Brazil": "BR", + "Brunei": "BN", "Bulgaria": "BG", "Burundi": "BI", "Cambodia": "KH", + "Cameroon": "CM", "Canada": "CA", "Central African Republic": "CF", + "Chad": "TD", "Chile": "CL", "China": "CN", "Colombia": "CO", + "Congo": "CG", "Costa Rica": "CR", "Croatia": "HR", "Cuba": "CU", + "Cyprus": "CY", "Czechia": "CZ", "Czech Republic": "CZ", + "Côte d'Ivoire": "CI", "Democratic Republic of the Congo": "CD", + "Denmark": "DK", "Dominican Republic": "DO", "Ecuador": "EC", + "Egypt": "EG", "Equatorial Guinea": "GQ", "Eritrea": "ER", + "Estonia": "EE", "Ethiopia": "ET", "Fiji": "FJ", "Finland": "FI", + "France": "FR", "Gabon": "GA", "Georgia": "GE", "Germany": "DE", + "Ghana": "GH", "Greece": "GR", "Guinea": "GN", "Hungary": "HU", + "Iceland": "IS", "India": "IN", "Indonesia": "ID", "Iran": "IR", + "Iraq": "IQ", "Ireland": "IE", "Israel": "IL", "Italy": "IT", + "Jamaica": "JM", "Japan": "JP", "Jordan": "JO", "Kazakhstan": "KZ", + "Kenya": "KE", "Kuwait": "KW", "Kyrgyzstan": "KG", "Laos": "LA", + "Latvia": "LV", "Lebanon": "LB", "Liberia": "LR", "Libya": "LY", + "Lithuania": "LT", "Luxembourg": "LU", "Madagascar": "MG", + "Malawi": "MW", "Malaysia": "MY", "Mali": "ML", "Malta": "MT", + "Marshall Islands": "MH", "Mauritius": "MU", "Mexico": "MX", + "Moldova": "MD", "Monaco": "MC", "Mongolia": "MN", "Montenegro": "ME", + "Morocco": "MA", "Mozambique": "MZ", "Myanmar": "MM", "Namibia": "NA", + "Nepal": "NP", "Netherlands": "NL", "New Zealand": "NZ", "Niger": "NE", + "Nigeria": "NG", "North Korea": "KP", "North Macedonia": "MK", + "Norway": "NO", "Oman": "OM", "Pakistan": "PK", "Panama": "PA", + "Papua New Guinea": "PG", "Paraguay": "PY", "Peru": "PE", + "Philippines": "PH", "Poland": "PL", "Portugal": "PT", "Qatar": "QA", + "Romania": "RO", "Russia": "RU", "Russian Federation": "RU", + "Rwanda": "RW", "San Marino": "SM", "Saudi Arabia": "SA", + "Senegal": "SN", "Serbia": "RS", "Seychelles": "SC", + "Sierra Leone": "SL", "Singapore": "SG", "Slovakia": "SK", + "Slovenia": "SI", "Somalia": "SO", "South Africa": "ZA", + "South Korea": "KR", "Korea, Republic of": "KR", "Spain": "ES", + "Sri Lanka": "LK", "Sudan": "SD", "Sweden": "SE", "Switzerland": "CH", + "Syria": "SY", "Taiwan": "TW", "Tajikistan": "TJ", "Tanzania": "TZ", + "Thailand": "TH", "Togo": "TG", "Tonga": "TO", "Tunisia": "TN", + "Turkey": "TR", "Turkmenistan": "TM", "Uganda": "UG", "Ukraine": "UA", + "United Arab Emirates": "AE", "United Kingdom": "GB", + "United States": "US", "United States of America": "US", + "Uruguay": "UY", "Uzbekistan": "UZ", "Vanuatu": "VU", "Venezuela": "VE", + "Viet Nam": "VN", "Vietnam": "VN", "Yemen": "YE", "Zambia": "ZM", + "Zimbabwe": "ZW", +} + + +def iso_for(name: str) -> str: + """The two letters for a country written out in words. + + A military block is named for the country whose it is -- "United States + military" -- and flies the same flag, so the suffix is dropped rather + than treated as somewhere else. + """ + name = (name or "").strip() + if not name: + return "" + if len(name) == 2 and name.isalpha(): + return name.upper() # already a code + if name in COUNTRY_ISO: + return COUNTRY_ISO[name] + for suffix in (" military", " Military"): + if name.endswith(suffix): + return COUNTRY_ISO.get(name[:-len(suffix)], "") + return "" + + def known(code: str) -> bool: return (code or "").strip().upper() in FLAGS diff --git a/bandsaunter/flightmap.py b/bandsaunter/flightmap.py index ca7de0f..ec8a6b9 100644 --- a/bandsaunter/flightmap.py +++ b/bandsaunter/flightmap.py @@ -41,7 +41,9 @@ from .images import GLYPH_H, draw_text, text_width, write_png __all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif", "write_mp4", "fit", "PALETTE", "ffmpeg_available", "ground_for", "background", "label_lines", "draw_flag", - "FLAG", "flag_index"] + "FLAG", "FAINT", "flag_index", "dim_ground", "local_airports", + "showing", "fade_ramp", + "GROUND_BRIGHTNESS"] # --------------------------------------------------------------------------- @@ -58,6 +60,7 @@ TRAIL = RAMP + 32 # the same 32, dimmed, for the path just flown OLD = TRAIL + 32 # and dimmer still, for the path flown earlier GROUND = OLD + 32 # 32 shades of the map underneath FLAG = GROUND + 32 # the dozen colours the little flags are made of +FAINT = FLAG + 12 # the same 32 again, fainter still, for fading RAMP_STEPS = 32 GROUND_SHADES = 32 TRANSPARENT = 255 # never drawn with: it means "as the frame before" @@ -114,10 +117,18 @@ def _dimmed(colours, factor: float) -> list[tuple[int, int, int]]: return [tuple(int(round(c * factor)) for c in rgb) for rgb in colours] -# The map under the aircraft: night-blue, dark at the bottom of the range so -# that a trail crossing a city is still the brightest thing there. -GROUND_LOW = (12, 14, 20) -GROUND_HIGH = (74, 82, 96) +# The map under the aircraft: night-blue, running from nearly the background +# to about as bright as it can go before the aircraft stop standing out. How +# far up that range a drawing actually goes is the brightness setting below, +# so there is room to turn it up on a screen that needs it and down on one +# that does not. +GROUND_LOW = (16, 19, 26) +GROUND_HIGH = (150, 164, 186) + +# What fraction of that range the map is drawn over unless told otherwise. +# Enough to read a coastline and the name of a town, and not so much that a +# city washes out the aircraft crossing it. +GROUND_BRIGHTNESS = 0.70 def _ground_shades(steps: int = GROUND_SHADES) -> list[tuple[int, int, int]]: @@ -146,7 +157,7 @@ def flag_index(letter: str) -> int: def _palette() -> np.ndarray: ramp = _ramp() table = (list(_FIXED) + ramp + _dimmed(ramp, 0.55) + _dimmed(ramp, 0.30) - + _ground_shades() + _flag_colours()) + + _ground_shades() + _flag_colours() + _dimmed(ramp, 0.14)) table += [(0, 0, 0)] * (256 - len(table)) return np.array(table[:256], dtype=np.uint8) @@ -154,6 +165,25 @@ def _palette() -> np.ndarray: PALETTE = _palette() +# How faint an aircraft is drawn as it fades, in the four strengths the +# palette holds. An indexed picture cannot blend, so a fade is a handful of +# steps rather than a slope -- which at a second or two apart reads as a +# fade all the same. +FADE_STEPS = ((0.66, RAMP), (0.40, TRAIL), (0.18, OLD), (0.0, FAINT)) + +# Below this the label goes: a name at a tenth of its colour is not +# information, it is something in the way of the aircraft still flying. +LABEL_WHILE = 0.40 + + +def fade_ramp(strength: float) -> int: + """Which set of colours an aircraft at this strength is drawn from.""" + for above, base in FADE_STEPS: + if strength > above: + return base + return FAINT + + def altitude_step(feet: float) -> int: """Which of the 32 altitude colours a height falls in.""" if feet <= 0: @@ -356,9 +386,25 @@ def _degrees(value: float, axis: str) -> str: return f"{abs(value):.2f}{hemisphere}" +def dim_ground(levels, brightness: float = GROUND_BRIGHTNESS): + """Squeeze the map into the part of its range being used. + + The palette holds the whole range; a drawing uses as much of it as the + brightness asks for. Scaling the shades rather than rebuilding the + palette means the animation's colour table stays the same table from one + frame to the next, which is the whole basis of the frame differencing. + """ + part = max(0.05, min(1.0, float(brightness))) + top = max(1, int(round((GROUND_SHADES - 1) * part))) + return np.clip((np.asarray(levels, dtype=np.float64) + * top / (GROUND_SHADES - 1)).round(), + 0, GROUND_SHADES - 1).astype(np.uint8) + + def background(view: Projection, title: str = "", airports=(), unit: str = DEFAULT_SPEED_UNIT, ground=None, - attribution: str = "") -> np.ndarray: + attribution: str = "", + brightness: float = GROUND_BRIGHTNESS) -> np.ndarray: """The map without anything flying on it: ground, grid, scale, key, title. ``ground`` is the real map underneath, as brightness levels covering the @@ -369,7 +415,7 @@ def background(view: Projection, title: str = "", airports=(), width, height = canvas_size(view) img = np.full((height, width), BG, dtype=np.uint8) if ground is not None: - patch = np.asarray(ground, dtype=np.uint8) + patch = dim_ground(ground, brightness) rows = min(view.height, patch.shape[0]) cols = min(view.width, patch.shape[1]) img[view.top:view.top + rows, view.left:view.left + cols] = \ @@ -399,6 +445,7 @@ def background(view: Projection, title: str = "", airports=(), continue x, y = view.xy(lat, lon) _box(img, x - 3, y - 3, x + 3, y + 3, AIRPORT) + _box(img, x - 1, y - 1, x + 1, y + 1, AIRPORT, fill=True) draw_text(img, x + 6, y - 3, name, AIRPORT) if title: @@ -461,7 +508,8 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track], when: float, *, trail_seconds: float = 0.0, stale: float = 300.0, labels: bool = True, clock: str = "", unit: str = DEFAULT_SPEED_UNIT, - project: bool = True, known=None) -> np.ndarray: + project: bool = True, known=None, + fade: float = 0.0) -> 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 @@ -479,24 +527,31 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track], if track.at(when, stale=stale) is not None) > CROWDED taken: list[tuple[int, int, int, int]] = [] for track in tracks: - now = track.at(when, stale=stale) if project else \ - (track.fixes[-1] if when >= track.fixes[-1].at - else track.at(when, stale=stale)) - if now is None: + if project: + seen = showing(track, when, stale, fade) + else: + seen = ((track.fixes[-1], 1.0) if when >= track.fixes[-1].at + else showing(track, when, stale, fade)) + if seen is None: continue + now, strength = seen flying += 1 trail = track.trail(when, trail_seconds) recent = when - (trail_seconds or 120.0) / 2.0 + faded = fade_ramp(strength) for a, b in zip(trail, trail[1:]): - shade = (TRAIL if b.at >= recent else OLD) + \ - altitude_step(b.altitude_ft) + base_shade = TRAIL if b.at >= recent else OLD + # A fading aircraft takes its trail with it: the two are one + # thing on the picture and half of it lingering would be worse + # than either. + shade = max(base_shade, faded) + altitude_step(b.altitude_ft) x0, y0 = view.xy(a.latitude, a.longitude) x1, y1 = view.xy(b.latitude, b.longitude) _line(img, x0, y0, x1, y1, shade) - colour = RAMP + altitude_step(now.altitude_ft) + colour = faded + altitude_step(now.altitude_ft) x, y = view.xy(now.latitude, now.longitude) _marker(img, x, y, now.track_deg, colour) - if labels: + if labels and strength > LABEL_WHILE: _label(img, x, y, track, now, colour, taken, unit, entry=(known.get(track.icao) if known and not crowded else None)) @@ -505,6 +560,33 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track], return img +def showing(track: Track, when: float, stale: float = 300.0, + fade: float = 0.0): + """Where an aircraft is at a moment, and how strongly to draw it. + + An aircraft that stops transmitting has not stopped existing, and taking + it off the picture between one frame and the next says it did. So past + the point where its position can still be believed it is left where it + was last actually seen, fading, until it is gone -- which is what the + watching eye reads as "that one has gone quiet", rather than as a + blink. + + Fading happens at the last known position and never at a reckoned one: + the whole reason for giving up on an aircraft is that where it would be + by now is a guess. + """ + now = track.at(when, stale=stale) + if now is not None: + return now, 1.0 + if fade <= 0 or not track.fixes: + return None + last = track.fixes[-1] + gone = when - last.at - stale + if gone < 0 or gone > fade: + return None + return last, max(0.0, 1.0 - gone / fade) + + def _marker(img: np.ndarray, x: int, y: int, heading: float, colour: int) -> None: """A little arrowhead, pointing the way the aircraft is going. @@ -556,11 +638,15 @@ def label_lines(track: Track, now, unit: str = DEFAULT_SPEED_UNIT, rows.append((line, "")) if entry is None: return rows + from .flags import iso_for + + home = iso_for(getattr(entry, "owner_country", "") + or getattr(entry, "country", "")) kind = entry.type_code or entry.model if kind and entry.registration: - rows.append((f"{kind} {entry.registration}", "")) + rows.append((f"{kind} {entry.registration}", home)) elif kind or entry.registration: - rows.append((kind or entry.registration, "")) + rows.append((kind or entry.registration, home)) if entry.origin_code or entry.origin: rows.append((_short_place(entry.origin_code, entry.origin), entry.origin_country)) @@ -886,6 +972,25 @@ def _span(seconds: float) -> str: return f"{hours} h {minutes:02d} min" +def local_airports(view: Projection, ask=None) -> list: + """Every aerodrome inside the picture, from the map data itself. + + A route only names the airports its aircraft are flying between, and + those are almost never the ones underneath: a receiver hears aircraft + over its own county, and the county's airports are exactly what says + where on the map you are looking. + """ + from . import basemap + + try: + extra = {"ask": ask} if ask is not None else {} + found = basemap.airports_in(view.south, view.west, view.north, + view.east, **extra) + except Exception: + return [] + return [(a["code"], a["latitude"], a["longitude"]) for a in found] + + def _known_from(book, tracks: list[Track]) -> dict: """What a register says about each aircraft, looked up once. @@ -899,14 +1004,16 @@ def _known_from(book, tracks: list[Track]) -> dict: for track in tracks} -def _airports_from(known: dict): - """Every airport a route lookup gave a position for. +def _airports_from(known: dict, book=None): + """Every airport named by a route, wherever its position can be had. - Taken from what has already been looked up rather than asking again: the - book is thread-safe rather than free, and one question per aircraft is - the whole budget. + A route names two airports and often gives a position for neither, so + the codes are looked up as well -- once each, and remembered. An + airport that cannot be placed cannot be drawn, and a map of an evening's + flying with no airports on it is missing the two ends of every flight. """ seen: dict[str, tuple[str, float, float]] = {} + wanted: set[str] = set() for entry in known.values(): for code, lat, lon in ((getattr(entry, "origin_code", ""), getattr(entry, "origin_lat", 0.0), @@ -914,8 +1021,19 @@ def _airports_from(known: dict): (getattr(entry, "destination_code", ""), getattr(entry, "destination_lat", 0.0), getattr(entry, "destination_lon", 0.0))): - if code and (lat or lon): + if not code: + continue + if lat or lon: seen[code] = (code, lat, lon) + else: + wanted.add(code) + if book is not None and hasattr(book, "airports"): + try: + for found in book.airports(sorted(wanted - set(seen))): + seen[found["code"]] = (found["code"], found["latitude"], + found["longitude"]) + except Exception: + pass # a map with fewer airports on it return list(seen.values()) @@ -946,13 +1064,20 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0, title: str = "", book=None, labels: bool = True, kind: str = "", unit: str = DEFAULT_SPEED_UNIT, ground: bool = False, fetch=None, tile_url: str = "", - radius_nm: float = 0.0, centre=None) -> Animation | None: + radius_nm: float = 0.0, centre=None, + brightness: float = GROUND_BRIGHTNESS, + airports: bool = False, ask=None, + fade: float = 0.0) -> Animation | None: """Draw the whole log as a moving map. ``speed`` is how many seconds of real flying go by in one second of animation; given ``seconds`` instead, it is worked out so the whole log plays in about that long. The clock in the corner is the real time of day, so a fast animation is still readable as an evening. + + ``ground`` and ``airports`` both reach for a network and are therefore + both off here, and both on in the settings the program itself uses: a + library call should not go out to the world unless it was asked to. """ located = [t for t in tracks if t.located] if not located: @@ -993,8 +1118,16 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0, known = _known_from(book, located) levels, credit = ground_for(view, fetch, tile_url) if ground \ else (None, "") - base = background(view, title=heading, airports=_airports_from(known), - unit=unit, ground=levels, attribution=credit) + marked = _airports_from(known, book) + if airports: + # The ones underneath as well as the ones flown between, and the + # nearer ones last so they win where two share a name. + seen = {code for code, _, _ in marked} + marked += [one for one in local_airports(view, ask) + if one[0] not in seen] + base = background(view, title=heading, airports=marked, + unit=unit, ground=levels, attribution=credit, + brightness=brightness) canvas_w, canvas_h = canvas_size(view) base = _pad_to(base, canvas_w, canvas_h) @@ -1005,7 +1138,8 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0, yield _pad_to(render_frame(base, view, located, when, trail_seconds=trail_seconds, stale=stale, labels=labels, - clock=clock, unit=unit, known=known), + clock=clock, unit=unit, known=known, + fade=fade), canvas_w, canvas_h) path = Path(out_path) diff --git a/bandsaunter/flights.py b/bandsaunter/flights.py index d672101..e81dcb7 100644 --- a/bandsaunter/flights.py +++ b/bandsaunter/flights.py @@ -37,6 +37,7 @@ AIRCRAFT_URL = "https://api.adsbdb.com/v0/aircraft/{icao}" ROUTE_URL = "https://api.adsbdb.com/v0/callsign/{call}" BACKUP_AIRCRAFT_URL = "https://hexdb.io/api/v1/aircraft/{icao}" BACKUP_ROUTE_URL = "https://hexdb.io/api/v1/route/icao/{call}" +AIRPORT_URL = "https://hexdb.io/api/v1/airport/icao/{code}" # Bumped when the shape of a cached record changes, so a cache written by an # older version is ignored rather than misread. Version 2 added the country @@ -418,7 +419,8 @@ class FlightBook: aircraft_url: str = AIRCRAFT_URL, route_url: str = ROUTE_URL, backup_aircraft_url: str = BACKUP_AIRCRAFT_URL, - backup_route_url: str = BACKUP_ROUTE_URL): + backup_route_url: str = BACKUP_ROUTE_URL, + airport_url: str = AIRPORT_URL): self.online = online self.timeout = timeout self.max_age = max_age @@ -426,10 +428,15 @@ class FlightBook: self.route_url = route_url self.backup_aircraft_url = backup_aircraft_url self.backup_route_url = backup_route_url + self.airport_url = airport_url self.cache_path = Path(cache) if cache is not None else _cache_path() self._lock = threading.Lock() self._entries: dict[str, Flight] = {} self._routes: dict[str, dict] = {} + # Airports, by ICAO code. A route often names two the map has never + # heard of and gives no position for either, and an airport that + # cannot be placed cannot be drawn. + self._airports: dict[str, dict] = {} # Callsigns already asked about. A route nobody holds would # otherwise be asked for again by every caller that looks at the same # aircraft -- the table, the report and the map all do -- and an @@ -455,6 +462,10 @@ class FlightBook: entry.version >= CACHE_VERSION and \ now - entry.fetched_at < self.max_age: self._entries[key] = entry + for key, body in (raw.get("airports") or {}).items(): + if isinstance(body, dict) and \ + now - float(body.get("fetched_at") or 0) < self.max_age: + self._airports[key] = body for key, body in (raw.get("routes") or {}).items(): if not isinstance(body, dict): continue @@ -472,6 +483,7 @@ class FlightBook: "aircraft": {k: e.__dict__ for k, e in self._entries.items() if e.status in ("found", "unlisted")}, "routes": dict(self._routes), + "airports": dict(self._airports), } self._dirty = False try: @@ -519,6 +531,50 @@ class FlightBook: thread.start() return entry + def airport(self, code: str) -> dict: + """Where an airport is, asked once and remembered. + + An empty answer is remembered too, and on purpose: a code no + register holds would otherwise be asked about on every drawing for + the rest of the month. + """ + code = (code or "").strip().upper() + if not code: + return {} + with self._lock: + got = self._airports.get(code) + if got is not None: + return got + found: dict = {} + if self.online and self.airport_url: + try: + body = self._request(self.airport_url.format( + code=urllib.parse.quote(code))) + found = {"code": code, + "name": str(body.get("airport") or "").strip(), + "latitude": float(body.get("latitude") or 0.0), + "longitude": float(body.get("longitude") or 0.0), + "country": str(body.get("country_code") + or "").strip().upper()} + if not (found["latitude"] or found["longitude"]): + found = {} + except Exception: + found = {} + with self._lock: + found["fetched_at"] = time.time() + self._airports[code] = found + self._dirty = True + return found + + def airports(self, codes) -> list[dict]: + """Every airport in a list of codes that can be placed on a map.""" + out: dict[str, dict] = {} + for code in codes: + found = self.airport(code) + if found.get("latitude") or found.get("longitude"): + out[found["code"]] = found + return list(out.values()) + def get_all(self, pairs) -> list[Flight]: return [self.get(icao, call) for icao, call in pairs] diff --git a/bandsaunter/livemap.py b/bandsaunter/livemap.py index e9953e1..7c55f35 100644 --- a/bandsaunter/livemap.py +++ b/bandsaunter/livemap.py @@ -23,6 +23,7 @@ from dataclasses import dataclass import numpy as np +from .flags import iso_for from .flightlog import (DEFAULT_SPEED_UNIT, distance_label, distance_nm, in_distance, in_speed, speed_label) from .flightmap import Projection, altitude_step @@ -56,6 +57,17 @@ REDRAW_MS = 200 # it is drawn every frame and wants a hard bound. TRAIL_POINTS = 400 +# How far the worked-out middle of the map has to drift before it is moved. +# Below this it is noise from aircraft arriving and leaving, and moving the +# map for it costs the tiles that were fetched for the old view. +RESETTLE_NM = 12.0 + +# How much map to fetch beyond what is being shown. A window that fetched +# exactly what it needed would fetch again on every resize and every small +# drift; a third again in each direction covers both, at the cost of about +# twice as many tiles the first time. +GROUND_MARGIN = 0.33 + # The widest a line in a box is allowed to get before it is folded. An # airport's full name and the town it is in run to forty characters on their # own and a route is two of them, so one flight from Los Angeles to Dallas @@ -66,6 +78,14 @@ WRAP_CHARS = 30 # tall, so twelve by eight sits on a line without crowding it. FLAG_PIXELS = 12 +# Below this an aircraft keeps its symbol and loses its box. +LABEL_WHILE = 0.40 + + +def _alpha(strength: float) -> int: + """How opaque something at this strength is drawn.""" + return max(0, min(255, int(round(255 * strength)))) + def _qt(): """The first Qt binding that imports, or None. @@ -137,6 +157,7 @@ class Blip: manufacturer: str = "" operator: str = "" country: str = "" + country_code: str = "" # two letters, for the flag beside it origin: str = "" origin_country: str = "" # two letters, for the flag beside it destination: str = "" @@ -199,7 +220,7 @@ class Blip: age = max(0.0, time.time() - self.last_seen) out.append(("", f"{self.messages:,} frames {age:.0f}s ago", "")) if self.country: - out.append(("", self.country, "")) + out.append(("reg'd", self.country, self.country_code)) return out def _bearing(self, home) -> float: @@ -265,6 +286,7 @@ def blip_for(craft, entry=None) -> Blip: blip.manufacturer = entry.manufacturer blip.operator = entry.operator or entry.airline blip.country = entry.owner_country or entry.country + blip.country_code = iso_for(blip.country) blip.origin = entry.origin or entry.origin_code blip.origin_country = entry.origin_country blip.destination = entry.destination or entry.destination_code @@ -285,13 +307,16 @@ class Sky: """ def __init__(self, unit: str = DEFAULT_SPEED_UNIT, hold: float = 45.0, - home=None, radius_nm: float = 100.0): + home=None, radius_nm: float = 100.0, + brightness: float = 0.70, fade: float = 20.0): import threading self.unit = unit self.hold = hold self.home = home self.radius_nm = radius_nm + self.brightness = brightness + self.fade = fade self.frames = 0 self.aircraft_seen = 0 self.started = time.time() @@ -308,7 +333,9 @@ class Sky: self._trails: dict[str, deque] = {} self._ground = None self._ground_for = None + self._ground_box = None self._wanted = None + self._settled = None # -- written by the listener ------------------------------------------ def update(self, blips, frames: int, seen: int) -> None: @@ -324,9 +351,15 @@ class Sky: if not trail or trail[-1][:2] != here[:2]: trail.append(here) - def set_ground(self, levels, key) -> None: + def set_ground(self, levels, key, box=None) -> None: + """Keep the map that was fetched, and the piece of world it covers. + + A failed fetch is kept too, as nothing: otherwise a machine with no + network asks for the same tiles five times a second all night. + """ with self._lock: self._ground, self._ground_for = levels, key + self._ground_box = box if self._wanted is not None and self._wanted[0] == key: self._wanted = None @@ -340,20 +373,52 @@ class Sky: with self._lock: return self._wanted + def ground_covering(self, south, west, north, east): + """The map already fetched, if it covers this view, and its box. + + What is asked for is a slightly different piece of the world every + time the middle shifts or the window is resized, and refetching for + each of those would leave the ground blank while it happened. The + fetched piece is bigger than the piece being shown for exactly this + reason, and anything inside it is taken from what is already here. + """ + with self._lock: + levels, box = self._ground, self._ground_box + if levels is None or box is None: + return None, None + if (south >= box[0] and west >= box[1] + and north <= box[2] and east <= box[3]): + return levels, box + return None, None + # -- read by the window ----------------------------------------------- def flying(self, now: float | None = None) -> list[Blip]: """What is overhead, oldest first heard at the top. An aircraft nothing has been heard from for a while has gone out of - range; it stays in the log and leaves the picture. + range; it stays in the log and fades off the picture. """ now = time.time() if now is None else now + limit = self.hold + max(0.0, self.fade) with self._lock: out = [b for b in self._blips.values() - if now - b.last_seen <= self.hold and b.located] + if now - b.last_seen <= limit and b.located] out.sort(key=lambda b: (b.first_seen, b.icao)) return out + def strength(self, blip: Blip, now: float | None = None) -> float: + """How strongly to draw one aircraft: full, then fading, then gone. + + Taking an aircraft off the picture between one frame and the next + says it stopped existing. Fading it says it stopped talking, which + is what actually happened. + """ + now = time.time() if now is None else now + age = max(0.0, now - blip.last_seen) + if age <= self.hold or self.fade <= 0: + return 1.0 if age <= self.hold else 0.0 + return max(0.0, 1.0 - (age - self.hold) / self.fade) + def trail(self, icao: str) -> list: with self._lock: return list(self._trails.get(icao, ())) @@ -367,17 +432,31 @@ class Sky: """Where to put the middle of the map. Whatever the receiver was told, or the middle of everything heard so - far, which settles within the first few aircraft and is a median, so - one bad position cannot move it. + far -- a median, so one bad position cannot move it. + + Once settled it is left alone. Recomputing it every time the picture + is painted moves the map by a fraction of a mile whenever an aircraft + appears or leaves, which throws away the tiles fetched for the old + view and leaves the ground blank while new ones are fetched: the map + blinks out several times a minute for no reason anybody watching + could see. It only moves again if it has drifted far enough to + matter, which happens once, early, and then never. """ if self.home is not None: return self.home with self._lock: + settled = self._settled lats = sorted(b.latitude for b in self._blips.values() if b.located) lons = sorted(b.longitude for b in self._blips.values() if b.located) if not lats: - return None - return lats[len(lats) // 2], lons[len(lons) // 2] + return settled + middle = lats[len(lats) // 2], lons[len(lons) // 2] + if settled is not None and \ + distance_nm(settled[0], settled[1], *middle) < RESETTLE_NM: + return settled + with self._lock: + self._settled = middle + return middle # --------------------------------------------------------------------------- @@ -509,12 +588,20 @@ def _build(): return Projection(south=south, west=west, north=north, east=east, left=0, top=0, width=width, height=height) + @staticmethod + def ground_box(view: Projection): + """The piece of world to fetch: what is shown, and some around it.""" + down = (view.north - view.south) * GROUND_MARGIN + across = (view.east - view.west) * GROUND_MARGIN + return (max(-85.0, view.south - down), view.west - across, + min(85.0, view.north + down), view.east + across) + def ground_key(self, view: Projection): """What the map underneath was fetched for, rounded so that a pixel of drift does not throw it away.""" - return (round(view.south, 3), round(view.west, 3), - round(view.north, 3), round(view.east, 3), - view.width, view.height) + box = self.ground_box(view) + return (round(box[0], 2), round(box[1], 2), round(box[2], 2), + round(box[3], 2), view.width, view.height) # -- painting ----------------------------------------------------- def paintEvent(self, event) -> None: @@ -533,22 +620,29 @@ def _build(): flying = self.sky.flying() if self.trails: for blip in flying: - self._draw_trail(painter, view, blip) + self._draw_trail(painter, view, blip, + self.sky.strength(blip)) # The symbols go down first and their own space is spoken for, # so that one aircraft's box cannot be placed on top of another # aircraft -- which is the one thing on the picture that has to # stay visible. - placed = [(blip, view.xy(blip.latitude, blip.longitude)) - for blip in flying] + now = time.time() + placed = [(blip, view.xy(blip.latitude, blip.longitude), + self.sky.strength(blip, now)) for blip in flying] taken: list[tuple[int, int, int, int]] = [] - for blip, (x, y) in placed: + for blip, (x, y), strength in placed: self._draw_symbol(painter, x, y, blip.track_deg, - craft_colour(blip.altitude_ft)) + craft_colour(blip.altitude_ft, + _alpha(strength)), strength) taken.append((x - 11, y - 11, 22, 22)) - for blip, (x, y) in placed: - self._draw_aircraft(painter, view, blip, x, y, taken) + for blip, (x, y), strength in placed: + self._draw_aircraft(painter, view, blip, x, y, taken, strength) self._draw_scale(painter, view) - self._draw_header(painter, len(flying)) + # Counted as overhead only while it is still being heard: one + # that has gone quiet is on the picture, fading, and saying it + # is overhead would be saying more than was heard. + self._draw_header(painter, + sum(1 for _b, _xy, s in placed if s >= 1.0)) painter.end() def _draw_waiting(self, painter) -> None: @@ -562,16 +656,26 @@ def _build(): "both arrived") def _draw_ground(self, painter, view) -> None: - key = self.ground_key(view) - levels = self.sky.ground(key) + levels, box = self.sky.ground_covering(view.south, view.west, + view.north, view.east) if levels is None: # Ask for it and carry on drawing. The map arrives when it # arrives; the aircraft are the part that cannot wait. - self.sky.want_ground( - key, (view.south, view.west, view.north, view.east), - (view.width, view.height)) + # Asked for at the size the bigger box needs to hold the + # same detail as the window, rather than at the window's + # own size: fetching a third more world into the same + # pixels and then cutting the middle out of it is how a + # sharp map ends up looking like a photograph of a map. + scale = 1.0 + 2.0 * GROUND_MARGIN + self.sky.want_ground(self.ground_key(view), + self.ground_box(view), + (int(view.width * scale), + int(view.height * scale))) return - shades = np.clip(np.asarray(levels), 0, GROUND_SHADES - 1) + from .flightmap import dim_ground + + shades = dim_ground(self._crop(levels, box, view), + self.sky.brightness) pixels = np.ascontiguousarray( PALETTE[GROUND + shades].astype(np.uint8)) self._ground_pixels = pixels # QImage does not copy it @@ -579,6 +683,25 @@ def _build(): image = QImage(pixels.data, width, height, 3 * width, RGB888) painter.drawImage(0, 0, image) + @staticmethod + def _crop(levels, box, view: Projection): + """The part of the fetched map this view is looking at. + + Both are laid out the same way -- latitude down, longitude across, + evenly -- so this is arithmetic rather than another projection. + """ + south, west, north, east = box + rows, columns = levels.shape[0], levels.shape[1] + lats = np.linspace(view.north, view.south, view.height) + lons = np.linspace(view.west, view.east, view.width) + y = np.clip(((north - lats) / max(1e-9, north - south) + * (rows - 1)).round().astype(np.int64), 0, rows - 1) + x = np.clip(((lons - west) / max(1e-9, east - west) + * (columns - 1)).round().astype(np.int64), + 0, columns - 1) + return np.clip(levels[y[:, None], x[None, :]], 0, + GROUND_SHADES - 1) + def _draw_graticule(self, painter, view) -> None: pen = QPen(rgb(GRID)) pen.setStyle(DOTTED) @@ -611,20 +734,24 @@ def _build(): _degrees(lon, "lon")) lon += step - def _draw_trail(self, painter, view, blip: Blip) -> None: + def _draw_trail(self, painter, view, blip: Blip, + strength: float = 1.0) -> None: points = self.sky.trail(blip.icao) if len(points) < 2: return - painter.setPen(QPen(craft_colour(blip.altitude_ft, 90), 1.4)) + painter.setPen(QPen(craft_colour(blip.altitude_ft, + int(90 * strength)), 1.4)) path = QPolygonF([QPointF(*view.xy(lat, lon)) for lat, lon, _ in points]) painter.drawPolyline(path) def _draw_aircraft(self, painter, view, blip: Blip, x, y, - taken) -> None: - if self.detail <= 0: + taken, strength: float = 1.0) -> None: + # A box at a tenth of its colour is not information, it is + # something in the way of the aircraft still flying. + if self.detail <= 0 or strength < LABEL_WHILE: return - colour = craft_colour(blip.altitude_ft) + colour = craft_colour(blip.altitude_ft, _alpha(strength)) lines = self._wrapped(self._box_lines(blip)) box = self._box_size(lines) bx, by = place_box(x, y, box[0], box[1], taken, @@ -642,7 +769,8 @@ def _build(): else blip.icao self._draw_box(painter, bx, by, box, lines, colour, title) - def _draw_symbol(self, painter, x, y, heading, colour) -> None: + def _draw_symbol(self, painter, x, y, heading, colour, + strength: float = 1.0) -> None: angle = math.radians(heading % 360.0) sin, cos = math.sin(angle), math.cos(angle) @@ -654,7 +782,7 @@ def _build(): painter.setBrush(colour) painter.drawPolygon(QPolygonF([point(9, 0), point(-6, 5), point(-3, 0), point(-6, -5)])) - painter.setBrush(QColor(255, 255, 255, 200)) + painter.setBrush(QColor(255, 255, 255, int(200 * strength))) painter.drawEllipse(QPointF(x, y), 1.6, 1.6) def _box_lines(self, blip: Blip) -> list[tuple[str, str]]: @@ -703,9 +831,10 @@ def _build(): title: str) -> None: width, height = box painter.setPen(QPen(colour, 1.2)) - painter.setBrush(QColor(10, 12, 18, 215)) + painter.setBrush(QColor(10, 12, 18, int(215 * (colour.alpha() / 255)))) painter.drawRoundedRect(QRectF(bx, by, width, height), 3.0, 3.0) painter.setFont(self.head_font) + faded = colour.alpha() painter.setPen(colour.lighter(135)) head = QtGui.QFontMetrics(self.head_font) painter.drawText(bx + 7, by + head.ascent() + 4, title) @@ -716,7 +845,7 @@ def _build(): y = by + head.height() + 6 + metrics.ascent() for label, value, country in lines: if label: - painter.setPen(rgb(GRID)) + painter.setPen(rgb(GRID, faded)) painter.drawText(bx + 7, y, f"{label:>5s}") left = bx + 7 + gutter if flagged: @@ -724,7 +853,7 @@ def _build(): self._draw_flag(painter, left, y - metrics.ascent() + 2, country) left += FLAG_PIXELS + 4 - painter.setPen(rgb(INK)) + painter.setPen(rgb(INK, faded)) painter.drawText(left, y, value) y += metrics.height() @@ -767,8 +896,8 @@ def _build(): painter.drawLine(12 + pixels, y - 4, 12 + pixels, y + 4) painter.drawText(18 + pixels, y + 4, f"{miles} {distance_label(self.sky.unit)}") - if self.show_ground and self.sky.ground( - self.ground_key(view)) is not None: + if self.show_ground and self.sky.ground_covering( + view.south, view.west, view.north, view.east)[0] is not None: painter.setPen(rgb(GRID)) painter.drawText(self.width() - 8 - metrics.horizontalAdvance(ATTRIBUTION), @@ -796,7 +925,8 @@ def _build(): painter.setFont(self.head_font) painter.setPen(rgb(INK)) painter.drawText(10, metrics.ascent() + 4, told) - keys = "d detail t trails g map +/- range q quit" + keys = ("d detail t trails g map [ ] bright " + "+/- range q quit") painter.setPen(rgb(GRID)) painter.drawText(self.width() - 8 - metrics.horizontalAdvance(keys), @@ -837,6 +967,10 @@ def _build(): self.sky.radius_nm = max(5.0, self.sky.radius_nm / 1.5) elif text == "-": self.sky.radius_nm = min(3000.0, self.sky.radius_nm * 1.5) + elif text == "[": + self.sky.brightness = max(0.1, self.sky.brightness - 0.1) + elif text == "]": + self.sky.brightness = min(1.0, self.sky.brightness + 0.1) self.view.update() def closeEvent(self, event) -> None: @@ -883,11 +1017,15 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None: if wanted is None: time.sleep(0.2) continue - key, (south, west, north, east), (width, height) = wanted + key, box, (width, height) = wanted + south, west, north, east = box try: extra = {"fetch": fetch} if fetch is not None else {} if url: extra["url"] = url + # Fetched at the size of the window rather than the bigger box, + # so a wider view costs no more pixels; it is stretched back over + # the box when it is drawn. levels = basemap.ground_under(south, west, north, east, width, height, shades=_ground_shades(), **extra) @@ -895,7 +1033,7 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None: levels = None # Remembered either way: a map that could not be fetched must not be # asked for again every fifth of a second for the rest of the night. - sky.set_ground(levels, key) + sky.set_ground(levels, key, box if levels is not None else None) def _ground_shades() -> int: diff --git a/docs/realtime.png b/docs/realtime.png index 24b80b6..6ddb583 100644 Binary files a/docs/realtime.png and b/docs/realtime.png differ diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 7d6c801..13c9a1f 100644 --- a/packaging/bandsaunter.1 +++ b/packaging/bandsaunter.1 @@ -1,5 +1,5 @@ .\" Generated by packaging/make-man.py -- do not edit by hand. -.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_04" "User Commands" +.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_06" "User Commands" .SH NAME bandsaunter \- scan, record and identify radio signals with an RTL-SDR .SH SYNOPSIS @@ -1335,10 +1335,14 @@ cycles how much each box says, for a busy sky; turns the trails off, .B g the map underneath, +.B [ +and +.B ] +its brightness, .B + and .B \- -change the range and +the range, and .B q closes it. Closing the window leaves exactly the files a passive capture leaves, because it is the same code with a different thing watching it: the @@ -1480,6 +1484,29 @@ draws the tracks on their own, .BI \-\-tiles " URL" points at another server, and where there is no network and nothing cached the picture falls back to the plain grid. +.PP +An aircraft that goes quiet fades rather than vanishing: taking it off the +picture between one frame and the next says it stopped existing, and fading it +says it stopped talking, which is what happened. It fades where it was last +actually seen and never along a reckoned track, since the reason for giving up +on it is that where it would be by now is a guess. +.BI \-\-fade " SECONDS" +is how long that takes, and zero takes it away at once. +.PP +The map is averaged down to the size of the picture rather than point-sampled, +so lettering and roads stay whole, and the window fetches enough pixels to +cover its margin at full detail rather than enlarging what it has. +.BI \-\-map\-brightness " PERCENT" +is how far up its range the map is drawn: dark enough that the aircraft stay +the brightest thing on the picture, light enough that a coastline can be made +out, and which way to err depends on the screen. +.PP +Every aerodrome under the picture is marked, not only the ones being flown +between \[em] a receiver hears aircraft over its own county, and the county's +airports say where on the map you are looking. They come from the same map +data the tiles are drawn from, asked once per area and kept for a month. +.B \-\-no\-airports +turns that off. .SH METERS AND SENSORS Two things on the ISM bands are worth naming rather than reporting as hexadecimal. diff --git a/packaging/make-man.py b/packaging/make-man.py index 1cc2b21..ccddfc1 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -728,10 +728,14 @@ cycles how much each box says, for a busy sky; turns the trails off, .B g the map underneath, +.B [ +and +.B ] +its brightness, .B + and .B \- -change the range and +the range, and .B q closes it. Closing the window leaves exactly the files a passive capture leaves, because it is the same code with a different thing watching it: the @@ -873,6 +877,29 @@ draws the tracks on their own, .BI \-\-tiles " URL" points at another server, and where there is no network and nothing cached the picture falls back to the plain grid. +.PP +An aircraft that goes quiet fades rather than vanishing: taking it off the +picture between one frame and the next says it stopped existing, and fading it +says it stopped talking, which is what happened. It fades where it was last +actually seen and never along a reckoned track, since the reason for giving up +on it is that where it would be by now is a guess. +.BI \-\-fade " SECONDS" +is how long that takes, and zero takes it away at once. +.PP +The map is averaged down to the size of the picture rather than point-sampled, +so lettering and roads stay whole, and the window fetches enough pixels to +cover its margin at full detail rather than enlarging what it has. +.BI \-\-map\-brightness " PERCENT" +is how far up its range the map is drawn: dark enough that the aircraft stay +the brightest thing on the picture, light enough that a coastline can be made +out, and which way to err depends on the screen. +.PP +Every aerodrome under the picture is marked, not only the ones being flown +between \[em] a receiver hears aircraft over its own county, and the county's +airports say where on the map you are looking. They come from the same map +data the tiles are drawn from, asked once per area and kept for a month. +.B \-\-no\-airports +turns that off. .SH METERS AND SENSORS Two things on the ISM bands are worth naming rather than reporting as hexadecimal. diff --git a/packaging/saunterbrowse.1 b/packaging/saunterbrowse.1 index ac70b91..084a1e2 100644 --- a/packaging/saunterbrowse.1 +++ b/packaging/saunterbrowse.1 @@ -1,5 +1,5 @@ .\" Generated by packaging/make-browse-man.py -- do not edit by hand. -.TH SAUNTERBROWSE 1 "2026-09-04" "bandsaunter 2026-09-04_04" "User Commands" +.TH SAUNTERBROWSE 1 "2026-09-04" "bandsaunter 2026-09-04_06" "User Commands" .SH NAME saunterbrowse \- read and listen to what a bandsaunter scan collected .SH SYNOPSIS diff --git a/tests/test_aircraft_menu.py b/tests/test_aircraft_menu.py index a1f5ffe..1436f09 100644 --- a/tests/test_aircraft_menu.py +++ b/tests/test_aircraft_menu.py @@ -212,6 +212,7 @@ def _listen_and_draw(monkeypatch, console, tmp_path, picture="png"): number("picture"), picture, # what to draw number("lookup"), "no", # no lookups: no network in a test number("basemap"), "no", # nor a tile server + number("airports"), "no", # nor the map data "l", # listen now "m", "1", # draw the newest log "b"], cfg) @@ -266,7 +267,7 @@ def test_listening_can_draw_as_soon_as_it_stops(monkeypatch, console, run(monkeypatch, console, [number("simulate"), "yes", number("seconds"), "3", number("lookup"), "no", number("basemap"), "no", - number("draw_after"), "yes", + number("airports"), "no", number("draw_after"), "yes", number("picture"), "png", "l", "b"], cfg) assert list(tmp_path.glob("adsb_*.png")) diff --git a/tests/test_basemap.py b/tests/test_basemap.py index c78d0fa..bbee9bb 100644 --- a/tests/test_basemap.py +++ b/tests/test_basemap.py @@ -4,7 +4,9 @@ Nothing here touches the network. The tiles are made up in the test and fed in through the same door the real ones come through, and the PNGs are built here from the specification rather than by the decoder they are testing. """ +import json import struct +import time import zlib import numpy as np @@ -418,7 +420,7 @@ def test_the_tile_server_can_be_pointed_somewhere_else(tmp_path, monkeypatch): def test_the_map_goes_under_the_picture_and_is_credited(tmp_path): out = fm.animate(two_aircraft(), tmp_path / "on-the-map.png", width=400, - ground=True, fetch=gradient_tile) + ground=True, fetch=gradient_tile, airports=False) assert out is not None and out.ground assert "on the map" in out.summary() from bandsaunter.images import PNG_SIGNATURE @@ -457,3 +459,221 @@ def test_the_credit_is_written_on_the_picture_itself(): levels, credit = fm.ground_for(view, fetch=gradient_tile) base = fm.background(view, ground=levels, attribution=credit) assert _has_text(base, "OPENSTREETMAP") + + +# --------------------------------------------------------------------------- +# What aerodromes are under the picture +# --------------------------------------------------------------------------- + +def _overpass(elements): + """Stand in for the map data, in the shape it really answers with.""" + def ask(box, url, timeout): + return {"elements": elements} + return ask + + +def _node(code=None, name="", lat=32.1, lon=-110.9, ref=None, kind="node"): + tags = {"aeroway": "aerodrome", "name": name} + if code: + tags["icao"] = code + if ref: + tags["ref"] = ref + element = {"type": kind, "tags": tags} + if kind == "node": + element.update({"lat": lat, "lon": lon}) + else: + element["center"] = {"lat": lat, "lon": lon} + return element + + +def test_the_aerodromes_in_a_box_come_back_with_a_code_and_a_place(tmp_path): + got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json", + ask=_overpass([_node("KTUS", "Tucson International")])) + assert len(got) == 1 + assert got[0]["code"] == "KTUS" + assert got[0]["latitude"] == pytest.approx(32.1) + + +def test_the_big_airports_are_relations_and_must_be_asked_for(): + """Tucson International and Davis-Monthan are both relations; asking + only for nodes and ways finds every airstrip in the county and misses + the two the county is known for.""" + import inspect + + source = inspect.getsource(bm._ask_overpass) + for kind in ("node", "way", "relation"): + assert f'{kind}["aeroway"="aerodrome"]' in source, kind + + +def test_a_relation_is_placed_by_its_middle(tmp_path): + got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json", + ask=_overpass([_node("KDMA", "Davis-Monthan", + kind="relation")])) + assert got and got[0]["code"] == "KDMA" + + +def test_a_landing_strip_with_no_real_code_is_left_off(tmp_path): + """A local identifier like 14AZ names nothing anybody would recognise, + and turns a map into a list of airstrips.""" + got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json", + ask=_overpass([_node(None, "Ruby Star", ref="14AZ"), + _node(None, "Nowhere", + ref="MX-0492"), + _node(None, "Unnamed strip")])) + assert got == [] + + +def test_a_four_letter_reference_is_good_enough(tmp_path): + got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json", + ask=_overpass([_node(None, "Somewhere", ref="EGLL")])) + assert [a["code"] for a in got] == ["EGLL"] + + +def test_one_airport_tagged_twice_is_marked_once(tmp_path): + """A point for the terminal and an outline for the field: marking both + writes the name over itself.""" + got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json", + ask=_overpass([_node("KFHU", "Sierra Vista"), + _node("KFHU", "Sierra Vista", + kind="relation")])) + assert [a["code"] for a in got] == ["KFHU"] + + +def test_the_ones_with_real_codes_come_first(tmp_path): + got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json", + ask=_overpass([_node(None, "Strip", ref="ZZZZ"), + _node("KTUS", "Tucson")])) + assert [a["code"] for a in got][0] == "KTUS" + + +def test_a_view_full_of_airstrips_is_capped(tmp_path): + many = [_node(f"K{i:03d}"[:4], f"Strip {i}") for i in range(200)] + got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json", + ask=_overpass(many)) + assert len(got) <= bm.MOST_AIRPORTS + + +def test_the_answer_is_kept_so_the_question_is_asked_once(tmp_path): + """A runway does not move, and the service being asked is a volunteer + one.""" + asked = [] + + def ask(box, url, timeout): + asked.append(box) + return {"elements": [_node("KTUS", "Tucson")]} + + where = tmp_path / "a.json" + first = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where, ask=ask) + second = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where, ask=ask) + assert first == second + assert len(asked) == 1, "asked twice for the same piece of the world" + + +def test_an_answer_from_an_older_question_is_asked_again(tmp_path): + where = tmp_path / "a.json" + where.write_text(json.dumps({"fetched_at": time.time(), "version": 1, + "airports": [{"code": "OLD"}]})) + got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where, + ask=_overpass([_node("KTUS", "Tucson")])) + assert [a["code"] for a in got] == ["KTUS"] + + +def test_no_network_means_no_airports_rather_than_no_map(tmp_path): + def refuse(box, url, timeout): + raise OSError("no route to host") + + assert bm.airports_in(32.0, -111.2, 32.4, -110.7, + cache=tmp_path / "a.json", ask=refuse) == [] + + +def test_nonsense_from_the_service_is_survived(tmp_path): + for answer in ({}, {"elements": None}, {"elements": [{"tags": None}]}, + {"elements": [{"tags": {"icao": "KTUS"}}]}): + got = bm.airports_in(32.0, -111.2, 32.4, -110.7, + cache=tmp_path / f"{id(answer)}.json", + ask=lambda b, u, t, a=answer: a) + assert got == [] + + +def test_the_map_asks_for_the_airports_under_it(): + """A route names where its aircraft are going; the airports underneath + are what say where on the map you are looking.""" + view = fm.fit(two_aircraft(), width=500) + got = fm.local_airports(view, ask=_overpass([_node("EGLL", "Heathrow", + lat=view.south + 0.1, + lon=view.west + 0.1)])) + assert got and got[0][0] == "EGLL" + + +def test_a_service_that_is_not_there_costs_the_airports_and_nothing_else(): + def refuse(box, url, timeout): + raise OSError("no") + + view = fm.fit(two_aircraft(), width=500) + assert fm.local_airports(view, ask=refuse) == [] + + +# --------------------------------------------------------------------------- +# How sharp the map is +# --------------------------------------------------------------------------- + +def test_detail_is_averaged_down_rather_than_thrown_away(): + """Taking the nearest source pixel keeps a fraction of a tile and turns + the rest into aliasing: hard, broken lettering and roads that come and + go along their length. A checkerboard is half black and half white, so + averaging it lands in the middle and sampling it lands at one end.""" + fine = np.tile(np.array([0.0, 255.0], dtype=np.float32), 128)[None, :] + out = bm._resample(fine, np.linspace(0, 256, 41), axis=1) + assert out.shape == (1, 40) + assert abs(float(out.mean()) - 127.5) < 4.0 + assert float(out.max()) < 160.0 and float(out.min()) > 95.0 + + +def test_averaging_takes_the_whole_cell_and_no_more(): + """Each output cell is the mean of the source pixels under it.""" + values = np.arange(100, dtype=np.float32)[None, :] + out = bm._resample(values, np.linspace(0, 100, 11), axis=1) + assert out.shape == (1, 10) + for i in range(10): + assert abs(float(out[0, i]) - (i * 10 + 4.5)) < 0.01 + + +def test_averaging_works_the_other_way_up_too(): + values = np.arange(60, dtype=np.float32)[:, None] + out = bm._resample(values, np.linspace(0, 60, 7), axis=0) + assert out.shape == (6, 1) + assert float(out[0, 0]) < float(out[-1, 0]) + + +def test_a_cell_smaller_than_a_source_pixel_takes_that_pixel(): + """Zoomed in past the tiles, a cell covers less than one of them.""" + values = np.array([[10.0, 20.0, 30.0]], dtype=np.float32) + out = bm._resample(values, np.linspace(0, 3, 10), axis=1) + assert out.shape == (1, 9) + assert set(np.round(out[0]).astype(int)) <= {10, 20, 30} + + +def test_a_gradient_still_comes_out_as_a_gradient(): + south, west, north, east = tile_bounds(9, 81, 178) + levels = bm.ground_under(south, west, north, east, 64, 64, shades=32, + fetch=gradient_tile, zoom=9, pause=0) + rows = levels.mean(axis=1) + assert (np.diff(rows) <= 0.51).all(), "the gradient came out lumpy" + + +def test_averaging_is_the_same_shape_as_the_picture_asked_for(): + south, west, north, east = tile_bounds(9, 81, 178) + for width, height in ((40, 40), (137, 91), (300, 200), (17, 5)): + levels = bm.ground_under(south, west, north, east, width, height, + shades=32, fetch=gradient_tile, zoom=9, + pause=0) + assert levels.shape == (height, width) + + +def test_a_map_asked_for_at_more_detail_than_the_tiles_hold_still_works(): + """Zoomed in past the tiles, a cell covers less than one source pixel.""" + south, west, north, east = tile_bounds(9, 81, 178) + levels = bm.ground_under(south, west, north, east, 2000, 2000, shades=32, + fetch=gradient_tile, zoom=9, pause=0) + assert levels.shape == (2000, 2000) + assert levels[0].mean() != levels[-1].mean() diff --git a/tests/test_flightmap.py b/tests/test_flightmap.py index a5ede39..4efa7ae 100644 --- a/tests/test_flightmap.py +++ b/tests/test_flightmap.py @@ -922,3 +922,267 @@ def test_labels_step_aside_rather_than_landing_on_each_other(): for i, one in enumerate(taken): for two in taken[i + 1:]: assert not fm._overlaps(one, two), f"{one} overlaps {two}" + + +# --------------------------------------------------------------------------- +# The country an aircraft is registered in +# --------------------------------------------------------------------------- + +def test_the_registration_carries_the_country_it_is_registered_in(): + track = straight() + entry = _Entry() + entry.owner_country = "United Kingdom" + rows = dict((text, flag) for text, flag in + fm.label_lines(track, track.fixes[0], "knots", entry)) + assert rows["B739 N904DN"] == "GB" + + +def test_the_address_block_stands_in_when_a_register_says_nothing(): + """The 24-bit address says which country issued it, by treaty, with no + website involved at all.""" + track = straight() + entry = _Entry() + entry.owner_country = "" + entry.country = "United States" + rows = dict(fm.label_lines(track, track.fixes[0], "knots", entry)) + assert rows["B739 N904DN"] == "US" + + +def test_a_country_nobody_named_gets_no_flag(): + track = straight() + entry = _Entry() + entry.owner_country = "" + entry.country = "" + rows = dict(fm.label_lines(track, track.fixes[0], "knots", entry)) + assert rows["B739 N904DN"] == "" + + +# --------------------------------------------------------------------------- +# Airports on the map +# --------------------------------------------------------------------------- + +def test_an_airport_a_route_gave_a_position_for_is_marked(): + known = {"A": _Entry()} + found = fm._airports_from(known) + assert {code for code, _, _ in found} == {"KATL", "EGLL"} + + +def test_an_airport_with_only_a_code_is_looked_up_so_it_can_be_marked(): + """A route names two airports and often gives a position for neither; an + airport that cannot be placed cannot be drawn.""" + asked = [] + + class _Book: + def airports(self, codes): + asked.extend(codes) + return [{"code": code, "latitude": 32.1, "longitude": -110.9} + for code in codes] + + entry = _Entry(origin_lat=0.0, origin_lon=0.0, + destination_lat=0.0, destination_lon=0.0) + found = fm._airports_from({"A": entry}, _Book()) + assert sorted(asked) == ["EGLL", "KATL"] + assert len(found) == 2 + + +def test_an_airport_that_cannot_be_looked_up_is_simply_not_drawn(): + class _Book: + def airports(self, codes): + raise OSError("no network") + + entry = _Entry(origin_lat=0.0, origin_lon=0.0, + destination_lat=0.0, destination_lon=0.0) + assert fm._airports_from({"A": entry}, _Book()) == [] + + +def test_an_airport_already_placed_is_not_looked_up_again(): + asked = [] + + class _Book: + def airports(self, codes): + asked.extend(codes) + return [] + + fm._airports_from({"A": _Entry()}, _Book()) + assert asked == [], "asked about airports it already had" + + +def test_an_airport_is_drawn_where_it_is_and_named(): + view = fm.fit(two_aircraft(), width=600) + middle = ((view.south + view.north) / 2, (view.west + view.east) / 2) + base = fm.background(view, airports=[("EGLL", middle[0], middle[1])]) + x, y = view.xy(*middle) + marker = (base[y - 4:y + 5, x - 4:x + 5] == fm.AIRPORT) + assert marker.any(), "no marker where the airport is" + # And its name beside it, in the same colour, to the right of the marker. + beside = (base[y - 6:y + 8, x + 5:x + 60] == fm.AIRPORT) + assert beside.sum() > 10, "the airport was marked but not named" + + +def test_an_airport_off_the_edge_is_not_drawn(): + view = fm.fit(two_aircraft(), width=600) + plain = fm.background(view) + away = fm.background(view, airports=[("KSEA", 47.4, -122.3)]) + assert np.array_equal(plain, away) + + +# --------------------------------------------------------------------------- +# How dark the ground is +# --------------------------------------------------------------------------- + +def _brightest_drawn(brightness): + """The lightest the map actually gets at one brightness setting.""" + top = int(fm.dim_ground(np.array([[fm.GROUND_SHADES - 1]]), + brightness)[0, 0]) + return fm.PALETTE[fm.GROUND + top].astype(float).mean() + + +def test_the_map_is_light_enough_to_read(): + """It was too dark to make out a coastline at all.""" + assert _brightest_drawn(fm.GROUND_BRIGHTNESS) > 110 + + +def test_the_aircraft_stay_brighter_than_the_ground_they_are_over(): + """Whatever else, the picture has to stay about the aeroplanes.""" + ground = _brightest_drawn(fm.GROUND_BRIGHTNESS) + for feet in (0, 10_000, 25_000, 40_000): + marker = fm.PALETTE[fm.RAMP + fm.altitude_step(feet)] + assert marker.astype(float).mean() > ground, feet + + +def test_there_is_room_to_turn_it_up_and_down(): + assert _brightest_drawn(1.0) > _brightest_drawn(fm.GROUND_BRIGHTNESS) + assert _brightest_drawn(0.3) < _brightest_drawn(fm.GROUND_BRIGHTNESS) + + +@pytest.mark.parametrize("brightness", [0.0, -1.0, 5.0, 1.0, 0.1]) +def test_a_brightness_outside_the_range_is_brought_back_into_it(brightness): + shades = fm.dim_ground(np.arange(fm.GROUND_SHADES, dtype=np.uint8), + brightness) + assert shades.min() >= 0 and shades.max() <= fm.GROUND_SHADES - 1 + + +def test_turning_it_down_really_does_darken_the_picture(): + levels = np.full((20, 20), fm.GROUND_SHADES - 1, dtype=np.uint8) + assert fm.dim_ground(levels, 0.3).max() < fm.dim_ground(levels, 0.9).max() + + +def test_the_darkest_ground_is_no_darker_than_the_background(): + darkest = fm.PALETTE[fm.GROUND].astype(float).mean() + background = fm.PALETTE[fm.BG].astype(float).mean() + assert darkest >= background + + +# --------------------------------------------------------------------------- +# Fading out rather than blinking out +# --------------------------------------------------------------------------- + +def test_an_aircraft_still_being_heard_is_at_full_strength(): + track = straight(seconds=120) + seen = fm.showing(track, track.fixes[-1].at, stale=300.0, fade=20.0) + assert seen is not None and seen[1] == 1.0 + + +def test_one_that_has_gone_quiet_fades_instead_of_disappearing(): + track = straight(seconds=120) + last = track.fixes[-1].at + strengths = [] + for gone in (301, 305, 310, 315, 319): + seen = fm.showing(track, last + gone, stale=300.0, fade=20.0) + assert seen is not None, gone + strengths.append(seen[1]) + assert strengths == sorted(strengths, reverse=True) + assert strengths[0] > 0.9 and strengths[-1] < 0.1 + + +def test_it_is_gone_once_the_fade_is_over(): + track = straight(seconds=120) + last = track.fixes[-1].at + assert fm.showing(track, last + 321, stale=300.0, fade=20.0) is None + + +def test_no_fade_takes_it_away_the_moment_it_is_given_up_on(): + track = straight(seconds=120) + last = track.fixes[-1].at + assert fm.showing(track, last + 301, stale=300.0, fade=0.0) is None + + +def test_it_fades_where_it_was_last_seen_and_not_where_it_might_be(): + """The whole reason for giving up on an aircraft is that where it would + be by now is a guess; fading it along that guess would be inventing an + aeroplane slowly instead of quickly.""" + track = straight(seconds=120) + last = track.fixes[-1] + seen = fm.showing(track, last.at + 310, stale=300.0, fade=20.0) + assert (seen[0].latitude, seen[0].longitude) == (last.latitude, + last.longitude) + + +def test_before_it_was_ever_heard_it_is_still_not_drawn(): + track = straight(seconds=120) + assert fm.showing(track, track.fixes[0].at - 60, fade=20.0) is None + + +def test_the_fading_aircraft_is_drawn_in_fainter_colours(): + track = straight(seconds=120) + view = fm.fit([track], width=500) + base = fm.background(view) + last = track.fixes[-1].at + + def drawn(when): + """The aircraft's own pixels: what changed, not what was already + there. The altitude key along the bottom is painted in the same + colours and belongs to the background.""" + frame = fm.render_frame(base, view, [track], when, stale=300.0, + fade=20.0, labels=False) + return frame[frame != base] + + fresh = drawn(last) + faint = drawn(last + 318) + assert fresh.size and faint.size + # A live aircraft has its marker in the full colours; its trail behind it + # is dimmer on purpose, so only the brightest pixels are the test. + assert (fresh < fm.TRAIL).any(), "a live aircraft was drawn faintly" + # A nearly gone one has nothing in them at all. + assert not (faint < fm.FAINT).any(), "a fading aircraft was drawn brightly" + + +def test_a_faded_aircraft_keeps_its_symbol_and_loses_its_label(): + track = straight(seconds=120) + view = fm.fit([track], width=500) + base = fm.background(view) + last = track.fixes[-1].at + known = {track.icao: _Entry()} + faint = fm.render_frame(base, view, [track], last + 318, stale=300.0, + fade=20.0, known=known) + assert (faint != base).any(), "nothing was drawn at all" + assert not _has_text(faint, "B739"), "the label survived the fade" + + +def test_the_trail_fades_with_the_aircraft_it_belongs_to(): + """The two are one thing on the picture, and half of it lingering would + be worse than either.""" + track = straight(seconds=120) + view = fm.fit([track], width=500) + base = fm.background(view) + last = track.fixes[-1].at + faint = fm.render_frame(base, view, [track], last + 318, stale=300.0, + fade=20.0, labels=False) + drawn = faint[faint != base] + assert drawn.size + assert drawn.min() >= fm.FAINT, "the trail stayed behind when it faded" + + +def test_the_faint_colours_are_the_same_hues_only_dimmer(): + for step in (0, 15, 31): + bright = fm.PALETTE[fm.RAMP + step].astype(float) + faint = fm.PALETTE[fm.FAINT + step].astype(float) + assert faint.sum() < bright.sum() + # The same colour, turned down: the ratios between the channels hold. + assert np.allclose(faint / max(1e-9, faint.sum()), + bright / bright.sum(), atol=0.03) + + +def test_the_palette_still_has_room_after_the_fade_colours(): + assert fm.FAINT + fm.RAMP_STEPS < fm.TRANSPARENT + assert fm.PALETTE.shape == (256, 3) diff --git a/tests/test_livemap.py b/tests/test_livemap.py index cc10b44..8d35133 100644 --- a/tests/test_livemap.py +++ b/tests/test_livemap.py @@ -55,7 +55,7 @@ def a_blip(icao="A76154", callsign="DAL538", lat=32.95, lon=-110.95, def a_sky(*blips, **over) -> Sky: settings = dict(unit="knots", hold=45.0, home=(32.4325, -111.0841), - radius_nm=100.0) + radius_nm=100.0, brightness=0.70) settings.update(over) sky = Sky(**settings) sky.started = now() - 600 @@ -297,11 +297,60 @@ def test_nothing_heard_yet_has_no_middle(): assert a_sky(home=None).centre() is None -def test_a_map_is_only_used_for_the_view_it_was_fetched_for(): +def test_a_map_is_used_for_any_view_it_covers(): + """The whole point of fetching more than is shown: a view that has + drifted a little is still inside what was fetched.""" sky = a_sky() - sky.set_ground(np.zeros((4, 4), dtype=np.uint8), "a") - assert sky.ground("a") is not None - assert sky.ground("b") is None + sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k", + (31.0, -113.0, 34.0, -109.0)) + levels, box = sky.ground_covering(32.0, -112.0, 33.0, -110.0) + assert levels is not None and box == (31.0, -113.0, 34.0, -109.0) + + +def test_a_map_is_not_used_for_a_view_that_has_left_it(): + sky = a_sky() + sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k", + (31.0, -113.0, 34.0, -109.0)) + assert sky.ground_covering(40.0, -113.0, 42.0, -109.0)[0] is None + assert sky.ground_covering(31.0, -120.0, 34.0, -109.0)[0] is None + + +@qt +def test_the_map_is_fetched_at_the_size_the_bigger_box_needs(app): + """Fetching a third more world into the same pixels and then cutting the + middle out of it is how a sharp map ends up looking like a photograph of + a map.""" + from bandsaunter.livemap import GROUND_MARGIN, SkyView + + sky = a_sky(a_blip()) + view = SkyView(sky) + view.resize(900, 650) + view._draw_ground(_NoPainter(), view.projection()) + wanted = sky.wanted_ground() + assert wanted is not None + _key, _box, (width, height) = wanted + assert width >= 900 * (1 + 2 * GROUND_MARGIN) - 2 + assert height >= 650 * (1 + 2 * GROUND_MARGIN) - 2 + + +class _NoPainter: + """Stands in for a painter for the one call that only asks for a map.""" + + def drawImage(self, *a): + raise AssertionError("nothing should be drawn without a map") + + +def test_more_is_fetched_than_is_shown(app): + """A window that fetched exactly what it needed would fetch again on + every resize and every small drift of the middle.""" + from bandsaunter.livemap import SkyView + + view = SkyView(a_sky(a_blip())) + view.resize(900, 650) + projection = view.projection() + box = view.ground_box(projection) + assert box[0] < projection.south and box[1] < projection.west + assert box[2] > projection.north and box[3] > projection.east def test_a_map_that_could_not_be_fetched_is_not_asked_for_again(): @@ -314,6 +363,84 @@ def test_a_map_that_could_not_be_fetched_is_not_asked_for_again(): assert sky.wanted_ground() is None +# --------------------------------------------------------------------------- +# The map staying still +# --------------------------------------------------------------------------- + +def test_the_middle_settles_and_then_stays_put(): + """Recomputing it on every paint moves the map a fraction of a mile + whenever an aircraft appears or leaves, which throws away the tiles + fetched for the old view and blinks the ground out several times a + minute.""" + sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0), + a_blip(icao="2", lat=32.5, lon=-111.1), + a_blip(icao="3", lat=32.6, lon=-111.2), home=None) + settled = sky.centre() + assert settled is not None + # One more aircraft, a little to one side: the middle must not follow it. + sky.update([a_blip(icao="4", lat=32.9, lon=-110.7)], 1, 4) + assert sky.centre() == settled + + +def test_the_middle_does_move_if_it_is_really_somewhere_else(): + """Settling must not mean stuck: the first few aircraft can be anywhere.""" + sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0), home=None) + first = sky.centre() + for i in range(6): + sky.update([a_blip(icao=f"far{i}", lat=40.0 + i * 0.1, lon=-90.0)], + 1, i) + assert sky.centre() != first + assert abs(sky.centre()[0] - 40.0) < 2 + + +def test_a_receiver_that_was_told_where_it_is_never_drifts_at_all(): + sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0)) + for i in range(5): + sky.update([a_blip(icao=f"x{i}", lat=35.0 + i, lon=-100.0)], 1, i) + assert sky.centre() == (32.4325, -111.0841) + + +@qt +def test_a_small_drift_does_not_throw_the_map_away(app): + """The failure this is here for: the ground blinking out because the + view moved by less than a mile.""" + from bandsaunter.livemap import SkyView + + sky = a_sky(a_blip(icao="1", lat=32.40, lon=-111.00), + a_blip(icao="2", lat=32.50, lon=-111.10), home=None) + view = SkyView(sky) + view.resize(900, 650) + first = view.projection() + sky.set_ground(np.full((650, 900), 20, dtype=np.uint8), + view.ground_key(first), view.ground_box(first)) + # Another aircraft arrives a few miles away. + sky.update([a_blip(icao="3", lat=32.44, lon=-111.06)], 1, 3) + moved = view.projection() + assert sky.ground_covering(moved.south, moved.west, + moved.north, moved.east)[0] is not None + + +@qt +def test_the_crop_lines_the_map_up_with_the_view(app): + """A map fetched for a bigger box has to be cut to the right piece, or + the coast is drawn in the wrong place and the aircraft with it.""" + from bandsaunter.livemap import SkyView + + view = SkyView(a_sky(a_blip())) + view.resize(100, 80) + projection = view.projection() + box = (projection.south - 1.0, projection.west - 1.0, + projection.north + 1.0, projection.east + 1.0) + # A map that is dark in the north and bright in the south. + levels = np.repeat(np.linspace(0, 31, 200).astype(np.uint8)[:, None], + 200, axis=1) + cropped = view._crop(levels, box, projection) + assert cropped.shape == (projection.height, projection.width) + assert cropped[0].mean() < cropped[-1].mean() # north still on top + # And the piece taken is the middle of the fetched one, not all of it. + assert cropped.min() > levels.min() and cropped.max() < levels.max() + + def test_the_map_is_fetched_off_the_painting_thread(): asked = [] @@ -477,12 +604,18 @@ def test_the_map_underneath_is_drawn_when_there_is_one(app): sky = a_sky(a_blip()) view = SkyView(sky) view.resize(900, 650) - key = view.ground_key(view.projection()) - sky.set_ground(np.full((650, 900), 31, dtype=np.uint8), key) + projection = view.projection() + sky.set_ground(np.full((650, 900), 31, dtype=np.uint8), + view.ground_key(projection), + view.ground_box(projection)) lit = _rendered(view)[:, :, :3] - from bandsaunter.flightmap import GROUND, GROUND_SHADES, PALETTE + from bandsaunter.flightmap import (GROUND, GROUND_SHADES, PALETTE, + dim_ground) - r, g, b = (int(v) for v in PALETTE[GROUND + GROUND_SHADES - 1]) + # The brightest shade a drawing reaches is what the brightness allows, + # not the top of the palette. + top = int(dim_ground(np.array([[GROUND_SHADES - 1]]), sky.brightness)[0, 0]) + r, g, b = (int(v) for v in PALETTE[GROUND + top]) covered = ((lit[:, :, 2] == r) & (lit[:, :, 1] == g) & (lit[:, :, 0] == b)) assert covered.mean() > 0.5, "the map was not painted under the aircraft" @@ -652,3 +785,185 @@ def test_the_widest_box_stays_within_reason(app): width, _height = view._box_size( view._wrapped(blip.lines("mph", home=(32.4325, -111.0841)))) assert width < 320, width + + +# --------------------------------------------------------------------------- +# How bright the map is +# --------------------------------------------------------------------------- + +def test_the_map_brightness_reaches_the_window(): + from bandsaunter import aircraft as air + + sky = Sky(brightness=0.9) + assert sky.brightness == 0.9 + assert air.AircraftOptions().map_brightness == 70 + + +@qt +def test_turning_the_brightness_down_darkens_the_map(app): + from bandsaunter.livemap import SkyView + + def painted(brightness): + sky = a_sky(a_blip(), brightness=brightness) + view = SkyView(sky) + view.resize(400, 300) + projection = view.projection() + sky.set_ground(np.full((300, 400), 31, dtype=np.uint8), + view.ground_key(projection), + view.ground_box(projection)) + return _rendered(view, 400, 300)[:, :, :3].astype(float).mean() + + assert painted(0.3) < painted(0.7) < painted(1.0) + + +@qt +def test_the_keys_change_the_brightness(app): + from bandsaunter.livemap import Window, _qt + + _name, core, gui, _widgets, _signal = _qt() + window = Window(a_sky(a_blip())) + try: + def press(letter): + window.keyPressEvent(gui.QKeyEvent( + _get(core.QEvent, "Type", "KeyPress"), ord(letter), + _get(core.Qt, "KeyboardModifier", "NoModifier"), letter)) + + was = window.sky.brightness + press("]") + assert window.sky.brightness > was + press("[") + press("[") + assert window.sky.brightness < was + for _ in range(20): # and never off either end + press("[") + assert 0.0 < window.sky.brightness <= 1.0 + finally: + window.close() + + +def test_the_country_of_registration_carries_a_flag(): + from bandsaunter.adsb import Aircraft + from bandsaunter.flights import Flight + + craft = Aircraft(icao="4CA1FA", callsign="RYR1234") + blip = blip_for(craft, Flight(icao="4CA1FA", owner_country="Ireland")) + assert blip.country == "Ireland" and blip.country_code == "IE" + rows = {label: (value, flag) for label, value, flag in blip.lines("knots")} + assert rows["reg'd"] == ("Ireland", "IE") + + +def test_a_country_with_no_code_gets_no_flag_rather_than_a_wrong_one(): + from bandsaunter.adsb import Aircraft + from bandsaunter.flights import Flight + + blip = blip_for(Aircraft(icao="4CA1FA"), + Flight(icao="4CA1FA", owner_country="Atlantis")) + assert blip.country_code == "" + + +# --------------------------------------------------------------------------- +# Fading out rather than blinking out +# --------------------------------------------------------------------------- + +def test_an_aircraft_still_being_heard_is_drawn_in_full(): + sky = a_sky(hold=10.0, fade=20.0) + assert sky.strength(a_blip(last_seen=now() - 1)) == 1.0 + assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0 + + +def test_one_that_has_gone_quiet_fades_rather_than_vanishing(): + """Taking it off between one frame and the next says it stopped + existing; fading says it stopped talking, which is what happened.""" + sky = a_sky(hold=10.0, fade=20.0) + at = now() + strengths = [sky.strength(a_blip(last_seen=at - age), at) + for age in (5, 15, 20, 25, 29)] + assert strengths == sorted(strengths, reverse=True) + assert strengths[0] == 1.0 + assert 0.0 < strengths[-1] < 0.1 + + +def test_it_is_gone_once_it_has_finished_fading(): + sky = a_sky(hold=10.0, fade=20.0) + old = a_blip(last_seen=now() - 40) + assert sky.strength(old) == 0.0 + sky.update([old], 1, 1) + assert old.icao not in [b.icao for b in sky.flying()] + + +def test_it_stays_on_the_picture_for_the_whole_fade(): + sky = a_sky(hold=10.0, fade=20.0) + quiet = a_blip(last_seen=now() - 25) + sky.update([quiet], 1, 1) + assert [b.icao for b in sky.flying()] == [quiet.icao] + + +def test_no_fade_at_all_takes_it_away_at_once(): + sky = a_sky(hold=10.0, fade=0.0) + assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0 + assert sky.strength(a_blip(last_seen=now() - 11)) == 0.0 + + +def test_how_long_the_fade_takes_is_a_setting(): + from bandsaunter import aircraft as air + + assert air.AircraftOptions().fade == 20.0 + quick = a_sky(hold=10.0, fade=4.0) + slow = a_sky(hold=10.0, fade=60.0) + faded = a_blip(last_seen=now() - 20) + assert quick.strength(faded) == 0.0 + assert slow.strength(faded) > 0.5 + + +def test_the_setting_reaches_the_window(): + assert Sky(fade=7.5).fade == 7.5 + + +@qt +def test_a_fading_aircraft_is_drawn_fainter(app): + from bandsaunter.livemap import SkyView + + def brightness(age): + sky = a_sky(hold=5.0, fade=20.0) + sky.update([a_blip(last_seen=now() - age)], 1, 1) + view = SkyView(sky) + view.detail = 0 + picture = _rendered(view, 500, 400) + lit = _lit(picture) + if not len(lit): + return 0.0 + return float(picture[lit[:, 0], lit[:, 1], :3].mean()) + + assert brightness(1) > brightness(20) + + +@qt +def test_a_faded_aircraft_keeps_its_symbol_and_loses_its_box(app): + """A box at a tenth of its colour is something in the way of the + aircraft still flying.""" + from bandsaunter.livemap import SkyView + + sky = a_sky(hold=5.0, fade=20.0) + sky.update([a_blip(last_seen=now() - 22)], 1, 1) # strength about 0.15 + view = SkyView(sky) + fresh = _painted(_rendered(view, 600, 450)) + sky2 = a_sky(hold=5.0, fade=20.0) + sky2.update([a_blip(last_seen=now() - 1)], 1, 1) + view2 = SkyView(sky2) + full = _painted(_rendered(view2, 600, 450)) + assert fresh < full, "the box was still drawn on a nearly faded aircraft" + + +@qt +def test_one_that_has_gone_quiet_is_not_counted_as_overhead(app): + """It is on the picture, fading; saying it is overhead says more than + was heard.""" + from bandsaunter.livemap import SkyView + + sky = a_sky(hold=5.0, fade=30.0) + sky.update([a_blip(icao="AAA111", last_seen=now() - 1), + a_blip(icao="BBB222", lat=32.6, last_seen=now() - 20)], 9, 2) + picture = _rendered(SkyView(sky), 900, 650) + assert _painted(picture) > 100 # both are drawn + assert len(sky.flying()) == 2 + assert sum(1 for b in sky.flying() if sky.strength(b) >= 1.0) == 1