This commit is contained in:
firestar5683
2026-08-01 18:32:41 -05:00
parent e8959fa3b7
commit a44635ea6b
24 changed files with 2351 additions and 819 deletions
+5 -1
View File
@@ -208,7 +208,11 @@ class LatControlTorque(LatControl):
roll_offset_fade = np.interp(CS.vEgo, FF_ROLL_OFFSET_FADE_BP, FF_ROLL_OFFSET_FADE_V)
roll_compensation = params.roll * ACCELERATION_DUE_TO_GRAVITY * roll_offset_fade
curvature_deadzone = abs(VM.calc_curvature(math.radians(self.steering_angle_deadzone_deg), CS.vEgo, 0.0))
flm_center_deadband_deg = (
get_flm_full_surface_center_deadband_deg(self.flm_surface_profile_key, CS.vEgo) if flm_surface_active else 0.0
)
effective_deadband_deg = self.steering_angle_deadzone_deg + flm_center_deadband_deg
curvature_deadzone = abs(VM.calc_curvature(math.radians(effective_deadband_deg), CS.vEgo, 0.0))
lateral_accel_deadzone = curvature_deadzone * CS.vEgo ** 2
delay_frames = int(np.clip(lat_delay / self.dt, 1, self.request_buffer_len))
@@ -2709,6 +2709,11 @@ FLM_FULL_SURFACE_SUFFIX_METADATA = {
"unwind_taper_right": {"min": 0.0, "max": 12.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"center_taper_max": {"min": 0.0, "max": 0.18, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"highway_center_taper_max": {"min": 0.0, "max": 0.18, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"center_deadband_crawl_deg": {"min": 0.0, "max": 0.30, "precision": 0.005, "deltaType": "absolute", "safeLiveTrial": True},
"center_deadband_low_deg": {"min": 0.0, "max": 0.30, "precision": 0.005, "deltaType": "absolute", "safeLiveTrial": True},
"center_deadband_mid_deg": {"min": 0.0, "max": 0.20, "precision": 0.005, "deltaType": "absolute", "safeLiveTrial": True},
"center_deadband_fast_deg": {"min": 0.0, "max": 0.12, "precision": 0.005, "deltaType": "absolute", "safeLiveTrial": True},
"center_deadband_highway_deg": {"min": 0.0, "max": 0.08, "precision": 0.005, "deltaType": "absolute", "safeLiveTrial": True},
"turn_in_threshold_reduction_left": {"min": 0.0, "max": 2.00, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"turn_in_threshold_reduction_right": {"min": 0.0, "max": 2.00, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"unwind_threshold_increase_left": {"min": 0.0, "max": 12.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
@@ -2737,6 +2742,11 @@ FLM_FULL_SURFACE_NEUTRAL_DEFAULTS = {
"unwind_taper_right": 0.0,
"center_taper_max": 0.0,
"highway_center_taper_max": 0.0,
"center_deadband_crawl_deg": 0.0,
"center_deadband_low_deg": 0.0,
"center_deadband_mid_deg": 0.0,
"center_deadband_fast_deg": 0.0,
"center_deadband_highway_deg": 0.0,
"turn_in_threshold_reduction_left": 0.0,
"turn_in_threshold_reduction_right": 0.0,
"unwind_threshold_increase_left": 0.0,
@@ -2823,6 +2833,24 @@ def get_flm_full_surface_center_taper_scale(profile_key: str | None, desired_lat
return 1.0 - min(reduction, 0.20)
def get_flm_full_surface_center_deadband_deg(profile_key: str | None, v_ego: float) -> float:
if not profile_key:
return 0.0
suffixes = (
"center_deadband_crawl_deg",
"center_deadband_low_deg",
"center_deadband_mid_deg",
"center_deadband_fast_deg",
"center_deadband_highway_deg",
)
values = [
_flm_vehicle_knob(_flm_profile_symbol(profile_key, suffix), 0.0)
for suffix in suffixes
]
return float(np.interp(max(v_ego, 0.0), FLM_FRICTION_SPEED_KNOTS, values))
def get_flm_full_surface_ff_scale(profile_key: str | None, desired_lateral_accel: float, desired_lateral_jerk: float, v_ego: float,
include_base_ff: bool = False) -> float:
if not profile_key or desired_lateral_accel == 0.0:
@@ -323,6 +323,50 @@ class TestLatControl:
assert get_flm_runtime_overrides() == {}
assert get_standard_friction_threshold(10.0) == pytest.approx(base)
def test_flm_center_deadband_curve_interpolates_by_speed(self):
overrides = normalize_flm_overrides({
"vehicleKnobs": {
"torque_universal.center_deadband_crawl_deg": 0.0,
"torque_universal.center_deadband_low_deg": 0.04,
"torque_universal.center_deadband_mid_deg": 0.08,
"torque_universal.center_deadband_fast_deg": 0.04,
"torque_universal.center_deadband_highway_deg": 0.02,
},
})
try:
set_flm_runtime_overrides(overrides)
helper = latcontrol_vehicle_tunes.get_flm_full_surface_center_deadband_deg
assert helper("torque_universal", 0.0) == pytest.approx(0.0)
assert helper("torque_universal", 10.0) == pytest.approx(0.08)
assert helper("torque_universal", 12.5) == pytest.approx(0.06)
assert helper("torque_universal", 25.0) == pytest.approx(0.02)
finally:
clear_flm_runtime_overrides()
def test_flm_center_deadband_only_reaches_controller_with_active_trial(self, monkeypatch):
controller, VM, CS, params, starpilot_toggles = self._build_torque_controller(GM.CHEVROLET_BOLT_ACC_2022_2023)
symbol = f"{controller.flm_surface_profile_key}.center_deadband_highway_deg"
recorded_deadzones = []
def record_deadzone(_error, deadzone, _threshold, _torque_params):
recorded_deadzones.append(deadzone)
return 0.0
monkeypatch.setattr(latcontrol_torque, "get_friction", record_deadzone)
starpilot_toggles.flm_active_overrides = {"vehicleKnobs": {symbol: 0.08}}
starpilot_toggles.flm_active_profile_id = ""
starpilot_toggles.flm_trial_applied = False
controller.update(True, CS, VM, params, False, 0.0025, False, 0.2, None, None, starpilot_toggles)
inactive_deadzone = recorded_deadzones[-1]
starpilot_toggles.flm_active_profile_id = "report:cleanup:recommended"
starpilot_toggles.flm_trial_applied = True
try:
controller.update(True, CS, VM, params, False, 0.0025, False, 0.2, None, None, starpilot_toggles)
assert recorded_deadzones[-1] > inactive_deadzone
finally:
clear_flm_runtime_overrides()
def test_flm_vehicle_knob_override_ioniq6_center_taper(self):
baseline = get_ioniq_6_center_taper_scale(0.0, 32.0)
overrides = normalize_flm_overrides({
+2 -2
View File
@@ -1,6 +1,6 @@
"""Small, shared cache for read-mostly UI parameters.
Parameter reads are file-backed. The BIG UI asks for the same values from
Parameter reads are file-backed. The raylib UIs ask for the same values from
multiple widgets during a frame, so a short cache avoids repeated open/read/
close cycles without making settings changes sticky: every write invalidates
the affected key immediately and the short TTL bounds visibility of writes
@@ -100,7 +100,7 @@ _SHARED_UI_PARAMS: UIParamCache | None = None
def shared_ui_params() -> UIParamCache:
"""Return the cache shared by BIG UI views and settings panels."""
"""Return the cache shared by raylib UI views and settings panels."""
global _SHARED_UI_PARAMS
if _SHARED_UI_PARAMS is None:
_SHARED_UI_PARAMS = UIParamCache()
@@ -19,7 +19,7 @@ from openpilot.selfdrive.ui.mici.onroad.starpilot_status import (
TRAFFIC_COLOR,
get_border_color,
)
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.lib.starpilot_visuals import get_border_width
from openpilot.starpilot.common.favorite_slots import is_favorite_action_key, load_favorite_slots, toggle_favorite_slot
from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent
@@ -507,7 +507,7 @@ class StandstillTimerOverlay:
return minute_text, second_text
def _draw_centered_text(self, rect: rl.Rectangle, text: str, y: float, font: rl.Font, font_size: int, color: rl.Color) -> None:
text_size = rl.measure_text_ex(font, text, font_size, 0)
text_size = measure_text_cached(font, text, font_size)
text_pos = rl.Vector2(rect.x + rect.width / 2 - text_size.x / 2, rect.y + y - text_size.y / 2)
shadow_pos = rl.Vector2(text_pos.x + 2, text_pos.y + 2)
rl.draw_text_ex(font, text, shadow_pos, font_size, 0, rl.Color(0, 0, 0, 170))
@@ -516,7 +516,7 @@ class StandstillTimerOverlay:
@staticmethod
def _fit_font_size(font: rl.Font, text: str, initial_size: int, max_width: float, minimum_size: int) -> int:
font_size = max(initial_size, minimum_size)
while font_size > minimum_size and rl.measure_text_ex(font, text, font_size, 0).x > max_width:
while font_size > minimum_size and measure_text_cached(font, text, font_size).x > max_width:
font_size -= 2
return font_size
@@ -830,14 +830,16 @@ class AugmentedRoadView(CameraView):
def _switch_stream_if_needed(self, sm, camera_view: int):
if camera_view == CAMERA_VIEW_NONE:
self._cancel_pending_switch()
self._reverse_driver_camera_frames = 0
self._reverse_driver_camera_active = False
return
if getattr(self, "_onroad_reentry_pending", False):
self._refresh_available_streams()
if self._update_reverse_driver_camera_state():
target = DRIVER_CAM
if self.stream_type != target:
self.switch_stream(target)
self.switch_stream(DRIVER_CAM)
return
wide_available = WIDE_CAM in self.available_streams
@@ -859,7 +861,8 @@ class AugmentedRoadView(CameraView):
else:
target = ROAD_CAM
if self.stream_type != target:
if (getattr(self, "_onroad_reentry_pending", False) or
self.stream_type != target or (self._switching and self._target_stream_type != target)):
self.switch_stream(target)
def _update_calibration(self):
+3 -484
View File
@@ -1,486 +1,5 @@
import os
import platform
import weakref
import numpy as np
import pyray as rl
"""Compatibility import for the shared CameraView implementation."""
from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware import TICI
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.egl import (init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture,
create_external_texture, destroy_external_texture, EGLImage)
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.selfdrive.ui.onroad.cameraview import CameraView
CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts
MICI_FORCE_TEXTURE_CAMERA = os.getenv("MICI_FORCE_TEXTURE_CAMERA", "0") == "1"
VERSION = """
#version 300 es
precision mediump float;
"""
if platform.system() == "Darwin":
VERSION = """
#version 330 core
"""
VERTEX_SHADER = VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
in vec3 vertexNormal;
in vec4 vertexColor;
uniform mat4 mvp;
out vec2 fragTexCoord;
out vec4 fragColor;
void main() {
fragTexCoord = vertexTexCoord;
fragColor = vertexColor;
gl_Position = mvp * vec4(vertexPosition, 1.0);
}
"""
FRAME_FRAGMENT_SHADER_EXTERNAL = """
#version 300 es
#extension GL_OES_EGL_image_external_essl3 : enable
precision mediump float;
in vec2 fragTexCoord;
uniform samplerExternalOES texture0;
out vec4 fragColor;
uniform int engaged;
uniform int enhance_driver;
void main() {
vec4 color = texture(texture0, fragTexCoord);
// Keep the onroad camera feed full-color in every driving state.
if (engaged == 1) {
color.rgb = color.rgb;
}
if (enhance_driver == 1) {
float brightness = 1.1;
color.rgb = color.rgb + 0.15;
color.rgb = clamp((color.rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
color.rgb = color.rgb * color.rgb * (3.0 - 2.0 * color.rgb);
color.rgb = pow(color.rgb, vec3(0.8));
}
fragColor = vec4(color.rgb, color.a);
}
"""
FRAME_FRAGMENT_SHADER_YUV = VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
uniform sampler2D texture1;
out vec4 fragColor;
uniform int engaged;
uniform int enhance_driver;
void main() {
float y = texture(texture0, fragTexCoord).r;
vec2 uv = texture(texture1, fragTexCoord).ra - 0.5;
vec3 rgb = vec3(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x);
// Keep the onroad camera feed full-color in every driving state.
if (engaged == 1) {
rgb = rgb;
}
// TODO: the images out of camerad need some more correction and
// the ui should apply a gamma curve for the device display
if (enhance_driver == 1) {
float brightness = 1.1;
rgb = rgb + 0.15;
rgb = clamp((rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
rgb = pow(rgb, vec3(0.8));
}
fragColor = vec4(rgb, 1.0);
}
"""
class CameraView(Widget):
def __init__(self, name: str, stream_type: VisionStreamType):
super().__init__()
self._name = name
# Primary stream
self.client = VisionIpcClient(name, stream_type, conflate=True)
self._stream_type = stream_type
self.available_streams: list[VisionStreamType] = []
# Target stream for switching
self._target_client: VisionIpcClient | None = None
self._target_stream_type: VisionStreamType | None = None
self._switching: bool = False
self._texture_needs_update = True
self.last_connection_attempt: float = 0.0
self._use_egl = TICI and not MICI_FORCE_TEXTURE_CAMERA and init_egl()
if TICI and MICI_FORCE_TEXTURE_CAMERA:
cloudlog.warning("CameraView EGL disabled by MICI_FORCE_TEXTURE_CAMERA, using texture rendering")
elif TICI and not self._use_egl:
cloudlog.error("CameraView EGL init failed, falling back to texture rendering")
frame_shader = FRAME_FRAGMENT_SHADER_EXTERNAL if self._use_egl else FRAME_FRAGMENT_SHADER_YUV
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, frame_shader)
self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not self._use_egl else -1
self._engaged_loc = rl.get_shader_location(self.shader, "engaged")
self._engaged_val = rl.ffi.new("int[1]", [1])
self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver")
self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0])
self.frame: VisionBuf | None = None
self._last_frame_id = -1
self._regressive_frame_count = 0
self.texture_y: rl.Texture | None = None
self.texture_uv: rl.Texture | None = None
# EGL resources
self.egl_images: dict[int, EGLImage] = {}
self.egl_texture: rl.Texture | None = None
self._external_texture_id = 0
self._placeholder_color: rl.Color | None = None
self._closed = False
# Initialize EGL for zero-copy rendering when available.
if self._use_egl:
self._create_egl_texture()
self_ref = weakref.ref(self)
def offroad_transition_callback():
if (view := self_ref()) is not None:
view._offroad_transition()
self._offroad_transition_callback = offroad_transition_callback
ui_state.add_offroad_transition_callback(self._offroad_transition_callback)
def _offroad_transition(self):
self._reset_camera_connection()
def _reset_camera_connection(self):
# EGL images and VisionBuf objects both retain the imported camera buffer.
# Release them on every road-state transition instead of pinning the old
# camerad allocation until this view happens to render again.
self._clear_textures()
self.frame = None
self._last_frame_id = -1
self.available_streams.clear()
self.client = VisionIpcClient(self._name, self._stream_type, conflate=True)
self._target_client = None
self._target_stream_type = None
self._switching = False
self._texture_needs_update = True
self.last_connection_attempt = 0.0
def _set_placeholder_color(self, color: rl.Color):
"""Set a placeholder color to be drawn when no frame is available."""
self._placeholder_color = color
def switch_stream(self, stream_type: VisionStreamType) -> None:
if self._stream_type == stream_type:
return
if self._switching and self._target_stream_type == stream_type:
return
cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}')
if self._target_client:
del self._target_client
self._target_stream_type = stream_type
self._target_client = VisionIpcClient(self._name, stream_type, conflate=True)
self._switching = True
@property
def stream_type(self) -> VisionStreamType:
return self._stream_type
def close(self) -> None:
if self._closed:
return
self._closed = True
callback = getattr(self, "_offroad_transition_callback", None)
if callback is not None:
ui_state.remove_offroad_transition_callback(callback)
self._offroad_transition_callback = None
self._clear_textures()
# Clean up shader
if self.shader and self.shader.id:
rl.unload_shader(self.shader)
self.shader.id = 0
self.frame = None
self._last_frame_id = -1
self.available_streams.clear()
self.client = None
self._target_client = None
def __del__(self):
self.close()
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
if not self.frame:
return np.eye(3)
# Calculate aspect ratios
widget_aspect_ratio = rect.width / rect.height
frame_aspect_ratio = self.frame.width / self.frame.height
# Calculate scaling factors to maintain aspect ratio
zx = min(frame_aspect_ratio / widget_aspect_ratio, 1.0)
zy = min(widget_aspect_ratio / frame_aspect_ratio, 1.0)
return np.array([
[zx, 0.0, 0.0],
[0.0, zy, 0.0],
[0.0, 0.0, 1.0]
])
def _render(self, rect: rl.Rectangle):
if self._switching:
self._handle_switch()
if not self._ensure_connection():
self._draw_placeholder(rect)
return
if self._use_egl:
self._observe_displayed_frame()
# Try to get a new buffer without blocking
buffer = self.client.recv(timeout_ms=0)
if buffer:
self._accept_frame(buffer, self.client.frame_id)
elif not self.client.is_connected():
# ensure we clear the displayed frame when the connection is lost
self.frame = None
if not self.frame:
self._draw_placeholder(rect)
return
transform = self._calc_frame_matrix(rect)
src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height))
# Flip driver camera horizontally
if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER:
src_rect.width = -src_rect.width
# Calculate scale
scale_x = rect.width * transform[0, 0] # zx
scale_y = rect.height * transform[1, 1] # zy
# Calculate base position (centered)
x_offset = rect.x + (rect.width - scale_x) / 2
y_offset = rect.y + (rect.height - scale_y) / 2
x_offset += transform[0, 2] * rect.width / 2
y_offset += transform[1, 2] * rect.height / 2
dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y)
# Render with appropriate method
if self._use_egl:
self._render_egl(src_rect, dst_rect)
else:
self._render_textures(src_rect, dst_rect)
def _draw_placeholder(self, rect: rl.Rectangle):
if self._placeholder_color:
rl.draw_rectangle_rec(rect, self._placeholder_color)
def _observe_displayed_frame(self) -> None:
if self.frame is not None:
client_frame_id = getattr(self.client, "frame_id", -1) if hasattr(self, "client") and self.client is not None else -1
frame_id = getattr(self.frame, "frame_id", client_frame_id)
self._last_frame_id = max(self._last_frame_id, int(frame_id))
def _accept_frame(self, frame: VisionBuf, packet_frame_id: int) -> bool:
content_frame_id = int(getattr(frame, "frame_id", packet_frame_id))
if content_frame_id < self._last_frame_id:
self._regressive_frame_count += 1
if self._regressive_frame_count == 1 or self._regressive_frame_count % 100 == 0:
message = f"Dropping regressive {self._name} frame: content={content_frame_id}, packet={packet_frame_id}, "
message += f"displayed={self._last_frame_id}, idx={frame.idx}, count={self._regressive_frame_count}"
cloudlog.warning(message)
return False
self.frame = frame
self._last_frame_id = content_frame_id
self._texture_needs_update = True
return True
def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
"""Render using EGL for direct buffer access"""
if self.frame is None or self.egl_texture is None or not self._external_texture_id:
return
idx = self.frame.idx
egl_image = self.egl_images.get(idx)
# Create EGL image if needed
if egl_image is None:
egl_image = create_egl_image(self.frame.width, self.frame.height, self.frame.stride, self.frame.fd, self.frame.uv_offset)
if egl_image:
self.egl_images[idx] = egl_image
else:
return
# Update texture dimensions to match current frame
self.egl_texture.width = self.frame.width
self.egl_texture.height = self.frame.height
# Bind the EGL image to our texture
bind_egl_image_to_texture(self._external_texture_id, egl_image)
# Render with shader
rl.begin_shader_mode(self.shader)
self._update_texture_color_filtering()
rl.draw_texture_pro(self.egl_texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
rl.end_shader_mode()
def _render_textures(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
"""Render using texture copies"""
if not self.texture_y or not self.texture_uv or self.frame is None:
return
# Update textures with new frame data
if self._texture_needs_update:
y_data = self.frame.data[: self.frame.uv_offset]
uv_data = self.frame.data[self.frame.uv_offset:]
rl.update_texture(self.texture_y, rl.ffi.cast("void *", y_data.ctypes.data))
rl.update_texture(self.texture_uv, rl.ffi.cast("void *", uv_data.ctypes.data))
self._texture_needs_update = False
# Render with shader
rl.begin_shader_mode(self.shader)
self._update_texture_color_filtering()
rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv)
rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
rl.end_shader_mode()
def _update_texture_color_filtering(self):
self._engaged_val[0] = 1 if ui_state.status != UIStatus.DISENGAGED else 0
if self._engaged_loc >= 0:
rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
if self._enhance_driver_loc >= 0:
rl.set_shader_value(self.shader, self._enhance_driver_loc, self._enhance_driver_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
def _ensure_connection(self) -> bool:
if not self.client.is_connected():
self.frame = None
self._last_frame_id = -1
self.available_streams.clear()
# Throttle connection attempts
current_time = rl.get_time()
if current_time - self.last_connection_attempt < CONNECTION_RETRY_INTERVAL:
return False
self.last_connection_attempt = current_time
# A GL texture can retain the last EGL image after camerad exits. Release
# it before connect() frees and replaces the client's imported buffers.
self._clear_textures()
if not self.client.connect(False) or not self.client.num_buffers:
return False
cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}")
self._initialize_textures()
self.available_streams = self.client.available_streams(self._name, block=False)
return True
def _handle_switch(self) -> None:
"""Check if target stream is ready and switch immediately."""
if not self._target_client or not self._switching:
return
# Try to connect target if needed
if not self._target_client.is_connected():
if not self._target_client.connect(False) or not self._target_client.num_buffers:
return
cloudlog.debug(f"Target stream connected: {self._target_stream_type}")
# Check if target has frames ready
target_frame = self._target_client.recv(timeout_ms=0)
if target_frame:
self.frame = target_frame # Update current frame to target frame
self._complete_switch()
def _complete_switch(self) -> None:
"""Instantly switch to target stream."""
cloudlog.debug(f"Switching to {self._target_stream_type}")
# Delete the GL texture before releasing the old client. Merely destroying
# the EGLImage handle leaves its storage alive while a texture sibling exists.
self._clear_textures()
# Switch to target
self.client = self._target_client
self._stream_type = self._target_stream_type
enhance_driver_val = getattr(self, "_enhance_driver_val", None)
if enhance_driver_val is not None:
enhance_driver_val[0] = 1 if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0
client_frame_id = getattr(self.client, "frame_id", -1) if hasattr(self, "client") and self.client is not None else -1
frame = getattr(self, "frame", None)
self._last_frame_id = int(getattr(frame, "frame_id", client_frame_id)) if frame is not None else -1
self._texture_needs_update = True
# Reset state
self._target_client = None
self._target_stream_type = None
self._switching = False
# Initialize textures for new stream
self._initialize_textures()
def _initialize_textures(self):
self._clear_textures()
if self._use_egl:
self._create_egl_texture()
else:
self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride),
int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE))
self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2),
int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA))
def _create_egl_texture(self):
temp_image = rl.gen_image_color(1, 1, rl.BLACK)
self.egl_texture = rl.load_texture_from_image(temp_image)
rl.unload_image(temp_image)
self._external_texture_id = create_external_texture()
if not self._external_texture_id:
raise RuntimeError("Failed to create external camera texture")
def _clear_textures(self):
if self.texture_y and self.texture_y.id:
rl.unload_texture(self.texture_y)
self.texture_y = None
if self.texture_uv and self.texture_uv.id:
rl.unload_texture(self.texture_uv)
self.texture_uv = None
if self._use_egl:
if self._external_texture_id:
destroy_external_texture(self._external_texture_id)
self._external_texture_id = 0
if self.egl_texture and self.egl_texture.id:
rl.unload_texture(self.egl_texture)
self.egl_texture = None
for data in self.egl_images.values():
destroy_egl_image(data)
self.egl_images = {}
if __name__ == "__main__":
gui_app.init_window("camera view")
road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD)
for _ in gui_app.render():
road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
__all__ = ["CameraView"]
@@ -1,7 +1,7 @@
import pyray as rl
from cereal import car, log, messaging
from cereal import log, messaging
from msgq.visionipc import VisionStreamType
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
from openpilot.selfdrive.ui.ui_state import ui_state, device
from openpilot.system.ui.lib.application import gui_app, FontWeight
+96 -65
View File
@@ -2,14 +2,13 @@ import gc
from types import SimpleNamespace
import weakref
import pytest
from openpilot.selfdrive.ui.mici.onroad import cameraview as mici_cameraview
from openpilot.selfdrive.ui.onroad import cameraview as big_cameraview
from openpilot.selfdrive.ui.mici.onroad import augmented_road_view as mici_augmented_road_view
@pytest.mark.parametrize("module", (mici_cameraview, big_cameraview))
def test_road_transition_releases_camera_buffers(monkeypatch, module):
def test_road_transition_releases_camera_buffers(monkeypatch):
module = big_cameraview
class FakeClient:
pass
@@ -25,12 +24,12 @@ def test_road_transition_releases_camera_buffers(monkeypatch, module):
view._target_stream_type = object()
view._switching = True
view._texture_needs_update = False
view._regressive_frame_count = 2
view.last_connection_attempt = 123.0
view._closed = True
cleared = []
view._clear_textures = lambda: cleared.append(True)
monkeypatch.setattr(module, "VisionIpcClient", lambda *_args, **_kwargs: FakeClient())
del old_client
view._offroad_transition()
@@ -44,11 +43,13 @@ def test_road_transition_releases_camera_buffers(monkeypatch, module):
assert view._target_stream_type is None
assert view._switching is False
assert view._texture_needs_update
assert view._regressive_frame_count == 0
assert view.last_connection_attempt == 0.0
@pytest.mark.parametrize("module", (mici_cameraview, big_cameraview))
def test_transition_callback_does_not_retain_camera_view(monkeypatch, module):
def test_transition_callback_does_not_retain_camera_view(monkeypatch):
module = big_cameraview
class FakeClient:
pass
@@ -72,61 +73,50 @@ def test_transition_callback_does_not_retain_camera_view(monkeypatch, module):
assert callbacks == []
@pytest.mark.parametrize("module", (mici_cameraview, big_cameraview))
def test_stream_switch_releases_graphics_before_old_client(module):
def test_stream_switch_releases_graphics_before_old_client():
module = big_cameraview
events = []
class FakeClient:
pass
class FakeFrame:
pass
view = module.CameraView.__new__(module.CameraView)
view.client = FakeClient()
old_client_finalizer = weakref.finalize(view.client, events.append, "client")
old_client = FakeClient()
old_client_finalizer = weakref.finalize(old_client, events.append, "client")
old_frame = FakeFrame()
old_frame.frame_id = 10
old_frame.owner = old_client
old_frame_finalizer = weakref.finalize(old_frame, events.append, "frame")
view.client = old_client
view._target_client = FakeClient()
view._target_stream_type = object()
view._stream_type = object()
view._switching = True
view.frame = old_frame
view._regressive_frame_count = 2
view._texture_needs_update = False
view._closed = True
view._clear_textures = lambda: events.append("graphics")
view._initialize_textures = lambda: events.append("initialize")
del old_frame
del old_client
view._complete_switch()
view._complete_switch(SimpleNamespace(frame_id=11))
gc.collect()
assert old_client_finalizer.alive is False
assert events == ["graphics", "client", "initialize"]
assert old_frame_finalizer.alive is False
assert events == ["graphics", "frame", "client", "initialize"]
assert view._regressive_frame_count == 0
@pytest.mark.parametrize(("target_stream", "expected"), (
(mici_cameraview.VisionStreamType.VISION_STREAM_DRIVER, 1),
(mici_cameraview.VisionStreamType.VISION_STREAM_ROAD, 0),
(mici_cameraview.VisionStreamType.VISION_STREAM_WIDE_ROAD, 0),
))
def test_mici_stream_switch_updates_driver_enhancement(target_stream, expected):
class FakeClient:
frame_id = 42
def test_egl_cleanup_deletes_texture_before_images(monkeypatch):
module = big_cameraview
view = mici_cameraview.CameraView.__new__(mici_cameraview.CameraView)
view.client = FakeClient()
view._target_client = FakeClient()
view._target_stream_type = target_stream
view._stream_type = mici_cameraview.VisionStreamType.VISION_STREAM_DRIVER
view._switching = True
view._texture_needs_update = False
view._enhance_driver_val = [-1]
view._closed = True
view._clear_textures = lambda: None
view._initialize_textures = lambda: None
view._complete_switch()
assert view._enhance_driver_val[0] == expected
assert view._last_frame_id == -1
@pytest.mark.parametrize("module", (mici_cameraview, big_cameraview))
def test_egl_cleanup_deletes_texture_before_images(monkeypatch, module):
events = []
view = module.CameraView.__new__(module.CameraView)
view.texture_y = None
@@ -136,10 +126,7 @@ def test_egl_cleanup_deletes_texture_before_images(monkeypatch, module):
view.egl_images = {0: object(), 1: object()}
view._closed = True
if module is mici_cameraview:
view._use_egl = True
else:
monkeypatch.setattr(module, "TICI", True)
view._use_egl = True
monkeypatch.setattr(module.rl, "unload_texture", lambda _texture: events.append("texture"))
monkeypatch.setattr(module, "destroy_external_texture", lambda _texture: events.append("external"))
@@ -153,28 +140,72 @@ def test_egl_cleanup_deletes_texture_before_images(monkeypatch, module):
assert view.egl_images == {}
@pytest.mark.parametrize("module", (mici_cameraview, big_cameraview))
def test_egl_render_keeps_external_and_raylib_texture_targets_separate(monkeypatch, module):
frame = SimpleNamespace(idx=3, width=1928, height=1208, stride=2048, fd=9, uv_offset=2473984)
image = object()
def test_egl_cleanup_synchronizes_after_backend_switch(monkeypatch):
module = big_cameraview
events = []
view = module.CameraView.__new__(module.CameraView)
view.frame = frame
view.egl_texture = SimpleNamespace(id=7, width=1, height=1)
view.texture_y = None
view.texture_uv = None
view.egl_texture = SimpleNamespace(id=7)
view._external_texture_id = 11
view.egl_images = {frame.idx: image}
view.shader = object()
view.egl_images = {0: object()}
view._use_egl = False
view._closed = True
view._update_texture_color_filtering = lambda: None
bound = []
drawn = []
monkeypatch.setattr(module, "bind_egl_image_to_texture", lambda texture_id, egl_image: bound.append((texture_id, egl_image)))
monkeypatch.setattr(module.rl, "begin_shader_mode", lambda _shader: None)
monkeypatch.setattr(module.rl, "end_shader_mode", lambda: None)
monkeypatch.setattr(module.rl, "draw_texture_pro", lambda texture, *_args: drawn.append(texture.id))
monkeypatch.setattr(module, "is_egl_initialized", lambda: True)
monkeypatch.setattr(module.rl, "rl_draw_render_batch_active", lambda: events.append("flush"))
monkeypatch.setattr(module, "finish_gl", lambda: events.append("finish"))
monkeypatch.setattr(module.rl, "unload_texture", lambda _texture: events.append("texture"))
monkeypatch.setattr(module, "destroy_external_texture", lambda _texture: events.append("external"))
monkeypatch.setattr(module, "destroy_egl_image", lambda _image: events.append("image"))
rect = SimpleNamespace()
view._render_egl(rect, rect)
view._clear_textures()
assert bound == [(11, image)]
assert drawn == [7]
assert events == ["flush", "finish", "external", "texture", "image"]
def test_reverse_activation_cancels_mismatched_pending_switch():
view = mici_augmented_road_view.AugmentedRoadView.__new__(mici_augmented_road_view.AugmentedRoadView)
view._stream_type = mici_augmented_road_view.DRIVER_CAM
view._target_stream_type = mici_augmented_road_view.WIDE_CAM
view._target_client = object()
view._switching = True
view._closed = True
view._update_reverse_driver_camera_state = lambda: True
view._switch_stream_if_needed(None, mici_augmented_road_view.CAMERA_VIEW_AUTO)
assert view._target_client is None
assert view._target_stream_type is None
assert not view._switching
def test_onroad_transition_marks_camera_reentry(monkeypatch):
module = big_cameraview
class FakeClient:
pass
view = module.CameraView.__new__(module.CameraView)
view._name = "camerad"
view._stream_type = object()
view.client = FakeClient()
view.frame = None
view.available_streams = []
view._target_client = None
view._target_stream_type = None
view._switching = False
view._texture_needs_update = False
view._regressive_frame_count = 1
view._closed = True
view._onroad_reentry_pending = False
view._reentry_stream_selected = False
view._clear_textures = lambda: None
monkeypatch.setattr(module.ui_state, "is_onroad", lambda: True)
view._offroad_transition()
assert view._onroad_reentry_pending
assert not view._reentry_stream_selected
+6 -1
View File
@@ -200,10 +200,14 @@ class AugmentedRoadView(CameraView):
def _switch_stream_if_needed(self, sm, camera_view: int):
if camera_view == CAMERA_VIEW_NONE:
self._cancel_pending_switch()
self._reverse_driver_camera_frames = 0
self._reverse_driver_camera_active = False
return
if getattr(self, "_onroad_reentry_pending", False):
self._refresh_available_streams()
if self._update_reverse_driver_camera_state():
target = DRIVER_CAM
elif camera_view == CAMERA_VIEW_DRIVER:
@@ -224,7 +228,8 @@ class AugmentedRoadView(CameraView):
else:
target = ROAD_CAM
if self.stream_type != target:
if (getattr(self, "_onroad_reentry_pending", False) or
self.stream_type != target or (self._switching and self._target_stream_type != target)):
self.switch_stream(target)
def _update_calibration(self):
+337 -133
View File
@@ -1,3 +1,4 @@
import os
import platform
import weakref
import numpy as np
@@ -7,12 +8,17 @@ from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware import TICI
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.egl import (init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture,
create_external_texture, destroy_external_texture, EGLImage)
from openpilot.system.ui.lib.egl import (
init_egl, is_egl_initialized, finish_gl, create_egl_image, destroy_egl_image,
bind_egl_image_to_texture, create_external_texture, destroy_external_texture, EGLImage,
)
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.ui_state import ui_state
CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts
MICI_FORCE_TEXTURE_CAMERA = os.getenv("MICI_FORCE_TEXTURE_CAMERA", "0") == "1"
# One stale frame can be normal ring-buffer reuse; repeated consecutive regressions demote EGL.
EGL_REGRESSIVE_FRAME_FALLBACK_THRESHOLD = 3
VERSION = """
#version 300 es
@@ -39,32 +45,48 @@ void main() {
}
"""
# Choose fragment shader based on platform capabilities
if TICI:
FRAME_FRAGMENT_SHADER = """
#version 300 es
#extension GL_OES_EGL_image_external_essl3 : enable
precision mediump float;
in vec2 fragTexCoord;
uniform samplerExternalOES texture0;
out vec4 fragColor;
void main() {
vec4 color = texture(texture0, fragTexCoord);
fragColor = vec4(pow(color.rgb, vec3(1.0/1.28)), color.a);
FRAME_FRAGMENT_SHADER_EXTERNAL = """
#version 300 es
#extension GL_OES_EGL_image_external_essl3 : enable
precision mediump float;
in vec2 fragTexCoord;
uniform samplerExternalOES texture0;
uniform int enhance_driver;
out vec4 fragColor;
void main() {
vec4 color = texture(texture0, fragTexCoord);
color.rgb = pow(color.rgb, vec3(1.0/1.28));
if (enhance_driver == 1) {
float brightness = 1.1;
color.rgb = color.rgb + 0.15;
color.rgb = clamp((color.rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
color.rgb = color.rgb * color.rgb * (3.0 - 2.0 * color.rgb);
color.rgb = pow(color.rgb, vec3(0.8));
}
"""
else:
FRAME_FRAGMENT_SHADER = VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
uniform sampler2D texture1;
out vec4 fragColor;
void main() {
float y = texture(texture0, fragTexCoord).r;
vec2 uv = texture(texture1, fragTexCoord).ra - 0.5;
fragColor = vec4(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x, 1.0);
fragColor = vec4(color.rgb, color.a);
}
"""
FRAME_FRAGMENT_SHADER_YUV = VERSION + """
in vec2 fragTexCoord;
uniform sampler2D texture0;
uniform sampler2D texture1;
uniform int enhance_driver;
out vec4 fragColor;
void main() {
float y = texture(texture0, fragTexCoord).r;
vec2 uv = texture(texture1, fragTexCoord).ra - 0.5;
vec3 rgb = vec3(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x);
if (enhance_driver == 1) {
float brightness = 1.1;
rgb = rgb + 0.15;
rgb = clamp((rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
rgb = rgb * rgb * (3.0 - 2.0 * rgb);
rgb = pow(rgb, vec3(0.8));
}
"""
fragColor = vec4(rgb, 1.0);
}
"""
class CameraView(Widget):
@@ -72,7 +94,7 @@ class CameraView(Widget):
super().__init__()
self._name = name
# Primary stream
self.client = VisionIpcClient(name, stream_type, conflate=True)
self.client: VisionIpcClient | None = None
self._stream_type = stream_type
self.available_streams: list[VisionStreamType] = []
@@ -83,8 +105,18 @@ class CameraView(Widget):
self._texture_needs_update = True
self.last_connection_attempt: float = 0.0
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER)
self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1
self._use_egl = TICI and not MICI_FORCE_TEXTURE_CAMERA and init_egl()
if TICI and MICI_FORCE_TEXTURE_CAMERA:
cloudlog.warning("CameraView EGL disabled by MICI_FORCE_TEXTURE_CAMERA, using texture rendering")
elif TICI and not self._use_egl:
cloudlog.error("CameraView EGL init failed, falling back to texture rendering")
self._enhance_driver_val = rl.ffi.new("int[1]", [0])
self._load_frame_shader()
if self._use_egl and not self.shader.id:
cloudlog.error("CameraView EGL shader failed, falling back to texture rendering")
self._use_egl = False
self._load_frame_shader()
self.frame: VisionBuf | None = None
self._last_frame_id = -1
@@ -99,12 +131,17 @@ class CameraView(Widget):
self._placeholder_color: rl.Color | None = None
self._closed = False
self._onroad_reentry_pending = False
self._reentry_stream_selected = False
# Initialize EGL for zero-copy rendering on TICI
if TICI:
if not init_egl():
raise RuntimeError("Failed to initialize EGL")
self._create_egl_texture()
if self._use_egl and not self._create_egl_texture():
cloudlog.error("CameraView EGL texture creation failed, falling back to texture rendering")
self._use_egl = False
if self.shader and self.shader.id:
rl.unload_shader(self.shader)
self.shader.id = 0
self._load_frame_shader()
cloudlog.info(f"CameraView using {'EGL zero-copy' if self._use_egl else 'texture-copy'} rendering for {stream_type}")
self_ref = weakref.ref(self)
@@ -118,30 +155,43 @@ class CameraView(Widget):
def _offroad_transition(self):
self._reset_camera_connection()
def _reset_camera_connection(self):
# EGL images and VisionBuf objects both retain the imported camera buffer.
# Release them on every road-state transition instead of pinning the old
# camerad allocation until this view happens to render again.
def _retire_active_client(self) -> None:
"""Release graphics, frame, and client as one camera generation."""
self._clear_textures()
self.frame = None
self.client = None
def _reset_camera_connection(self):
self._cancel_pending_switch()
self._retire_active_client()
self._last_frame_id = -1
self._regressive_frame_count = 0
self.available_streams.clear()
self.client = VisionIpcClient(self._name, self._stream_type, conflate=True)
self._target_client = None
self._target_stream_type = None
self._switching = False
self._texture_needs_update = True
self.last_connection_attempt = 0.0
self._onroad_reentry_pending = ui_state.is_onroad()
self._reentry_stream_selected = False
def _set_placeholder_color(self, color: rl.Color):
"""Set a placeholder color to be drawn when no frame is available."""
self._placeholder_color = color
def _refresh_available_streams(self) -> None:
streams = VisionIpcClient.available_streams(self._name, block=False)
if streams:
self.available_streams = list(streams)
def switch_stream(self, stream_type: VisionStreamType) -> None:
if self._stream_type == stream_type:
if getattr(self, "_onroad_reentry_pending", False):
self._select_reentry_stream(stream_type)
return
if self._switching and self._target_stream_type == stream_type:
if self._switching:
if self._target_stream_type == stream_type:
return
self._cancel_pending_switch()
if self._stream_type == stream_type:
return
cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}')
@@ -153,6 +203,32 @@ class CameraView(Widget):
self._target_client = VisionIpcClient(self._name, stream_type, conflate=True)
self._switching = True
def _cancel_pending_switch(self) -> None:
if self._target_client is not None:
cloudlog.debug(f"Cancelling pending camera switch to {self._target_stream_type}")
self._target_client = None
self._target_stream_type = None
self._switching = False
def _discard_pending_client(self) -> None:
"""Discard a failed candidate while retaining the requested stream."""
self._target_client = None
self._switching = False
def _select_reentry_stream(self, stream_type: VisionStreamType) -> None:
"""Select the desired stream before displaying any post-transition frame."""
self._cancel_pending_switch()
if self._stream_type != stream_type:
self._retire_active_client()
self._stream_type = stream_type
self.frame = None
self._last_frame_id = -1
self._regressive_frame_count = 0
self._texture_needs_update = True
self._reentry_stream_selected = True
@property
def stream_type(self) -> VisionStreamType:
return self._stream_type
@@ -166,17 +242,19 @@ class CameraView(Widget):
if callback is not None:
ui_state.remove_offroad_transition_callback(callback)
self._offroad_transition_callback = None
self._clear_textures()
self._cancel_pending_switch()
self._retire_active_client()
# Clean up shader
if self.shader and self.shader.id:
rl.unload_shader(self.shader)
self.shader.id = 0
self.frame = None
self._last_frame_id = -1
self.available_streams.clear()
self.client = None
self._target_client = None
self._onroad_reentry_pending = False
self._reentry_stream_selected = False
def __del__(self):
self.close()
@@ -203,13 +281,15 @@ class CameraView(Widget):
if self._switching:
self._handle_switch()
if self._onroad_reentry_pending and not self._reentry_stream_selected:
# Standalone CameraView users have no higher-level stream selector.
self._select_reentry_stream(self._stream_type)
if not self._ensure_connection():
self._draw_placeholder(rect)
return
# An EGL image references camerad's reusable ring-buffer slot. Account for
# that slot advancing before accepting another (possibly older) slot.
if TICI:
if self._use_egl:
self._observe_displayed_frame()
# Try to get a new buffer without blocking
@@ -243,16 +323,34 @@ class CameraView(Widget):
dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y)
# Render with appropriate method
if TICI:
self._render_egl(src_rect, dst_rect)
else:
if self._use_egl:
try:
rendered = self._render_egl(src_rect, dst_rect)
except Exception:
cloudlog.exception("CameraView EGL rendering failed")
rendered = False
if not rendered:
self._fallback_to_textures("EGL frame rendering failed")
if not self._use_egl:
self._render_textures(src_rect, dst_rect)
def _draw_placeholder(self, rect: rl.Rectangle):
if self._placeholder_color:
rl.draw_rectangle_rec(rect, self._placeholder_color)
def _load_frame_shader(self) -> None:
frame_shader = FRAME_FRAGMENT_SHADER_EXTERNAL if self._use_egl else FRAME_FRAGMENT_SHADER_YUV
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, frame_shader)
self._texture1_loc = -1 if self._use_egl else rl.get_shader_location(self.shader, "texture1")
self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver")
def _update_shader_state(self) -> None:
self._enhance_driver_val[0] = 1 if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0
if self._enhance_driver_loc >= 0:
rl.set_shader_value(self.shader, self._enhance_driver_loc, self._enhance_driver_val,
rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
def _observe_displayed_frame(self) -> None:
if self.frame is not None:
client_frame_id = getattr(self.client, "frame_id", -1) if hasattr(self, "client") and self.client is not None else -1
@@ -261,50 +359,88 @@ class CameraView(Widget):
def _accept_frame(self, frame: VisionBuf, packet_frame_id: int) -> bool:
content_frame_id = int(getattr(frame, "frame_id", packet_frame_id))
if content_frame_id != packet_frame_id:
cloudlog.debug(
f"Dropping inconsistent {self._name} frame: content={content_frame_id}, packet={packet_frame_id}"
)
return False
if content_frame_id < self._last_frame_id:
self._regressive_frame_count += 1
if self._regressive_frame_count == 1 or self._regressive_frame_count % 100 == 0:
message = f"Dropping regressive {self._name} frame: content={content_frame_id}, packet={packet_frame_id}, "
message += f"displayed={self._last_frame_id}, idx={frame.idx}, count={self._regressive_frame_count}"
cloudlog.warning(message)
if getattr(self, "_use_egl", False) and self._regressive_frame_count >= EGL_REGRESSIVE_FRAME_FALLBACK_THRESHOLD:
self._fallback_to_textures("repeated regressive frames")
return False
self.frame = frame
self._last_frame_id = content_frame_id
self._regressive_frame_count = 0
self._texture_needs_update = True
self._onroad_reentry_pending = False
self._reentry_stream_selected = False
return True
def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
"""Render using EGL for direct buffer access"""
if self.frame is None or self.egl_texture is None or not self._external_texture_id:
return
def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> bool:
"""Render using EGL for direct buffer access."""
if self.frame is None or self.egl_texture is None or not self.egl_texture.id or not self._external_texture_id:
return False
idx = self.frame.idx
egl_image = self.egl_images.get(idx)
# Create EGL image if needed
if egl_image is None:
egl_image = create_egl_image(self.frame.width, self.frame.height, self.frame.stride, self.frame.fd, self.frame.uv_offset)
if egl_image:
self.egl_images[idx] = egl_image
else:
return
if egl_image is None:
return False
self.egl_images[idx] = egl_image
# Update texture dimensions to match current frame
self.egl_texture.width = self.frame.width
self.egl_texture.height = self.frame.height
# Bind the EGL image to our texture
bind_egl_image_to_texture(self._external_texture_id, egl_image)
# Render with shader
rl.begin_shader_mode(self.shader)
rl.draw_texture_pro(self.egl_texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
rl.end_shader_mode()
try:
self._update_shader_state()
rl.draw_texture_pro(self.egl_texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
finally:
rl.end_shader_mode()
return True
def _fallback_to_textures(self, reason: str) -> None:
if not self._use_egl:
return
cloudlog.error(f"CameraView switching from EGL to texture rendering: {reason}")
self._use_egl = False
try:
self._clear_textures()
except Exception:
cloudlog.exception("CameraView EGL cleanup failed during texture fallback")
if self.shader and self.shader.id:
try:
rl.unload_shader(self.shader)
except Exception:
cloudlog.exception("CameraView EGL shader cleanup failed during texture fallback")
self.shader.id = 0
try:
self._load_frame_shader()
self._initialize_textures()
self._texture_needs_update = True
except Exception:
cloudlog.exception("CameraView texture fallback initialization failed")
def _render_textures(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
"""Render using texture copies"""
if not self.texture_y or not self.texture_uv or self.frame is None:
"""Copy camera data into ordinary Raylib textures before drawing.
Raylib batches camera draws as GL_TEXTURE_2D. Imported EGL images are
GL_TEXTURE_EXTERNAL_OES objects and cannot safely pass through that path;
copying also prevents the GPU from sampling camerad's reusable buffers
after they have been handed back to the producer.
"""
if (self.texture_y is None or not self.texture_y.id or
self.texture_uv is None or not self.texture_uv.id or self.frame is None):
return
# Update textures with new frame data
@@ -318,33 +454,45 @@ class CameraView(Widget):
# Render with shader
rl.begin_shader_mode(self.shader)
rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv)
rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
rl.end_shader_mode()
try:
self._update_shader_state()
rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv)
rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
finally:
rl.end_shader_mode()
def _ensure_connection(self) -> bool:
if not self.client.is_connected():
self.frame = None
self._last_frame_id = -1
self.available_streams.clear()
if self.client is not None and self.client.is_connected():
return True
# Throttle connection attempts
current_time = rl.get_time()
if current_time - self.last_connection_attempt < CONNECTION_RETRY_INTERVAL:
return False
self.last_connection_attempt = current_time
# A pending candidate owns the connection attempt. Poll it until its first
# frame arrives instead of reconnecting the same client in place.
if self._switching:
self._handle_switch()
return self.client is not None and self.client.is_connected()
# A GL texture can retain the last EGL image after camerad exits. Release
# it before connect() frees and replaces the client's imported buffers.
self._clear_textures()
if not self.client.connect(False) or not self.client.num_buffers:
return False
if self.client is not None:
self._retire_active_client()
self._last_frame_id = -1
self._regressive_frame_count = 0
self.available_streams.clear()
cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}")
self._initialize_textures()
self.available_streams = self.client.available_streams(self._name, block=False)
# Throttle connection attempts
current_time = rl.get_time()
if current_time - self.last_connection_attempt < CONNECTION_RETRY_INTERVAL:
return False
self.last_connection_attempt = current_time
return True
# Do not create a client until camerad advertises the requested stream.
stream_type = self._target_stream_type or self._stream_type
if stream_type not in VisionIpcClient.available_streams(self._name, block=False):
return False
self._target_stream_type = stream_type
self._target_client = VisionIpcClient(self._name, stream_type, conflate=True)
self._switching = True
self._handle_switch()
return self.client is not None and self.client.is_connected()
def _handle_switch(self) -> None:
"""Check if target stream is ready and switch immediately."""
@@ -354,6 +502,7 @@ class CameraView(Widget):
# Try to connect target if needed
if not self._target_client.is_connected():
if not self._target_client.connect(False) or not self._target_client.num_buffers:
self._discard_pending_client()
return
cloudlog.debug(f"Target stream connected: {self._target_stream_type}")
@@ -361,71 +510,126 @@ class CameraView(Widget):
# Check if target has frames ready
target_frame = self._target_client.recv(timeout_ms=0)
if target_frame:
self.frame = target_frame # Update current frame to target frame
self._complete_switch()
packet_frame_id = int(getattr(self._target_client, "frame_id", -1))
content_frame_id = int(getattr(target_frame, "frame_id", packet_frame_id))
if content_frame_id != packet_frame_id:
message = f"Discarding inconsistent {self._name} target frame: content={content_frame_id}, "
message += f"packet={packet_frame_id}, stream={self._target_stream_type}"
cloudlog.warning(message)
self._discard_pending_client()
return
self._complete_switch(target_frame)
elif not self._target_client.is_connected():
# A failed recv can invalidate the server/buffer generation. Never
# reconnect this client; the next attempt must use a fresh candidate.
self._discard_pending_client()
def _complete_switch(self) -> None:
def _complete_switch(self, target_frame: VisionBuf) -> None:
"""Instantly switch to target stream."""
cloudlog.debug(f"Switching to {self._target_stream_type}")
# Delete the GL texture before releasing the old client. Merely destroying
# the EGLImage handle leaves its storage alive while a texture sibling exists.
self._clear_textures()
# Switch to target
self.client = self._target_client
self._stream_type = self._target_stream_type
client_frame_id = getattr(self.client, "frame_id", -1) if hasattr(self, "client") and self.client is not None else -1
frame = getattr(self, "frame", None)
self._last_frame_id = int(getattr(frame, "frame_id", client_frame_id)) if frame is not None else -1
self._texture_needs_update = True
# Reset state
target_client = self._target_client
target_stream_type = self._target_stream_type
self._target_client = None
self._target_stream_type = None
self._switching = False
# Retire the old generation before exposing the new client and frame.
self._retire_active_client()
# Switch to target
self.client = target_client
self._stream_type = target_stream_type
self.frame = target_frame
client_frame_id = getattr(self.client, "frame_id", -1) if self.client is not None else -1
self._last_frame_id = int(getattr(self.frame, "frame_id", client_frame_id)) if self.frame is not None else -1
self._regressive_frame_count = 0
self._texture_needs_update = True
self._onroad_reentry_pending = False
self._reentry_stream_selected = False
# Initialize textures for new stream
self._initialize_textures()
available_streams = getattr(self.client, "available_streams", None)
if available_streams is not None:
self.available_streams = available_streams(self._name, block=False)
def _initialize_textures(self):
self._clear_textures()
if TICI:
self._create_egl_texture()
if self._use_egl:
if not self._create_egl_texture():
self._fallback_to_textures("EGL texture creation failed")
else:
self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride),
int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE))
self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2),
int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA))
if not self.texture_y.id or not self.texture_uv.id:
cloudlog.error("CameraView texture-copy texture creation failed")
self._clear_textures()
def _create_egl_texture(self):
temp_image = rl.gen_image_color(1, 1, rl.BLACK)
self.egl_texture = rl.load_texture_from_image(temp_image)
rl.unload_image(temp_image)
self._external_texture_id = create_external_texture()
if not self._external_texture_id:
raise RuntimeError("Failed to create external camera texture")
def _clear_textures(self):
if self.texture_y and self.texture_y.id:
rl.unload_texture(self.texture_y)
self.texture_y = None
if self.texture_uv and self.texture_uv.id:
rl.unload_texture(self.texture_uv)
self.texture_uv = None
if TICI:
def _create_egl_texture(self) -> bool:
temp_image = None
try:
temp_image = rl.gen_image_color(1, 1, rl.BLACK)
texture = rl.load_texture_from_image(temp_image)
if texture is None or not texture.id:
self.egl_texture = None
return False
self.egl_texture = texture
self._external_texture_id = create_external_texture()
if not self._external_texture_id:
rl.unload_texture(self.egl_texture)
self.egl_texture = None
return False
return True
except Exception:
if self._external_texture_id:
destroy_external_texture(self._external_texture_id)
self._external_texture_id = 0
if self.egl_texture and self.egl_texture.id:
if self.egl_texture is not None and self.egl_texture.id:
rl.unload_texture(self.egl_texture)
self.egl_texture = None
cloudlog.exception("CameraView failed to create EGL texture")
return False
finally:
if temp_image is not None:
try:
rl.unload_image(temp_image)
except Exception:
cloudlog.exception("CameraView failed to unload temporary EGL image")
for data in self.egl_images.values():
destroy_egl_image(data)
self.egl_images = {}
def _clear_textures(self):
if ((self._external_texture_id or self.egl_texture is not None or self.egl_images) and is_egl_initialized()):
try:
# Raylib queues draw calls. Submit them before waiting for the GPU so
# no pending batch can still reference an EGL-backed texture.
rl.rl_draw_render_batch_active()
finish_gl()
except Exception:
cloudlog.exception("CameraView failed to synchronize EGL resources")
if self.texture_y is not None:
if self.texture_y.id:
rl.unload_texture(self.texture_y)
self.texture_y = None
if self.texture_uv is not None:
if self.texture_uv.id:
rl.unload_texture(self.texture_uv)
self.texture_uv = None
if self._external_texture_id:
destroy_external_texture(self._external_texture_id)
self._external_texture_id = 0
if self.egl_texture and self.egl_texture.id:
rl.unload_texture(self.egl_texture)
self.egl_texture = None
for data in self.egl_images.values():
destroy_egl_image(data)
self.egl_images = {}
if __name__ == "__main__":
+137 -22
View File
@@ -1,10 +1,12 @@
import os
import resource
import sys
import time
import traceback
import threading
from collections import deque
from collections import Counter, deque
from pathlib import Path
from typing import Any
from openpilot.common.swaglog import cloudlog
@@ -26,6 +28,10 @@ class UIStallMonitor:
self._name = name
self._threshold_s = float(os.getenv("UI_STALL_PROBE_MAX_DT", "5"))
self._poll_s = float(os.getenv("UI_STALL_PROBE_POLL_DT", "0.25"))
self._hitch_threshold_s = float(os.getenv("UI_HITCH_PROBE_MAX_DT", "0.25"))
self._hitch_report_interval_s = max(self._poll_s, float(os.getenv("UI_HITCH_REPORT_INTERVAL", "300")))
self._hitch_report_min_count = max(1, int(os.getenv("UI_HITCH_REPORT_MIN_COUNT", "3")))
self._hitch_log_interval_s = max(0.0, float(os.getenv("UI_HITCH_LOG_INTERVAL", "10")))
self._dump_dir = _default_dump_dir()
self._main_thread_id = threading.get_ident()
@@ -36,6 +42,14 @@ class UIStallMonitor:
self._stall_reported = False
self._stalled_since = now
self._stalled_phase = self._phase
self._context: dict[str, Any] = {}
self._hitch_counts: Counter[str] = Counter()
self._hitch_max_s: dict[str, float] = {}
self._recent_hitches = deque(maxlen=max(1, int(os.getenv("UI_HITCH_HISTORY_LEN", "16"))))
self._hitch_window_started = now
self._last_hitch_report = now
self._last_hitch_log = now - self._hitch_log_interval_s
self._lock = threading.Lock()
self._history = deque(maxlen=max(1, int(os.getenv("UI_STALL_HISTORY_LEN", "64"))))
@@ -44,31 +58,55 @@ class UIStallMonitor:
self._thread = threading.Thread(target=self._run, name=f"{name}_stall_probe", daemon=True)
def start(self) -> None:
if self._threshold_s <= 0.0:
if self._threshold_s <= 0.0 and self._hitch_threshold_s <= 0.0:
return
self._thread.start()
def stop(self) -> None:
if self._threshold_s <= 0.0:
if self._threshold_s <= 0.0 and self._hitch_threshold_s <= 0.0:
return
self._stop_event.set()
self._thread.join(timeout=1.0)
def set_context(self, context: dict[str, Any]) -> None:
with self._lock:
self._context = dict(context)
def progress(self, phase: str) -> None:
now = time.monotonic()
recovered = None
hitch_warning = None
with self._lock:
previous_phase = self._phase
phase_duration_s = now - self._last_progress
if phase != self._phase:
self._phase = phase
self._phase_entered = now
self._history.append((now, phase))
self._last_progress = now
if self._hitch_threshold_s > 0.0 and phase_duration_s >= self._hitch_threshold_s:
self._hitch_counts[previous_phase] += 1
self._hitch_max_s[previous_phase] = max(phase_duration_s, self._hitch_max_s.get(previous_phase, 0.0))
self._recent_hitches.append({
"phase": previous_phase,
"next_phase": phase,
"duration_ms": round(phase_duration_s * 1000.0, 1),
"monotonic": round(now, 3),
})
if now - self._last_hitch_log >= self._hitch_log_interval_s:
self._last_hitch_log = now
hitch_warning = (previous_phase, phase, phase_duration_s)
if self._stall_reported:
recovered = (now - self._stalled_since, self._stalled_phase, phase)
self._stall_reported = False
if hitch_warning is not None:
previous_phase, current_phase, duration_s = hitch_warning
cloudlog.warning(f"{self._name} frame hitch {duration_s * 1000.0:.0f}ms in phase={previous_phase} (next_phase={current_phase})")
if recovered is not None:
stalled_for_s, stalled_phase, current_phase = recovered
cloudlog.warning(f"{self._name} stall recovered after {stalled_for_s:.1f}s (stalled_phase={stalled_phase}, current_phase={current_phase})")
@@ -82,28 +120,43 @@ class UIStallMonitor:
phase_for_s = now - self._phase_entered
already_reported = self._stall_reported
if stalled_for_s < self._threshold_s or already_reported:
continue
should_report_stall = self._threshold_s > 0.0 and stalled_for_s >= self._threshold_s and not already_reported
if should_report_stall:
self._stall_reported = True
self._stalled_since = self._last_progress
self._stalled_phase = phase
dump = self._build_dump(now, phase, stalled_for_s, phase_for_s)
dump_path = self._write_dump(dump)
with self._lock:
self._stall_reported = True
self._stalled_since = now
self._stalled_phase = phase
if should_report_stall:
frames = sys._current_frames()
preview = self._main_thread_preview(frames)
dump = self._build_dump(now, phase, stalled_for_s, phase_for_s, frames=frames)
dump_path = self._write_dump(dump)
self._report_stall(dump, dump_path, phase, stalled_for_s, phase_for_s, preview=preview)
self._report_stall(dump, dump_path, phase, stalled_for_s, phase_for_s)
hitch_report = self._take_hitch_report(now)
if hitch_report is not None:
self._report_hitches(hitch_report)
def _report_stall(self, dump: str, dump_path: Path | None, phase: str, stalled_for_s: float, phase_for_s: float) -> None:
preview = self._main_thread_preview()
def _report_stall(self, dump: str, dump_path: Path | None, phase: str, stalled_for_s: float, phase_for_s: float,
preview: str | None = None) -> None:
preview = preview if preview is not None else self._main_thread_preview()
path_s = str(dump_path) if dump_path is not None else "<write_failed>"
with self._lock:
context = dict(self._context)
cloudlog.error(f"{self._name} main loop stalled for {stalled_for_s:.1f}s in phase={phase} (phase_for={phase_for_s:.1f}s) dump={path_s}\n{preview}")
tags = {
"ui_stall_name": self._name,
"ui_stall_phase": phase,
}
if "ui_mode" in context:
tags["ui_mode"] = str(context["ui_mode"])
if "started" in context:
tags["ui_onroad"] = str(bool(context["started"])).lower()
_capture_message(
"raylib UI main loop stalled",
tags={
"ui_stall_name": self._name,
"ui_stall_phase": phase,
},
tags=tags,
extras={
"pid": os.getpid(),
"stalled_for_s": round(stalled_for_s, 3),
@@ -111,13 +164,74 @@ class UIStallMonitor:
"dump_path": path_s,
"main_thread_stack": preview,
"thread_dump": dump,
"ui_context": context,
"runtime_metrics": self._runtime_metrics(),
},
attachment_path=dump_path,
flush_timeout=2.0,
)
def _build_dump(self, now: float, phase: str, stalled_for_s: float, phase_for_s: float) -> str:
frames = sys._current_frames()
def _take_hitch_report(self, now: float) -> dict[str, Any] | None:
with self._lock:
count = sum(self._hitch_counts.values())
if now - self._last_hitch_report < self._hitch_report_interval_s or count < self._hitch_report_min_count:
return None
report = {
"window_s": round(now - self._hitch_window_started, 3),
"hitch_threshold_ms": round(self._hitch_threshold_s * 1000.0, 1),
"total_hitches": count,
"phase_counts": dict(self._hitch_counts),
"phase_max_ms": {phase: round(duration_s * 1000.0, 1) for phase, duration_s in self._hitch_max_s.items()},
"recent_hitches": list(self._recent_hitches),
"ui_context": dict(self._context),
}
self._hitch_counts.clear()
self._hitch_max_s.clear()
self._recent_hitches.clear()
self._hitch_window_started = now
self._last_hitch_report = now
return report
def _report_hitches(self, report: dict[str, Any]) -> None:
phase_max_ms = report["phase_max_ms"]
worst_phase = max(phase_max_ms, key=phase_max_ms.get)
context = report["ui_context"]
tags = {
"ui_stall_name": self._name,
"ui_hitch_worst_phase": worst_phase,
}
if "ui_mode" in context:
tags["ui_mode"] = str(context["ui_mode"])
if "started" in context:
tags["ui_onroad"] = str(bool(context["started"])).lower()
_capture_message(
"raylib UI frame hitches",
level="warning",
tags=tags,
extras={**report, "runtime_metrics": self._runtime_metrics()},
flush_timeout=0.25,
)
@staticmethod
def _runtime_metrics() -> dict[str, Any]:
usage = resource.getrusage(resource.RUSAGE_SELF)
try:
load_average = [round(value, 3) for value in os.getloadavg()]
except OSError:
load_average = []
return {
"load_average": load_average,
"max_rss_kb": usage.ru_maxrss,
"user_cpu_s": round(usage.ru_utime, 3),
"system_cpu_s": round(usage.ru_stime, 3),
"thread_count": threading.active_count(),
}
def _build_dump(self, now: float, phase: str, stalled_for_s: float, phase_for_s: float,
frames: dict[int, Any] | None = None) -> str:
frames = frames if frames is not None else sys._current_frames()
threads = {thread.ident: thread for thread in threading.enumerate()}
lines = [
f"name={self._name}",
@@ -150,8 +264,9 @@ class UIStallMonitor:
return "".join(line if line.endswith("\n") else f"{line}\n" for line in lines)
def _main_thread_preview(self) -> str:
frame = sys._current_frames().get(self._main_thread_id)
def _main_thread_preview(self, frames: dict[int, Any] | None = None) -> str:
frames = frames if frames is not None else sys._current_frames()
frame = frames.get(self._main_thread_id)
if frame is None:
return "main_thread_stack=<unavailable>"
stack_lines = traceback.format_stack(frame)
+435 -9
View File
@@ -1,3 +1,5 @@
from types import SimpleNamespace
import pytest
from openpilot.selfdrive.ui.mici.onroad import cameraview as mici_cameraview
@@ -10,21 +12,101 @@ class FakeFrame:
self.idx = idx
def _camera_view(cameraview):
view = cameraview.CameraView.__new__(cameraview.CameraView)
def _camera_view():
view = big_cameraview.CameraView.__new__(big_cameraview.CameraView)
view._name = "camerad"
view._stream_type = big_cameraview.VisionStreamType.VISION_STREAM_ROAD
view.frame = None
view._last_frame_id = -1
view._regressive_frame_count = 0
view._texture_needs_update = False
view._external_texture_id = 0
view._closed = True
return view
@pytest.mark.parametrize("cameraview", [big_cameraview, mici_cameraview])
def test_reused_egl_slot_cannot_move_camera_backwards(monkeypatch, cameraview):
monkeypatch.setattr(cameraview.cloudlog, "warning", lambda *_args, **_kwargs: None)
view = _camera_view(cameraview)
def test_mici_uses_shared_camera_view():
assert mici_cameraview.CameraView is big_cameraview.CameraView
def test_pending_switch_is_cancelled_when_requested_stream_is_current():
view = _camera_view()
view._stream_type = big_cameraview.VisionStreamType.VISION_STREAM_ROAD
view._target_stream_type = big_cameraview.VisionStreamType.VISION_STREAM_DRIVER
view._target_client = object()
view._switching = True
view.switch_stream(big_cameraview.VisionStreamType.VISION_STREAM_ROAD)
assert view._target_client is None
assert view._target_stream_type is None
assert not view._switching
def test_onroad_reentry_selects_requested_stream_before_rendering(monkeypatch):
view = _camera_view()
view._name = "camerad"
view._stream_type = big_cameraview.VisionStreamType.VISION_STREAM_WIDE_ROAD
view.client = object()
view._target_client = object()
view._target_stream_type = big_cameraview.VisionStreamType.VISION_STREAM_ROAD
view._switching = True
view._onroad_reentry_pending = True
view._reentry_stream_selected = False
view._clear_textures = lambda: None
clients = []
class FakeClient:
def __init__(self, name, stream_type, conflate):
self.name = name
self.stream_type = stream_type
self.conflate = conflate
clients.append(self)
monkeypatch.setattr(big_cameraview, "VisionIpcClient", FakeClient)
view.switch_stream(big_cameraview.VisionStreamType.VISION_STREAM_ROAD)
assert clients == []
assert view.client is None
assert view.stream_type == big_cameraview.VisionStreamType.VISION_STREAM_ROAD
assert view._target_client is None
assert view._target_stream_type is None
assert not view._switching
assert view._reentry_stream_selected
def test_onroad_reentry_guard_clears_on_first_fresh_frame():
view = _camera_view()
view._onroad_reentry_pending = True
view._reentry_stream_selected = True
assert view._accept_frame(FakeFrame(frame_id=1, idx=0), packet_frame_id=1)
assert not view._onroad_reentry_pending
assert not view._reentry_stream_selected
def test_standalone_camera_reentry_selects_configured_stream():
view = _camera_view()
view._switching = False
view._onroad_reentry_pending = True
view._reentry_stream_selected = False
selected = []
placeholders = []
view._select_reentry_stream = lambda stream_type: (
selected.append(stream_type), setattr(view, "_reentry_stream_selected", True)
)
view._draw_placeholder = lambda rect: placeholders.append(rect)
view._ensure_connection = lambda: False
view._render(object())
assert selected == [view._stream_type]
assert len(placeholders) == 1
def test_reused_egl_slot_cannot_move_camera_backwards(monkeypatch):
monkeypatch.setattr(big_cameraview.cloudlog, "warning", lambda *_args, **_kwargs: None)
view = _camera_view()
displayed = FakeFrame(frame_id=10, idx=0)
assert view._accept_frame(displayed, packet_frame_id=10)
@@ -40,13 +122,357 @@ def test_reused_egl_slot_cannot_move_camera_backwards(monkeypatch, cameraview):
assert view._regressive_frame_count == 1
@pytest.mark.parametrize("cameraview", [big_cameraview, mici_cameraview])
def test_newer_camera_frame_is_accepted(cameraview):
view = _camera_view(cameraview)
def test_newer_camera_frame_is_accepted():
view = _camera_view()
view._last_frame_id = 30
view._regressive_frame_count = 2
newer = FakeFrame(frame_id=31, idx=2)
assert view._accept_frame(newer, packet_frame_id=31)
assert view.frame is newer
assert view._last_frame_id == 31
assert view._regressive_frame_count == 0
assert view._texture_needs_update
def test_shared_camera_has_upstream_shaders_and_driver_enhancement():
assert "samplerExternalOES" in big_cameraview.FRAME_FRAGMENT_SHADER_EXTERNAL
assert "pow(color.rgb, vec3(1.0/1.28))" in big_cameraview.FRAME_FRAGMENT_SHADER_EXTERNAL
assert "uniform sampler2D texture0" in big_cameraview.FRAME_FRAGMENT_SHADER_YUV
assert "uniform sampler2D texture1" in big_cameraview.FRAME_FRAGMENT_SHADER_YUV
assert "uniform int enhance_driver" in big_cameraview.FRAME_FRAGMENT_SHADER_EXTERNAL
assert "uniform int enhance_driver" in big_cameraview.FRAME_FRAGMENT_SHADER_YUV
assert "uniform int engaged" not in big_cameraview.FRAME_FRAGMENT_SHADER_EXTERNAL
assert "uniform int engaged" not in big_cameraview.FRAME_FRAGMENT_SHADER_YUV
assert hasattr(big_cameraview.CameraView, "_render_egl")
assert hasattr(big_cameraview.CameraView, "_fallback_to_textures")
def test_shared_camera_falls_back_after_repeated_regressive_frames(monkeypatch):
monkeypatch.setattr(big_cameraview.cloudlog, "warning", lambda *_args, **_kwargs: None)
view = _camera_view()
view._use_egl = True
view.frame = FakeFrame(frame_id=30, idx=0)
view._last_frame_id = 30
fallback_reasons = []
view._fallback_to_textures = fallback_reasons.append
for frame_id in (20, 19, 18):
assert not view._accept_frame(FakeFrame(frame_id=frame_id, idx=1), packet_frame_id=frame_id)
assert fallback_reasons == ["repeated regressive frames"]
assert view.frame.frame_id == 30
def test_shared_camera_fallback_reloads_texture_backend(monkeypatch):
view = _camera_view()
view._use_egl = True
view.shader = SimpleNamespace(id=1)
events = []
view._clear_textures = lambda: events.append("clear")
view._load_frame_shader = lambda: events.append(("shader", view._use_egl))
view._initialize_textures = lambda: events.append("textures")
monkeypatch.setattr(big_cameraview.cloudlog, "error", lambda *_args, **_kwargs: None)
monkeypatch.setattr(big_cameraview.rl, "unload_shader", lambda _shader: events.append("unload_shader"))
view._fallback_to_textures("test")
assert events == ["clear", "unload_shader", ("shader", False), "textures"]
assert not view._use_egl
def test_connection_retry_discards_failed_client_and_uses_fresh_candidate(monkeypatch):
view = _camera_view()
view._name = "camerad"
view._clear_textures = lambda: None
view.client = SimpleNamespace(is_connected=lambda: False)
view._target_client = None
view._target_stream_type = None
view._switching = False
view.available_streams = []
view.last_connection_attempt = 0.0
candidates = []
class FakeClient:
@staticmethod
def available_streams(_name, block=False):
return [view._stream_type]
def __init__(self, *_args, **_kwargs):
candidates.append(self)
self.connected = False
self.num_buffers = 0
def is_connected(self):
return self.connected
def connect(self, _block):
return False
monkeypatch.setattr(big_cameraview, "VisionIpcClient", FakeClient)
monkeypatch.setattr(big_cameraview.rl, "get_time", lambda: 1.0)
assert not view._ensure_connection()
assert view.client is None
assert len(candidates) == 1
monkeypatch.setattr(big_cameraview.rl, "get_time", lambda: 1.3)
assert not view._ensure_connection()
assert len(candidates) == 2
assert candidates[0] is not candidates[1]
def test_candidate_is_not_active_until_first_consistent_frame(monkeypatch):
view = _camera_view()
view._name = "camerad"
view._clear_textures = lambda: None
view._initialize_textures = lambda: None
view.client = None
view._target_client = None
view._target_stream_type = None
view._switching = False
view.available_streams = []
view.last_connection_attempt = 0.0
class FakeClient:
@staticmethod
def available_streams(_name, block=False):
return [view._stream_type]
def __init__(self, *_args, **_kwargs):
self.connected = False
self.num_buffers = 1
self.frame_id = -1
self.frames = [None, FakeFrame(frame_id=42, idx=0)]
def is_connected(self):
return self.connected
def connect(self, _block):
self.connected = True
return True
def recv(self, timeout_ms=0):
frame = self.frames.pop(0)
if frame is not None:
self.frame_id = frame.frame_id
return frame
monkeypatch.setattr(big_cameraview, "VisionIpcClient", FakeClient)
monkeypatch.setattr(big_cameraview.rl, "get_time", lambda: 1.0)
assert not view._ensure_connection()
assert view.client is None
candidate = view._target_client
assert candidate is not None
assert view._ensure_connection()
assert view.client is candidate
assert view.frame.frame_id == 42
assert view._target_client is None
assert not view._switching
def test_inconsistent_candidate_frame_is_discarded(monkeypatch):
view = _camera_view()
view._name = "camerad"
view._clear_textures = lambda: None
view.client = None
view._target_client = None
view._target_stream_type = None
view._switching = False
view.available_streams = []
view.last_connection_attempt = 0.0
class FakeClient:
@staticmethod
def available_streams(_name, block=False):
return [view._stream_type]
def __init__(self, *_args, **_kwargs):
self.connected = False
self.num_buffers = 1
self.frame_id = 10
def is_connected(self):
return self.connected
def connect(self, _block):
self.connected = True
return True
def recv(self, timeout_ms=0):
return FakeFrame(frame_id=9, idx=0)
monkeypatch.setattr(big_cameraview, "VisionIpcClient", FakeClient)
monkeypatch.setattr(big_cameraview.rl, "get_time", lambda: 1.0)
assert not view._ensure_connection()
assert view.client is None
assert view._target_client is None
assert view._target_stream_type == view._stream_type
assert not view._switching
def test_disconnected_candidate_is_discarded_without_reconnect():
view = _camera_view()
class Candidate:
num_buffers = 1
def __init__(self):
self.connected = True
def is_connected(self):
return self.connected
def connect(self, _block):
pytest.fail("discarded candidate was reconnected")
def recv(self, timeout_ms=0):
self.connected = False
return None
candidate = Candidate()
view._target_client = candidate
view._target_stream_type = view._stream_type
view._switching = True
view._handle_switch()
assert view._target_client is None
assert not view._switching
assert view._target_stream_type == view._stream_type
def test_steady_state_packet_content_mismatch_is_rejected():
view = _camera_view()
displayed = FakeFrame(frame_id=10, idx=0)
assert view._accept_frame(displayed, packet_frame_id=10)
delayed = FakeFrame(frame_id=12, idx=1)
assert not view._accept_frame(delayed, packet_frame_id=11)
assert view.frame is displayed
assert view._last_frame_id == 10
def test_egl_image_creation_failure_is_reported(monkeypatch):
view = _camera_view()
view.frame = SimpleNamespace(idx=0, width=1928, height=1208, stride=2048, fd=7, uv_offset=2473984)
view.egl_texture = SimpleNamespace(id=1)
view._external_texture_id = 11
view.egl_images = {}
monkeypatch.setattr(big_cameraview, "create_egl_image", lambda *_args: None)
assert not view._render_egl(None, None)
assert view.egl_images == {}
def test_invalid_egl_texture_is_reported_without_binding(monkeypatch):
view = _camera_view()
view.frame = SimpleNamespace(idx=0)
view.egl_texture = SimpleNamespace(id=0)
view._external_texture_id = 11
view.egl_images = {0: object()}
monkeypatch.setattr(big_cameraview, "bind_egl_image_to_texture",
lambda *_args: pytest.fail("invalid EGL texture was bound"))
assert not view._render_egl(None, None)
def test_invalid_external_texture_is_reported_without_binding(monkeypatch):
view = _camera_view()
view.frame = SimpleNamespace(idx=0)
view.egl_texture = SimpleNamespace(id=7)
view.egl_images = {0: object()}
monkeypatch.setattr(big_cameraview, "bind_egl_image_to_texture",
lambda *_args: pytest.fail("invalid external texture was bound"))
assert not view._render_egl(None, None)
def test_egl_render_always_ends_shader_mode(monkeypatch):
view = _camera_view()
view.frame = SimpleNamespace(idx=0, width=1928, height=1208)
view.egl_texture = SimpleNamespace(id=1, width=0, height=0)
view._external_texture_id = 11
view.egl_images = {0: object()}
view.shader = SimpleNamespace(id=1)
view._update_shader_state = lambda: None
events = []
monkeypatch.setattr(big_cameraview, "bind_egl_image_to_texture", lambda *_args: None)
monkeypatch.setattr(big_cameraview.rl, "begin_shader_mode", lambda *_args: events.append("begin"))
def fail_draw(*_args):
raise RuntimeError("draw failed")
monkeypatch.setattr(big_cameraview.rl, "draw_texture_pro", fail_draw)
monkeypatch.setattr(big_cameraview.rl, "end_shader_mode", lambda: events.append("end"))
with pytest.raises(RuntimeError, match="draw failed"):
view._render_egl(None, None)
assert events == ["begin", "end"]
def test_egl_render_keeps_external_and_raylib_texture_targets_separate(monkeypatch):
view = _camera_view()
image = object()
view.frame = SimpleNamespace(idx=3, width=1928, height=1208)
view.egl_texture = SimpleNamespace(id=7, width=1, height=1)
view._external_texture_id = 11
view.egl_images = {3: image}
view.shader = object()
view._update_shader_state = lambda: None
bound = []
drawn = []
monkeypatch.setattr(big_cameraview, "bind_egl_image_to_texture",
lambda texture_id, egl_image: bound.append((texture_id, egl_image)))
monkeypatch.setattr(big_cameraview.rl, "begin_shader_mode", lambda _shader: None)
monkeypatch.setattr(big_cameraview.rl, "end_shader_mode", lambda: None)
monkeypatch.setattr(big_cameraview.rl, "draw_texture_pro", lambda texture, *_args: drawn.append(texture.id))
assert view._render_egl(None, None)
assert bound == [(11, image)]
assert drawn == [7]
def test_driver_enhancement_tracks_active_stream(monkeypatch):
view = _camera_view()
view.shader = SimpleNamespace(id=1)
view._enhance_driver_loc = 2
view._enhance_driver_val = [0]
values = []
monkeypatch.setattr(big_cameraview.rl, "set_shader_value",
lambda _shader, _loc, value, _type: values.append(value[0]))
view._stream_type = big_cameraview.VisionStreamType.VISION_STREAM_ROAD
view._update_shader_state()
view._stream_type = big_cameraview.VisionStreamType.VISION_STREAM_DRIVER
view._update_shader_state()
view._stream_type = big_cameraview.VisionStreamType.VISION_STREAM_WIDE_ROAD
view._update_shader_state()
assert values == [0, 1, 0]
def test_texture_fallback_survives_egl_cleanup_failure(monkeypatch):
view = _camera_view()
view._use_egl = True
view.shader = SimpleNamespace(id=1)
events = []
def fail_cleanup():
raise RuntimeError("cleanup failed")
view._clear_textures = fail_cleanup
view._load_frame_shader = lambda: events.append(("shader", view._use_egl))
view._initialize_textures = lambda: events.append("textures")
monkeypatch.setattr(big_cameraview.cloudlog, "error", lambda *_args, **_kwargs: None)
monkeypatch.setattr(big_cameraview.cloudlog, "exception", lambda *_args, **_kwargs: None)
monkeypatch.setattr(big_cameraview.rl, "unload_shader", lambda _shader: events.append("unload_shader"))
view._fallback_to_textures("test")
assert not view._use_egl
assert events == ["unload_shader", ("shader", False), "textures"]
+71
View File
@@ -23,3 +23,74 @@ def test_stall_report_is_sent_to_bugsink(monkeypatch, tmp_path):
assert "test_stall_report_is_sent_to_bugsink" in report["extras"]["main_thread_stack"]
assert report["extras"]["thread_dump"] == dump
assert report["attachment_path"] == dump_path
def test_phase_hitches_are_aggregated(monkeypatch):
now = [100.0]
monkeypatch.setattr(stall_monitor.time, "monotonic", lambda: now[0])
monkeypatch.setattr(stall_monitor.cloudlog, "warning", lambda *_args, **_kwargs: None)
monitor = stall_monitor.UIStallMonitor("raylib_ui")
monitor._hitch_report_interval_s = 1.0
monitor._hitch_report_min_count = 2
monitor.set_context({"ui_mode": "small", "started": True})
monitor.progress("gui_app.before_widget_render")
now[0] += 0.4
monitor.progress("gui_app.after_widget_render")
now[0] += 0.3
monitor.progress("gui_app.before_end_drawing")
now[0] += 0.4
report = monitor._take_hitch_report(now[0])
assert report is not None
assert report["total_hitches"] == 2
assert report["phase_counts"] == {
"gui_app.before_widget_render": 1,
"gui_app.after_widget_render": 1,
}
assert report["phase_max_ms"]["gui_app.before_widget_render"] == 400.0
assert report["ui_context"] == {"ui_mode": "small", "started": True}
def test_hitch_report_is_rate_limited_and_sent_to_bugsink(monkeypatch):
reports = []
monkeypatch.setattr(stall_monitor, "_capture_message", lambda message, **kwargs: reports.append((message, kwargs)))
monitor = stall_monitor.UIStallMonitor("raylib_ui")
monitor._hitch_counts.update({"gui_app.before_end_drawing": 3})
monitor._hitch_max_s["gui_app.before_end_drawing"] = 0.75
monitor._hitch_report_interval_s = 10.0
monitor._hitch_report_min_count = 3
monitor._last_hitch_report = 5.0
monitor._hitch_window_started = 5.0
monitor.set_context({"ui_mode": "small", "started": False})
assert monitor._take_hitch_report(14.9) is None
report = monitor._take_hitch_report(15.0)
assert report is not None
monitor._report_hitches(report)
message, kwargs = reports[0]
assert message == "raylib UI frame hitches"
assert kwargs["level"] == "warning"
assert kwargs["tags"] == {
"ui_stall_name": "raylib_ui",
"ui_hitch_worst_phase": "gui_app.before_end_drawing",
"ui_mode": "small",
"ui_onroad": "false",
}
assert kwargs["extras"]["total_hitches"] == 3
assert monitor._take_hitch_report(30.0) is None
def test_stall_report_uses_captured_stack_preview(monkeypatch, tmp_path):
report = {}
monkeypatch.setattr(stall_monitor, "_capture_message", lambda _message, **kwargs: report.update(kwargs))
monitor = stall_monitor.UIStallMonitor("raylib_ui")
dump_path = monitor._write_dump("thread dump")
monitor._report_stall("thread dump", dump_path, "gui_app.before_end_drawing", 5.0, 5.0,
preview="main_thread_stack:\ncaptured before recovery")
assert report["extras"]["main_thread_stack"] == "main_thread_stack:\ncaptured before recovery"
@@ -0,0 +1,35 @@
import threading
from openpilot.selfdrive.ui.lib.ui_param_cache import UIParamCache
from openpilot.selfdrive.ui import ui_state as ui_state_module
def test_raylib_ui_uses_read_through_param_cache():
assert isinstance(ui_state_module.ui_state.ui_params, UIParamCache)
assert ui_state_module.ui_state.ui_params is not ui_state_module.ui_state.params
def test_usbgpu_poll_does_not_block_ui_thread(monkeypatch):
started = threading.Event()
release = threading.Event()
def poll():
started.set()
release.wait(timeout=1.0)
return True
monkeypatch.setattr(ui_state_module, "chestnut_present", poll)
state = object.__new__(ui_state_module.UIState)
state.usbgpu = False
state._usbgpu_update_time = 0.0
state._usbgpu_poll_thread = None
state._schedule_usbgpu_poll(now=1.0, force=True)
assert started.wait(timeout=0.2)
polling_thread = state._usbgpu_poll_thread
state._schedule_usbgpu_poll(now=2.0, force=True)
assert state._usbgpu_poll_thread is polling_thread
release.set()
polling_thread.join(timeout=1.0)
assert state.usbgpu is True
+37
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import os
import time
from openpilot.system.hardware import TICI
from openpilot.common.realtime import config_realtime_process, set_core_affinity
@@ -11,6 +12,36 @@ from openpilot.selfdrive.ui.ui_state import ui_state
BIG_UI = gui_app.big_ui()
def _stall_context() -> dict[str, object]:
active_widget = gui_app.get_active_widget()
context = {
"ui_mode": "big" if BIG_UI else "small",
"started": ui_state.started,
"ignition": ui_state.ignition,
"engaged": ui_state.engaged,
"render_frame": gui_app.frame,
"ui_state_frame": ui_state.sm.frame,
"target_fps": gui_app.target_fps,
"active_widget": type(active_widget).__name__ if active_widget is not None else "none",
}
try:
device_state = ui_state.sm["deviceState"]
context.update({
"device_state_valid": bool(ui_state.sm.valid["deviceState"]),
"memory_usage_percent": int(device_state.memoryUsagePercent),
"gpu_usage_percent": int(device_state.gpuUsagePercent),
"max_cpu_usage_percent": max((int(value) for value in device_state.cpuUsagePercent), default=0),
"max_cpu_temp_c": round(max((float(value) for value in device_state.cpuTempC), default=0.0), 1),
"max_gpu_temp_c": round(max((float(value) for value in device_state.gpuTempC), default=0.0), 1),
"thermal_status": str(device_state.thermalStatus),
})
except Exception:
pass
return context
def main():
cores = {5, }
config_realtime_process(0, 51)
@@ -32,8 +63,10 @@ def main():
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
MiciMainLayout()
stall_monitor.progress("ui.after_layout_init")
stall_monitor.set_context(_stall_context())
kick_watchdog()
stall_monitor.progress("ui.loop_ready")
context_update_time = 0.0
for should_render in gui_app.render():
stall_monitor.progress("ui.loop_iteration")
@@ -41,6 +74,10 @@ def main():
stall_monitor.progress("ui.after_watchdog")
ui_state.update()
stall_monitor.progress("ui.after_state_update")
now = time.monotonic()
if now - context_update_time >= 1.0:
stall_monitor.set_context(_stall_context())
context_update_time = now
if should_render:
# reaffine after power save offlines our core
if TICI and os.sched_getaffinity(0) != cores:
+22 -8
View File
@@ -36,9 +36,7 @@ class UIState:
def _initialize(self):
self.params = Params()
# BIG UI views use this read-through cache; keep ``params`` untouched for
# MICI and for non-rendering callers that rely on its exact semantics.
self.ui_params = shared_ui_params() if gui_app.big_ui() else self.params
self.ui_params = shared_ui_params()
self.params_memory = Params(memory=True)
self.sm = messaging.SubMaster(
[
@@ -87,10 +85,11 @@ class UIState:
self.is_metric: bool = self.params.get_bool("IsMetric")
self.is_release = self.params.get_bool("IsReleaseBranch")
self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM")
self.usbgpu: bool = chestnut_present()
self.usbgpu: bool = False
self.usbgpu_compiled: bool = self.params.get_bool("UsbGpuCompiled")
self.usbgpu_active: bool = self.params.get_bool("UsbGpuActive")
self._usbgpu_update_time: float = 0.0
self._usbgpu_poll_thread: threading.Thread | None = None
self.started: bool = False
self.ignition: bool = False
self.recording_audio: bool = False
@@ -125,8 +124,26 @@ class UIState:
self._offroad_transition_callbacks: list[Callable[[], None]] = []
self._engaged_transition_callbacks: list[Callable[[], None]] = []
self._schedule_usbgpu_poll(force=True)
self.update_params()
def _poll_usbgpu_presence(self) -> None:
try:
self.usbgpu = chestnut_present()
except Exception:
cloudlog.exception("USB GPU presence poll failed")
def _schedule_usbgpu_poll(self, now: float | None = None, force: bool = False) -> None:
now = time.monotonic() if now is None else now
if not force and now - self._usbgpu_update_time < USBGPU_POLL_INTERVAL:
return
if self._usbgpu_poll_thread is not None and self._usbgpu_poll_thread.is_alive():
return
self._usbgpu_update_time = now
self._usbgpu_poll_thread = threading.Thread(target=self._poll_usbgpu_presence, name="ui_usbgpu_poll", daemon=True)
self._usbgpu_poll_thread.start()
def add_offroad_transition_callback(self, callback: Callable[[], None]):
self._offroad_transition_callbacks.append(callback)
@@ -180,7 +197,6 @@ class UIState:
self.light_sensor = -1
# Trust hardwared's filtered started state; raw ignition can flap on Toyota.
# Use the BIG-UI cache here as this path runs once per render iteration.
params = self.ui_params
force_onroad = params.get_bool("ForceOnroad")
force_offroad = params.get_bool("ForceOffroad")
@@ -195,9 +211,7 @@ class UIState:
self.is_metric = params.get_bool("IsMetric")
self.always_on_dm = params.get_bool("AlwaysOnDM")
now = time.monotonic()
if now - self._usbgpu_update_time >= USBGPU_POLL_INTERVAL:
self.usbgpu = chestnut_present()
self._usbgpu_update_time = now
self._schedule_usbgpu_poll(now)
self.usbgpu_compiled = params.get_bool("UsbGpuCompiled")
self.usbgpu_active = params.get_bool("UsbGpuActive")
self.switchback_mode_enabled = self.params_memory.get_bool("SwitchbackModeEnabled") if self.started else False
@@ -19,7 +19,7 @@ import { ModelManager } from "/assets/components/tools/model_manager.js?v=202603
import { LivePlots } from "/assets/components/tools/plots.js"
import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
import { TestingGround } from "/assets/components/tools/testing_ground.js"
import { Tuning } from "/assets/components/tools/tuning.js?v=flm-workspace-9"
import { Tuning } from "/assets/components/tools/tuning.js?v=flm-saved-tunes-1"
import { Troubleshoot } from "/assets/components/tools/troubleshoot.js"
import { TmuxLog } from "/assets/components/tools/tmux.js"
import { ToggleControl } from "/assets/components/tools/toggles.js"
@@ -93,7 +93,6 @@
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
display: flex;
gap: var(--gap-sm);
padding: var(--padding-sm);
@@ -105,6 +104,7 @@
}
.flmRouteItem {
cursor: pointer;
flex: 1 1 auto;
min-width: 0;
}
@@ -141,13 +141,20 @@
flex: 0 0 auto;
}
.flmSavedTuneActions {
align-items: stretch;
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: var(--gap-xs);
}
.flmRouteItem small,
.flmWorkspaceItem small,
.flmWorkspaceItem span {
color: var(--text-muted);
}
.flmWorkspaceItem:hover,
.flmRouteItem:hover,
.flmCard button.selected {
border-color: var(--main-fg);
@@ -387,4 +394,13 @@
font-size: var(--font-size-sm);
grid-template-columns: minmax(8rem, 1.3fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
}
.flmWorkspaceRow {
flex-direction: column;
}
.flmSavedTuneActions {
flex-direction: row;
flex-wrap: wrap;
}
}
@@ -16,7 +16,7 @@ const state = reactive({
routeProgress: 0,
routeTotal: 0,
connectDongleId: "",
workspace: { reports: [], activeTrial: null, status: {} },
workspace: { reports: [], savedTunes: [], activeTrial: null, status: {} },
status: {},
report: null,
feedbackAccepted: [],
@@ -258,29 +258,6 @@ async function deleteReport(reportId) {
}
}
async function clearWorkspace() {
if (state.runningAction) return
if (!window.confirm("Clear every saved tuning report, feedback entry, generated profile, and snapshot from the device?")) return
state.runningAction = true
try {
const response = await fetch("/api/flm/workspace/clear", { method: "POST" })
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to clear tuning workspace.")
state.report = null
syncFeedbackState(null)
state.workspace = payload.workspace || { reports: [], activeTrial: null, status: {} }
state.status = { ...state.status, ...(payload.workspace?.status || {}) }
showSnackbar(payload.message || "Cleared tuning workspace.")
} catch (error) {
state.error = error?.message || "Failed to clear tuning workspace."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
async function fetchStatus() {
try {
const response = await fetch("/api/flm/status")
@@ -291,7 +268,12 @@ async function fetchStatus() {
isOnroad: !!payload.isOnroad,
}
if (payload.activeTrial !== undefined) {
state.workspace = { ...state.workspace, activeTrial: payload.activeTrial, reports: payload.reports || state.workspace.reports }
state.workspace = {
...state.workspace,
activeTrial: payload.activeTrial,
reports: payload.reports || state.workspace.reports,
savedTunes: payload.savedTunes || state.workspace.savedTunes,
}
}
const reportId = state.status.reportId
if (reportId && state.report?.reportId !== reportId) {
@@ -431,6 +413,95 @@ async function applyProfile(profileId) {
}
}
async function saveCurrentTune() {
if (state.runningAction || !state.workspace?.activeTrial) return
const defaultName = state.workspace.activeTrial.profileLabel || state.workspace.currentCarFingerprint || "Saved Tune"
const name = window.prompt("Name this tune", defaultName)
if (name === null) return
state.runningAction = true
try {
const response = await fetch("/api/flm/saved-tunes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to save the active tune.")
state.error = ""
state.workspace = payload.workspace || state.workspace
showSnackbar(payload.message || "Saved the active tune.")
} catch (error) {
state.error = error?.message || "Failed to save the active tune."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
async function applySavedTune(tuneId) {
if (!tuneId || state.runningAction) return
state.runningAction = true
try {
const response = await fetch(`/api/flm/saved-tunes/${encodeURIComponent(tuneId)}/apply`, { method: "POST" })
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to apply saved tune.")
state.error = ""
state.workspace = payload.workspace || state.workspace
showSnackbar(payload.message || "Saved tune applied.")
} catch (error) {
state.error = error?.message || "Failed to apply saved tune."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
async function renameSavedTune(tune) {
if (!tune?.tuneId || state.runningAction) return
const name = window.prompt("Rename saved tune", tune.name || "Saved Tune")
if (name === null) return
state.runningAction = true
try {
const response = await fetch(`/api/flm/saved-tunes/${encodeURIComponent(tune.tuneId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to rename saved tune.")
state.error = ""
state.workspace = payload.workspace || state.workspace
showSnackbar(payload.message || "Saved tune renamed.")
} catch (error) {
state.error = error?.message || "Failed to rename saved tune."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
async function deleteSavedTune(tune) {
if (!tune?.tuneId || state.runningAction) return
if (!window.confirm(`Delete saved tune "${tune.name || "Saved Tune"}"?`)) return
state.runningAction = true
try {
const response = await fetch(`/api/flm/saved-tunes/${encodeURIComponent(tune.tuneId)}`, { method: "DELETE" })
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to delete saved tune.")
state.error = ""
state.workspace = payload.workspace || state.workspace
showSnackbar(payload.message || "Saved tune deleted.")
} catch (error) {
state.error = error?.message || "Failed to delete saved tune."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
async function selectPath(pathKey) {
if (!state.report?.reportId || !pathKey || state.runningAction) return
if (pathKey === (state.report.selectedPathKey || state.report.primaryPathKey)) return
@@ -596,8 +667,19 @@ function allReportProfiles() {
function activeTrialProfile() {
const activeTrial = state.workspace?.activeTrial
if (!activeTrial || activeTrial.reportId !== state.report?.reportId) return null
return allReportProfiles().find((profile) => profile.id === activeTrial.profileId) || null
if (!activeTrial) return null
if (activeTrial.reportId === state.report?.reportId) {
const reportProfile = allReportProfiles().find((profile) => profile.id === activeTrial.profileId)
if (reportProfile) return reportProfile
}
return {
id: activeTrial.profileId,
genericParams: activeTrial.appliedGenericParams || {},
flmOverrides: {
baseFrictionThresholds: activeTrial.appliedFrictionThresholds || {},
vehicleKnobs: activeTrial.appliedVehicleKnobs || {},
},
}
}
function mergedFlmOverrides() {
@@ -1020,6 +1102,12 @@ export function Tuning() {
@click="${revertProfile}">
Revert Trial
</button>
<button
class="longManeuverButton"
disabled="${() => state.runningAction || !state.workspace?.activeTrial}"
@click="${saveCurrentTune}">
Save Tune
</button>
${() => state.workspace?.activeTrial?.rollbackAvailable === false ? html`
<button
class="longManeuverButton"
@@ -1054,7 +1142,7 @@ export function Tuning() {
<p><strong>Updated:</strong> ${() => formatStatusAge(state.status?.updatedAt)}</p>
<p><strong>Selected Routes:</strong> ${() => state.selectedRoutes.length}</p>
<p><strong>Progress:</strong> ${() => `${safeCount(state.status?.progress)}/${safeCount(state.status?.total)}`}</p>
<p><strong>Active Trial:</strong> ${() => state.workspace?.activeTrial?.profileId || "None"}</p>
<p><strong>Active Trial:</strong> ${() => state.workspace?.activeTrial?.profileLabel || state.workspace?.activeTrial?.profileId || "None"}</p>
</div>
${() => state.status?.isOnroad ? html`
@@ -1147,37 +1235,54 @@ export function Tuning() {
<section class="flmCard">
<div class="flmCardHeader">
<div>
<h3>Workspace</h3>
<h3>Saved Tunes</h3>
</div>
<button
class="longManeuverButton danger"
disabled="${() => state.runningAction || !(state.workspace?.reports || []).length}"
@click="${clearWorkspace}">
Clear Workspace
class="longManeuverButton"
disabled="${() => state.runningAction || !state.workspace?.activeTrial}"
@click="${saveCurrentTune}">
Save Current
</button>
</div>
${() => state.loadingWorkspace ? html`<p class="longManeuverMuted">Loading workspace...</p>` : ""}
${() => state.loadingWorkspace ? html`<p class="longManeuverMuted">Loading saved tunes...</p>` : ""}
<p class="longManeuverMuted">
Recent reports stay on-device under <code>/data/galaxy/flm</code>. Loading a report refreshes the suggestion and trial view below.
Save a working FLM trial, switch between vehicle or trailer setups, then use Revert Trial to return to the exact manual settings from before FLM.
</p>
<div class="flmWorkspaceList">
${() => (state.workspace?.reports || []).length
? state.workspace.reports.map((report) => html`
${() => (state.workspace?.savedTunes || []).length
? state.workspace.savedTunes.map((tune) => html`
<div class="flmWorkspaceRow">
<button class="flmWorkspaceItem" @click="${() => loadReport(report.reportId)}">
<strong>${report.carFingerprint || "Unknown car"}</strong>
<span>${(report.routeNames || []).join(", ")}</span>
<small>${formatTimestamp(report.createdAt ? new Date(report.createdAt * 1000).toISOString() : "")}</small>
</button>
<button
class="longManeuverButton danger flmWorkspaceDelete"
disabled="${() => state.runningAction}"
@click="${() => deleteReport(report.reportId)}">
Delete
</button>
<div class="flmWorkspaceItem">
<strong>${tune.name || "Saved Tune"}${tune.active ? " (Active)" : ""}</strong>
<span>${tune.carFingerprint || "Unknown car"}${tune.pathLabel ? ` / ${tune.pathLabel}` : ""}</span>
<small>
${tune.genericParamCount} generic, ${tune.frictionCurveCount} friction curve, ${tune.vehicleKnobCount} vehicle knobs
</small>
<small>${formatTimestamp(tune.updatedAt ? new Date(tune.updatedAt * 1000).toISOString() : "")}</small>
</div>
<div class="flmSavedTuneActions">
<button
class="longManeuverButton"
disabled="${() => state.runningAction || tune.active}"
@click="${() => applySavedTune(tune.tuneId)}">
${tune.active ? "Active" : "Apply"}
</button>
<button
class="longManeuverButton"
disabled="${() => state.runningAction}"
@click="${() => renameSavedTune(tune)}">
Rename
</button>
<button
class="longManeuverButton danger"
disabled="${() => state.runningAction || tune.active}"
@click="${() => deleteSavedTune(tune)}">
Delete
</button>
</div>
</div>
`)
: html`<p class="longManeuverMuted">No tuning reports yet.</p>`}
: html`<p class="longManeuverMuted">No saved tunes yet. Apply a trial, then save it here.</p>`}
</div>
</section>
</div>
+528 -28
View File
@@ -259,6 +259,7 @@ def _workspace_paths() -> dict[str, Path]:
"profiles": root / "profiles",
"feedback": root / "feedback",
"snapshots": root / "snapshots",
"savedTunes": root / "saved_tunes",
"reference": root / "reference",
}
@@ -1136,6 +1137,88 @@ def _current_family_curve(family: str, current: dict[str, Any]) -> list[float]:
return _baseline_family_curve(family)
FLM_CHATTER_FRICTION_DELTAS = {
"low": [0.012, 0.020, 0.008, 0.0, 0.0],
"mid": [0.0, 0.012, 0.020, 0.008, 0.0],
"fast": [0.0, 0.0, 0.010, 0.020, 0.010],
"highway": [0.0, 0.0, 0.0, 0.012, 0.025],
"mixed": [0.0, 0.010, 0.018, 0.022, 0.025],
}
FLM_CHATTER_DEADBAND_SUFFIX = {
"low": "center_deadband_low_deg",
"mid": "center_deadband_mid_deg",
"fast": "center_deadband_fast_deg",
"highway": "center_deadband_highway_deg",
"mixed": "center_deadband_mid_deg",
}
FLM_CHATTER_DEADBAND_DELTA = {
"low": 0.035,
"mid": 0.025,
"fast": 0.018,
"highway": 0.012,
"mixed": 0.020,
}
FLM_CHATTER_THRESHOLD_PASS_MIN_DELTA = 0.012
def _center_chatter_friction_adjustment(family: str, speed_band: str, severity: float,
current: dict[str, Any]) -> dict[str, Any]:
current_curve = _current_family_curve(family, current)
deltas = FLM_CHATTER_FRICTION_DELTAS.get(speed_band, FLM_CHATTER_FRICTION_DELTAS["mixed"])
scale = min(max(severity, 0.45), 1.2)
suggested = [round(current_curve[idx] + (delta * scale), 4) for idx, delta in enumerate(deltas)]
return {
"type": "friction_curve",
"symbol": f"base_friction_threshold.{family}",
"family": family,
"current": current_curve,
"suggested": suggested,
"delta": [round(suggested[idx] - current_curve[idx], 4) for idx in range(len(current_curve))],
"stage": "friction_threshold",
"speedBand": speed_band,
}
def _center_chatter_threshold_pass_applied(family: str, speed_band: str, current: dict[str, Any]) -> bool:
baseline = _baseline_family_curve(family)
active = _current_family_curve(family, current)
target_indexes = {
"low": (0, 1),
"mid": (1, 2),
"fast": (2, 3),
"highway": (3, 4),
"mixed": tuple(range(len(FLM_FRICTION_SPEED_KNOTS))),
}.get(speed_band, tuple(range(len(FLM_FRICTION_SPEED_KNOTS))))
return max((active[idx] - baseline[idx] for idx in target_indexes), default=0.0) >= FLM_CHATTER_THRESHOLD_PASS_MIN_DELTA
def _center_chatter_deadband_adjustment(capabilities: dict[str, Any], speed_band: str, severity: float,
current: dict[str, Any]) -> dict[str, Any] | None:
rich_profile = capabilities.get("richProfileKey")
suffix = FLM_CHATTER_DEADBAND_SUFFIX.get(speed_band, FLM_CHATTER_DEADBAND_SUFFIX["mixed"])
if not rich_profile or not _rich_profile_supports_knob(capabilities, suffix):
return None
adjustment = _vehicle_knob_adjustment(
f"{rich_profile}.{suffix}",
FLM_CHATTER_DEADBAND_DELTA.get(speed_band, FLM_CHATTER_DEADBAND_DELTA["mixed"]) * min(max(severity, 0.5), 1.2),
current,
)
if adjustment is not None:
adjustment["stage"] = "center_deadband"
adjustment["speedBand"] = speed_band
return adjustment
def _direction_reversal_count(values: np.ndarray, min_step: float) -> int:
if len(values) < 3:
return 0
deltas = np.diff(values)
significant = deltas[np.abs(deltas) >= min_step]
if len(significant) < 2:
return 0
return int(np.sum(np.sign(significant[1:]) != np.sign(significant[:-1])))
def _clamp(value: float, lower: float, upper: float) -> float:
return min(max(float(value), lower), upper)
@@ -1231,23 +1314,52 @@ def _build_event_summaries(samples: list[FLMSample]) -> tuple[list[dict[str, Any
"saturation_limited": [1.0 if sample.saturated else 0.0 for sample in samples],
}
# Straight-road chatter detection uses a simple 4-second window.
# Detect controller-driven center chatter independently in each speed band.
# The desired path must remain calm while steering angle and either output or
# tracking error repeatedly reverse direction.
straight_windows = []
angle_thresholds = {"low": 0.80, "mid": 0.55, "fast": 0.38, "highway": 0.28}
error_thresholds = {"low": 0.16, "mid": 0.12, "fast": 0.09, "highway": 0.07}
output_thresholds = {"low": 0.055, "mid": 0.045, "fast": 0.035, "highway": 0.025}
for start_idx in range(0, max(len(samples) - 20, 1), 10):
window = samples[start_idx:start_idx + 40]
if len(window) < 20:
continue
if not all(eligibility[start_idx:start_idx + len(window)]):
continue
if float(np.mean([sample.v_ego for sample in window])) < 20.0:
mean_speed = float(np.mean([sample.v_ego for sample in window]))
if mean_speed < 2.0:
continue
if float(np.mean([abs(sample.desired_la) for sample in window])) > 0.12:
speed_band = _speed_band_label(mean_speed)
desired_series = np.array([sample.desired_la for sample in window])
if float(np.mean(np.abs(desired_series))) > (0.14 if speed_band == "low" else 0.18):
continue
centered_angles = np.array([sample.steering_angle_deg for sample in window]) - float(np.mean([sample.steering_angle_deg for sample in window]))
sign_changes = int(np.sum(np.sign(centered_angles[1:]) != np.sign(centered_angles[:-1])))
amplitude = float(np.max(centered_angles) - np.min(centered_angles))
chatter_score = (amplitude * 0.25) + (sign_changes * 0.04)
if amplitude > 0.45 and sign_changes >= 6:
desired_span = float(np.ptp(desired_series))
desired_reversals = _direction_reversal_count(desired_series, 0.008)
if desired_span > 0.18 or desired_reversals > 3:
continue
angle_series = np.array([sample.steering_angle_deg for sample in window])
angle_trend = np.linspace(angle_series[0], angle_series[-1], len(angle_series))
centered_angles = angle_series - angle_trend
error_series = np.array([sample.actual_la - sample.desired_la for sample in window])
output_series = np.array([sample.output for sample in window])
angle_p2p = float(np.ptp(centered_angles))
error_p2p = float(np.ptp(error_series))
output_p2p = float(np.ptp(output_series))
angle_reversals = _direction_reversal_count(centered_angles, max(angle_thresholds[speed_band] * 0.08, 0.025))
error_reversals = _direction_reversal_count(error_series, max(error_thresholds[speed_band] * 0.08, 0.006))
output_reversals = _direction_reversal_count(output_series, max(output_thresholds[speed_band] * 0.08, 0.002))
angle_evidence = angle_p2p >= angle_thresholds[speed_band] and angle_reversals >= 3
error_evidence = error_p2p >= error_thresholds[speed_band] and error_reversals >= 3
output_evidence = output_p2p >= output_thresholds[speed_band] and output_reversals >= 3
if angle_evidence and (error_evidence or output_evidence):
chatter_score = min(1.5, (
0.30 * (angle_p2p / angle_thresholds[speed_band]) +
0.18 * (error_p2p / error_thresholds[speed_band]) +
0.18 * (output_p2p / output_thresholds[speed_band]) +
0.025 * min(angle_reversals + error_reversals + output_reversals, 14)
))
straight_windows.append({
"startIdx": start_idx,
"endIdx": start_idx + len(window) - 1,
@@ -1255,9 +1367,20 @@ def _build_event_summaries(samples: list[FLMSample]) -> tuple[list[dict[str, Any
"peakScore": chatter_score,
"route": window[0].route,
"segment": window[0].segment,
"speedBand": "highway",
"speedBand": speed_band,
"direction": "center",
"supportCount": len(window),
"metrics": {
"meanSpeedMps": round(mean_speed, 3),
"steeringAngleP2P": round(angle_p2p, 4),
"trackingErrorP2P": round(error_p2p, 4),
"outputP2P": round(output_p2p, 4),
"steeringReversals": angle_reversals,
"trackingErrorReversals": error_reversals,
"outputReversals": output_reversals,
"desiredP2P": round(desired_span, 4),
"desiredReversals": desired_reversals,
},
})
curve_windows = []
@@ -1338,13 +1461,14 @@ def _build_event_summaries(samples: list[FLMSample]) -> tuple[list[dict[str, Any
def _summaries_from_events(bucket: str, samples: list[FLMSample], events: list[dict[str, Any]],
eligibility: list[bool] | None = None) -> list[dict[str, Any]]:
grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
for event in events:
key = (bucket, event["direction"])
event_speed_band = event["speedBand"] if bucket == "center_chatter" else "mixed"
key = (bucket, event["direction"], event_speed_band)
grouped.setdefault(key, []).append(event)
summaries = []
for (bucket_name, direction), grouped_events in grouped.items():
for (bucket_name, direction, _group_speed_band), grouped_events in grouped.items():
grouped_events.sort(key=lambda item: item["peakScore"], reverse=True)
strongest = grouped_events[:3]
strongest_labels = [
@@ -1371,6 +1495,7 @@ def _summaries_from_events(bucket: str, samples: list[FLMSample], events: list[d
"directionBias": direction,
"eventCount": len(grouped_events),
"segments": strongest_labels,
"chatterMetrics": top_event.get("metrics", {}),
},
"events": grouped_events,
"plotSvg": _build_plot_svg(plot_data),
@@ -1410,9 +1535,12 @@ def _primary_delta_from_summary(summary: dict[str, Any], capabilities: dict[str,
return None
if strategy == "baseline":
if bucket in ("center_chatter", "notchy_mid_curve"):
if bucket == "center_chatter":
return _center_chatter_friction_adjustment(family, speed_band, severity, current)
if bucket == "notchy_mid_curve":
current_curve = _current_family_curve(family, current)
deltas = [0.0, 0.01, 0.02, 0.025, 0.03] if bucket == "center_chatter" else [0.0, 0.0, 0.015, 0.02, 0.02]
deltas = [0.0, 0.0, 0.015, 0.02, 0.02]
scale = min(max(severity, 0.4), 1.2)
suggested = [round(current_curve[idx] + (delta * scale), 4) for idx, delta in enumerate(deltas)]
return {
@@ -1463,12 +1591,16 @@ def _primary_delta_from_summary(summary: dict[str, Any], capabilities: dict[str,
suggested_value = round(_clamp(current_value + (0.015 * severity * direction_mult), 0.0, 1.0), 4)
return {"type": "generic_param", "paramKey": "SteerFriction", "current": current_value, "suggested": suggested_value, "delta": round(suggested_value - current_value, 4)}
if bucket in ("center_chatter", "notchy_mid_curve"):
if bucket == "center_chatter":
if _center_chatter_threshold_pass_applied(family, speed_band, current):
deadband_adjustment = _center_chatter_deadband_adjustment(capabilities, speed_band, severity, current)
if deadband_adjustment is not None:
return deadband_adjustment
return _center_chatter_friction_adjustment(family, speed_band, severity, current)
if bucket == "notchy_mid_curve":
current_curve = _current_family_curve(family, current)
if bucket == "center_chatter":
deltas = [0.0, 0.01, 0.02, 0.025, 0.03]
else:
deltas = [0.0, 0.0, 0.015, 0.02, 0.02]
deltas = [0.0, 0.0, 0.015, 0.02, 0.02]
scale = min(max(severity, 0.4), 1.2)
suggested = [round(current_curve[idx] + (delta * scale), 4) for idx, delta in enumerate(deltas)]
return {
@@ -1588,7 +1720,7 @@ def _observed_behavior(summary: dict[str, Any]) -> str:
"early_turn_in": f"Turn-in is too eager{direction_text}; actual response jumps ahead of the plan during entry.",
"unwind_too_slow": f"Unwind is hanging on too long{direction_text}; the car keeps steering after the plan starts releasing.",
"unwind_too_fast": f"Unwind is releasing too quickly{direction_text}; the wheel gives back steering sooner than the plan wants.",
"center_chatter": "The car is doing repeated micro-corrections on straights or very light highway arcs.",
"center_chatter": f"The car is doing repeated micro-corrections around center in the {speed_band} speed band while the requested path stays calm.",
"notchy_mid_curve": "Mid-curve tracking is correcting in steps instead of flowing through the same steering band cleanly.",
"low_speed_unwillingness": "At low speed the controller is slow to wake up even though the turn request is already there.",
"saturation_limited": "The controller is spending meaningful time at or near its steering authority ceiling.",
@@ -1605,6 +1737,11 @@ def _likely_interpretation(summary: dict[str, Any], adjustment: dict[str, Any])
return "This looks more like a friction-threshold problem than a whole-tune problem; the controller is busy around center and needs a calmer deadzone slope."
if adjustment["type"] == "vehicle_knob":
symbol = adjustment["symbol"]
if "center_deadband_" in symbol:
return (
"A friction-threshold pass is already active in this speed band, but controller-driven reversals remain. "
"The residual motion is narrow enough for a small deadband cleanup instead of another broad friction increase."
)
if "ff_gain_" in symbol:
return "This car has a directional nonlinear torque map, and the mismatch is concentrated on one side. Correct that side's feedforward layer before moving global authority."
if "low_speed_angle_assist_max_torque" in symbol:
@@ -1635,6 +1772,11 @@ def _why_this_knob(adjustment: dict[str, Any]) -> str:
return "This changes the threshold that maps small lateral-accel error into friction compensation without pretending the whole torque slope is wrong."
if adjustment["type"] == "vehicle_knob":
symbol = adjustment["symbol"]
if "center_deadband_" in symbol:
return (
"This adds a small steering-angle deadband only around the affected speed knot, interpolated into neighboring speeds, "
"without reducing normal curve authority."
)
if "ff_gain_" in symbol:
return "This compensates the affected side without flattening the car's separate left/right nonlinear torque response into one global value."
if "low_speed_angle_assist_max_torque" in symbol:
@@ -1664,11 +1806,20 @@ def _render_adjustment_line(adjustment: dict[str, Any]) -> str:
curve = ", ".join(f"{value:.3f}" for value in adjustment["suggested"])
return f"Adjust {adjustment['family']} friction threshold curve at {FLM_FRICTION_SPEED_KNOTS} m/s to [{curve}]."
if adjustment["type"] == "vehicle_knob":
return f"Move `{adjustment['symbol']}` from {adjustment['current']:.3f} to {adjustment['suggested']:.3f}."
suffix = " as the second-stage center-chatter cleanup." if adjustment.get("stage") == "center_deadband" else "."
return f"Move `{adjustment['symbol']}` from {adjustment['current']:.3f} to {adjustment['suggested']:.3f}{suffix}"
return f"Move `{adjustment['paramKey']}` from {adjustment['current']:.3f} to {adjustment['suggested']:.3f}."
def _what_not_to_touch_yet(summary: dict[str, Any], adjustment: dict[str, Any] | None, strategy: str) -> str:
if summary.get("bucket") == "center_chatter":
if adjustment and adjustment.get("stage") == "friction_threshold":
return "Do not add deadband or center taper yet. First verify whether the speed-localized friction threshold removes the repeated reversals."
if adjustment and adjustment.get("stage") == "center_deadband":
return (
"Do not raise the whole friction curve again or reduce global feedforward. "
"This pass is only for the residual near-center motion in the affected speed band."
)
if strategy == "baseline":
if adjustment and adjustment.get("type") in ("generic_param", "friction_curve"):
return "Do not jump straight into phase-specific cleanup knobs yet. Get the broad authority and friction behavior into the right zip code first."
@@ -1679,11 +1830,37 @@ def _what_not_to_touch_yet(summary: dict[str, Any], adjustment: dict[str, Any] |
def _if_that_was_wrong(summary: dict[str, Any], adjustment: dict[str, Any], strategy: str) -> str:
if summary.get("bucket") == "center_chatter":
if adjustment.get("stage") == "friction_threshold":
return (
"If chatter remains after this threshold pass, re-analyze the next drive. FLM will move to a bounded deadband cleanup "
"for the same speed band rather than repeatedly raising the whole threshold curve."
)
if adjustment.get("stage") == "center_deadband":
return (
"If steering becomes reluctant around center, use the conservative profile or halve this deadband step; "
"leave the completed friction-threshold pass in place."
)
if strategy == "baseline":
return f"If this gets the car broadly closer but leaves one specific phase ugly, stop here and switch to Cleanup Pass for that band. {_why_this_knob(adjustment)}"
return f"If this cleans up the main symptom but introduces the opposite behavior, keep half the change and move to the next phase-specific knob. {_why_this_knob(adjustment)}"
def _log_support(summary: dict[str, Any]) -> str:
evidence = summary.get("evidence", {})
segment_labels = ", ".join(item["label"] for item in evidence.get("segments", [])[:3]) or "none"
base = f"Matched in {evidence.get('eventCount', 0)} event(s); strongest samples: {segment_labels}"
metrics = evidence.get("chatterMetrics", {})
if summary.get("bucket") != "center_chatter" or not metrics:
return base
return (
f"{base}. Strongest window: steering moved {metrics.get('steeringAngleP2P', 0.0):.2f} deg peak-to-peak "
f"with {metrics.get('steeringReversals', 0)} steering reversal(s) and {metrics.get('outputReversals', 0)} output reversal(s), "
f"while the desired path moved only {metrics.get('desiredP2P', 0.0):.3f} m/s^2 peak-to-peak"
)
def build_suggestions(summaries: list[dict[str, Any]], capabilities: dict[str, Any], current: dict[str, Any],
strategy: str = "cleanup") -> list[dict[str, Any]]:
suggestions = []
@@ -1744,7 +1921,7 @@ def build_suggestions(summaries: list[dict[str, Any]], capabilities: dict[str, A
"whatNotToTouchYet": _what_not_to_touch_yet(summary, adjustment, strategy),
"ifThatWasWrong": _if_that_was_wrong(summary, adjustment, strategy),
"driverFeel": _observed_behavior(summary),
"logSupport": f"Matched in {evidence.get('eventCount', 0)} event(s); strongest samples: {', '.join(item['label'] for item in evidence.get('segments', [])[:3]) or 'none'}",
"logSupport": _log_support(summary),
"whyThisKnob": _why_this_knob(adjustment),
"plotSvg": summary.get("plotSvg", ""),
"plotData": summary.get("plotData", {}),
@@ -1796,11 +1973,13 @@ def _merge_primary_adjustments(suggestions: list[dict[str, Any]], multiplier: fl
bucket = friction_targets.setdefault(family, {
"current": [float(value) for value in adjustment["current"]],
"weightedDelta": [0.0] * len(delta_curve),
"weight": 0.0,
"weights": [0.0] * len(delta_curve),
})
for idx, value in enumerate(delta_curve):
if math.isclose(value, 0.0, abs_tol=1e-9):
continue
bucket["weightedDelta"][idx] += value * weight
bucket["weight"] += weight
bucket["weights"][idx] += weight
requires_force_auto_tune_off = True
overrides: dict[str, Any] = {"schemaVersion": 1, "baseFrictionThresholds": {}, "vehicleKnobs": {}}
@@ -1825,9 +2004,12 @@ def _merge_primary_adjustments(suggestions: list[dict[str, Any]], multiplier: fl
overrides["vehicleKnobs"][symbol] = next_value
for family, bucket in friction_targets.items():
if bucket["weight"] <= 0:
if not any(weight > 0.0 for weight in bucket["weights"]):
continue
avg_delta_curve = [value / bucket["weight"] for value in bucket["weightedDelta"]]
avg_delta_curve = [
value / bucket["weights"][idx] if bucket["weights"][idx] > 0.0 else 0.0
for idx, value in enumerate(bucket["weightedDelta"])
]
values = [
round(max(0.05, float(bucket["current"][idx]) + (avg_delta_curve[idx] * multiplier)), 4)
for idx in range(len(bucket["current"]))
@@ -2546,6 +2728,62 @@ def _active_trial_display_state(paths: dict[str, Path], snapshot: Any) -> dict[s
}
def _current_car_identity(params: Params) -> dict[str, str]:
cp_bytes = params.get("CarParamsPersistent")
if not cp_bytes:
return {"carFingerprint": "", "brand": ""}
try:
with car.CarParams.from_bytes(cp_bytes) as car_params:
return {
"carFingerprint": str(getattr(car_params, "carFingerprint", "") or "").strip(),
"brand": str(getattr(car_params, "brand", "") or "").strip(),
}
except Exception:
return {"carFingerprint": "", "brand": ""}
def _normalize_saved_tune_name(name: str) -> str:
normalized = " ".join(str(name or "").split())
if not normalized:
raise ValueError("A saved tune name is required.")
if len(normalized) > 64:
raise ValueError("Saved tune names must be 64 characters or fewer.")
return normalized
def _load_saved_tune(tune_id: str, paths: dict[str, Path] | None = None) -> dict[str, Any]:
paths = paths or ensure_flm_workspace()
tune = _read_json(paths["savedTunes"] / f"{tune_id}.json", {})
if not isinstance(tune, dict) or not tune:
raise FileNotFoundError(tune_id)
return tune
def list_saved_tunes(paths: dict[str, Path] | None = None, active_tune_id: str = "") -> list[dict[str, Any]]:
paths = paths or ensure_flm_workspace()
saved_tunes = []
for path in paths["savedTunes"].glob("*.json"):
payload = _read_json(path, {})
if not isinstance(payload, dict) or not payload:
continue
flm_overrides = normalize_flm_overrides(payload.get("flmOverrides", {}))
saved_tunes.append({
"tuneId": str(payload.get("tuneId", path.stem) or path.stem),
"name": str(payload.get("name", "Saved Tune") or "Saved Tune"),
"createdAt": float(payload.get("createdAt", path.stat().st_mtime) or path.stat().st_mtime),
"updatedAt": float(payload.get("updatedAt", path.stat().st_mtime) or path.stat().st_mtime),
"carFingerprint": str(payload.get("carFingerprint", "") or ""),
"brand": str(payload.get("brand", "") or ""),
"sourceReportId": str(payload.get("sourceReportId", "") or ""),
"pathLabel": str(payload.get("pathLabel", "") or ""),
"genericParamCount": len(payload.get("genericParams", {})) if isinstance(payload.get("genericParams"), dict) else 0,
"frictionCurveCount": len(flm_overrides.get("baseFrictionThresholds", {})),
"vehicleKnobCount": len(flm_overrides.get("vehicleKnobs", {})),
"active": str(payload.get("tuneId", path.stem) or path.stem) == active_tune_id,
})
return sorted(saved_tunes, key=lambda tune: (tune["updatedAt"], tune["createdAt"]), reverse=True)
def list_workspace() -> dict[str, Any]:
paths = ensure_flm_workspace()
reports = []
@@ -2582,9 +2820,27 @@ def list_workspace() -> dict[str, Any]:
"recoveryNeeded": True,
"rollbackAvailable": False,
}
if current_profile_id.startswith("saved:"):
saved_tune_id = current_profile_id.split(":", 1)[1]
saved_tune = _read_json(paths["savedTunes"] / f"{saved_tune_id}.json", {})
if isinstance(saved_tune, dict) and saved_tune:
saved_overrides = normalize_flm_overrides(saved_tune.get("flmOverrides", {}))
raw_active_snapshot = {
**raw_active_snapshot,
"savedTuneId": saved_tune_id,
"profileLabel": str(saved_tune.get("name", "Saved Tune") or "Saved Tune"),
"carFingerprint": str(saved_tune.get("carFingerprint", "") or ""),
"appliedGenericParams": dict(saved_tune.get("genericParams", {})),
"appliedFrictionThresholds": saved_overrides.get("baseFrictionThresholds", {}),
"appliedVehicleKnobs": saved_overrides.get("vehicleKnobs", {}),
}
active_snapshot = _active_trial_display_state(paths, raw_active_snapshot)
active_tune_id = str(active_snapshot.get("savedTuneId", "") or "") if isinstance(active_snapshot, dict) else ""
current_car = _current_car_identity(params)
return {
"reports": reports[:20],
"savedTunes": list_saved_tunes(paths, active_tune_id),
"currentCarFingerprint": current_car["carFingerprint"],
"feedbackCount": len(feedback_files),
"activeTrial": active_snapshot,
"status": read_flm_status(),
@@ -2621,7 +2877,7 @@ def delete_report(report_id: str) -> dict[str, Any]:
status = read_flm_status()
if not status.get("running") and status.get("reportId") == report_id:
_clear_flm_status()
clear_flm_status()
return {
"message": f"Deleted tuning report {report_id}.",
@@ -2654,7 +2910,7 @@ def clear_workspace() -> dict[str, Any]:
removed.append(str(progress_path))
_clear_persistent_trial_baseline(params)
_clear_flm_status()
clear_flm_status()
return {
"message": "Cleared saved tuning reports, feedback, profiles, and snapshots.",
@@ -2809,6 +3065,250 @@ def _find_revert_snapshot(paths: dict[str, Path], active_snapshot: dict[str, Any
return _recover_report_baseline(paths, current_profile_id)
def _active_trial_adjustments(paths: dict[str, Path], params: Params,
active_snapshot: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
current_state = _snapshot_current_trial_state(params)
display_state = _active_trial_display_state(paths, active_snapshot) or {}
baseline_snapshot = _find_revert_snapshot(
paths,
active_snapshot,
str(current_state.get("FLMActiveProfileId", "") or ""),
params,
)
baseline_params = baseline_snapshot.get("params", {}) if isinstance(baseline_snapshot, dict) else {}
generic_params = {}
display_generic = display_state.get("appliedGenericParams", {})
if not isinstance(display_generic, dict):
display_generic = {}
for key in FLM_ADVANCED_LATERAL_PARAM_KEYS:
if key not in current_state:
continue
if key in baseline_params:
if current_state[key] != baseline_params[key]:
generic_params[key] = current_state[key]
elif key in display_generic:
generic_params[key] = current_state[key]
current_overrides = normalize_flm_overrides(current_state.get("FLMActiveOverrides", {}))
baseline_overrides = normalize_flm_overrides(baseline_params.get("FLMActiveOverrides", {}))
display_friction = display_state.get("appliedFrictionThresholds", {})
display_knobs = display_state.get("appliedVehicleKnobs", {})
if not isinstance(display_friction, dict):
display_friction = {}
if not isinstance(display_knobs, dict):
display_knobs = {}
friction_thresholds = {}
for family, payload in current_overrides.get("baseFrictionThresholds", {}).items():
if family in display_friction or payload != baseline_overrides.get("baseFrictionThresholds", {}).get(family):
friction_thresholds[family] = payload
vehicle_knobs = {}
for symbol, value in current_overrides.get("vehicleKnobs", {}).items():
if symbol in display_knobs or value != baseline_overrides.get("vehicleKnobs", {}).get(symbol):
vehicle_knobs[symbol] = value
return generic_params, normalize_flm_overrides({
"schemaVersion": 1,
"baseFrictionThresholds": friction_thresholds,
"vehicleKnobs": vehicle_knobs,
})
def _active_trial_car_fingerprint(paths: dict[str, Path], active_snapshot: dict[str, Any]) -> str:
fingerprint = str(active_snapshot.get("carFingerprint", "") or "")
if fingerprint:
return fingerprint
report_id = str(active_snapshot.get("reportId", "") or "")
report = _read_json(paths["reports"] / f"{report_id}.json", {}) if report_id else {}
return str(report.get("car", {}).get("carFingerprint", "") or "") if isinstance(report, dict) else ""
def save_active_trial_as_tune(name: str) -> dict[str, Any]:
paths = ensure_flm_workspace()
params = Params(return_defaults=True)
if not params.get_bool("FLMTrialApplied"):
raise RuntimeError("Apply an FLM trial before saving it as a tune.")
active_snapshot = _read_json(paths["snapshots"] / "active.json", {})
if not isinstance(active_snapshot, dict):
active_snapshot = {}
display_state = _active_trial_display_state(paths, active_snapshot) or {}
generic_params, flm_overrides = _active_trial_adjustments(paths, params, active_snapshot)
current_state = _snapshot_current_trial_state(params)
baseline_snapshot = _find_revert_snapshot(
paths,
active_snapshot,
str(current_state.get("FLMActiveProfileId", "") or ""),
params,
)
baseline_params = dict(baseline_snapshot.get("params", {})) if isinstance(baseline_snapshot, dict) else {}
report_id = str(display_state.get("reportId", "") or "")
report = _read_json(paths["reports"] / f"{report_id}.json", {}) if report_id else {}
report_car = report.get("car", {}) if isinstance(report, dict) else {}
current_car = _current_car_identity(params)
car_fingerprint = current_car["carFingerprint"] or str(report_car.get("carFingerprint", "") or "")
brand = current_car["brand"] or str(report_car.get("brand", "") or "")
now = time.time()
tune_id = f"tune-{time.time_ns()}"
tune = {
"schemaVersion": 1,
"tuneId": tune_id,
"name": _normalize_saved_tune_name(name),
"createdAt": now,
"updatedAt": now,
"carFingerprint": car_fingerprint,
"brand": brand,
"sourceReportId": report_id,
"sourceProfileId": str(display_state.get("profileId", "") or ""),
"pathKey": str(display_state.get("pathKey", "") or ""),
"pathLabel": str(display_state.get("pathLabel", "") or ""),
"baselineParams": baseline_params,
"genericParams": generic_params,
"flmOverrides": flm_overrides,
}
_write_json(paths["savedTunes"] / f"{tune_id}.json", tune)
active_snapshot.update({
"profileId": f"saved:{tune_id}",
"savedTuneId": tune_id,
"profileLabel": tune["name"],
"carFingerprint": car_fingerprint,
"updatedAt": now,
})
_write_json(paths["snapshots"] / "active.json", active_snapshot)
_apply_param_bundle(params, {"FLMActiveProfileId": f"saved:{tune_id}"})
return {
"message": f"Saved {tune['name']}.",
"tune": tune,
"workspace": list_workspace(),
}
def apply_saved_tune(tune_id: str) -> dict[str, Any]:
paths = ensure_flm_workspace()
tune = _load_saved_tune(tune_id, paths)
params = Params(return_defaults=True)
current_car = _current_car_identity(params)
tune_fingerprint = str(tune.get("carFingerprint", "") or "")
if current_car["carFingerprint"] and tune_fingerprint and current_car["carFingerprint"] != tune_fingerprint:
raise RuntimeError(
f"This tune is for {tune_fingerprint}, but the connected car is {current_car['carFingerprint']}."
)
current_state = _snapshot_current_trial_state(params)
raw_active_snapshot = _read_json(paths["snapshots"] / "active.json", {})
if not isinstance(raw_active_snapshot, dict):
raw_active_snapshot = {}
previous_display_state = _active_trial_display_state(paths, raw_active_snapshot) or {}
if current_state.get("FLMTrialApplied", False):
active_fingerprint = _active_trial_car_fingerprint(paths, raw_active_snapshot)
changing_cars = bool(current_car["carFingerprint"] and active_fingerprint and current_car["carFingerprint"] != active_fingerprint)
if changing_cars:
saved_baseline = tune.get("baselineParams", {})
if not isinstance(saved_baseline, dict) or not saved_baseline or saved_baseline.get("FLMTrialApplied", False):
raise RuntimeError("This saved tune does not contain a clean baseline for the connected car. Revert before changing cars, then save the tune again.")
baseline_params = saved_baseline
session_started_at = time.time()
else:
baseline_snapshot = _find_revert_snapshot(
paths,
raw_active_snapshot,
str(current_state.get("FLMActiveProfileId", "") or ""),
params,
)
if baseline_snapshot is None:
raise RuntimeError("The active FLM trial has no recoverable rollback baseline. Keep the current tune as the new baseline before switching tunes.")
baseline_params = baseline_snapshot["params"]
session_started_at = float(baseline_snapshot.get("sessionStartedAt", baseline_snapshot.get("capturedAt", time.time())) or time.time())
else:
baseline_params = current_state
session_started_at = time.time()
generic_params = {
key: value for key, value in tune.get("genericParams", {}).items()
if key in FLM_ADVANCED_LATERAL_PARAM_KEYS
} if isinstance(tune.get("genericParams"), dict) else {}
flm_overrides = normalize_flm_overrides(tune.get("flmOverrides", {}))
profile_id = f"saved:{tune_id}"
now = time.time()
snapshot = {
"reportId": str(tune.get("sourceReportId", "") or ""),
"profileId": profile_id,
"profileLabel": str(tune.get("name", "Saved Tune") or "Saved Tune"),
"savedTuneId": tune_id,
"carFingerprint": tune_fingerprint,
"pathKey": str(tune.get("pathKey", "") or ""),
"pathLabel": str(tune.get("pathLabel", "") or ""),
"capturedAt": session_started_at,
"updatedAt": now,
"sessionStartedAt": session_started_at,
"revisionCount": int(previous_display_state.get("revisionCount", 0) or 0) + 1,
"params": baseline_params,
"appliedGenericParams": generic_params,
"appliedFrictionThresholds": flm_overrides.get("baseFrictionThresholds", {}),
"appliedVehicleKnobs": flm_overrides.get("vehicleKnobs", {}),
}
_write_json(paths["snapshots"] / "active.json", snapshot)
_write_json(paths["snapshots"] / f"saved-{tune_id}-{time.time_ns()}.json", snapshot)
_persist_trial_baseline(params, snapshot)
# Start from the original manual baseline on every switch so values from the
# previously active saved tune cannot leak into this one.
bundle = {
key: baseline_params[key] for key in FLM_ADVANCED_LATERAL_PARAM_KEYS
if key in baseline_params
}
bundle.update(generic_params)
bundle["FLMActiveProfileId"] = profile_id
bundle["FLMActiveOverrides"] = flm_overrides
bundle["FLMTrialApplied"] = True
_apply_param_bundle(params, bundle)
if tune.get("pathKey") == "cleanup_pass" and tune_fingerprint:
_record_cleanup_progress(tune_fingerprint, str(tune.get("sourceReportId", "") or ""))
return {
"message": f"Applied saved tune {tune.get('name', 'Saved Tune')}.",
"tune": tune,
"workspace": list_workspace(),
}
def rename_saved_tune(tune_id: str, name: str) -> dict[str, Any]:
paths = ensure_flm_workspace()
tune = _load_saved_tune(tune_id, paths)
tune["name"] = _normalize_saved_tune_name(name)
tune["updatedAt"] = time.time()
_write_json(paths["savedTunes"] / f"{tune_id}.json", tune)
active_snapshot_path = paths["snapshots"] / "active.json"
active_snapshot = _read_json(active_snapshot_path, {})
if isinstance(active_snapshot, dict) and active_snapshot.get("savedTuneId") == tune_id:
active_snapshot["profileLabel"] = tune["name"]
active_snapshot["updatedAt"] = time.time()
_write_json(active_snapshot_path, active_snapshot)
return {
"message": f"Renamed saved tune to {tune['name']}.",
"tune": tune,
"workspace": list_workspace(),
}
def delete_saved_tune(tune_id: str) -> dict[str, Any]:
paths = ensure_flm_workspace()
tune = _load_saved_tune(tune_id, paths)
active_snapshot = _read_json(paths["snapshots"] / "active.json", {})
params = Params(return_defaults=True)
current_profile_id = params.get("FLMActiveProfileId", encoding="utf-8") or ""
if (
(isinstance(active_snapshot, dict) and active_snapshot.get("savedTuneId") == tune_id)
or (params.get_bool("FLMTrialApplied") and current_profile_id == f"saved:{tune_id}")
):
raise RuntimeError("Revert or switch away from this saved tune before deleting it.")
(paths["savedTunes"] / f"{tune_id}.json").unlink()
return {
"message": f"Deleted saved tune {tune.get('name', 'Saved Tune')}.",
"workspace": list_workspace(),
}
def apply_trial_profile(report_id: str, profile_id: str) -> dict[str, Any]:
paths = ensure_flm_workspace()
params = Params(return_defaults=True)
@@ -34,7 +34,7 @@
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
<link rel="stylesheet" href="/assets/components/tools/testing_ground.css">
<link rel="stylesheet" href="/assets/components/tools/tuning.css?v=flm-workspace-6">
<link rel="stylesheet" href="/assets/components/tools/tuning.css?v=flm-saved-tunes-1">
<link rel="stylesheet" href="/assets/components/tools/troubleshoot.css">
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
@@ -125,8 +125,16 @@ def _install_flm_import_stubs(tmp_path):
"hyundai_ioniq_6.crawl_turn_in_ff_boost_left": {"min": 0.0, "max": 0.5, "precision": 0.001, "defaultValue": 0.18, "profile": "hyundai_ioniq_6"},
"hyundai_ioniq_6.curvy_turn_in_trim_left": {"min": 0.0, "max": 0.2, "precision": 0.001, "defaultValue": 0.06, "profile": "hyundai_ioniq_6"},
"hyundai_ioniq_6.curvy_unwind_extra_reduction_left": {"min": 0.0, "max": 0.45, "precision": 0.001, "defaultValue": 0.18, "profile": "hyundai_ioniq_6"},
"hyundai_ioniq_6.center_deadband_low_deg": {"min": 0.0, "max": 0.3, "precision": 0.005, "defaultValue": 0.0, "profile": "hyundai_ioniq_6"},
"hyundai_ioniq_6.center_deadband_mid_deg": {"min": 0.0, "max": 0.3, "precision": 0.005, "defaultValue": 0.0, "profile": "hyundai_ioniq_6"},
"hyundai_ioniq_6.center_deadband_fast_deg": {"min": 0.0, "max": 0.3, "precision": 0.005, "defaultValue": 0.0, "profile": "hyundai_ioniq_6"},
"hyundai_ioniq_6.center_deadband_highway_deg": {"min": 0.0, "max": 0.3, "precision": 0.005, "defaultValue": 0.0, "profile": "hyundai_ioniq_6"},
"torque_universal.ff_gain_left": {"min": -0.4, "max": 0.6, "precision": 0.001, "defaultValue": 0.0, "profile": "torque_universal"},
"torque_universal.ff_gain_right": {"min": -0.4, "max": 0.6, "precision": 0.001, "defaultValue": 0.0, "profile": "torque_universal"},
"torque_universal.center_deadband_low_deg": {"min": 0.0, "max": 0.3, "precision": 0.005, "defaultValue": 0.0, "profile": "torque_universal"},
"torque_universal.center_deadband_mid_deg": {"min": 0.0, "max": 0.3, "precision": 0.005, "defaultValue": 0.0, "profile": "torque_universal"},
"torque_universal.center_deadband_fast_deg": {"min": 0.0, "max": 0.3, "precision": 0.005, "defaultValue": 0.0, "profile": "torque_universal"},
"torque_universal.center_deadband_highway_deg": {"min": 0.0, "max": 0.3, "precision": 0.005, "defaultValue": 0.0, "profile": "torque_universal"},
},
get_gm_base_friction_threshold=lambda v_ego: 0.20 + (0.001 * float(v_ego)),
get_hkg_canfd_base_friction_threshold=lambda v_ego: 0.39 + (0.001 * float(v_ego)),
@@ -375,6 +383,65 @@ def test_classify_torque_samples_detects_center_chatter(tmp_path):
assert len(chatter["plotData"]["times"]) == len(chatter["plotData"]["actual"])
def test_classify_torque_samples_detects_mid_speed_center_chatter(tmp_path):
module, _ = _load_flm_workspace_module(tmp_path)
samples = []
for idx in range(80):
samples.append(_sample(
module,
t=idx * 0.1,
v_ego=10.0,
desired_la=0.025 * math.sin(idx * 0.08),
actual_la=0.09 * math.sin(idx * 0.85),
steering_angle_deg=0.65 * math.sin(idx * 0.85),
output=0.035 * math.sin(idx * 0.85),
))
summaries, _ = module.classify_torque_samples(samples)
chatter = next(summary for summary in summaries if summary["bucket"] == "center_chatter")
assert chatter["speedBand"] == "mid"
assert chatter["evidence"]["chatterMetrics"]["steeringReversals"] >= 3
def test_classify_torque_samples_detects_low_speed_center_chatter(tmp_path):
module, _ = _load_flm_workspace_module(tmp_path)
samples = []
for idx in range(80):
samples.append(_sample(
module,
t=idx * 0.1,
v_ego=4.0,
desired_la=0.018 * math.sin(idx * 0.07),
actual_la=0.14 * math.sin(idx * 0.78),
steering_angle_deg=1.05 * math.sin(idx * 0.78),
output=0.065 * math.sin(idx * 0.78),
))
summaries, _ = module.classify_torque_samples(samples)
chatter = next(summary for summary in summaries if summary["bucket"] == "center_chatter")
assert chatter["speedBand"] == "low"
assert chatter["evidence"]["chatterMetrics"]["outputReversals"] >= 3
def test_classify_torque_samples_rejects_model_driven_center_motion(tmp_path):
module, _ = _load_flm_workspace_module(tmp_path)
samples = []
for idx in range(80):
desired = 0.14 * math.sin(idx * 0.85)
samples.append(_sample(
module,
t=idx * 0.1,
v_ego=24.0,
desired_la=desired,
actual_la=desired * 0.95,
steering_angle_deg=0.55 * math.sin(idx * 0.85),
output=0.04 * math.sin(idx * 0.85),
))
summaries, _ = module.classify_torque_samples(samples)
assert not any(summary["bucket"] == "center_chatter" for summary in summaries)
def test_plot_context_stops_at_ineligible_samples(tmp_path):
module, _ = _load_flm_workspace_module(tmp_path)
samples = [_sample(module, t=idx * 0.1, desired_la=idx * 0.01, actual_la=idx * 0.009) for idx in range(20)]
@@ -628,7 +695,7 @@ def test_build_suggestions_rebases_friction_curve_against_active_override(tmp_pa
"plotSvg": "",
}
capabilities = {"richProfileKey": "torque_universal", "frictionFamily": "standard"}
current_curve = [0.34, 0.35, 0.36, 0.37, 0.38]
current_curve = [0.34, 0.35, 0.36, 0.32, 0.33]
current = {
"SteerLatAccel": 1.8,
"SteerFriction": 0.2,
@@ -649,7 +716,64 @@ def test_build_suggestions_rebases_friction_curve_against_active_override(tmp_pa
assert adjustment["type"] == "friction_curve"
assert adjustment["family"] == "standard"
assert adjustment["current"] == current_curve
assert adjustment["suggested"][2] > current_curve[2]
assert adjustment["suggested"][4] > current_curve[4]
def test_center_chatter_cleanup_moves_to_deadband_after_threshold_pass(tmp_path):
module, _ = _load_flm_workspace_module(tmp_path)
summary = {
"bucket": "center_chatter",
"dimensionId": "center_chatter:center:mid",
"direction": "center",
"speedBand": "mid",
"severity": 0.9,
"evidence": {"speedBand": "mid", "directionBias": "center", "eventCount": 3, "segments": [{"label": "route/2"}]},
"plotSvg": "",
}
capabilities = {"richProfileKey": "torque_universal", "frictionFamily": "standard"}
current = {
"SteerLatAccel": 1.8,
"SteerFriction": 0.2,
"FLMActiveOverrides": {
"schemaVersion": 1,
"baseFrictionThresholds": {
"standard": {
"speedKnots": [0.0, 5.0, 10.0, 15.0, 25.0],
"values": [0.30, 0.32, 0.34, 0.33, 0.34],
},
},
"vehicleKnobs": {},
},
}
suggestions = module.build_suggestions([summary], capabilities, current, strategy="cleanup")
adjustment = suggestions[0]["primaryAdjustmentRaw"]
assert adjustment["type"] == "vehicle_knob"
assert adjustment["symbol"] == "torque_universal.center_deadband_mid_deg"
assert adjustment["stage"] == "center_deadband"
assert adjustment["suggested"] > adjustment["current"]
def test_center_chatter_friction_merge_preserves_each_speed_band(tmp_path):
module, _ = _load_flm_workspace_module(tmp_path)
current_curve = [0.30, 0.30, 0.30, 0.30, 0.30]
suggestions = []
for speed_band in ("low", "highway"):
adjustment = module._center_chatter_friction_adjustment("standard", speed_band, 1.0, {
"FLMActiveOverrides": {
"baseFrictionThresholds": {
"standard": {"speedKnots": [0.0, 5.0, 10.0, 15.0, 25.0], "values": current_curve},
},
},
})
suggestions.append({"severity": 1.0, "primaryAdjustmentRaw": adjustment})
_, overrides, _ = module._merge_primary_adjustments(suggestions, 1.0)
merged = overrides["baseFrictionThresholds"]["standard"]["values"]
assert merged[0] == pytest.approx(0.312)
assert merged[1] == pytest.approx(0.320)
assert merged[3] == pytest.approx(0.312)
assert merged[4] == pytest.approx(0.325)
def test_select_primary_tuning_path_prefers_baseline_for_broad_mismatch(tmp_path):
@@ -976,6 +1100,198 @@ def test_repeated_trial_revisions_revert_to_original_baseline(tmp_path):
assert fake_params_cls._store["FLMActiveOverrides"] == {}
def test_saved_tunes_switch_cleanly_and_revert_to_original_baseline(tmp_path, monkeypatch):
module, fake_params_cls = _load_flm_workspace_module(tmp_path)
workspace = module.ensure_flm_workspace()
monkeypatch.setattr(module, "_current_car_identity", lambda _params: {"carFingerprint": "TEST_CAR", "brand": "test"})
first_report_id = "report-save-first"
first_profile_id = f"{first_report_id}:cleanup_pass:recommended"
second_report_id = "report-save-second"
second_profile_id = f"{second_report_id}:cleanup_pass:recommended"
first_profile = {
"id": first_profile_id,
"label": "First Trial",
"pathKey": "cleanup_pass",
"pathLabel": "Cleanup Pass",
"genericParams": {
"AdvancedLateralTune": True,
"SteerFriction": 0.2,
"SteerLatAccel": 1.9,
},
"flmOverrides": {
"baseFrictionThresholds": {},
"vehicleKnobs": {"hyundai_ioniq_6.turn_in_boost_left": 0.08},
},
}
second_profile = {
"id": second_profile_id,
"label": "Second Trial",
"pathKey": "cleanup_pass",
"pathLabel": "Cleanup Pass",
"genericParams": {
"AdvancedLateralTune": True,
"SteerLatAccel": 2.0,
},
"flmOverrides": {
"baseFrictionThresholds": {},
"vehicleKnobs": {"hyundai_ioniq_6.unwind_taper_left": 0.62},
},
}
for report_id, profile in ((first_report_id, first_profile), (second_report_id, second_profile)):
(workspace["reports"] / f"{report_id}.json").write_text(json.dumps({
"reportId": report_id,
"car": {"carFingerprint": "TEST_CAR", "brand": "test"},
}), encoding="utf-8")
(workspace["profiles"] / f"{report_id}.json").write_text(json.dumps([profile]), encoding="utf-8")
fake_params_cls._store = {
"AdvancedLateralTune": False,
"ForceAutoTune": False,
"ForceAutoTuneOff": True,
"UseAutoSteerDelay": False,
"SteerDelay": 0.35,
"SteerFriction": 0.1,
"SteerKP": 1.0,
"SteerLatAccel": 1.5,
"SteerRatio": 15.0,
"FLMActiveProfileId": "",
"FLMActiveOverrides": {},
"FLMTrialApplied": False,
}
module.apply_trial_profile(first_report_id, first_profile_id)
first_tune = module.save_active_trial_as_tune("No Trailer")["tune"]
assert fake_params_cls._store["FLMActiveProfileId"] == f"saved:{first_tune['tuneId']}"
assert next(tune for tune in module.list_workspace()["savedTunes"] if tune["tuneId"] == first_tune["tuneId"])["active"] is True
module.revert_trial_profile()
module.apply_trial_profile(second_report_id, second_profile_id)
second_tune = module.save_active_trial_as_tune("With Trailer")["tune"]
module.apply_saved_tune(first_tune["tuneId"])
assert fake_params_cls._store["SteerFriction"] == pytest.approx(0.2)
assert fake_params_cls._store["SteerLatAccel"] == pytest.approx(1.9)
assert fake_params_cls._store["FLMActiveOverrides"]["vehicleKnobs"] == {
"hyundai_ioniq_6.turn_in_boost_left": pytest.approx(0.08),
}
module.apply_saved_tune(second_tune["tuneId"])
assert fake_params_cls._store["SteerFriction"] == pytest.approx(0.1)
assert fake_params_cls._store["SteerLatAccel"] == pytest.approx(2.0)
assert fake_params_cls._store["FLMActiveOverrides"]["vehicleKnobs"] == {
"hyundai_ioniq_6.unwind_taper_left": pytest.approx(0.62),
}
workspace_state = module.list_workspace()
assert next(tune for tune in workspace_state["savedTunes"] if tune["tuneId"] == second_tune["tuneId"])["active"] is True
module.revert_trial_profile()
assert fake_params_cls._store["AdvancedLateralTune"] is False
assert fake_params_cls._store["SteerFriction"] == pytest.approx(0.1)
assert fake_params_cls._store["SteerLatAccel"] == pytest.approx(1.5)
assert fake_params_cls._store["FLMActiveOverrides"] == {}
assert fake_params_cls._store["FLMTrialApplied"] is False
def test_saved_tune_rename_delete_and_vehicle_guard(tmp_path, monkeypatch):
module, fake_params_cls = _load_flm_workspace_module(tmp_path)
workspace = module.ensure_flm_workspace()
tune_id = "tune-test"
tune_path = workspace["savedTunes"] / f"{tune_id}.json"
tune_path.write_text(json.dumps({
"schemaVersion": 1,
"tuneId": tune_id,
"name": "Original",
"createdAt": 1.0,
"updatedAt": 1.0,
"carFingerprint": "CAR_A",
"genericParams": {"SteerLatAccel": 1.9},
"flmOverrides": {},
}), encoding="utf-8")
fake_params_cls._store = {
"SteerLatAccel": 1.5,
"FLMActiveProfileId": "",
"FLMActiveOverrides": {},
"FLMTrialApplied": False,
}
monkeypatch.setattr(module, "_current_car_identity", lambda _params: {"carFingerprint": "CAR_B", "brand": "test"})
with pytest.raises(RuntimeError, match="connected car is CAR_B"):
module.apply_saved_tune(tune_id)
monkeypatch.setattr(module, "_current_car_identity", lambda _params: {"carFingerprint": "CAR_A", "brand": "test"})
rename_result = module.rename_saved_tune(tune_id, " Tow Setup ")
assert rename_result["tune"]["name"] == "Tow Setup"
module.apply_saved_tune(tune_id)
with pytest.raises(RuntimeError, match="Revert or switch"):
module.delete_saved_tune(tune_id)
module.revert_trial_profile()
delete_result = module.delete_saved_tune(tune_id)
assert "Deleted saved tune Tow Setup" in delete_result["message"]
assert not tune_path.exists()
def test_saved_tune_car_switch_uses_the_destination_car_baseline(tmp_path, monkeypatch):
module, fake_params_cls = _load_flm_workspace_module(tmp_path)
workspace = module.ensure_flm_workspace()
tune_id = "tune-car-b"
(workspace["savedTunes"] / f"{tune_id}.json").write_text(json.dumps({
"schemaVersion": 1,
"tuneId": tune_id,
"name": "Car B",
"createdAt": 1.0,
"updatedAt": 1.0,
"carFingerprint": "CAR_B",
"baselineParams": {
"AdvancedLateralTune": False,
"SteerFriction": 0.08,
"SteerLatAccel": 1.3,
"FLMActiveProfileId": "",
"FLMActiveOverrides": {},
"FLMTrialApplied": False,
},
"genericParams": {"AdvancedLateralTune": True, "SteerLatAccel": 2.1},
"flmOverrides": {},
}), encoding="utf-8")
car_a_baseline = {
"AdvancedLateralTune": False,
"SteerFriction": 0.12,
"SteerLatAccel": 1.6,
"FLMActiveProfileId": "",
"FLMActiveOverrides": {},
"FLMTrialApplied": False,
}
(workspace["snapshots"] / "active.json").write_text(json.dumps({
"reportId": "",
"profileId": "saved:tune-car-a",
"profileLabel": "Car A",
"savedTuneId": "tune-car-a",
"carFingerprint": "CAR_A",
"capturedAt": 1.0,
"params": car_a_baseline,
"appliedGenericParams": {"AdvancedLateralTune": True, "SteerLatAccel": 1.9},
"appliedFrictionThresholds": {},
"appliedVehicleKnobs": {},
}), encoding="utf-8")
fake_params_cls._store = {
"AdvancedLateralTune": True,
"SteerFriction": 0.12,
"SteerLatAccel": 1.9,
"FLMActiveProfileId": "saved:tune-car-a",
"FLMActiveOverrides": {},
"FLMTrialApplied": True,
"FLMTrialBaseline": {"params": car_a_baseline},
}
monkeypatch.setattr(module, "_current_car_identity", lambda _params: {"carFingerprint": "CAR_B", "brand": "test"})
module.apply_saved_tune(tune_id)
assert fake_params_cls._store["SteerFriction"] == pytest.approx(0.08)
assert fake_params_cls._store["SteerLatAccel"] == pytest.approx(2.1)
module.revert_trial_profile()
assert fake_params_cls._store["AdvancedLateralTune"] is False
assert fake_params_cls._store["SteerFriction"] == pytest.approx(0.08)
assert fake_params_cls._store["SteerLatAccel"] == pytest.approx(1.3)
def test_orphaned_previous_revision_can_recover_its_baseline(tmp_path):
module, fake_params_cls = _load_flm_workspace_module(tmp_path)
workspace = module.ensure_flm_workspace()
+43
View File
@@ -5935,6 +5935,7 @@ def setup(app):
"status": flm_workspace.read_flm_status(),
"activeTrial": workspace.get("activeTrial"),
"reports": workspace.get("reports", [])[:10],
"savedTunes": workspace.get("savedTunes", []),
}), 200
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/analyze", methods=["POST"])
@@ -6018,6 +6019,48 @@ def setup(app):
except RuntimeError as error:
return jsonify({"error": str(error)}), 409
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/saved-tunes", methods=["POST"])
@app.route("/api/flm/saved-tunes", methods=["POST"])
def save_flm_tune():
data = request.get_json(silent=True) or {}
try:
return jsonify(flm_workspace.save_active_trial_as_tune(str(data.get("name") or ""))), 200
except ValueError as error:
return jsonify({"error": str(error)}), 400
except RuntimeError as error:
return jsonify({"error": str(error)}), 409
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/saved-tunes/<tune_id>/apply", methods=["POST"])
@app.route("/api/flm/saved-tunes/<tune_id>/apply", methods=["POST"])
def apply_flm_saved_tune(tune_id):
try:
return jsonify(flm_workspace.apply_saved_tune(tune_id)), 200
except FileNotFoundError:
return jsonify({"error": "Saved FLM tune not found."}), 404
except RuntimeError as error:
return jsonify({"error": str(error)}), 409
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/saved-tunes/<tune_id>", methods=["PATCH"])
@app.route("/api/flm/saved-tunes/<tune_id>", methods=["PATCH"])
def rename_flm_saved_tune(tune_id):
data = request.get_json(silent=True) or {}
try:
return jsonify(flm_workspace.rename_saved_tune(tune_id, str(data.get("name") or ""))), 200
except FileNotFoundError:
return jsonify({"error": "Saved FLM tune not found."}), 404
except ValueError as error:
return jsonify({"error": str(error)}), 400
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/saved-tunes/<tune_id>", methods=["DELETE"])
@app.route("/api/flm/saved-tunes/<tune_id>", methods=["DELETE"])
def delete_flm_saved_tune(tune_id):
try:
return jsonify(flm_workspace.delete_saved_tune(tune_id)), 200
except FileNotFoundError:
return jsonify({"error": "Saved FLM tune not found."}), 404
except RuntimeError as error:
return jsonify({"error": str(error)}), 409
@app.route(f"{LEGACY_LATERAL_METHOD_API_PREFIX}/trials/apply", methods=["POST"])
@app.route("/api/flm/trials/apply", methods=["POST"])
def apply_flm_trial():
+18 -2
View File
@@ -60,6 +60,7 @@ class EGLState:
active_texture: Any = None
gen_textures: Any = None
delete_textures: Any = None
gl_finish: Any = None
# Create a single instance of the state
@@ -99,6 +100,7 @@ def init_egl() -> bool:
void glGenTextures(int n, unsigned int *textures);
void glDeleteTextures(int n, const unsigned int *textures);
GLenum glGetError(void);
void glFinish(void);
""")
# Load libraries
@@ -121,6 +123,7 @@ def init_egl() -> bool:
_egl.active_texture = _egl.gles_lib.glActiveTexture
_egl.gen_textures = _egl.gles_lib.glGenTextures
_egl.delete_textures = _egl.gles_lib.glDeleteTextures
_egl.gl_finish = _egl.gles_lib.glFinish
# Initialize EGL display once here
_egl.display = _egl.get_current_display()
@@ -135,6 +138,15 @@ def init_egl() -> bool:
return False
def is_egl_initialized() -> bool:
return _egl.initialized
def finish_gl() -> None:
if _egl.initialized:
_egl.gl_finish()
def create_egl_image(width: int, height: int, stride: int, fd: int, uv_offset: int) -> EGLImage | None:
assert _egl.initialized, "EGL not initialized"
@@ -170,10 +182,12 @@ def create_egl_image(width: int, height: int, stride: int, fd: int, uv_offset: i
return EGLImage(egl_image=egl_image, fd=dup_fd)
def destroy_egl_image(egl_image: EGLImage) -> None:
def destroy_egl_image(egl_image: EGLImage) -> bool:
assert _egl.initialized, "EGL not initialized"
_egl.destroy_image_khr(_egl.display, egl_image.egl_image)
destroyed = bool(_egl.destroy_image_khr(_egl.display, egl_image.egl_image))
if not destroyed:
cloudlog.error(f"Failed to destroy EGL image: {_egl.get_error()}")
# Close the duplicated fd we created in create_egl_image()
# We need to handle OSError since the fd might already be closed
@@ -182,6 +196,8 @@ def destroy_egl_image(egl_image: EGLImage) -> None:
except OSError:
pass
return destroyed
def create_external_texture() -> int:
"""Create a texture name whose target is exclusively GL_TEXTURE_EXTERNAL_OES."""