Files
StarPilot/openpilot/selfdrive/navigation/tests/test_navigation.py
T
2026-10-20 02:19:24 +00:00

486 lines
26 KiB
Python

import gzip
import re
import struct
import tempfile
import threading
import time
import unittest
from contextlib import closing, contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import numpy as np
import requests
import openpilot.cereal.messaging as messaging
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.navigation.mapsd import MapsD
from openpilot.selfdrive.navigation.navd import REROUTE_COUNTER_MIN, ROUTE_RESEND_PERIOD, RouteEngine, set_banner
from openpilot.selfdrive.navigation.pmtiles import PMTiles, decode_directory, tile_id
from openpilot.selfdrive.navigation.renderer import MAJOR, MEDIUM, MINOR, SIZE, STREET, TRACK, MapRenderer, draw_segments, mercator, project, stroke_widths
from openpilot.selfdrive.navigation.route import RouteTracker
from openpilot.selfdrive.navigation.tiles import RemoteTiles
from openpilot.selfdrive.navigation.vector_tile import decode_lines, decode_roads
METER = 1 / 111319.49079327358 # degrees of latitude
def draw_line(image, points, intensity, width, **kwargs):
draw_segments(image, points[:-1], points[1:], width / 2, intensity, **kwargs)
def varint(value):
output = bytearray()
while value > 127:
output.append((value & 127) | 128)
value >>= 7
output.append(value)
return bytes(output)
def field(number, value):
if isinstance(value, bytes):
return varint(number << 3 | 2) + varint(len(value)) + value
return varint(number << 3) + varint(value)
def road_tile(crossing=None):
# One vertical line, from (2048, 0) to (2048, 4096), and one non-road layer. With crossing='bridge' or 'tunnel',
# a horizontal line from (1024, 2048) to (3072, 2048) at that level.
geometry = b''.join(varint(v) for v in [9, 4096, 0, 10, 0, 8192])
feature = field(2, b'\x00\x00') + field(3, 2) + field(4, geometry)
roads = field(15, 2) + field(1, b'roads') + field(2, feature)
if crossing is not None:
geometry = b''.join(varint(v) for v in [9, 2048, 4096, 10, 4096, 0])
roads += field(2, field(2, b'\x00\x00\x01\x01') + field(3, 2) + field(4, geometry))
roads += field(3, b'kind') + field(3, f'is_{crossing}'.encode()) + field(4, field(1, b'minor_road')) + field(4, field(7, 1)) + field(5, 4096)
return field(3, field(1, b'buildings')) + field(3, roads)
def make_archive(payload=None, leaf=False, run=1, max_zoom=0):
"""A PMTiles archive with one zoom-zero tile, in the root directory or in a leaf directory."""
tile = gzip.compress(road_tile() if payload is None else payload)
directory = gzip.compress(varint(1) + varint(0) + varint(run) + varint(len(tile)) + varint(1))
child = directory if leaf else b''
root = gzip.compress(varint(1) + varint(0) + varint(0) + varint(len(child)) + varint(1)) if leaf else directory
metadata = gzip.compress(b'{"vector_layers":[{"id":"roads"}]}')
header = bytearray(127)
header[:8] = b'PMTiles\x03'
offsets = [127, 127 + len(root), 127 + len(root) + len(metadata), 127 + len(root) + len(metadata) + len(child)]
struct.pack_into('<11Q', header, 8, offsets[0], len(root), offsets[1], len(metadata), offsets[2], len(child), offsets[3], len(tile), 1, 1, 1)
header[96:102] = bytes([1, 2, 2, 1, 0, max_zoom])
return bytes(header + root + metadata + child + tile)
@contextmanager
def local_archive(data):
"""A PMTiles reader over in-memory bytes, bypassing the tile server."""
source = MagicMock(size=len(data))
source.read.side_effect = lambda offset, length: data[offset:offset + length]
with closing(PMTiles(source)) as archive:
yield archive
def mapbox_response(steps):
response = MagicMock(status_code=200)
response.json.return_value = {'routes': [{'legs': [{'steps': steps}]}]}
return response
def route_engine(sm, pm, values):
params = MagicMock()
params.get.side_effect = values.get
params.remove.side_effect = lambda key: values.pop(key, None)
with patch('openpilot.selfdrive.navigation.navd.Api') as api:
api.return_value.get_token.return_value = 'test-token'
return RouteEngine(sm, pm, params=params)
class RangeHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.server.requests.append(dict(self.headers))
if self.headers.get('If-Match', self.server.etag) != self.server.etag:
self.send_error(412)
return
if self.server.ignore_range:
self.send_response(200)
self.end_headers()
return
offset, end = map(int, re.fullmatch(r'bytes=(\d+)-(\d+)', self.headers['Range']).groups())
data = self.server.archive[offset:end + 1]
self.send_response(206)
self.send_header('Content-Range', f'bytes {offset}-{end}/{len(self.server.archive)}')
self.send_header('Content-Length', str(len(data)))
self.send_header('ETag', self.server.etag)
self.end_headers()
self.wfile.write(data)
def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override
pass
class TileServerCase(unittest.TestCase):
"""Serves a leaf-directory archive over HTTP range requests, with the comma API and tile cache stubbed."""
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.cache = Path(self.directory.name) / 'navigation' / 'tiles'
self.server = ThreadingHTTPServer(('127.0.0.1', 0), RangeHandler)
self.server.archive = make_archive(leaf=True)
self.server.etag = '"test-version"'
self.server.ignore_range = False
self.server.requests = []
threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True).start()
self.addCleanup(self.server.server_close)
self.addCleanup(self.server.shutdown)
self.api = MagicMock(dongle_id='test-dongle')
self.api.get_token.return_value = 'device-token'
self.url = f'http://127.0.0.1:{self.server.server_port}/planet.pmtiles?sig=secret'
self.api.get.return_value.json.return_value = {'url': self.url, 'expires': time.time() + 86400} # noqa: TID251
for name, value in [('Api', MagicMock(return_value=self.api)), ('Params', MagicMock()), ('PC', True)]:
self.enterContext(patch(f'openpilot.selfdrive.navigation.tiles.{name}', value))
self.enterContext(patch('openpilot.selfdrive.navigation.tiles.Paths.comma_home', return_value=self.directory.name))
class TestEndToEnd(TileServerCase):
def test_navd_to_map_frames(self):
"""navd routes a destination over messaging; mapsd fetches tiles over HTTP and renders map frames."""
destination = {'latitude': 0.01, 'longitude': 0}
self.enterContext(patch('requests.get', return_value=mapbox_response([
{'geometry': {'coordinates': [[0, -0.01], [0, 0.01]]}, 'distance': 2224, 'duration': 100, 'bannerInstructions': []}])))
with OpenpilotPrefix():
pm = messaging.PubMaster(['navInstruction', 'navRoute', 'gpsLocationExternal'])
sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal', 'navRoute'])
values = {'NavDestination': dict(destination)}
navd = route_engine(messaging.SubMaster(['gpsLocation', 'gpsLocationExternal']), pm, values)
mapsd = MapsD()
self.addCleanup(mapsd.close)
def frame(fix=True):
gps = messaging.new_message('gpsLocationExternal', valid=True)
gps.gpsLocationExternal.hasFix = fix
pm.send('gpsLocationExternal', gps)
navd.update()
sm.update(0)
return mapsd.update(sm)
image, timestamp = frame()
# The route is centered over the white road, with the ego arrow three quarters down.
self.assertEqual((image[128, 128], image[128, 131], image[188, 128], image[195, 128]), (128, 255, 255, 0))
self.assertEqual(timestamp, sm.logMonoTime['gpsLocationExternal'])
fetched = len(self.server.requests)
tracker = mapsd.tracker
navd.send_route() # the same route again, as a restarted mapsd would get it
frame()
self.assertIs(mapsd.tracker, tracker) # progress along it is kept
values.pop('NavDestination')
self.assertFalse(frame()[0].any()) # black without a route
values['NavDestination'] = dict(destination)
self.assertEqual(frame()[0][128, 128], 128)
self.assertEqual(len(self.server.requests), fetched) # the second route is served from the cache
image, timestamp = frame(fix=False)
self.assertFalse(image.any() or timestamp) # black, not stale, without a fix
self.api.get.assert_called_once()
self.assertTrue(all('Range' in headers for headers in self.server.requests))
self.assertTrue(all(headers['If-Match'] == self.server.etag for headers in self.server.requests[1:]))
self.assertTrue(all(b'secret' not in p.read_bytes() for p in self.cache.iterdir()))
# The archive is replaced on the server and the cache evicted: the next fetch fails, the map goes black,
# and after a delay a fresh reader starts over on the new version.
self.server.etag = '"new-version"'
for cached in self.cache.iterdir():
cached.unlink()
values.pop('NavDestination')
frame()
values['NavDestination'] = dict(destination)
image, timestamp = frame()
self.assertFalse(image.any() or timestamp)
fetched = len(self.server.requests)
self.assertFalse(frame()[1]) # not retried yet
self.assertEqual(len(self.server.requests), fetched)
mapsd.retry_at = 0
self.assertEqual(frame()[0][128, 128], 128)
self.assertEqual((self.cache / 'version').read_text().split()[0], '"new-version"')
class TestRemoteTiles(TileServerCase):
def test_shared_cache_and_refresh(self):
with closing(PMTiles(RemoteTiles())) as archive:
archive.source.expires = 0 # the signed URL is refreshed before the next fetch
self.assertEqual(archive.get(0, 0, 0), road_tile())
self.assertEqual(archive.get(0, 0, 0), road_tile())
self.assertEqual(self.api.get.call_count, 2)
count = len(self.server.requests)
with closing(PMTiles(RemoteTiles())) as archive:
self.assertEqual(archive.get(0, 0, 0), road_tile())
# A second reader revalidates only the header; directories and tiles are shared on disk.
self.assertEqual(len(self.server.requests), count + 1)
with patch('openpilot.selfdrive.navigation.tiles.CACHE_LIMIT', 1), closing(RemoteTiles()) as source:
source.read(1, 2) # an uncached range is downloaded, then the cache is trimmed to the limit
self.assertEqual([p.name for p in self.cache.iterdir()], ['version'])
def test_prefetch_merges_nearby_ranges(self):
with patch('openpilot.selfdrive.navigation.tiles.MERGE_GAP', 16), closing(RemoteTiles()) as source:
count = len(self.server.requests)
source.prefetch([(20, 10), (0, 10), (60, 10), (20, 10)])
self.assertEqual(len(self.server.requests), count + 2) # 0-29 in one request, 60-69 in another
self.assertEqual(self.server.requests[-2]['Range'], 'bytes=0-29')
for offset in (0, 20, 60):
self.assertEqual(source.read(offset, 10), self.server.archive[offset:offset + 10])
self.assertEqual(len(self.server.requests), count + 2)
def test_failures(self):
with closing(PMTiles(RemoteTiles())) as archive:
self.server.etag = '"new-version"'
with self.assertRaisesRegex(OSError, '412'): # the archive changed under an open reader
archive.get(0, 0, 0)
with closing(PMTiles(RemoteTiles())) as archive: # a new reader starts over on the new version
self.assertEqual(archive.get(0, 0, 0), road_tile())
self.server.ignore_range = True
with self.assertRaisesRegex(OSError, '200'):
RemoteTiles()
self.api.get.return_value.raise_for_status.side_effect = requests.HTTPError(self.url)
with self.assertRaisesRegex(OSError, '^Unable to fetch map tiles$'): # the signed URL stays out of the error
RemoteTiles()
class TestRouteEngine(unittest.TestCase):
def setUp(self):
self.pm = MagicMock()
self.values = {'NavDestination': {'latitude': 0, 'longitude': 0.002}, 'LanguageSetting': 'main_en'}
self.sm = MagicMock()
self.sm.valid = dict.fromkeys(('gpsLocation', 'gpsLocationExternal'), True)
self.sm.logMonoTime = dict.fromkeys(self.sm.valid, time.monotonic_ns())
self.sm.recv_time = dict.fromkeys(self.sm.valid, time.monotonic())
self.sm.__getitem__.return_value = SimpleNamespace(hasFix=True, latitude=0, longitude=0, bearingDeg=90)
self.engine = route_engine(self.sm, self.pm, self.values)
self.engine.last_position, self.engine.last_bearing = (0, 0), 90
distance = 111.195 # Mapbox's length of 0.001 degrees at the equator
self.response = mapbox_response([
{'geometry': {'coordinates': [[0, 0], [0.0005, 0], [0.001, 0]]}, 'distance': distance, 'duration': 10,
'bannerInstructions': [{'distanceAlongGeometry': 100, 'primary': {'text': 'Turn right', 'type': 'turn', 'modifier': 'right'}}]},
{'geometry': {'coordinates': [[0.001, 0], [0.002, 0]]}, 'distance': distance, 'duration': 10, 'bannerInstructions': []},
])
def last_message(self, service):
return next(call.args[1] for call in reversed(self.pm.send.call_args_list) if call.args[0] == service)
def load_route(self):
with patch('requests.get', return_value=self.response) as get:
self.engine.calculate_route((0.002, 0))
self.engine.send_route()
return get
def test_request_and_instruction(self):
get = self.load_route()
self.assertEqual(get.call_args.args[0], 'https://api.commadotai.com/v1/nav/directions/v5/mapbox/driving-traffic/0,0;0.002,0')
options = get.call_args.kwargs['params']
self.assertEqual((options['access_token'], options['bearings'], options['language']), ('test-token', '90,90;', 'en'))
self.assertEqual(len(self.last_message('navRoute').navRoute.coordinates), 5)
self.engine.send_instruction()
message = self.last_message('navInstruction')
self.assertTrue(message.valid)
instruction = message.navInstruction.to_dict()
self.assertEqual((instruction['maneuverPrimaryText'], instruction['maneuverModifier'], instruction['timeRemaining']), ('Turn right', 'right', 20))
self.engine.last_position = (0.0007, 0) # on the step's second segment
self.engine.send_instruction()
self.assertAlmostEqual(self.last_message('navInstruction').navInstruction.timeRemaining, 13, delta=0.05)
self.engine.last_position = (0.001, 0) # at the maneuver
self.engine.send_instruction()
self.assertAlmostEqual(self.last_message('navInstruction').navInstruction.maneuverDistance, 0, delta=0.01)
def test_banner_selection(self):
banners = [{'distanceAlongGeometry': 100, 'primary': {'text': 'Turn right', 'type': 'turn', 'modifier': 'right'}},
{'distanceAlongGeometry': 50, 'primary': {'text': 'Turn right'}, 'secondary': {'text': 'onto Main St'}}]
# The last banner still ahead of the ego, else the first: 120 m, 70 m and 30 m from the maneuver.
for distance, expected in [(120, ('Turn right', '', False)), (70, ('Turn right', '', True)), (30, ('Turn right', 'onto Main St', True))]:
instruction = messaging.new_message('navInstruction').navInstruction
set_banner(instruction, banners, distance)
self.assertEqual((instruction.maneuverPrimaryText, instruction.maneuverSecondaryText, instruction.showFull), expected)
def test_route_sent_on_change_and_periodically(self):
sent = lambda: sum(call.args[0] == 'navRoute' for call in self.pm.send.call_args_list) # noqa: E731
self.sm.frame = 1
with patch('requests.get', return_value=self.response):
self.engine.update() # the route to a new destination is sent once
self.engine.update()
self.assertEqual(sent(), 1)
self.sm.frame = ROUTE_RESEND_PERIOD # and again periodically, for a mapsd that started after it
self.engine.update()
self.assertEqual(sent(), 2)
def test_maneuvers_and_rerouting(self):
self.load_route()
self.engine.last_position = (0, 0.001) # 111 m off route: reroute after a few consecutive misses
for _ in range(REROUTE_COUNTER_MIN):
self.assertFalse(self.engine.should_recompute())
self.assertTrue(self.engine.should_recompute())
with patch('requests.get') as get:
self.engine.recompute_route(gps_ok=False) # but not while the GPS drifts in a tunnel
get.assert_not_called()
with patch('requests.get', return_value=self.response) as get:
self.engine.recompute_route(gps_ok=True)
self.engine.reroute_counter = REROUTE_COUNTER_MIN # still off route, but an immediate second recompute is held back
self.engine.recompute_route(gps_ok=True)
self.assertEqual(get.call_count, 1)
self.engine.last_position = (0.0012, 0) # past the first maneuver
self.engine.send_instruction()
self.assertEqual(self.engine.step_idx, 1)
self.engine.last_position = (0.00215, 0) # 17 m past the destination: arrived, but the route is kept nearby
self.engine.send_instruction()
self.assertIsNotNone(self.engine.route)
self.engine.last_position = (0.0024, 0) # driving away from the destination
self.engine.send_instruction()
self.assertIsNone(self.engine.route)
self.assertNotIn('NavDestination', self.values)
def test_failed_request_clears_route(self):
self.load_route()
self.response.raise_for_status.side_effect = requests.HTTPError('test failure')
self.load_route()
self.assertIsNone(self.engine.route)
self.assertEqual(len(self.last_message('navRoute').navRoute.coordinates), 0)
self.engine.send_instruction()
self.assertFalse(self.last_message('navInstruction').valid)
def test_gps_source(self):
fixes = {'gpsLocation': SimpleNamespace(hasFix=True, latitude=1, longitude=1, bearingDeg=180),
'gpsLocationExternal': SimpleNamespace(hasFix=True, latitude=2, longitude=2, bearingDeg=0)}
self.sm.__getitem__.side_effect = fixes.get
self.sm.logMonoTime = {'gpsLocation': 2, 'gpsLocationExternal': 1} # replayed logs are judged by receipt time
with patch('requests.get', return_value=self.response):
self.engine.update()
self.assertEqual((self.engine.last_position, self.engine.last_bearing), ((1, 1), 180)) # the newest fix wins
self.sm.valid['gpsLocation'] = False
self.engine.update()
self.assertEqual(self.engine.last_position, (2, 2)) # unless it is invalid
fixes['gpsLocationExternal'] = SimpleNamespace(hasFix=True, latitude=3, longitude=3, bearingDeg=0)
self.sm.recv_time = dict.fromkeys(self.sm.valid, time.monotonic() - 3) # or stale
self.engine.update()
self.assertEqual(self.engine.last_position, (2, 2))
class TestTiles(unittest.TestCase):
def test_pmtiles_format(self):
self.assertEqual([tile_id(1, x, y) for x, y in [(0, 0), (0, 1), (1, 1), (1, 0)]], [1, 2, 3, 4])
self.assertEqual(sorted(tile_id(3, x, y) for x in range(8) for y in range(8)), list(range(21, 85)))
# IDs 5, 10; runs 3, 1; lengths 4, 6; the second offset follows the first blob.
data = bytes([2, 5, 5, 3, 1, 4, 6, 1, 0])
self.assertEqual(decode_directory(data), ([5, 10], [(3, 4, 0), (1, 6, 4)]))
for invalid in (b'\x80', b'\x01\x00\x01\x01\x00', data + b'\x00'):
with self.assertRaises(ValueError):
decode_directory(invalid)
with local_archive(make_archive(run=3, max_zoom=1)) as archive: # the zoom-zero tile is also addressed by IDs 1 and 2
self.assertEqual(archive.get(1, 0, 1), road_tile())
self.assertIsNone(archive.get(1, 1, 1))
with self.assertRaises(ValueError), local_archive(make_archive()[:-1]):
pass
def test_vector_tiles(self):
roads = decode_roads(road_tile())
self.assertEqual((len(roads), roads[0].kind), (1, 'minor_road'))
np.testing.assert_array_equal(roads[0].points, [[0.5, 0], [0.5, 1]])
lines = decode_lines([9, 20, 20, 10, 10, 0, 9, 9, 9, 10, 0, 10])
np.testing.assert_array_equal(lines[0], [[10, 10], [15, 10]])
np.testing.assert_array_equal(lines[1], [[10, 5], [10, 10]])
for data in (b'\x1a\xff', b'\x00', b'\x1a\x02\x0a\x04'):
with self.assertRaises(ValueError):
decode_roads(data)
for commands in ([10, 1, 1], [9, 0], [9, 0, 0, 15]):
with self.assertRaises(ValueError):
decode_lines(commands)
class TestRendering(unittest.TestCase):
def test_projection(self):
# At the equator, 64m ahead is the image center and the car is 64px below it, at 1m per pixel.
points = mercator(np.array([[0, 0], [0, 64 * METER], [64 * METER, 0]]))
np.testing.assert_allclose(project(points, 0, 0, 0), [[128, 192], [128, 128], [192, 192]], atol=1e-5)
np.testing.assert_allclose(project(points, 0, 0, 90), [[128, 192], [64, 192], [128, 128]], atol=1e-5)
pixels = project(mercator(np.array([[-179.999, 0]])), 179.999, 0, 90) # across the antimeridian
self.assertAlmostEqual(pixels[0, 1], 192 - 222.6389816, places=5)
def test_strokes(self):
image = np.zeros((SIZE, SIZE), dtype=np.uint8)
draw_line(image, np.array([[-1000, 128], [1000, 128]]), 255, 8)
self.assertTrue(np.all(image[128] == 255))
draw_line(image, np.array([[128, 0], [128, 256]]), 128, 5, overlay=True) # the grey route replaces the white road
self.assertEqual((image[128, 128], image[128, 131]), (128, 255))
before = image.copy()
draw_line(image, np.array([[-1000, -1000], [-500, -500]]), 255, 5)
np.testing.assert_array_equal(image, before)
draw_line(image, np.array([[40, 40], [40, 40]]), 255, 5) # a zero-length segment is still a dot
self.assertEqual(image[40, 40], 255)
def test_crossings(self):
route = np.array([[0.01, 0], [0.01, 0.01]]) # off to the side, out of the way of the probes
images = {}
for crossing in ('bridge', 'tunnel'):
with local_archive(make_archive(road_tile(crossing))) as archive:
images[crossing] = MapRenderer(archive).render(0, -40 * METER, 0, route)[0] # the crossing 40m ahead
# The horizontal road crosses the vertical one 40px above the ego, at row 152. A bridge severs the road beneath
# just outside its own stroke; a tunnel passes underneath untouched. The cut has butt caps, so the
# bridge's own end is not wrapped in black.
self.assertEqual((images['bridge'][146, 128], images['bridge'][152, 128], images['bridge'][152, 200], images['bridge'][140, 128]), (0, 255, 255, 255))
self.assertEqual((images['tunnel'][146, 128], images['tunnel'][152, 200]), (255, 255))
# The ground road cuts over the tunnel beneath it, and the tunnel is drawn without its casing.
self.assertEqual((images['tunnel'][152, 133], images['tunnel'][147, 200]), (0, 0))
def test_road_widths(self):
# Pinned at the style zoom of 1 m per pixel: 16.26 at the equator and 15.26 at 60 degrees. Tunnels have no casing.
styles, levels = np.array([MAJOR, MEDIUM, STREET, MINOR, TRACK, STREET]), np.array([0, 0, 0, 0, 0, -1])
np.testing.assert_allclose(stroke_widths(16.256, styles, levels), [19.02, 15.48, 9.86, 6.70, 7.02, 7.89], atol=0.01)
np.testing.assert_allclose(stroke_widths(15.256, styles, levels), [13.52, 10.78, 6.39, 4.16, 4.58, 4.62], atol=0.01)
def test_route_tiles(self):
route = np.array([[0, -0.01], [0, 0.01]]) # 2.2 km north along the prime meridian
with local_archive(make_archive(max_zoom=15)) as archive:
renderer = MapRenderer(archive)
tiles = renderer.route_tiles(route)
# Both columns beside the meridian, and the rows within the view radius of the route's ends.
self.assertEqual({x for x, _ in tiles}, {16383, 16384})
self.assertEqual({y for _, y in tiles}, set(range(16382, 16386)))
def test_validity_and_display(self):
route = np.array([[0, -0.01], [0, 0.01]])
with local_archive(make_archive()) as archive:
renderer = MapRenderer(archive)
self.assertTrue(renderer.render(0, 0, 0, route)[1])
self.assertFalse(renderer.render(0.01, 0, 0, route)[1]) # no road near the ego
with patch.object(archive, 'get', return_value=None): # a missing tile
image, valid = MapRenderer(archive).render(0, 0, 0, route)
self.assertFalse(valid)
self.assertEqual(image[128, 128], 128) # the route is still drawn
with local_archive(make_archive(field(3, field(1, b'buildings')))) as archive: # tiles without roads
self.assertFalse(MapRenderer(archive).render(0, 0, 0, route)[1])
class TestRouteTracking(unittest.TestCase):
def test_snap_heading_and_window(self):
m = METER
tracker = RouteTracker(np.array([[0, 0], [0, 200 * m], [200 * m, 200 * m]])) # north 200m, then east 200m
longitude, latitude, bearing = tracker.snap(3 * m, 100 * m) # 3m right of the first leg
self.assertAlmostEqual(tracker.progress, 100, delta=0.5)
self.assertAlmostEqual(longitude, 0) # lateral error removed
self.assertAlmostEqual(latitude, 100 * m)
self.assertAlmostEqual(bearing, 0, places=3)
self.assertAlmostEqual(tracker.snap(0, 200 * m)[2], 45, places=2) # at the corner the +-50m chord is mid-turn
self.assertAlmostEqual(tracker.snap(0, 150 * m)[2], 45, places=2) # progress never decreases
tracker.snap(100 * m, 200 * m) # progress 300: the window ahead no longer includes the first leg
self.assertAlmostEqual(tracker.snap(150 * m, 200 * m)[0], 150 * m) # so the window's offset is applied
self.assertAlmostEqual(tracker.snap(200 * m, 200 * m)[2], 90, places=2) # the chord clips at the route's end
# Out and back along the same road: a fix near the far leg stays on the outbound leg within the window,
# while a fresh tracker searches anywhere and lands on the return leg.
route = np.array([[0, 0], [0, 1000 * m], [10 * m, 1000 * m], [10 * m, 0]])
tracker = RouteTracker(route)
tracker.snap(1 * m, 100 * m)
self.assertAlmostEqual(tracker.snap(9 * m, 150 * m)[2], 0, places=3)
self.assertAlmostEqual(tracker.progress, 150, delta=0.5)
self.assertAlmostEqual(RouteTracker(route).snap(9 * m, 150 * m)[2], 180, places=3)
if __name__ == '__main__':
unittest.main()