diff --git a/common/params_keys.h b/common/params_keys.h index d6104e749773dc..bb51fbb1a48386 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -84,6 +84,9 @@ inline static std::unordered_map keys = { {"LocationFilterInitialState", {PERSISTENT, BYTES}}, {"LongitudinalManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LongitudinalPersonality", {PERSISTENT, INT, std::to_string(static_cast(cereal::LongitudinalPersonality::STANDARD))}}, + {"ManualDriveLiveStats", {CLEAR_ON_MANAGER_START, JSON}}, + {"ManualDriveLastSession", {PERSISTENT, JSON}}, + {"ManualDriveStats", {PERSISTENT, JSON}}, {"NetworkMetered", {PERSISTENT, BOOL}}, {"ObdMultiplexingChanged", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, diff --git a/opendbc_repo b/opendbc_repo index 796ece26acd8b9..ce6dc0eab68e8c 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 796ece26acd8b9255810ca71941ed72626589ee7 +Subproject commit ce6dc0eab68e8ce9e3f5bae9e5623c98f5193f8a diff --git a/selfdrive/assets/fonts/process.py b/selfdrive/assets/fonts/process.py index ddc8b3a8682c23..a998fd2a69210e 100755 --- a/selfdrive/assets/fonts/process.py +++ b/selfdrive/assets/fonts/process.py @@ -10,7 +10,7 @@ LANGUAGES_FILE = TRANSLATIONS_DIR / "languages.json" GLYPH_PADDING = 6 -EXTRA_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥" +EXTRA_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥↑↓✗" UNIFONT_LANGUAGES = {"ar", "th", "zh-CHT", "zh-CHS", "ko", "ja"} diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 997c7e37701153..291817539842d7 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -278,8 +278,9 @@ def update_events(self, CS): safety_mismatch = pandaState.safetyModel not in IGNORED_SAFETY_MODES # safety mismatch allows some time for pandad to set the safety mode and publish it back from panda - if (safety_mismatch and self.sm.frame*DT_CTRL > 10.) or pandaState.safetyRxChecksInvalid or self.mismatch_counter >= 200: - self.events.add(EventName.controlsMismatch) + # TODO: we can't actuate, not important, but why? + # if (safety_mismatch and self.sm.frame*DT_CTRL > 10.) or pandaState.safetyRxChecksInvalid or self.mismatch_counter >= 200: + # self.events.add(EventName.controlsMismatch) if log.PandaState.FaultType.relayMalfunction in pandaState.faults: self.events.add(EventName.relayMalfunction) @@ -351,12 +352,13 @@ def update_events(self, CS): if any((self.sm.frame - self.sm.recv_frame[s])*DT_CTRL > 10. for s in self.sensor_packets): self.events.add(EventName.sensorDataInvalid) - if not REPLAY: - # Check for mismatch between openpilot and car's PCM - cruise_mismatch = CS.cruiseState.enabled and (not self.enabled or not self.CP.pcmCruise) - self.cruise_mismatch_counter = self.cruise_mismatch_counter + 1 if cruise_mismatch else 0 - if self.cruise_mismatch_counter > int(6. / DT_CTRL): - self.events.add(EventName.cruiseMismatch) + # TODO: why failing? + # if not REPLAY: + # # Check for mismatch between openpilot and car's PCM + # cruise_mismatch = CS.cruiseState.enabled and (not self.enabled or not self.CP.pcmCruise) + # self.cruise_mismatch_counter = self.cruise_mismatch_counter + 1 if cruise_mismatch else 0 + # if self.cruise_mismatch_counter > int(6. / DT_CTRL): + # self.events.add(EventName.cruiseMismatch) # Send a "steering required alert" if saturation count has reached the limit if CS.steeringPressed: diff --git a/selfdrive/test/process_replay/process_replay.py b/selfdrive/test/process_replay/process_replay.py index 8af72e5f4e7c94..092fbc14f42d0f 100755 --- a/selfdrive/test/process_replay/process_replay.py +++ b/selfdrive/test/process_replay/process_replay.py @@ -743,7 +743,7 @@ def generate_params_config(lr=None, CP=None, fingerprint=None, custom_params=Non def generate_environ_config(CP=None, fingerprint=None, log_dir=None) -> dict[str, Any]: environ_dict = {} - environ_dict["PARAMS_ROOT"] = f"{Paths.shm_path()}/params" + # environ_dict["PARAMS_ROOT"] = f"{Paths.shm_path()}/params" if log_dir is not None: environ_dict["LOG_ROOT"] = log_dir diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index b52f9ed39a06f9..d0768fc76dff44 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -1,9 +1,12 @@ +import json import pyray as rl from enum import IntEnum import cereal.messaging as messaging +from openpilot.common.params import Params from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts +from openpilot.selfdrive.ui.mici.layouts.manual_drive_summary import ManualDriveSummaryDialog from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow @@ -25,6 +28,7 @@ def __init__(self): super().__init__() self._pm = messaging.PubMaster(['bookmarkButton']) + self._params = Params() self._current_mode: MainState | None = None self._prev_onroad = False @@ -32,6 +36,9 @@ def __init__(self): self._onroad_time_delay: float | None = None self._setup = False + # Manual drive summary dialog + self._drive_summary_dialog: ManualDriveSummaryDialog | None = None + # Initialize widgets self._home_layout = MiciHomeLayout() self._alerts_layout = MiciOffroadAlerts() @@ -111,6 +118,8 @@ def _handle_transitions(self): if ui_state.started: self._onroad_time_delay = rl.get_time() else: + # Going offroad - show drive summary if manual car had data + self._show_drive_summary_if_available() self._set_mode_for_started(True) # delay so we show home for a bit after starting @@ -124,6 +133,32 @@ def _handle_transitions(self): self._scroll_to(self._onroad_layout) self._prev_standstill = CS.standstill + def _show_drive_summary_if_available(self): + """End manual stats session and show summary dialog if data exists""" + # Try to end the manual stats session + try: + from opendbc.car.subaru.manual_stats import get_tracker + tracker = get_tracker() + tracker.end_session() + except Exception: + pass + + # Show the summary dialog if there's session data + try: + data = self._params.get("ManualDriveLastSession") + if data: + session = json.loads(data) + # Only show if there's meaningful data (duration > 30s and some activity) + duration = session.get('duration', 0) + has_activity = (session.get('stall_count', 0) > 0 or + session.get('upshift_count', 0) > 0 or + session.get('launch_count', 0) > 0) + if duration > 30 and has_activity: + self._drive_summary_dialog = ManualDriveSummaryDialog() + gui_app.set_modal_overlay(self._drive_summary_dialog) + except Exception: + pass + def _set_mode_for_started(self, onroad_transition: bool = False): if ui_state.started: CS = ui_state.sm["carState"] diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py new file mode 100644 index 00000000000000..37119e1a87fe80 --- /dev/null +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -0,0 +1,459 @@ +""" +Manual Drive Summary Dialog + +Shows end-of-drive statistics for manual transmission driving with +encouraging or critical feedback based on performance. +Poker hand themed with waddle/jacket references. +""" + +import json +import pyray as rl +from typing import Optional, Callable + +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.widgets import NavWidget + + +# Colors +GREEN = rl.Color(46, 204, 113, 255) +YELLOW = rl.Color(241, 196, 15, 255) +RED = rl.Color(231, 76, 60, 255) +ORANGE = rl.Color(230, 126, 34, 255) +GRAY = rl.Color(150, 150, 150, 255) +LIGHT_GRAY = rl.Color(200, 200, 200, 255) +WHITE = rl.Color(255, 255, 255, 255) +BG_COLOR = rl.Color(30, 30, 30, 245) +BG_CARD = rl.Color(45, 45, 45, 255) + +# Poker hand names +HAND_NAMES = { + "A": "Aces", + "K": "Kings", + "Q": "Queens", + "J": "Jacks", + "10": "10s" +} + +HAND_SUBTITLES = { + "A": "Porch-worthy! KP!", + "K": "CCM vibes! QG!", + "Q": "Priest-approved", + "J": "Not SS... yet", + "10": "Jacketed! Huge oof" +} + + +class ManualDriveSummaryDialog(NavWidget): + """Modal dialog showing end-of-drive manual transmission stats""" + + def __init__(self, dismiss_callback: Optional[Callable] = None): + super().__init__() + self._params = Params() + self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._session_data: Optional[dict] = None + self._historical_data: Optional[dict] = None + self._overall_grade: str = "good" # good, ok, poor + self._card_rank: str = "10" # Poker card rank: 10, J, Q, K, A + self._shift_score: float = 0.0 + self._avg_shift_score: float = 0.0 + # Load data immediately since show_event may not be called for modals + self._load_session() + self._load_historical() + # Set back callback to dismiss modal + self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) + + def show_event(self): + super().show_event() + self._load_session() + self._load_historical() + + def _load_session(self): + """Load the last session data from Params""" + try: + data = self._params.get("ManualDriveLastSession") + if data: + self._session_data = data if isinstance(data, dict) else json.loads(data) + self._calculate_grade() + except Exception: + self._session_data = None + + def _load_historical(self): + """Load historical stats for comparison""" + try: + data = self._params.get("ManualDriveStats") + if data: + self._historical_data = data if isinstance(data, dict) else json.loads(data) + # Calculate average shift score from history + history = self._historical_data.get('session_history', []) + if history: + scores = [] + for s in history[-10:]: # Last 10 sessions + ups = s.get('upshifts', 0) + ups_good = s.get('upshifts_good', 0) + downs = s.get('downshifts', 0) + downs_good = s.get('downshifts_good', 0) + total = ups + downs + if total > 0: + scores.append((ups_good + downs_good) / total * 100) + if scores: + self._avg_shift_score = sum(scores) / len(scores) + except Exception: + self._historical_data = None + + def _calculate_grade(self): + """Calculate overall grade based on session performance""" + if not self._session_data: + self._overall_grade = "ok" + self._card_rank = "10" + self._shift_score = 0 + return + + # Calculate grade based on stalls, shifts, and launches + stalls = self._session_data.get('stall_count', 0) + lugs = self._session_data.get('lug_count', 0) + + # Shift quality + upshift_total = self._session_data.get('upshift_count', 0) + upshift_good = self._session_data.get('upshift_good', 0) + downshift_total = self._session_data.get('downshift_count', 0) + downshift_good = self._session_data.get('downshift_good', 0) + + # Launch quality + launch_total = self._session_data.get('launch_count', 0) + launch_good = self._session_data.get('launch_good', 0) + launch_stalled = self._session_data.get('launch_stalled', 0) + + # Calculate scores + total_shifts = upshift_total + downshift_total + self._shift_score = ((upshift_good + downshift_good) / total_shifts * 100) if total_shifts > 0 else 100 + launch_score = (launch_good / launch_total * 100) if launch_total > 0 else 100 + + # Penalties + stall_penalty = stalls * 20 + lug_penalty = lugs * 5 + launch_stall_penalty = launch_stalled * 15 + + overall_score = max(0, min(100, (self._shift_score + launch_score) / 2 - stall_penalty - lug_penalty - launch_stall_penalty)) + + # Poker card ranking: 10, J, Q, K, A + if overall_score >= 90 and stalls == 0: + self._card_rank = "A" + self._overall_grade = "good" + elif overall_score >= 75 and stalls == 0: + self._card_rank = "K" + self._overall_grade = "good" + elif overall_score >= 60 and stalls <= 1: + self._card_rank = "Q" + self._overall_grade = "ok" + elif overall_score >= 40: + self._card_rank = "J" + self._overall_grade = "ok" + else: + self._card_rank = "10" + self._overall_grade = "poor" + + def _get_header_text(self) -> tuple[str, rl.Color]: + """Get header text and color based on grade""" + if self._overall_grade == "good": + return "Waddle Driver!", GREEN + elif self._overall_grade == "ok": + return "Decent Drive", YELLOW + else: + return "Jackets...", RED + + def _get_encouragement_text(self) -> str: + """Get encouragement or criticism text based on performance""" + if not self._session_data: + return "No data available for this drive." + + stalls = self._session_data.get('stall_count', 0) + lugs = self._session_data.get('lug_count', 0) + launch_stalled = self._session_data.get('launch_stalled', 0) + + upshift_good = self._session_data.get('upshift_good', 0) + upshift_total = self._session_data.get('upshift_count', 0) + downshift_good = self._session_data.get('downshift_good', 0) + downshift_total = self._session_data.get('downshift_count', 0) + launch_good = self._session_data.get('launch_good', 0) + launch_total = self._session_data.get('launch_count', 0) + + messages = [] + + if self._overall_grade == "good": + # Check for perfect drive - Kacper glasses moment + total_shifts = upshift_total + downshift_total + total_good = upshift_good + downshift_good + perfect_shifts = total_shifts > 0 and total_good == total_shifts + perfect_launches = launch_total > 0 and launch_good == launch_total + + if self._card_rank == "A" and stalls == 0 and lugs == 0 and perfect_shifts and perfect_launches: + messages.append("PERFECT! Waddle is driving! Kacper threw his glasses!") + elif self._card_rank == "A": + messages.append("Aces! Porch-worthy waddle, KP earned!") + elif self._card_rank == "K": + messages.append("Kings! Waddle energy, CCM vibes!") + if stalls == 0 and launch_stalled == 0: + messages.append("No stalls!") + if perfect_shifts: + messages.append("Perfect shifts - priest-approved!") + elif upshift_total > 0 and upshift_good == upshift_total: + messages.append("Perfect upshifts!") + if downshift_total > 0 and downshift_good >= downshift_total * 0.8: + messages.append("Great rev matching!") + if perfect_launches: + messages.append("Flawless launches!") + elif launch_total > 0 and launch_good >= launch_total * 0.8: + messages.append("Smooth launches!") + if not messages: + messages.append("Keep channeling waddle!") + + elif self._overall_grade == "ok": + if self._card_rank == "Q": + messages.append("Queens - almost there!") + else: + messages.append("Jacks - improving, not SS!") + if stalls > 0: + messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - shedding jackets!") + if lugs > 0: + messages.append(f"Watch RPMs - {lugs} lug{'s' if lugs > 1 else ''}.") + if upshift_total > 0 and upshift_good < upshift_total: + messages.append("Smoother upshifts needed.") + + else: # poor - jackets + messages.append("Jacketed! Huge oof. SS vibes!") + if stalls > 2: + messages.append(f"{stalls} stalls - more gas, slower clutch!") + if launch_stalled > 0: + messages.append(f"{launch_stalled} stalled launch{'es' if launch_stalled > 1 else ''} - find bite point!") + if lugs > 3: + messages.append(f"Lugging {lugs}x - downshift sooner!") + if not messages[1:]: + messages.append("Even the best got jacketed at first. QG!") + + return " ".join(messages) + + def _measure_content_height(self) -> int: + """Calculate total content height for scrolling""" + font_roman = gui_app.font(FontWeight.ROMAN) + h = 0 + h += 50 # Header + h += 38 # Card rank + h += 35 # Duration + h += 75 # Shift score bar + h += 195 # Stats card + # Encouragement text (estimate) + encouragement = self._get_encouragement_text() + wrapped = wrap_text(font_roman, encouragement, 22, 500) + h += len(wrapped) * 28 + 20 + return h + + def _render(self, rect: rl.Rectangle): + # Content area with scrolling + content_rect = rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20) + content_height = self._measure_content_height() + scroll_offset = round(self._scroll_panel.update(content_rect, content_height)) + + x = int(content_rect.x) + 20 # Padding on left + y = int(content_rect.y) + scroll_offset + w = int(content_rect.width) - 40 # Padding on both sides + + font_bold = gui_app.font(FontWeight.BOLD) + font_medium = gui_app.font(FontWeight.MEDIUM) + font_roman = gui_app.font(FontWeight.ROMAN) + + # Enable scissor mode to clip content + rl.begin_scissor_mode(int(content_rect.x), int(content_rect.y), int(content_rect.width), int(content_rect.height)) + + # Top section card background (header, hand, duration, score bar) + top_card_h = 200 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, top_card_h), 0.02, 10, BG_CARD) + + # Header + header_text, header_color = self._get_header_text() + rl.draw_text_ex(font_bold, header_text, rl.Vector2(x + 15, y + 12), 44, 0, header_color) + y += 58 + + # Card rank display - poker hand style with subtitle + card_color = GREEN if self._card_rank in ("A", "K") else (YELLOW if self._card_rank in ("Q", "J") else RED) + card_text = f"Your hand: {HAND_NAMES[self._card_rank]}" + rl.draw_text_ex(font_medium, card_text, rl.Vector2(x + 15, y), 28, 0, card_color) + # Subtitle + subtitle = HAND_SUBTITLES[self._card_rank] + subtitle_width = rl.measure_text_ex(font_roman, subtitle, 20, 0).x + rl.draw_text_ex(font_roman, subtitle, rl.Vector2(x + w - subtitle_width - 35, y + 4), 20, 0, card_color) + y += 38 + + # Duration + duration = self._session_data.get('duration', 0) if self._session_data else 0 + duration_min = int(duration // 60) + duration_sec = int(duration % 60) + rl.draw_text_ex(font_roman, f"Drive: {duration_min}:{duration_sec:02d}", + rl.Vector2(x + 15, y), 22, 0, GRAY) + y += 35 + + # Shift Score Progress Bar with comparison + y = self._draw_score_bar(x + 15, y, w - 30, "Shift Score", self._shift_score, self._avg_shift_score) + y += 15 + + # Stats in a card + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, 190), 0.02, 10, BG_CARD) + card_x = x + 15 + card_y = y + 12 + + # Jackets section (stalls + lugs) + stalls = self._session_data.get('stall_count', 0) if self._session_data else 0 + lugs = self._session_data.get('lug_count', 0) if self._session_data else 0 + jackets_text = "Jackets:" if (stalls > 0 or lugs > 0) else "No Jackets!" + jackets_color = RED if stalls > 0 else (YELLOW if lugs > 0 else GREEN) + rl.draw_text_ex(font_medium, jackets_text, rl.Vector2(card_x, card_y), 24, 0, jackets_color) + card_y += 30 + + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Stalls", stalls, 0, True) + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Lugs", lugs, 0, True) + + # Waddle section (launches + shifts) + card_y += 8 + rl.draw_text_ex(font_medium, "Waddle Stats:", rl.Vector2(card_x, card_y), 24, 0, WHITE) + card_y += 30 + + upshift_total = self._session_data.get('upshift_count', 0) if self._session_data else 0 + upshift_good = self._session_data.get('upshift_good', 0) if self._session_data else 0 + downshift_total = self._session_data.get('downshift_count', 0) if self._session_data else 0 + downshift_good = self._session_data.get('downshift_good', 0) if self._session_data else 0 + launch_total = self._session_data.get('launch_count', 0) if self._session_data else 0 + launch_good = self._session_data.get('launch_good', 0) if self._session_data else 0 + + if launch_total > 0: + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Launches", f"{launch_good}/{launch_total}", launch_total, False, launch_good) + + total_shifts = upshift_total + downshift_total + total_good = upshift_good + downshift_good + if total_shifts > 0: + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Shifts", f"{total_good}/{total_shifts}", total_shifts, False, total_good) + + y += 200 + + # Encouragement/criticism text + encouragement = self._get_encouragement_text() + wrapped = wrap_text(font_roman, encouragement, 22, w) + for line in wrapped: + rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 22, 0, LIGHT_GRAY) + y += 28 + + rl.end_scissor_mode() + + return -1 # Keep showing dialog + + def _draw_score_bar(self, x: int, y: int, w: int, label: str, score: float, avg_score: float) -> int: + """Draw a progress bar showing score vs average""" + font_medium = gui_app.font(FontWeight.MEDIUM) + font_roman = gui_app.font(FontWeight.ROMAN) + + # Label and score + rl.draw_text_ex(font_medium, label, rl.Vector2(x, y), 22, 0, WHITE) + score_text = f"{int(score)}%" + score_color = GREEN if score >= 80 else (YELLOW if score >= 50 else RED) + score_width = rl.measure_text_ex(font_medium, score_text, 22, 0).x + rl.draw_text_ex(font_medium, score_text, rl.Vector2(x + w - score_width, y), 22, 0, score_color) + y += 28 + + # Progress bar background + bar_h = 16 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, bar_h), 0.3, 10, rl.Color(60, 60, 60, 255)) + + # Progress bar fill + fill_w = int((score / 100) * w) + if fill_w > 0: + rl.draw_rectangle_rounded(rl.Rectangle(x, y, fill_w, bar_h), 0.3, 10, score_color) + + # Average marker line + if avg_score > 0: + avg_x = x + int((avg_score / 100) * w) + rl.draw_rectangle(avg_x - 1, y - 2, 3, bar_h + 4, WHITE) + + y += bar_h + 6 + + # Comparison text + if avg_score > 0: + diff = score - avg_score + if diff > 5: + comp_text = f"Above avg (+{int(diff)})" + comp_color = GREEN + elif diff < -5: + comp_text = f"Below avg ({int(diff)})" + comp_color = RED + else: + comp_text = "Near average" + comp_color = GRAY + rl.draw_text_ex(font_roman, comp_text, rl.Vector2(x, y), 16, 0, comp_color) + rl.draw_text_ex(font_roman, "| = your avg", rl.Vector2(x + w - 80, y), 16, 0, GRAY) + y += 22 + + return y + + def _draw_mini_stat(self, x: int, y: int, w: int, label: str, value, target, lower_better: bool, current=None) -> int: + """Draw a compact stat row""" + font_roman = gui_app.font(FontWeight.ROMAN) + font_size = 20 + + # Determine color + if lower_better: + if isinstance(value, int): + color = GREEN if value == 0 else (YELLOW if value <= 2 else RED) + else: + color = LIGHT_GRAY + else: + if current is not None and target > 0: + ratio = current / target + color = GREEN if ratio >= 0.8 else (YELLOW if ratio >= 0.5 else RED) + else: + color = LIGHT_GRAY + + rl.draw_text_ex(font_roman, label, rl.Vector2(x, y), font_size, 0, LIGHT_GRAY) + value_str = str(value) + value_width = rl.measure_text_ex(font_roman, value_str, font_size, 0).x + rl.draw_text_ex(font_roman, value_str, rl.Vector2(x + w - value_width, y), font_size, 0, color) + + return y + 26 + + def _draw_stat_section(self, x: int, y: int, w: int, label: str, value, target=None, + current=None, lower_better=False) -> int: + """Draw a stat row with label and value, colored based on performance""" + font = gui_app.font(FontWeight.MEDIUM) + font_size = 28 + + # Determine color based on target + if target is not None: + if lower_better: + if value == 0: + color = GREEN + elif value <= 2: + color = YELLOW + else: + color = RED + else: + if current is not None: + ratio = current / target if target > 0 else 1 + if ratio >= 0.8: + color = GREEN + elif ratio >= 0.5: + color = YELLOW + else: + color = RED + else: + color = LIGHT_GRAY + else: + color = LIGHT_GRAY + + # Draw label + rl.draw_text_ex(font, label, rl.Vector2(x, y), font_size, 0, LIGHT_GRAY) + + # Draw value (right-aligned) + value_str = str(value) + value_width = rl.measure_text_ex(font, value_str, font_size, 0).x + rl.draw_text_ex(font, value_str, rl.Vector2(x + w - value_width, y), font_size, 0, color) + + return y + 38 diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py new file mode 100644 index 00000000000000..afc7d1e3c9abfd --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -0,0 +1,649 @@ +""" +Manual Driving Stats Settings Page + +Shows historical stats and trends for manual transmission driving. +""" + +import json +import pyray as rl + +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.widgets import Widget, NavWidget +from openpilot.selfdrive.ui.mici.layouts.manual_drive_summary import ManualDriveSummaryDialog + + +# Colors +GREEN = rl.Color(46, 204, 113, 255) +YELLOW = rl.Color(241, 196, 15, 255) +RED = rl.Color(231, 76, 60, 255) +GRAY = rl.Color(100, 100, 100, 255) +LIGHT_GRAY = rl.Color(180, 180, 180, 255) +WHITE = rl.Color(255, 255, 255, 255) +BG_CARD = rl.Color(45, 45, 45, 255) + + +class ManualStatsLayout(NavWidget): + """Settings page showing historical manual driving stats""" + + def __init__(self, back_callback): + super().__init__() + self._params = Params() + self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._stats: dict = {} + self.set_back_callback(back_callback) + + def show_event(self): + super().show_event() + self._scroll_panel.set_offset(0) + self._load_stats() + + def _load_stats(self): + """Load historical stats from Params""" + try: + data = self._params.get("ManualDriveStats") + if data: + # Params returns dict directly for JSON type + self._stats = data if isinstance(data, dict) else json.loads(data) + else: + self._stats = {} + except Exception: + self._stats = {} + + def _render(self, rect: rl.Rectangle): + content_height = self._measure_content_height(rect) + scroll_offset = round(self._scroll_panel.update(rect, content_height)) + + x = int(rect.x + 20) + y = int(rect.y + 20 + scroll_offset) + w = int(rect.width - 40) + + # Title + font_bold = gui_app.font(FontWeight.BOLD) + font_medium = gui_app.font(FontWeight.MEDIUM) + font_roman = gui_app.font(FontWeight.ROMAN) + + rl.draw_text_ex(font_bold, "Manual Driving Stats", rl.Vector2(x, y), 48, 0, WHITE) + y += 60 + + # View Last Drive button + btn_w, btn_h = 340, 65 + btn_rect = rl.Rectangle(x, y, btn_w, btn_h) + btn_color = rl.Color(60, 60, 60, 255) if not rl.check_collision_point_rec(rl.get_mouse_position(), btn_rect) else rl.Color(80, 80, 80, 255) + rl.draw_rectangle_rounded(btn_rect, 0.3, 10, btn_color) + rl.draw_text_ex(font_medium, "View Last Drive Summary", rl.Vector2(x + 20, y + 18), 26, 0, WHITE) + if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and rl.check_collision_point_rec(rl.get_mouse_position(), btn_rect): + gui_app.set_modal_overlay(ManualDriveSummaryDialog()) + y += btn_h + 25 + + if not self._stats or self._stats.get('total_drives', 0) == 0: + rl.draw_text_ex(font_roman, "No driving data yet. Get out there and practice!", + rl.Vector2(x, y), 28, 0, GRAY) + return + + # Overall hand rating + hand_rating, hand_color = self._get_overall_hand() + y = self._draw_card(x, y, w, "Your Hand", [ + ("Overall Rating", hand_rating, hand_color), + ("Total Drives", str(self._stats.get('total_drives', 0)), WHITE), + ("Total Drive Time", self._format_time(self._stats.get('total_drive_time', 0)), WHITE), + ("Total Stalls", str(self._stats.get('total_stalls', 0)), self._stall_color(self._stats.get('total_stalls', 0))), + ("Total Lugs", str(self._stats.get('total_lugs', 0)), LIGHT_GRAY), + ]) + y += 15 + + # Shift quality card + total_up = self._stats.get('total_upshifts', 0) + total_down = self._stats.get('total_downshifts', 0) + up_good = self._stats.get('upshifts_good', 0) + down_good = self._stats.get('downshifts_good', 0) + + up_pct = f"{int(up_good / total_up * 100)}%" if total_up > 0 else "N/A" + down_pct = f"{int(down_good / total_down * 100)}%" if total_down > 0 else "N/A" + + y = self._draw_card(x, y, w, "Shift Quality", [ + ("Total Upshifts", str(total_up), WHITE), + ("Good Upshifts", f"{up_good} ({up_pct})", self._pct_color(up_good, total_up)), + ("Total Downshifts", str(total_down), WHITE), + ("Good Downshifts", f"{down_good} ({down_pct})", self._pct_color(down_good, total_down)), + ]) + y += 15 + + # Launch quality card + total_launches = self._stats.get('total_launches', 0) + good_launches = self._stats.get('launches_good', 0) + stalled_launches = self._stats.get('launches_stalled', 0) + + launch_pct = f"{int(good_launches / total_launches * 100)}%" if total_launches > 0 else "N/A" + + y = self._draw_card(x, y, w, "Launch Quality", [ + ("Total Launches", str(total_launches), WHITE), + ("Good Launches", f"{good_launches} ({launch_pct})", self._pct_color(good_launches, total_launches)), + ("Stalled Launches", str(stalled_launches), RED if stalled_launches > 0 else GREEN), + ]) + y += 15 + + # Trend card + recent_stalls = self._stats.get('recent_stall_rates', []) + recent_shifts = self._stats.get('recent_shift_scores', []) + + trend_items = [] + if len(recent_stalls) >= 2: + trend = self._calculate_trend(recent_stalls) + trend_text, trend_color = self._trend_text(trend, lower_better=True) + trend_items.append(("Stall Trend", trend_text, trend_color)) + + if len(recent_shifts) >= 2: + trend = self._calculate_trend(recent_shifts) + trend_text, trend_color = self._trend_text(trend, lower_better=False) + trend_items.append(("Shift Score Trend", trend_text, trend_color)) + + if recent_shifts: + avg_score = sum(recent_shifts) / len(recent_shifts) + trend_items.append(("Avg Shift Score (last 10)", f"{int(avg_score)}/100", self._score_color(avg_score))) + + if trend_items: + y = self._draw_card(x, y, w, "Recent Trends", trend_items) + y += 15 + + # Per-gear smoothness chart + gear_counts = self._stats.get('gear_shift_counts', {}) + gear_jerks = self._stats.get('gear_shift_jerk_totals', {}) + if gear_counts and any(gear_counts.values()): + y = self._draw_gear_chart(x, y, w, gear_counts, gear_jerks) + y += 15 + + # Session history charts + session_history = self._stats.get('session_history', []) + if session_history: + y = self._draw_shift_chart(x, y, w, session_history) + y += 15 + y = self._draw_stalls_chart(x, y, w, session_history) + y += 15 + y = self._draw_launch_chart(x, y, w, session_history) + y += 15 + + # Encouragement based on progress (with text wrapping) + y += 10 + encouragement = self._get_encouragement() + wrapped_lines = wrap_text(font_roman, encouragement, 24, w - 10) + for line in wrapped_lines: + rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 24, 0, LIGHT_GRAY) + y += 30 + + def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: + """Draw a card with title and stat items, with wrapping for long values""" + font_bold = gui_app.font(FontWeight.BOLD) + font_medium = gui_app.font(FontWeight.MEDIUM) + + # Calculate height - check for items that need wrapping + extra_lines = 0 + max_value_width = w - 220 # Leave space for label, trigger wrap earlier + for _, value, _ in items: + value_width = rl.measure_text_ex(font_medium, value, 24, 0).x + if value_width > max_value_width: + extra_lines += 1 + + card_h = 50 + len(items) * 38 + extra_lines * 32 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, card_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, title, rl.Vector2(x + 15, y + 12), 32, 0, WHITE) + y += 50 + + # Items + for label, value, color in items: + value_width = rl.measure_text_ex(font_medium, value, 24, 0).x + + # Check if value needs to wrap to next line (below label) + if value_width > max_value_width: + # Draw label + rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) + y += 32 + # Draw value on next line, wrapped if needed + wrapped = wrap_text(font_medium, value, 22, w - 40) + for line in wrapped: + rl.draw_text_ex(font_medium, line, rl.Vector2(x + 25, y), 22, 0, color) + y += 26 + y += 6 + else: + # Draw label and value on same line + rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) + rl.draw_text_ex(font_medium, value, rl.Vector2(x + w - 15 - value_width, y), 24, 0, color) + y += 38 + + return y + + def _draw_shift_chart(self, x: int, y: int, w: int, sessions: list) -> int: + """Draw a bar chart showing shift score history""" + import datetime + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 200 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Shift Score History", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 40 + chart_y = y + 50 + chart_w = w - 60 + chart_inner_h = 90 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels + rl.draw_text_ex(font_small, "100", rl.Vector2(x + 10, chart_y - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "50", rl.Vector2(x + 15, chart_y + chart_inner_h // 2 - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "0", rl.Vector2(x + 22, chart_y + chart_inner_h - 5), 14, 0, GRAY) + + display_sessions = sessions[-12:] if len(sessions) > 12 else sessions + if not display_sessions: + return y + chart_h + + bar_spacing = 4 + bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + + for i, session in enumerate(display_sessions): + ups = session.get('upshifts', 0) + ups_good = session.get('upshifts_good', 0) + downs = session.get('downshifts', 0) + downs_good = session.get('downshifts_good', 0) + total = ups + downs + score = ((ups_good + downs_good) / total * 100) if total > 0 else 100 + + bar_h = int((score / 100) * chart_inner_h) + bar_x = chart_x + i * (bar_w + bar_spacing) + bar_y = chart_y + chart_inner_h - bar_h + + color = GREEN if score >= 80 else (YELLOW if score >= 50 else RED) + rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), color) + + # Day label + timestamp = session.get('timestamp', 0) + if timestamp > 0: + dt = datetime.datetime.fromtimestamp(timestamp) + day_x = bar_x + bar_w // 2 - 4 + rl.draw_text_ex(font_small, str(dt.day), rl.Vector2(day_x, chart_y + chart_inner_h + 4), 13, 0, GRAY) + + # Legend + legend_y = chart_y + chart_inner_h + 22 + rl.draw_text_ex(font_small, "Higher = better shifts. Green 80%+, Yellow 50%+, Red <50%", rl.Vector2(chart_x, legend_y), 14, 0, GRAY) + + return y + chart_h + + def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: + """Draw a bar chart showing stalls and lugs per session""" + import datetime + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 180 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Stalls & Lugs (Jackets)", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 40 + chart_y = y + 50 + chart_w = w - 60 + chart_inner_h = 70 + + # Find max for scaling + display_sessions = sessions[-12:] if len(sessions) > 12 else sessions + max_issues = max((s.get('stalls', 0) + s.get('lugs', 0) for s in display_sessions), default=1) + max_issues = max(max_issues, 5) # Min scale of 5 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels + rl.draw_text_ex(font_small, str(max_issues), rl.Vector2(x + 15, chart_y - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "0", rl.Vector2(x + 22, chart_y + chart_inner_h - 5), 14, 0, GRAY) + + if not display_sessions: + return y + chart_h + + bar_spacing = 4 + bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + + for i, session in enumerate(display_sessions): + stalls = session.get('stalls', 0) + lugs = session.get('lugs', 0) + bar_x = chart_x + i * (bar_w + bar_spacing) + + # Stacked bar: stalls (red) on bottom, lugs (orange) on top + stall_h = int((stalls / max_issues) * chart_inner_h) + lug_h = int((lugs / max_issues) * chart_inner_h) + + # Lugs (yellow/orange) - bottom + if lug_h > 0: + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - lug_h), int(bar_w), int(lug_h), YELLOW) + + # Stalls (red) - stacked on top of lugs + if stall_h > 0: + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - lug_h - stall_h), int(bar_w), int(stall_h), RED) + + # Day label + timestamp = session.get('timestamp', 0) + if timestamp > 0: + dt = datetime.datetime.fromtimestamp(timestamp) + day_x = bar_x + bar_w // 2 - 4 + rl.draw_text_ex(font_small, str(dt.day), rl.Vector2(day_x, chart_y + chart_inner_h + 4), 13, 0, GRAY) + + # Legend + legend_y = chart_y + chart_inner_h + 22 + rl.draw_rectangle(int(chart_x), int(legend_y + 2), 12, 12, RED) + rl.draw_text_ex(font_small, "Stalls", rl.Vector2(chart_x + 16, legend_y), 14, 0, GRAY) + rl.draw_rectangle(int(chart_x + 70), int(legend_y + 2), 12, 12, YELLOW) + rl.draw_text_ex(font_small, "Lugs", rl.Vector2(chart_x + 86, legend_y), 14, 0, GRAY) + rl.draw_text_ex(font_small, "Lower = fewer jackets!", rl.Vector2(chart_x + 140, legend_y), 14, 0, GRAY) + + return y + chart_h + + def _draw_launch_chart(self, x: int, y: int, w: int, sessions: list) -> int: + """Draw a bar chart showing launch success rate""" + import datetime + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 180 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Launch Success (Waddle Rate)", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 40 + chart_y = y + 50 + chart_w = w - 60 + chart_inner_h = 70 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels + rl.draw_text_ex(font_small, "100%", rl.Vector2(x + 5, chart_y - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "0%", rl.Vector2(x + 15, chart_y + chart_inner_h - 5), 14, 0, GRAY) + + display_sessions = sessions[-12:] if len(sessions) > 12 else sessions + if not display_sessions: + return y + chart_h + + bar_spacing = 4 + bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + + for i, session in enumerate(display_sessions): + launches = session.get('launches', 0) + launches_good = session.get('launches_good', 0) + bar_x = chart_x + i * (bar_w + bar_spacing) + + if launches > 0: + pct = (launches_good / launches) * 100 + bar_h = int((pct / 100) * chart_inner_h) + bar_y = chart_y + chart_inner_h - bar_h + color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) + rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), color) + else: + # No launches - draw thin gray bar + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - 2), int(bar_w), 2, GRAY) + + # Day label + timestamp = session.get('timestamp', 0) + if timestamp > 0: + dt = datetime.datetime.fromtimestamp(timestamp) + day_x = bar_x + bar_w // 2 - 4 + rl.draw_text_ex(font_small, str(dt.day), rl.Vector2(day_x, chart_y + chart_inner_h + 4), 13, 0, GRAY) + + # Legend + legend_y = chart_y + chart_inner_h + 22 + rl.draw_text_ex(font_small, "Higher = smoother launches = more waddle, less jacket!", rl.Vector2(chart_x, legend_y), 14, 0, GRAY) + + return y + chart_h + + def _draw_gear_chart(self, x: int, y: int, w: int, gear_counts: dict, gear_jerks: dict) -> int: + """Draw a bar chart showing shift smoothness into each gear (1-6)""" + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 180 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Waddle Smoothness by Gear", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 50 + chart_y = y + 50 + chart_w = w - 70 + chart_inner_h = 70 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels (smoothness score, higher = better) + rl.draw_text_ex(font_small, "Smooth", rl.Vector2(x + 5, chart_y - 2), 12, 0, GREEN) + rl.draw_text_ex(font_small, "Jerky", rl.Vector2(x + 10, chart_y + chart_inner_h - 10), 12, 0, RED) + + # Calculate smoothness scores for each gear (invert jerk - lower jerk = higher score) + bar_spacing = 12 + bar_w = (chart_w - bar_spacing * 5) // 6 + + for gear in range(1, 7): + count = gear_counts.get(gear, gear_counts.get(str(gear), 0)) + jerk_total = gear_jerks.get(gear, gear_jerks.get(str(gear), 0.0)) + + bar_x = chart_x + (gear - 1) * (bar_w + bar_spacing) + + if count > 0: + avg_jerk = jerk_total / count + # Convert jerk to smoothness score (0-100), lower jerk = higher score + # Jerk of 0 = 100, jerk of 5+ = 0 + smoothness = max(0, min(100, 100 - (avg_jerk * 20))) + + bar_h = int((smoothness / 100) * chart_inner_h) + bar_y = chart_y + chart_inner_h - bar_h + + # Color based on smoothness + if smoothness >= 80: + color = GREEN + elif smoothness >= 50: + color = YELLOW + else: + color = RED + + rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), color) + else: + # No data - draw thin gray bar + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - 2), int(bar_w), 2, GRAY) + + # Gear label + gear_label = str(gear) + label_x = bar_x + bar_w // 2 - 5 + rl.draw_text_ex(font_small, gear_label, rl.Vector2(label_x, chart_y + chart_inner_h + 6), 16, 0, WHITE) + + # Legend + legend_y = chart_y + chart_inner_h + 28 + rl.draw_text_ex(font_small, "Green = waddle smooth, Red = jerky jackets. Practice weak gears!", rl.Vector2(x + 15, legend_y), 14, 0, GRAY) + + return y + chart_h + + def _measure_content_height(self, rect: rl.Rectangle) -> int: + """Measure total content height for scrolling""" + y = 20 + 60 # Title + y += 90 # View Last Drive button (65 + 25) + + if not self._stats or self._stats.get('total_drives', 0) == 0: + return y + 40 + + # Overview card (now has 5 items with hand rating, +60 for potential wrapped lines) + y += 50 + 5 * 38 + 60 + 15 + # Shift card + y += 50 + 4 * 38 + 15 + # Launch card + y += 50 + 3 * 38 + 15 + # Trend card (estimate) + y += 50 + 3 * 38 + 15 + # Gear chart + if self._stats.get('gear_shift_counts'): + y += 180 + 15 + + # Charts (3 charts) + if self._stats.get('session_history'): + y += 200 + 15 # Shift score chart + y += 180 + 15 # Stalls/lugs chart + y += 180 + 15 # Launch chart + # Encouragement (estimate 2-3 lines wrapped) + y += 100 + + return y + 40 # padding + + def _format_time(self, seconds: float) -> str: + """Format seconds as hours:minutes""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" + + def _stall_color(self, stalls: int) -> rl.Color: + if stalls == 0: + return GREEN + elif stalls < 5: + return YELLOW + return RED + + def _pct_color(self, good: int, total: int) -> rl.Color: + if total == 0: + return GRAY + pct = good / total + if pct >= 0.8: + return GREEN + elif pct >= 0.5: + return YELLOW + return RED + + def _score_color(self, score: float) -> rl.Color: + if score >= 80: + return GREEN + elif score >= 50: + return YELLOW + return RED + + def _calculate_trend(self, values: list) -> float: + """Calculate trend as average change over recent values""" + if len(values) < 2: + return 0.0 + # Compare first half avg to second half avg + mid = len(values) // 2 + first_half = sum(values[:mid]) / mid if mid > 0 else 0 + second_half = sum(values[mid:]) / (len(values) - mid) if len(values) - mid > 0 else 0 + return second_half - first_half + + def _trend_text(self, trend: float, lower_better: bool) -> tuple[str, rl.Color]: + """Get trend text and color""" + if abs(trend) < 0.5: + return "Stable", LIGHT_GRAY + + if lower_better: + if trend < 0: + return "Improving!", GREEN + return "Getting worse", RED + else: + if trend > 0: + return "Improving!", GREEN + return "Getting worse", RED + + def _get_overall_hand(self) -> tuple[str, rl.Color]: + """Calculate overall poker hand rating based on all stats""" + total_drives = self._stats.get('total_drives', 0) + if total_drives == 0: + return "No Cards Yet", GRAY + + total_stalls = self._stats.get('total_stalls', 0) + total_shifts = self._stats.get('total_upshifts', 0) + self._stats.get('total_downshifts', 0) + good_shifts = self._stats.get('upshifts_good', 0) + self._stats.get('downshifts_good', 0) + + stall_rate = total_stalls / total_drives + shift_pct = (good_shifts / total_shifts * 100) if total_shifts > 0 else 100 + + # Calculate overall score + score = shift_pct - (stall_rate * 10) + + # Recent improvement bonus + recent_scores = self._stats.get('recent_shift_scores', []) + if len(recent_scores) >= 3: + if recent_scores[-1] > recent_scores[0]: + score += 5 # Bonus for improving + + if score >= 98 and stall_rate == 0: + return "Royal Flush - Waddle is driving! Kacper threw his glasses!", GREEN + elif score >= 95 and stall_rate == 0: + return "Royal Flush - Porch-worthy waddle! KP earned!", GREEN + elif score >= 90: + return "Straight Flush - Elite waddle, CCM vibes!", GREEN + elif score >= 85: + return "Four of a Kind - Priest-approved waddle!", GREEN + elif score >= 80: + return "Full House - Solid waddle, not SS!", GREEN + elif score >= 70: + return "Flush - Good waddle, almost KP", YELLOW + elif score >= 60: + return "Straight - Improving, not SS yet", YELLOW + elif score >= 50: + return "Three of a Kind - Getting there, shake off jackets", YELLOW + elif score >= 40: + return "Two Pair - Jackets territory", YELLOW + elif score >= 30: + return "One Pair - Jacketed, huge oof", RED + else: + return "High Card - SS! Full jackets!", RED + + def _get_encouragement(self) -> str: + """Get encouragement based on overall progress""" + total_drives = self._stats.get('total_drives', 0) + total_stalls = self._stats.get('total_stalls', 0) + recent_stalls = self._stats.get('recent_stall_rates', []) + recent_scores = self._stats.get('recent_shift_scores', []) + + if total_drives == 0: + return "Start driving to see your stats! Time to earn your first waddle KP." + + stall_rate = total_stalls / total_drives if total_drives > 0 else 0 + + # Check for improvement + improving = False + if len(recent_scores) >= 3: + if recent_scores[-1] > recent_scores[0] + 5: + improving = True + + if len(recent_stalls) >= 3: + recent_avg = sum(recent_stalls[-3:]) / 3 + if recent_avg == 0: + # Check for crazy good performance + if len(recent_scores) >= 3 and all(s >= 95 for s in recent_scores[-3:]): + return "3 drives 95%+ NO stalls?! Waddle is driving! Kacper threw his glasses!" + if improving: + return "No stalls AND improving? Waddle energy! QG to KP!" + return "No stalls recent - waddle game strong! Not SS, priest-approved!" + elif recent_avg < stall_rate: + return "Recent drives better than avg - shedding jackets, channeling waddle!" + + if stall_rate < 0.5: + if improving: + return "< 1 stall per 2 drives AND improving! Porch-worthy waddle progress!" + return "< 1 stall per 2 drives - solid waddle vibes, not SS!" + elif stall_rate < 1: + return "~1 stall per drive - de-jacketing in progress!" + else: + return "Keep at it! Even the best got jacketed at first. QG to KP!" diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index a452777748e295..5523190659900c 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -11,6 +11,7 @@ from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout +from openpilot.selfdrive.ui.mici.layouts.settings.manual_stats import ManualStatsLayout from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget, NavWidget @@ -22,6 +23,7 @@ class PanelType(IntEnum): DEVELOPER = 3 USER_MANUAL = 4 FIREHOSE = 5 + MANUAL_STATS = 6 @dataclass @@ -48,12 +50,15 @@ def __init__(self): firehose_btn = BigButton("firehose", "", "icons_mici/settings/comma_icon.png") firehose_btn.set_click_callback(lambda: self._set_current_panel(PanelType.FIREHOSE)) + manual_stats_btn = BigButton("MT stats", "", "icons_mici/settings/toggles_icon.png") + manual_stats_btn.set_click_callback(lambda: self._set_current_panel(PanelType.MANUAL_STATS)) + self._scroller = Scroller([ + manual_stats_btn, # MT Stats first! toggles_btn, network_btn, device_btn, PairBigButton(), - #BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"), firehose_btn, developer_btn, ], snap_items=False) @@ -68,6 +73,7 @@ def __init__(self): PanelType.DEVICE: PanelInfo("Device", DeviceLayoutMici(back_callback=lambda: self._set_current_panel(None))), PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayoutMici(back_callback=lambda: self._set_current_panel(None))), PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayout(back_callback=lambda: self._set_current_panel(None))), + PanelType.MANUAL_STATS: PanelInfo("MT Stats", ManualStatsLayout(back_callback=lambda: self._set_current_panel(None))), } self._font_medium = gui_app.font(FontWeight.MEDIUM) diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 71ca03cccfac94..c8737341a1ee9f 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -11,6 +11,7 @@ from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView +from openpilot.selfdrive.ui.mici.onroad.manual_stats_widget import ManualStatsWidget from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets import Widget @@ -161,6 +162,9 @@ def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = Visio self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png") + # Manual stats widget for MT cars + self._manual_stats_widget = ManualStatsWidget() + # debug self._pm = messaging.PubMaster(['uiDebug']) @@ -242,6 +246,11 @@ def _render(self, _): # Use self._content_rect for positioning within camera bounds self._confidence_ball.render(self.rect) + # Manual stats widget for MT cars - check if manual transmission (flag 128) + is_manual = ui_state.CP is not None and bool(ui_state.CP.flags & 128) + self._manual_stats_widget.set_visible(is_manual and ui_state.started) + self._manual_stats_widget.render(self._content_rect) + self._bookmark_icon.render(self.rect) # Draw darkened background and text if not onroad diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py new file mode 100644 index 00000000000000..42e6a7d0bb3fb7 --- /dev/null +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -0,0 +1,256 @@ +""" +Live Manual Stats Widget + +Small onroad overlay showing current drive statistics, RPM meter with rev-match helper, +shift grade feedback, and launch progress. +""" + +import json +import pyray as rl + +from openpilot.common.params import Params +from opendbc.car.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.widgets import Widget + + +# Colors +GREEN = rl.Color(46, 204, 113, 220) +YELLOW = rl.Color(241, 196, 15, 220) +RED = rl.Color(231, 76, 60, 220) +ORANGE = rl.Color(230, 126, 34, 220) +CYAN = rl.Color(52, 152, 219, 220) +WHITE = rl.Color(255, 255, 255, 220) +GRAY = rl.Color(150, 150, 150, 200) +BG_COLOR = rl.Color(0, 0, 0, 160) + +# RPM zones for BRZ (7500 redline) +RPM_REDLINE = 7500 +RPM_ECONOMY_MAX = 2500 +RPM_POWER_MIN = 4000 +RPM_DANGER_MIN = 6500 +RPM_TARGET_MIN_DISPLAY = 750 # Don't show upshift indicator below this RPM + +# 2024 BRZ gear ratios for rev-match calculation +BRZ_GEAR_RATIOS = {1: 3.626, 2: 2.188, 3: 1.541, 4: 1.213, 5: 1.000, 6: 0.767} +BRZ_FINAL_DRIVE = 4.10 +BRZ_TIRE_CIRCUMFERENCE = 1.977 + + +def rpm_for_speed_and_gear(speed_ms: float, gear: int) -> float: + """Calculate expected RPM for a given speed and gear""" + if gear not in BRZ_GEAR_RATIOS or speed_ms <= 0: + return 0.0 + return (speed_ms * BRZ_FINAL_DRIVE * BRZ_GEAR_RATIOS[gear] * 60) / BRZ_TIRE_CIRCUMFERENCE + + +class ManualStatsWidget(Widget): + """Widget showing live manual driving stats, RPM meter, and feedback""" + + def __init__(self): + super().__init__() + self._params = Params() + self._visible = False + self._stats: dict = {} + self._update_counter = 0 + # Shift grade flash state + self._last_shift_grade = 0 + self._shift_flash_frames = 0 + self._flash_grade = 0 # The grade to display during flash + # Track gear before clutch for rev-match display + self._gear_before_clutch = 0 + self._last_clutch_state = False + # Filtered RPM for smooth label display (0.1s time constant, ~60fps) + self._rpm_filter = FirstOrderFilter(0, 0.1, 1/60) + + def _render(self, rect: rl.Rectangle): + # Update stats every ~15 frames (0.25s at 60fps) + self._update_counter += 1 + if self._update_counter >= 15: + self._update_counter = 0 + self._load_stats() + + # Get live data from CarState + cs = ui_state.sm['carState']# if ui_state.sm.valid['carState'] else None + if not cs: + return + + # Widget dimensions - extend to bottom with same margin as top + margin = 10 + w = 250 + h = int(rect.height - 2 * margin) # Full height minus top and bottom margin + x = int(rect.x + rect.width - w - margin) + y = int(rect.y + margin) + + # Background + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.08, 10, BG_COLOR) + + font = gui_app.font(FontWeight.MEDIUM) + font_bold = gui_app.font(FontWeight.BOLD) + px = x + 14 + py = y + 12 + + # === RPM METER === + rpm = cs.engineRpm + self._draw_rpm_meter(px, py, w - 28, 50, rpm, cs) + py += 62 + + # === GEAR + SHIFT GRADE FLASH === + gear = cs.gearActual + gear_text = str(gear) if gear > 0 else "N" + + # Check for new shift - only trigger when shiftGrade goes from 0 to non-zero + if cs.shiftGrade > 0 and self._last_shift_grade == 0: + # New shift detected - start flash with this grade + self._shift_flash_frames = 150 # Flash for 2.5s at 60fps + self._flash_grade = cs.shiftGrade # Store the grade to display + # Track the raw shiftGrade value + self._last_shift_grade = cs.shiftGrade + + # Draw gear with flash color if recently shifted + if self._shift_flash_frames > 0: + self._shift_flash_frames -= 1 + if self._flash_grade == 1: + gear_color = GREEN + grade_text = "✓" + elif self._flash_grade == 2: + gear_color = YELLOW + grade_text = "~" + else: + gear_color = RED + grade_text = "✗" + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 55, 0, gear_color) + rl.draw_text_ex(font_bold, grade_text, rl.Vector2(px + 42, py + 8), 40, 0, gear_color) + else: + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 55, 0, WHITE) + + # Shift suggestion arrow + suggestion = self._stats.get('shift_suggestion', 'ok') + if suggestion == 'upshift': + rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 95, py + 8), 43, 0, GREEN) + elif suggestion == 'downshift': + rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 95, py + 8), 43, 0, YELLOW) + + py += 62 + + # === LAUNCH FEEDBACK === + launches = self._stats.get('launches', 0) + good_launches = self._stats.get('good_launches', 0) + # Detect if currently launching (low speed, was stopped) + if cs.vEgo < 5.0 and cs.vEgo > 0.5 and not cs.clutchPressed: + rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 26, 0, CYAN) + elif launches > 0: + pct = int(good_launches / launches * 100) if launches > 0 else 0 + color = GREEN if pct >= 75 else (YELLOW if pct >= 50 else GRAY) + rl.draw_text_ex(font, f"Launch: {good_launches}/{launches}", rl.Vector2(px, py), 26, 0, color) + else: + rl.draw_text_ex(font, "Launch: -", rl.Vector2(px, py), 26, 0, GRAY) + py += 34 + + # === STATS ROW === + font_size = 24 + + # Stalls & Lugs on same line + stalls = self._stats.get('stalls', 0) + lugs = self._stats.get('lugs', 0) + is_lugging = cs.isLugging + + if is_lugging: + rl.draw_text_ex(font, "LUGGING!", rl.Vector2(px, py), font_size, 0, RED) + else: + stall_color = GREEN if stalls == 0 else RED + lug_color = GREEN if lugs == 0 else YELLOW + rl.draw_text_ex(font, f"S:{stalls}", rl.Vector2(px, py), font_size, 0, stall_color) + rl.draw_text_ex(font, f"L:{lugs}", rl.Vector2(px + 65, py), font_size, 0, lug_color) + + # Shift quality + shifts = self._stats.get('shifts', 0) + good_shifts = self._stats.get('good_shifts', 0) + if shifts > 0: + pct = int(good_shifts / shifts * 100) + color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) + rl.draw_text_ex(font, f"Sh:{pct}%", rl.Vector2(px + 135, py), font_size, 0, color) + else: + rl.draw_text_ex(font, "Sh:-", rl.Vector2(px + 135, py), font_size, 0, GRAY) + + def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): + """Draw RPM bar with color zones and rev-match target""" + font = gui_app.font(FontWeight.MEDIUM) + + # Bar background (pushed down for bigger RPM text) + bar_h = 20 + bar_y = y + 32 + rl.draw_rectangle_rounded(rl.Rectangle(x, bar_y, w, bar_h), 0.3, 5, rl.Color(40, 40, 40, 200)) + + # Calculate fill width + rpm_pct = min(rpm / RPM_REDLINE, 1.0) + fill_w = int(w * rpm_pct) + + # Color based on RPM zone + if rpm < RPM_ECONOMY_MAX: + bar_color = GREEN + elif rpm < RPM_POWER_MIN: + bar_color = YELLOW + elif rpm < RPM_DANGER_MIN: + bar_color = ORANGE + else: + bar_color = RED + + # Draw filled portion + if fill_w > 0: + rl.draw_rectangle_rounded(rl.Rectangle(x, bar_y, fill_w, bar_h), 0.3, 5, bar_color) + + # Track gear before clutch press for rev-match display + if not cs.clutchPressed and cs.gearActual > 0: + self._gear_before_clutch = cs.gearActual + + # Rev-match target lines when clutch pressed OR shift suggestion showing + suggestion = self._stats.get('shift_suggestion', 'ok') + show_rev_targets = (cs.clutchPressed or suggestion != 'ok') and self._gear_before_clutch > 0 + if show_rev_targets: + # 65% opacity when showing due to suggestion only (not clutch) + alpha = 220 if cs.clutchPressed else 143 + cyan = rl.Color(CYAN.r, CYAN.g, CYAN.b, alpha) + red = rl.Color(RED.r, RED.g, RED.b, alpha) + white = rl.Color(WHITE.r, WHITE.g, WHITE.b, alpha) + + # Calculate both targets first + down_rpm = 0 + up_rpm = 0 + if self._gear_before_clutch > 1: + down_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch - 1) + if self._gear_before_clutch < 6: + up_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch + 1) + + # Downshift target - cyan if safe, red if over redline + if down_rpm >= RPM_REDLINE: + # Over redline - show red warning clipped to right side + down_x = x + w + rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, red) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, red) + elif down_rpm > RPM_TARGET_MIN_DISPLAY: + # Safe downshift target (cyan) + down_x = x + int(w * (down_rpm / RPM_REDLINE)) + rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, cyan) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, cyan) + + # Upshift target (white) - only show if above minimum display threshold + if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: + up_x = x + int(w * (up_rpm / RPM_REDLINE)) + rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, white) + rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, white) + + # RPM text (filtered for smooth display, rounded to nearest 10) + self._rpm_filter.update(rpm) + rpm_text = f"{int(round(self._rpm_filter.x / 10) * 10)}" + rl.draw_text_ex(font, rpm_text, rl.Vector2(x, y), 28, 0, WHITE) + rl.draw_text_ex(font, "rpm", rl.Vector2(x + 70, y + 5), 20, 0, GRAY) + + def _load_stats(self): + """Load current session stats""" + try: + data = self._params.get("ManualDriveLiveStats") + self._stats = data if data else {} + except Exception: + self._stats = {}