#!/usr/bin/env python3 from __future__ import annotations import json import math import os import shutil import signal import subprocess import sys import threading import time from dataclasses import asdict, dataclass from pathlib import Path from typing import Any import numpy as np from cereal import car from opendbc.car.hyundai.values import HyundaiFlags from openpilot.common.params import Params from openpilot.selfdrive.controls.lib.latcontrol_torque import KP from openpilot.selfdrive.controls.lib.latcontrol_vehicle_tunes import ( FLM_FRICTION_SPEED_KNOTS, get_flm_capabilities, get_flm_rich_profile_key, get_flm_supported_vehicle_knobs, get_gm_base_friction_threshold, get_hkg_canfd_base_friction_threshold, get_standard_friction_threshold, normalize_flm_overrides, ) from openpilot.starpilot.common.lateral_delay import full_lateral_delay from openpilot.system.hardware import PC from openpilot.system.hardware.hw import Paths from openpilot.tools.lib.logreader import LogReader from openpilot.starpilot.system.the_galaxy import utilities FLM_STATUS_PATH = Path("/tmp/galaxy_flm_status.json") FLM_LOG_PATH = Path("/tmp/galaxy_flm.log") FLM_STATUS_MAX_AGE_SECONDS = 3600.0 FLM_ANALYZER_ROUTE_LIMIT = 8 FLM_ANALYZER_PROCESS = None FLM_ANALYZER_LOCK = threading.Lock() FLM_PROGRESS_FILENAME = "progress.json" FLM_ONROAD_POLL_INTERVAL_SECONDS = 0.25 FLM_SEGMENT_TIMEOUT_SECONDS = 60.0 class FLMAnalysisCancelled(RuntimeError): pass class FLMSegmentTimeout(RuntimeError): pass TRIAL_PARAM_SPECS = { "AdvancedLateralTune": "bool", "ForceAutoTune": "bool", "ForceAutoTuneOff": "bool", "UseAutoSteerDelay": "bool", "SteerDelay": "float", "SteerFriction": "float", "SteerKP": "float", "SteerLatAccel": "float", "SteerRatio": "float", "FLMActiveProfileId": "string", "FLMActiveOverrides": "json", "FLMTrialApplied": "bool", } FLM_ADVANCED_LATERAL_PARAM_KEYS = { "AdvancedLateralTune", "ForceAutoTune", "ForceAutoTuneOff", "UseAutoSteerDelay", "SteerDelay", "SteerFriction", "SteerKP", "SteerLatAccel", "SteerRatio", } FLM_TRIAL_BASELINE_PARAM = "FLMTrialBaseline" GENERIC_PARAM_METADATA = { "SteerDelay": {"min": 0.01, "max": 1.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True}, "SteerFriction": {"min": 0.0, "max": 1.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True}, "SteerKP": {"min": 0.1, "max": 1.5, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True}, "SteerLatAccel": {"min": 0.5, "max": 5.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True}, "SteerRatio": {"min": 5.0, "max": 25.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True}, } FLM_REFERENCE_MODEL = { "version": 1, "families": { "turn_in_boost": { "reason": "Used when the car waits too long to initiate torque even though desired lateral accel is already rising.", "too_low": "Turn starts late or feels unwilling.", "too_high": "Car dives into the curve too early or spikes past the plan on entry.", }, "unwind_taper": { "reason": "Used when the car holds steering too long or drops it too quickly on exit.", "too_low": "Unwind drags and the car keeps steering past the intended release.", "too_high": "Unwind snaps back and the wheel releases too aggressively.", }, "friction_threshold_curve": { "reason": "Used to calm chatter or wake up low-speed response without pretending the whole torque map is wrong.", "too_low": "Tiny errors create twitch and correction chatter.", "too_high": "Controller feels reluctant near center and can hesitate at low speed.", }, "center_taper": { "reason": "Used only when the problem is calm-road or near-center nibbling, not general curve response.", "too_low": "Straight-road wheel activity stays busy.", "too_high": "Car feels lazy around center and can miss light corrections.", }, }, } FLM_PATH_SPECS = { "baseline_fix": { "title": "Baseline Fix", "description": "Use the broad knobs first to get the car into the right zip code before touching narrower cleanup layers.", "whenToUse": "Use this when the car is broadly wrong: repeated line riding, multi-band under/oversteer, saturation, or obvious whole-car mismatch.", "alternateHint": "If the car is already mostly good and only one band is bothering you, switch to Cleanup Pass instead.", }, "cleanup_pass": { "title": "Cleanup Pass", "description": "Use the narrow band-specific knobs first so you can clean up one behavior without disturbing the rest of the tune.", "whenToUse": "Use this when the car is already mostly in the right zip code and the misses are localized to one phase or speed band.", "alternateHint": "If the car is still broadly wrong after this, step back and run Baseline Fix first.", }, } FLM_DRIVER_OVERRIDE_PRE_BUFFER_S = 0.35 FLM_DRIVER_OVERRIDE_POST_BUFFER_S = 1.0 @dataclass(slots=True) class RouteSource: route: str footage_path: str segment: str segment_num: int log_path: str used_qlog: bool @dataclass(slots=True) class FLMSample: route: str segment: int t: float v_ego: float lat_active: bool steering_pressed: bool saturated: bool actual_la: float desired_la: float desired_jerk: float error: float error_rate: float p: float i: float d: float f: float output: float steering_angle_deg: float steering_torque: float cmd_torque: float out_torque: float roll_deg: float def _get_galaxy_dir() -> Path: return Path(Paths.comma_home()) / "starpilot" / "data" / "galaxy" if PC else Path("/data/galaxy") def get_flm_workspace_root() -> Path: return _get_galaxy_dir() / "flm" def _legacy_workspace_root() -> Path: return _get_galaxy_dir() / "".join(("f", "t", "m")) def _migrate_legacy_payload(value): legacy_upper = "".join(("F", "T", "M")) legacy_lower = legacy_upper.lower() if isinstance(value, dict): migrated = {} for key, item in value.items(): migrated_key = str(key) if migrated_key.startswith(legacy_upper): migrated_key = f"FLM{migrated_key[len(legacy_upper):]}" elif migrated_key.startswith(legacy_lower): migrated_key = f"flm{migrated_key[len(legacy_lower):]}" migrated[migrated_key] = _migrate_legacy_payload(item) return migrated if isinstance(value, list): return [_migrate_legacy_payload(item) for item in value] if isinstance(value, str): legacy_method_name = "Firestar " + "Tuning Method" return value.replace(legacy_method_name, "Firestar Lateral Method").replace(legacy_upper, "FLM") return value def _migrate_legacy_workspace(root: Path) -> None: marker = root / ".flm_rebrand_v1" if marker.exists(): return legacy_root = _legacy_workspace_root() if legacy_root.is_dir() and legacy_root != root: root.parent.mkdir(parents=True, exist_ok=True) if root.exists(): shutil.copytree(legacy_root, root, dirs_exist_ok=True) shutil.rmtree(legacy_root) else: legacy_root.replace(root) if not root.exists(): return legacy_lower = "".join(("f", "t", "m")) legacy_reference = root / "reference" / f"{legacy_lower}_reference.json" current_reference = root / "reference" / "flm_reference.json" if legacy_reference.is_file() and not current_reference.exists(): legacy_reference.replace(current_reference) for path in root.rglob("*.json"): try: payload = json.loads(path.read_text(encoding="utf-8")) migrated = _migrate_legacy_payload(payload) if migrated != payload: path.write_text(json.dumps(migrated, indent=2, sort_keys=True), encoding="utf-8") except Exception: continue legacy_upper = legacy_lower.upper() for path in root.rglob("*.html"): try: content = path.read_text(encoding="utf-8") content = content.replace(legacy_upper, "FLM").replace(legacy_lower, "flm") path.write_text(content, encoding="utf-8") except Exception: continue marker.touch() def _workspace_paths() -> dict[str, Path]: root = get_flm_workspace_root() return { "root": root, "reports": root / "reports", "profiles": root / "profiles", "feedback": root / "feedback", "snapshots": root / "snapshots", "savedTunes": root / "saved_tunes", "reference": root / "reference", } def ensure_flm_workspace() -> dict[str, Path]: _migrate_legacy_workspace(get_flm_workspace_root()) paths = _workspace_paths() for key, path in paths.items(): if key != "root": path.mkdir(parents=True, exist_ok=True) reference_path = paths["reference"] / "flm_reference.json" if not reference_path.exists(): reference_path.write_text(json.dumps(FLM_REFERENCE_MODEL, indent=2, sort_keys=True), encoding="utf-8") return paths def _read_json(path: Path, default): try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return default def _write_json(path: Path, payload) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_suffix(path.suffix + ".tmp") tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") tmp_path.replace(path) def _progress_path() -> Path: return get_flm_workspace_root() / FLM_PROGRESS_FILENAME def _record_cleanup_progress(car_fingerprint: str, source_report_id: str = "") -> None: fingerprint = str(car_fingerprint or "").strip() if not fingerprint: return progress = _read_json(_progress_path(), {}) if not isinstance(progress, dict): progress = {} vehicles = progress.get("vehicles", {}) if not isinstance(vehicles, dict): vehicles = {} vehicles[fingerprint] = { "minimumPathKey": "cleanup_pass", "sourceReportId": str(source_report_id or ""), "updatedAt": time.time(), } _write_json(_progress_path(), {"version": 1, "vehicles": vehicles}) def _cleanup_progress_locked(car_fingerprint: str) -> bool: fingerprint = str(car_fingerprint or "").strip() if not fingerprint: return False progress = _read_json(_progress_path(), {}) vehicle_progress = progress.get("vehicles", {}).get(fingerprint, {}) if isinstance(progress, dict) else {} if isinstance(vehicle_progress, dict) and vehicle_progress.get("minimumPathKey") == "cleanup_pass": return True # Bootstrap workspaces created before progression tracking was added. for path in _workspace_paths()["reports"].glob("*.json"): report = _read_json(path, {}) if not isinstance(report, dict): continue car_info = report.get("car", {}) if ( isinstance(car_info, dict) and str(car_info.get("carFingerprint", "")) == fingerprint and car_info.get("controlPath") == "torque" and report.get("primaryPathKey") == "cleanup_pass" ): _record_cleanup_progress(fingerprint, str(report.get("reportId", path.stem))) return True return False def _worker_env(repo_root: Path) -> dict[str, str]: env = os.environ.copy() pythonpath = [ "/usr/local/venv/lib/python3.12/site-packages", str(repo_root / "starpilot" / "third_party"), str(repo_root), ] if env.get("PYTHONPATH"): pythonpath.append(env["PYTHONPATH"]) env["PYTHONPATH"] = os.pathsep.join(pythonpath) env.setdefault("OPENBLAS_NUM_THREADS", "1") env.setdefault("OMP_NUM_THREADS", "1") env.setdefault("MKL_NUM_THREADS", "1") env.setdefault("NUMEXPR_NUM_THREADS", "1") return env def read_flm_status() -> dict[str, Any]: data = _read_json(FLM_STATUS_PATH, {}) return data if isinstance(data, dict) else {} def _write_flm_status(payload: dict[str, Any]) -> None: payload = dict(payload) payload["updatedAt"] = time.time() tmp_path = FLM_STATUS_PATH.with_suffix(".tmp") tmp_path.write_text(json.dumps(payload, separators=(",", ":")), encoding="utf-8") tmp_path.replace(FLM_STATUS_PATH) def clear_flm_status() -> None: try: FLM_STATUS_PATH.unlink() except FileNotFoundError: pass except OSError: pass def _require_flm_offroad(params: Params | None = None) -> None: params = params or Params(return_defaults=True) if params.get_bool("IsOnroad"): raise FLMAnalysisCancelled("FLM analysis stopped because the vehicle went onroad.") def flm_analyzer_running() -> bool: process = FLM_ANALYZER_PROCESS if process is not None and process.poll() is None: return True status = read_flm_status() pid = int(status.get("pid") or 0) started_at = float(status.get("startedAt") or 0.0) if pid <= 0 or started_at <= 0: return False if (time.time() - started_at) > FLM_STATUS_MAX_AGE_SECONDS: clear_flm_status() return False try: os.kill(pid, 0) except ProcessLookupError: clear_flm_status() return False except PermissionError: return True except OSError: return False return True def _terminate_flm_process(process) -> None: try: os.killpg(os.getpgid(process.pid), signal.SIGTERM) except (AttributeError, ProcessLookupError, PermissionError, OSError): try: process.terminate() except (AttributeError, ProcessLookupError, OSError): pass try: process.wait(timeout=2.0) except subprocess.TimeoutExpired: try: os.killpg(os.getpgid(process.pid), signal.SIGKILL) except (AttributeError, ProcessLookupError, PermissionError, OSError): try: process.kill() except (AttributeError, ProcessLookupError, OSError): pass try: process.wait(timeout=1.0) except (AttributeError, subprocess.TimeoutExpired): pass def _write_flm_cancelled_status(status: dict[str, Any], reason: str) -> None: _write_flm_status({ **status, "pid": 0, "running": False, "state": "cancelled_onroad" if reason == "onroad" else "cancelled", "error": "FLM analysis stopped because the vehicle went onroad." if reason == "onroad" else "", }) def stop_flm_background_analysis(reason: str = "") -> bool: global FLM_ANALYZER_PROCESS with FLM_ANALYZER_LOCK: process = FLM_ANALYZER_PROCESS status = read_flm_status() pid = int(status.get("pid") or 0) if process is not None and process.poll() is None: _terminate_flm_process(process) FLM_ANALYZER_PROCESS = None if reason: _write_flm_cancelled_status(status, reason) else: clear_flm_status() return True if pid > 0: try: os.killpg(pid, signal.SIGTERM) except ProcessLookupError: pass except PermissionError: try: os.kill(pid, signal.SIGTERM) except (ProcessLookupError, PermissionError, OSError): return False except OSError: try: os.kill(pid, signal.SIGTERM) except (ProcessLookupError, PermissionError, OSError): return False if reason: _write_flm_cancelled_status(status, reason) else: clear_flm_status() return True return False def _watch_flm_process_for_onroad(process) -> None: params = Params(return_defaults=True) while process.poll() is None: if params.get_bool("IsOnroad"): stop_flm_background_analysis(reason="onroad") return time.sleep(FLM_ONROAD_POLL_INTERVAL_SECONDS) def _watch_flm_worker_for_onroad() -> None: params = Params(return_defaults=True) while True: if params.get_bool("IsOnroad"): _write_flm_cancelled_status(read_flm_status(), "onroad") # Workers are launched as process-group leaders. Terminate the entire group # so any log decompression helpers cannot survive the onroad transition. if os.getpgrp() == os.getpid(): try: os.killpg(os.getpgrp(), signal.SIGTERM) except (ProcessLookupError, PermissionError, OSError): pass os._exit(0) time.sleep(FLM_ONROAD_POLL_INTERVAL_SECONDS) def cancel_flm_if_onroad() -> bool: if not Params(return_defaults=True).get_bool("IsOnroad"): return False if not flm_analyzer_running(): return False return stop_flm_background_analysis(reason="onroad") def _optional_segment_number(value: Any) -> int | None: if value is None or str(value).strip() == "": return None number = int(value) if number < 0: raise ValueError("Segment numbers cannot be negative.") return number def normalize_segment_ranges(route_names: list[str], segment_ranges: Any) -> dict[str, dict[str, int | None]]: if not isinstance(segment_ranges, dict): return {} normalized: dict[str, dict[str, int | None]] = {} valid_routes = set(route_names) for route, raw_range in segment_ranges.items(): route_name = str(route).strip() if route_name not in valid_routes or not isinstance(raw_range, dict): continue start = _optional_segment_number(raw_range.get("start")) end = _optional_segment_number(raw_range.get("end")) if start is not None and end is not None and start > end: raise ValueError(f"{route_name}: the first segment cannot be after the last segment.") if start is not None or end is not None: normalized[route_name] = {"start": start, "end": end} return normalized def start_flm_background_analysis(route_names: list[str], footage_paths: list[str], segment_ranges: dict[str, dict[str, int | None]] | None = None) -> bool: global FLM_ANALYZER_PROCESS route_names = [str(route) for route in route_names if str(route).strip()] if not route_names: return False segment_ranges = normalize_segment_ranges(route_names, segment_ranges) try: _require_flm_offroad() except FLMAnalysisCancelled: return False ensure_flm_workspace() process_to_watch = None with FLM_ANALYZER_LOCK: if flm_analyzer_running(): return True repo_root = Path(__file__).resolve().parents[3] command = [ "nice", "-n", "19", sys.executable or "python3", str(Path(__file__).resolve()), "worker", json.dumps({ "routes": route_names[:FLM_ANALYZER_ROUTE_LIMIT], "footagePaths": [str(path) for path in footage_paths], "segmentRanges": { route: segment_range for route, segment_range in segment_ranges.items() if route in route_names[:FLM_ANALYZER_ROUTE_LIMIT] }, }), ] log_file = None try: log_file = open(FLM_LOG_PATH, "ab") FLM_ANALYZER_PROCESS = subprocess.Popen( command, cwd=str(repo_root), env=_worker_env(repo_root), stdout=log_file, stderr=log_file, start_new_session=True, ) process_to_watch = FLM_ANALYZER_PROCESS _write_flm_status({ "pid": FLM_ANALYZER_PROCESS.pid, "startedAt": time.time(), "running": True, "state": "queued", "routes": route_names[:FLM_ANALYZER_ROUTE_LIMIT], "segmentRanges": segment_ranges, "progress": 0, "total": len(route_names[:FLM_ANALYZER_ROUTE_LIMIT]), }) except Exception: FLM_ANALYZER_PROCESS = None return False finally: if log_file is not None: log_file.close() if process_to_watch is not None: threading.Thread(target=_watch_flm_process_for_onroad, args=(process_to_watch,), daemon=True).start() return flm_analyzer_running() def _parse_segment_num(segment_name: str) -> int: try: return int(str(segment_name).rsplit("--", 1)[1]) except Exception: return 0 def resolve_route_sources(route_names: list[str], footage_paths: list[str], segment_ranges: dict[str, dict[str, int | None]] | None = None) -> tuple[list[RouteSource], list[str]]: sources: list[RouteSource] = [] warnings: list[str] = [] segment_ranges = normalize_segment_ranges(route_names, segment_ranges) for route in route_names: route_added = False segment_range = segment_ranges.get(route, {}) segment_start = segment_range.get("start") segment_end = segment_range.get("end") for footage_path in footage_paths: segments = utilities.get_segments_in_route(route, footage_path) if not segments: continue for segment in segments: segment_num = _parse_segment_num(segment) if segment_start is not None and segment_num < segment_start: continue if segment_end is not None and segment_num > segment_end: continue segment_path = Path(footage_path) / segment rlog_path = None qlog_path = None for candidate in ("rlog.zst", "rlog.bz2", "rlog"): candidate_path = segment_path / candidate if candidate_path.exists(): rlog_path = candidate_path break for candidate in ("qlog.zst", "qlog.bz2", "qlog"): candidate_path = segment_path / candidate if candidate_path.exists(): qlog_path = candidate_path break log_path = rlog_path or qlog_path if log_path is None: continue if rlog_path is None and qlog_path is not None: warnings.append(f"{route} segment {segment} fell back to qlog.") sources.append(RouteSource( route=route, footage_path=str(footage_path), segment=segment, segment_num=segment_num, log_path=str(log_path), used_qlog=rlog_path is None, )) route_added = True if route_added: break if not route_added: if segment_range: start_label = segment_start if segment_start is not None else "first" end_label = segment_end if segment_end is not None else "last" warnings.append(f"{route} had no local logs in the selected segment range {start_label}-{end_label}.") else: warnings.append(f"{route} could not be resolved to a local route with logs.") sources.sort(key=lambda source: (source.route, source.segment_num)) return sources, warnings def _speed_band_label(v_ego: float) -> str: if v_ego < 6.0: return "low" if v_ego < 15.0: return "mid" if v_ego < 25.0: return "fast" return "highway" def _route_label(route: str, segment: int) -> str: return f"{route}/{segment}" def _event_direction(samples: list[FLMSample]) -> str: mean_desired = float(np.mean([sample.desired_la for sample in samples])) if mean_desired > 0.02: return "left" if mean_desired < -0.02: return "right" return "center" def _group_masked_events(samples: list[FLMSample], mask: list[bool], score_series: list[float], min_points: int = 5) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] start_idx = None for idx, active in enumerate(mask + [False]): if active and start_idx is None: start_idx = idx continue if active: continue if start_idx is None: continue end_idx = idx - 1 event_samples = samples[start_idx:end_idx + 1] if len(event_samples) >= min_points: event_scores = score_series[start_idx:end_idx + 1] peak_offset = int(np.argmax(event_scores)) peak_idx = start_idx + peak_offset peak_sample = samples[peak_idx] direction = _event_direction(event_samples) events.append({ "startIdx": start_idx, "endIdx": end_idx, "peakIdx": peak_idx, "peakScore": float(event_scores[peak_offset]), "route": peak_sample.route, "segment": peak_sample.segment, "speedBand": _speed_band_label(float(np.mean([sample.v_ego for sample in event_samples]))), "direction": direction, "supportCount": len(event_samples), }) start_idx = None return events def _analysis_eligibility_mask(samples: list[FLMSample]) -> list[bool]: eligible = [bool(sample.lat_active) for sample in samples] group_start = 0 while group_start < len(samples): group_key = (samples[group_start].route, samples[group_start].segment) group_end = group_start + 1 while group_end < len(samples) and (samples[group_end].route, samples[group_end].segment) == group_key: group_end += 1 last_override = -math.inf for idx in range(group_start, group_end): sample = samples[idx] if sample.steering_pressed: last_override = sample.t if sample.steering_pressed or (sample.t - last_override) <= FLM_DRIVER_OVERRIDE_POST_BUFFER_S: eligible[idx] = False next_override = math.inf for idx in range(group_end - 1, group_start - 1, -1): sample = samples[idx] if sample.steering_pressed: next_override = sample.t if sample.steering_pressed or (next_override - sample.t) <= FLM_DRIVER_OVERRIDE_PRE_BUFFER_S: eligible[idx] = False # Force an event boundary between route segments even when lateral control stays active. eligible[group_start] = False eligible[group_end - 1] = False group_start = group_end return eligible def _build_plot_data(samples: list[FLMSample], event: dict[str, Any], eligibility: list[bool] | None = None) -> dict[str, Any]: start_idx = int(event["startIdx"]) end_idx = int(event["endIdx"]) event_route = samples[start_idx].route event_segment = samples[start_idx].segment # Add a small amount of context without crossing an intervention buffer or # segment boundary. The highlighted region remains the classified event. for _ in range(12): candidate = start_idx - 1 if candidate < 0 or (eligibility is not None and not eligibility[candidate]): break if samples[candidate].route != event_route or samples[candidate].segment != event_segment: break start_idx = candidate for _ in range(12): candidate = end_idx + 1 if candidate >= len(samples) or (eligibility is not None and not eligibility[candidate]): break if samples[candidate].route != event_route or samples[candidate].segment != event_segment: break end_idx = candidate window = samples[start_idx:end_idx + 1] if len(window) < 2: return {} # Keep reports lightweight on unusually long windows while preserving both ends. if len(window) > 160: indices = np.linspace(0, len(window) - 1, 160, dtype=int) window = [window[int(idx)] for idx in indices] times = np.array([sample.t for sample in window], dtype=float) desired = np.array([sample.desired_la for sample in window], dtype=float) actual = np.array([sample.actual_la for sample in window], dtype=float) relative_times = times - float(times[0]) event_start_time = max(float(samples[int(event["startIdx"])].t - times[0]), 0.0) event_end_time = max(float(samples[int(event["endIdx"])].t - times[0]), event_start_time) return { "times": [round(float(value), 3) for value in relative_times], "desired": [round(float(value), 4) for value in desired], "actual": [round(float(value), 4) for value in actual], "windowDurationSec": round(float(relative_times[-1]), 2), "eventStartSec": round(event_start_time, 2), "eventEndSec": round(event_end_time, 2), "eventDurationSec": round(max(event_end_time - event_start_time, 0.0), 2), "meanSpeedMph": round(float(np.mean([sample.v_ego for sample in window])) * 2.236936, 1), "route": event_route, "segment": event_segment, "segmentLabel": _route_label(event_route, event_segment), "direction": str(event.get("direction", "center")), "speedBand": str(event.get("speedBand", "mixed")), "driverOverrideFree": bool(eligibility is None or all(eligibility[start_idx:end_idx + 1])), } def _build_plot_svg(plot_data: dict[str, Any]) -> str: times = np.array(plot_data.get("times", []), dtype=float) desired = np.array(plot_data.get("desired", []), dtype=float) actual = np.array(plot_data.get("actual", []), dtype=float) if len(times) < 2 or len(desired) != len(times) or len(actual) != len(times): return "" time_span = max(float(times.max()), 1e-3) y_min = float(min(np.min(desired), np.min(actual))) y_max = float(max(np.max(desired), np.max(actual))) y_pad = max((y_max - y_min) * 0.10, 0.1) y_min -= y_pad y_max += y_pad y_span = max(y_max - y_min, 1e-3) def _points(series): coords = [] for t_val, y_val in zip(times, series, strict=True): x = (float(t_val) / time_span) * 380.0 y = 120.0 - (((float(y_val) - y_min) / y_span) * 120.0) coords.append(f"{x:.1f},{y:.1f}") return " ".join(coords) return ( "" ) def _decode_init_param(init, key: str) -> str: params = getattr(init, "params", None) if isinstance(params, dict): value = params.get(key, b"") else: value = next((entry.value for entry in getattr(params, "entries", []) if entry.key == key), b"") try: return bytes(value).decode("utf-8", errors="replace") except (TypeError, ValueError): return str(value or "") def _car_params_control_path(car_params) -> str: angle_type = getattr(car.CarParams.SteerControlType, "angle", None) if angle_type is not None and getattr(car_params, "steerControlType", None) == angle_type: return "angle" lateral_tuning = getattr(car_params, "lateralTuning", None) tuning_type = lateral_tuning.which() if lateral_tuning is not None else "" return tuning_type if tuning_type in ("torque", "pid") else "unknown" def _effective_control_path(car_params, observed_states: dict[str, int]) -> tuple[str, str]: state_paths = { "torqueState": "torque", "pidState": "pid", "angleState": "angle", } observed_paths = { path for state, path in state_paths.items() if int(observed_states.get(state, 0) or 0) > 0 } if len(observed_paths) == 1: return observed_paths.pop(), "controlsState" if len(observed_paths) > 1: return "mixed", "controlsState" return _car_params_control_path(car_params), "carParams" def _effective_torque_car_params(car_params): if _car_params_control_path(car_params) == "torque": return car_params try: from opendbc.car.interfaces import CarInterfaceBase builder = car_params.as_builder() CarInterfaceBase.configure_torque_tune(builder.carFingerprint, builder.lateralTuning) return builder except (AttributeError, KeyError, TypeError, ValueError): return car_params def _segment_samples(segment_source: RouteSource, params: Params | None = None) -> tuple[list[FLMSample], car.CarParams | None, dict[str, str], dict[str, int]]: samples: list[FLMSample] = [] car_params = None init_data: dict[str, str] = {} control_states: dict[str, int] = {} latest: dict[str, Any] = {} params = params or Params(return_defaults=True) last_offroad_check = 0.0 _require_flm_offroad(params) for msg in LogReader(segment_source.log_path): now = time.monotonic() if now - last_offroad_check >= FLM_ONROAD_POLL_INTERVAL_SECONDS: _require_flm_offroad(params) last_offroad_check = now which = msg.which() if which == "carParams": car_params = msg.carParams continue if which == "initData": init = msg.initData init_data = { "gitCommit": str(getattr(init, "gitCommit", "") or ""), "gitBranch": str(getattr(init, "gitBranch", "") or ""), "ForceTorqueController": _decode_init_param(init, "ForceTorqueController"), } continue if which == "carState": latest["carState"] = msg.carState continue if which == "carControl": latest["carControl"] = msg.carControl continue if which == "carOutput": latest["carOutput"] = msg.carOutput continue if which == "liveParameters": latest["liveParameters"] = msg.liveParameters continue if which != "controlsState": continue controls_state = msg.controlsState lateral_state = controls_state.lateralControlState lateral_state_name = lateral_state.which() control_states[lateral_state_name] = control_states.get(lateral_state_name, 0) + 1 if lateral_state_name != "torqueState" or "carState" not in latest or "carControl" not in latest: continue torque_state = lateral_state.torqueState car_state = latest["carState"] car_control = latest["carControl"] live_parameters = latest.get("liveParameters") car_output = latest.get("carOutput") roll_deg = math.degrees(float(getattr(live_parameters, "roll", 0.0) or 0.0)) if live_parameters is not None else 0.0 out_torque = float(getattr(getattr(car_output, "actuatorsOutput", None), "torque", 0.0) or 0.0) if car_output is not None else 0.0 samples.append(FLMSample( route=segment_source.route, segment=segment_source.segment_num, t=float(msg.logMonoTime) / 1e9, v_ego=float(getattr(car_state, "vEgo", 0.0) or 0.0), lat_active=bool(getattr(car_control, "latActive", False)), steering_pressed=bool(getattr(car_state, "steeringPressed", False)), saturated=bool(getattr(torque_state, "saturated", False)), actual_la=float(getattr(torque_state, "actualLateralAccel", 0.0) or 0.0), desired_la=float(getattr(torque_state, "desiredLateralAccel", 0.0) or 0.0), desired_jerk=float(getattr(torque_state, "desiredLateralJerk", 0.0) or 0.0), error=float(getattr(torque_state, "error", 0.0) or 0.0), error_rate=float(getattr(torque_state, "errorRate", 0.0) or 0.0), p=float(getattr(torque_state, "p", 0.0) or 0.0), i=float(getattr(torque_state, "i", 0.0) or 0.0), d=float(getattr(torque_state, "d", 0.0) or 0.0), f=float(getattr(torque_state, "f", 0.0) or 0.0), output=float(getattr(torque_state, "output", 0.0) or 0.0), steering_angle_deg=float(getattr(car_state, "steeringAngleDeg", 0.0) or 0.0), steering_torque=float(getattr(car_state, "steeringTorque", 0.0) or 0.0), cmd_torque=float(getattr(getattr(car_control, "actuators", None), "torque", 0.0) or 0.0), out_torque=out_torque, roll_deg=roll_deg, )) return samples, car_params, init_data, control_states def _segment_samples_with_timeout(segment_source: RouteSource, params: Params, timeout_seconds: float = FLM_SEGMENT_TIMEOUT_SECONDS): if ( timeout_seconds <= 0.0 or threading.current_thread() is not threading.main_thread() or not hasattr(signal, "SIGALRM") or not hasattr(signal, "setitimer") ): return _segment_samples(segment_source, params=params) def handle_timeout(_signum, _frame): raise FLMSegmentTimeout( f"{segment_source.route} segment {segment_source.segment_num} exceeded the {timeout_seconds:.0f}-second read limit." ) previous_handler = signal.getsignal(signal.SIGALRM) signal.signal(signal.SIGALRM, handle_timeout) previous_timer = signal.setitimer(signal.ITIMER_REAL, timeout_seconds) try: return _segment_samples(segment_source, params=params) finally: signal.setitimer(signal.ITIMER_REAL, 0.0) signal.signal(signal.SIGALRM, previous_handler) if previous_timer[0] > 0.0: signal.setitimer(signal.ITIMER_REAL, previous_timer[0], previous_timer[1]) def _current_param_state(CP, params: Params) -> dict[str, Any]: advanced_enabled = params.get_bool("AdvancedLateralTune") torque_tune = CP.lateralTuning.torque if CP.lateralTuning.which() == "torque" else None stock_delay = full_lateral_delay(float(getattr(CP, "steerActuatorDelay", 0.0) or 0.0)) stock_ratio = float(getattr(CP, "steerRatio", 0.0) or 0.0) stock_friction = float(getattr(torque_tune, "friction", 0.0) or 0.0) if torque_tune is not None else 0.0 stock_lat_accel = float(getattr(torque_tune, "latAccelFactor", 0.0) or 0.0) if torque_tune is not None else 0.0 return { "AdvancedLateralTune": advanced_enabled, "ForceAutoTune": params.get_bool("ForceAutoTune"), "ForceAutoTuneOff": params.get_bool("ForceAutoTuneOff"), "UseAutoSteerDelay": params.get_bool("UseAutoSteerDelay"), "SteerDelay": params.get_float("SteerDelay", return_default=True, default=stock_delay) if advanced_enabled else stock_delay, "SteerFriction": params.get_float("SteerFriction", return_default=True, default=stock_friction) if advanced_enabled else stock_friction, "SteerKP": params.get_float("SteerKP", return_default=True, default=KP) if advanced_enabled else KP, "SteerLatAccel": params.get_float("SteerLatAccel", return_default=True, default=stock_lat_accel) if advanced_enabled else stock_lat_accel, "SteerRatio": params.get_float("SteerRatio", return_default=True, default=stock_ratio) if advanced_enabled else stock_ratio, "FLMActiveProfileId": params.get("FLMActiveProfileId", encoding="utf-8") or "", "FLMActiveOverrides": normalize_flm_overrides(params.get("FLMActiveOverrides", encoding="utf-8") or "{}"), "FLMTrialApplied": params.get_bool("FLMTrialApplied"), } def _stock_param_state(CP, capabilities: dict[str, Any]) -> dict[str, Any]: torque_tune = CP.lateralTuning.torque if CP.lateralTuning.which() == "torque" else None friction_family = str(capabilities.get("frictionFamily", "standard")) rich_profile = capabilities.get("richProfileKey") rich_knobs = { symbol: float(meta["defaultValue"]) for symbol, meta in get_flm_supported_vehicle_knobs().items() if rich_profile and meta.get("profile") == rich_profile } return { "UseAutoSteerDelay": True, "SteerDelay": full_lateral_delay(float(getattr(CP, "steerActuatorDelay", 0.0) or 0.0)), "SteerFriction": float(getattr(torque_tune, "friction", 0.0) or 0.0) if torque_tune is not None else 0.0, "SteerKP": float(KP), "SteerLatAccel": float(getattr(torque_tune, "latAccelFactor", 0.0) or 0.0) if torque_tune is not None else 0.0, "SteerRatio": float(getattr(CP, "steerRatio", 0.0) or 0.0), "FLMBaseFrictionThresholds": { friction_family: { "speedKnots": list(FLM_FRICTION_SPEED_KNOTS), "values": _baseline_family_curve(friction_family), }, } if torque_tune is not None else {}, "FLMVehicleKnobs": rich_knobs, } def _nonlinear_torque_map(CP) -> dict[str, Any]: if str(getattr(CP, "brand", "") or "") != "gm": return {} try: from opendbc.car.gm.interface import NON_LINEAR_TORQUE_PARAM_ALIASES, get_nonlinear_torque_params except (ImportError, AttributeError): return {} raw_params = get_nonlinear_torque_params(CP.carFingerprint) if raw_params is None: return {} if isinstance(raw_params, dict): left = [float(value) for value in raw_params.get("left", [])] right = [float(value) for value in raw_params.get("right", [])] else: left = [float(value) for value in raw_params] right = list(left) if len(left) != 4 or len(right) != 4: return {} return { "type": "siglin", "left": left, "right": right, "asymmetric": any(not math.isclose(left[idx], right[idx], abs_tol=1e-9) for idx in range(4)), "sourceFingerprint": str(NON_LINEAR_TORQUE_PARAM_ALIASES.get(CP.carFingerprint, CP.carFingerprint)), "learnedByLiveTorque": False, } def _baseline_family_curve(family: str) -> list[float]: getter = { "gm": get_gm_base_friction_threshold, "standard": get_standard_friction_threshold, "hkg_canfd": get_hkg_canfd_base_friction_threshold, }.get(family, get_standard_friction_threshold) return [round(float(getter(knot)), 4) for knot in FLM_FRICTION_SPEED_KNOTS] def _current_family_curve(family: str, current: dict[str, Any]) -> list[float]: active_overrides = current.get("FLMActiveOverrides", {}) if isinstance(current, dict) else {} payload = active_overrides.get("baseFrictionThresholds", {}).get(family, {}) if isinstance(active_overrides, dict) else {} values = payload.get("values", []) if isinstance(payload, dict) else [] if isinstance(values, list) and len(values) == len(FLM_FRICTION_SPEED_KNOTS): try: return [round(float(value), 4) for value in values] except Exception: pass 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) def _round_to_precision(value: float, precision: float) -> float: if precision <= 0: return float(value) steps = round(float(value) / precision) return round(steps * precision, 6) def _current_vehicle_knob_value(symbol: str, current: dict[str, Any]) -> float | None: knob = get_flm_supported_vehicle_knobs().get(symbol) if knob is None: return None active_overrides = current.get("FLMActiveOverrides", {}) if isinstance(current, dict) else {} vehicle_knobs = active_overrides.get("vehicleKnobs", {}) if isinstance(active_overrides, dict) else {} try: return float(vehicle_knobs.get(symbol, knob["defaultValue"])) except Exception: return float(knob["defaultValue"]) def _vehicle_knob_adjustment(symbol: str, delta: float, current: dict[str, Any] | None = None) -> dict[str, Any] | None: knob = get_flm_supported_vehicle_knobs().get(symbol) if knob is None: return None current_value = _current_vehicle_knob_value(symbol, current or {}) if current_value is None: return None suggested_value = _round_to_precision(_clamp(current_value + delta, knob["min"], knob["max"]), knob["precision"]) if math.isclose(current_value, suggested_value, abs_tol=max(float(knob["precision"]) / 2.0, 1e-6)): return None return { "type": "vehicle_knob", "symbol": symbol, "current": current_value, "suggested": suggested_value, "delta": round(suggested_value - current_value, 4), } def _rich_profile_supports_knob(capabilities: dict[str, Any], suffix: str) -> bool: rich_profile = capabilities.get("richProfileKey") if not rich_profile: return False return f"{rich_profile}.{suffix}" in get_flm_supported_vehicle_knobs() def _build_event_summaries(samples: list[FLMSample]) -> tuple[list[dict[str, Any]], dict[str, Any]]: eligibility = _analysis_eligibility_mask(samples) active_samples = [sample for sample, allowed in zip(samples, eligibility, strict=True) if allowed] if not active_samples: return [], {"sampleCount": 0} over_error = [abs(sample.actual_la) - abs(sample.desired_la) for sample in samples] desired = [sample.desired_la for sample in samples] jerk = [sample.desired_jerk for sample in samples] angle = [sample.steering_angle_deg for sample in active_samples] output = [sample.output for sample in active_samples] entry_phase = [(abs(d) > 0.30 and abs(j) > 0.25 and d * j > 0.0) for d, j in zip(desired, jerk, strict=True)] unwind_phase = [(abs(d) > 0.25 and abs(j) > 0.20 and d * j < 0.0) for d, j in zip(desired, jerk, strict=True)] steady_curve_phase = [(abs(d) > 0.35 and abs(j) < 0.18) for d, j in zip(desired, jerk, strict=True)] saturation_phase = [ bool(allowed and sample.saturated and abs(sample.desired_la) > 0.30 and (abs(sample.desired_jerk) > 0.16 or abs(sample.actual_la) > 0.45)) for sample, allowed in zip(samples, eligibility, strict=True) ] base_masks = { "understeer": [(allowed and phase and (ov < -0.20)) for allowed, phase, ov in zip(eligibility, steady_curve_phase, over_error, strict=True)], "oversteer": [(allowed and phase and (ov > 0.20)) for allowed, phase, ov in zip(eligibility, steady_curve_phase, over_error, strict=True)], "late_turn_in": [(allowed and phase and ov < -0.16) for allowed, phase, ov in zip(eligibility, entry_phase, over_error, strict=True)], "early_turn_in": [(allowed and phase and ov > 0.16) for allowed, phase, ov in zip(eligibility, entry_phase, over_error, strict=True)], "unwind_too_slow": [(allowed and phase and ov > 0.14) for allowed, phase, ov in zip(eligibility, unwind_phase, over_error, strict=True)], "unwind_too_fast": [(allowed and phase and ov < -0.14) for allowed, phase, ov in zip(eligibility, unwind_phase, over_error, strict=True)], "low_speed_unwillingness": [ bool(allowed and sample.v_ego < 6.0 and abs(sample.desired_la) > 0.30 and abs(sample.desired_jerk) > 0.18 and (abs(sample.actual_la) + 0.18) < abs(sample.desired_la)) for sample, allowed in zip(samples, eligibility, strict=True) ], "saturation_limited": saturation_phase, } score_map = { "understeer": [max((-ov), 0.0) for ov in over_error], "oversteer": [max(ov, 0.0) for ov in over_error], "late_turn_in": [max((-ov), 0.0) + abs(j) * 0.1 for ov, j in zip(over_error, jerk, strict=True)], "early_turn_in": [max(ov, 0.0) + abs(j) * 0.1 for ov, j in zip(over_error, jerk, strict=True)], "unwind_too_slow": [max(ov, 0.0) for ov in over_error], "unwind_too_fast": [max((-ov), 0.0) for ov in over_error], "low_speed_unwillingness": [max(abs(d) - abs(sample.actual_la), 0.0) for d, sample in zip(desired, samples, strict=True)], "saturation_limited": [1.0 if sample.saturated else 0.0 for sample in samples], } # 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 mean_speed = float(np.mean([sample.v_ego for sample in window])) if mean_speed < 2.0: continue 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 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, "peakIdx": start_idx + int(len(window) / 2), "peakScore": chatter_score, "route": window[0].route, "segment": window[0].segment, "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 = [] for start_idx in range(0, max(len(samples) - 20, 1), 8): window = samples[start_idx:start_idx + 36] if len(window) < 18: continue if not all(eligibility[start_idx:start_idx + len(window)]): continue if float(np.mean([sample.v_ego for sample in window])) < 15.0: continue desired_sign = float(np.mean([sample.desired_la for sample in window])) if abs(desired_sign) < 0.35: continue if any((sample.desired_la * desired_sign) < 0.0 for sample in window): continue error_series = np.array([sample.actual_la - sample.desired_la for sample in window]) sign_changes = int(np.sum(np.sign(error_series[1:]) != np.sign(error_series[:-1]))) amplitude = float(np.max(error_series) - np.min(error_series)) if amplitude > 0.22 and sign_changes >= 4: curve_windows.append({ "startIdx": start_idx, "endIdx": start_idx + len(window) - 1, "peakIdx": start_idx + int(np.argmax(np.abs(error_series))), "peakScore": amplitude + sign_changes * 0.03, "route": window[0].route, "segment": window[0].segment, "speedBand": _speed_band_label(float(np.mean([sample.v_ego for sample in window]))), "direction": "left" if desired_sign > 0.0 else "right", "supportCount": len(window), }) summaries: list[dict[str, Any]] = [] for bucket, mask in base_masks.items(): events = _group_masked_events(samples, mask, score_map[bucket]) if events: summaries.extend(_summaries_from_events(bucket, samples, events, eligibility)) if straight_windows: summaries.extend(_summaries_from_events("center_chatter", samples, straight_windows, eligibility)) if curve_windows: summaries.extend(_summaries_from_events("notchy_mid_curve", samples, curve_windows, eligibility)) left_errors = [abs(sample.actual_la) - abs(sample.desired_la) for sample in active_samples if sample.desired_la > 0.25] right_errors = [abs(sample.actual_la) - abs(sample.desired_la) for sample in active_samples if sample.desired_la < -0.25] summary_stats = { "sampleCount": len(active_samples), "excludedDriverOverrideSamples": sum(1 for sample, allowed in zip(samples, eligibility, strict=True) if sample.lat_active and not allowed), "qlogFallback": False, "meanDesiredAbs": round(float(np.mean(np.abs([sample.desired_la for sample in active_samples]))), 4), "meanErrorAbs": round(float(np.mean(np.abs([sample.actual_la - sample.desired_la for sample in active_samples]))), 4), "leftBias": round(float(np.mean(left_errors)), 4) if left_errors else 0.0, "rightBias": round(float(np.mean(right_errors)), 4) if right_errors else 0.0, "highwayStraightAngleP2P": round(float(np.percentile(np.abs(angle), 95) - np.percentile(np.abs(angle), 5)), 4) if angle else 0.0, "meanOutputAbs": round(float(np.mean(np.abs(output))), 4) if output else 0.0, } if summary_stats["meanErrorAbs"] < 0.08 and not any(summary["severity"] > 0.65 for summary in summaries): summaries.append({ "bucket": "model_limited", "dimensionId": "model_limited:overall", "direction": "center", "speedBand": "mixed", "count": 1, "severity": 0.25, "evidence": { "speedBand": "mixed", "directionBias": "center", "eventCount": 1, "segments": [], }, "events": [], "plotSvg": "", "plotData": {}, }) return sorted(summaries, key=lambda item: item["severity"], reverse=True), summary_stats 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, str], list[dict[str, Any]]] = {} for event in events: 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, _group_speed_band), grouped_events in grouped.items(): grouped_events.sort(key=lambda item: item["peakScore"], reverse=True) strongest = grouped_events[:3] strongest_labels = [ { "route": event["route"], "segment": event["segment"], "label": _route_label(event["route"], event["segment"]), "score": round(float(event["peakScore"]), 3), } for event in strongest ] top_event = strongest[0] top_speed_band = top_event["speedBand"] plot_data = _build_plot_data(samples, top_event, eligibility) summaries.append({ "bucket": bucket_name, "dimensionId": f"{bucket_name}:{direction}:{top_speed_band}", "direction": direction, "speedBand": top_speed_band, "count": len(grouped_events), "severity": round(float(min(1.5, np.mean([event["peakScore"] for event in strongest]))), 3), "evidence": { "speedBand": top_speed_band, "directionBias": direction, "eventCount": len(grouped_events), "segments": strongest_labels, "chatterMetrics": top_event.get("metrics", {}), }, "events": grouped_events, "plotSvg": _build_plot_svg(plot_data), "plotData": plot_data, }) return summaries def classify_torque_samples(samples: list[FLMSample]) -> tuple[list[dict[str, Any]], dict[str, Any]]: return _build_event_summaries(samples) def _primary_delta_from_summary(summary: dict[str, Any], capabilities: dict[str, Any], current: dict[str, Any], strategy: str = "cleanup") -> dict[str, Any] | None: bucket = summary["bucket"] direction = summary["direction"] severity = max(float(summary["severity"]), 0.2) speed_band = str(summary.get("speedBand", "mixed")) rich_profile = capabilities.get("richProfileKey") family = capabilities.get("frictionFamily", "standard") side = "left" if direction != "right" else "right" curvy_band = speed_band in ("mid", "fast") supports_low_speed_assist = _rich_profile_supports_knob(capabilities, "low_speed_angle_assist_max_torque") supports_crawl_turn_in = _rich_profile_supports_knob(capabilities, f"crawl_turn_in_ff_boost_{side}") supports_turn_in_boost = _rich_profile_supports_knob(capabilities, f"turn_in_boost_{side}") supports_unwind_taper = _rich_profile_supports_knob(capabilities, f"unwind_taper_{side}") supports_curvy_turn_in_trim = _rich_profile_supports_knob(capabilities, f"curvy_turn_in_trim_{side}") supports_curvy_turn_in_speed = _rich_profile_supports_knob(capabilities, "curvy_turn_in_trim_speed_max") supports_curvy_speed_max = _rich_profile_supports_knob(capabilities, "curvy_speed_max") supports_curvy_unwind_extra = _rich_profile_supports_knob(capabilities, f"curvy_unwind_extra_reduction_{side}") supports_curvy_unwind_floor = _rich_profile_supports_knob(capabilities, f"curvy_unwind_floor_relief_{side}") supports_ff_gain = _rich_profile_supports_knob(capabilities, f"ff_gain_{side}") nonlinear_map = capabilities.get("nonlinearTorqueMap", {}) asymmetric_nonlinear_map = bool(isinstance(nonlinear_map, dict) and nonlinear_map.get("asymmetric")) if bucket == "model_limited": return None if strategy == "baseline": 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.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 { "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))], } if bucket == "low_speed_unwillingness": current_curve = _current_family_curve(family, current) deltas = [-0.03, -0.025, -0.015, -0.005, 0.0] scale = min(max(severity, 0.5), 1.2) suggested = [round(max(0.05, 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))], } if bucket in ("understeer", "late_turn_in", "saturation_limited"): if asymmetric_nonlinear_map and direction in ("left", "right") and supports_ff_gain: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.ff_gain_{side}", 0.025 * severity, current) if adjustment is not None: return adjustment current_value = float(current["SteerLatAccel"]) scale = 0.04 if bucket == "saturation_limited" else 0.03 suggested_value = round(_clamp(current_value + max(scale, current_value * scale * severity), 0.5, 5.0), 4) return {"type": "generic_param", "paramKey": "SteerLatAccel", "current": current_value, "suggested": suggested_value, "delta": round(suggested_value - current_value, 4)} if bucket in ("oversteer", "early_turn_in"): if asymmetric_nonlinear_map and direction in ("left", "right") and supports_ff_gain: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.ff_gain_{side}", -0.025 * severity, current) if adjustment is not None: return adjustment current_value = float(current["SteerLatAccel"]) suggested_value = round(_clamp(current_value - max(0.03, current_value * 0.03 * severity), 0.5, 5.0), 4) return {"type": "generic_param", "paramKey": "SteerLatAccel", "current": current_value, "suggested": suggested_value, "delta": round(suggested_value - current_value, 4)} if bucket in ("unwind_too_slow", "unwind_too_fast"): current_value = float(current["SteerFriction"]) direction_mult = -1.0 if bucket == "unwind_too_slow" else 1.0 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 == "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) 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 { "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))], } if bucket == "low_speed_unwillingness": current_curve = _current_family_curve(family, current) deltas = [-0.03, -0.025, -0.015, -0.005, 0.0] scale = min(max(severity, 0.5), 1.2) if supports_low_speed_assist: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.low_speed_angle_assist_max_torque", 0.04 * scale, current) if adjustment is not None: return adjustment if supports_crawl_turn_in: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.crawl_turn_in_ff_boost_{side}", 0.03 * scale, current) if adjustment is not None: return adjustment if rich_profile and supports_turn_in_boost: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.turn_in_boost_{side}", 0.025 * scale, current) if adjustment is not None: return adjustment suggested = [round(max(0.05, 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))], } if bucket in ("understeer", "late_turn_in"): if bucket == "understeer" and asymmetric_nonlinear_map and direction in ("left", "right") and supports_ff_gain: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.ff_gain_{side}", 0.025 * severity, current) if adjustment is not None: return adjustment if curvy_band and speed_band == "fast" and bucket == "late_turn_in" and supports_curvy_turn_in_speed: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.curvy_turn_in_trim_speed_max", 1.6 * severity, current) if adjustment is not None: return adjustment if curvy_band and supports_curvy_turn_in_trim: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.curvy_turn_in_trim_{side}", -0.018 * severity, current) if adjustment is not None: return adjustment if rich_profile and supports_turn_in_boost: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.turn_in_boost_{side}", 0.02 * severity, current) if adjustment is not None: return adjustment current_value = float(current["SteerLatAccel"]) suggested_value = round(_clamp(current_value + max(0.03, current_value * 0.03 * severity), 0.5, 5.0), 4) return {"type": "generic_param", "paramKey": "SteerLatAccel", "current": current_value, "suggested": suggested_value, "delta": round(suggested_value - current_value, 4)} if bucket in ("oversteer", "early_turn_in"): if bucket == "oversteer" and asymmetric_nonlinear_map and direction in ("left", "right") and supports_ff_gain: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.ff_gain_{side}", -0.025 * severity, current) if adjustment is not None: return adjustment if curvy_band and supports_curvy_turn_in_trim: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.curvy_turn_in_trim_{side}", 0.018 * severity, current) if adjustment is not None: return adjustment if rich_profile and supports_turn_in_boost: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.turn_in_boost_{side}", -0.02 * severity, current) if adjustment is not None: return adjustment current_value = float(current["SteerLatAccel"]) suggested_value = round(_clamp(current_value - max(0.03, current_value * 0.03 * severity), 0.5, 5.0), 4) return {"type": "generic_param", "paramKey": "SteerLatAccel", "current": current_value, "suggested": suggested_value, "delta": round(suggested_value - current_value, 4)} if bucket in ("unwind_too_slow", "unwind_too_fast"): if curvy_band and supports_curvy_unwind_extra: if bucket == "unwind_too_slow" and speed_band == "fast" and supports_curvy_speed_max: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.curvy_speed_max", 1.8 * severity, current) if adjustment is not None: return adjustment direction_mult = 1.0 if bucket == "unwind_too_slow" else -1.0 adjustment = _vehicle_knob_adjustment(f"{rich_profile}.curvy_unwind_extra_reduction_{side}", 0.03 * severity * direction_mult, current) if adjustment is not None: return adjustment if rich_profile and supports_unwind_taper: direction_mult = 1.0 if bucket == "unwind_too_slow" else -1.0 adjustment = _vehicle_knob_adjustment(f"{rich_profile}.unwind_taper_{side}", 0.08 * severity * direction_mult, current) if adjustment is not None: return adjustment current_value = float(current["SteerFriction"]) direction_mult = -1.0 if bucket == "unwind_too_slow" else 1.0 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 == "saturation_limited": if curvy_band and supports_curvy_unwind_floor: adjustment = _vehicle_knob_adjustment(f"{rich_profile}.curvy_unwind_floor_relief_{side}", 0.04 * severity, current) if adjustment is not None: return adjustment current_value = float(current["SteerLatAccel"]) suggested_value = round(_clamp(current_value + max(0.04, current_value * 0.04 * severity), 0.5, 5.0), 4) return {"type": "generic_param", "paramKey": "SteerLatAccel", "current": current_value, "suggested": suggested_value, "delta": round(suggested_value - current_value, 4)} return None def _observed_behavior(summary: dict[str, Any]) -> str: bucket = summary["bucket"] direction = summary["direction"] speed_band = summary["speedBand"] direction_text = "" if direction == "center" else f" on {direction} {speed_band} inputs" mapping = { "understeer": f"The car is not matching requested lateral accel{direction_text}; it stays wider than plan before recovery.", "oversteer": f"The car is exceeding requested lateral accel{direction_text}; it is stepping past plan before correcting back.", "late_turn_in": f"Turn-in is late{direction_text}; desired lateral accel is already building while actual response lags.", "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": 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.", "model_limited": "The controller is largely matching the commanded path; this sample does not show a strong tuning mismatch.", } return mapping.get(bucket, "The controller is showing a repeatable mismatch against the requested path.") def _likely_interpretation(summary: dict[str, Any], adjustment: dict[str, Any]) -> str: bucket = summary["bucket"] if adjustment["type"] == "friction_curve": if bucket == "low_speed_unwillingness": return "The near-center friction threshold is too high in the crawl-speed band, so small requests are being muted." 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: return "The main torque path is waking up too late below about 8 mph, so the low-speed assist layer needs a little more authority." if "crawl_turn_in_ff_boost" in symbol: return "The crawl-speed turn-in band is still too lazy, even before the broader tune needs to move." if "curvy_speed_max" in symbol: return "The curvy unwind band is dropping out too early at higher speed, so the curve-specific release cleanup is not staying active long enough." if "curvy_turn_in_trim_speed_max" in symbol: return "The fast-curve entry trim band is fading out too early, so the controller is falling back to the base path before the curve is done." if "curvy_turn_in_trim" in symbol: return "This is a curve-band entry problem, not a whole-car turn-in problem; the mid-speed trim needs to move without touching the rest of the tune." if "curvy_unwind" in symbol: return "This is a curve-band release problem, not a global unwind problem; the mid-speed unwind cleanup needs to move on its own." if bucket in ("understeer", "late_turn_in", "low_speed_unwillingness", "saturation_limited"): return "Primary turn-in authority is too low for the way this car is reacting in that band." if bucket in ("oversteer", "early_turn_in"): return "Entry authority is too aggressive for the speed band being hit here." if bucket == "unwind_too_slow": return "Exit steering is being held too long after the plan starts backing out of the curve." if bucket == "unwind_too_fast": return "Exit steering is tapering away too quickly once unwind starts." return "The mismatch is consistent enough to justify a direct tuning pass." def _why_this_knob(adjustment: dict[str, Any]) -> str: if adjustment["type"] == "friction_curve": 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: return "This directly raises the crawl-speed assist ceiling that fills the gap before the normal torque path wakes up." if "crawl_turn_in_ff_boost" in symbol: return "This only touches the crawl-speed turn-in band instead of disturbing normal-speed behavior." if "curvy_speed_max" in symbol: return "This keeps the dedicated curvy unwind helper alive deeper into faster curves instead of globally changing the whole unwind map." if "curvy_turn_in_trim_speed_max" in symbol: return "This extends the fast-curve trim band instead of making the whole car more eager to turn everywhere." if "curvy_turn_in_trim" in symbol: return "This trims entry only in the dedicated curvy speed band instead of flattening turn-in everywhere." if "curvy_unwind" in symbol: return "This cleans up release only in the dedicated curvy speed band instead of changing global unwind behavior." if "turn_in_boost" in symbol: return "This targets entry behavior directly instead of disturbing the whole tune." if "unwind_taper" in symbol: return "This targets release behavior directly instead of flattening the whole response." if "threshold" in symbol: return "This adjusts the transition deadzone for the specific phase that is misbehaving." return "This is the closest car-specific knob to the symptom being shown." return "This is the smallest generic user-facing change that moves the car in the right direction without inventing a new code path." def _render_adjustment_line(adjustment: dict[str, Any]) -> str: if adjustment["type"] == "friction_curve": 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": 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." return "Do not start layering narrow cleanup knobs onto a car that is still broadly wrong." if adjustment and adjustment.get("type") == "generic_param": return "Do not widen this into a whole-car ratio or delay change first. This symptom can usually be cleaned up without global geometry edits." return "Do not change unrelated center-taper or steer-ratio behavior first. This symptom has a narrower cause than that." 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 = [] for summary in summaries: adjustment = _primary_delta_from_summary(summary, capabilities, current, strategy=strategy) evidence = summary.get("evidence", {}) if adjustment is None: suggestions.append({ "dimensionId": summary["dimensionId"], "bucket": summary["bucket"], "severity": float(summary.get("severity", 0.0)), "evidence": evidence, "currentVsSuggested": None, "observedBehavior": _observed_behavior(summary), "likelyInterpretation": _likely_interpretation(summary, {"type": "generic_param", "paramKey": "none"}), "primaryAdjustment": "Do not change the tune yet.", "whatNotToTouchYet": "Do not start cutting or adding turn-in. This sample does not show a clean controller-side miss.", "ifThatWasWrong": "If a stronger sample later shows actual lateral accel lagging or overshooting the plan, revisit with that route.", "strategy": strategy, "plotSvg": summary.get("plotSvg", ""), "plotData": summary.get("plotData", {}), }) continue if adjustment["type"] == "friction_curve": current_vs_suggested = { "type": "friction_curve", "family": adjustment["family"], "current": adjustment["current"], "suggested": adjustment["suggested"], } elif adjustment["type"] == "vehicle_knob": current_vs_suggested = { "type": "vehicle_knob", "symbol": adjustment["symbol"], "current": adjustment["current"], "suggested": adjustment["suggested"], } else: current_vs_suggested = { "type": "generic_param", "paramKey": adjustment["paramKey"], "current": adjustment["current"], "suggested": adjustment["suggested"], } suggestions.append({ "dimensionId": summary["dimensionId"], "bucket": summary["bucket"], "severity": float(summary.get("severity", 0.0)), "evidence": evidence, "currentVsSuggested": current_vs_suggested, "primaryAdjustmentRaw": adjustment, "strategy": strategy, "observedBehavior": _observed_behavior(summary), "likelyInterpretation": _likely_interpretation(summary, adjustment), "primaryAdjustment": _render_adjustment_line(adjustment), "whatNotToTouchYet": _what_not_to_touch_yet(summary, adjustment, strategy), "ifThatWasWrong": _if_that_was_wrong(summary, adjustment, strategy), "driverFeel": _observed_behavior(summary), "logSupport": _log_support(summary), "whyThisKnob": _why_this_knob(adjustment), "plotSvg": summary.get("plotSvg", ""), "plotData": summary.get("plotData", {}), }) return suggestions def _clamp_generic_param(param_key: str, value: float) -> float: meta = GENERIC_PARAM_METADATA[param_key] return _round_to_precision(_clamp(value, meta["min"], meta["max"]), meta["precision"]) def _merge_primary_adjustments(suggestions: list[dict[str, Any]], multiplier: float) -> tuple[dict[str, Any], dict[str, Any], bool]: params_delta: dict[str, Any] = {"AdvancedLateralTune": True} requires_force_auto_tune_off = False generic_targets: dict[str, dict[str, Any]] = {} vehicle_targets: dict[str, dict[str, Any]] = {} friction_targets: dict[str, dict[str, Any]] = {} for suggestion in suggestions: adjustment = suggestion.get("primaryAdjustmentRaw") if not isinstance(adjustment, dict): continue weight = max(float(suggestion.get("severity", 0.0)), 0.25) if adjustment["type"] == "generic_param": param_key = adjustment["paramKey"] bucket = generic_targets.setdefault(param_key, { "current": float(adjustment["current"]), "weightedDelta": 0.0, "weight": 0.0, }) bucket["weightedDelta"] += float(adjustment["delta"]) * weight bucket["weight"] += weight if param_key in ("SteerFriction", "SteerLatAccel", "SteerKP", "SteerDelay", "SteerRatio"): requires_force_auto_tune_off = True elif adjustment["type"] == "vehicle_knob": symbol = adjustment["symbol"] bucket = vehicle_targets.setdefault(symbol, { "current": float(adjustment["current"]), "weightedDelta": 0.0, "weight": 0.0, }) bucket["weightedDelta"] += float(adjustment["delta"]) * weight bucket["weight"] += weight requires_force_auto_tune_off = True elif adjustment["type"] == "friction_curve": family = adjustment["family"] delta_curve = [float(value) for value in adjustment["delta"]] bucket = friction_targets.setdefault(family, { "current": [float(value) for value in adjustment["current"]], "weightedDelta": [0.0] * len(delta_curve), "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["weights"][idx] += weight requires_force_auto_tune_off = True overrides: dict[str, Any] = {"schemaVersion": 1, "baseFrictionThresholds": {}, "vehicleKnobs": {}} for param_key, bucket in generic_targets.items(): avg_delta = (bucket["weightedDelta"] / bucket["weight"]) * multiplier if bucket["weight"] > 0 else 0.0 next_value = _clamp_generic_param(param_key, float(bucket["current"]) + avg_delta) precision = float(GENERIC_PARAM_METADATA[param_key]["precision"]) if not math.isclose(float(bucket["current"]), next_value, abs_tol=max(precision / 2.0, 1e-6)): params_delta[param_key] = next_value if "SteerDelay" in params_delta: params_delta["UseAutoSteerDelay"] = False supported_knobs = get_flm_supported_vehicle_knobs() for symbol, bucket in vehicle_targets.items(): meta = supported_knobs.get(symbol) if meta is None or bucket["weight"] <= 0: continue avg_delta = (bucket["weightedDelta"] / bucket["weight"]) * multiplier next_value = _round_to_precision(_clamp(float(bucket["current"]) + avg_delta, meta["min"], meta["max"]), meta["precision"]) if not math.isclose(float(bucket["current"]), next_value, abs_tol=max(float(meta["precision"]) / 2.0, 1e-6)): overrides["vehicleKnobs"][symbol] = next_value for family, bucket in friction_targets.items(): if not any(weight > 0.0 for weight in bucket["weights"]): continue 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"])) ] if any(not math.isclose(float(bucket["current"][idx]), values[idx], abs_tol=1e-6) for idx in range(len(values))): overrides["baseFrictionThresholds"][family] = {"speedKnots": list(FLM_FRICTION_SPEED_KNOTS), "values": values} overrides = normalize_flm_overrides(overrides) return params_delta, overrides, requires_force_auto_tune_off def _resolve_conflicting_actionable_suggestions(suggestions: list[dict[str, Any]]) -> list[dict[str, Any]]: families = { "understeer": ("turn_in", "more"), "late_turn_in": ("turn_in", "more"), "oversteer": ("turn_in", "less"), "early_turn_in": ("turn_in", "less"), "unwind_too_slow": ("unwind", "more"), "unwind_too_fast": ("unwind", "less"), } grouped: dict[tuple[str, str, str], dict[str, list[dict[str, Any]]]] = {} passthrough: list[dict[str, Any]] = [] for suggestion in suggestions: bucket = str(suggestion.get("bucket", "")) family_info = families.get(bucket) if family_info is None: passthrough.append(suggestion) continue evidence = suggestion.get("evidence", {}) key = ( family_info[0], str(evidence.get("directionBias", "center")), str(evidence.get("speedBand", "mixed")), ) grouped.setdefault(key, {"more": [], "less": []})[family_info[1]].append(suggestion) resolved = list(passthrough) for polarities in grouped.values(): more = polarities["more"] less = polarities["less"] if not more or not less: resolved.extend(more or less) continue def score(items: list[dict[str, Any]]) -> float: total = 0.0 for item in items: severity = max(float(item.get("severity", 0.0)), 0.25) event_count = max(int(item.get("evidence", {}).get("eventCount", 0)), 1) total += severity * math.log1p(event_count) return total more_score = score(more) less_score = score(less) if more_score >= less_score * 1.2: resolved.extend(more) elif less_score >= more_score * 1.2: resolved.extend(less) return sorted(resolved, key=lambda item: float(item.get("severity", 0.0)), reverse=True) def _bucket_tuning_family(bucket: str) -> str: if bucket in ("understeer", "late_turn_in", "oversteer", "early_turn_in", "saturation_limited", "low_speed_unwillingness"): return "authority" if bucket in ("unwind_too_slow", "unwind_too_fast"): return "release" if bucket in ("center_chatter", "notchy_mid_curve"): return "stability" return "other" def _select_primary_tuning_path_unlocked(summaries: list[dict[str, Any]], summary_stats: dict[str, Any]) -> dict[str, Any]: actionable = [ summary for summary in summaries if summary.get("bucket") not in ("model_limited", "angle_control_diagnostic") and float(summary.get("severity", 0.0)) >= 0.4 ] if not actionable: return { "primaryPathKey": "cleanup_pass", "alternatePathKey": "baseline_fix", "reason": "This sample does not show a broad controller-side miss. Start with the narrower cleanup path if you test anything.", "baselineScore": 0, } mean_error = float(summary_stats.get("meanErrorAbs", 0.0) or 0.0) families = {_bucket_tuning_family(str(summary.get("bucket", ""))) for summary in actionable} major_events = [summary for summary in actionable if float(summary.get("severity", 0.0)) >= 0.8] severe_global = [ summary for summary in actionable if summary.get("bucket") in ("understeer", "oversteer", "late_turn_in", "early_turn_in", "saturation_limited") and float(summary.get("severity", 0.0)) >= 0.85 ] severe_global_bands = { (str(summary.get("direction", "center")), str(summary.get("speedBand", "mixed"))) for summary in severe_global } severe_global_segments = { str(segment.get("label", "")) for summary in severe_global for segment in summary.get("evidence", {}).get("segments", []) if segment.get("label") } severe_saturation = any( summary.get("bucket") == "saturation_limited" and float(summary.get("severity", 0.0)) >= 0.85 for summary in actionable ) if mean_error < 0.08 and not severe_saturation and not ( len(severe_global_bands) >= 2 and len(severe_global_segments) >= 2 ): return { "primaryPathKey": "cleanup_pass", "alternatePathKey": "baseline_fix", "reason": "Overall lateral-accel tracking is already strong. The remaining misses are isolated enough that changing the base tune would disturb more good behavior than it fixes.", "baselineScore": 0, } baseline_score = 0 if mean_error >= 0.14: baseline_score += 2 elif mean_error >= 0.11: baseline_score += 1 if len(actionable) >= 4: baseline_score += 1 if len(families - {"other"}) >= 3: baseline_score += 1 if len(major_events) >= 2: baseline_score += 1 if severe_global: baseline_score += 1 if baseline_score >= 3: return { "primaryPathKey": "baseline_fix", "alternatePathKey": "cleanup_pass", "reason": "This route looks broadly wrong across enough bands that the right first move is to fix base authority and friction behavior before touching narrower cleanup layers.", "baselineScore": baseline_score, } return { "primaryPathKey": "cleanup_pass", "alternatePathKey": "baseline_fix", "reason": "This route is already close enough overall that the better first move is a narrow cleanup pass instead of a broad whole-car reset.", "baselineScore": baseline_score, } def select_primary_tuning_path(summaries: list[dict[str, Any]], summary_stats: dict[str, Any], cleanup_progress_locked: bool = False) -> dict[str, Any]: decision = _select_primary_tuning_path_unlocked(summaries, summary_stats) if not cleanup_progress_locked: return decision raw_primary_path = decision["primaryPathKey"] if raw_primary_path == "baseline_fix": return { **decision, "primaryPathKey": "cleanup_pass", "alternatePathKey": "baseline_fix", "reason": ( "This vehicle already progressed to Cleanup Pass. This route contains broader misses, but FLM will not automatically " "reset a tune that already reached fine adjustment. Review Baseline Fix manually if the regression is real and repeatable." ), "rawPrimaryPathKey": raw_primary_path, "automaticBaselineDemotionBlocked": True, "cleanupProgressLocked": True, } return { **decision, "rawPrimaryPathKey": raw_primary_path, "cleanupProgressLocked": True, } def build_trial_profiles(report_id: str, suggestions: list[dict[str, Any]], feedback: dict[str, Any], capabilities: dict[str, Any], path_key: str = "cleanup_pass", path_label: str = "Cleanup Pass") -> list[dict[str, Any]]: ignored = set(str(item) for item in feedback.get("ignoredDimensions", [])) accepted = set(str(item) for item in feedback.get("acceptedDimensions", [])) has_feedback_decisions = bool(ignored or accepted) considered = [ suggestion for suggestion in suggestions if suggestion.get("dimensionId") not in ignored and ( not accepted or suggestion.get("dimensionId") in accepted ) ] if not considered and not has_feedback_decisions: considered = [suggestion for suggestion in suggestions if suggestion.get("primaryAdjustmentRaw")] actionable = [ suggestion for suggestion in considered if suggestion.get("primaryAdjustmentRaw") ] actionable = _resolve_conflicting_actionable_suggestions(actionable) profiles = [] profile_defs = [ ("conservative", "Conservative", 0.6), ("recommended", "Recommended", 1.0), ("assertive", "Assertive", 1.35), ] for suffix, label, multiplier in profile_defs: params_delta, overrides, force_auto_tune_off = _merge_primary_adjustments(actionable, multiplier) if not overrides and len(params_delta) <= 1: continue profile = { "id": f"{report_id}:{path_key}:{suffix}", "reportId": report_id, "label": label, "pathKey": path_key, "pathLabel": path_label, "description": f"{label} {path_label.lower()} trial generated from {len(actionable)} confirmed symptom dimension(s).", "genericParams": params_delta, "flmOverrides": overrides, "requiresForceAutoTuneOff": bool(force_auto_tune_off), "capabilities": capabilities, } if force_auto_tune_off: profile["genericParams"]["ForceAutoTuneOff"] = True profile["genericParams"]["ForceAutoTune"] = False profiles.append(profile) return profiles[:3] def _add_parameters_start_here(capabilities: dict[str, Any], suggestions: list[dict[str, Any]], primary_path_key: str) -> list[str]: lines = ["Turn on Advanced Lateral Tune before trying any suggested profile."] if primary_path_key == "baseline_fix": lines.append("This route looks broadly wrong enough that the first move should be a baseline fix, not a surgical cleanup pass.") lines.append("Start with the broad knobs this report suggests. Once the car is in the right zip code, re-run analysis and switch to Cleanup Pass for the leftovers.") else: lines.append("This route is already mostly in the right zip code, so start with cleanup changes before reaching for broader whole-car adjustments.") if any(suggestion.get("primaryAdjustmentRaw", {}).get("type") == "generic_param" for suggestion in suggestions if suggestion.get("primaryAdjustmentRaw")): lines.append("Generic advanced lateral params are in play on this pass, so apply those first before deciding you need deeper code-level changes.") if any(suggestion.get("primaryAdjustmentRaw", {}).get("type") == "friction_curve" for suggestion in suggestions if suggestion.get("primaryAdjustmentRaw")): lines.append("Friction-threshold changes are active in this pass because the logs point to small-signal steering behavior, not just whole-tune authority.") if not capabilities.get("richProfileKey"): lines.append("This car does not expose richer live FLM knobs yet, so generic advanced params and friction-threshold trials are the first code-level moves to test.") return lines def build_recommendation_paths(report_id: str, summaries: list[dict[str, Any]], summary_stats: dict[str, Any], capabilities: dict[str, Any], current: dict[str, Any], feedback: dict[str, Any], cleanup_progress_locked: bool = False) -> tuple[list[dict[str, Any]], dict[str, Any]]: decision = select_primary_tuning_path(summaries, summary_stats, cleanup_progress_locked) all_suggestions = { "baseline_fix": build_suggestions(summaries, capabilities, current, strategy="baseline"), "cleanup_pass": build_suggestions(summaries, capabilities, current, strategy="cleanup"), } ordered_keys = [decision["primaryPathKey"], decision["alternatePathKey"]] paths = [] for path_key in ordered_keys: spec = FLM_PATH_SPECS[path_key] suggestions = all_suggestions[path_key] profiles = build_trial_profiles(report_id, suggestions, feedback, capabilities, path_key=path_key, path_label=spec["title"]) paths.append({ "key": path_key, "title": spec["title"], "description": spec["description"], "whenToUse": spec["whenToUse"], "alternateHint": spec["alternateHint"], "isPrimary": path_key == decision["primaryPathKey"], "whySelected": decision["reason"] if path_key == decision["primaryPathKey"] else spec["alternateHint"], "suggestions": suggestions, "profiles": profiles, }) return paths, decision def _render_report_html(report: dict[str, Any]) -> str: report_paths = [path for path in report.get("paths", []) if isinstance(path, dict)] selected_path_key = str(report.get("selectedPathKey") or report.get("primaryPathKey") or "") primary_path = next((path for path in report_paths if path.get("key") == selected_path_key), None) if primary_path is None: primary_path = next((path for path in report_paths if path.get("isPrimary")), report_paths[0] if report_paths else {}) findings_html = [] for suggestion in report.get("suggestions", []): evidence = suggestion.get("evidence", {}) current_vs_suggested = suggestion.get("currentVsSuggested") if current_vs_suggested is None: delta_html = "
No trial adjustment suggested for this dimension.
" elif current_vs_suggested["type"] == "friction_curve": delta_html = ( f"Current: {current_vs_suggested['current']}
" f"Suggested: {current_vs_suggested['suggested']}
" ) else: label = current_vs_suggested.get("paramKey") or current_vs_suggested.get("symbol") delta_html = ( f"{label}: {float(current_vs_suggested['current']):.3f} -> {float(current_vs_suggested['suggested']):.3f}
" ) findings_html.append( "Observed behavior: {suggestion['observedBehavior']}
" f"Likely interpretation: {suggestion['likelyInterpretation']}
" f"Primary adjustment: {suggestion['primaryAdjustment']}
" f"What not to touch yet: {suggestion['whatNotToTouchYet']}
" f"If that was wrong, next thing to try: {suggestion['ifThatWasWrong']}
" f"Evidence: speed={evidence.get('speedBand', 'mixed')}, direction={evidence.get('directionBias', 'center')}, events={evidence.get('eventCount', 0)}
" f"Strongest segments: {', '.join(item['label'] for item in evidence.get('segments', [])[:3]) or 'none'}
" f"{delta_html}" f"{suggestion.get('plotSvg', '')}" "{badge}: {path.get('description', '')}
" f"Why this path: {path.get('whySelected', '')}
" f"When to use it: {path.get('whenToUse', '')}
" "{key}: {value}{family} curve: {payload.get('values', [])}{key}: {value}{profile['description']}
" f"Generic params:
FLM overrides:
No trial profiles generated for this path.
" profile_html.append( f"No strong findings.
" profiles_block = "".join(profile_html) or "No trial profiles generated.
" return ( "" "{report['car']['carFingerprint']} | {report['car'].get('gitBranch', '')} {report['car'].get('gitCommit', '')}
" f"{', '.join(report.get('routeNames', []))}
{report['car'].get('controlPath', 'unknown')}
{report['capabilities'].get('frictionFamily', 'standard')}
{'Asymmetric left/right siglin' if report['capabilities'].get('nonlinearTorqueMap', {}).get('asymmetric') else ('Symmetric siglin' if report['capabilities'].get('nonlinearTorqueMap') else 'Not detected')}