diff --git a/README.md b/README.md index 37df651..8f3ce0d 100644 --- a/README.md +++ b/README.md @@ -1646,6 +1646,37 @@ map is enlarged after all. Somebody else's tile server is not a thing to fetch a thousand tiles from for one picture. A smaller `--radius` buys the detail back, since the same budget then covers less ground. +**Tiles that do not arrive.** Resizing the window is the demanding case: a +wider picture picks a sharper zoom, and a hundred tiles that have never been +on this disk are asked for at once. A busy server refuses some of them, and +what the window used to do with a refusal was draw it. + +A tile that never arrived leaves its square of the canvas black, and black is +not a neutral colour here — the brightness is inverted on the way in, so the +darkest possible square comes out as **the brightest thing on the picture**: a +glowing rectangle where the map should be. It also dragged the floor of the +map's own contrast down to black, so every other pixel was drawn dimmer to +make room for a square that was not there. And it was kept: the map was +cached under the view it was fetched for, and the hole stayed until the view +changed. + +So three things happen instead. The missing squares are **asked for again**, +straight away, because the usual reason for a refusal is the ninety-nine tiles +asked for just before it — and only the missing ones, since the rest are on +the disk by then. What is still missing is **drawn as bare ground** rather +than as a light, and left out of the reckoning when the darkest and brightest +of the map are worked out, so a hole costs nothing but itself. And the map is +**remembered as provisional**: the window keeps drawing it and asks for the +rest of it half a minute later — four attempts in all, each of which retries +its own misses once, so a square gets eight chances before one that will not +come is accepted as one that is not there. + +The politeness pause between requests is now paid only on a tile that had to +be fetched. It had been paid on every tile including the ones read back off +the disk, which put twenty-six seconds of sleeping into redrawing a view that +was entirely cached — and would have made asking again for three missing +squares cost the wait for the two hundred that were not. + `--map-brightness PERCENT` (70 by default) is how far up its range the map is drawn, and **the vector themes bend the middle of that range down hard** — because a tinted photograph of a county behind the vectors is the one thing @@ -2498,6 +2529,17 @@ trails, `g` the map underneath, `[` and `]` its brightness, `+`/`-` the range, `q` quits. `--radius` sets how far it reaches to begin with and `--theme` picks from the same five. +**The window measures in whatever unit you are being shown.** `--units +imperial` puts statute miles round the rings, along the scale at the bottom +and on `--radius` itself, and `--units metric` puts kilometres on all three. +The rings are then *drawn* at the distance they are labelled: the outermost +one at `--radius 100 --units imperial` stands seventy-five statute miles from +the red flag, measured on the ground, not seventy-five kilometres with miles +written beside it. A ring is the thing a distance gets judged against by eye, +so a ring labelled in one unit and drawn in another is a wrong answer given +confidently — and the tables printed afterwards have always followed this +setting, so the window disagreeing with them was the window being wrong. + **Where the aerial is** puts the red flag on the map, centres the range rings and gives every station a distance and a bearing. Set it with `--at LAT,LON`, or in the menu under **Receiver at** — and if you have already told the diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index ca242f0..612d1e2 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-21" -VERSION_REVISION = 2 +VERSION_REVISION = 3 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/aprs.py b/bandsaunter/aprs.py index a3de229..c6446f8 100644 --- a/bandsaunter/aprs.py +++ b/bandsaunter/aprs.py @@ -36,6 +36,7 @@ from .settings import Setting, format_value __all__ = ["AprsOptions", "OPTIONS", "OPTION_GROUPS", "defaults", "in_group", "channel_text", "find_channel", "report_channels", "Found", "use_region", "receiver_at", "coordinates", + "map_unit", "radius_in_nm", "by_key", "format_option", "describe", "summarise", "Station", "Net", "Heard", "listen", "open_device", "open_log", "pump", "finish", "report", "watch", "windowed", "blip_for", @@ -73,7 +74,7 @@ class AprsOptions: digipeated: bool = True # count frames that reached here relayed # -- the map -------------------------------------------------------- - radius: float = 50.0 # km the window reaches around the aerial + radius: float = 50.0 # how far the window reaches, in map_unit theme: str = "night" map_brightness: int = 70 basemap: bool = True @@ -239,12 +240,17 @@ OPTIONS: tuple[Setting, ...] = ( # -- the map -------------------------------------------------------- O("radius", "Map reaches", "The map", "float", - "how far around the aerial the window reaches, in kilometres", + "how far around the aerial the window reaches, in the unit being shown", "Which is also the zoom. Fifty kilometres is a region; five is a town " "and a digipeater at the edge of it falls off the picture. Stations " "outside it are still heard, still logged and still on the map drawn " - "afterwards \u2014 they are simply off the edge of the window.", - unit="km", minimum=1.0, maximum=2000.0, flags=("--radius",), + "afterwards \u2014 they are simply off the edge of the window. " + "In whatever unit Show readings in is set to: kilometres in metric, " + "statute miles in imperial. No suffix is printed against it here " + "because the suffix is the other setting's to give, and a number " + "labelled km while the rings beside it are drawn in miles would be " + "worse than one labelled nothing.", + minimum=1.0, maximum=2000.0, flags=("--radius",), example="50"), O("theme", "Theme", "The map", "choice", "how the window looks", @@ -423,6 +429,30 @@ def coordinates(text: str) -> tuple[float, float] | None: return (lat, lon) if abs(lat) <= 90 and abs(lon) <= 180 else None +def map_unit(options: AprsOptions) -> str: + """Which unit the window measures in: statute miles or kilometres. + + The same setting that picks the unit for every reading in the tables + picks it here. A window that said kilometres round its rings while the + table beside it said miles would be two answers to one question. + """ + return "mph" if options.imperial else "kph" + + +def radius_in_nm(options: AprsOptions) -> float: + """How far the window reaches, as the drawing wants it. + + The setting is in whatever unit is being shown -- kilometres in metric, + statute miles in imperial -- so that the number typed in and the number + written beside the outermost ring are the same number. Everything + inside the drawing is in nautical miles, that being a minute of + latitude and so the unit the geometry is already in. + """ + from .flightlog import speed_unit + + return max(1.0, float(options.radius)) / speed_unit(map_unit(options))[3] + + def receiver_at(options: AprsOptions) -> tuple[float, float] | None: """Where the aerial is standing, from here or from the aircraft settings. @@ -1490,8 +1520,8 @@ def watch(console, options: AprsOptions, output_dir: str, # out of the palette this writes. set_theme(options.theme) sky = livemap.Sky( - unit="kph", hold=options.hold, home=home, - radius_nm=max(1.0, options.radius) / 1.852, + unit=map_unit(options), hold=options.hold, home=home, + radius_nm=radius_in_nm(options), brightness=max(10, options.map_brightness) / 100.0, fade=0.0, airports=False, rings=options.window_rings, box_opacity=max(0, options.box_opacity) / 100.0, diff --git a/bandsaunter/basemap.py b/bandsaunter/basemap.py index ede263e..52b69cc 100644 --- a/bandsaunter/basemap.py +++ b/bandsaunter/basemap.py @@ -71,6 +71,15 @@ USER_AGENT = (f"bandsaunter/{__version__} " # tile that was not already on the disk. FETCH_PAUSE = 0.12 +# What to do about tiles that did not arrive. A server that has just been +# asked for a hundred tiles in a row refuses some of them, and the answer to +# being told to slow down is to slow down and ask again rather than to draw +# the gap. Longer than FETCH_PAUSE because the first pass is what provoked +# it; once, because a tile that fails twice is usually a tile that is not +# there, and the caller asks again later anyway. +RETRY_PAUSE = 0.8 +RETRIES = 1 + # 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. @@ -303,12 +312,55 @@ def fetch_tile(zoom: int, x: int, y: int, url: str = TILE_URL, return body +def on_disk(zoom: int, x: int, y: int, cache: Path | None = None, + **kw) -> bool: + """Whether this tile has been fetched before. + + A read off the disk owes a volunteer-funded server no politeness, and + paying it anyway is how a view whose tiles are all in hand takes half a + minute to redraw: two hundred and twenty tiles at an eighth of a second + each, spent sleeping between files that were already there. + """ + where = (cache if cache is not None else cache_dir()) / str(zoom) / str(x) + try: + return (where / f"{y}.png").exists() + except OSError: + return False + + +def _one_tile(fetch, zoom: int, tx: int, ty: int, **kw): + """One decoded tile, or None if it could not be had or made sense of.""" + body = fetch(zoom, tx, ty, **kw) + if body is None: + return None + try: + tile = decode_png(body) + except (PNGError, zlib.error, ValueError): + return None + if tile.shape[0] != TILE_PIXELS or tile.shape[1] != TILE_PIXELS: + return None + return tile + + def mosaic(south: float, west: float, north: float, east: float, zoom: int, - fetch=fetch_tile, pause: float = FETCH_PAUSE, **kw): + fetch=fetch_tile, pause: float = FETCH_PAUSE, + retries: int = RETRIES, retry_pause: float = RETRY_PAUSE, + cached=on_disk, **kw): """Every tile the box touches, stitched into one image. - Returns the pixels and where their top-left corner sits in the world, in - tile-grid pixels at this zoom, so the resampling below can place them. + Returns the pixels, where their top-left corner sits in the world in + tile-grid pixels at this zoom so the resampling below can place them, + and which tiles actually arrived. + + That last one matters because the canvas starts black, and black is not + a neutral colour here: the brightness is inverted further down, so a + tile that never arrived is drawn as the brightest thing on the map + rather than as a gap. Saying which squares are real lets the map be + drawn without them, and lets the caller know the answer is not final. + + Tiles that did not arrive are asked for again before the map is given up + on, because the usual reason for a hole is a hundred tiles having been + asked for in the preceding second. """ x0, y0 = tile_of(north, west, zoom) x1, y1 = tile_of(south, east, zoom) @@ -317,32 +369,40 @@ def mosaic(south: float, west: float, north: float, east: float, zoom: int, span = 2 ** zoom wide, tall = right - left + 1, bottom - top + 1 if wide <= 0 or tall <= 0 or wide * tall > MAX_TILES * 4: - return None, 0, 0 + return None, 0, 0, np.zeros((0, 0), dtype=bool) canvas = np.zeros((tall * TILE_PIXELS, wide * TILE_PIXELS, 3), dtype=np.uint8) - got = 0 - for row in range(tall): - for column in range(wide): - tx, ty = (left + column) % span, top + row - if not 0 <= ty < span: - continue - body = fetch(zoom, tx, ty, **kw) - if body is None: - continue - try: - tile = decode_png(body) - except (PNGError, zlib.error, ValueError): - continue - if tile.shape[0] != TILE_PIXELS or tile.shape[1] != TILE_PIXELS: + covered = np.zeros((tall, wide), dtype=bool) + # Rows off the top or bottom of the world are left out rather than + # asked for: they are not missing tiles, they are places there is no + # map of, and they stay uncovered so they are drawn as nothing. + todo = [(row, column, (left + column) % span, top + row) + for row in range(tall) for column in range(wide) + if 0 <= top + row < span] + for attempt in range(max(0, retries) + 1): + if not todo: + break + if attempt and retry_pause: + time.sleep(retry_pause) + missed = [] + was_here = {(row, column): bool(pause) and cached(zoom, tx, ty, **kw) + for row, column, tx, ty in todo} + for row, column, tx, ty in todo: + tile = _one_tile(fetch, zoom, tx, ty, **kw) + if tile is None: + missed.append((row, column, tx, ty)) continue canvas[row * TILE_PIXELS:(row + 1) * TILE_PIXELS, column * TILE_PIXELS:(column + 1) * TILE_PIXELS] = tile - got += 1 - if pause: + covered[row, column] = True + # Asked before the fetch rather than after it, because the + # fetch is what puts the tile on the disk. + if pause and not was_here[(row, column)]: time.sleep(pause) - if not got: - return None, 0, 0 - return canvas, left * TILE_PIXELS, top * TILE_PIXELS + todo = missed + if not covered.any(): + return None, 0, 0, covered + return canvas, left * TILE_PIXELS, top * TILE_PIXELS, covered # --------------------------------------------------------------------------- @@ -494,17 +554,23 @@ def ground_under(south: float, west: float, north: float, east: float, point of putting a coastline under it is that the coastline is where the aircraft was. - Returns None when nothing could be fetched, which the caller draws as the - plain grid it drew before. + Returns the levels and whether that is the whole answer. Nothing at all + is None, which the caller draws as the plain grid it drew before; a map + with squares missing from it is returned all the same, because most of a + map is better than none, but it is flagged as not settled so the caller + knows to ask again rather than keeping it for the life of the view. """ if width < 1 or height < 1 or north <= south or east <= west: - return None + return None, True if zoom is None: zoom = choose_zoom(south, west, north, east, width=width) - tiles, origin_x, origin_y = mosaic(south, west, north, east, zoom, - fetch=fetch, **kw) + tiles, origin_x, origin_y, covered = mosaic(south, west, north, east, + zoom, fetch=fetch, **kw) if tiles is None: - return None + # Nothing arrived. Settled on purpose: a machine with no network + # must not be made to ask for the same tiles five times a second + # for the rest of the night. + return None, True # 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 @@ -528,11 +594,32 @@ def ground_under(south: float, west: float, north: float, east: float, # brightest thing on the picture. 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()) + + # Averaged over the tiles that are really there rather than over the + # canvas. Both sums come off the same box filter, so dividing one by + # the other is the weighted mean over the real pixels -- which gets the + # cells along the edge of a hole right as well, those being part tile + # and part nothing. + real = np.repeat(np.repeat(covered.astype(np.float32), TILE_PIXELS, + axis=0), TILE_PIXELS, axis=1) + share = _resample(_resample(real, ys, axis=0), xs, axis=1) + luma = _resample(_resample(whole * real, ys, axis=0), xs, axis=1) + here = share > 1e-6 + luma = np.where(here, luma / np.where(here, share, 1.0), 0.0) + + if not here.any(): + return None, True + # The darkest and brightest of what was actually fetched. A hole left + # in the reckoning would put the floor at black, which is darker than + # any real tile: the whole map would be drawn dimmer to make room for + # a square that is not there. + low, high = float(luma[here].min()), float(luma[here].max()) if high - low < 1.0: levels = np.zeros_like(luma) else: levels = 1.0 - (luma - low) / (high - low) - return np.clip((levels * (shades - 1)).round(), 0, - shades - 1).astype(np.uint8) + # And the hole itself is drawn as bare ground rather than as the + # brightest thing on the picture, which is what inverting black gives. + levels = np.where(here, levels, 0.0) + return (np.clip((levels * (shades - 1)).round(), 0, + shades - 1).astype(np.uint8), bool(covered.all())) diff --git a/bandsaunter/flightmap.py b/bandsaunter/flightmap.py index a4c2882..bc87bcc 100644 --- a/bandsaunter/flightmap.py +++ b/bandsaunter/flightmap.py @@ -1854,7 +1854,10 @@ def ground_for(view: Projection, fetch=None, url: str = "") -> tuple: extra = {"fetch": fetch} if fetch is not None else {} if url: extra["url"] = url - levels = basemap.ground_under( + # A still picture gets one attempt at the tiles -- there is no + # window to ask again from -- so whether the answer was complete + # changes nothing here. It already retried the misses itself. + levels, _settled = basemap.ground_under( view.south, view.west, view.north, view.east, view.width, view.height, shades=GROUND_SHADES, **extra) except Exception: diff --git a/bandsaunter/livemap.py b/bandsaunter/livemap.py index 0ebe320..1a965c4 100644 --- a/bandsaunter/livemap.py +++ b/bandsaunter/livemap.py @@ -72,6 +72,17 @@ RESETTLE_NM = 12.0 # the map settling rather than wandering, a little is enough. GROUND_MARGIN = 0.12 +# What to do about a map that came back with squares missing from it. The +# usual cause is a resize: the window grows, a sharper zoom is chosen, and a +# hundred tiles that have never been on this disk are asked for at once -- +# whereupon a volunteer-funded server refuses some of them. The fetch +# retries its own misses, so anything left is a server asking to be left +# alone for a bit, and the wait is long enough to be that. Counted as well +# as timed because a square can be missing for good, and asking all night +# for a tile that does not exist is the same discourtesy more slowly. +GROUND_RETRY_S = 25.0 +GROUND_TRIES = 4 + # 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 @@ -446,6 +457,12 @@ class Sky: self._ground_for = None self._ground_box = None self._ground_serial = 0 + # Whether the map in hand is the whole of what was asked for, when + # it arrived, and how many times it has been asked for since. + self._ground_settled = True + self._ground_at = 0.0 + self._ground_tries = 0 + self._ground_ask = None # The aerodromes under the view, fetched with the map and kept the # same way: they come from the same place, cover the same box, and # go stale at the same moment. @@ -469,15 +486,26 @@ class Sky: if not trail or trail[-1][:2] != here[:2]: trail.append(here) - def set_ground(self, levels, key, box=None) -> None: + def set_ground(self, levels, key, box=None, settled: bool = True) -> 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. + + ``settled`` is whether this is the whole map or only most of it. + Most of it is still worth drawing -- the alternative is a bare grid + -- but it is not worth keeping for the life of the view, so an + unsettled map may be asked for again. """ with self._lock: + if key != self._ground_for: + self._ground_tries = 0 self._ground, self._ground_for = levels, key self._ground_box = box + self._ground_settled = bool(settled) + self._ground_at = time.time() + if not settled: + self._ground_tries += 1 # Counted rather than compared: the drawing side keeps the # dimmed pixels it made last time, and needs to know whether # what it made them from is still the same map. @@ -532,11 +560,41 @@ class Sky: with self._lock: return self._ground_serial + def ground_settled(self) -> bool: + """Whether the map in hand is the whole of what was asked for.""" + with self._lock: + return self._ground_settled + def want_ground(self, key, box, size) -> None: """Say which map is needed. Painting must never wait on a network.""" with self._lock: if self._ground_for != key: self._wanted = (key, box, size) + self._ground_ask = (key, box, size) + + def reask_ground(self) -> None: + """Ask again for a map that came back with squares missing from it. + + For the map already in hand, under the key it was fetched with, + rather than for whatever the view happens to be this frame. The + view drifts a pixel at a time inside the box that was fetched and is + answered from it without asking for anything; asking on each of + those drifts would be a refetch every frame rather than a retry. + + There was a check here that the remembered request was still the one + the map in hand came from. It could not be made to fail: a request + for a different view is only ever taken up after ``_wanted`` has + been filled, and a filled ``_wanted`` has already returned above. + Code that cannot be made to matter is code that is not doing + anything, so it went. + """ + with self._lock: + if self._ground_settled or self._wanted is not None: + return + if self._ground_tries >= GROUND_TRIES: + return + if time.time() - self._ground_at >= GROUND_RETRY_S: + self._wanted = self._ground_ask def wanted_ground(self): with self._lock: @@ -1012,6 +1070,11 @@ def _build(): (int(view.width * scale), int(view.height * scale))) return + # Most of a map, with squares missing where tiles did not + # arrive. Drawn -- it is most of a map -- and asked for again, + # which is rate-limited inside and does nothing at all once the + # map is whole. + self.sky.reask_ground() # Cutting the view out of the fetched map, dimming it and # looking every level up in the palette is about seventy # milliseconds over two megapixels, and none of it changes @@ -1701,14 +1764,18 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None: # the screen. Rendering the wider box into the window's pixels # and stretching it back was a whole-map upscale of a fifth, # which is what a sharp map looks like when it looks blurred. - levels = basemap.ground_under(south, west, north, east, - width, height, - shades=_ground_shades(), **extra) + levels, settled = basemap.ground_under( + south, west, north, east, width, height, + shades=_ground_shades(), **extra) except Exception: - levels = None + levels, settled = None, True # 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, box if levels is not None else None) + # A map that came back with squares missing is remembered too -- it + # is most of a map and it gets drawn -- but not as the last word, + # so the window can ask for the rest of it in a moment. + sky.set_ground(levels, key, box if levels is not None else None, + settled=settled) def _airports(sky: Sky) -> None: diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 68f030e..f788817 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-21" "bandsaunter 2026-09-21_02" "User Commands" +.TH BANDSAUNTER 1 "2026-09-21" "bandsaunter 2026-09-21_03" "User Commands" .SH NAME bandsaunter \- scan, record and identify radio signals with an RTL-SDR .SH SYNOPSIS @@ -1579,6 +1579,25 @@ the window wants. A drawing is capped at a couple of hundred tiles, which at 3840 by 2160 is reached: there the zoom has stopped climbing and the map is enlarged after all, and a smaller radius buys the detail back. .PP +Resizing the window is the demanding case: a wider picture picks a sharper +zoom and a hundred tiles that have never been on this disk are asked for at +once, whereupon a busy server refuses some of them. A tile that does not +arrive leaves its square of the canvas black, and black is not neutral here +\[em] the brightness is inverted on the way in, so the darkest possible square +came out as the brightest thing on the picture, dragged the floor of the map's +own contrast down with it, and stayed there for the life of the view. Instead +the missing squares are asked for again at once, and only those, the rest +being on the disk by then; whatever is still missing is drawn as bare ground +and left out of the reckoning when the darkest and brightest of the map are +worked out; and the map is kept as provisional rather than as the last word, +asked for again half a minute later, four attempts in all, each retrying its +own misses once, so a square gets eight chances before one that will not come +is accepted as one that is not there. +.PP +The politeness pause between requests is paid only on a tile that had to be +fetched. Paid on every tile, as it had been, it put twenty-six seconds of +sleeping into redrawing a view whose tiles were all in hand. +.PP .B \-\-no\-basemap draws the tracks on their own, .BI \-\-tiles " URL" @@ -2715,6 +2734,20 @@ the range, and .B q to quit. .PP +The window measures in whatever unit is being shown. +.B \-\-units " imperial" +puts statute miles round the rings, along the scale at the bottom and on +.B \-\-radius +itself; +.B \-\-units " metric" +puts kilometres on all three. The rings are drawn at the distance they are +labelled \[em] the outermost at +.B \-\-radius " 100" +in imperial stands seventy-five statute miles from the flag, measured on the +ground, rather than seventy-five kilometres with miles written beside it. A +ring is what a distance gets judged against by eye, so one labelled in a unit +it was not drawn in is a wrong answer given confidently. +.PP .BI \-\-at " LAT,LON" puts the red flag on the map, centres the range rings and gives every station a distance and a bearing. Left unset, whatever the aircraft side was told is used @@ -2861,9 +2894,9 @@ Leave it on for a picture of the network; turn it off to find out what you can a .SS The map .TP .B --radius -Map reaches \[em] how far around the aerial the window reaches, in kilometres (km). +Map reaches \[em] how far around the aerial the window reaches, in the unit being shown. .br -Setting name \fBradius\fR, default \fB50 km\fR. +Setting name \fBradius\fR, default \fB50\fR. .br Accepts: at least 1, at most 2000. .TP diff --git a/packaging/make-man.py b/packaging/make-man.py index 3523400..737ef06 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -1030,6 +1030,25 @@ the window wants. A drawing is capped at a couple of hundred tiles, which at 3840 by 2160 is reached: there the zoom has stopped climbing and the map is enlarged after all, and a smaller radius buys the detail back. .PP +Resizing the window is the demanding case: a wider picture picks a sharper +zoom and a hundred tiles that have never been on this disk are asked for at +once, whereupon a busy server refuses some of them. A tile that does not +arrive leaves its square of the canvas black, and black is not neutral here +\[em] the brightness is inverted on the way in, so the darkest possible square +came out as the brightest thing on the picture, dragged the floor of the map's +own contrast down with it, and stayed there for the life of the view. Instead +the missing squares are asked for again at once, and only those, the rest +being on the disk by then; whatever is still missing is drawn as bare ground +and left out of the reckoning when the darkest and brightest of the map are +worked out; and the map is kept as provisional rather than as the last word, +asked for again half a minute later, four attempts in all, each retrying its +own misses once, so a square gets eight chances before one that will not come +is accepted as one that is not there. +.PP +The politeness pause between requests is paid only on a tile that had to be +fetched. Paid on every tile, as it had been, it put twenty-six seconds of +sleeping into redrawing a view whose tiles were all in hand. +.PP .B \-\-no\-basemap draws the tracks on their own, .BI \-\-tiles " URL" @@ -1713,6 +1732,20 @@ the range, and .B q to quit. .PP +The window measures in whatever unit is being shown. +.B \-\-units " imperial" +puts statute miles round the rings, along the scale at the bottom and on +.B \-\-radius +itself; +.B \-\-units " metric" +puts kilometres on all three. The rings are drawn at the distance they are +labelled \[em] the outermost at +.B \-\-radius " 100" +in imperial stands seventy-five statute miles from the flag, measured on the +ground, rather than seventy-five kilometres with miles written beside it. A +ring is what a distance gets judged against by eye, so one labelled in a unit +it was not drawn in is a wrong answer given confidently. +.PP .BI \-\-at " LAT,LON" puts the red flag on the map, centres the range rings and gives every station a distance and a bearing. Left unset, whatever the aircraft side was told is used diff --git a/tests/test_aprs.py b/tests/test_aprs.py index 915e908..25b8d6c 100644 --- a/tests/test_aprs.py +++ b/tests/test_aprs.py @@ -1075,6 +1075,32 @@ def test_the_window_gets_the_position_so_the_flag_is_drawn(tmp_path, assert "aircraft" not in built["waiting"] +@pytest.mark.parametrize("units, unit, nm", [ + ("metric", "kph", 100 / 1.852), + ("imperial", "mph", 100 / 1.150779), +]) +def test_the_window_is_built_in_the_unit_being_shown(units, unit, nm, + tmp_path, monkeypatch): + """Not just the helpers: the window that actually gets opened. The + rings, their labels and the scale along the bottom all read off what is + handed over here.""" + from bandsaunter import livemap + + built = {} + monkeypatch.setattr(livemap, "available", lambda: True) + monkeypatch.setattr(livemap, "show", + lambda sky, title: built.update( + unit=sky.unit, radius_nm=sky.radius_nm)) + monkeypatch.setattr(ap, "open_device", lambda console, opts: Silence(1)) + console = Console(width=120, force_terminal=False) + with console.capture(): + ap.watch(console, ap.AprsOptions(rate=RATE, log=False, report=False, + basemap=False, units=units, + radius=100.0), str(tmp_path)) + assert built["unit"] == unit + assert built["radius_nm"] == pytest.approx(nm, rel=1e-6) + + def test_it_says_where_the_position_came_from_rather_than_assuming( tmp_path, monkeypatch): """A position inherited from another section is a convenience right up @@ -1109,6 +1135,74 @@ def test_knowing_nowhere_says_what_that_costs(tmp_path, monkeypatch): assert "no flag" in out and "--at" in out +def _haversine_km(a, b): + """Great-circle distance, written here rather than imported, so that a + test of how far the rings are drawn is not asking the drawing.""" + import math + + lat1, lon1 = math.radians(a[0]), math.radians(a[1]) + lat2, lon2 = math.radians(b[0]), math.radians(b[1]) + h = (math.sin((lat2 - lat1) / 2) ** 2 + + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2) + return 2 * 6371.0088 * math.asin(math.sqrt(h)) + + +def _rings_as_drawn(options): + """Every ring the window would draw: how far it really is from the flag, + in kilometres, and what is written beside it.""" + from bandsaunter.flightlog import move + from bandsaunter.flightmap import ring_labels + + home = (47.6062, -122.3322) + out = [] + for nm, text in ring_labels(ap.radius_in_nm(options), + ap.map_unit(options)): + edge = move(home[0], home[1], 0.0, nm) + out.append((_haversine_km(home, edge), text)) + return out + + +@pytest.mark.parametrize("units, label, per_km", [ + ("metric", "km", 1.0), + ("imperial", "mi", 1.609344), +]) +def test_the_rings_are_drawn_at_the_distance_they_are_labelled( + units, label, per_km): + """Both halves of it. Writing miles beside a ring drawn at kilometres + is worse than writing nothing: it is a wrong answer given confidently, + and the ring is the thing a distance gets judged against by eye.""" + options = ap.defaults() + options.units, options.radius = units, 100.0 + drawn = _rings_as_drawn(options) + assert [text for _km, text in drawn] == [f"25 {label}", f"50 {label}", + f"75 {label}"] + for km, text in drawn: + said = float(text.split()[0]) + assert abs(km - said * per_km) < 0.5, (km, text) + + +def test_the_window_reaches_as_far_as_it_was_told_in_the_unit_being_shown(): + """A hundred set in imperial is a hundred miles, not a hundred + kilometres with miles written on it.""" + metric, imperial = ap.defaults(), ap.defaults() + metric.units, metric.radius = "metric", 100.0 + imperial.units, imperial.radius = "imperial", 100.0 + assert ap.radius_in_nm(metric) == pytest.approx(100 / 1.852, rel=1e-6) + assert ap.radius_in_nm(imperial) == pytest.approx(100 / 1.150779, + rel=1e-6) + assert ap.radius_in_nm(imperial) > ap.radius_in_nm(metric) + + +def test_the_unit_the_window_measures_in_follows_the_one_being_shown(): + """The scale along the bottom and the rings read off the same setting + as the tables, so the window cannot disagree with the report.""" + options = ap.defaults() + options.units = "metric" + assert ap.map_unit(options) == "kph" + options.units = "imperial" + assert ap.map_unit(options) == "mph" + + def test_the_box_gives_the_position_in_figures(): """A mark shows where a station is; a number is what gets written down.""" rows = dict((label, value) for label, value, _f diff --git a/tests/test_basemap.py b/tests/test_basemap.py index bbee9bb..218a44a 100644 --- a/tests/test_basemap.py +++ b/tests/test_basemap.py @@ -215,8 +215,9 @@ def counting_fetcher(answers=None, fail_on=()): def test_the_tiles_are_stitched_in_the_right_places(): fetch = counting_fetcher() - raster, ox, oy = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=fetch, - pause=0) + raster, ox, oy, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, + fetch=fetch, pause=0) + assert covered.all() assert raster is not None assert raster.shape[2] == 3 assert raster.shape[0] % bm.TILE_PIXELS == 0 @@ -233,28 +234,33 @@ def test_a_tile_that_does_not_arrive_leaves_a_hole_rather_than_an_error(): seen = counting_fetcher() bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=seen, pause=0) missing = seen.asked[:1] - raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, - fetch=counting_fetcher(fail_on=missing), pause=0) + raster, _, _, covered = bm.mosaic( + 47.0, -122.8, 48.5, -121.0, 9, + fetch=counting_fetcher(fail_on=missing), pause=0, retry_pause=0) assert raster is not None assert not raster[:bm.TILE_PIXELS, :bm.TILE_PIXELS].any() # the hole + # And the hole is reported rather than left to be mistaken for map. + assert not covered[0, 0] and covered.sum() == covered.size - 1 def test_nothing_at_all_gives_no_map(): def nothing(z, x, y, **kw): return None - raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=nothing, - pause=0) + raster, _, _, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, + fetch=nothing, pause=0, retry_pause=0) assert raster is None + assert not covered.any() def test_a_corrupt_tile_is_skipped(): def rubbish(z, x, y, **kw): return b"not a png" - raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=rubbish, - pause=0) + raster, _, _, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, + fetch=rubbish, pause=0, retry_pause=0) assert raster is None + assert not covered.any() # --------------------------------------------------------------------------- @@ -269,14 +275,197 @@ def gradient_tile(z, x, y, **kw): def test_the_ground_comes_back_as_levels_the_map_can_paint(): - levels = bm.ground_under(47.0, -122.8, 48.5, -121.0, 200, 150, - shades=32, fetch=gradient_tile, pause=0) + levels, _settled = bm.ground_under(47.0, -122.8, 48.5, -121.0, 200, 150, + shades=32, fetch=gradient_tile, + pause=0) assert levels is not None assert levels.shape == (150, 200) assert levels.dtype == np.uint8 assert levels.max() <= 31 +def _patchy(fail_on, note=None): + """A tile server that refuses some squares. What a busy one does.""" + def fetch(z, x, y, **kw): + if note is not None: + note.append((z, x, y)) + if (x, y) in fail_on: + return None + # Something to see, and different in each tile, so that a hole is + # distinguishable from map rather than from a flat field. + return solid(40 if (x + y) % 3 == 0 else 230) + + return fetch + + +BOX = (47.0, -123.0, 48.0, -122.0) + + +def _hole_at(): + """The grid position of one tile inside BOX, worked out here rather + than asked of the code whose arithmetic is under test elsewhere.""" + zoom = bm.choose_zoom(*BOX, width=400) + x0, y0 = bm.tile_of(BOX[2], BOX[1], zoom) + return zoom, (int(x0) + 1, int(y0) + 1) + + +def _hole_mask(hole, zoom, width=400, height=400, grow=0): + """Which output pixels the missing tile covers. + + Worked out from the tile's own corners rather than from the picture, so + that a test of what is drawn in the gap is not asking the code that drew + it where the gap is. ``grow`` widens it, for the band along the edge + where a cell is averaged over part of a tile and part of nothing. + """ + south, west, north, east = tile_bounds(zoom, hole[0], hole[1]) + top = (BOX[2] - min(north, BOX[2])) / (BOX[2] - BOX[0]) * height + bottom = (BOX[2] - max(south, BOX[0])) / (BOX[2] - BOX[0]) * height + left = (max(west, BOX[1]) - BOX[1]) / (BOX[3] - BOX[1]) * width + right = (min(east, BOX[3]) - BOX[1]) / (BOX[3] - BOX[1]) * width + mask = np.zeros((height, width), dtype=bool) + mask[max(0, int(top) - grow):int(bottom) + 1 + grow, + max(0, int(left) - grow):int(right) + 1 + grow] = True + return mask + + +def test_a_missing_tile_is_a_gap_rather_than_the_brightest_thing_on_the_map(): + """The canvas starts black and the brightness is inverted further down, + so a square that never arrived used to come out as the brightest thing + on the picture: a glowing rectangle where the map should be.""" + zoom, hole = _hole_at() + whole, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()), pause=0, + zoom=zoom) + holed, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}), + pause=0, retry_pause=0, zoom=zoom) + assert whole is not None and holed is not None + gap = _hole_mask(hole, zoom) + assert gap.sum() > 1000, "the hole is not where this test thinks it is" + # There was map there, and now there is nothing -- rather than the + # brightest shade the picture has. + assert whole[gap].max() > 0 + assert holed[_hole_mask(hole, zoom, grow=-1)].max() == 0 + # And what leaks in is a hairline round the edge, where a cell is + # averaged over part of a tile and part of nothing, rather than a + # fraction of the hole: the averaging weighs the real pixels only. + assert int((holed[gap] > 0).sum()) < gap.sum() // 100 + assert holed.max() == whole.max(), "the map lost its brightest shade" + + +def test_a_missing_tile_does_not_dim_the_rest_of_the_map(): + """Black is darker than any real tile, so counting a hole when working + out the darkest and brightest of what was fetched drags the floor down, + and every other pixel is drawn dimmer to make room for a square that is + not there.""" + zoom, hole = _hole_at() + whole, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()), pause=0, + zoom=zoom) + holed, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}), + pause=0, retry_pause=0, zoom=zoom) + # Outside the gap and the averaged band along its edge, the map is the + # map: the hole took nothing else with it. + elsewhere = ~_hole_mask(hole, zoom, grow=2) + assert elsewhere.sum() > holed.size // 2 + assert bool((holed[elsewhere] == whole[elsewhere]).all()) + + +def test_a_map_with_squares_missing_says_it_is_not_the_whole_answer(): + """Most of a map is worth drawing and is not worth keeping: the caller + has to be able to tell the two cases apart to know whether to ask + again.""" + _zoom, hole = _hole_at() + whole, settled = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()), + pause=0) + assert whole is not None and settled + holed, settled = bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}), + pause=0, retry_pause=0) + assert holed is not None and not settled + + +def test_a_tile_that_fails_once_is_asked_for_again(): + """The usual reason for a hole is that a hundred tiles were asked for + in the preceding second, which is a server asking to be slowed down + rather than a tile that is not there.""" + _zoom, hole = _hole_at() + refused = {"left": 1} + + def flaky(z, x, y, **kw): + if (x, y) == hole and refused["left"]: + refused["left"] -= 1 + return None + return solid(40 if (x + y) % 3 == 0 else 230) + + whole, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()), pause=0) + healed, settled = bm.ground_under(*BOX, 400, 400, fetch=flaky, pause=0, + retry_pause=0) + assert refused["left"] == 0, "the tile was never asked for a second time" + assert settled, "a recovered map is the whole answer" + assert bool((healed == whole).all()) + + +def test_only_the_tiles_that_failed_are_asked_for_again(): + """A retry that fetched the lot again would treat a busy server by + asking it for everything twice.""" + _zoom, hole = _hole_at() + asked = [] + bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}, note=asked), + pause=0, retry_pause=0) + twice = [where for where in set(asked) if asked.count(where) > 1] + assert len(twice) == 1 and twice[0][1:] == hole + + +def test_retrying_can_be_turned_off(): + _zoom, hole = _hole_at() + asked = [] + bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}, note=asked), + pause=0, retries=0) + assert len(asked) == len(set(asked)) + + +def test_only_a_tile_that_had_to_be_fetched_costs_politeness(monkeypatch): + """The pause is an apology to a volunteer-funded server, and a file + that was already on the disk was never asked of it. Paying it anyway + is how a view whose tiles are all in hand takes half a minute to + redraw -- and how asking again for three missing squares costs the + wait for two hundred that are not missing.""" + slept = [] + monkeypatch.setattr(bm.time, "sleep", lambda s: slept.append(s)) + zoom, _hole = _hole_at() + + _r, _x, _y, covered = bm.mosaic(*BOX, zoom, fetch=_patchy(()), + pause=0.05, + cached=lambda z, x, y, **kw: False) + assert len(slept) == int(covered.sum()) > 1 + + slept.clear() + bm.mosaic(*BOX, zoom, fetch=_patchy(()), pause=0.05, + cached=lambda z, x, y, **kw: True) + assert slept == [] + + +def test_asking_again_for_what_is_missing_does_not_wait_for_what_is_not( + monkeypatch): + """Which is what makes a second ask affordable at all: the tiles that + arrived the first time are on the disk, so the only thing paid for is + the handful that did not.""" + slept = [] + monkeypatch.setattr(bm.time, "sleep", lambda s: slept.append(s)) + zoom, hole = _hole_at() + # Everything arrived but the one square, so everything but that square + # is now on the disk. + bm.mosaic(*BOX, zoom, fetch=_patchy({hole}), pause=0.05, retries=0, + cached=lambda z, x, y, **kw: (x, y) != hole) + assert len(slept) <= 1, slept + + +def test_a_tile_is_on_the_disk_once_it_has_been_fetched(tmp_path): + zoom, hole = _hole_at() + assert not bm.on_disk(zoom, hole[0], hole[1], cache=tmp_path) + where = tmp_path / str(zoom) / str(hole[0]) + where.mkdir(parents=True) + (where / f"{hole[1]}.png").write_bytes(solid(200)) + assert bm.on_disk(zoom, hole[0], hole[1], cache=tmp_path) + + def tile_bounds(zoom: int, x: int, y: int): """The corners of one tile, from the inverse of the standard formula. @@ -298,7 +487,8 @@ def test_the_map_is_inverted_so_that_ink_shows_on_a_dark_picture(): """A printed map is dark ink on white paper; this picture is the other way round, so the dark parts of a tile are the bright parts here.""" south, west, north, east = tile_bounds(9, 81, 178) - levels = bm.ground_under(south, west, north, east, 64, 64, shades=32, + levels, _settled = bm.ground_under(south, west, north, east, 64, 64, + shades=32, fetch=gradient_tile, zoom=9, pause=0) # The tile is black at the top and white at the bottom, so the picture # has to be bright at the top and dark at the bottom. @@ -313,7 +503,8 @@ def test_north_is_at_the_top(): return make_png(px) south, west, north, east = tile_bounds(9, 81, 178) - levels = bm.ground_under(south, west, north, east, 40, 40, shades=32, + levels, _settled = bm.ground_under(south, west, north, east, 40, 40, + shades=32, fetch=half_and_half, zoom=9, pause=0) assert levels[0].mean() < levels[-1].mean() # white inverts to dark @@ -325,7 +516,8 @@ def test_east_is_to_the_right(): return make_png(px) south, west, north, east = tile_bounds(9, 81, 178) - levels = bm.ground_under(south, west, north, east, 40, 40, shades=32, + levels, _settled = bm.ground_under(south, west, north, east, 40, 40, + shades=32, fetch=half_and_half, zoom=9, pause=0) assert levels[:, 0].mean() < levels[:, -1].mean() @@ -338,7 +530,8 @@ def test_one_tile_covers_its_own_box_exactly(): return make_png(px) south, west, north, east = tile_bounds(9, 81, 178) - levels = bm.ground_under(south, west, north, east, 128, 128, shades=32, + levels, _settled = bm.ground_under(south, west, north, east, 128, 128, + shades=32, fetch=corner_marks, zoom=9, pause=0) dark = levels < levels.max() / 2 # the white corner, inverted assert dark[:4, :4].all() @@ -349,13 +542,18 @@ def test_no_tiles_means_no_ground_rather_than_an_exception(): def nothing(z, x, y, **kw): return None - assert bm.ground_under(47.0, -122.8, 48.5, -121.0, 50, 50, - fetch=nothing, pause=0) is None + levels, settled = bm.ground_under(47.0, -122.8, 48.5, -121.0, 50, 50, + fetch=nothing, pause=0, retry_pause=0) + assert levels is None + # Settled on purpose: a machine with no network must not spend the + # night asking for tiles it is never going to be given. + assert settled def test_a_box_that_makes_no_sense_is_refused_quietly(): - assert bm.ground_under(48.0, -122.0, 47.0, -123.0, 50, 50, - fetch=gradient_tile, pause=0) is None + levels, settled = bm.ground_under(48.0, -122.0, 47.0, -123.0, 50, 50, + fetch=gradient_tile, pause=0) + assert levels is None and settled # --------------------------------------------------------------------------- @@ -655,7 +853,8 @@ def test_a_cell_smaller_than_a_source_pixel_takes_that_pixel(): 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, + levels, _settled = 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" @@ -664,16 +863,18 @@ def test_a_gradient_still_comes_out_as_a_gradient(): 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) + levels, _settled = 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, + levels, _settled = 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_livemap.py b/tests/test_livemap.py index 34be38e..672c67e 100644 --- a/tests/test_livemap.py +++ b/tests/test_livemap.py @@ -363,6 +363,123 @@ def test_a_map_that_could_not_be_fetched_is_not_asked_for_again(): assert sky.wanted_ground() is None +def test_a_map_with_squares_missing_is_asked_for_again(): + """A resize picks a sharper zoom and asks for a hundred tiles that have + never been on this disk at once, and a busy server refuses some of + them. Most of a map gets drawn; it does not get kept.""" + from bandsaunter.livemap import GROUND_RETRY_S + + sky = a_sky() + sky.want_ground("a", (0, 0, 1, 1), (10, 10)) + sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1), + settled=False) + assert sky.wanted_ground() is None, "not before the server has a rest" + assert not sky.ground_settled() + sky.reask_ground() + assert sky.wanted_ground() is None, "still too soon" + sky._ground_at -= GROUND_RETRY_S + 1 + sky.reask_ground() + # Asked for again, and for the map it already asked for rather than for + # whatever the view has drifted to since. + assert sky.wanted_ground() == ("a", (0, 0, 1, 1), (10, 10)) + + +def test_a_whole_map_is_never_asked_for_again(): + sky = a_sky() + sky.want_ground("a", (0, 0, 1, 1), (10, 10)) + sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1)) + assert sky.ground_settled() + sky._ground_at -= 10_000.0 + sky.reask_ground() + assert sky.wanted_ground() is None + + +def test_a_square_that_is_missing_for_good_is_not_asked_for_all_night(): + """A tile can be absent because there is no such tile. Asking for it + every half minute until morning is the same discourtesy more slowly.""" + from bandsaunter.livemap import GROUND_RETRY_S, GROUND_TRIES + + sky = a_sky() + sky.want_ground("a", (0, 0, 1, 1), (10, 10)) + asks = 0 + for _ in range(GROUND_TRIES + 5): + sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1), + settled=False) + sky._ground_at -= GROUND_RETRY_S + 1 + sky.reask_ground() + if sky.wanted_ground() is not None: + asks += 1 + sky._wanted = None + assert asks == GROUND_TRIES - 1, asks + + +def test_moving_the_view_starts_the_asking_over(): + """The count is against one view's tiles. A window that was resized + twice has not used up its patience on the second view.""" + from bandsaunter.livemap import GROUND_RETRY_S, GROUND_TRIES + + sky = a_sky() + for _ in range(GROUND_TRIES + 2): + sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1), + settled=False) + sky.want_ground("b", (0, 0, 2, 2), (20, 20)) + sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "b", (0, 0, 2, 2), + settled=False) + sky._wanted = None + sky._ground_at -= GROUND_RETRY_S + 1 + sky.reask_ground() + assert sky.wanted_ground() is not None + + +@qt +def test_drawing_most_of_a_map_draws_it_and_asks_for_the_rest(app): + """Both halves: the window does not go bare while it waits, and it does + not settle for the map it was given.""" + from bandsaunter.livemap import GROUND_RETRY_S, SkyView + + sky = a_sky(a_blip()) + view = SkyView(sky) + view.resize(900, 650) + view._draw_ground(_NoPainter(), view.projection()) # asks + wanted = sky.wanted_ground() + assert wanted is not None + key, box, _size = wanted + sky.set_ground(np.full((760, 1050), 12, dtype=np.uint8), key, box, + settled=False) + + drawn = [] + + class _Painter(_NoPainter): + def drawImage(self, *a): + drawn.append(a) + + sky._ground_at -= GROUND_RETRY_S + 1 + view._draw_ground(_Painter(), view.projection()) + assert drawn, "most of a map is still worth drawing" + assert sky.wanted_ground() is not None, "and worth finishing" + + +@qt +def test_drawing_a_whole_map_asks_for_nothing(app): + from bandsaunter.livemap import SkyView + + sky = a_sky(a_blip()) + view = SkyView(sky) + view.resize(900, 650) + view._draw_ground(_NoPainter(), view.projection()) + key, box, _size = sky.wanted_ground() + sky.set_ground(np.full((760, 1050), 12, dtype=np.uint8), key, box) + sky._wanted = None + + class _Painter(_NoPainter): + def drawImage(self, *a): + pass + + sky._ground_at -= 10_000.0 + view._draw_ground(_Painter(), view.projection()) + assert sky.wanted_ground() is None + + # --------------------------------------------------------------------------- # The map staying still # ---------------------------------------------------------------------------