diff --git a/openpilot/starpilot/ui/layout_preview_renderer.py b/openpilot/starpilot/ui/layout_preview_renderer.py index 0a2a3a715d..5f819aaa99 100644 --- a/openpilot/starpilot/ui/layout_preview_renderer.py +++ b/openpilot/starpilot/ui/layout_preview_renderer.py @@ -28,6 +28,7 @@ from openpilot.starpilot.ui.onroad_dm import draw_monitor_art, monitor_bounds, m from openpilot.starpilot.ui.onroad_stopped_timer import PREVIEW_DURATION_S from openpilot.starpilot.ui.layout_preview_sidebar import SIDEBAR_WIDTH, render_sidebar from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile, default_font_directory +from openpilot.starpilot.speed_limits.speed_domain import OffsetBand, OffsetSchedule from openpilot.starpilot.ui.rainbow_path import RainbowPath from openpilot.starpilot.ui.road_colors import edge_gradient, lane_color, path_mode, solid_gradient from openpilot.starpilot.ui.onroad_customization import ROAD_COLORS @@ -78,6 +79,7 @@ def sample_state(scene: str, document: dict) -> OnroadState: from openpilot.starpilot.ui.weather import WeatherReading state = OnroadState(weather=WeatherReading(21, 2, True), engaged=lateral or longitudinal, camera_available=False, speed_mps=0.0 if scene in ("cem_lead", "stopped") else 22.0, cruise_kph=88.0, + slc_offset_schedule=OffsetSchedule((OffsetBand(0., None, 2.),)), stopped_duration_s=PREVIEW_DURATION_S if scene == "stopped" else None, speed_limit=SpeedLimitObservation(kind=ObservationKind.VALID, source="dashboard", speed_limit_mps=22.0 if pending else 20.0, diff --git a/openpilot/starpilot/ui/onroad_state.py b/openpilot/starpilot/ui/onroad_state.py index f551ecb3fa..ce410d204d 100644 --- a/openpilot/starpilot/ui/onroad_state.py +++ b/openpilot/starpilot/ui/onroad_state.py @@ -23,6 +23,7 @@ from openpilot.starpilot.ui.traffic_status import TrafficDisplay from openpilot.starpilot.ui.developer_preview import OnroadVisualPreview from openpilot.starpilot.ui.navigation_state import NavigationDisplay from openpilot.starpilot.ui.live_developer_sidebar import DeveloperMetric +from openpilot.starpilot.speed_limits.speed_domain import OffsetSchedule @@ -244,6 +245,7 @@ class OnroadState: weather: WeatherReading | None = None ford_rear_fog_on: bool = False ford_rear_fog_source_ns: int = 0 + slc_offset_schedule: OffsetSchedule | None = None def __post_init__(self) -> None: if self.speed_mps is not None and (not math.isfinite(self.speed_mps) or self.speed_mps < 0): diff --git a/openpilot/starpilot/ui/runtime_snapshot.py b/openpilot/starpilot/ui/runtime_snapshot.py index 050d817365..407cc0546a 100644 --- a/openpilot/starpilot/ui/runtime_snapshot.py +++ b/openpilot/starpilot/ui/runtime_snapshot.py @@ -55,6 +55,8 @@ from openpilot.starpilot.ui.settings_state import Destination, DestinationAvaila from openpilot.starpilot.ui.shell import ShellMode, ShellSnapshot from openpilot.starpilot.ui.software_state import DownloadLabel, SoftwareRequest, SoftwareState from openpilot.starpilot.ui.toggles_state import Personality, ToggleKey, TogglesState +from openpilot.starpilot.saved_source import read_saved +from openpilot.starpilot.speed_limits import offset_document SLC_SIGN_HOLD_NS = 150_000_000 @@ -314,6 +316,7 @@ class RuntimeSnapshotAdapter: self._customization_value = default_document() self._appearance_value = OnroadAppearance() self._appearance_read_ns: int | None = None + self._slc_offset_schedule = None # A second screen (Android Auto) swaps in its own display choices before anything uses them. self.appearance_override: Callable[[OnroadAppearance], OnroadAppearance] | None = None self._last_mode: ShellMode | None = None @@ -442,6 +445,12 @@ class RuntimeSnapshotAdapter: self._appearance_value = onroad_appearance(self.ui_state.params) self._navigation_enabled = _flag(self.ui_state.params, "NavigationEnabled") self._show_curve_status = _flag(self.ui_state.params, "ShowCSCStatus") + try: + raw, readable = read_saved(self.ui_state.params, "SLCOffsetSchedule", offset_document.MAX_DOCUMENT_BYTES) + offsets = offset_document.decode(raw) if readable and raw is not None else None + self._slc_offset_schedule = offsets.schedule() if isinstance(offsets, offset_document.OffsetDocument) else None + except (AttributeError, OSError, TypeError, ValueError, OverflowError): + self._slc_offset_schedule = None if self.appearance_override is not None: self._appearance_value = self.appearance_override(self._appearance_value) self._appearance_read_ns = now_ns @@ -720,7 +729,7 @@ class RuntimeSnapshotAdapter: engaged=display_lat_active or display_long_active or longitudinal_overridden, camera_available=started, speed_mps=raw_speed, cruise_kph=cruise, speed_limit=observation, appearance=self._appearance_value, customization=self._customization_value, - alert=alert, metric=bool(ui.is_metric), + alert=alert, metric=bool(ui.is_metric), slc_offset_schedule=self._slc_offset_schedule, experimental_available=bool(ui.has_longitudinal_control), experimental_enabled=observed_experimental, personality=list(Personality).index(persona), diff --git a/openpilot/starpilot/ui/speed_limit_shapes.py b/openpilot/starpilot/ui/speed_limit_shapes.py index b14593ea68..0db328db86 100644 --- a/openpilot/starpilot/ui/speed_limit_shapes.py +++ b/openpilot/starpilot/ui/speed_limit_shapes.py @@ -49,26 +49,28 @@ class SpeedLimitShapes: if image.data is not None: rl.unload_image(image) - def _draw(self, name, x, y, color, rotation=0, *, scale=None): + def _draw(self, name, x, y, color, rotation=0, *, scale=None, width_scale=1): self._load() source = self.sources[name] origin = self.origins[name] if scale is None else rl.Vector2(source.width * scale / SPRITE_DENSITIES.get(name, 1) / 2, source.height * scale / SPRITE_DENSITIES.get(name, 1) / 2) scale = (self.scale if scale is None else scale) / SPRITE_DENSITIES.get(name, 1) - rl.draw_texture_pro(self.texture, source, rl.Rectangle(x, y, source.width * scale, source.height * scale), + if width_scale != 1: + origin = rl.Vector2(origin.x * width_scale, origin.y) + rl.draw_texture_pro(self.texture, source, rl.Rectangle(x, y, source.width * scale * width_scale, source.height * scale), origin, rotation, color) def circle(self, x, y, color): self._outlined('ring', x, y, color) - def _outlined(self, name, x, y, color, *, scale=None): + def _outlined(self, name, x, y, color, *, scale=None, width_scale=1): radius = self.scale if scale is None else scale for dx, dy in ((-radius, 0), (radius, 0), (0, -radius), (0, radius)): - self._draw(name, x + dx, y + dy, rl.Color(6, 14, 10, 230), scale=scale) - self._draw(name, x, y, color, scale=scale) + self._draw(name, x + dx, y + dy, rl.Color(6, 14, 10, 230), scale=scale, width_scale=width_scale) + self._draw(name, x, y, color, scale=scale, width_scale=width_scale) - def outline(self, x, y, color, *, scale=None): - self._outlined('outline', x, y, color, scale=scale) + def outline(self, x, y, color, *, scale=None, width_scale=1): + self._outlined('outline', x, y, color, scale=scale, width_scale=width_scale) def button(self, x, y, color, *, profile, scale, pressed=False): self._draw(f'button_{profile}' + ('_pressed' if pressed else ''), x, y, color, scale=scale) diff --git a/openpilot/starpilot/ui/speed_limit_widget.py b/openpilot/starpilot/ui/speed_limit_widget.py index 06990c4745..e0777df6c5 100644 --- a/openpilot/starpilot/ui/speed_limit_widget.py +++ b/openpilot/starpilot/ui/speed_limit_widget.py @@ -312,12 +312,17 @@ class SpeedLimitWidget: return color = white + # Clamping the controller target does not configure an offset for this sign. + offset_text = shown.offset_text if vienna or shown.offset_configured else None if vienna: x, top = x + 5 * scale, top + 5 * scale center_x = 59 self.shapes.circle(x + 59 * scale, top + sign_center_y * scale, self.pulse.color(rl.Color(201, 34, 49, 255), now)) else: - self.shapes.outline(x + center_x * scale, top + sign_center_y * scale, IN_USE if shown.limit_in_use else white) + # Keep 4:5 proportions and the original text sizes inside the existing tap area. + sign_scale = (160 if offset_text else 150) / SIGN_HEIGHT + self.shapes.outline(x + center_x * scale, top + (SIGN_HEIGHT * sign_scale / 2 - 9) * scale, IN_USE if shown.limit_in_use else white, + scale=scale * sign_scale, width_scale=SIGN_HEIGHT * 4 / 5 / SIGN_WIDTH) heading_size = HEADING_SIZE if not vienna else 18 if vienna and self.profile == Profile.LARGE and not shown.pending: @@ -327,8 +332,8 @@ class SpeedLimitWidget: heading_size *= 108 / (right - left) left, right, a, b = ink(heading, FontRole.SEMI_BOLD, heading_size) # Lift the Vienna stack to keep the offset clear of the bottom rim. - lift = (12 if shown.limit_in_use else 7 if shown.offset_text else 2) if vienna else 0 - header_y = 47 - lift if vienna else HEADING_ROW + lift = (12 if shown.limit_in_use else 7 if offset_text else 2) if vienna else 0 + header_y = 47 - lift if vienna else 21 if offset_text else 26 arrow_scale = 1. if vienna: # Fit the heading and arrow inside the circle at the top of their ink. @@ -352,15 +357,15 @@ class SpeedLimitWidget: role = FontRole.BOLD if vienna else FontRole.SET_SPEED size = 58 if vienna else NUMBER_SIZE offset_size = 30 if vienna else 27 - number_center = (73 if shown.offset_text else 80) - lift if vienna else NUMBER_ROW + number_center = (73 if offset_text else 80) - lift if vienna else 76 if offset_text else 90 while True: left, right, a, b = ink(shown.posted_text, role, size) width, height = right - left, b - a number_top = number_center - height / 2 if vienna: fits = number_top >= 57 - lift and circle_fits(59 - width / 2, 59 + width / 2, number_top, number_top + height) - if shown.offset_text: - left, right, a, b = ink(shown.offset_text, FontRole.MEDIUM, offset_size) + if offset_text: + left, right, a, b = ink(offset_text, FontRole.MEDIUM, offset_size) offset_top = number_top + height + 4 fits = fits and circle_fits(59 - (right - left) / 2, 59 + (right - left) / 2, offset_top, offset_top + b - a) else: @@ -374,12 +379,12 @@ class SpeedLimitWidget: centered(shown.posted_text, role, size, number_top, color) else: draw_speed_row(self.fonts, shown.posted_text, role, size * self.text_scale, x + center_x * scale, - top + NUMBER_ROW * scale, color, '0123456789') - if shown.offset_text: + top + number_center * scale, color, '0123456789') + if offset_text: if not vienna: - left, right, a, b = ink(shown.offset_text, FontRole.MEDIUM, offset_size) + left, right, a, b = ink(offset_text, FontRole.MEDIUM, offset_size) offset_size *= min(1, 108 / (right - left), 24 / (b - a)) - offset_top = 105 - centered(shown.offset_text, FontRole.MEDIUM, offset_size, offset_top, color if vienna else secondary) + offset_top = 120 + centered(offset_text, FontRole.MEDIUM, offset_size, offset_top, color if vienna else secondary) if shown.limit_in_use: centered('IN USE', FontRole.SEMI_BOLD, 15 if vienna else 22, 98 if vienna else 117, IN_USE) diff --git a/openpilot/starpilot/ui/tests/test_compact_sign_height.py b/openpilot/starpilot/ui/tests/test_compact_sign_height.py index e6c65333ae..9ba915b715 100644 --- a/openpilot/starpilot/ui/tests/test_compact_sign_height.py +++ b/openpilot/starpilot/ui/tests/test_compact_sign_height.py @@ -26,7 +26,7 @@ def historical_outline(): @pytest.mark.parametrize('profile', list(Profile)) @pytest.mark.parametrize('offset', [False, True]) @pytest.mark.parametrize('vienna', [False, True]) -def test_both_sign_styles_keep_their_height_with_or_without_offset(profile, offset, vienna, historical_outline): +def test_sign_layout_and_tap_area_stay_fixed_with_or_without_offset(profile, offset, vienna, historical_outline): current = state(show_slc_offset=offset) current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna)) widget = SpeedLimitWidget(fonts(profile), profile) @@ -46,8 +46,10 @@ def test_both_sign_styles_keep_their_height_with_or_without_offset(profile, offs else: historical_outline.assert_not_called() widget.shapes.outline.assert_called_once() - assert widget.shapes.outline.call_args.args[:2] == (bounds[0]+12+SIGN_WIDTH/2*scale, bounds[1]+12+(SIGN_HEIGHT/2-7)*scale) - assert widget.shapes.outline.call_args.kwargs == {} + sign_scale = (160 if offset else 150) / SIGN_HEIGHT + assert widget.shapes.outline.call_args.args[:2] == (bounds[0]+12+SIGN_WIDTH/2*scale, + bounds[1]+12+(SIGN_HEIGHT*sign_scale/2-9)*scale) + assert widget.shapes.outline.call_args.kwargs == {'scale': scale * sign_scale, 'width_scale': .9} assert ('+5' in [call.args[0] for call in foreground_calls(widget.fonts.draw)]) == offset @@ -103,3 +105,84 @@ def test_small_rectangular_no_offset_regression(historical_outline): assert bounds is not None and bounds[3] - bounds[1] == SIGN_HEIGHT + 20 historical_outline.assert_not_called() widget.shapes.outline.assert_called_once() + + +@pytest.mark.parametrize('profile', list(Profile)) +@pytest.mark.parametrize('metric', (False, True)) +@pytest.mark.parametrize('posted_mps', (1 / 3.6, 20., 40., 160 / 3.6)) +@pytest.mark.parametrize('configured_offset', (0, 4, -4)) +def test_only_the_displayed_ranges_configured_offset_expands_the_sign(profile, metric, posted_mps, configured_offset): + from openpilot.starpilot.speed_limits.speed_domain import OffsetBand, OffsetSchedule, adjusted_limit + factor = 3.6 if metric else 2.2369362921 + bands = ((0., 10.), (10., 40.), (40., None)) + schedule = OffsetSchedule(tuple(OffsetBand(lower, upper, + (configured_offset if lower <= posted_mps and (upper is None or posted_mps < upper) else 7) / factor) + for lower, upper in bands)) + target = adjusted_limit(posted_mps, schedule) + assert target is not None + current = state(metric=metric, speed_mps=5., slc_offset_schedule=schedule) + current = replace(current, speed_limit=SpeedLimitObservation(ObservationKind.VALID, 'vision', posted_mps, + offset_mps=target - posted_mps, presentation_adjusted_limit_mps=target, accepted_speed_limit_mps=15.)) + shown = resolve_unified_speed(current) + assert shown.offset_configured == (configured_offset != 0) + assert shown.limit_text == str(round(target * factor)) + if configured_offset == 0 and posted_mps in (1 / 3.6, 160 / 3.6): + assert shown.offset_text is not None # The effective adjustment is clamping, not a configured offset. + widget = SpeedLimitWidget(fonts(profile), profile) + widget.shapes = Mock() + widget.render(current) + visible_offset = configured_offset != 0 and shown.offset_text is not None + assert widget.shapes.outline.call_args.kwargs['scale'] == pytest.approx(widget.scale * (160 if visible_offset else 150) / SIGN_HEIGHT) + assert [call.args[0] for call in foreground_calls(widget.fonts.draw)] == ( + ['VISION', shown.posted_text] + ([shown.offset_text] if visible_offset else [])) + + +@pytest.mark.parametrize('profile', list(Profile)) +def test_mutcd_preserves_text_sizes_and_fits_both_sizes_with_real_fonts(profile): + from itertools import product + from openpilot.starpilot.speed_limits.speed_domain import OffsetBand, OffsetSchedule + from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, default_font_directory + owns_context = not rl.is_window_ready() + if owns_context: + rl.init_window(128, 128, 'MUTCD spacing') + try: + with BitmapFonts(profile, default_font_directory()) as font: + widget = SpeedLimitWidget(font, profile) + for value, adjustment, stage in product((65, 120, 199), (0, 4, 10, -10), ('live', 'pending', 'retained')): + current = state(metric=True, slc_offset_schedule=OffsetSchedule((OffsetBand(0., None, adjustment / 3.6),))) + current = replace(current, speed_mps=(value + adjustment + 2) / 3.6, + speed_limit=SpeedLimitObservation(ObservationKind.VALID, 'vision', value / 3.6, + offset_mps=adjustment / 3.6, action_enabled=False, + pending_speed_limit_mps=value / 3.6 if stage == 'pending' else None)) + if stage == 'retained': + current = replace(current, speed_limit=replace(current.speed_limit, kind=ObservationKind.STALE, speed_limit_mps=None, + last_received_speed_limit_mps=value / 3.6, last_received_adjusted_limit_mps=(value + adjustment) / 3.6, + last_received_source='vision', session_id='drive')) + with patch.object(font, 'draw') as drawn, patch.object(widget.shapes, 'outline') as outline: + widget.render(current) + scale = widget.scale * (160 if adjustment else 150) / SIGN_HEIGHT + cx, cy = outline.call_args.args[:2] + half_width, half_height = (SIGN_WIDTH / 2 - 4) * .9 * scale, (SIGN_HEIGHT / 2 - 4) * scale + boxes = [] + for call in foreground_calls(drawn): + text, role, size, x, y, _ = call.args + left, right = font.horizontal_ink(text, role, size) + top, bottom = font.vertical_ink(text, role, size) + if text.startswith('LAST '): + assert right - left == pytest.approx(108 * widget.scale) + else: + assert size == pytest.approx({FontRole.SEMI_BOLD: 26, FontRole.SET_SPEED: 70, FontRole.MEDIUM: 27}[role] * widget.text_scale) + boxes.append((x + left, y + top, x + right, y + bottom)) + assert cx - half_width + 1 <= min(box[0] for box in boxes) + assert max(box[2] for box in boxes) <= cx + half_width - 1 + assert cy - half_height + 1 <= boxes[0][1] + assert boxes[-1][3] <= cy + half_height - 1 + assert all(a[3] + 4 * widget.scale <= b[1] for a, b in zip(boxes, boxes[1:], strict=False)) + bounds = speed_limit_bounds(profile, current) + outer_width, outer_height = (SIGN_WIDTH / 2 - 2) * .9 * scale + scale, (SIGN_HEIGHT / 2 - 2) * scale + scale + assert bounds[0] <= cx - outer_width and cx + outer_width <= bounds[2] + assert bounds[1] <= cy - outer_height and cy + outer_height <= bounds[3] + widget.close() + finally: + if owns_context: + rl.close_window() diff --git a/openpilot/starpilot/ui/tests/test_onroad_customization.py b/openpilot/starpilot/ui/tests/test_onroad_customization.py index bd6520bab5..99507181f9 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_customization.py +++ b/openpilot/starpilot/ui/tests/test_onroad_customization.py @@ -619,9 +619,9 @@ def test_compact_us_sign_keeps_camera_visible_at_saved_position(speed): hud._speed_limit_sign(state) fill.assert_not_called() outline.assert_called_once() - assert outline.call_args.args[:2] == (264, 95) + assert outline.call_args.args[:2] == (264, 96) assert [call.args[0] for call in foreground_calls(fonts.draw)] == ['SPEED', str(speed)] - assert foreground_calls(fonts.draw)[0].args[3:5] == (259, 38) + assert foreground_calls(fonts.draw)[0].args[3:5] == (259, 47) def test_compact_drag_keeps_animation_state_and_separate_origins(): diff --git a/openpilot/starpilot/ui/tests/test_runtime_snapshot.py b/openpilot/starpilot/ui/tests/test_runtime_snapshot.py index 20f56ca916..dbd1adf7bb 100644 --- a/openpilot/starpilot/ui/tests/test_runtime_snapshot.py +++ b/openpilot/starpilot/ui/tests/test_runtime_snapshot.py @@ -100,6 +100,43 @@ def ui_fake(): has_longitudinal_control=True, CP=None, recording_audio=False) +def test_slc_offset_schedule_uses_cached_validated_settings(tmp_path): + from pathlib import Path + from openpilot.common.params import Params + from openpilot.starpilot.saved_source import read_saved + from openpilot.starpilot.speed_limits import offset_document as od + from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed + ui = ui_fake() + ui.params = Params(str(tmp_path)) + document = od.OffsetDocument((0., 10., 15., 20., 25., 30., 35., 40.), (7., 7., 7., 0., 7., 7., 7.)) + ui.params.put('SLCOffsetSchedule', od.to_value(document), block=True) + adapter = RuntimeSnapshotAdapter(ui) + with patch('openpilot.starpilot.ui.runtime_snapshot.read_saved', wraps=read_saved) as reads: + first = adapter.build(ShellMode.ONROAD, now_ns=NOW).onroad + assert first.slc_offset_schedule == document.schedule() + assert not resolve_unified_speed(first).offset_configured + document = od.OffsetDocument(document.bounds_mps, (7.,) * 7) + ui.params.put('SLCOffsetSchedule', od.to_value(document), block=True) + cached = adapter.build(ShellMode.ONROAD, now_ns=NOW + 50_000_000).onroad + assert cached.slc_offset_schedule is first.slc_offset_schedule + reads.assert_called_once() + adapter.invalidate_appearance() + updated = adapter.build(ShellMode.ONROAD, now_ns=NOW + 100_000_000).onroad + assert updated.slc_offset_schedule == document.schedule() + assert resolve_unified_speed(updated).offset_configured + assert reads.call_count == 2 + path = Path(ui.params.get_param_path('SLCOffsetSchedule')) + for raw in (b'{bad', od.encode(od.NEEDS_REVIEW), b'x' * (od.MAX_DOCUMENT_BYTES + 1), None): + if raw is None: + path.unlink() + else: + path.write_bytes(raw) + adapter.invalidate_appearance() + invalid = adapter.build(ShellMode.ONROAD, now_ns=NOW + 100_000_000).onroad + assert invalid.slc_offset_schedule is None + assert not resolve_unified_speed(invalid).offset_configured + + class TestRuntimeSnapshot(unittest.TestCase): def test_home_uses_shared_statistics_source_without_onroad_polling(self): diff --git a/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py b/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py index a310b338a6..8bfddfee79 100644 --- a/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py +++ b/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py @@ -78,6 +78,21 @@ def test_bad_atlas_dimensions_release_image_without_uploading(gpu): gpu['load_texture_from_image'].assert_not_called() +@pytest.mark.parametrize('scale', (1., 8 / 9)) +def test_narrow_outline_reuses_the_atlas_and_keeps_its_center(gpu, scale): + owner = shapes.SpeedLimitShapes() + owner.outline(64, 65, shapes.rl.WHITE, scale=scale, width_scale=.9) + assert gpu['draw_texture_pro'].call_count == 5 + _, source, destination, origin, _, _ = gpu['draw_texture_pro'].call_args.args + assert (source.width, source.height) == (136, 152) + assert (destination.width, destination.height) == pytest.approx((136 * scale * .9, 152 * scale)) + assert (origin.x, origin.y) == pytest.approx((destination.width / 2, destination.height / 2)) + owner.outline(64, 65, shapes.rl.WHITE) + assert gpu['draw_texture_pro'].call_args.args[3].x == 68 + gpu['load_texture_from_image'].assert_called_once() + owner.close() + + def test_large_sign_scale_changes_destination_and_rotation_origin_only(gpu): owner = shapes.SpeedLimitShapes(1.25) owner.arrow('compact', 345.75, 111.25, -1, shapes.rl.BLACK) diff --git a/openpilot/starpilot/ui/tests/test_unified_speed_native.py b/openpilot/starpilot/ui/tests/test_unified_speed_native.py index 660fc6bdd8..990ddaa27d 100644 --- a/openpilot/starpilot/ui/tests/test_unified_speed_native.py +++ b/openpilot/starpilot/ui/tests/test_unified_speed_native.py @@ -33,12 +33,14 @@ def drawing(monkeypatch): def state(**changes): + from openpilot.starpilot.speed_limits.speed_domain import OffsetBand, OffsetSchedule observation = SpeedLimitObservation(ObservationKind.VALID, 'map', 50 / 3.6, accepted_speed_limit_mps=40 / 3.6, accepted_adjusted_limit_mps=45 / 3.6, pending_speed_limit_mps=50 / 3.6, pending_adjusted_limit_mps=55 / 3.6, presentation_adjusted_limit_mps=55 / 3.6, accepted_source='dashboard', action_enabled=True, session_id='drive', presentation_id=2, decision_id=1, pending_since_ns=10_000_000_000) return replace(OnroadState(True, False, 45 / 3.6, 100., observation, metric=True, longitudinal_active=True, + slc_offset_schedule=OffsetSchedule((OffsetBand(0., None, 5 / 3.6),)), observed_ns=10_000_000_000), **changes) diff --git a/openpilot/starpilot/ui/tests/test_unified_speed_presentation.py b/openpilot/starpilot/ui/tests/test_unified_speed_presentation.py index f03eb8c981..0d8d390f8e 100644 --- a/openpilot/starpilot/ui/tests/test_unified_speed_presentation.py +++ b/openpilot/starpilot/ui/tests/test_unified_speed_presentation.py @@ -105,6 +105,21 @@ def test_hidden_max_and_offset_preferences_keep_the_latest_sign(): assert (shown.mode, shown.posted_text, shown.offset_text) == ('limit_only', '108', None) +@pytest.mark.parametrize('stage,expected', [('live', True), ('confirmation', False), ('retained', False), ('last_received', True)]) +def test_configured_offset_follows_the_resolved_posted_limit(stage, expected): + from openpilot.starpilot.speed_limits.speed_domain import OffsetBand, OffsetSchedule + current = state(slc_offset_schedule=OffsetSchedule((OffsetBand(0., 25., 0.), OffsetBand(25., None, 2.)))) + if stage == 'confirmation': + current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=20., pending_adjusted_limit_mps=20.)) + elif stage in ('retained', 'last_received'): + current = replace(current, speed_limit=replace(current.speed_limit, kind=ObservationKind.STALE, speed_limit_mps=None, + accepted_adjusted_limit_mps=20., last_received_speed_limit_mps=30., last_received_adjusted_limit_mps=32., + effective_cluster_target_mps=20. if stage == 'retained' else None, effective_cap_mps=20.)) + shown = resolve_unified_speed(current, confirmation=stage == 'confirmation') + assert shown.posted_text == ('108' if expected else '72') + assert shown.offset_configured == expected + + @pytest.mark.parametrize('pending,adjusted,presentation,offset,expected', [ (30., 33., 32., 2., '119'), (30., None, 32., 2., '115'), diff --git a/openpilot/starpilot/ui/unified_speed_presentation.py b/openpilot/starpilot/ui/unified_speed_presentation.py index 92b5b92da6..b7eeabc331 100644 --- a/openpilot/starpilot/ui/unified_speed_presentation.py +++ b/openpilot/starpilot/ui/unified_speed_presentation.py @@ -3,6 +3,8 @@ from dataclasses import dataclass import math +from openpilot.starpilot.speed_limits.speed_domain import offset_for_limit + @dataclass(frozen=True) class UnifiedSpeedPresentation: @@ -23,6 +25,7 @@ class UnifiedSpeedPresentation: limit_in_use: bool = False set_limited: bool = False show_last: bool = False + offset_configured: bool = False @property def max_status(self) -> str: @@ -159,4 +162,6 @@ def resolve_unified_speed(state, *, confirmation=False) -> UnifiedSpeedPresentat obs.override_basis != 'pedal' and positive(obs.effective_cluster_target_mps) else None, speed(active_target) if slc_limiting and positive(active_target) else None, - posted_retained, limit_in_use, set_limited, show_last) + posted_retained, limit_in_use, set_limited, show_last, + offset_configured=state.slc_offset_schedule is not None and + offset_for_limit(posted, state.slc_offset_schedule) not in (None, 0.))