IQ.Pilot Prebuilt Release @ 27f668a

This commit is contained in:
IQ.Lvbs CI [bot]
2026-09-03 18:23:24 -05:00
commit b073c5182b
2554 changed files with 679696 additions and 0 deletions

14
iqpilot/tools/Brewfile Normal file
View File

@@ -0,0 +1,14 @@
brew "git-lfs"
brew "capnp"
brew "coreutils"
brew "eigen"
brew "ffmpeg"
brew "glfw"
brew "libusb"
brew "libtool"
brew "llvm"
brew "openssl@3.0"
brew "zeromq"
cask "gcc-arm-embedded"
brew "portaudio"
brew "gcc@13"

89
iqpilot/tools/README.md Normal file
View File

@@ -0,0 +1,89 @@
# IQ.Pilot tools
## System Requirements
IQ.Pilot is developed and tested on **Apple macOS**, which is the primary development target aside from supported vehicle hardware (3, 3x, 4).
Most of IQ.Pilot should work natively on macOS. On Windows you can use WSL for a nearly native Ubuntu experience. Running natively on any other system is not currently recommended and will likely require modifications.
## Native setup on Ubuntu 24.04 and macOS
Follow these instructions for a fully managed setup experience. If you'd like to manage the dependencies yourself, just read the setup scripts in this directory.
**1. Clone IQ.Pilot**
``` bash
git clone https://gitlvb.teallvbs.xyz/IQ.Lvbs/IQ.Pilot.git
```
**2. Run the setup script**
``` bash
cd IQ.Pilot
iqpilot/tools/iq.sh setup
```
**3. Activate a Python shell**
Activate a shell with the Python dependencies installed:
``` bash
source .venv/bin/activate
```
**4. Build IQ.Pilot**
``` bash
scons -u -j$(nproc)
```
# Using IQ.Pilot tools with Konn3kt:
This guide explains how to use IQ.Pilot tools to view and analyze routes from Konn3kt.
## Overview
All you need to do is authenticate with Konn3kt so you can access your routes.
## Quick Start
### 1. Authenticate with Konn3kt
Run the authentication helper:
```bash
cd IQ.Pilot
python3 iqpilot/tools/lib/auth.py
```
This will:
- Open your browser to log in via GitHub OAuth
- Save your authentication token to `~/.comma/auth.json`
- Allow access to your Konn3kt routes
If browser auto-open is unavailable (headless/WSL), copy the printed URL into any browser — the local callback listens on port 3000.
## How It Works
OP Tools reads your Konn3kt JWT token from `~/.comma/auth.json`.
You can always view public routes!
## WSL on Windows
[Windows Subsystem for Linux (WSL)](https://docs.microsoft.com/en-us/windows/wsl/about) should provide a similar experience to native Ubuntu. [WSL 2](https://docs.microsoft.com/en-us/windows/wsl/compare-versions) specifically has been reported by several users to be a seamless experience.
Follow [these instructions](https://docs.microsoft.com/en-us/windows/wsl/install) to setup the WSL and install the `Ubuntu-24.04` distribution. Once your Ubuntu WSL environment is setup, follow the Linux setup instructions to finish setting up your environment. See [these instructions](https://learn.microsoft.com/en-us/windows/wsl/tutorials/gui-apps) for running GUI apps.
**NOTE**: If you are running WSL and any GUIs are failing (segfaulting or other strange issues) even after following the steps above, you may need to enable software rendering with `LIBGL_ALWAYS_SOFTWARE=1`, e.g. `LIBGL_ALWAYS_SOFTWARE=1 selfdrive/ui/ui`.
## CTF
Learn about the IQ.Pilot ecosystem and tools by playing our [CTF](/tools/CTF.md).
## Directory Structure
```
├── cabana/ # View and plot CAN messages from drives or in realtime
├── joystick/ # Control your car with a joystick
├── jotpluggler/ # View and plot IQ.Pilot logs
├── lib/ # Libraries to support the tools and reading IQ.Pilot logs
├── maneuvers/ # Lateral and longitudinal maneuver testing
├── replay/ # Replay drives and mock IQ.Pilot services
├── scripts/ # Miscellaneous scripts
└── serial/ # Tools for using the comma serial
```

View File

@@ -0,0 +1,3 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""

17
iqpilot/tools/auto_source.py Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import sys
from iqpilot.tools.lib.logreader import LogReader, ReadMode
def main():
if len(sys.argv) != 2:
print("Usage: python auto_source.py <log_path>")
sys.exit(1)
log_path = sys.argv[1]
lr = LogReader(log_path, default_mode=ReadMode.AUTO, sort_by_time=True)
print("\n".join(lr.logreader_identifiers))
if __name__ == "__main__":
main()

457
iqpilot/tools/clip/run.py Executable file
View File

@@ -0,0 +1,457 @@
#!/usr/bin/env python3
import os
import sys
import time
import logging
import subprocess
import threading
import queue
import multiprocessing
import itertools
import numpy as np
import tqdm
from argparse import ArgumentParser
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from iqpilot.tools.lib.route import Route
from iqpilot.tools.lib.logreader import LogReader
from iqpilot.tools.lib.filereader import FileReader
from iqpilot.tools.lib.framereader import FrameReader, ffprobe
from iqpilot.selfdrive.test.process_replay.migration import migrate_all
from iqpilot.common.prefix import OpenpilotPrefix
from iqpilot.common.utils import Timer
from iqpilot.cereal.visionipc import VisionStreamType
from msgq.visionipc import VisionIpcServer
FRAMERATE = 20
DEMO_ROUTE, DEMO_START, DEMO_END = 'a2a0ccea32023010/2023-07-27--13-01-19', 90, 105
logger = logging.getLogger('clip')
def parse_args():
parser = ArgumentParser(description="Direct clip renderer")
parser.add_argument("route", nargs="?", help="Route ID (dongle/route or dongle/route/start/end)")
parser.add_argument("-s", "--start", type=int, help="Start time in seconds")
parser.add_argument("-e", "--end", type=int, help="End time in seconds")
parser.add_argument("-o", "--output", default="output.mp4", help="Output file path")
parser.add_argument("-d", "--data-dir", help="Local directory with route data")
parser.add_argument("-t", "--title", help="Title overlay text")
parser.add_argument("-f", "--file-size", type=float, default=9.0, help="Target file size in MB")
parser.add_argument("-x", "--speed", type=int, default=1, help="Speed multiplier")
parser.add_argument("--demo", action="store_true", help="Use demo route with default timing")
ui_group = parser.add_mutually_exclusive_group()
ui_group.add_argument("--big", dest="big", action="store_true", default=None, help="Force big UI (2160x1080)")
ui_group.add_argument("--mici", dest="big", action="store_false", help="Force mici UI (536x240)")
parser.add_argument("--qcam", action="store_true", help="Use qcamera instead of fcamera")
parser.add_argument("--windowed", action="store_true", help="Show window")
parser.add_argument("--no-metadata", action="store_true", help="Disable metadata overlay")
parser.add_argument("--no-time-overlay", action="store_true", help="Disable time overlay")
args = parser.parse_args()
if args.demo:
args.route, args.start, args.end = args.route or DEMO_ROUTE, args.start or DEMO_START, args.end or DEMO_END
elif not args.route:
parser.error("route is required (or use --demo)")
if args.route and args.route.count('/') == 3:
parts = args.route.split('/')
args.route, args.start, args.end = '/'.join(parts[:2]), args.start or int(parts[2]), args.end or int(parts[3])
if args.start is None or args.end is None:
parser.error("--start and --end are required")
if args.end <= args.start:
parser.error(f"end ({args.end}) must be greater than start ({args.start})")
return args
def setup_env(output_path: str, big: bool = False, speed: int = 1, target_mb: float = 0, duration: int = 0):
os.environ.update({"RECORD": "1", "OFFSCREEN": "1", "RECORD_OUTPUT": str(Path(output_path).with_suffix(".mp4"))})
if speed > 1:
os.environ["RECORD_SPEED"] = str(speed)
if target_mb > 0 and duration > 0:
os.environ["RECORD_BITRATE"] = f"{int(target_mb * 8 * 1024 / (duration / speed))}k"
if big:
os.environ["BIG"] = "1"
else:
os.environ["BIG"] = "0"
def _download_segment(path: str) -> bytes:
with FileReader(path) as f:
return bytes(f.read())
def _parse_and_chunk_segment(args: tuple) -> list[dict]:
raw_data, fps = args
from iqpilot.tools.lib.logreader import _LogFileReader
messages = migrate_all(list(_LogFileReader("", dat=raw_data, sort_by_time=True)))
if not messages:
return []
dt_ns, chunks, current, next_time = 1e9 / fps, [], {}, messages[0].logMonoTime + 1e9 / fps # type: ignore[var-annotated]
for msg in messages:
if msg.logMonoTime >= next_time:
chunks.append(current)
current, next_time = {}, next_time + dt_ns * ((msg.logMonoTime - next_time) // dt_ns + 1)
current[msg.which()] = msg
return chunks + [current] if current else chunks
def load_logs_parallel(log_paths: list[str], fps: int = 20) -> list[dict]:
num_workers = min(16, len(log_paths), (multiprocessing.cpu_count() or 1))
logger.info(f"Downloading {len(log_paths)} segments with {num_workers} workers...")
with ThreadPoolExecutor(max_workers=num_workers) as pool:
futures = {pool.submit(_download_segment, path): idx for idx, path in enumerate(log_paths)}
raw_data = {futures[f]: f.result() for f in as_completed(futures)}
logger.info("Parsing and chunking segments...")
with multiprocessing.Pool(num_workers) as pool:
return list(itertools.chain.from_iterable(pool.map(_parse_and_chunk_segment, [(raw_data[i], fps) for i in range(len(log_paths))])))
def patch_submaster(message_chunks, ui_state):
# Reset started_frame so alerts render correctly (recv_frame must be >= started_frame)
ui_state.started_frame = 0
ui_state.started_time = time.monotonic()
def mock_update(timeout=None):
sm, t = ui_state.sm, time.monotonic()
sm.updated = dict.fromkeys(sm.services, False)
if sm.frame < len(message_chunks):
for svc, msg in message_chunks[sm.frame].items():
if svc in sm.data:
sm.seen[svc] = sm.updated[svc] = sm.alive[svc] = sm.valid[svc] = True
sm.data[svc] = getattr(msg.as_builder(), svc)
sm.logMonoTime[svc], sm.recv_time[svc], sm.recv_frame[svc] = msg.logMonoTime, t, sm.frame
sm.frame += 1
ui_state.sm.update = mock_update
def get_frame_dimensions(camera_path: str) -> tuple[int, int]:
"""Get frame dimensions from a video file using ffprobe."""
probe = ffprobe(camera_path)
stream = probe["streams"][0]
return stream["width"], stream["height"]
def iter_segment_frames(camera_paths, start_time, end_time, fps=20, use_qcam=False,
frame_size: tuple[int, int] | None = None, on_segment_open=None):
frames_per_seg = fps * 60
start_frame, end_frame = int(start_time * fps), int(end_time * fps)
current_seg: int = -1
seg_frames: FrameReader | np.ndarray | None = None
for global_idx in range(start_frame, end_frame):
seg_idx, local_idx = global_idx // frames_per_seg, global_idx % frames_per_seg
if seg_idx != current_seg:
current_seg = seg_idx
path = camera_paths[seg_idx] if seg_idx < len(camera_paths) else None
if not path:
raise RuntimeError(f"No camera file for segment {seg_idx}")
if on_segment_open is not None:
on_segment_open(seg_idx, path)
if use_qcam:
w, h = frame_size or get_frame_dimensions(path)
with FileReader(path) as f:
result = subprocess.run(["ffmpeg", "-v", "quiet", "-i", "-", "-f", "rawvideo", "-pix_fmt", "nv12", "-"],
input=f.read(), capture_output=True)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {result.stderr.decode()}")
seg_frames = np.frombuffer(result.stdout, dtype=np.uint8).reshape(-1, w * h * 3 // 2)
else:
seg_frames = FrameReader(path, pix_fmt="nv12")
assert seg_frames is not None
frame = seg_frames[local_idx] if use_qcam else seg_frames.get(local_idx) # type: ignore[index, union-attr]
yield global_idx, frame
class FrameQueue:
def __init__(self, camera_paths, start_time, end_time, fps=20, prefetch_count=60, use_qcam=False):
# Probe first valid camera file for dimensions
first_path = next((p for p in camera_paths if p), None)
if not first_path:
raise RuntimeError("No valid camera paths")
self.frame_w, self.frame_h = get_frame_dimensions(first_path)
self._queue, self._stop, self._error = queue.Queue(maxsize=prefetch_count), threading.Event(), None
self._use_qcam = use_qcam
self._current_seg_idx = None
self._current_path = first_path
self._state_lock = threading.Lock()
self._thread = threading.Thread(target=self._worker,
args=(camera_paths, start_time, end_time, fps, use_qcam, (self.frame_w, self.frame_h)), daemon=True)
self._thread.start()
def _set_current_source(self, seg_idx, path):
with self._state_lock:
self._current_seg_idx = seg_idx
self._current_path = path
def _worker(self, camera_paths, start_time, end_time, fps, use_qcam, frame_size):
try:
for idx, data in iter_segment_frames(camera_paths, start_time, end_time, fps, use_qcam, frame_size, self._set_current_source):
if self._stop.is_set():
break
self._queue.put((idx, data.tobytes()))
except Exception as e:
logger.exception("Decode error")
self._error = e
finally:
self._queue.put(None)
def get(self, timeout=60.0):
deadline = time.monotonic() + timeout
while True:
if self._error:
raise self._error
remaining = max(0.0, deadline - time.monotonic())
if remaining == 0.0:
break
try:
result = self._queue.get(timeout=min(0.5, remaining))
except queue.Empty:
continue
if result is None:
if self._error:
raise self._error
raise StopIteration("No more frames")
return result
if self._error:
raise self._error
with self._state_lock:
seg_idx = self._current_seg_idx
path = self._current_path
camera_kind = "qcamera" if self._use_qcam else "fcamera"
source = f"segment {seg_idx}" if seg_idx is not None else "the initial segment"
if path:
source = f"{source} ({path})"
hint = ""
if path and path.startswith(("http://", "https://")):
hint = " Try downloading the route locally with --data-dir or verify the remote camera endpoint supports timely range reads."
raise TimeoutError(f"Timed out after {timeout:.0f}s waiting for {camera_kind} frames from {source}; camera fetch or decode is stalled.{hint}")
def stop(self):
self._stop.set()
while not self._queue.empty():
try:
self._queue.get_nowait()
except queue.Empty:
break
self._thread.join(timeout=2.0)
def load_route_metadata(route):
from iqpilot.common.params import Params, UnknownKeyName
lr = LogReader(route.log_paths()[0])
init_data, car_params = lr.first('initData'), lr.first('carParams')
params = Params()
for entry in init_data.params.entries:
try:
value = params.cpp2python(entry.key, entry.value)
if value is None:
logger.warning("Skipping malformed route param %s while loading clip metadata", entry.key)
continue
params.put(entry.key, value)
except UnknownKeyName:
pass
except TypeError:
logger.warning("Skipping route param %s due to type mismatch while loading clip metadata", entry.key)
origin = init_data.gitRemote.split('/')[3] if len(init_data.gitRemote.split('/')) > 3 else 'unknown'
return {
'version': init_data.version, 'route': route.name.canonical_name,
'car': car_params.carFingerprint if car_params else 'unknown', 'origin': origin,
'commit': init_data.gitCommit[:7],
}
def detect_big_ui(route: Route) -> bool:
try:
init_data = LogReader(route.log_paths()[0]).first('initData')
git_branch = (init_data.gitBranch or "").lower()
device_type = str(init_data.deviceType).lower()
if device_type in ("mici", "tizi", "tici"):
big = device_type != "mici"
reason = f"route device type {device_type}"
elif "mici" in git_branch:
big = False
reason = f"route branch {git_branch}"
else:
big = True
reason = f"route branch {git_branch or 'unknown'}"
logger.info("Auto-detected %s UI from %s", "big" if big else "mici", reason)
return big
except Exception:
logger.warning("Falling back to big UI; failed to auto-detect UI mode", exc_info=True)
return True
def draw_text_box(rl, text, x, y, size, gui_app, font, font_scale, color=None, center=False):
box_color, text_color = rl.Color(0, 0, 0, 85), color or rl.WHITE
# measure_text_ex is NOT auto-scaled, so multiply by font_scale
# draw_text_ex IS auto-scaled, so pass size directly
text_size = rl.measure_text_ex(font, text, size * font_scale, 0)
text_width, text_height = int(text_size.x), int(text_size.y)
if center:
x = (gui_app.width - text_width) // 2
rl.draw_rectangle(x - 8, y - 4, text_width + 16, text_height + 8, box_color)
rl.draw_text_ex(font, text, rl.Vector2(x, y), size, 0, text_color)
def render_overlays(rl, gui_app, font, font_scale, metadata, title, start_time, frame_idx, show_metadata, show_time):
if show_metadata and metadata and frame_idx < FRAMERATE * 5:
m = metadata
text = ", ".join([f"IQ.Pilot v{m['version']}", f"route: {m['route']}", f"car: {m['car']}", f"origin: {m['origin']}",
f"commit: {m['commit']}"])
# Truncate if too wide (leave 20px margin on each side)
max_width = gui_app.width - 40
while rl.measure_text_ex(font, text, 15 * font_scale, 0).x > max_width and len(text) > 20:
text = text[:-4] + "..."
draw_text_box(rl, text, 0, 8, 15, gui_app, font, font_scale, center=True)
if title:
draw_text_box(rl, title, 0, 60, 32, gui_app, font, font_scale, center=True)
if show_time:
t = start_time + frame_idx / FRAMERATE
time_text = f"{int(t)//60:02d}:{int(t)%60:02d}"
time_width = int(rl.measure_text_ex(font, time_text, 24 * font_scale, 0).x)
draw_text_box(rl, time_text, gui_app.width - time_width - 45, 45, 24, gui_app, font, font_scale)
def prefetch_nav_tiles(road_view, ui_state, message_chunks) -> None:
"""If the route drove with on-screen maps enabled, feed the panel the first messages and block
until the opening viewport's tiles are fetched, so the clip doesn't start on the placeholder grid."""
nav_panel = getattr(getattr(road_view, "_hud_renderer", None), "nav_map_panel", None)
if nav_panel is None or not getattr(nav_panel, "maps_enabled", lambda: False)():
return
logger.info("Route has on-screen maps enabled, prefetching map tiles...")
for _ in range(min(len(message_chunks), FRAMERATE * 2)):
ui_state.sm.update()
nav_panel.update()
if nav_panel.active:
break
if nav_panel.active:
if not nav_panel.warm_up_tiles():
logger.warning("Map tiles incomplete after warmup (missing MapboxToken or slow network); map may render partially")
else:
logger.warning("No nav position in the first seconds of the clip; map tiles will load mid-clip")
ui_state.sm.frame = 0
def clip(route: Route, output: str, start: int, end: int, headless: bool = True, big: bool = False,
title: str | None = None, show_metadata: bool = True, show_time: bool = True, use_qcam: bool = False):
timer, duration = Timer(), end - start
# The prefix must wrap the UI imports: ui_state and the nav map panel bind Params() to the
# params path active when they're constructed, and load_route_metadata below seeds the route's
# params (on-screen maps gate, units, Mapbox token) into the prefixed dir.
with OpenpilotPrefix(shared_download_cache=True):
import pyray as rl
if big:
from iqpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView
else:
from iqpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView # type: ignore[assignment]
from iqpilot.selfdrive.ui.ui_state import ui_state
from iqpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
timer.lap("import")
logger.info(f"Clipping {route.name.canonical_name}, {start}s-{end}s ({duration}s)")
seg_start, seg_end = start // 60, (end - 1) // 60 + 1
all_chunks = load_logs_parallel(route.log_paths()[seg_start:seg_end], fps=FRAMERATE)
timer.lap("logs")
frame_start = (start - seg_start * 60) * FRAMERATE
message_chunks = all_chunks[frame_start:frame_start + duration * FRAMERATE]
if not message_chunks:
logger.error("No messages to render")
sys.exit(1)
metadata = load_route_metadata(route)
if not show_metadata:
metadata = None
if headless:
rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN)
camera_paths = route.qcamera_paths() if use_qcam else route.camera_paths()
frame_queue = FrameQueue(camera_paths, start, end, fps=FRAMERATE, use_qcam=use_qcam)
ecamera_paths = route.ecamera_paths() if not use_qcam else []
wide_frame_queue: FrameQueue | None = None
if any(p for p in ecamera_paths[seg_start:seg_end] if p):
wide_frame_queue = FrameQueue(ecamera_paths, start, end, fps=FRAMERATE)
vipc = VisionIpcServer("camerad")
vipc.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 4, frame_queue.frame_w, frame_queue.frame_h)
if wide_frame_queue:
vipc.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 4, wide_frame_queue.frame_w, wide_frame_queue.frame_h)
vipc.start_listener()
patch_submaster(message_chunks, ui_state)
gui_app.init_window("clip", fps=FRAMERATE)
road_view = AugmentedRoadView()
road_view.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
font = gui_app.font(FontWeight.NORMAL)
prefetch_nav_tiles(road_view, ui_state, message_chunks)
timer.lap("setup")
frame_idx = 0
with tqdm.tqdm(total=len(message_chunks), desc="Rendering", unit="frame") as pbar:
for should_render in gui_app.render():
if frame_idx >= len(message_chunks):
break
_, frame_bytes = frame_queue.get()
vipc.send(VisionStreamType.VISION_STREAM_ROAD, frame_bytes, frame_idx, int(frame_idx * 5e7), int(frame_idx * 5e7))
if wide_frame_queue:
_, wide_bytes = wide_frame_queue.get()
vipc.send(VisionStreamType.VISION_STREAM_WIDE_ROAD, wide_bytes, frame_idx, int(frame_idx * 5e7), int(frame_idx * 5e7))
ui_state.update()
if should_render:
road_view.render()
render_overlays(rl, gui_app, font, FONT_SCALE, metadata, title, start, frame_idx, show_metadata, show_time)
frame_idx += 1
pbar.update(1)
timer.lap("render")
frame_queue.stop()
if wide_frame_queue:
wide_frame_queue.stop()
gui_app.close()
timer.lap("ffmpeg")
logger.info(f"Clip saved to: {Path(output).resolve()}")
logger.info(f"Generated {timer.fmt(duration)}")
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s\t%(message)s")
args = parse_args()
route = Route(args.route, data_dir=args.data_dir)
big = args.big if args.big is not None else detect_big_ui(route)
setup_env(args.output, big=big, speed=args.speed, target_mb=args.file_size, duration=args.end - args.start)
try:
clip(route, args.output, args.start, args.end, not args.windowed,
big, args.title, not args.no_metadata, not args.no_time_overlay, args.qcam)
except TimeoutError as e:
logger.error("%s", e)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
# Increase the pip timeout to handle TimeoutError
export PIP_DEFAULT_TIMEOUT=200
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
ROOT="$DIR"/../../
cd "$ROOT"
if ! command -v "uv" > /dev/null 2>&1; then
echo "installing uv..."
curl -LsSf --retry 5 --retry-delay 5 --retry-all-errors https://astral.sh/uv/install.sh | sh
UV_BIN="$HOME/.local/bin"
PATH="$UV_BIN:$PATH"
fi
echo "updating uv..."
# ok to fail, can also fail due to installing with brew
uv self update || true
echo "installing python packages..."
UV_SYNC_ARGS=(--frozen)
if [[ "${IQPILOT_RUNTIME_DEPENDENCIES_ONLY:-0}" != "1" ]]; then
UV_SYNC_ARGS+=(--all-extras)
fi
UV_SYNC_OK=0
for attempt in 1 2 3; do
if uv sync "${UV_SYNC_ARGS[@]}"; then
UV_SYNC_OK=1
break
fi
[[ "${attempt}" -lt 3 ]] && sleep "$((attempt * 5))"
done
if [[ "${UV_SYNC_OK}" -ne 1 ]]; then
exit 1
fi
source .venv/bin/activate
if [[ "$(uname)" == 'Darwin' ]]; then
touch "$ROOT"/.env
echo "export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES" >> "$ROOT"/.env
fi

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env bash
set -e
SUDO=""
# Use sudo if not root
if [[ ! $(id -u) -eq 0 ]]; then
if [[ -z $(which sudo) ]]; then
echo "Please install sudo or run as root"
exit 1
fi
SUDO="sudo"
fi
# Check if stdin is open
if [ -t 0 ]; then
INTERACTIVE=1
fi
# Install common packages
function install_ubuntu_common_requirements() {
$SUDO apt-get update
# normal stuff, mostly for the bare docker image
$SUDO apt-get install -y --no-install-recommends \
ca-certificates \
clang \
build-essential \
curl \
libssl-dev \
libcurl4-openssl-dev \
locales \
git \
git-lfs \
xvfb
# TODO: vendor the rest of these in third_party/
$SUDO apt-get install -y --no-install-recommends \
gcc-arm-none-eabi \
capnproto \
libcapnp-dev \
libdrm-dev \
ffmpeg \
libavformat-dev \
libavcodec-dev \
libavdevice-dev \
libavutil-dev \
libavfilter-dev \
libbz2-dev \
libeigen3-dev \
libffi-dev \
libgles2-mesa-dev \
libglfw3-dev \
libglib2.0-0 \
libjpeg-dev \
libncurses5-dev \
libusb-1.0-0-dev \
libva-dev \
libx264-dev \
libzmq3-dev \
libzstd-dev \
libsqlite3-dev \
opencl-headers \
ocl-icd-libopencl1 \
ocl-icd-opencl-dev \
portaudio19-dev \
gettext
}
# Install Ubuntu 24.04 LTS packages
function install_ubuntu_lts_latest_requirements() {
install_ubuntu_common_requirements
$SUDO apt-get install -y --no-install-recommends \
g++-12 \
python3-dev \
python3-venv
}
# Detect OS using /etc/os-release file
if [ -f "/etc/os-release" ]; then
source /etc/os-release
case "$VERSION_CODENAME" in
"jammy" | "kinetic" | "noble")
install_ubuntu_lts_latest_requirements
;;
*)
echo "$ID $VERSION_ID is unsupported. This setup script is written for Ubuntu 24.04."
read -p "Would you like to attempt installation anyway? " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
install_ubuntu_lts_latest_requirements
esac
if [[ -d "/etc/udev/rules.d/" ]]; then
# Setup jungle udev rules
$SUDO tee /etc/udev/rules.d/12-panda_jungle.rules > /dev/null <<EOF
SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddcf", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddef", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="bbaa", ATTRS{idProduct}=="ddcf", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="bbaa", ATTRS{idProduct}=="ddef", MODE="0666"
EOF
# Setup panda udev rules
$SUDO tee /etc/udev/rules.d/11-panda.rules > /dev/null <<EOF
SUBSYSTEM=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="df11", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddcc", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddee", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="bbaa", ATTRS{idProduct}=="ddcc", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="bbaa", ATTRS{idProduct}=="ddee", MODE="0666"
EOF
# Setup adb udev rules
$SUDO tee /etc/udev/rules.d/50-comma-adb.rules > /dev/null <<EOF
SUBSYSTEM=="usb", ATTR{idVendor}=="04d8", ATTR{idProduct}=="1234", ENV{adb_user}="yes"
EOF
$SUDO udevadm control --reload-rules && $SUDO udevadm trigger || true
fi
else
echo "No /etc/os-release in the system. Make sure you're running on Ubuntu, or similar."
exit 1
fi

328
iqpilot/tools/iq.sh Executable file
View File

@@ -0,0 +1,328 @@
#!/usr/bin/env bash
# Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
set -euo pipefail
IQ_RESET='\033[0m'
IQ_BOLD='\033[1m'
IQ_MUTED='\033[38;5;245m'
IQ_RED='\033[38;5;204m'
IQ_GREEN='\033[38;5;120m'
IQ_CYAN='\033[38;2;38;145;184m'
IQ_BLUE='\033[38;2;51;112;176m'
IQ_PURPLE='\033[38;5;141m'
IQ_PINK='\033[38;5;211m'
IQ_ROOT=''
IQ_DRY=0
IQ_NO_VERIFY=0
IQ_LOG_FILE=''
IQ_REBOOT=0
if [[ ! -t 1 || "${NO_COLOR:-}" != '' ]]; then
IQ_RESET='' IQ_BOLD='' IQ_MUTED='' IQ_RED='' IQ_GREEN='' IQ_CYAN='' IQ_BLUE='' IQ_PURPLE='' IQ_PINK=''
fi
iq_line() {
printf '%b%s%b\n' "$IQ_CYAN" '━━━━━━━━━━━━' "$IQ_RESET"
}
iq_title() {
printf '%bI%b%bQ%b%b.%b%bP%b%bi%b%bl%b%bo%b%bt%b %b%s%b\n' "$IQ_CYAN" "$IQ_RESET" "$IQ_BLUE" "$IQ_RESET" "$IQ_PURPLE" "$IQ_RESET" "$IQ_PINK" "$IQ_RESET" "$IQ_PURPLE" "$IQ_RESET" "$IQ_BLUE" "$IQ_RESET" "$IQ_CYAN" "$IQ_RESET" "$IQ_CYAN" "$IQ_RESET" "$IQ_MUTED" "$1" "$IQ_RESET"
}
iq_ok() {
printf ' %b●%b %s\n' "$IQ_GREEN" "$IQ_RESET" "$1"
}
iq_fail() {
printf ' %b●%b %s\n' "$IQ_RED" "$IQ_RESET" "$1" >&2
}
iq_note() {
printf ' %b●%b %s\n' "$IQ_CYAN" "$IQ_RESET" "$1"
}
iq_find_root() {
local candidate="${IQ_ROOT:-$PWD}"
while [[ "$candidate" != / ]]; do
if [[ ( -f "$candidate/launch_iqpilot.sh" || -f "$candidate/launch_openpilot.sh" ) && -d "$candidate/iqpilot" ]]; then
IQ_ROOT="$candidate"
return 0
fi
candidate="$(cd "$candidate/.." && pwd)"
done
for candidate in "$HOME/iqpilot" "$HOME/openpilot" /data/iqpilot /data/openpilot; do
if [[ ( -f "$candidate/launch_iqpilot.sh" || -f "$candidate/launch_openpilot.sh" ) && -d "$candidate/iqpilot" ]]; then
IQ_ROOT="$candidate"
return 0
fi
done
return 1
}
iq_require_root() {
if ! iq_find_root; then
iq_fail 'IQ.Pilot checkout not found. Run this inside the checkout or use --dir PATH.'
return 1
fi
}
iq_run() {
local rendered
printf -v rendered '%q ' "$@"
rendered="${rendered% }"
printf '%b%b %b%s%b\n' "$IQ_PINK" "$IQ_RESET" "$IQ_MUTED" "$rendered" "$IQ_RESET"
[[ "$IQ_DRY" = 1 ]] || "$@"
}
iq_check() {
iq_title 'environment check'
iq_require_root
iq_ok "checkout $IQ_ROOT"
command -v git >/dev/null 2>&1 || { iq_fail 'git is not installed'; return 1; }
iq_ok "git $(git --version | sed 's/git version //')"
command -v python3 >/dev/null 2>&1 || { iq_fail 'python3 is not installed'; return 1; }
iq_ok "python $(python3 --version | sed 's/Python //')"
if [[ -x "$IQ_ROOT/.venv/bin/python3" ]]; then
iq_ok 'venv ready'
else
iq_note 'venv not created yet — run iq setup'
fi
}
iq_install() {
local shell_name rc_file iq_script command
shell_name="$(basename "${SHELL:-bash}")"
rc_file="$HOME/.${shell_name}rc"
[[ "$(uname)" = Darwin && "$shell_name" = bash ]] && rc_file="$HOME/.bash_profile"
iq_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/iq.sh"
command="alias iq='${iq_script} \"\$@\"'"
touch "$rc_file"
grep -Fqx "$command" "$rc_file" 2>/dev/null || printf '\n%s\n' "$command" >> "$rc_file"
iq_title 'command installed'
iq_ok "restart your shell, then use iq ${rc_file}"
}
iq_setup() {
local script
iq_require_root
if [[ -f /AGNOS ]]; then
iq_title 'setup'
iq_note 'IQ.OS manages system dependencies and the base Python runtime'
iq_note 'use iq pkg to synchronize private packages when needed'
iq_check
return 0
fi
case "$OSTYPE" in
linux-gnu*) script="$IQ_ROOT/iqpilot/tools/ubuntu_setup.sh" ;;
darwin*) script="$IQ_ROOT/iqpilot/tools/mac_setup.sh" ;;
*) iq_fail "unsupported platform: $OSTYPE"; return 1 ;;
esac
iq_title 'setup'
iq_run "$script"
if command -v git-lfs >/dev/null 2>&1; then
iq_run git -C "$IQ_ROOT" lfs pull
fi
iq_ok 'setup complete'
}
iq_venv() {
iq_require_root
[[ -f "$IQ_ROOT/.venv/bin/activate" ]] || { iq_fail 'venv not found — run iq setup first'; return 1; }
case "$(basename "${SHELL:-bash}")" in
zsh) ZDOTDIR="$(mktemp -d)"; printf 'source %q\nsource %q\n' "$HOME/.zshrc" "$IQ_ROOT/.venv/bin/activate" > "$ZDOTDIR/.zshrc"; zsh ;;
*) bash --rcfile <(printf 'source %q\nsource %q\n' "$HOME/.bashrc" "$IQ_ROOT/.venv/bin/activate") ;;
esac
}
iq_build() {
iq_require_root
if [[ -f /AGNOS ]]; then
iq_run "$IQ_ROOT/iqpilot/system/manager/build.py"
else
(cd "$IQ_ROOT" && iq_run scons "$@")
fi
}
iq_quality() {
iq_require_root
(cd "$IQ_ROOT" && iq_run scripts/lint/lint.sh "$@")
}
iq_desktop_tool() {
local name="$1" launcher="$2"
shift 2
iq_require_root
if [[ ! -x "$IQ_ROOT/$launcher" ]]; then
if [[ -f /AGNOS ]]; then
iq_note "$name is not included in IQ.OS checkouts"
return 0
fi
iq_fail "$name launcher is missing: $IQ_ROOT/$launcher"
return 1
fi
iq_title "opening $name"
(cd "$IQ_ROOT" && iq_run "$IQ_ROOT/$launcher" "$@")
}
iq_cabana() {
iq_desktop_tool 'Cabana' 'iqpilot/tools/cabana/cabana' "$@"
}
iq_juggle() {
iq_desktop_tool 'Jotpluggler' 'iqpilot/tools/jotpluggler/pluggle.py' "$@"
}
iq_pkg() {
iq_require_root
# the venv python has the component packages (iqdbc etc.); bare python3 does not
local py=python3
[[ -x "$IQ_ROOT/.venv/bin/python3" ]] && py="$IQ_ROOT/.venv/bin/python3"
if [[ -f "$IQ_ROOT/iqpilot/tools/scripts/setup_private_packages.py" ]]; then
(cd "$IQ_ROOT" && iq_run "$py" iqpilot/tools/scripts/setup_private_packages.py "$@")
else
iq_note 'private package sources are not present in this checkout'
fi
if [[ -f "$IQ_ROOT/artifacts/runtime/ensure_private_installed.sh" ]]; then
(cd "$IQ_ROOT" && iq_run bash artifacts/runtime/ensure_private_installed.sh)
fi
}
iq_update() {
local fast=0
iq_require_root
if [[ $# -gt 0 ]]; then
[[ $# = 1 && "$1" = f ]] || { iq_fail 'usage: iq update [f] [r]'; return 1; }
fast=1
fi
iq_title 'updating IQ.Pilot'
iq_run git -C "$IQ_ROOT" pull
iq_pkg
[[ "$fast" = 1 ]] && iq_fast_restart
}
iq_status() {
local branch commit tree
iq_require_root
branch="$(git -C "$IQ_ROOT" branch --show-current 2>/dev/null || printf detached)"
commit="$(git -C "$IQ_ROOT" rev-parse --short HEAD 2>/dev/null || printf unknown)"
tree=clean
[[ -n "$(git -C "$IQ_ROOT" status --porcelain 2>/dev/null)" ]] && tree=modified
iq_title 'status'
iq_note "root $IQ_ROOT"
iq_note "branch $branch"
iq_note "commit $commit"
[[ "$tree" = clean ]] && iq_ok "tree $tree" || iq_note "tree $tree"
}
iq_switch() {
local remote=origin branch
iq_require_root
[[ $# -ge 1 ]] || { iq_fail 'usage: iq switch [REMOTE] BRANCH'; return 1; }
[[ $# -ge 2 ]] && { remote="$1"; shift; }
branch="$1"
iq_title "switching to $remote/$branch"
iq_note 'this discards uncommitted changes and untracked files'
iq_run git -C "$IQ_ROOT" fetch "$remote" "$branch:refs/remotes/$remote/$branch"
iq_run git -C "$IQ_ROOT" checkout -B "$branch" --track "$remote/$branch"
iq_run git -C "$IQ_ROOT" reset --hard "$remote/$branch"
iq_run git -C "$IQ_ROOT" clean -df
}
iq_service() {
local action="$1"
[[ -f /AGNOS ]] || { iq_note "${action} is available on IQ.OS devices only"; return 0; }
iq_run sudo systemctl "$action" iq
}
iq_wait_for_tmux() {
local expected="$1" attempt active
[[ "$IQ_DRY" = 1 ]] && { iq_note "would verify IQ tmux session ${expected}"; return 0; }
for ((attempt = 0; attempt < 15; attempt++)); do
if tmux has-session -t iq 2>/dev/null; then active=running; else active=stopped; fi
[[ "$active" = "$expected" ]] && return 0
sleep 1
done
return 1
}
iq_fast_restart() {
[[ -f /AGNOS || "$IQ_DRY" = 1 ]] || { iq_fail 'fast update restart is available on IQ.OS devices only'; return 1; }
iq_title 'fast restarting IQ.Pilot'
iq_run sudo systemctl stop iq
if ! iq_wait_for_tmux stopped; then
iq_fail 'IQ tmux session did not stop; attempt a reboot.'
return 1
fi
iq_run sudo systemctl start iq
if ! iq_wait_for_tmux running; then
iq_fail 'IQ tmux session did not start; attempt a reboot.'
return 1
fi
iq_ok 'IQ.Pilot restarted'
}
iq_help() {
iq_title 'command center'
printf '%bUsage%b iq [--dir PATH] [--dry] COMMAND [ARGS] [r]\n\n' "$IQ_BOLD" "$IQ_RESET"
printf '%bCOMMAND%b\n' "$IQ_PURPLE" "$IQ_RESET"
printf ' %bsetup%b install development dependencies\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bcheck%b verify checkout, Git, Python, and venv\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bbuild%b build IQ.Pilot\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bquality%b run code-quality checks\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bcabana%b open the CAN analysis tool\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bjuggle%b open the log plotting tool\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bpkg%b authenticate and synchronize private packages\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bupdate%b pull IQ.Pilot, synchronize packages, optionally fast restart\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bstatus%b show checkout, branch, commit, and tree state\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bswitch%b replace the checkout with another branch\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bvenv%b open a shell with the project venv active\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bstart%b start IQ.Pilot on IQ.OS\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %bstop%b stop IQ.Pilot on IQ.OS\n' "$IQ_CYAN" "$IQ_RESET"
printf ' %binstall%b install the iq shell command\n' "$IQ_CYAN" "$IQ_RESET"
printf '\n %br%b reboot after a successful command\n' "$IQ_CYAN" "$IQ_RESET"
printf '\n%bExamples%b\n' "$IQ_PURPLE" "$IQ_RESET"
printf ' iq setup iq update iq update f iq update f r\n'
iq_line
}
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--dir) [[ $# -ge 2 ]] || { iq_fail 'missing path after --dir'; exit 2; }; IQ_ROOT="$2"; shift 2 ;;
--dry) IQ_DRY=1; shift ;;
-n|--no-verify) IQ_NO_VERIFY=1; shift ;;
-l|--log) [[ $# -ge 2 ]] || { iq_fail 'missing file after --log'; exit 2; }; IQ_LOG_FILE="$2"; shift 2 ;;
-h|--help|help) iq_help; exit 0 ;;
*) break ;;
esac
done
command="${1:-help}"
[[ $# -gt 0 ]] && shift
if [[ $# -gt 0 && "${!#}" = r ]]; then
IQ_REBOOT=1
set -- "${@:1:$#-1}"
fi
case "$command" in
help) iq_help ;;
setup) iq_setup "$@" ;;
check) iq_check "$@" ;;
build) iq_build "$@" ;;
quality) iq_quality "$@" ;;
cabana) iq_cabana "$@" ;;
juggle) iq_juggle "$@" ;;
pkg) iq_pkg "$@" ;;
update) iq_update "$@" ;;
status) iq_status "$@" ;;
switch) iq_switch "$@" ;;
venv) iq_venv "$@" ;;
start) iq_service start "$@" ;;
stop) iq_service stop "$@" ;;
install) iq_install "$@" ;;
*) iq_fail "unknown command: $command"; iq_help; exit 2 ;;
esac
if [[ "$IQ_REBOOT" = 1 ]]; then
iq_title 'restarting IQ.OS'
iq_run sudo reboot
fi

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
import argparse
from tqdm import tqdm
from iqpilot.cereal.services import SERVICE_LIST, QueueSize
from iqpilot.tools.lib.logreader import LogReader
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Analyze message sizes from a log route")
parser.add_argument("route", nargs="?", default="98395b7c5b27882e/000000a8--f87e7cd255",
help="Log route to analyze (default: 98395b7c5b27882e/000000a8--f87e7cd255)")
args = parser.parse_args()
lr = LogReader(args.route)
szs = {}
for msg in tqdm(lr):
sz = len(msg.as_builder().to_bytes())
msg_type = msg.which()
if msg_type not in szs:
szs[msg_type] = {'min': sz, 'max': sz, 'sum': sz, 'count': 1}
else:
szs[msg_type]['min'] = min(szs[msg_type]['min'], sz)
szs[msg_type]['max'] = max(szs[msg_type]['max'], sz)
szs[msg_type]['sum'] += sz
szs[msg_type]['count'] += 1
print()
print(f"{'Service':<36} {'Min (KB)':>12} {'Max (KB)':>12} {'Avg (KB)':>12} {'KB/min':>12} {'KB/sec':>12} {'Minutes in 10MB':>18} {'Seconds in Queue':>18}")
print("-" * 132)
def sort_key(x):
k, v = x
avg = v['sum'] / v['count']
freq = SERVICE_LIST.get(k, None)
freq_val = freq.frequency if freq else 0.0
kb_per_min = (avg * freq_val * 60) / 1024 if freq_val > 0 else 0.0
return kb_per_min
total_kb_per_min = 0.0
RINGBUFFER_SIZE_KB = 10 * 1024 # 10MB old default
for k, v in sorted(szs.items(), key=sort_key, reverse=True):
avg = v['sum'] / v['count']
service = SERVICE_LIST.get(k, None)
freq_val = service.frequency if service else 0.0
queue_size_kb = (service.queue_size / 1024) if service else 250 # default to SMALL
kb_per_min = (avg * freq_val * 60) / 1024 if freq_val > 0 else 0.0
kb_per_sec = kb_per_min / 60
minutes_in_buffer = RINGBUFFER_SIZE_KB / kb_per_min if kb_per_min > 0 else float('inf')
seconds_in_queue = (queue_size_kb / kb_per_sec) if kb_per_sec > 0 else float('inf')
total_kb_per_min += kb_per_min
min_str = f"{minutes_in_buffer:.2f}" if minutes_in_buffer != float('inf') else "inf"
sec_queue_str = f"{seconds_in_queue:.2f}" if seconds_in_queue != float('inf') else "inf"
print(f"{k:<36} {v['min']/1024:>12.2f} {v['max']/1024:>12.2f} {avg/1024:>12.2f} {kb_per_min:>12.2f} {kb_per_sec:>12.2f} {min_str:>18} {sec_queue_str:>18}")
# Summary section
print()
print(f"Total usage: {total_kb_per_min / 1024:.2f} MB/min")
# Calculate memory usage: old (10MB for all) vs new (from services.py)
OLD_SIZE = 10 * 1024 * 1024 # 10MB was the old default
old_total = len(SERVICE_LIST) * OLD_SIZE
new_total = sum(s.queue_size for s in SERVICE_LIST.values())
# Count by queue size
size_counts = {QueueSize.BIG: 0, QueueSize.MEDIUM: 0, QueueSize.SMALL: 0}
for s in SERVICE_LIST.values():
size_counts[s.queue_size] += 1
savings_pct = (1 - new_total / old_total) * 100
print()
print(f"{'Queue Size Comparison':<40}")
print("-" * 60)
print(f"{'Old (10MB default):':<30} {old_total / 1024 / 1024:>10.2f} MB")
print(f"{'New (from services.py):':<30} {new_total / 1024 / 1024:>10.2f} MB")
print(f"{'Savings:':<30} {savings_pct:>10.1f}%")
print()
print(f"{'Breakdown:':<30}")
print(f" BIG (10MB): {size_counts[QueueSize.BIG]:>3} services")
print(f" MEDIUM (2MB): {size_counts[QueueSize.MEDIUM]:>3} services")
print(f" SMALL (250KB): {size_counts[QueueSize.SMALL]:>3} services")

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import numpy as np
import time
from tqdm import tqdm
from iqpilot.cereal import car
from iqdbc.car.tests.routes import CarTestRoute
from iqpilot.selfdrive.car.tests.test_models import TestCarModelBase
N_RUNS = 10
DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19"
class CarModelTestCase(TestCarModelBase):
test_route = CarTestRoute(DEMO_ROUTE, None)
if __name__ == '__main__':
# Get CAN messages and parsers
tm = CarModelTestCase()
tm.setUpClass()
tm.setUp()
CC = car.CarControl.new_message()
ets = []
for _ in tqdm(range(N_RUNS)):
start_t = time.process_time_ns()
for msg in tm.can_msgs:
for cp in tm.CI.can_parsers.values():
if cp is not None:
cp.update_strings(msg)
ets.append((time.process_time_ns() - start_t) * 1e-6)
print(f'{len(tm.can_msgs)} CAN packets, {N_RUNS} runs')
print(f'{np.mean(ets):.2f} mean ms, {max(ets):.2f} max ms, {min(ets):.2f} min ms, {np.std(ets):.2f} std ms')
print(f'{np.mean(ets) / len(tm.can_msgs):.4f} mean ms / CAN packet')

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python3
import argparse
import numpy as np
import time
from collections import defaultdict, deque
from collections.abc import MutableSequence
import iqpilot.cereal.messaging as messaging
if __name__ == "__main__":
context = messaging.Context()
poller = messaging.Poller()
parser = argparse.ArgumentParser()
parser.add_argument("socket", type=str, nargs='*', help="socket name")
args = parser.parse_args()
socket_names = args.socket
sockets = {}
rcv_times: defaultdict[str, MutableSequence[float]] = defaultdict(lambda: deque(maxlen=100))
valids: defaultdict[str, deque[bool]] = defaultdict(lambda: deque(maxlen=100))
t = time.monotonic()
for name in socket_names:
sock = messaging.sub_sock(name, poller=poller)
sockets[sock] = name
prev_print = t
while True:
for socket in poller.poll(100):
msg = messaging.recv_one(socket)
if msg is None:
continue
name = msg.which()
t = time.monotonic()
rcv_times[name].append(msg.logMonoTime / 1e9)
valids[name].append(msg.valid)
if t - prev_print > 1:
print()
for name in socket_names:
dts = np.diff(rcv_times[name])
mean = np.mean(dts)
print(f"{name}: Freq {1.0 / mean:.2f} Hz, Min {np.min(dts) / mean * 100:.2f}%, Max {np.max(dts) / mean * 100:.2f}%, valid ", all(valids[name]))
prev_print = t

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env python3
import iqpilot.cereal.messaging as messaging
from iqpilot.cereal.services import SERVICE_LIST
TO_CHECK = ['carState']
if __name__ == "__main__":
sm = messaging.SubMaster(TO_CHECK)
prev_t: dict[str, float] = {}
while True:
sm.update()
for s in TO_CHECK:
if sm.updated[s]:
t = sm.logMonoTime[s] / 1e9
if s in prev_t:
expected = 1.0 / (SERVICE_LIST[s].frequency)
dt = t - prev_t[s]
if dt > 10 * expected:
print(t, s, dt)
prev_t[s] = t

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
import sys
import time
import numpy as np
import datetime
from collections.abc import MutableSequence
from collections import defaultdict
import iqpilot.cereal.messaging as messaging
if __name__ == "__main__":
ts: defaultdict[str, MutableSequence[float]] = defaultdict(list)
socks = {s: messaging.sub_sock(s, conflate=False) for s in sys.argv[1:]}
try:
st = time.monotonic()
while True:
print()
for s, sock in socks.items():
msgs = messaging.drain_sock(sock)
for m in msgs:
ts[s].append(m.logMonoTime / 1e6)
if len(ts[s]) > 2:
d = np.diff(ts[s])[-100:]
print(f"{s:25} {np.mean(d):7.2f} {np.std(d):7.2f} {np.max(d):7.2f} {np.min(d):7.2f}")
time.sleep(1)
except KeyboardInterrupt:
print("\n")
print("="*5, "timing summary", "="*5)
for s, sock in socks.items():
msgs = messaging.drain_sock(sock)
if len(ts[s]) > 2:
d = np.diff(ts[s])
print(f"{s:25} {np.mean(d):7.2f} {np.std(d):7.2f} {np.max(d):7.2f} {np.min(d):7.2f}")
print("="*5, datetime.timedelta(seconds=time.monotonic()-st), "="*5)

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
'''
System tools like top/htop can only show current cpu usage values, so I write this script to do statistics jobs.
Features:
Use psutil library to sample cpu usage(avergage for all cores) of openpilot processes, at a rate of 5 samples/sec.
Do cpu usage statistics periodically, 5 seconds as a cycle.
Calculate the average cpu usage within this cycle.
Calculate minumium/maximum/accumulated_average cpu usage as long term inspections.
Monitor multiple processes simuteneously.
Sample usage:
root@localhost:/data/openpilot$ python iqpilot/tools/iqperf/cpu_usage_stat.py pandad,ubloxd
('Add monitored proc:', './pandad')
('Add monitored proc:', 'python locationd/ubloxd.py')
pandad: 1.96%, min: 1.96%, max: 1.96%, acc: 1.96%
ubloxd.py: 0.39%, min: 0.39%, max: 0.39%, acc: 0.39%
'''
import psutil
import time
import os
import sys
import numpy as np
import argparse
import re
from collections import defaultdict
from iqpilot.system.manager.process_config import managed_processes
# Do statistics every 5 seconds
PRINT_INTERVAL = 5
SLEEP_INTERVAL = 0.2
monitored_proc_names = [
# android procs
'SurfaceFlinger', 'sensors.qcom'
] + list(managed_processes.keys())
cpu_time_names = ['user', 'system', 'children_user', 'children_system']
def get_arg_parser():
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("proc_names", nargs="?", default='',
help="Process names to be monitored, comma separated")
parser.add_argument("--list_all", action='store_true',
help="Show all running processes' cmdline")
parser.add_argument("--detailed_times", action='store_true',
help="show cpu time details (split by user, system, child user, child system)")
return parser
if __name__ == "__main__":
args = get_arg_parser().parse_args(sys.argv[1:])
if args.list_all:
for p in psutil.process_iter():
print('cmdline', p.cmdline(), 'name', p.name())
sys.exit(0)
if len(args.proc_names) > 0:
monitored_proc_names = args.proc_names.split(',')
monitored_procs = []
stats = {}
for p in psutil.process_iter():
if p == psutil.Process():
continue
matched = any(l for l in p.cmdline() if any(pn for pn in monitored_proc_names if re.match(fr'.*{pn}.*', l, re.M | re.I)))
if matched:
k = ' '.join(p.cmdline())
print('Add monitored proc:', k)
stats[k] = {'cpu_samples': defaultdict(list), 'min': defaultdict(lambda: None), 'max': defaultdict(lambda: None),
'avg': defaultdict(float), 'last_cpu_times': None, 'last_sys_time': None}
stats[k]['last_sys_time'] = time.monotonic()
stats[k]['last_cpu_times'] = p.cpu_times()
monitored_procs.append(p)
i = 0
interval_int = int(PRINT_INTERVAL / SLEEP_INTERVAL)
while True:
for p in monitored_procs:
k = ' '.join(p.cmdline())
cur_sys_time = time.monotonic()
cur_cpu_times = p.cpu_times()
cpu_times = np.subtract(cur_cpu_times, stats[k]['last_cpu_times']) / (cur_sys_time - stats[k]['last_sys_time'])
stats[k]['last_sys_time'] = cur_sys_time
stats[k]['last_cpu_times'] = cur_cpu_times
cpu_percent = 0
for num, name in enumerate(cpu_time_names):
stats[k]['cpu_samples'][name].append(cpu_times[num])
cpu_percent += cpu_times[num]
stats[k]['cpu_samples']['total'].append(cpu_percent)
time.sleep(SLEEP_INTERVAL)
i += 1
if i % interval_int == 0:
l = []
for k, stat in stats.items():
if len(stat['cpu_samples']) <= 0:
continue
for name, samples in stat['cpu_samples'].items():
samples = np.array(samples)
avg = samples.mean()
c = samples.size
min_cpu = np.amin(samples)
max_cpu = np.amax(samples)
if stat['min'][name] is None or min_cpu < stat['min'][name]:
stat['min'][name] = min_cpu
if stat['max'][name] is None or max_cpu > stat['max'][name]:
stat['max'][name] = max_cpu
stat['avg'][name] = (stat['avg'][name] * (i - c) + avg * c) / (i)
stat['cpu_samples'][name] = []
msg = f"avg: {stat['avg']['total']:.2%}, min: {stat['min']['total']:.2%}, max: {stat['max']['total']:.2%} {os.path.basename(k)}"
if args.detailed_times:
for stat_type in ['avg', 'min', 'max']:
msg += f"\n {stat_type}: {[(name + ':' + str(round(stat[stat_type][name] * 100, 2))) for name in cpu_time_names]}"
l.append((os.path.basename(k), stat['avg']['total'], msg))
l.sort(key=lambda x: -x[1])
for x in l:
print(x[2])
print('avg sum: {:.2%} over {} samples {} seconds\n'.format(
sum(stat['avg']['total'] for k, stat in stats.items()), i, i * SLEEP_INTERVAL
))

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""
io_stall_repro.py — bench reproduction + fix-validation rig for the VW PQ EPS
HCA fault (control loop stalling on a /data read under eMMC write saturation).
The real fault chain (proven from rlog b29ee8c5a0a735d1/000000e4--8a8ba97b54):
loggerd buffered writes saturate eMMC -> ext4 jbd2 journal commits ->
controlsd's inline Params.get (util::read_file on /data) blocks ~300-630ms ->
controlsd stops publishing carControl -> card's all_alive guard withholds
HCA_1 -> EPS LH2_Sta_HCA 7->2.
This reproduces the *proximate* cause WITHOUT driving, WITHOUT the EPS, and
WITHOUT touching the real control stack. Two roles:
--writer : emulate loggerd. Buffered (no-fsync) writes to /data at a target
MB/s, with an optional periodic big flush to mimic 60s segment
rotation. This is what saturates the eMMC.
--probe : emulate controlsd's I/O exposure. A 100Hz loop that every
--param-period seconds reads a real param (util::read_file on
/data). It records per-iteration loop gaps and param-read
durations. A 300ms gap here == the stall that drops HCA.
--threaded moves the param read to a background thread (the
proposed fix, mirroring card.py's params_thread) so you can A/B it.
USAGE (run parked, ignition on, on the device):
# 1) baseline: probe alone -> gaps should be tiny
python3 io_stall_repro.py --probe --secs 120
# 2) reproduce: writer in one shell, probe in another
python3 io_stall_repro.py --writer --mbps 25 --rotate 60
python3 io_stall_repro.py --probe --secs 180 # expect big gaps
# 3) validate fix A (params off control thread):
python3 io_stall_repro.py --probe --secs 180 --threaded # gaps should vanish
# 4) validate fix B (smoother writeback) — set before step 2, as root:
# echo 5 > /proc/sys/vm/dirty_background_ratio
# echo 10 > /proc/sys/vm/dirty_ratio
# then re-run step 2 inline probe and compare gap distribution.
Cleanup: writer deletes its scratch files on exit. Read-only wrt openpilot.
"""
import argparse
import os
import sys
import time
import threading
import signal
SCRATCH_DEFAULT = "/data/media/0/io_repro_scratch"
# ----------------------------------------------------------------------------- writer
def run_writer(args):
os.makedirs(args.scratch, exist_ok=True)
chunk = os.urandom(1 << 20) # 1 MiB
bytes_per_s = int(args.mbps * (1 << 20))
print(f"[writer] buffered no-fsync writes to {args.scratch} at ~{args.mbps} MB/s, rotate every {args.rotate}s (big flush). Ctrl-C to stop.", file=sys.stderr)
stop = {"v": False}
signal.signal(signal.SIGINT, lambda *_: stop.update(v=True))
signal.signal(signal.SIGTERM, lambda *_: stop.update(v=True))
files = []
seg = 0
try:
while not stop["v"]:
seg_start = time.monotonic()
path = os.path.join(args.scratch, f"seg_{seg}.bin")
f = open(path, "wb", buffering=1 << 20)
files.append(path)
written = 0
# write at target rate using buffered fwrite, NO fsync (exactly loggerd)
while not stop["v"] and (time.monotonic() - seg_start) < args.rotate:
f.write(chunk)
written += len(chunk)
# pace to target MB/s
target_t = written / bytes_per_s
elapsed = time.monotonic() - seg_start
if target_t > elapsed:
time.sleep(min(0.1, target_t - elapsed))
# "segment rotation": flush+close a big buffered file at once -> writeback burst
f.flush()
f.close()
seg += 1
# keep only a few recent files so we don't fill the disk
while len(files) > 3:
old = files.pop(0)
try:
os.remove(old)
except OSError:
pass
finally:
for p in files:
try:
os.remove(p)
except OSError:
pass
print("[writer] stopped, scratch cleaned.", file=sys.stderr)
# ----------------------------------------------------------------------------- probe
def _get_param(key):
# real /data read, same syscall path as controlsd's get_params_iq
try:
from iqpilot.common.params import Params
return Params().get_bool(key)
except Exception:
# fallback: plain file read of a param file if openpilot import unavailable
p = os.path.join(os.getenv("PARAMS_ROOT", "/data/params"), "d", key)
try:
with open(p, "rb") as fh:
return fh.read()
except OSError:
return None
class ThreadedParam:
"""Mirror card.py params_thread: refresh the param on a bg thread, control
loop reads the cached value (non-blocking)."""
def __init__(self, key, period):
self.key = key
self.period = period
self.val = None
self.stop = False
self.t = threading.Thread(target=self._loop, daemon=True)
self.t.start()
def _loop(self):
while not self.stop:
self.val = _get_param(self.key)
time.sleep(self.period)
def read(self): # O(1), no I/O on the control thread
return self.val
def run_probe(args):
# pin like controlsd (core 4) so we share the same iowait domain if possible
try:
os.sched_setaffinity(0, {args.core})
except (OSError, AttributeError):
pass
interval = 0.01 # 100Hz, like the control loop
gaps = [] # ms, per-iteration loop overrun beyond 10ms
read_ms = [] # ms, time spent in the param read on the control thread
worst = 0.0
threaded = ThreadedParam(args.param_key, args.param_period) if args.threaded else None
print(
f"[probe] 100Hz loop for {args.secs}s, param '{args.param_key}' every {args.param_period}s, threaded={args.threaded}, core={args.core}", file=sys.stderr
)
t_end = time.monotonic() + args.secs
next_t = time.monotonic()
last_param = 0.0
while time.monotonic() < t_end:
loop_start = time.monotonic()
# the I/O exposure: read param on the control thread (inline) every period
if loop_start - last_param >= args.param_period:
r0 = time.monotonic()
if threaded is not None:
_ = threaded.read() # cached, no I/O on this thread (the FIX)
else:
_ = _get_param(args.param_key) # inline /data read (current behavior)
dr = (time.monotonic() - r0) * 1000
read_ms.append(dr)
last_param = loop_start
# measure scheduling/lag: how late did this iteration actually fire?
next_t += interval
lag = (time.monotonic() - next_t) * 1000 # ms behind schedule
if lag > 5:
gaps.append(lag)
worst = max(worst, lag)
sleep = next_t - time.monotonic()
if sleep > 0:
time.sleep(sleep)
else:
next_t = time.monotonic() # don't spiral after a big stall
if threaded:
threaded.stop = True
def pct(xs, p):
return sorted(xs)[int(p / 100 * (len(xs) - 1))] if xs else 0.0
print("\n================ PROBE RESULT ================")
print(f"loop-lag events >5ms : {len(gaps)}")
print(f"loop-lag p50/p99/max : {pct(gaps, 50):.0f} / {pct(gaps, 99):.0f} / {worst:.0f} ms")
print(f"param-read p50/p99/max: {pct(read_ms, 50):.1f} / {pct(read_ms, 99):.1f} / {max(read_ms + [0]):.1f} ms (n={len(read_ms)})")
hca_class = max(gaps + [0])
verdict = "FAULT-CLASS STALL REPRODUCED (>250ms -> would drop HCA)" if hca_class > 250 else "marginal (100-250ms)" if hca_class > 100 else "clean (<100ms)"
print(f"VERDICT: {verdict}")
print("=============================================")
# ----------------------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--writer", action="store_true", help="emulate loggerd eMMC saturation")
ap.add_argument("--probe", action="store_true", help="emulate controlsd I/O exposure")
ap.add_argument("--mbps", type=float, default=25.0, help="writer target MB/s (loggerd ~10-30)")
ap.add_argument("--rotate", type=float, default=60.0, help="writer segment/flush period s")
ap.add_argument("--scratch", default=SCRATCH_DEFAULT)
ap.add_argument("--secs", type=float, default=180.0, help="probe duration s")
ap.add_argument("--param-key", default="IsMetric", help="a real param key to read")
ap.add_argument("--param-period", type=float, default=3.0, help="controlsd reads every 3s")
ap.add_argument("--threaded", action="store_true", help="probe: read param off control thread (the FIX)")
ap.add_argument("--core", type=int, default=4, help="probe cpu affinity (control core)")
args = ap.parse_args()
if args.writer == args.probe:
ap.error("pick exactly one of --writer / --probe (run them in separate shells)")
run_writer(args) if args.writer else run_probe(args)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""
io_stall_tracer.py — continuous low-overhead per-process disk-I/O tracer.
WHY: the VW PQ EPS HCA faults are caused by a control process (controlsd/card)
blocking ~300-630ms in disk I/O (iowait) on /data, which stops HCA_1 TX. The
stall is far too short to catch with a manual `iostat`/`iotop` run. This sampler
runs for the whole drive at 100ms cadence and records, per process:
- write_bytes (/proc/<pid>/io) -> identifies the WRITER saturating eMMC
- delayacct_blkio (/proc/<pid>/stat) -> per-process cumulative block-I/O wait
- state (/proc/<pid>/stat) -> catches 'D' (uninterruptible disk wait)
plus whole-device /proc/diskstats. After a fault, find the wall-clock time of the
LH2_Sta_HCA->2 event (from the rlog) and look at the rows around it: the process
whose write_bytes delta spikes is the bully; the control process whose blkio
delta jumps / state == 'D' is the victim.
Deploy: copy to the device, run alongside openpilot during a drive:
python3 io_stall_tracer.py --out /data/media/0/io_trace.csv
Overhead: reading /proc for ~40 procs every 100ms is well under 1% of one core,
and it pins itself to CPU 0 (away from the control cores 4/5) at low priority.
Read-only. Writes a single CSV. No openpilot deps.
"""
import argparse
import os
import time
import glob
import sys
CLK_TCK = os.sysconf("SC_CLK_TCK") # usually 100 -> blkio ticks are 10ms each
def read_proc_io(pid):
# wchar/rchar = bytes moved via read()/write() syscalls (catches BUFFERED writers
# like loggerd, which never appear in write_bytes because the kernel flushes their
# page-cache dirty pages asynchronously via kworker). write_bytes = bytes actually
# sent to the block device. Track both.
try:
with open(f"/proc/{pid}/io") as f:
d = {}
for line in f:
k, _, v = line.partition(":")
d[k] = int(v)
return (d.get("wchar", 0), d.get("rchar", 0), d.get("write_bytes", 0), d.get("read_bytes", 0))
except (OSError, ValueError):
return None
def read_proc_stat(pid):
# state is field 3; delayacct_blkio_ticks is field 42 (1-indexed). comm may
# contain spaces/parens, so split on the last ')'.
try:
with open(f"/proc/{pid}/stat") as f:
data = f.read()
rparen = data.rfind(")")
comm = data[data.find("(") + 1 : rparen]
rest = data[rparen + 2 :].split()
state = rest[0] # field 3
blkio_ticks = int(rest[39]) if len(rest) > 39 else 0 # field 42
return comm, state, blkio_ticks
except (OSError, ValueError, IndexError):
return None
def read_diskstats():
# returns {dev: (sectors_written, ms_doing_io)} for whole-disk devices
out = {}
try:
with open("/proc/diskstats") as f:
for line in f:
p = line.split()
if len(p) < 14:
continue
dev = p[2]
# field 10 (idx 9) = sectors written; field 13 (idx 12) = ms doing I/O
out[dev] = (int(p[9]), int(p[12]))
except (OSError, AttributeError):
pass
return out
# These counters tell us WHICH kernel mechanism caused a stall, which decides
# the fix: compact_stall jumping -> memory compaction (texture-pool fix);
# allocstall/pgsteal jumping -> direct reclaim; high nr_dirty/nr_writeback ->
# loggerd writeback bomb (loggerd sync_file_range fix). meminfo Dirty/Writeback
# are absolute kB; vmstat ones are cumulative event counts (we delta them).
VMSTAT_KEYS = (
"compact_stall",
"compact_fail",
"allocstall_normal",
"allocstall_movable",
"pgsteal_direct",
"pgscan_direct",
"pgmajfault",
"nr_dirty",
"nr_writeback",
)
MEMINFO_KEYS = ("MemFree", "MemAvailable", "Dirty", "Writeback")
def read_vmstat():
out = {}
try:
with open("/proc/vmstat") as f:
for line in f:
k, _, v = line.partition(" ")
if k in VMSTAT_KEYS:
out[k] = int(v)
except OSError:
pass
return out
def read_meminfo():
out = {}
try:
with open("/proc/meminfo") as f:
for line in f:
k, _, v = line.partition(":")
if k in MEMINFO_KEYS:
out[k] = int(v.split()[0]) # kB
except (OSError, IndexError):
pass
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="/data/media/0/io_trace.csv")
ap.add_argument("--hz", type=float, default=10.0, help="sample rate (default 10Hz/100ms)")
ap.add_argument("--disk", default="sda", help="comma-separated disk devices to track (default sda)")
ap.add_argument(
"--names",
default="controlsd,car.c,selfd,ui,loggerd,encoderd,modeld,camerad,locationd,estimatord,navd,mapd",
help="substring match of process comm to record (others summed as 'other')",
)
args = ap.parse_args()
# be a good citizen: low priority, off the control cores
try:
os.nice(10)
os.sched_setaffinity(0, {0})
except OSError:
pass
watch = [n.strip() for n in args.names.split(",") if n.strip()]
disks = [d.strip() for d in args.disk.split(",") if d.strip()]
interval = 1.0 / args.hz
prev_io = {} # pid -> (wchar, rchar, write_bytes, read_bytes)
prev_blkio = {} # pid -> blkio_ticks
prev_disk = read_diskstats()
prev_vm = read_vmstat()
f = open(args.out, "w", buffering=1)
# per-proc rows fill the first block; one SYS row per tick fills the trailing
# mechanism columns (deltas for the vmstat counts, absolute kB for meminfo).
columns = [
"wall",
"mono",
"proc",
"pid",
"state",
"d_wchar_kB",
"d_wbytes_kB",
"d_blkio_ms",
"disk_d_write_kB",
"disk_d_busy_ms",
"compact_stall",
"allocstall",
"pgmajfault",
"dirty_kB",
"writeback_kB",
"memfree_kB",
"memavail_kB",
]
f.write(",".join(columns) + "\n")
print(f"[io_stall_tracer] writing {args.out} at {args.hz}Hz, tracking {watch}", file=sys.stderr)
while True:
t_wall = time.time()
t_mono = time.monotonic()
# whole-disk delta (write kB + busy ms) for the named disks
disk = read_diskstats()
disk_dw = disk_db = 0
for dev in disks:
if dev in disk and dev in prev_disk:
disk_dw += (disk[dev][0] - prev_disk[dev][0]) * 512 / 1024.0 # sectors->kB
disk_db += disk[dev][1] - prev_disk[dev][1]
prev_disk = disk
seen = set()
rows = []
for path in glob.glob("/proc/[0-9]*"):
pid = path.rsplit("/", 1)[1]
st = read_proc_stat(pid)
if st is None:
continue
comm, state, blkio = st
label = next((w for w in watch if w in comm), None)
if label is None:
# still track D-state of anything to catch surprise writers/blockers
if state != "D":
continue
label = comm
io = read_proc_io(pid)
if io is None:
continue
wchar, rchar, wbytes, rbytes = io
pw = prev_io.get(pid, io)
pblk = prev_blkio.get(pid, blkio)
d_wchar = (wchar - pw[0]) / 1024.0 # syscall write volume (catches loggerd)
d_wbytes = (wbytes - pw[2]) / 1024.0 # bytes hitting the block device
d_blk = (blkio - pblk) * (1000.0 / CLK_TCK) # ticks -> ms blocked on block I/O
prev_io[pid] = io
prev_blkio[pid] = blkio
seen.add(pid)
# only emit rows that carry signal (writing, blocked, or in D) to keep file small
if d_wchar > 4 or d_wbytes > 4 or d_blk > 5 or state == "D":
rows.append((label, pid, state, d_wchar, d_wbytes, d_blk))
# drop dead pids from prev maps occasionally
if len(prev_io) > 4000:
prev_io = {p: v for p, v in prev_io.items() if p in seen}
prev_blkio = {p: v for p, v in prev_blkio.items() if p in seen}
for label, pid, state, d_wchar, d_wbytes, d_blk in rows:
f.write(f"{t_wall:.3f},{t_mono:.3f},{label},{pid},{state},{d_wchar:.0f},{d_wbytes:.0f},{d_blk:.0f},{disk_dw:.0f},{disk_db:.0f},,,,,,,\n")
# one SYS row per tick: the kernel-mechanism counters (compaction vs reclaim
# vs writeback). Compare these against the stall's wall-clock to see which
# one spiked.
vm = read_vmstat()
mi = read_meminfo()
d_compact = vm.get("compact_stall", 0) - prev_vm.get("compact_stall", 0)
d_alloc = (vm.get("allocstall_normal", 0) + vm.get("allocstall_movable", 0)) - (prev_vm.get("allocstall_normal", 0) + prev_vm.get("allocstall_movable", 0))
d_majflt = vm.get("pgmajfault", 0) - prev_vm.get("pgmajfault", 0)
prev_vm = vm
values = [
f"{t_wall:.3f}",
f"{t_mono:.3f}",
"SYS",
"0",
"-",
"",
"",
"",
"",
"",
str(d_compact),
str(d_alloc),
str(d_majflt),
str(mi.get("Dirty", 0)),
str(mi.get("Writeback", 0)),
str(mi.get("MemFree", 0)),
str(mi.get("MemAvailable", 0)),
]
f.write(",".join(values) + "\n")
time.sleep(max(0.0, interval - (time.monotonic() - t_mono)))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
import argparse
import numpy as np
import capnp
from collections import defaultdict
from iqpilot.cereal.messaging import SubMaster
def cputime_total(ct):
return ct.user + ct.nice + ct.system + ct.idle + ct.iowait + ct.irq + ct.softirq
def cputime_busy(ct):
return ct.user + ct.nice + ct.system + ct.irq + ct.softirq
def proc_cputime_total(ct):
return ct.cpuUser + ct.cpuSystem + ct.cpuChildrenUser + ct.cpuChildrenSystem
def proc_name(proc):
name = proc.name
if len(proc.cmdline):
name = proc.cmdline[0]
if len(proc.exe):
name = proc.exe + " - " + name
return name
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument('--mem', action='store_true')
parser.add_argument('--cpu', action='store_true')
args = parser.parse_args()
sm = SubMaster(['deviceState', 'procLog'])
last_temp = 0.0
last_mem = 0.0
total_times = [0.]*8
busy_times = [0.]*8
prev_proclog: capnp._DynamicStructReader | None = None
prev_proclog_t: int | None = None
while True:
sm.update()
if sm.updated['deviceState']:
t = sm['deviceState']
last_temp = np.mean(t.cpuTempC)
last_mem = t.memoryUsagePercent
if sm.updated['procLog']:
m = sm['procLog']
cores = [0.]*8
total_times_new = [0.]*8
busy_times_new = [0.]*8
for c in m.cpuTimes:
n = c.cpuNum
total_times_new[n] = cputime_total(c)
busy_times_new[n] = cputime_busy(c)
for n in range(8):
t_busy = busy_times_new[n] - busy_times[n]
t_total = total_times_new[n] - total_times[n]
cores[n] = t_busy / t_total
total_times = total_times_new[:]
busy_times = busy_times_new[:]
print(f"CPU {100.0 * np.mean(cores):.2f}% - RAM: {last_mem:.2f}% - Temp {last_temp:.2f}C")
if args.cpu and prev_proclog is not None and prev_proclog_t is not None:
procs: dict[str, float] = defaultdict(float)
dt = (sm.logMonoTime['procLog'] - prev_proclog_t) / 1e9
for proc in m.procs:
try:
name = proc_name(proc)
prev_proc = [p for p in prev_proclog.procs if proc.pid == p.pid][0]
cpu_time = proc_cputime_total(proc) - proc_cputime_total(prev_proc)
cpu_usage = cpu_time / dt * 100.
procs[name] += cpu_usage
except IndexError:
pass
print("Top CPU usage:")
for k, v in sorted(procs.items(), key=lambda item: item[1], reverse=True)[:10]:
print(f"{k.rjust(70)} {v:.2f} %")
print()
if args.mem:
mems = {}
for proc in m.procs:
name = proc_name(proc)
mems[name] = float(proc.memRss) / 1e6
print("Top memory usage:")
for k, v in sorted(mems.items(), key=lambda item: item[1], reverse=True)[:10]:
print(f"{k.rjust(70)} {v:.2f} MB")
print()
prev_proclog = m
prev_proclog_t = sm.logMonoTime['procLog']

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
import argparse
import numpy as np
import matplotlib.pyplot as plt
from functools import partial
from tqdm import tqdm
from typing import NamedTuple
from iqpilot.tools.lib.logreader import LogReader
from iqpilot.selfdrive.locationd.models.pose_kf import EARTH_G
RLOG_MIN_LAT_ACTIVE = 50
RLOG_MIN_STEERING_UNPRESSED = 50
RLOG_MIN_REQUESTING_MAX = 25 # sample many times after reaching max torque
QLOG_DECIMATION = 10
class Event(NamedTuple):
lateral_accel: float
speed: float
roll: float
timestamp: float # relative to start of route (s)
def find_events(lr: LogReader, extrapolate: bool = False, qlog: bool = False) -> list[Event]:
min_lat_active = RLOG_MIN_LAT_ACTIVE // QLOG_DECIMATION if qlog else RLOG_MIN_LAT_ACTIVE
min_steering_unpressed = RLOG_MIN_STEERING_UNPRESSED // QLOG_DECIMATION if qlog else RLOG_MIN_STEERING_UNPRESSED
min_requesting_max = RLOG_MIN_REQUESTING_MAX // QLOG_DECIMATION if qlog else RLOG_MIN_REQUESTING_MAX
# if we test with driver torque safety, max torque can be slightly noisy
steer_threshold = 0.7 if extrapolate else 0.95
events = []
# state tracking
steering_unpressed = 0 # frames
requesting_max = 0 # frames
lat_active = 0 # frames
# current state
curvature = 0
v_ego = 0
roll = 0
out_torque = 0
start_ts = 0
for msg in lr:
if msg.which() == 'carControl':
if start_ts == 0:
start_ts = msg.logMonoTime
lat_active = lat_active + 1 if msg.carControl.latActive else 0
elif msg.which() == 'carOutput':
out_torque = msg.carOutput.actuatorsOutput.torque
requesting_max = requesting_max + 1 if abs(out_torque) > steer_threshold else 0
elif msg.which() == 'carState':
steering_unpressed = steering_unpressed + 1 if not msg.carState.steeringPressed else 0
v_ego = msg.carState.vEgo
elif msg.which() == 'controlsState':
curvature = msg.controlsState.curvature
elif msg.which() == 'vehicleParameters':
roll = msg.vehicleParameters.roll
if lat_active > min_lat_active and steering_unpressed > min_steering_unpressed and requesting_max > min_requesting_max:
# TODO: record max lat accel at the end of the event, need to use the past lat accel as overriding can happen before we detect it
requesting_max = 0
factor = 1 / abs(out_torque)
current_lateral_accel = (curvature * v_ego ** 2 * factor) - roll * EARTH_G
events.append(Event(current_lateral_accel, v_ego, roll, round((msg.logMonoTime - start_ts) * 1e-9, 2)))
print(events[-1])
return events
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Find max lateral acceleration events",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("route", nargs='+')
parser.add_argument("-e", "--extrapolate", action="store_true", help="Extrapolates max lateral acceleration events linearly. " +
"This option can be far less accurate.")
args = parser.parse_args()
events = []
for route in tqdm(args.route):
try:
lr = LogReader(route, sort_by_time=True)
except Exception:
print(f'Skipping {route}')
continue
qlog = route.endswith('/q')
if qlog:
print('WARNING: Treating route as qlog!')
print('Finding events...')
events += lr.run_across_segments(8, partial(find_events, extrapolate=args.extrapolate, qlog=qlog), disable_tqdm=True)
print()
print(f'Found {len(events)} events')
perc_left_accel = -np.percentile([-ev.lateral_accel for ev in events if ev.lateral_accel < 0] or [0], 90)
perc_right_accel = np.percentile([ev.lateral_accel for ev in events if ev.lateral_accel > 0] or [0], 90)
CP = lr.first('carParams')
plt.ion()
plt.clf()
plt.suptitle(f'{CP.carFingerprint} - Max lateral acceleration events')
plt.title(', '.join(args.route))
plt.scatter([ev.speed for ev in events], [ev.lateral_accel for ev in events], label='max lateral accel events')
plt.plot([0, 35], [3, 3], c='r', label='ISO 11270 - 3 m/s^2')
plt.plot([0, 35], [-3, -3], c='r')
plt.plot([0, 35], [perc_left_accel, perc_left_accel], c='g', linestyle='--', label='90th percentile left lateral accel')
plt.plot([0, 35], [perc_right_accel, perc_right_accel], c='#ff7f0e', linestyle='--', label='90th percentile right lateral accel')
plt.text(0.4, float(perc_left_accel + 0.4), f'{perc_left_accel:.2f} m/s^2', verticalalignment='center', fontsize=12)
plt.text(0.4, float(perc_right_accel - 0.4), f'{perc_right_accel:.2f} m/s^2', verticalalignment='center', fontsize=12)
plt.xlim(0, 35)
plt.ylim(-5, 5)
plt.xlabel('speed (m/s)')
plt.ylabel('lateral acceleration (m/s^2)')
plt.legend()
plt.show(block=True)

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
import os
import argparse
import struct
from collections import deque
from statistics import mean
from iqpilot.cereal import log
import iqpilot.cereal.messaging as messaging
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Sniff a communication socket')
parser.add_argument('--addr', default='127.0.0.1')
args = parser.parse_args()
if args.addr != "127.0.0.1":
os.environ["ZMQ"] = "1"
messaging.reset_context()
poller = messaging.Poller()
messaging.sub_sock('can', poller, addr=args.addr)
active = 0
start_t = 0
start_v = 0
max_v = 0
max_t = 0
window = deque(maxlen=10)
avg = 0
while 1:
polld = poller.poll(1000)
for sock in polld:
msg = sock.receive()
with log.Event.from_bytes(msg) as log_evt:
evt = log_evt
for item in evt.can:
if item.address == 0xe4 and item.src == 128:
torque_req = struct.unpack('!h', item.dat[0:2])[0]
# print(torque_req)
active = abs(torque_req) > 0
if abs(torque_req) < 100:
if max_v > 5:
print(f'{start_v} -> {max_v} = {round(max_v - start_v, 2)} over {round(max_t - start_t, 2)}s')
start_t = evt.logMonoTime / 1e9
start_v = avg
max_t = 0
max_v = 0
if item.address == 0x1ab and item.src == 0:
motor_torque = ((item.dat[0] & 0x3) << 8) + item.dat[1]
window.append(motor_torque)
avg = mean(window)
#print(f'{evt.logMonoTime}: {avg}')
if active and avg > max_v + 0.5:
max_v = avg
max_t = evt.logMonoTime / 1e9

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
import argparse
import zstandard as zstd
from collections import defaultdict
import matplotlib.pyplot as plt
from iqpilot.cereal.services import SERVICE_LIST
from iqpilot.common.utils import LOG_COMPRESSION_LEVEL
from iqpilot.tools.lib.logreader import LogReader
from tqdm import tqdm
MIN_SIZE = 0.5 # Percent size of total to show as separate entry
def make_pie(msgs, typ):
msgs_by_type = defaultdict(list)
for m in msgs:
msgs_by_type[m.which()].append(m.as_builder().to_bytes())
total = len(zstd.compress(b"".join([m.as_builder().to_bytes() for m in msgs]), LOG_COMPRESSION_LEVEL))
uncompressed_total = len(b"".join([m.as_builder().to_bytes() for m in msgs]))
length_by_type = {k: len(b"".join(v)) for k, v in msgs_by_type.items()}
# calculate compressed size by calculating diff when removed from the segment
compressed_length_by_type = {}
for k in tqdm(msgs_by_type.keys(), desc="Compressing"):
compressed_length_by_type[k] = total - len(zstd.compress(b"".join([m.as_builder().to_bytes() for m in msgs if m.which() != k]), LOG_COMPRESSION_LEVEL))
sizes = sorted(compressed_length_by_type.items(), key=lambda kv: kv[1])
print("name - comp. size (uncomp. size)")
for (name, sz) in sizes:
print(f"{name:<22} - {sz / 1024:.2f} kB ({length_by_type[name] / 1024:.2f} kB)")
print()
print(f"{typ} - Real total {total / 1024:.2f} kB")
print(f"{typ} - Breakdown total {sum(compressed_length_by_type.values()) / 1024:.2f} kB")
print(f"{typ} - Uncompressed total {uncompressed_total / 1024 / 1024:.2f} MB")
sizes_large = [(k, sz) for (k, sz) in sizes if sz >= total * MIN_SIZE / 100]
sizes_large += [('other', sum(sz for (_, sz) in sizes if sz < total * MIN_SIZE / 100))]
labels, sizes = zip(*sizes_large, strict=True)
plt.figure()
plt.title(f"{typ}")
plt.pie(sizes, labels=labels, autopct='%1.1f%%')
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='View log size breakdown by message type')
parser.add_argument('route', help='route to use')
parser.add_argument('--as-qlog', action='store_true', help='decimate rlog using latest decimation factors')
args = parser.parse_args()
msgs = list(LogReader(args.route))
if args.as_qlog:
new_msgs = []
msg_cnts: dict[str, int] = defaultdict(int)
for msg in msgs:
msg_which = msg.which()
if msg.which() in ("initData", "sentinel"):
new_msgs.append(msg)
continue
if msg_which not in SERVICE_LIST:
continue
decimation = SERVICE_LIST[msg_which].decimation
if decimation is not None and msg_cnts[msg_which] % decimation == 0:
new_msgs.append(msg)
msg_cnts[msg_which] += 1
msgs = new_msgs
make_pie(msgs, 'qlog')
plt.show()

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""
sendcan_gap_audit.py — measure the openpilot->car actuator TX cadence from an rlog.
WHY: VW PQ random long/lat disengages are caused by control-loop STALLS that
freeze the `sendcan` publish for >100ms. The car's ECU runs a counter/checksum
watchdog on the actuator messages (ACC_System ADR, HCA_1) and FAULTS when frames
arrive late/missing — it does NOT care about the payload value. So the single
metric that predicts the fault is the inter-frame GAP in sendcan, not anything
about accel/torque. (Reference: route 20e3cd4f0d5f39d1|00000038--0f69286335 had a
103ms gap at ~373s -> engine MO2_Sta_GRA->0 -> main switch off -> disengage. A
separate 60ms gap did NOT disengage: the ECU timeout sits ~60-100ms.)
This is the pass/fail metric for the mlockall / loggerd-writeback fix (5324c46)
and, later, the decoupled in-card heartbeat TX. Run it on a BASELINE route to see
the offending gaps, then on POST-FIX drives to confirm they're gone.
python3 iqpilot/tools/iqperf/sendcan_gap_audit.py <route_or_segment> [--warn-ms 30] [--fault-ms 100]
Exit code 0 if no gap >= --fault-ms, else 1 (so it can gate CI / a smoke test).
Read-only; pulls rlogs via the normal LogReader (konn3kt for IQ.Pilot routes).
"""
import argparse
import sys
from iqpilot.tools.lib.logreader import LogReader
def audit(route: str, warn_ms: float, fault_ms: float) -> int:
lr = LogReader(route, sort_by_time=True)
last = None
gaps = [] # (t_end, dt_ms) for every gap >= warn_ms
n = 0
worst = 0.0
for m in lr:
if m.which() != "sendcan":
continue
t = m.logMonoTime / 1e9
n += 1
if last is not None:
dt = (t - last) * 1000.0
worst = max(worst, dt)
if dt >= warn_ms:
gaps.append((t, dt))
last = t
faults = [(t, dt) for t, dt in gaps if dt >= fault_ms]
print(f"route : {route}")
print(f"sendcan frames : {n}")
print(f"worst gap : {worst:.1f} ms")
print(f"gaps >= {warn_ms:.0f}ms : {len(gaps)}")
print(f"gaps >= {fault_ms:.0f}ms (FAULT-RISK): {len(faults)}")
if gaps:
print("\n t(s) gap(ms) risk")
for t, dt in gaps:
print(f" {t:10.3f} {dt:7.1f} {'<-- FAULT RISK' if dt >= fault_ms else ''}")
if faults:
print(f"\nFAIL: {len(faults)} gap(s) >= {fault_ms:.0f}ms can trip the car's actuator counter watchdog (late/missing frames).")
return 1
print(f"\nPASS: no sendcan gap >= {fault_ms:.0f}ms.")
return 0
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("route", help="route name, segment, or URL (e.g. dongle|time--hash or .../5:8)")
p.add_argument("--warn-ms", type=float, default=30.0, help="list gaps >= this (default 30)")
p.add_argument("--fault-ms", type=float, default=100.0, help="fail on gaps >= this (default 100)")
args = p.parse_args()
return audit(args.route, args.warn_ms, args.fault_ms)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
# console path for the boot/manager tmux: must never die or block; on any error, forward raw.
import os
import re
import signal
import sys
try:
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
except Exception:
pass
_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
_DROP = re.compile(
r"(\x1b\[\?\d+[a-z])"
r"|^Last login:"
r"|gbm_create_device\(\d+\): Info:"
r"|^pid \d+'s (current|new) affinity list:"
r"|kj/filesystem-disk-unix\.c\+\+:\d+: warning: PWD"
)
_CLOUDLOG = re.compile(r"^([\w./+-]+\.(?:cc|cpp|c|h|py)): (.*)$")
_ALREADY = re.compile(r"^\s*(?:\x1b\[[0-9;]*m)?\s*(CRIT|ERR|WARN|info|dbg)\b")
_ERRISH = re.compile(r"not supported|fail|error|invalid|cannot|timed out|timeout|unable", re.I)
def _sev(msg):
return ("\033[1;38;5;203m", " ERR", "\033[1;38;5;210m") if _ERRISH.search(msg) \
else ("\033[38;5;110m", "info", "")
def _restyle(line):
if _DROP.search(line):
return None
if _ALREADY.match(line):
return line
m = _CLOUDLOG.match(re.sub(r"\x1b\[[0-9;]*m", "", line))
if m and _COLOR:
src, msg = m.group(1), m.group(2)
lc, ln, mc = _sev(msg)
body = f"{mc}{msg}\033[0m" if mc else msg
return f"{lc}{ln}\033[0m \033[2m{src}\033[0m {body}"
return line
def _emit(out, buf):
styled = _restyle(buf)
if styled is not None:
out.write(styled + "\r\n"); out.flush()
def main():
out = sys.stdout
buf = ""
pending_cr = False
read = sys.stdin.buffer.read
while True:
try:
ch = read(1)
except Exception:
break
if not ch:
break
try:
c = ch.decode("utf-8", "replace")
if pending_cr:
pending_cr = False
if c == "\n":
_emit(out, buf); buf = ""; continue
out.write(buf + "\r"); out.flush(); buf = "" # bare \r: progress
if c == "\r":
pending_cr = True
elif c == "\n":
_emit(out, buf); buf = ""
else:
buf += c
except Exception:
try:
out.write(buf); out.flush()
except Exception:
pass
buf = ""; pending_cr = False
if pending_cr:
out.write(buf + "\r"); out.flush()
elif buf:
try:
styled = _restyle(buf)
if styled is not None:
out.write(styled); out.flush()
except Exception:
pass
if __name__ == "__main__":
try:
main()
except Exception:
try:
import shutil
shutil.copyfileobj(sys.stdin.buffer, sys.stdout.buffer)
except Exception:
pass

View File

@@ -0,0 +1,32 @@
# IQ.Pilot pretty git wrappers (gpull/gfetch/gsync/greset/gbv). bash + zsh.
# Source from your shell rc: source .../tools/iqpilot/git-pretty.sh
if [ -n "${BASH_SOURCE:-}" ]; then
_iq_gp_src="${BASH_SOURCE[0]}"
elif [ -n "${ZSH_VERSION:-}" ]; then
_iq_gp_src="${(%):-%x}"
else
_iq_gp_src="$0"
fi
_IQ_GIT_PRETTY="$(cd "$(dirname "$_iq_gp_src")" 2>/dev/null && pwd)/git_pretty.py"
unset _iq_gp_src
if [ -z "${_IQ_GP_PY:-}" ]; then
if command -v python3 >/dev/null 2>&1; then _IQ_GP_PY=python3
elif [ -x /usr/bin/python3 ]; then _IQ_GP_PY=/usr/bin/python3
else _IQ_GP_PY=python; fi
fi
_iq_git_pretty() {
command git "$@" 2>&1 | "$_IQ_GP_PY" "$_IQ_GIT_PRETTY"
if [ -n "${ZSH_VERSION:-}" ]; then
return ${pipestatus[1]}
else
return ${PIPESTATUS[0]}
fi
}
gpull() { _iq_git_pretty -c color.ui=always pull --progress "$@"; }
gfetch() { _iq_git_pretty -c color.ui=always fetch --progress "$@"; }
gsync() { _iq_git_pretty -c color.ui=always submodule update --init --recursive --progress "$@"; }
greset() { _iq_git_pretty -c color.ui=always reset "$@"; }
gbv() { _iq_git_pretty -c color.branch=always branch -v "$@"; }

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
import os
import re
import sys
_GRAD = {
"PULL": ((95, 240, 150), (40, 200, 120)),
"FETCH": ((95, 205, 255), (130, 110, 250)),
"SUBMOD": ((200, 160, 255), (150, 110, 250)),
"RESET": ((255, 190, 90), (230, 80, 70)),
"BRANCH": ((95, 215, 255), (70, 130, 245)),
}
_TARGET_RGB = (150, 152, 178)
_GREEN = (120, 210, 130)
_DIM = "\033[2;38;5;246m"
_RST = "\033[0m"
def _mode():
if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
return None
return "true" if os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit") else "256"
def _fg(rgb, mode):
r, g, b = rgb
if mode == "true":
return f"\033[38;2;{r};{g};{b}m"
if abs(r - g) < 12 and abs(g - b) < 12 and abs(r - b) < 12:
idx = 232 + min(23, round((r + g + b) / 3 / 255 * 23))
else:
idx = 16 + 36 * round(r / 255 * 5) + 6 * round(g / 255 * 5) + round(b / 255 * 5)
return f"\033[38;5;{idx}m"
def _grad(word, label, mode):
start, end = _GRAD.get(label, _GRAD["FETCH"])
n = max(1, len(word) - 1)
out = [f"\033[1m{_fg(tuple(int(s + (e - s) * i / n) for s, e in zip(start, end)), mode)}{ch}"
for i, ch in enumerate(word)]
return "".join(out) + _RST
def _label(label, body, mode):
pad = " " * max(0, 8 - len(label))
return f"{pad}{_grad(label, label, mode)} {_fg(_TARGET_RGB, mode)}{body}{_RST}"
def _restyle(line, mode):
s = line.rstrip("\n")
raw = re.sub(r"\033\[[0-9;]*m", "", s) # match against de-colored text
if raw == "Already up to date.":
return f"{_fg(_GREEN, mode)}✓ already up to date{_RST}"
m = re.match(r"Updating ([0-9a-f]+\.\.[0-9a-f]+)$", raw)
if m:
return _label("PULL", m.group(1), mode)
if raw == "Fast-forward":
return f"{_DIM}fast-forward{_RST}"
m = re.match(r"HEAD is now at ([0-9a-f]+) (.*)$", raw)
if m:
return _label("RESET", f"{m.group(1)} {m.group(2)}", mode)
m = re.match(r"Submodule path '(.+)': checked out '([0-9a-f]+)'$", raw)
if m:
return _label("SUBMOD", f"{m.group(1)} @ {m.group(2)[:9]}", mode)
m = re.match(r"Submodule '(.+)' \((.+)\) registered for path '(.+)'$", raw)
if m:
return _label("SUBMOD", f"{m.group(3)} (registered)", mode)
m = re.match(r"From (.+)$", raw)
if m:
return _label("FETCH", m.group(1), mode)
m = re.match(r"\s*\*?\s*\[new (?:branch|tag)\]\s+(\S+)\s+->\s+(\S+)$", raw)
if m:
return _label("FETCH", f"new {m.group(1)}{m.group(2)}", mode)
m = re.match(r"\s*\*\s+(?:branch|tag)\s+(\S+)\s+->\s+(\S+)$", raw)
if m:
return _label("FETCH", f"{m.group(1)}{m.group(2)}", mode)
m = re.match(r"\s*([0-9a-f]+\.\.[0-9a-f]+)\s+(\S+)\s+->\s+(\S+)$", raw)
if m:
return _label("FETCH", f"{m.group(1)} {m.group(2)}{m.group(3)}", mode)
m = re.match(r"([* ]) +(\S+) +([0-9a-f]{7,})( .*)?$", raw)
if m:
cur, name, sha, msg = m.groups()
star = f"{_fg(_GREEN, mode)}{_RST} " if cur == "*" else " "
return f"{star}{_grad(name, 'BRANCH', mode)} {_fg(_TARGET_RGB, mode)}{sha}{(msg or '')}{_RST}"
return s
def main():
mode = _mode()
out = sys.stdout
if mode is None: # not a tty / NO_COLOR: passthrough
for chunk in iter(lambda: sys.stdin.buffer.read(4096), b""):
out.buffer.write(chunk)
out.buffer.flush()
return
buf = ""
stream = sys.stdin
while True:
ch = stream.read(1)
if not ch:
break
if ch == "\r": # progress fragment: emit live, untouched
out.write(buf + "\r")
out.flush()
buf = ""
elif ch == "\n":
out.write(_restyle(buf, mode) + "\n")
out.flush()
buf = ""
else:
buf += ch
if buf:
out.write(_restyle(buf, mode))
out.flush()
if __name__ == "__main__":
try:
main()
except (BrokenPipeError, KeyboardInterrupt):
pass

View File

@@ -0,0 +1,775 @@
#!/usr/bin/env python3
"""
╔══════════════════════════════════════════════════════════════════════════╗
║ IQ.Pilot MICI UI Preview Tool ║
║ ────────────────────────────────────────────────────────────────────── ║
║ Renders any MICI layout/widget at 536×240 on your Mac desktop so you ║
║ can visually inspect and iterate without a physical comma 4. ║
║ ║
║ MODES ║
║ screenshot — render N frames, save PNG(s), open in Preview ║
║ video — render N seconds to MP4 via ffmpeg, open in QuickTime ║
║ live — interactive window, hot-reload on file-save ║
║ ║
║ USAGE ║
║ python tools/iqpilot/mici_preview.py [OPTIONS] ║
║ ║
║ OPTIONS ║
║ --panel PANEL Panel to render: steering, visuals, display, ║
║ software, cruise, trips, osm, models, toggles, ║
║ device, developer, home, settings (default: steering)║
║ --mode MODE screenshot | video | live (default: screenshot) ║
║ --frames N Frames to settle before screenshot (default: 90) ║
║ --shots N Number of screenshots to take (default: 1) ║
║ --duration S Video duration in seconds (default: 4) ║
║ --fps N Render FPS (default: 60) ║
║ --scale F Window scale multiplier (2.0 = 1072×480 window) ║
║ (default: 2.5) ║
║ --out PATH Output file/dir (default: /tmp/mici_preview/) ║
║ --open Open output file(s) after capture (default: True) ║
║ --mock Use mock UI state (no real params needed) ║
║ ║
║ EXAMPLES ║
║ # Screenshot the steering panel (scaled 2.5×, opens in Preview) ║
║ python tools/iqpilot/mici_preview.py --panel steering ║
║ ║
║ # 4-second video of the neon glow animation ║
║ python tools/iqpilot/mici_preview.py --panel steering --mode video ║
║ ║
║ # Live interactive window with hot-reload ║
║ python tools/iqpilot/mici_preview.py --panel visuals --mode live ║
║ ║
║ # Multiple panels in one go (screenshots) ║
║ python tools/iqpilot/mici_preview.py --panel all ║
╚══════════════════════════════════════════════════════════════════════════╝
"""
import argparse
import importlib
import os
import subprocess
import sys
import time
import queue
import threading
from pathlib import Path
# ── Project root on sys.path ──────────────────────────────────────────────
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
# Force MICI mode (no BIG UI)
os.environ.setdefault("BIG", "0")
os.environ.setdefault("IQPILOT_UI", "1")
# Use offscreen mode for screenshot/video (no FPS cap → fast capture)
# Live mode overrides this below.
import pyray as rl # noqa: E402 — must come after env setup
# ── MICI canvas dimensions ─────────────────────────────────────────────────
MICI_W = 536
MICI_H = 240
# ── Output directory ───────────────────────────────────────────────────────
DEFAULT_OUT = Path("/tmp/mici_preview")
# ── Color palette for the preview chrome ──────────────────────────────────
CHROME_BG = rl.Color(18, 18, 22, 255) # dark bg behind the MICI canvas
LABEL_COLOR = rl.Color(160, 160, 160, 200) # panel label text
# ── All available panels ───────────────────────────────────────────────────
ALL_PANELS = [
"steering", "visuals", "display", "software", "cruise",
"trips", "osm", "models", "toggles", "device", "developer", "home",
]
# ─────────────────────────────────────────────────────────────────────────────
# Mock UI state so we can run without a live comma process
# ─────────────────────────────────────────────────────────────────────────────
def _patch_mock_state():
"""Replace real ui_state and Params with lightweight mocks."""
import types
# ── Shared in-memory store ────────────────────────────────────────────────
_store: dict = {}
class MockParams:
"""
Drop-in Params mock that also supports `Params | None` type expressions
at module import time by implementing __or__ / __ror__ on the class itself.
"""
# Allow `Params | None` as a runtime type-union expression
def __class_getitem__(cls, item):
return cls
def __or__(cls, other):
import types as _t
return _t.UnionType if hasattr(_t, 'UnionType') else object
__ror__ = __or__
def __init__(self, _=None, **__):
pass # ignore any constructor args (real Params accepts path kwarg)
def get(self, key, default=None, return_default=False):
val = _store.get(key, default)
# Real Params.get() returns str or None; convert bools → str
if isinstance(val, bool):
return str(int(val))
return val
def get_bool(self, key, default=False):
val = _store.get(key, default)
if isinstance(val, str):
return val not in ('', '0', 'false', 'False', 'None')
return bool(val)
def put(self, key, val):
_store[key] = val
def put_bool(self, key, val):
_store[key] = bool(val)
def put_nonblocking(self, key, val):
_store[key] = val
# Seed some sensible defaults so widgets render realistically
mp = MockParams()
# ── Steering ────────────────────────────────────────────────────────────
mp.put("AolEnabled", False)
mp.put("AolSteeringMode", 0) # 0=remain active, 1=pause, 2=disengage
mp.put("AolMainCruiseAllowed", True)
mp.put("AolUnifiedEngagementMode", False)
mp.put("AolPauseOnSteeringOverride", False)
mp.put("NeuralNetworkFeedForward", False)
mp.put("IQLaneChangeTimer", 0) # nudge
mp.put("IQLaneChangeBsmDelay", False)
mp.put("IQEdgeGuard", False)
# ── Visuals (correct param keys matching visuals.py) ─────────────────────
mp.put("IQBlindSpotAlerts", True)
mp.put("IQSteerEffortArc", True)
mp.put("IQRoadNameOverlay", True)
mp.put("IQBlinkerIndicators", True)
mp.put("IQAccelMeter", False)
mp.put("IQLeadReadouts", 0) # 0=off
mp.put("IQDevUIInfo", 0) # 0=off
mp.put("AlphaLongitudinalEnabled", False) # real param; gates ChevronInfo
# ── Display ───────────────────────────────────────────────────────────────
mp.put("OnroadScreenOffBrightness", 0) # 0=auto
mp.put("OnroadScreenOffTimer", 60) # 1m
mp.put("InteractivityTimeout", 0) # default
# ── Software ──────────────────────────────────────────────────────────────
mp.put("DisableUpdates", False)
mp.put("GitBranch", "master-mici")
mp.put("Version", "IQ.Pilot 0.9.5-mici")
# ── Models ────────────────────────────────────────────────────────────────
mp.put("IQLiveSteerDelay", False)
mp.put("IQLaneTurnDesire", False)
mp.put("IQLaneTurnValue", "19.0")
# ── Cruise ────────────────────────────────────────────────────────────────
mp.put("ExperimentalMode", False)
mp.put("IQDynamicMode", False)
mp.put("LongitudinalPersonality", 1)
mp.put("IQSpeedAssistMode", 0) # 0=off
# ── Misc / system ─────────────────────────────────────────────────────────
mp.put("IsMetric", False)
mp.put("UIAccentColor", "#00FFF5") # default neon cyan
# Inject mock into common.params
# IMPORTANT: inject the CLASS (not an instance) so that `Params | None`
# type-union expressions in downstream modules work at import time.
mock_module = types.ModuleType("iqpilot.common.params")
mock_module.Params = MockParams
sys.modules["iqpilot.common.params"] = mock_module
# ── Mock ui_state (both the iqpilot layer and the top-level selfdrive layer) ─
class MockCP:
enableBsm = True
openpilotLongitudinalControl = True
alphaLongitudinalAvailable = False
# Minimal mock for ui_state.sm — returns empty/default objects for any key
class _MockBundle:
internalName = "mock-model"
displayName = "Mock Model"
index = 0
status = None # not downloading
models = []
overrides = []
class _MockModelManager:
availableBundles = []
activeBundle = _MockBundle()
selectedBundle = None # None = not downloading
from iqpilot.cereal import log as _log
class _MockDeviceState:
networkType = _log.DeviceState.NetworkType.wifi
networkStrength = type("NS", (), {"raw": 3})()
egpuDockPresent = True
started = False
freeSpacePercent = 50.0
memoryUsagePercent = 40
class _MockSM:
"""Minimal SubMaster-like dict that returns sensible defaults."""
_data = {
"iqModelManager": _MockModelManager(),
"deviceState": _MockDeviceState(),
}
def __getitem__(self, key):
return self._data.get(key, type("Empty", (), {"enabled": False})())
@property
def updated(self):
return type("U", (), {"__getitem__": lambda s, k: False})()
@property
def alive(self):
return type("A", (), {"__getitem__": lambda s, k: True})()
@property
def valid(self):
return type("V", (), {"__getitem__": lambda s, k: True})()
@property
def frame(self): return 0
def __contains__(self, key): return True
class MockUIState:
CP = MockCP()
params = mp
sm = _MockSM()
started = False
ignition = False
is_metric = False
has_longitudinal_control = True
always_on_dm = False
recording_audio = False
personality = 1 # standard
custom_interactive_timeout = 0
light_sensor = -1.0
is_release = False
# IQ-specific extras
aol_enabled = False
aol_state = 0
def is_offroad(self): return True
def is_onroad(self): return False
def add_offroad_transition_callback(self, cb): pass
def add_engaged_transition_callback(self, cb): pass
def update_params(self): pass
@property
def engaged(self): return False
_mock_ui_state = MockUIState()
# iqpilot.selfdrive.ui.ui_state (base openpilot layer — hosts the IQ UI state classes/enums)
from enum import Enum, IntEnum
class _UIStatus(Enum):
DISENGAGED = "disengaged"
ENGAGED = "engaged"
OVERRIDE = "override"
LAT_ONLY = "lat_only"
LONG_ONLY = "long_only"
class _OnroadTimerStatus(Enum):
NONE = 0
PAUSE = 1
RESUME = 2
class _OnroadBrightness(IntEnum):
AUTO = 0
AUTO_DARK = 1
mock_base_ui_mod = types.ModuleType("iqpilot.selfdrive.ui.ui_state")
mock_base_ui_mod.ui_state = _mock_ui_state
mock_base_ui_mod.UIStatus = _UIStatus
mock_base_ui_mod.OnroadTimerStatus = _OnroadTimerStatus
mock_base_ui_mod.OnroadBrightness = _OnroadBrightness
mock_base_ui_mod.device = type("MockDevice", (), {"awake": True})()
sys.modules["iqpilot.selfdrive.ui.ui_state"] = mock_base_ui_mod
return mp
# ─────────────────────────────────────────────────────────────────────────────
# Panel loader
# ─────────────────────────────────────────────────────────────────────────────
def load_panel(name: str):
"""
Instantiate a MICI panel/layout by name.
Returns a Widget instance with set_rect() already called.
"""
rect = rl.Rectangle(0, 0, MICI_W, MICI_H)
layouts = {
"steering": ("iqpilot.ui.mici.layouts.steering", "SteeringLayoutMici"),
"visuals": ("iqpilot.ui.mici.layouts.visuals", "VisualsLayoutMici"),
"display": ("iqpilot.ui.mici.layouts.display", "DisplayLayoutMici"),
"software": ("iqpilot.ui.mici.layouts.software", "SoftwareLayoutMici"),
"cruise": ("iqpilot.ui.mici.layouts.cruise", "CruiseLayoutMici"),
"trips": ("iqpilot.ui.mici.layouts.trips", "TripsLayoutMici"),
"osm": ("iqpilot.ui.mici.layouts.osm", "OSMLayoutMici"),
"models": ("iqpilot.ui.mici.layouts.models", "ModelsLayoutMici"),
"toggles": ("iqpilot.selfdrive.ui.mici.layouts.settings.toggles", "TogglesLayoutMici"),
"device": ("iqpilot.selfdrive.ui.mici.layouts.settings.device", "DeviceLayoutMici"),
"developer":("iqpilot.selfdrive.ui.mici.layouts.settings.developer","DeveloperLayoutMici"),
"home": ("iqpilot.selfdrive.ui.mici.layouts.home", "MiciHomeLayout"),
"settings": ("iqpilot.ui.mici.layouts.settings", "IQMiciSettingsLayout"),
}
if name not in layouts:
raise ValueError(f"Unknown panel '{name}'. Choose from: {', '.join(layouts)}")
# All IQ.Pilot MICI layout panels accept an optional back_callback
NEEDS_BACK_CB = {
"steering", "visuals", "display", "software", "cruise",
"trips", "osm", "models", "toggles", "device", "developer",
}
mod_path, cls_name = layouts[name]
mod = importlib.import_module(mod_path)
cls = getattr(mod, cls_name)
if name in NEEDS_BACK_CB:
widget = cls(back_callback=lambda: None)
else:
widget = cls()
widget.set_rect(rect)
widget.show_event()
eg = os.environ.get("IQ_EGPU_STATE")
mc = os.environ.get("IQ_MAC_STATE")
if (eg or mc) and hasattr(widget, "_egpu_state"):
widget._egpu_state = eg or None
widget._mac_state = mc or None
widget._egpu_progress = float(os.environ.get("IQ_EGPU_PROGRESS", "0") or 0)
widget._mac_progress = float(os.environ.get("IQ_MAC_PROGRESS", "0") or 0)
widget._update_dock_status = lambda: None
return widget
# ─────────────────────────────────────────────────────────────────────────────
# Rendering helpers
# ─────────────────────────────────────────────────────────────────────────────
def _draw_chrome(panel_name: str, scale: float, canvas_x: int, canvas_y: int):
"""Draw the preview window chrome: background, label, dimension hint."""
# Nothing to draw outside the canvas — the window IS the canvas (+ padding)
pass
def _render_frame(widget, render_tex: rl.RenderTexture, canvas_x: int, canvas_y: int, scale: float, panel_name: str):
"""
Render one frame:
1. Draw the MICI widget into render_tex (536×240 offscreen)
2. Blit the texture into the window at the correct position + scale
3. Draw chrome overlays (panel label, grid, etc.)
"""
# Draw into the MICI-sized render texture
rl.begin_texture_mode(render_tex)
rl.clear_background(rl.Color(0, 0, 0, 255))
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
rl.end_texture_mode()
# Blit to screen (flip Y because OpenGL textures are upside-down)
src = rl.Rectangle(0, 0, MICI_W, -MICI_H) # negative H = flip
dst = rl.Rectangle(canvas_x, canvas_y, MICI_W * scale, MICI_H * scale)
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
# Panel name label bottom-left
rl.draw_text(panel_name.upper(), canvas_x + 6, canvas_y + int(MICI_H * scale) + 6,
14, rl.Color(120, 120, 120, 180))
# Dimension hint bottom-right
hint = f"{MICI_W}×{MICI_H} (×{scale:.1f})"
hint_w = rl.measure_text(hint, 12)
win_w = rl.get_screen_width()
rl.draw_text(hint, win_w - hint_w - 8, canvas_y + int(MICI_H * scale) + 6,
12, rl.Color(80, 80, 80, 160))
# ─────────────────────────────────────────────────────────────────────────────
# Screenshot mode
# ─────────────────────────────────────────────────────────────────────────────
def run_screenshot(panel_name: str, args, out_dir: Path) -> list[Path]:
"""
Render `args.frames` frames (so animations settle), then take `args.shots`
screenshots 0.5s apart. Returns list of saved PNG paths.
Uses gui_app.init_window() so fonts are loaded correctly, and the SCALE
env var (set to args.scale in main()) controls window size.
"""
from iqpilot.system.ui.lib.application import gui_app
gui_app.init_window(f"MICI Preview — {panel_name}", fps=args.fps)
# Offscreen MICI-resolution render texture
render_tex = rl.load_render_texture(MICI_W, MICI_H)
# Load widget AFTER window/fonts are initialized
widget = load_panel(panel_name)
saved: list[Path] = []
frame = 0
shots_taken = 0
next_shot_frame = args.frames # first shot after settle
# Display at native scaled window coords (0,0 → screen size)
win_w = rl.get_screen_width()
win_h = rl.get_screen_height()
while not rl.window_should_close() and shots_taken < args.shots:
rl.begin_drawing()
rl.clear_background(CHROME_BG)
# Render widget into the MICI-sized texture, then blit full-window
rl.begin_texture_mode(render_tex)
rl.clear_background(rl.BLACK)
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
rl.end_texture_mode()
# Blit the render texture to fill the whole window (Y-flipped)
src = rl.Rectangle(0, 0, MICI_W, -MICI_H)
dst = rl.Rectangle(0, 0, win_w, win_h)
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
# Settle progress bar
if frame < args.frames:
pct = frame / args.frames
rl.draw_rectangle(0, win_h - 3, int(win_w * pct), 3, rl.Color(0, 255, 245, 140))
rl.draw_text(f"settling {frame}/{args.frames}", 6, 6, 11, rl.Color(100, 100, 100, 160))
rl.end_drawing()
frame += 1
if frame == next_shot_frame:
# Read from render texture (native MICI res, no padding to crop)
img = rl.load_image_from_texture(render_tex.texture)
rl.image_flip_vertical(img)
# Save at 2× native for clarity on Retina displays
rl.image_resize(img, MICI_W * 2, MICI_H * 2)
out_dir.mkdir(parents=True, exist_ok=True)
ts = int(time.time() * 1000)
path = out_dir / f"mici_{panel_name}_{shots_taken + 1:02d}_{ts}.png"
rl.export_image(img, str(path))
rl.unload_image(img)
saved.append(path)
print(f" 📸 saved: {path}")
shots_taken += 1
next_shot_frame += int(args.fps * 0.5) # 0.5s between shots
rl.unload_render_texture(render_tex)
rl.close_window()
return saved
# ─────────────────────────────────────────────────────────────────────────────
# Video mode
# ─────────────────────────────────────────────────────────────────────────────
def run_video(panel_name: str, args, out_dir: Path) -> Path:
"""
Render args.duration seconds at args.fps, pipe raw RGBA frames to ffmpeg → MP4.
Returns the output MP4 path.
"""
from iqpilot.system.ui.lib.application import gui_app
out_dir.mkdir(parents=True, exist_ok=True)
ts = int(time.time())
mp4_path = out_dir / f"mici_{panel_name}_{ts}.mp4"
# Launch ffmpeg to accept raw RGBA frames at MICI native resolution
ffmpeg = subprocess.Popen([
"ffmpeg", "-v", "warning", "-nostats",
"-f", "rawvideo", "-pix_fmt", "rgba",
"-s", f"{MICI_W}x{MICI_H}",
"-r", str(args.fps),
"-i", "pipe:0",
"-vf", "vflip,format=yuv420p",
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
"-y", str(mp4_path),
], stdin=subprocess.PIPE)
# Write-queue so rendering doesn't block on ffmpeg
frame_queue: queue.Queue = queue.Queue(maxsize=args.fps * 2)
stop_event = threading.Event()
def _writer():
while not stop_event.is_set() or not frame_queue.empty():
try:
data = frame_queue.get(timeout=0.1)
ffmpeg.stdin.write(data)
except queue.Empty:
pass
ffmpeg.stdin.close()
writer_thread = threading.Thread(target=_writer, daemon=True)
writer_thread.start()
gui_app.init_window(f"MICI Preview (recording) — {panel_name}", fps=args.fps)
render_tex = rl.load_render_texture(MICI_W, MICI_H)
widget = load_panel(panel_name)
win_w = rl.get_screen_width()
win_h = rl.get_screen_height()
total_frames = int(args.fps * args.duration)
frame = 0
settle = min(args.frames, total_frames // 4)
print(f" 🎬 recording {args.duration}s at {args.fps}fps → {mp4_path.name}")
while not rl.window_should_close() and frame < total_frames:
rl.begin_drawing()
rl.clear_background(CHROME_BG)
rl.begin_texture_mode(render_tex)
rl.clear_background(rl.BLACK)
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
rl.end_texture_mode()
src = rl.Rectangle(0, 0, MICI_W, -MICI_H)
dst = rl.Rectangle(0, 0, win_w, win_h)
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
# REC progress bar
pct = frame / total_frames
rl.draw_rectangle(0, win_h - 3, int(win_w * pct), 3, rl.Color(255, 80, 80, 180))
rl.draw_text(f"REC {frame}/{total_frames}", 6, 6, 11, rl.Color(255, 80, 80, 200))
rl.end_drawing()
# Queue raw RGBA from MICI-res texture for ffmpeg
if frame >= settle:
import ctypes
img = rl.load_image_from_texture(render_tex.texture)
colors = rl.load_image_colors(img)
raw = bytes(ctypes.string_at(colors, MICI_W * MICI_H * 4))
rl.unload_image_colors(colors)
rl.unload_image(img)
try:
frame_queue.put(raw, timeout=1.0)
except queue.Full:
pass
frame += 1
rl.unload_render_texture(render_tex)
rl.close_window()
stop_event.set()
writer_thread.join(timeout=10)
ffmpeg.wait(timeout=15)
print(f" ✅ video saved: {mp4_path}")
return mp4_path
# ─────────────────────────────────────────────────────────────────────────────
# Live / hot-reload mode
# ─────────────────────────────────────────────────────────────────────────────
def run_live(panel_name: str, args, mock_params):
"""
Interactive window with hot-reload.
Watches the source file of the selected panel; re-imports it on save.
Press S to take a screenshot, R to force reload, Q/Esc to quit.
"""
from iqpilot.system.ui.lib.application import gui_app
gui_app.init_window(f"MICI Live — {panel_name} [S=shot R=reload Q=quit]", fps=args.fps)
render_tex = rl.load_render_texture(MICI_W, MICI_H)
win_w = rl.get_screen_width()
win_h = rl.get_screen_height()
widget = load_panel(panel_name)
last_mtime: dict[str, float] = {}
def _watch_paths() -> list[Path]:
"""Files to watch for changes (panel module + shared theme/button)."""
mods = [
ROOT / "iqpilot/selfdrive/ui/iqpilot/mici/layouts" / f"{panel_name}.py",
ROOT / "iqpilot/selfdrive/ui/iqpilot/theme.py",
ROOT / "iqpilot/selfdrive/ui/mici/widgets/button.py",
]
return [p for p in mods if p.exists()]
def _needs_reload() -> bool:
for p in _watch_paths():
mtime = p.stat().st_mtime
if last_mtime.get(str(p), 0) != mtime:
last_mtime[str(p)] = mtime
return True
return False
def _reload():
nonlocal widget
print(" 🔄 reloading...")
# Invalidate cached modules so importlib picks up changes
prefix = "iqpilot.ui.mici.layouts"
for key in list(sys.modules.keys()):
if key.startswith(prefix) or key == "iqpilot.ui.theme":
del sys.modules[key]
try:
widget = load_panel(panel_name)
print(" ✅ reloaded OK")
except Exception as e:
print(f" ❌ reload error: {e}")
# Seed mtimes
for p in _watch_paths():
last_mtime[str(p)] = p.stat().st_mtime
out_dir = Path(args.out)
shot_count = 0
while not rl.window_should_close():
# Check for file changes
if _needs_reload():
_reload()
# Key bindings
if rl.is_key_pressed(rl.KeyboardKey.KEY_Q) or rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
break
if rl.is_key_pressed(rl.KeyboardKey.KEY_R):
_reload()
if rl.is_key_pressed(rl.KeyboardKey.KEY_S):
# Take screenshot from render texture (native MICI res)
img = rl.load_image_from_texture(render_tex.texture)
rl.image_flip_vertical(img)
rl.image_resize(img, MICI_W * 2, MICI_H * 2)
out_dir.mkdir(parents=True, exist_ok=True)
ts = int(time.time() * 1000)
path = out_dir / f"mici_{panel_name}_live_{shot_count:03d}_{ts}.png"
rl.export_image(img, str(path))
rl.unload_image(img)
shot_count += 1
print(f" 📸 screenshot saved: {path}")
if args.open:
subprocess.Popen(["open", str(path)])
rl.begin_drawing()
rl.clear_background(CHROME_BG)
# Render widget into MICI-sized texture, blit to full window
rl.begin_texture_mode(render_tex)
rl.clear_background(rl.BLACK)
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
rl.end_texture_mode()
src = rl.Rectangle(0, 0, MICI_W, -MICI_H)
dst = rl.Rectangle(0, 0, win_w, win_h)
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
# Status hint overlay
watch_files = _watch_paths()
hint = f"watching {len(watch_files)} file(s) | S=shot R=reload Q=quit"
rl.draw_text(hint, 6, win_h - 18, 11, rl.Color(80, 80, 80, 160))
rl.end_drawing()
rl.unload_render_texture(render_tex)
rl.close_window()
# ─────────────────────────────────────────────────────────────────────────────
# Entry point
# ─────────────────────────────────────────────────────────────────────────────
def parse_args():
p = argparse.ArgumentParser(
description="IQ.Pilot MICI UI Preview Tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
p.add_argument("--panel", default="steering",
help="Panel name or 'all'")
p.add_argument("--mode", default="screenshot",
choices=["screenshot", "video", "live"],
help="Capture mode")
p.add_argument("--frames", type=int, default=90,
help="Settle frames before screenshot")
p.add_argument("--shots", type=int, default=1,
help="Number of screenshots")
p.add_argument("--duration", type=float, default=4.0,
help="Video duration in seconds")
p.add_argument("--fps", type=int, default=60,
help="Render FPS")
p.add_argument("--scale", type=float, default=2.5,
help="Window scale multiplier (1.0 = native 536×240)")
p.add_argument("--out", default=str(DEFAULT_OUT),
help="Output directory")
p.add_argument("--open", action="store_true", default=True,
help="Open output after capture (macOS)")
p.add_argument("--no-open", dest="open", action="store_false")
p.add_argument("--mock", action="store_true", default=True,
help="Use mock UI state (no live process needed)")
p.add_argument("--no-mock", dest="mock", action="store_false")
p.add_argument("--accent", default=None,
help="Override neon accent color, e.g. '#FF6B00'")
return p.parse_args()
def main():
args = parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
# Set SCALE env var BEFORE gui_app module is imported (it reads it at import time)
os.environ["SCALE"] = str(args.scale)
# Apply mock state early (before any widget imports)
mock_params = None
if args.mock:
print(" 🎭 using mock UI state (--no-mock to use live params)")
mock_params = _patch_mock_state()
# Override accent color if requested
if args.accent:
if mock_params:
mock_params.put("UIAccentColor", args.accent)
print(f" 🎨 accent color: {args.accent}")
panels = ALL_PANELS if args.panel == "all" else [args.panel]
mode = args.mode
if mode == "live" and len(panels) > 1:
print(" ⚠️ live mode only supports a single panel. Using first panel.")
panels = panels[:1]
all_outputs: list[Path] = []
for panel_name in panels:
print(f"\n{mode.upper()} — panel: {panel_name}")
try:
if mode == "live":
run_live(panel_name, args, mock_params)
elif mode == "screenshot":
saved = run_screenshot(panel_name, args, out_dir)
all_outputs.extend(saved)
elif mode == "video":
mp4 = run_video(panel_name, args, out_dir)
all_outputs.append(mp4)
except Exception as e:
print(f" ❌ failed for panel '{panel_name}': {e}")
import traceback; traceback.print_exc()
if rl.is_window_ready():
rl.close_window()
# Open all outputs at once
if args.open and all_outputs:
print(f"\n 📂 opening {len(all_outputs)} output(s)...")
subprocess.Popen(["open"] + [str(p) for p in all_outputs])
print("\n ✨ done\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,296 @@
#!/usr/bin/env python3
import argparse
import atexit
import os
from select import select
import signal
import socket
import struct
import sys
import termios
import threading
import time
import numpy as np
from inputs import UnpluggedError, get_gamepad
from iqpilot.cereal import messaging
from iqpilot.common.params import Params
from iqpilot.common.realtime import Ratekeeper
from iqpilot.system.hardware import HARDWARE
REMOTE_PORT_DEFAULT = 8765
REMOTE_TIMEOUT_S = 0.25
REMOTE_PUBLISH_HZ = 30
LOCAL_PUBLISH_HZ = 100
class KBHit:
def __init__(self) -> None:
self.stdin_fd = sys.stdin.fileno()
self.old_term = termios.tcgetattr(self.stdin_fd)
self.new_term = self.old_term.copy()
self.new_term[3] &= ~(termios.ICANON | termios.ECHO)
termios.tcsetattr(self.stdin_fd, termios.TCSAFLUSH, self.new_term)
atexit.register(self.set_normal_term)
def set_normal_term(self) -> None:
termios.tcsetattr(self.stdin_fd, termios.TCSAFLUSH, self.old_term)
@staticmethod
def getch() -> str:
return sys.stdin.read(1)
@staticmethod
def kbhit():
return select([sys.stdin], [], [], 0)[0] != []
class Keyboard:
def __init__(self):
self.kb = KBHit()
self.axis_increment = 0.05 # 5% of full actuation each key press
self.axes_map = {'w': 'gb', 's': 'gb',
'a': 'steer', 'd': 'steer'}
self.axes_values = {'gb': 0., 'steer': 0.}
self.axes_order = ['gb', 'steer']
self.cancel = False
self.idle_sleep_s = 0.0
def update(self):
key = self.kb.getch().lower()
self.cancel = False
if key == 'r':
self.axes_values = dict.fromkeys(self.axes_values, 0.)
elif key == 'c':
self.cancel = True
elif key in self.axes_map:
axis = self.axes_map[key]
incr = self.axis_increment if key in ['w', 'a'] else -self.axis_increment
self.axes_values[axis] = float(np.clip(self.axes_values[axis] + incr, -1, 1))
else:
return False
return True
def get_buttons(self):
return [False, self.cancel]
class Joystick:
def __init__(self):
# This class supports a PlayStation 5 DualSense controller on the comma 3X
# Using both analog sticks: left stick Y for gas/brake, right stick X for steering
self.cancel_button = 'BTN_NORTH' # BTN_NORTH=X/triangle
if HARDWARE.get_device_type() == 'pc':
accel_axis = 'ABS_Y' # Left stick Y-axis
steer_axis = 'ABS_RX' # Right stick X-axis
self.flip_map = {} # No flipping needed
else:
accel_axis = 'ABS_Y' # Left stick Y-axis
steer_axis = 'ABS_Z' # Right stick X-axis
self.flip_map = {} # No flipping needed
self.min_axis_value = {accel_axis: 0., steer_axis: 0.}
self.max_axis_value = {accel_axis: 255., steer_axis: 255.}
self.axes_values = {accel_axis: 0., steer_axis: 0.}
self.axes_order = [accel_axis, steer_axis]
self.cancel = False
self.idle_sleep_s = 0.0
def update(self):
try:
joystick_event = get_gamepad()[0]
except (OSError, UnpluggedError):
self.axes_values = dict.fromkeys(self.axes_values, 0.)
return False
event = (joystick_event.code, joystick_event.state)
# flip left trigger to negative accel
if event[0] in self.flip_map:
event = (self.flip_map[event[0]], -event[1])
if event[0] == self.cancel_button:
if event[1] == 1:
self.cancel = True
elif event[1] == 0: # state 0 is falling edge
self.cancel = False
elif event[0] in self.axes_values:
self.max_axis_value[event[0]] = max(event[1], self.max_axis_value[event[0]])
self.min_axis_value[event[0]] = min(event[1], self.min_axis_value[event[0]])
norm = -float(np.interp(event[1], [self.min_axis_value[event[0]], self.max_axis_value[event[0]]], [-1., 1.]))
norm = norm if abs(norm) > 0.03 else 0. # center can be noisy, deadzone of 3%
self.axes_values[event[0]] = norm
else:
return False
return True
def get_buttons(self):
return [False, self.cancel]
class RemoteJoystick:
def __init__(self, host: str, port: int):
self.addr = (host, port)
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.socket.bind(self.addr)
self.socket.settimeout(0.1)
self.axes_values = {'gb': 0.0, 'steer': 0.0}
self.axes_order = ['gb', 'steer']
self.buttons = [False, False]
self.last_update = 0.0
self.authenticated = False
self.client_addr = None
self.idle_sleep_s = 0.01
def _clamp(self, value: float) -> float:
return float(np.clip(value, -1.0, 1.0))
def _handle_timeout(self, now: float) -> None:
if self.authenticated and (now - self.last_update) > REMOTE_TIMEOUT_S:
self.axes_values = {'gb': 0.0, 'steer': 0.0}
self.buttons = [False, False]
def _send_auth_ok(self, addr) -> None:
try:
self.socket.sendto(bytes([1]), addr)
except OSError:
pass
def update(self):
now = time.monotonic()
try:
data, addr = self.socket.recvfrom(64)
except TimeoutError:
self._handle_timeout(now)
return False
except OSError:
return False
if not data:
return False
msg_type = data[0]
if msg_type == 0:
self.client_addr = addr
self.authenticated = True
self._send_auth_ok(addr)
return True
if msg_type == 2:
try:
payload = data[1:].decode("utf-8", errors="strict").strip()
steer_s, accel_s, engage_s, disengage_s = payload.split(",", 3)
steer = float(steer_s)
accel = float(accel_s)
engage = engage_s == "1"
disengage = disengage_s == "1"
except (UnicodeDecodeError, ValueError):
return False
self.axes_values['steer'] = self._clamp(steer)
self.axes_values['gb'] = self._clamp(accel)
self.buttons = [engage, disengage]
self.last_update = now
return True
if msg_type != 1 or len(data) < 9:
return False
steer, accel = struct.unpack_from("<ff", data, 1)
engage = bool(data[9]) if len(data) > 9 else False
disengage = bool(data[10]) if len(data) > 10 else False
self.axes_values['steer'] = self._clamp(steer)
self.axes_values['gb'] = self._clamp(accel)
self.buttons = [engage, disengage]
self.last_update = now
return True
def get_buttons(self):
return self.buttons
def send_thread(joystick, show_values: bool):
pm = messaging.PubMaster(['testJoystick'])
publish_hz = REMOTE_PUBLISH_HZ if isinstance(joystick, RemoteJoystick) else LOCAL_PUBLISH_HZ
rk = Ratekeeper(publish_hz, print_delay_threshold=None)
while True:
if show_values and rk.frame % 20 == 0:
print('\n' + ', '.join(f'{name}: {round(v, 3)}' for name, v in joystick.axes_values.items()))
joystick_msg = messaging.new_message('testJoystick')
joystick_msg.valid = True
joystick_msg.testJoystick.axes = [joystick.axes_values[ax] for ax in joystick.axes_order]
joystick_msg.testJoystick.buttons = joystick.get_buttons()
pm.send('testJoystick', joystick_msg)
rk.keep_time()
def joystick_control_thread(joystick, show_values: bool):
Params().put_bool('JoystickDebugMode', True)
try:
threading.Thread(target=send_thread, args=(joystick, show_values), daemon=True).start()
while True:
updated = joystick.update()
if not updated and joystick.idle_sleep_s > 0:
time.sleep(joystick.idle_sleep_s)
finally:
Params().put_bool('JoystickDebugMode', False)
def main():
joystick_control_thread(Joystick(), True)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Publishes events from your joystick to control your car.\n' +
'openpilot must be offroad before starting joystick_control. This tool supports ' +
'a PlayStation 5 DualSense controller on the comma 3X.',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('--keyboard', action='store_true', help='Use your keyboard instead of a joystick')
parser.add_argument('--remote', action='store_true', help='Listen for UDP joystick input')
parser.add_argument('--listen', type=int, default=REMOTE_PORT_DEFAULT, help='UDP port for remote joystick input')
parser.add_argument('--listen-address', default='0.0.0.0', help='UDP address to bind for remote input')
args = parser.parse_args()
if not Params().get_bool("IsOffroad") and "ZMQ" not in os.environ:
print("The car must be off before running joystick_control.")
exit()
if args.remote and args.keyboard:
print("Choose only one input mode.")
exit()
print()
if args.remote:
print(f'Listening for remote joystick on {args.listen_address}:{args.listen}')
elif args.keyboard:
print('Gas/brake control: `W` and `S` keys')
print('Steering control: `A` and `D` keys')
print('Buttons')
print('- `R`: Resets axes')
print('- `C`: Cancel cruise control')
else:
print('Using joystick, make sure to run cereal/messaging/bridge on your device if running over the network!')
print('If not running on a comma device, the mapping may need to be adjusted.')
def handle_exit(signum, _frame):
Params().put_bool('JoystickDebugMode', False)
raise SystemExit
signal.signal(signal.SIGINT, handle_exit)
signal.signal(signal.SIGTERM, handle_exit)
if args.remote:
joystick = RemoteJoystick(args.listen_address, int(args.listen))
joystick_control_thread(joystick, False)
else:
joystick = Keyboard() if args.keyboard else Joystick()
joystick_control_thread(joystick, True)

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
import math
import numpy as np
from iqpilot.cereal import messaging, car, custom
from iqdbc.car.vehicle_model import VehicleModel
from iqpilot.common.realtime import DT_CTRL, Ratekeeper
from iqpilot.common.params import Params
from iqpilot.common.swaglog import cloudlog
LongCtrlState = car.CarControl.Actuators.LongControlState
MAX_LAT_ACCEL = 5.0
MAX_STEERING_ANGLE_DEG = 500.0
ACCEL_RELEASE_THRESHOLD = 0.01
DECEL_REQUEST_THRESHOLD = -0.02
STOPPING_HOLD_SPEED_MARGIN = 0.3
STOPPING_SPEED = 0.25
def get_lateral_joystick_outputs(CP: car.CarParams, VM: VehicleModel, v_ego: float, roll: float, steer_axis: float) -> tuple[float, float, float]:
steer_axis = float(np.clip(steer_axis, -1, 1))
steering_angle_deg = steer_axis * MAX_STEERING_ANGLE_DEG
curvature = -VM.calc_curvature(math.radians(steering_angle_deg), v_ego, roll)
if CP.steerControlType in (car.CarParams.SteerControlType.angle, car.CarParams.SteerControlType.curvatureDEPRECATED):
return 0.0, steering_angle_deg, curvature
max_curvature = MAX_LAT_ACCEL / max(v_ego ** 2, 5)
max_angle = min(math.degrees(VM.get_steer_from_curvature(max_curvature, v_ego, roll)), MAX_STEERING_ANGLE_DEG)
return steer_axis, steer_axis * max_angle, steer_axis * -max_curvature
def joystickd_thread():
params = Params()
cloudlog.info("joystickd is waiting for CarParams")
CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams)
CP_IQ = messaging.log_from_bytes(params.get("IQCarParams", block=True), custom.IQCarParams)
VM = VehicleModel(CP)
sm = messaging.SubMaster(['carState', 'onroadEvents', 'vehicleParameters', 'selfdriveState', 'iqState', 'testJoystick'], frequency=1. / DT_CTRL)
pm = messaging.PubMaster(['carControl', 'controlsState'])
# Stop-hold behavior for joystick long control:
# - enter hold only when user requested decel and we are near/at stop
# - neutral input does not request decel while rolling
# - release hold on positive accel request
decel_intent_latched = False
stop_hold_latched = False
rk = Ratekeeper(100, print_delay_threshold=None)
while 1:
sm.update(0)
cc_msg = messaging.new_message('carControl')
cc_msg.valid = True
CC = cc_msg.carControl
ss = sm['selfdriveState']
ss_iq = sm['iqState']
aol_enabled = bool(getattr(ss_iq.aol, 'enabled', False))
aol_active = bool(getattr(ss_iq.aol, 'active', False))
joystick_angle_lat_active = aol_active or (
aol_enabled and CP.steerControlType == car.CarParams.SteerControlType.angle
)
CC.enabled = bool(ss.enabled or aol_enabled)
CC.latActive = bool(ss.active or joystick_angle_lat_active) and not sm['carState'].steerFaultTemporary and not sm['carState'].steerFaultPermanent
long_through_override = CP_IQ.longActiveWithGasOverride and CP.openpilotLongitudinalControl
override_longitudinal = any(e.overrideLongitudinal for e in sm['onroadEvents'])
CC.longActive = bool(ss.enabled) and (not override_longitudinal or long_through_override) and CP.openpilotLongitudinalControl
CC.cruiseControl.cancel = sm['carState'].cruiseState.enabled and (not CC.enabled or not CP.pcmCruise)
CC.hudControl.leadDistanceBars = 2
actuators = CC.actuators
# reset joystick if it hasn't been received in a while
should_reset_joystick = sm.recv_frame['testJoystick'] == 0 or (sm.frame - sm.recv_frame['testJoystick'])*DT_CTRL > 0.2
if not should_reset_joystick:
joystick_axes = sm['testJoystick'].axes
else:
joystick_axes = [0.0, 0.0]
if CC.longActive:
accel_cmd = float(np.clip(joystick_axes[0], -1, 1))
actuators.accel = 4.0 * accel_cmd
positive_accel_requested = accel_cmd > ACCEL_RELEASE_THRESHOLD
negative_accel_requested = accel_cmd < DECEL_REQUEST_THRESHOLD
near_stop = sm['carState'].standstill or sm['carState'].vEgo <= (STOPPING_SPEED + STOPPING_HOLD_SPEED_MARGIN)
if positive_accel_requested:
stop_hold_latched = False
decel_intent_latched = False
elif negative_accel_requested:
decel_intent_latched = True
if decel_intent_latched and near_stop and not positive_accel_requested:
stop_hold_latched = True
# If we are moving again and driver is not asking for decel, clear stale hold state.
if stop_hold_latched and sm['carState'].vEgo > (STOPPING_SPEED + STOPPING_HOLD_SPEED_MARGIN) and not negative_accel_requested:
stop_hold_latched = False
decel_intent_latched = False
actuators.longControlState = LongCtrlState.stopping if stop_hold_latched else LongCtrlState.pid
CC.cruiseControl.resume = positive_accel_requested
else:
decel_intent_latched = False
stop_hold_latched = False
if CC.latActive:
torque, steering_angle_deg, curvature = get_lateral_joystick_outputs(CP, VM, sm['carState'].vEgo, sm['vehicleParameters'].roll, joystick_axes[1])
actuators.torque = torque
actuators.steeringAngleDeg = steering_angle_deg
actuators.curvature = curvature
pm.send('carControl', cc_msg)
cs_msg = messaging.new_message('controlsState')
cs_msg.valid = True
controlsState = cs_msg.controlsState
controlsState.lateralControlState.init('debugState')
lp = sm['vehicleParameters']
steer_angle_without_offset = math.radians(sm['carState'].steeringAngleDeg - lp.angleOffsetDeg)
controlsState.curvature = -VM.calc_curvature(steer_angle_without_offset, sm['carState'].vEgo, lp.roll)
pm.send('controlsState', cs_msg)
rk.keep_time()
def main():
joystickd_thread()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,33 @@
from iqpilot.cereal import car
from iqpilot.tools.joystick.joystickd import get_lateral_joystick_outputs
class StubVehicleModel:
def calc_curvature(self, steer_angle: float, v_ego: float, roll: float) -> float:
return steer_angle
def get_steer_from_curvature(self, curvature: float, v_ego: float, roll: float) -> float:
return curvature
def test_angle_cars_use_angle_outputs():
CP = car.CarParams.new_message()
CP.steerControlType = car.CarParams.SteerControlType.angle
torque, steering_angle_deg, curvature = get_lateral_joystick_outputs(CP, StubVehicleModel(), 20.0, 0.0, 0.5)
assert torque == 0.0
assert steering_angle_deg != 0.0
assert curvature < 0.0
def test_torque_cars_keep_torque_outputs():
CP = car.CarParams.new_message()
CP.steerControlType = car.CarParams.SteerControlType.torque
torque, steering_angle_deg, curvature = get_lateral_joystick_outputs(CP, StubVehicleModel(), 20.0, 0.0, 0.5)
assert torque == 0.5
assert steering_angle_deg != 0.0
assert curvature < 0.0

View File

62
iqpilot/tools/lib/api.py Normal file
View File

@@ -0,0 +1,62 @@
import os
import json
import requests
from requests.adapters import HTTPAdapter, Retry
from iqpilot.system.hardware.hw import Paths
API_HOST = os.getenv('API_HOST', 'https://api-iqlabs.konn3kt.com')
# TODO: this should be merged into common.api
class CommaApi:
def __init__(self, token=None):
self.session = requests.Session()
self.session.headers['User-agent'] = 'OpenpilotTools'
if token:
self.session.headers['Authorization'] = 'JWT ' + token
retries = Retry(total=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
self.session.mount('https://', HTTPAdapter(max_retries=retries))
def request(self, method, endpoint, **kwargs):
with self.session.request(method, API_HOST + '/' + endpoint, **kwargs) as resp:
resp_json = resp.json()
if isinstance(resp_json, dict) and resp_json.get('error'):
if resp.status_code in [401, 403]:
raise UnauthorizedError('Unauthorized. Authenticate with tools/lib/auth.py')
e = APIError(str(resp.status_code) + ":" + resp_json.get('description', str(resp_json['error'])))
e.status_code = resp.status_code
raise e
return resp_json
def get(self, endpoint, **kwargs):
return self.request('GET', endpoint, **kwargs)
def post(self, endpoint, **kwargs):
return self.request('POST', endpoint, **kwargs)
class APIError(Exception):
pass
class UnauthorizedError(Exception):
pass
def get_token():
try:
with open(os.path.join(Paths.config_root(), 'auth.json')) as f:
return json.load(f)['access_token']
except Exception:
return None
def set_token(token):
os.makedirs(Paths.config_root(), exist_ok=True)
with open(os.path.join(Paths.config_root(), 'auth.json'), 'w') as f:
json.dump({'access_token': token}, f)
def clear_token():
try:
os.unlink(os.path.join(Paths.config_root(), 'auth.json'))
except FileNotFoundError:
pass

114
iqpilot/tools/lib/auth.py Executable file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Usage::
usage: auth.py [-h] [{github,jwt}] [jwt]
Login to your konn3kt account
positional arguments:
{github,jwt}
jwt
optional arguments:
-h, --help show this help message and exit
Examples::
./auth.py # Log in with GitHub
./auth.py jwt ey..hw # Log in with a pre-issued JWT (for CI)
"""
import argparse
import sys
import pprint
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any
from urllib.parse import parse_qs, urlencode
from iqpilot.tools.lib.api import APIError, CommaApi, UnauthorizedError, set_token, get_token
PORT = 3000
class ClientRedirectServer(HTTPServer):
query_params: dict[str, Any] = {}
class ClientRedirectHandler(BaseHTTPRequestHandler):
def do_GET(self):
if '?' in self.path:
query_parsed = parse_qs(self.path.split('?', 1)[1], keep_blank_values=True)
if 'code' in query_parsed or 'error' in query_parsed:
self.server.query_params = query_parsed
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
self.wfile.write(b'Return to the CLI to continue')
def log_message(self, fmt, *fmt_args):
sys.stderr.write(f"[auth callback] {self.address_string()} {fmt % fmt_args}\n")
def auth_redirect_link(method):
if method != 'github':
raise NotImplementedError(f"no redirect implemented for method {method}")
params = {
'client_id': 'Ov23lifjMafxJzFatvuB',
'redirect_uri': 'https://api-iqlabs.konn3kt.com/v2/auth/h/redirect/',
'state': f'service,localhost:{PORT}',
'scope': 'read:user',
}
return 'https://github.com/login/oauth/authorize?' + urlencode(params)
def login(method):
oauth_uri = auth_redirect_link(method)
web_server = ClientRedirectServer(('localhost', PORT), ClientRedirectHandler)
print(f'To sign in, use your browser and navigate to {oauth_uri}')
webbrowser.open(oauth_uri, new=2)
while True:
web_server.handle_request()
if 'code' in web_server.query_params:
break
elif 'error' in web_server.query_params:
print('Authentication Error: "{}". Description: "{}" '.format(
web_server.query_params['error'],
web_server.query_params.get('error_description')), file=sys.stderr)
break
try:
auth_resp = CommaApi().post('v2/auth/', data={'code': web_server.query_params['code'], 'provider': web_server.query_params['provider']})
set_token(auth_resp['access_token'])
except APIError as e:
print(f'Authentication Error: {e}', file=sys.stderr)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Login to your konn3kt account')
parser.add_argument('method', default='github', const='github', nargs='?', choices=['github', 'jwt'])
parser.add_argument('jwt', nargs='?')
args = parser.parse_args()
if args.method == 'jwt':
if args.jwt is None:
print("method JWT selected, but no JWT was provided")
exit(1)
set_token(args.jwt)
else:
login(args.method)
try:
me = CommaApi(token=get_token()).get('/v1/me')
print("Authenticated!")
pprint.pprint(me)
except UnauthorizedError:
print("Got invalid JWT")
exit(1)

View File

@@ -0,0 +1,58 @@
import os
import io
import posixpath
import socket
from functools import cache
from iqpilot.common.utils import retry
from urllib.parse import urlparse
from iqpilot.tools.lib.url_file import URLFile
DATA_ENDPOINT = os.getenv("DATA_ENDPOINT", "http://data-raw.comma.internal/")
@cache
@retry(delay=0.0)
def internal_source_available(url: str) -> bool:
if os.path.isdir(url):
return True
try:
hostname = urlparse(url).hostname
port = urlparse(url).port or 80
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.5)
s.connect((hostname, port))
return True
except (socket.gaierror, ConnectionRefusedError):
pass
return False
def resolve_name(fn):
if fn.startswith("cd:/"):
return posixpath.join(DATA_ENDPOINT, fn[4:])
return fn
@cache
def file_exists(fn):
fn = resolve_name(fn)
if fn.startswith(("http://", "https://")):
return URLFile(fn).get_length_online() != -1
return os.path.exists(fn)
class DiskFile(io.BufferedReader):
def get_multi_range(self, ranges: list[tuple[int, int]]) -> list[bytes]:
parts = []
for r in ranges:
self.seek(r[0])
parts.append(self.read(r[1] - r[0]))
return parts
def FileReader(fn):
fn = resolve_name(fn)
if fn.startswith(("http://", "https://")):
return URLFile(fn)
else:
return DiskFile(open(fn, "rb"))

View File

@@ -0,0 +1,176 @@
import os
import subprocess
import json
import logging
from collections.abc import Iterator
from collections import OrderedDict
import numpy as np
from iqpilot.tools.lib.filereader import FileReader, resolve_name
from iqpilot.tools.lib.vidindex import hevc_index
class DataUnreadableError(Exception):
pass
logger = logging.getLogger("tools")
HEVC_SLICE_B = 0
HEVC_SLICE_P = 1
HEVC_SLICE_I = 2
class LRUCache:
def __init__(self, capacity: int):
self._cache: OrderedDict = OrderedDict()
self.capacity = capacity
def __getitem__(self, key):
self._cache.move_to_end(key)
return self._cache[key]
def __setitem__(self, key, value):
self._cache[key] = value
if len(self._cache) > self.capacity:
self._cache.popitem(last=False)
def __contains__(self, key):
return key in self._cache
def assert_hvec(fn: str) -> None:
with FileReader(fn) as f:
header = f.read(4)
if len(header) == 0:
raise DataUnreadableError(f"{fn} is empty")
elif header == b"\x00\x00\x00\x01":
if 'hevc' not in fn:
raise NotImplementedError(fn)
def decompress_video_data(rawdat, w, h, pix_fmt="rgb24", vid_fmt='hevc', hwaccel="auto", loglevel="info") -> np.ndarray:
threads = os.getenv("FFMPEG_THREADS", "0")
args = ["ffmpeg", "-v", loglevel,
"-threads", threads,
"-hwaccel", hwaccel,
"-c:v", "hevc",
"-vsync", "0",
"-f", vid_fmt,
"-flags2", "showall",
"-i", "-",
"-f", "rawvideo",
"-pix_fmt", pix_fmt,
"-"]
dat = subprocess.check_output(args, input=rawdat)
ret: np.ndarray
if pix_fmt == "rgb24":
ret = np.frombuffer(dat, dtype=np.uint8).reshape(-1, h, w, 3)
elif pix_fmt in ["nv12", "yuv420p"]:
ret = np.frombuffer(dat, dtype=np.uint8).reshape(-1, (h*w*3//2))
else:
raise NotImplementedError(f"Unsupported pixel format: {pix_fmt}")
return ret
def ffprobe(fn, fmt=None):
fn = resolve_name(fn)
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams"]
if fmt:
cmd += ["-f", fmt]
cmd += ["-i", "-"]
try:
with FileReader(fn) as f:
ffprobe_output = subprocess.check_output(cmd, input=f.read(4096))
except subprocess.CalledProcessError as e:
raise DataUnreadableError(fn) from e
return json.loads(ffprobe_output)
def get_index_data(fn: str, index_data: dict|None = None):
if index_data is None:
index_data = get_video_index(fn)
if index_data is None:
raise DataUnreadableError(f"Failed to index {fn!r}")
stream = index_data["probe"]["streams"][0]
return index_data["index"], index_data["global_prefix"], stream["width"], stream["height"]
def get_video_index(fn):
assert_hvec(fn)
frame_types, dat_len, prefix = hevc_index(fn)
index = np.array(frame_types + [(0xFFFFFFFF, dat_len)], dtype=np.uint32)
probe = ffprobe(fn, "hevc")
return {
'index': index,
'global_prefix': prefix,
'probe': probe
}
class FfmpegDecoder:
def __init__(self, fn: str, index_data: dict|None = None,
pix_fmt: str = "rgb24", hwaccel="auto", loglevel="quiet"):
self.fn = fn
self.index, self.prefix, self.w, self.h = get_index_data(fn, index_data)
self.frame_count = len(self.index) - 1 # sentinel row at the end
self.iframes = np.where(self.index[:, 0] == HEVC_SLICE_I)[0]
self.pix_fmt = pix_fmt
self.loglevel, self.hwaccel = loglevel, hwaccel
def _gop_bounds(self, frame_idx: int):
f_b = frame_idx
while f_b > 0 and self.index[f_b, 0] != HEVC_SLICE_I:
f_b -= 1
f_e = frame_idx + 1
while f_e < self.frame_count and self.index[f_e, 0] != HEVC_SLICE_I:
f_e += 1
return f_b, f_e, self.index[f_b, 1], self.index[f_e, 1]
def _decode_gop(self, raw: bytes) -> Iterator[np.ndarray]:
yield from decompress_video_data(raw, self.w, self.h, pix_fmt=self.pix_fmt, hwaccel=self.hwaccel, loglevel=self.loglevel)
def get_gop_start(self, frame_idx: int):
return self.iframes[np.searchsorted(self.iframes, frame_idx, side="right") - 1]
def get_iterator(self, start_fidx: int = 0, end_fidx: int|None = None,
frame_skip: int = 1) -> Iterator[tuple[int, np.ndarray]]:
end_fidx = end_fidx or self.frame_count
fidx = start_fidx
while fidx < end_fidx:
f_b, f_e, off_b, off_e = self._gop_bounds(fidx)
with FileReader(self.fn) as f:
f.seek(off_b)
raw = self.prefix + f.read(off_e - off_b)
# number of frames to discard inside this GOP before the wanted one
for i, frm in enumerate(decompress_video_data(raw, self.w, self.h, self.pix_fmt, hwaccel=self.hwaccel, loglevel=self.loglevel)):
fidx = f_b + i
if fidx >= end_fidx:
return
elif fidx >= start_fidx and (fidx - start_fidx) % frame_skip == 0:
yield fidx, frm
fidx += 1
def FrameIterator(fn: str, index_data: dict|None=None, pix_fmt: str = "rgb24",
start_fidx:int=0, end_fidx=None, frame_skip:int=1, hwaccel="auto", loglevel="quiet") -> Iterator[np.ndarray]:
dec = FfmpegDecoder(fn, pix_fmt=pix_fmt, index_data=index_data, hwaccel=hwaccel, loglevel=loglevel)
for _, frame in dec.get_iterator(start_fidx=start_fidx, end_fidx=end_fidx, frame_skip=frame_skip):
yield frame
class FrameReader:
def __init__(self, fn: str, index_data: dict|None = None, cache_size: int = 30,
pix_fmt: str = "rgb24", hwaccel="auto", loglevel="quiet"):
self.decoder = FfmpegDecoder(fn, index_data=index_data, pix_fmt=pix_fmt, hwaccel=hwaccel, loglevel=loglevel)
self.iframes = self.decoder.iframes
self._cache: LRUCache = LRUCache(cache_size)
self.w, self.h, self.frame_count, = self.decoder.w, self.decoder.h, self.decoder.frame_count
self.pix_fmt = pix_fmt
self.it: Iterator[tuple[int, np.ndarray]] | None = None
self.fidx = -1
def get(self, fidx:int):
if fidx in self._cache: # If frame is cached, return it
return self._cache[fidx]
read_start = self.decoder.get_gop_start(fidx)
if not self.it or fidx < self.fidx or read_start != self.decoder.get_gop_start(self.fidx): # If the frame is in a different GOP, reset the iterator
self.it = self.decoder.get_iterator(read_start)
self.fidx = -1
while self.fidx < fidx:
self.fidx, frame = next(self.it)
self._cache[self.fidx] = frame
return self._cache[fidx]

436
iqpilot/tools/lib/logreader.py Executable file
View File

@@ -0,0 +1,436 @@
#!/usr/bin/env python3
import bz2
from functools import partial
import multiprocessing
import capnp
import enum
import os
import pathlib
import sys
import tqdm
import urllib.parse
import warnings
import zstandard as zstd
import numpy as np
from collections.abc import Callable, Iterable, Iterator
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from iqpilot.cereal import log as capnp_log, messaging
from iqpilot.cereal.services import SERVICE_LIST
from iqpilot.common.swaglog import cloudlog
from iqpilot.tools.lib.filereader import DATA_ENDPOINT, FileReader, file_exists, internal_source_available
from iqpilot.tools.lib.route import Route, SegmentRange, FileName
LogMessage = type[capnp._DynamicStructReader]
LogIterable = Iterable[LogMessage]
RawLogIterable = Iterable[bytes]
FileNames = tuple[str, ...]
Source = Callable[[SegmentRange, list[int], FileNames], dict[int, str]]
InternalUnavailableException = Exception("Internal source not available")
OPENPILOT_CI_BASE_URL = "https://commadataci.blob.core.windows.net/openpilotci/"
OPENPILOT_CI_ACCOUNT_URL = "https://commadataci.blob.core.windows.net"
def get_url(route_name: str, segment_num: str | int, filename: str) -> str:
return f"{OPENPILOT_CI_BASE_URL}{route_name.replace('|', '/')}/{segment_num}/{filename}"
def upload_file(path: str, blob_name: str, overwrite=False) -> str:
from azure.identity import AzureCliCredential
from azure.storage.blob import BlobClient
token_path = Path("/data/azure_token")
credential = os.environ.get("AZURE_TOKEN") or (token_path.read_text().strip() if token_path.is_file() else AzureCliCredential())
client = BlobClient(OPENPILOT_CI_ACCOUNT_URL, container_name="openpilotci", blob_name=blob_name, credential=credential)
with open(path, "rb") as f:
client.upload_blob(f, overwrite=overwrite)
return OPENPILOT_CI_BASE_URL + blob_name
def comma_api_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames) -> dict[int, str]:
route = Route(sr.route_name)
if fns == FileName.RLOG:
return {seg: route.log_paths()[seg] for seg in seg_idxs if route.log_paths()[seg] is not None}
return {seg: route.qlog_paths()[seg] for seg in seg_idxs if route.qlog_paths()[seg] is not None}
def internal_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames, endpoint_url: str = DATA_ENDPOINT) -> dict[int, str]:
if not internal_source_available(endpoint_url):
raise InternalUnavailableException
def internal_url(seg, file):
return f"{endpoint_url.rstrip('/')}/{sr.dongle_id}/{sr.log_id}/{seg}/{file}"
return eval_source({seg: [internal_url(seg, fn) for fn in fns] for seg in seg_idxs})
def openpilotci_source(sr: SegmentRange, seg_idxs: list[int], fns: FileNames) -> dict[int, str]:
return eval_source({seg: [get_url(sr.route_name, seg, fn) for fn in fns] for seg in seg_idxs})
def eval_source(files: dict[int, list[str] | str]) -> dict[int, str]:
valid_files: dict[int, str] = {}
for seg_idx, urls in files.items():
if isinstance(urls, str):
urls = [urls]
for url in urls:
if file_exists(url):
valid_files[seg_idx] = url
break
return valid_files
ALL_SERVICES = list(SERVICE_LIST.keys())
def raw_live_logreader(services: list[str] = ALL_SERVICES, addr: str = '127.0.0.1') -> RawLogIterable:
if addr != "127.0.0.1":
os.environ["ZMQ"] = "1"
messaging.reset_context()
poller = messaging.Poller()
for service in services:
messaging.sub_sock(service, poller, addr=addr)
while True:
for sock in poller.poll(100):
yield sock.receive()
def live_logreader(services: list[str] = ALL_SERVICES, addr: str = '127.0.0.1') -> LogIterable:
for msg in raw_live_logreader(services, addr):
with capnp_log.Event.from_bytes(msg) as evt:
yield evt
def flatten_type_dict(data, sep="/", prefix=None):
result = {}
if isinstance(data, dict):
for key, value in data.items():
result.update(flatten_type_dict(value, sep, key if prefix is None else prefix + sep + key))
return result
if isinstance(data, list):
return {prefix: np.array(data)}
return {prefix: data}
def get_message_dict(message, typ):
valid = message.valid
message = message._get(typ)
if not hasattr(message, 'to_dict') or typ in ('qcomGnss', 'ubloxGnss'):
return None
result = flatten_type_dict(message.to_dict(verbose=True))
result['_valid'] = valid
return result
def potentially_ragged_array(values, dtype=None, **kwargs):
try:
return np.array(values, dtype=dtype, **kwargs)
except ValueError:
return np.array(values, dtype=object, **kwargs)
def msgs_to_time_series(msgs):
values = {}
for msg in msgs:
typ = msg.which()
msg_dict = get_message_dict(msg, typ)
if msg_dict is None:
continue
group = values.setdefault(typ, {"t": [], **{key: [] for key in msg_dict}})
group["t"].append(msg.logMonoTime / 1.0e9)
for key, value in msg_dict.items():
group[key].append(value)
for group in values.values():
order = np.argsort(group["t"])
for name, group_values in group.items():
group[name] = potentially_ragged_array(group_values)[order]
return values
def save_log(dest, log_msgs, compress=True):
dat = b"".join(msg.as_builder().to_bytes() for msg in log_msgs)
if compress and dest.endswith(".bz2"):
dat = bz2.compress(dat)
elif compress and dest.endswith(".zst"):
dat = zstd.compress(dat, 10)
with open(dest, "wb") as f:
f.write(dat)
def decompress_stream(data: bytes):
dctx = zstd.ZstdDecompressor()
decompressed_data = b""
with dctx.stream_reader(data) as reader:
decompressed_data = reader.read()
return decompressed_data
class CachedEventReader:
__slots__ = ('_evt', '_enum')
def __init__(self, evt: capnp._DynamicStructReader, _enum: str | None = None):
"""All capnp attribute accesses are expensive, and which() is often called multiple times"""
self._evt = evt
self._enum: str | None = _enum
# fast pickle support
def __reduce__(self):
return CachedEventReader._reducer, (self._evt.as_builder().to_bytes(), self._enum)
@staticmethod
def _reducer(data: bytes, _enum: str | None = None):
with capnp_log.Event.from_bytes(data) as evt:
return CachedEventReader(evt, _enum)
def __repr__(self):
return self._evt.__repr__()
def __str__(self):
return self._evt.__str__()
def __dir__(self):
return dir(self._evt)
def which(self) -> str:
if self._enum is None:
self._enum = self._evt.which()
return self._enum
def __getattr__(self, name: str):
if name.startswith("__") and name.endswith("__"):
return getattr(self, name)
return getattr(self._evt, name)
class _LogFileReader:
def __init__(self, fn, only_union_types=False, sort_by_time=False, dat=None):
self.data_version = None
self._only_union_types = only_union_types
ext = None
if not dat:
_, ext = os.path.splitext(urllib.parse.urlparse(fn).path)
if ext not in ('', '.bz2', '.zst'):
# old rlogs weren't compressed
raise ValueError(f"unknown extension {ext}")
with FileReader(fn) as f:
dat = f.read()
if ext == ".bz2" or dat.startswith(b'BZh9'):
dat = bz2.decompress(dat)
elif ext == ".zst" or dat.startswith(b'\x28\xB5\x2F\xFD'):
# https://github.com/facebook/zstd/blob/dev/doc/zstd_compression_format.md#zstandard-frames
dat = decompress_stream(dat)
ents = capnp_log.Event.read_multiple_bytes(dat)
self._ents = []
try:
for e in ents:
self._ents.append(CachedEventReader(e))
except capnp.KjException:
warnings.warn("Corrupted events detected", RuntimeWarning, stacklevel=1)
if sort_by_time:
self._ents.sort(key=lambda x: x.logMonoTime)
def __iter__(self) -> Iterator[capnp._DynamicStructReader]:
for ent in self._ents:
if self._only_union_types:
try:
ent.which()
yield ent
except (capnp.lib.capnp.KjException, RuntimeError):
pass
else:
yield ent
class ReadMode(enum.StrEnum):
RLOG = "r" # only read rlogs
QLOG = "q" # only read qlogs
AUTO = "a" # default to rlogs, fallback to qlogs
AUTO_INTERACTIVE = "i" # default to rlogs, fallback to qlogs with a prompt from the user
class LogsUnavailable(Exception):
pass
def direct_source(file_or_url: str) -> list[str]:
return [file_or_url]
# TODO this should apply to camera files as well
def auto_source(identifier: str, sources: list[Source], default_mode: ReadMode) -> list[str]:
exceptions = {}
sr = SegmentRange(identifier)
needed_seg_idxs = sr.seg_idxs
mode = default_mode if sr.selector is None else ReadMode(sr.selector)
if mode == ReadMode.QLOG:
try_fns = [FileName.QLOG]
else:
try_fns = [FileName.RLOG]
# If selector allows it, fallback to qlogs
if mode in (ReadMode.AUTO, ReadMode.AUTO_INTERACTIVE):
try_fns.append(FileName.QLOG)
# Build a dict of valid files as we evaluate each source. May contain mix of rlogs, qlogs, and None.
# This function only returns when we've sourced all files, or throws an exception
valid_files: dict[int, str] = {}
for fn in try_fns:
for source in sources:
try:
files = source(sr, needed_seg_idxs, fn)
# Build a dict of valid files
valid_files |= files
# Don't check for segment files that have already been found
needed_seg_idxs = [idx for idx in needed_seg_idxs if idx not in valid_files]
# We've found all files, return them
if len(needed_seg_idxs) == 0:
return list(valid_files.values())
else:
raise FileNotFoundError(f"Did not find {fn} for seg idxs {needed_seg_idxs} of {sr.route_name}")
except Exception as e:
exceptions[source.__name__] = e
if fn == try_fns[0]:
missing_logs = len(needed_seg_idxs)
if mode == ReadMode.AUTO:
cloudlog.warning(f"{missing_logs}/{len(sr.seg_idxs)} rlogs were not found, falling back to qlogs for those segments...")
elif mode == ReadMode.AUTO_INTERACTIVE:
if input(f"{missing_logs}/{len(sr.seg_idxs)} rlogs were not found, would you like to fallback to qlogs for those segments? (y/N) ").lower() != "y":
break
missing_logs = len(needed_seg_idxs)
raise LogsUnavailable(f"{missing_logs}/{len(sr.seg_idxs)} logs were not found, please ensure all logs " +
"are uploaded. You can fall back to qlogs with '/a' selector at the end of the route name.\n\n" +
"Exceptions for sources:\n - " + "\n - ".join([f"{k}: {repr(v)}" for k, v in exceptions.items()]))
def parse_indirect(identifier: str) -> str:
if "useradmin.comma.ai" in identifier:
query = parse_qs(urlparse(identifier).query)
identifier = query["onebox"][0]
elif "connect.comma.ai" in identifier or "konn3kt.com" in identifier:
path = urlparse(identifier).path.strip("/").split("/")
if path and path[0] == "connectdata":
# signed data URL from the API host (api-*.konn3kt.com/connectdata/...), not a share link
return identifier
path = ['/'.join(path[:2]), *path[2:]] # recombine log id
identifier = path[0]
if len(path) > 2:
# convert url with seconds to segments
start, end = int(path[1]) // 60, int(path[2]) // 60 + 1
identifier = f"{identifier}/{start}:{end}"
# add selector if it exists
if len(path) > 3:
identifier += f"/{path[3]}"
else:
# add selector if it exists
identifier = "/".join(path)
return identifier
def parse_direct(identifier: str):
if identifier.startswith(("http://", "https://", "cd:/")) or pathlib.Path(identifier).exists():
return identifier
return None
class LogReader:
def _parse_identifier(self, identifier: str) -> list[str]:
# useradmin, etc.
identifier = parse_indirect(identifier)
# direct url or file
direct_parsed = parse_direct(identifier)
if direct_parsed is not None:
return direct_source(identifier)
identifiers = auto_source(identifier, self.sources, self.default_mode)
return identifiers
def __init__(self, identifier: str | list[str], default_mode: ReadMode = ReadMode.RLOG,
sources: list[Source] | None = None, sort_by_time=False, only_union_types=False):
if sources is None:
sources = [internal_source, comma_api_source, openpilotci_source]
self.default_mode = default_mode
self.sources = sources
self.identifier = identifier
if isinstance(identifier, str):
self.identifier = [identifier]
self.sort_by_time = sort_by_time
self.only_union_types = only_union_types
self.__lrs: dict[int, _LogFileReader] = {}
self.reset()
def _get_lr(self, i):
if i not in self.__lrs:
self.__lrs[i] = _LogFileReader(self.logreader_identifiers[i], sort_by_time=self.sort_by_time, only_union_types=self.only_union_types)
return self.__lrs[i]
def __iter__(self):
for i in range(len(self.logreader_identifiers)):
yield from self._get_lr(i)
def _run_on_segment(self, func, i):
return func(self._get_lr(i))
def run_across_segments(self, num_processes, func, disable_tqdm=False, desc=None):
with multiprocessing.Pool(num_processes) as pool:
ret = []
num_segs = len(self.logreader_identifiers)
for p in tqdm.tqdm(pool.imap(partial(self._run_on_segment, func), range(num_segs)), total=num_segs, disable=disable_tqdm, desc=desc):
ret.extend(p)
return ret
def reset(self):
self.logreader_identifiers = []
for identifier in self.identifier:
self.logreader_identifiers.extend(self._parse_identifier(identifier))
@staticmethod
def from_bytes(dat):
return _LogFileReader("", dat=dat)
def filter(self, msg_type: str):
return (getattr(m, m.which()) for m in filter(lambda m: m.which() == msg_type, self))
def first(self, msg_type: str):
return next(self.filter(msg_type), None)
@property
def time_series(self):
return msgs_to_time_series(self)
if __name__ == "__main__":
import codecs
# capnproto <= 0.8.0 throws errors converting byte data to string
# below line catches those errors and replaces the bytes with \x__
codecs.register_error("strict", codecs.backslashreplace_errors)
log_path = sys.argv[1]
lr = LogReader(log_path, sort_by_time=True)
for msg in lr:
print(msg)

382
iqpilot/tools/lib/route.py Normal file
View File

@@ -0,0 +1,382 @@
import os
import re
import requests
from functools import cache
from urllib.parse import urlparse
from collections import defaultdict
from itertools import chain
from iqpilot.tools.lib.api import APIError, CommaApi, get_token
class RE:
DONGLE_ID = r'(?P<dongle_id>[a-f0-9]{16})'
TIMESTAMP = r'(?P<timestamp>[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2})'
LOG_ID_V2 = r'(?P<count>[a-f0-9]{8})--(?P<uid>[a-z0-9]{10})'
LOG_ID = fr'(?P<log_id>(?:{TIMESTAMP}|{LOG_ID_V2}))'
ROUTE_NAME = fr'(?P<route_name>{DONGLE_ID}[|_/]{LOG_ID})'
SEGMENT_NAME = fr'{ROUTE_NAME}(?:--|/)(?P<segment_num>[0-9]+)'
INDEX = r'-?[0-9]+'
SLICE = fr'(?P<start>{INDEX})?:?(?P<end>{INDEX})?:?(?P<step>{INDEX})?'
SEGMENT_RANGE = fr'{ROUTE_NAME}(?:(--|/)(?P<slice>({SLICE})))?(?:/(?P<selector>([qra])))?'
BOOTLOG_NAME = ROUTE_NAME
EXPLORER_FILE = fr'^(?P<segment_name>{SEGMENT_NAME})--(?P<file_name>[a-z]+\.[a-z0-9]+)$'
OP_SEGMENT_DIR = fr'^(?P<segment_name>{SEGMENT_NAME})$'
class FileName:
RLOG = ("rlog.zst", "rlog.bz2")
QLOG = ("qlog.zst", "qlog.bz2")
QCAMERA = ('qcamera.ts',)
FCAMERA = ('fcamera.hevc',)
ECAMERA = ('ecamera.hevc',)
DCAMERA = ('dcamera.hevc',)
BOOTLOG = ('bootlog.zst', 'bootlog.bz2')
class Route:
def __init__(self, name, data_dir=None):
self._name = RouteName(name)
self.files = None
if data_dir is not None:
self._segments = self._get_segments_local(data_dir)
else:
self._segments = self._get_segments_remote()
self.max_seg_number = self._segments[-1].name.segment_num
@property
def name(self):
return self._name
@property
def segments(self):
return self._segments
def log_paths(self):
log_path_by_seg_num = {s.name.segment_num: s.log_path for s in self._segments}
return [log_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
def qlog_paths(self):
qlog_path_by_seg_num = {s.name.segment_num: s.qlog_path for s in self._segments}
return [qlog_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
def camera_paths(self):
camera_path_by_seg_num = {s.name.segment_num: s.camera_path for s in self._segments}
return [camera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
def dcamera_paths(self):
dcamera_path_by_seg_num = {s.name.segment_num: s.dcamera_path for s in self._segments}
return [dcamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
def ecamera_paths(self):
ecamera_path_by_seg_num = {s.name.segment_num: s.ecamera_path for s in self._segments}
return [ecamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
def qcamera_paths(self):
qcamera_path_by_seg_num = {s.name.segment_num: s.qcamera_path for s in self._segments}
return [qcamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
# TODO: refactor this, it's super repetitive
def _get_segments_remote(self):
api = CommaApi(get_token())
route_files = api.get('v1/route/' + self.name.canonical_name + '/files')
self.files = [f['url'] if isinstance(f, dict) else f
for f in chain.from_iterable(route_files.values())]
segments = {}
for url in self.files:
_, dongle_id, time_str, segment_num, fn = urlparse(url).path.rsplit('/', maxsplit=4)
segment_name = f'{dongle_id}|{time_str}--{segment_num}'
if segments.get(segment_name):
segments[segment_name] = Segment(
segment_name,
url if fn in FileName.RLOG else segments[segment_name].log_path,
url if fn in FileName.QLOG else segments[segment_name].qlog_path,
url if fn in FileName.FCAMERA else segments[segment_name].camera_path,
url if fn in FileName.DCAMERA else segments[segment_name].dcamera_path,
url if fn in FileName.ECAMERA else segments[segment_name].ecamera_path,
url if fn in FileName.QCAMERA else segments[segment_name].qcamera_path,
)
else:
segments[segment_name] = Segment(
segment_name,
url if fn in FileName.RLOG else None,
url if fn in FileName.QLOG else None,
url if fn in FileName.FCAMERA else None,
url if fn in FileName.DCAMERA else None,
url if fn in FileName.ECAMERA else None,
url if fn in FileName.QCAMERA else None,
)
return sorted(segments.values(), key=lambda seg: seg.name.segment_num)
def _get_segments_local(self, data_dir):
files = os.listdir(data_dir)
segment_files = defaultdict(list)
for f in files:
fullpath = os.path.join(data_dir, f)
explorer_match = re.match(RE.EXPLORER_FILE, f)
op_match = re.match(RE.OP_SEGMENT_DIR, f)
if explorer_match:
segment_name = explorer_match.group('segment_name')
fn = explorer_match.group('file_name')
if segment_name.replace('_', '|').startswith(self.name.canonical_name):
segment_files[segment_name].append((fullpath, fn))
elif op_match and os.path.isdir(fullpath):
segment_name = op_match.group('segment_name')
if segment_name.startswith(self.name.canonical_name):
for seg_f in os.listdir(fullpath):
segment_files[segment_name].append((os.path.join(fullpath, seg_f), seg_f))
elif f == self.name.canonical_name:
for seg_num in os.listdir(fullpath):
if not seg_num.isdigit():
continue
segment_name = f'{self.name.canonical_name}--{seg_num}'
for seg_f in os.listdir(os.path.join(fullpath, seg_num)):
segment_files[segment_name].append((os.path.join(fullpath, seg_num, seg_f), seg_f))
segments = []
for segment, files in segment_files.items():
try:
log_path = next(path for path, filename in files if filename in FileName.RLOG)
except StopIteration:
log_path = None
try:
qlog_path = next(path for path, filename in files if filename in FileName.QLOG)
except StopIteration:
qlog_path = None
try:
camera_path = next(path for path, filename in files if filename in FileName.FCAMERA)
except StopIteration:
camera_path = None
try:
dcamera_path = next(path for path, filename in files if filename in FileName.DCAMERA)
except StopIteration:
dcamera_path = None
try:
ecamera_path = next(path for path, filename in files if filename in FileName.ECAMERA)
except StopIteration:
ecamera_path = None
try:
qcamera_path = next(path for path, filename in files if filename in FileName.QCAMERA)
except StopIteration:
qcamera_path = None
segments.append(Segment(segment, log_path, qlog_path, camera_path, dcamera_path, ecamera_path, qcamera_path))
if len(segments) == 0:
raise ValueError(f'Could not find segments for route {self.name.canonical_name} in data directory {data_dir}')
return sorted(segments, key=lambda seg: seg.name.segment_num)
class Segment:
def __init__(self, name, log_path, qlog_path, camera_path, dcamera_path, ecamera_path, qcamera_path):
self._events = None
self._name = SegmentName(name)
self.log_path = log_path
self.qlog_path = qlog_path
self.camera_path = camera_path
self.dcamera_path = dcamera_path
self.ecamera_path = ecamera_path
self.qcamera_path = qcamera_path
@property
def name(self):
return self._name
@staticmethod
@cache
def _get_route_metadata(route_name: str):
api = CommaApi(get_token())
return api.get(f'v1/route/{route_name}')
@property
def url(self):
route_name = self._name.route_name.canonical_name
metadata = self._get_route_metadata(route_name)
return f'{metadata["url"]}/{self._name.segment_num}'
@property
def events(self):
if not self._events:
try:
resp = requests.get(f'{self.url}/events.json')
resp.raise_for_status()
self._events = resp.json()
except Exception as e:
raise APIError(f'error getting events for segment {self._name}') from e
return self._events
class RouteName:
def __init__(self, name_str: str):
self._name_str = name_str
delim = next(c for c in self._name_str if c in ("|", "/"))
self._dongle_id, self._time_str = self._name_str.split(delim)
assert len(self._dongle_id) == 16, self._name_str
assert len(self._time_str) == 20, self._name_str
self._canonical_name = f"{self._dongle_id}|{self._time_str}"
@property
def canonical_name(self) -> str: return self._canonical_name
@property
def dongle_id(self) -> str: return self._dongle_id
@property
def log_id(self) -> str: return self._time_str
@property
def time_str(self) -> str: return self._time_str
@property
def azure_prefix(self):
return f'{self.dongle_id}/{self.log_id}'
def __str__(self) -> str: return self._canonical_name
class SegmentName:
# TODO: add constructor that takes dongle_id, time_str, segment_num and then create instances
# of this class instead of manually constructing a segment name (use canonical_name prop instead)
def __init__(self, name_str: str, allow_route_name=False):
data_dir_path_separator_index = name_str.rsplit("|", 1)[0].rfind("/")
use_data_dir = (data_dir_path_separator_index != -1) and ("|" in name_str)
self._name_str = name_str[data_dir_path_separator_index + 1:] if use_data_dir else name_str
self._data_dir = name_str[:data_dir_path_separator_index] if use_data_dir else None
seg_num_delim = "--" if self._name_str.count("--") == 2 else "/"
name_parts = self._name_str.rsplit(seg_num_delim, 1)
if allow_route_name and len(name_parts) == 1:
name_parts.append("-1") # no segment number
self._route_name = RouteName(name_parts[0])
self._num = int(name_parts[1])
self._canonical_name = f"{self._route_name._dongle_id}|{self._route_name._time_str}--{self._num}"
@property
def canonical_name(self) -> str: return self._canonical_name
# TODO should only use one name
@property
def data_name(self) -> str: return f"{self._route_name.canonical_name}/{self._num}"
@property
def azure_prefix(self):
return f'{self.dongle_id}/{self.log_id}/{self._num}'
@property
def dongle_id(self) -> str: return self._route_name.dongle_id
@property
def time_str(self) -> str: return self._route_name.time_str
@property
def log_id(self) -> str: return self._route_name.time_str
@property
def segment_num(self) -> int: return self._num
@property
def route_name(self) -> RouteName: return self._route_name
@property
def data_dir(self) -> str | None: return self._data_dir
def __str__(self) -> str: return self._canonical_name
@staticmethod
def from_file_name(file_name):
# ??????/xxxxxxxxxxxxxxxx|1111-11-11-11--11-11-11/1/rlog.bz2
dongle_id, route_name, segment_num = file_name.replace('|', '/').split('/')[-4:-1]
return SegmentName(dongle_id + "|" + route_name + "--" + segment_num)
@staticmethod
def from_device_key(dongle_id, key):
# 2018-05-07--18-56-13--5/rlog.bz2
segment_name = key.split('/')[0]
return SegmentName(dongle_id + "|" + segment_name)
@staticmethod
def from_file_key(key):
# 38c52c217150700f/2018-05-07--18-56-13/5/rlog.bz2
az_prefix = '/'.join(key.split('/')[:3])
return SegmentName.from_azure_prefix(az_prefix)
@staticmethod
def from_azure_prefix(prefix):
# xxxxxxxx/1111-11-11-11--11-11-11/0
dongle_id, route_name, segment_num = prefix.split("/")
return SegmentName(dongle_id + "|" + route_name + "--" + segment_num)
@cache
def get_max_seg_number_cached(sr: 'SegmentRange') -> int:
try:
api = CommaApi(get_token())
max_seg_number = api.get("/v1/route/" + sr.route_name.replace("/", "|"))["maxqlog"]
assert isinstance(max_seg_number, int)
return max_seg_number
except Exception as e:
raise Exception("unable to get max_segment_number. ensure you have access to this route or the route is public.") from e
class SegmentRange:
def __init__(self, segment_range: str):
m = re.fullmatch(RE.SEGMENT_RANGE, segment_range)
assert m is not None, f"Segment range is not valid {segment_range}"
self.m = m
@property
def route_name(self) -> str:
return self.m.group("route_name")
@property
def dongle_id(self) -> str:
return self.m.group("dongle_id")
@property
def log_id(self) -> str:
return self.m.group("log_id")
@property
def slice(self) -> str:
return self.m.group("slice") or ""
@property
def selector(self) -> str | None:
return self.m.group("selector")
@property
def seg_idxs(self) -> list[int]:
m = re.fullmatch(RE.SLICE, self.slice)
assert m is not None, f"Invalid slice: {self.slice}"
start, end, step = (None if s is None else int(s) for s in m.groups())
# one segment specified
if start is not None and end is None and ':' not in self.slice:
if start < 0:
start += get_max_seg_number_cached(self) + 1
return [start]
s = slice(start, end, step)
# no specified end or using relative indexing, need number of segments
if end is None or end < 0 or (start is not None and start < 0):
return list(range(get_max_seg_number_cached(self) + 1))[s]
else:
return list(range(end + 1))[s]
def __str__(self) -> str:
return f"{self.dongle_id}/{self.log_id}" + (f"/{self.slice}" if self.slice else "") + (f"/{self.selector}" if self.selector else "")
def __repr__(self) -> str:
return self.__str__()

View File

View File

@@ -0,0 +1,205 @@
import http.server
import multiprocessing
import os
import shutil
import socket
import tempfile
import pytest
from iqpilot.selfdrive.test.helpers import http_server_context
from iqpilot.system.hardware.hw import Paths
from iqpilot.tools.lib.url_file import URLFile, prune_cache
import iqpilot.tools.lib.url_file as url_file_module
def concurrent_prune_cache(cache_root, entry, barrier):
Paths.download_cache_root = staticmethod(lambda: cache_root)
barrier.wait()
prune_cache(entry)
class CachingTestRequestHandler(http.server.BaseHTTPRequestHandler):
FILE_EXISTS = True
def do_GET(self):
if self.FILE_EXISTS:
self.send_response(206 if "Range" in self.headers else 200, b'1234')
else:
self.send_response(404)
self.end_headers()
def do_HEAD(self):
if self.FILE_EXISTS:
self.send_response(200)
self.send_header("Content-Length", "4")
else:
self.send_response(404)
self.end_headers()
@pytest.fixture
def host():
with http_server_context(handler=CachingTestRequestHandler) as (host, port):
yield f"http://{host}:{port}"
class TestFileDownload:
def test_head_connection_released(self, monkeypatch):
class Response:
status = 200
headers = {"content-length": "4"}
released = False
def release_conn(self):
self.released = True
response = Response()
monkeypatch.setattr(URLFile, "_request", lambda self, method, url, headers=None: response)
assert URLFile("https://example.com/test").get_length_online() == 4
assert response.released
def test_pipeline_defaults(self, host):
# TODO: parameterize the defaults so we don't rely on hard-coded values in xx
assert URLFile.pool_manager().pools._maxsize == 10# PoolManager num_pools param
pool_manager_defaults = {
"maxsize": 100,
"socket_options": [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),],
}
for k, v in pool_manager_defaults.items():
assert URLFile.pool_manager().connection_pool_kw.get(k) == v
retry_defaults = {
"total": 6,
"backoff_factor": 0.75,
"status_forcelist": [409, 429, 500, 502, 503, 504],
}
for k, v in retry_defaults.items():
assert getattr(URLFile.pool_manager().connection_pool_kw["retries"], k) == v
# ensure caching on by default and cache dir gets created
os.environ.pop("DISABLE_FILEREADER_CACHE", None)
if os.path.exists(Paths.download_cache_root()):
shutil.rmtree(Paths.download_cache_root())
URLFile(f"{host}/test.txt").get_length()
URLFile(f"{host}/test.txt").read()
assert os.path.exists(Paths.download_cache_root())
def compare_loads(self, url, start=0, length=None):
"""Compares range between cached and non cached version"""
file_cached = URLFile(url, cache=True)
file_downloaded = URLFile(url, cache=False)
file_cached.seek(start)
file_downloaded.seek(start)
assert file_cached.get_length() == file_downloaded.get_length()
assert length + start if length is not None else 0 <= file_downloaded.get_length()
response_cached = file_cached.read(ll=length)
response_downloaded = file_downloaded.read(ll=length)
assert response_cached == response_downloaded
# Now test with cache in place
file_cached = URLFile(url, cache=True)
file_cached.seek(start)
response_cached = file_cached.read(ll=length)
assert file_cached.get_length() == file_downloaded.get_length()
assert response_cached == response_downloaded
def test_small_file(self):
# Make sure we don't force cache
os.environ.pop("DISABLE_FILEREADER_CACHE", None)
small_file_url = "https://raw.githubusercontent.com/commaai/openpilot/master/docs/SAFETY.md"
# If you want large file to be larger than a chunk
# large_file_url = "https://commadataci.blob.core.windows.net/openpilotci/0375fdf7b1ce594d/2019-06-13--08-32-25/3/fcamera.hevc"
# Load full small file
self.compare_loads(small_file_url)
file_small = URLFile(small_file_url)
length = file_small.get_length()
self.compare_loads(small_file_url, length - 100, 100)
self.compare_loads(small_file_url, 50, 100)
# Load small file 100 bytes at a time
for i in range(length // 100):
self.compare_loads(small_file_url, 100 * i, 100)
def test_large_file(self):
large_file_url = "https://commadataci.blob.core.windows.net/openpilotci/0375fdf7b1ce594d/2019-06-13--08-32-25/3/qlog.bz2"
# Load the end 100 bytes of both files
file_large = URLFile(large_file_url)
length = file_large.get_length()
self.compare_loads(large_file_url, length - 100, 100)
self.compare_loads(large_file_url)
@pytest.mark.parametrize("cache_enabled", [True, False])
def test_recover_from_missing_file(self, host, cache_enabled):
if cache_enabled:
os.environ.pop("DISABLE_FILEREADER_CACHE", None)
else:
os.environ["DISABLE_FILEREADER_CACHE"] = "1"
file_url = f"{host}/test.png"
CachingTestRequestHandler.FILE_EXISTS = False
length = URLFile(file_url).get_length()
assert length == -1
CachingTestRequestHandler.FILE_EXISTS = True
length = URLFile(file_url).get_length()
assert length == 4
class TestCache:
def test_concurrent_prune_cache(self, tmp_path):
context = multiprocessing.get_context("fork")
barrier = context.Barrier(16)
processes = [context.Process(target=concurrent_prune_cache, args=(f"{tmp_path}/", f"entry_{i}", barrier)) for i in range(16)]
for process in processes:
process.start()
for process in processes:
process.join(10)
assert process.exitcode == 0
manifest = set()
for line in (tmp_path / "manifest.txt").read_text().splitlines():
parts = line.split()
if len(parts) == 2:
manifest.add(parts[0])
assert manifest == {f"entry_{i}" for i in range(16)}
def test_prune_cache(self, monkeypatch):
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.setattr(Paths, 'download_cache_root', staticmethod(lambda: tmpdir + "/"))
# setup test files and manifest
manifest_lines = []
for i in range(3):
fname = f"hash_{i}"
with open(tmpdir + "/" + fname, "wb") as f:
f.truncate(1000)
manifest_lines.append(f"{fname} {1000 + i}")
with open(tmpdir + "/manifest.txt", "w") as f:
f.write('\n'.join(manifest_lines))
# under limit, shouldn't prune
assert len(os.listdir(tmpdir)) == 4
prune_cache()
assert len([name for name in os.listdir(tmpdir) if name != "manifest.lock"]) == 4
# set a tiny cache limit to force eviction (1.5 chunks worth)
monkeypatch.setattr(url_file_module, 'CACHE_SIZE', url_file_module.CHUNK_SIZE + url_file_module.CHUNK_SIZE // 2)
# prune_cache should evict oldest files to get under limit
prune_cache()
remaining = [name for name in os.listdir(tmpdir) if name != "manifest.lock"]
# should have evicted at least one file + manifest
assert len(remaining) < 4
# newest file should remain
assert manifest_lines[2].split()[0] in remaining

View File

@@ -0,0 +1,176 @@
import capnp
import contextlib
import shutil
import tempfile
import os
import pytest
import requests
from parameterized import parameterized
from iqpilot.cereal import log as capnp_log
from iqpilot.tools.lib.logreader import InternalUnavailableException, LogReader, parse_indirect
from iqpilot.tools.lib.route import SegmentRange
from iqpilot.tools.lib.url_file import URLFileException
NUM_SEGS = 17 # number of segments in the test route
ALL_SEGS = list(range(NUM_SEGS))
TEST_ROUTE = "344c5c15b34f2d8a/2024-01-03--09-37-12"
QLOG_FILE = "https://commadataci.blob.core.windows.net/openpilotci/0375fdf7b1ce594d/2019-06-13--08-32-25/3/qlog.bz2"
@contextlib.contextmanager
def setup_source_scenario(mocker, is_internal=False):
internal_source_mock = mocker.patch("iqpilot.tools.lib.logreader.internal_source")
internal_source_mock.__name__ = internal_source_mock._mock_name
openpilotci_source_mock = mocker.patch("iqpilot.tools.lib.logreader.openpilotci_source")
openpilotci_source_mock.__name__ = openpilotci_source_mock._mock_name
comma_api_source_mock = mocker.patch("iqpilot.tools.lib.logreader.comma_api_source")
comma_api_source_mock.__name__ = comma_api_source_mock._mock_name
if is_internal:
internal_source_mock.return_value = {3: QLOG_FILE}
else:
internal_source_mock.side_effect = InternalUnavailableException
openpilotci_source_mock.return_value = {}
comma_api_source_mock.return_value = {3: QLOG_FILE}
yield
class TestLogReader:
@pytest.mark.parametrize(("identifier", "expected"), [
(f"{TEST_ROUTE}", ALL_SEGS),
(f"{TEST_ROUTE.replace('/', '|')}", ALL_SEGS),
(f"{TEST_ROUTE}--0", [0]),
(f"{TEST_ROUTE}--5", [5]),
(f"{TEST_ROUTE}/0", [0]),
(f"{TEST_ROUTE}/5", [5]),
(f"{TEST_ROUTE}/0:10", ALL_SEGS[0:10]),
(f"{TEST_ROUTE}/0:0", []),
(f"{TEST_ROUTE}/4:6", ALL_SEGS[4:6]),
(f"{TEST_ROUTE}/0:-1", ALL_SEGS[0:-1]),
(f"{TEST_ROUTE}/:5", ALL_SEGS[:5]),
(f"{TEST_ROUTE}/2:", ALL_SEGS[2:]),
(f"{TEST_ROUTE}/2:-1", ALL_SEGS[2:-1]),
(f"{TEST_ROUTE}/-1", [ALL_SEGS[-1]]),
(f"{TEST_ROUTE}/-2", [ALL_SEGS[-2]]),
(f"{TEST_ROUTE}/-2:-1", ALL_SEGS[-2:-1]),
(f"{TEST_ROUTE}/-4:-2", ALL_SEGS[-4:-2]),
(f"{TEST_ROUTE}/:10:2", ALL_SEGS[:10:2]),
(f"{TEST_ROUTE}/5::2", ALL_SEGS[5::2]),
(f"https://useradmin.comma.ai/?onebox={TEST_ROUTE}", ALL_SEGS),
(f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '|')}", ALL_SEGS),
(f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '%7C')}", ALL_SEGS),
])
def test_indirect_parsing(self, identifier, expected, mocker):
mocker.patch("iqpilot.tools.lib.route.get_max_seg_number_cached", return_value=NUM_SEGS - 1)
parsed = parse_indirect(identifier)
sr = SegmentRange(parsed)
assert list(sr.seg_idxs) == expected, identifier
@parameterized.expand([
(f"{TEST_ROUTE}", f"{TEST_ROUTE}"),
(f"{TEST_ROUTE.replace('/', '|')}", f"{TEST_ROUTE}"),
(f"{TEST_ROUTE}--5", f"{TEST_ROUTE}/5"),
(f"{TEST_ROUTE}/0/q", f"{TEST_ROUTE}/0/q"),
(f"{TEST_ROUTE}/5:6/r", f"{TEST_ROUTE}/5:6/r"),
(f"{TEST_ROUTE}/5", f"{TEST_ROUTE}/5"),
])
def test_canonical_name(self, identifier, expected):
sr = SegmentRange(identifier)
assert str(sr) == expected
@pytest.mark.parametrize("cache_enabled", [True, False])
def test_direct_parsing(self, mocker, cache_enabled):
file_exists_mock = mocker.patch("iqpilot.tools.lib.filereader.file_exists")
if cache_enabled:
os.environ.pop("DISABLE_FILEREADER_CACHE", None)
else:
os.environ["DISABLE_FILEREADER_CACHE"] = "1"
qlog = tempfile.NamedTemporaryFile(mode='wb', delete=False)
with requests.get(QLOG_FILE, stream=True) as r:
with qlog as f:
shutil.copyfileobj(r.raw, f)
for f in [QLOG_FILE, qlog.name]:
l = len(list(LogReader(f)))
assert l > 100
with pytest.raises(URLFileException) if not cache_enabled else pytest.raises(AssertionError):
l = len(list(LogReader(QLOG_FILE.replace("/3/", "/200/"))))
# file_exists should not be called for direct files
assert file_exists_mock.call_count == 0
@parameterized.expand([
(f"{TEST_ROUTE}///",),
(f"{TEST_ROUTE}---",),
(f"{TEST_ROUTE}/-4:--2",),
(f"{TEST_ROUTE}/-a",),
(f"{TEST_ROUTE}/j",),
(f"{TEST_ROUTE}/0:1:2:3",),
(f"{TEST_ROUTE}/:::3",),
(f"{TEST_ROUTE}3",),
(f"{TEST_ROUTE}-3",),
(f"{TEST_ROUTE}--3a",),
])
def test_bad_ranges(self, segment_range):
with pytest.raises(AssertionError):
_ = SegmentRange(segment_range).seg_idxs
@pytest.mark.parametrize("segment_range, api_call", [
(f"{TEST_ROUTE}/0", False),
(f"{TEST_ROUTE}/:2", False),
(f"{TEST_ROUTE}/0:", True),
(f"{TEST_ROUTE}/-1", True),
(f"{TEST_ROUTE}", True),
])
def test_slicing_api_call(self, mocker, segment_range, api_call):
max_seg_mock = mocker.patch("iqpilot.tools.lib.route.get_max_seg_number_cached")
max_seg_mock.return_value = NUM_SEGS
_ = SegmentRange(segment_range).seg_idxs
assert api_call == max_seg_mock.called
@pytest.mark.parametrize("is_internal", [True, False])
def test_auto_source_scenarios(self, mocker, is_internal):
lr = LogReader(QLOG_FILE)
qlog_len = len(list(lr))
with setup_source_scenario(mocker, is_internal=is_internal):
lr = LogReader(f"{TEST_ROUTE}/3/q")
log_len = len(list(lr))
assert qlog_len == log_len
def test_only_union_types(self):
with tempfile.NamedTemporaryFile() as qlog:
# write valid Event messages
num_msgs = 100
with open(qlog.name, "wb") as f:
f.write(b"".join(capnp_log.Event.new_message().to_bytes() for _ in range(num_msgs)))
msgs = list(LogReader(qlog.name))
assert len(msgs) == num_msgs
[m.which() for m in msgs]
# append non-union Event message
event_msg = capnp_log.Event.new_message()
non_union_bytes = bytearray(event_msg.to_bytes())
non_union_bytes[event_msg.total_size.word_count * 8] = 0xff # set discriminant value out of range using Event word offset
with open(qlog.name, "ab") as f:
f.write(non_union_bytes)
# ensure new message is added, but is not a union type
msgs = list(LogReader(qlog.name))
assert len(msgs) == num_msgs + 1
with pytest.raises((capnp.KjException, RuntimeError)):
[m.which() for m in msgs]
# should not be added when only_union_types=True
msgs = list(LogReader(qlog.name, only_union_types=True))
assert len(msgs) == num_msgs
[m.which() for m in msgs]

View File

@@ -0,0 +1,27 @@
from collections import namedtuple
from iqpilot.tools.lib.route import SegmentName
class TestRouteLibrary:
def test_segment_name_formats(self):
Case = namedtuple('Case', ['input', 'expected_route', 'expected_segment_num', 'expected_data_dir'])
cases = [ Case("a2a0ccea32023010|2023-07-27--13-01-19", "a2a0ccea32023010|2023-07-27--13-01-19", -1, None),
Case("a2a0ccea32023010/2023-07-27--13-01-19--1", "a2a0ccea32023010|2023-07-27--13-01-19", 1, None),
Case("a2a0ccea32023010|2023-07-27--13-01-19/2", "a2a0ccea32023010|2023-07-27--13-01-19", 2, None),
Case("a2a0ccea32023010/2023-07-27--13-01-19/3", "a2a0ccea32023010|2023-07-27--13-01-19", 3, None),
Case("/data/media/0/realdata/a2a0ccea32023010|2023-07-27--13-01-19", "a2a0ccea32023010|2023-07-27--13-01-19", -1, "/data/media/0/realdata"),
Case("/data/media/0/realdata/a2a0ccea32023010|2023-07-27--13-01-19--1", "a2a0ccea32023010|2023-07-27--13-01-19", 1, "/data/media/0/realdata"),
Case("/data/media/0/realdata/a2a0ccea32023010|2023-07-27--13-01-19/2", "a2a0ccea32023010|2023-07-27--13-01-19", 2, "/data/media/0/realdata") ]
def _validate(case):
route_or_segment_name = case.input
s = SegmentName(route_or_segment_name, allow_route_name=True)
assert str(s.route_name) == case.expected_route
assert s.segment_num == case.expected_segment_num
assert s.data_dir == case.expected_data_dir
for case in cases:
_validate(case)

View File

@@ -0,0 +1,255 @@
import logging
import fcntl
import os
import re
import socket
import time
from hashlib import md5
from urllib3 import PoolManager, Retry
from urllib3.response import BaseHTTPResponse
from urllib3.util import Timeout
from iqpilot.common.utils import atomic_write
from iqpilot.system.hardware.hw import Paths
from urllib3.exceptions import MaxRetryError
# Cache chunk size
K = 1000
CHUNK_SIZE = 1000 * K
CACHE_SIZE = 10 * 1024 * 1024 * 1024 # total cache size in GB
logging.getLogger("urllib3").setLevel(logging.WARNING)
USER_AGENT = os.getenv("IQPILOT_HTTP_USER_AGENT", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
value = int(raw)
return value if value > 0 else default
except ValueError:
return default
def hash_url(link: str) -> str:
return md5((link.split("?")[0]).encode('utf-8')).hexdigest()
def prune_cache(new_entry: str | None = None) -> None:
"""Evicts oldest cache files (LRU) until cache is under the size limit."""
cache_root = Paths.download_cache_root()
os.makedirs(cache_root, exist_ok=True)
manifest_path = os.path.join(cache_root, "manifest.txt")
with open(os.path.join(cache_root, "manifest.lock"), "w") as lock_file:
fcntl.flock(lock_file, fcntl.LOCK_EX)
manifest = {}
try:
with open(manifest_path) as f:
manifest = {parts[0]: int(parts[1]) for line in f if (parts := line.strip().split()) and len(parts) == 2}
except FileNotFoundError:
pass
if new_entry:
manifest[new_entry] = int(time.time()) # noqa: TID251
sorted_items = sorted(manifest.items(), key=lambda x: x[1])
while len(manifest) * CHUNK_SIZE > CACHE_SIZE and sorted_items:
key, _ = sorted_items.pop(0)
try:
os.remove(os.path.join(cache_root, key))
except OSError:
pass
manifest.pop(key, None)
with atomic_write(manifest_path, mode="w", overwrite=True) as f:
f.write('\n'.join(f"{k} {v}" for k, v in manifest.items()))
class URLFileException(Exception):
pass
class URLFile:
_pool_manager: PoolManager | None = None
@staticmethod
def reset() -> None:
URLFile._pool_manager = None
@staticmethod
def pool_manager() -> PoolManager:
if URLFile._pool_manager is None:
socket_options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
retries = Retry(
total=_env_int("URLFILE_RETRIES_TOTAL", 6),
connect=_env_int("URLFILE_RETRIES_CONNECT", 6),
read=_env_int("URLFILE_RETRIES_READ", 6),
backoff_factor=float(os.getenv("URLFILE_RETRIES_BACKOFF", "0.75")),
status_forcelist=[409, 429, 500, 502, 503, 504],
)
URLFile._pool_manager = PoolManager(num_pools=10, maxsize=100, socket_options=socket_options, retries=retries)
return URLFile._pool_manager
def __init__(self, url: str, timeout: int = 10, cache: bool | None = None):
self._url = url
connect_timeout = _env_int("URLFILE_CONNECT_TIMEOUT", min(timeout, 10))
read_timeout = _env_int("URLFILE_READ_TIMEOUT", max(timeout, 30))
total_timeout = _env_int("URLFILE_TOTAL_TIMEOUT", max(read_timeout * 4, 180))
self._timeout = Timeout(connect=connect_timeout, read=read_timeout, total=total_timeout)
self._pos = 0
self._length: int | None = None
# Caching enabled by default, can be disabled with DISABLE_FILEREADER_CACHE=1, or overwritten by the cache input
self._force_download = int(os.environ.get("DISABLE_FILEREADER_CACHE", "0")) == 1
if cache is not None:
self._force_download = not cache
if not self._force_download:
os.makedirs(Paths.download_cache_root(), exist_ok=True)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback) -> None:
pass
def _request(self, method: str, url: str, headers: dict[str, str] | None = None) -> BaseHTTPResponse:
# the data host is behind cloudflare, which answers a default urllib3 agent
# with a 1010 block. It reads as 403 Forbidden on a correctly signed url, so
# it looks like an auth problem and is not one.
headers = {**(headers or {}), "User-Agent": USER_AGENT}
try:
return URLFile.pool_manager().request(method, url, timeout=self._timeout, headers=headers)
except MaxRetryError as e:
raise URLFileException(f"Failed to {method} {url}: {e}") from e
def get_length_online(self) -> int:
response = self._request('HEAD', self._url)
try:
if not (200 <= response.status <= 299):
return -1
length = response.headers.get('content-length', 0)
return int(length)
finally:
response.release_conn()
def get_length(self) -> int:
if self._length is not None:
return self._length
file_length_path = os.path.join(Paths.download_cache_root(), hash_url(self._url) + "_length")
if not self._force_download and os.path.exists(file_length_path):
with open(file_length_path) as file_length:
content = file_length.read()
self._length = int(content)
return self._length
self._length = self.get_length_online()
if not self._force_download and self._length != -1:
with atomic_write(file_length_path, mode="w", overwrite=True) as file_length:
file_length.write(str(self._length))
return self._length
def read(self, ll: int | None = None) -> bytes:
if self._force_download:
return self.read_aux(ll=ll)
file_begin = self._pos
file_end = self._pos + ll if ll is not None else self.get_length()
assert file_end != -1, f"Remote file is empty or doesn't exist: {self._url}"
# We have to align with chunks we store. Position is the begginiing of the latest chunk that starts before or at our file
position = (file_begin // CHUNK_SIZE) * CHUNK_SIZE
response = b""
while True:
self._pos = position
chunk_number = self._pos / CHUNK_SIZE
file_name = hash_url(self._url) + "_" + str(chunk_number)
full_path = os.path.join(Paths.download_cache_root(), str(file_name))
data = None
# If we don't have a file, download it
if not os.path.exists(full_path):
data = self.read_aux(ll=CHUNK_SIZE)
with atomic_write(full_path, mode="wb", overwrite=True) as new_cached_file:
new_cached_file.write(data)
prune_cache(file_name)
else:
with open(full_path, "rb") as cached_file:
data = cached_file.read()
response += data[max(0, file_begin - position): min(CHUNK_SIZE, file_end - position)]
position += CHUNK_SIZE
if position >= file_end:
self._pos = file_end
return response
def read_aux(self, ll: int | None = None) -> bytes:
if ll is None:
length = self.get_length()
if length == -1:
raise URLFileException(f"Remote file is empty or doesn't exist: {self._url}")
end = length
else:
end = self._pos + ll
data = self.get_multi_range([(self._pos, end)])
self._pos += len(data[0])
return data[0]
def get_multi_range(self, ranges: list[tuple[int, int]]) -> list[bytes]:
# HTTP range requests are inclusive
assert all(e > s for s, e in ranges), "Range end must be greater than start"
rs = [f"{s}-{e-1}" for s, e in ranges if e > s]
r = self._request("GET", self._url, headers={"Range": "bytes=" + ",".join(rs)})
if r.status not in [200, 206]:
raise URLFileException(f"Expected 206 or 200 response {r.status} ({self._url})")
ctype = (r.headers.get("content-type") or "").lower()
if "multipart/byteranges" not in ctype:
return [r.data,]
m = re.search(r'boundary="?([^";]+)"?', ctype)
if not m:
raise URLFileException(f"Missing multipart boundary ({self._url})")
boundary = m.group(1).encode()
parts = []
for chunk in r.data.split(b"--" + boundary):
if b"\r\n\r\n" not in chunk:
continue
payload = chunk.split(b"\r\n\r\n", 1)[1].rstrip(b"\r\n")
if payload and payload != b"--":
parts.append(payload)
if len(parts) != len(ranges):
raise URLFileException(f"Expected {len(ranges)} parts, got {len(parts)} ({self._url})")
return parts
def seekable(self) -> bool:
return True
def seek(self, pos: int, whence: int = 0) -> int:
pos = int(pos)
if whence == os.SEEK_SET:
self._pos = pos
elif whence == os.SEEK_CUR:
self._pos += pos
elif whence == os.SEEK_END:
length = self.get_length()
assert length != -1, "Cannot seek from end on unknown length file"
self._pos = length + pos
else:
raise URLFileException("Invalid whence value")
return self._pos
def tell(self) -> int:
return self._pos
@property
def name(self) -> str:
return self._url
os.register_at_fork(after_in_child=URLFile.reset)

311
iqpilot/tools/lib/vidindex.py Executable file
View File

@@ -0,0 +1,311 @@
#!/usr/bin/env python3
import argparse
import os
import struct
from enum import IntEnum
from iqpilot.tools.lib.filereader import FileReader
DEBUG = int(os.getenv("DEBUG", "0"))
# compare to ffmpeg parsing
# ffmpeg -i <input.hevc> -c copy -bsf:v trace_headers -f null - 2>&1 | grep -B4 -A32 '] 0 '
# H.265 specification
# https://www.itu.int/rec/dologin_pub.asp?lang=e&id=T-REC-H.265-201802-S!!PDF-E&type=items
NAL_UNIT_START_CODE = b"\x00\x00\x01"
NAL_UNIT_START_CODE_SIZE = len(NAL_UNIT_START_CODE)
NAL_UNIT_HEADER_SIZE = 2
class HevcNalUnitType(IntEnum):
TRAIL_N = 0 # RBSP structure: slice_segment_layer_rbsp( )
TRAIL_R = 1 # RBSP structure: slice_segment_layer_rbsp( )
TSA_N = 2 # RBSP structure: slice_segment_layer_rbsp( )
TSA_R = 3 # RBSP structure: slice_segment_layer_rbsp( )
STSA_N = 4 # RBSP structure: slice_segment_layer_rbsp( )
STSA_R = 5 # RBSP structure: slice_segment_layer_rbsp( )
RADL_N = 6 # RBSP structure: slice_segment_layer_rbsp( )
RADL_R = 7 # RBSP structure: slice_segment_layer_rbsp( )
RASL_N = 8 # RBSP structure: slice_segment_layer_rbsp( )
RASL_R = 9 # RBSP structure: slice_segment_layer_rbsp( )
RSV_VCL_N10 = 10
RSV_VCL_R11 = 11
RSV_VCL_N12 = 12
RSV_VCL_R13 = 13
RSV_VCL_N14 = 14
RSV_VCL_R15 = 15
BLA_W_LP = 16 # RBSP structure: slice_segment_layer_rbsp( )
BLA_W_RADL = 17 # RBSP structure: slice_segment_layer_rbsp( )
BLA_N_LP = 18 # RBSP structure: slice_segment_layer_rbsp( )
IDR_W_RADL = 19 # RBSP structure: slice_segment_layer_rbsp( )
IDR_N_LP = 20 # RBSP structure: slice_segment_layer_rbsp( )
CRA_NUT = 21 # RBSP structure: slice_segment_layer_rbsp( )
RSV_IRAP_VCL22 = 22
RSV_IRAP_VCL23 = 23
RSV_VCL24 = 24
RSV_VCL25 = 25
RSV_VCL26 = 26
RSV_VCL27 = 27
RSV_VCL28 = 28
RSV_VCL29 = 29
RSV_VCL30 = 30
RSV_VCL31 = 31
VPS_NUT = 32 # RBSP structure: video_parameter_set_rbsp( )
SPS_NUT = 33 # RBSP structure: seq_parameter_set_rbsp( )
PPS_NUT = 34 # RBSP structure: pic_parameter_set_rbsp( )
AUD_NUT = 35
EOS_NUT = 36
EOB_NUT = 37
FD_NUT = 38
PREFIX_SEI_NUT = 39
SUFFIX_SEI_NUT = 40
RSV_NVCL41 = 41
RSV_NVCL42 = 42
RSV_NVCL43 = 43
RSV_NVCL44 = 44
RSV_NVCL45 = 45
RSV_NVCL46 = 46
RSV_NVCL47 = 47
UNSPEC48 = 48
UNSPEC49 = 49
UNSPEC50 = 50
UNSPEC51 = 51
UNSPEC52 = 52
UNSPEC53 = 53
UNSPEC54 = 54
UNSPEC55 = 55
UNSPEC56 = 56
UNSPEC57 = 57
UNSPEC58 = 58
UNSPEC59 = 59
UNSPEC60 = 60
UNSPEC61 = 61
UNSPEC62 = 62
UNSPEC63 = 63
# B.2.2 Byte stream NAL unit semantics
# - The nal_unit_type within the nal_unit( ) syntax structure is equal to VPS_NUT, SPS_NUT or PPS_NUT.
# - The byte stream NAL unit syntax structure contains the first NAL unit of an access unit in decoding
# order, as specified in clause 7.4.2.4.4.
HEVC_PARAMETER_SET_NAL_UNITS = (
HevcNalUnitType.VPS_NUT,
HevcNalUnitType.SPS_NUT,
HevcNalUnitType.PPS_NUT,
)
# 3.29 coded slice segment NAL unit: A NAL unit that has nal_unit_type in the range of TRAIL_N to RASL_R,
# inclusive, or in the range of BLA_W_LP to RSV_IRAP_VCL23, inclusive, which indicates that the NAL unit
# contains a coded slice segment
HEVC_CODED_SLICE_SEGMENT_NAL_UNITS = (
HevcNalUnitType.TRAIL_N,
HevcNalUnitType.TRAIL_R,
HevcNalUnitType.TSA_N,
HevcNalUnitType.TSA_R,
HevcNalUnitType.STSA_N,
HevcNalUnitType.STSA_R,
HevcNalUnitType.RADL_N,
HevcNalUnitType.RADL_R,
HevcNalUnitType.RASL_N,
HevcNalUnitType.RASL_R,
HevcNalUnitType.BLA_W_LP,
HevcNalUnitType.BLA_W_RADL,
HevcNalUnitType.BLA_N_LP,
HevcNalUnitType.IDR_W_RADL,
HevcNalUnitType.IDR_N_LP,
HevcNalUnitType.CRA_NUT,
)
class VideoFileInvalid(Exception):
pass
def get_ue(dat: bytes, start_idx: int, skip_bits: int) -> tuple[int, int]:
prefix_val = 0
prefix_len = 0
suffix_val = 0
suffix_len = 0
i = start_idx
while i < len(dat):
j = 7
while j >= 0:
if skip_bits > 0:
skip_bits -= 1
elif prefix_val == 0:
prefix_val = (dat[i] >> j) & 1
prefix_len += 1
else:
suffix_val = (suffix_val << 1) | ((dat[i] >> j) & 1)
suffix_len += 1
j -= 1
if prefix_val == 1 and prefix_len - 1 == suffix_len:
val = int(2**(prefix_len-1) - 1 + suffix_val)
size = prefix_len + suffix_len
return val, size
i += 1
raise VideoFileInvalid("invalid exponential-golomb code")
def require_nal_unit_start(dat: bytes, nal_unit_start: int) -> None:
if nal_unit_start < 1:
raise ValueError("start index must be greater than zero")
if dat[nal_unit_start:nal_unit_start + NAL_UNIT_START_CODE_SIZE] != NAL_UNIT_START_CODE:
raise VideoFileInvalid("data must begin with start code")
def get_hevc_nal_unit_length(dat: bytes, nal_unit_start: int) -> int:
try:
pos = dat.index(NAL_UNIT_START_CODE, nal_unit_start + NAL_UNIT_START_CODE_SIZE)
except ValueError:
pos = -1
# length of NAL unit is byte count up to next NAL unit start index
nal_unit_len = (pos if pos != -1 else len(dat)) - nal_unit_start
if DEBUG:
print(" nal_unit_len:", nal_unit_len)
return nal_unit_len
def get_hevc_nal_unit_type(dat: bytes, nal_unit_start: int) -> HevcNalUnitType:
# 7.3.1.2 NAL unit header syntax
# nal_unit_header( ) { // descriptor
# forbidden_zero_bit f(1)
# nal_unit_type u(6)
# nuh_layer_id u(6)
# nuh_temporal_id_plus1 u(3)
# }
header_start = nal_unit_start + NAL_UNIT_START_CODE_SIZE
nal_unit_header = dat[header_start:header_start + NAL_UNIT_HEADER_SIZE]
if len(nal_unit_header) != 2:
raise VideoFileInvalid("data to short to contain nal unit header")
nal_unit_type = HevcNalUnitType((nal_unit_header[0] >> 1) & 0x3F)
if DEBUG:
print(" nal_unit_type:", nal_unit_type.name, f"({nal_unit_type.value})")
return nal_unit_type
def get_hevc_slice_type(dat: bytes, nal_unit_start: int, nal_unit_type: HevcNalUnitType) -> tuple[int, bool]:
# 7.3.2.9 Slice segment layer RBSP syntax
# slice_segment_layer_rbsp( ) {
# slice_segment_header( )
# slice_segment_data( )
# rbsp_slice_segment_trailing_bits( )
# }
# ...
# 7.3.6.1 General slice segment header syntax
# slice_segment_header( ) { // descriptor
# first_slice_segment_in_pic_flag u(1)
# if( nal_unit_type >= BLA_W_LP && nal_unit_type <= RSV_IRAP_VCL23 )
# no_output_of_prior_pics_flag u(1)
# slice_pic_parameter_set_id ue(v)
# if( !first_slice_segment_in_pic_flag ) {
# if( dependent_slice_segments_enabled_flag )
# dependent_slice_segment_flag u(1)
# slice_segment_address u(v)
# }
# if( !dependent_slice_segment_flag ) {
# for( i = 0; i < num_extra_slice_header_bits; i++ )
# slice_reserved_flag[ i ] u(1)
# slice_type ue(v)
# ...
rbsp_start = nal_unit_start + NAL_UNIT_START_CODE_SIZE + NAL_UNIT_HEADER_SIZE
skip_bits = 0
# 7.4.7.1 General slice segment header semantics
# first_slice_segment_in_pic_flag equal to 1 specifies that the slice segment is the first slice segment of the picture in
# decoding order. first_slice_segment_in_pic_flag equal to 0 specifies that the slice segment is not the first slice segment
# of the picture in decoding order.
is_first_slice = dat[rbsp_start] >> 7 & 1 == 1
if not is_first_slice:
# TODO: parse dependent_slice_segment_flag and slice_segment_address and get real slice_type
# for now since we don't use it return -1 for slice_type
return (-1, is_first_slice)
skip_bits += 1 # skip past first_slice_segment_in_pic_flag
if nal_unit_type >= HevcNalUnitType.BLA_W_LP and nal_unit_type <= HevcNalUnitType.RSV_IRAP_VCL23:
# 7.4.7.1 General slice segment header semantics
# no_output_of_prior_pics_flag affects the output of previously-decoded pictures in the decoded picture buffer after the
# decoding of an IDR or a BLA picture that is not the first picture in the bitstream as specified in Annex C.
skip_bits += 1 # skip past no_output_of_prior_pics_flag
# 7.4.7.1 General slice segment header semantics
# slice_pic_parameter_set_id specifies the value of pps_pic_parameter_set_id for the PPS in use.
# The value of slice_pic_parameter_set_id shall be in the range of 0 to 63, inclusive.
_, size = get_ue(dat, rbsp_start, skip_bits)
skip_bits += size # skip past slice_pic_parameter_set_id
# 7.4.3.3.1 General picture parameter set RBSP semanal_unit_lenntics
# num_extra_slice_header_bits specifies the number of extra slice header bits that are present in the slice header RBSP
# for coded pictures referring to the PPS. The value of num_extra_slice_header_bits shall be in the range of 0 to 2, inclusive,
# in bitstreams conforming to this version of this Specification. Other values for num_extra_slice_header_bits are reserved
# for future use by ITU-T | ISO/IEC. However, decoders shall allow num_extra_slice_header_bits to have any value.
# TODO: get from PPS_NUT pic_parameter_set_rbsp( ) for corresponding slice_pic_parameter_set_id
num_extra_slice_header_bits = 0
skip_bits += num_extra_slice_header_bits
# 7.4.7.1 General slice segment header semantics
# slice_type specifies the coding type of the slice according to Table 7-7.
# Table 7-7 - Name association to slice_type
# slice_type | Name of slice_type
# 0 | B (B slice)
# 1 | P (P slice)
# 2 | I (I slice)
# unsigned integer 0-th order Exp-Golomb-coded syntax element with the left bit first
slice_type, _ = get_ue(dat, rbsp_start, skip_bits)
if DEBUG:
print(" slice_type:", slice_type, f"(first slice: {is_first_slice})")
if slice_type > 2:
raise VideoFileInvalid("slice_type must be 0, 1, or 2")
return slice_type, is_first_slice
def hevc_index(hevc_file_name: str, allow_corrupt: bool=False) -> tuple[list, int, bytes]:
with FileReader(hevc_file_name) as f:
dat = f.read()
if len(dat) < NAL_UNIT_START_CODE_SIZE + 1:
raise VideoFileInvalid("data is too short")
if dat[0] != 0x00:
raise VideoFileInvalid("first byte must be 0x00")
prefix_dat = b""
frame_types = list()
i = 1 # skip past first byte 0x00
try:
while i < len(dat):
require_nal_unit_start(dat, i)
nal_unit_len = get_hevc_nal_unit_length(dat, i)
nal_unit_type = get_hevc_nal_unit_type(dat, i)
if nal_unit_type in HEVC_PARAMETER_SET_NAL_UNITS:
prefix_dat += dat[i:i+nal_unit_len]
elif nal_unit_type in HEVC_CODED_SLICE_SEGMENT_NAL_UNITS:
slice_type, is_first_slice = get_hevc_slice_type(dat, i, nal_unit_type)
if is_first_slice:
frame_types.append((slice_type, i))
i += nal_unit_len
except Exception as e:
if not allow_corrupt:
raise
print(f"ERROR: NAL unit skipped @ {i}\n", str(e))
return frame_types, len(dat), prefix_dat
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("input_file", type=str)
parser.add_argument("output_prefix_file", type=str)
parser.add_argument("output_index_file", type=str)
args = parser.parse_args()
frame_types, dat_len, prefix_dat = hevc_index(args.input_file)
with open(args.output_prefix_file, "wb") as f:
f.write(prefix_dat)
with open(args.output_index_file, "wb") as f:
for ft, fp in frame_types:
f.write(struct.pack("<II", ft, fp))
f.write(struct.pack("<II", 0xFFFFFFFF, dat_len))
if __name__ == "__main__":
main()

60
iqpilot/tools/mac_setup.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
ROOT="$(cd $DIR/../../ && pwd)"
ARCH=$(uname -m)
# homebrew update is slow
export HOMEBREW_NO_AUTO_UPDATE=1
if [[ $SHELL == "/bin/zsh" ]]; then
RC_FILE="$HOME/.zshrc"
elif [[ $SHELL == "/bin/bash" ]]; then
RC_FILE="$HOME/.bash_profile"
fi
# Install brew if required
if [[ $(command -v brew) == "" ]]; then
echo "Installing Homebrew"
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
echo "[ ] installed brew t=$SECONDS"
# make brew available now
if [[ $ARCH == "x86_64" ]]; then
echo 'eval "$(/usr/local/bin/brew shellenv)"' >> $RC_FILE
eval "$(/usr/local/bin/brew shellenv)"
else
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> $RC_FILE
eval "$(/opt/homebrew/bin/brew shellenv)"
fi
else
brew up
fi
brew bundle --file=$DIR/Brewfile
echo "[ ] finished brew install t=$SECONDS"
BREW_PREFIX=$(brew --prefix)
# archive backend tools for pip dependencies
export LDFLAGS="$LDFLAGS -L${BREW_PREFIX}/opt/zlib/lib"
export LDFLAGS="$LDFLAGS -L${BREW_PREFIX}/opt/bzip2/lib"
export CPPFLAGS="$CPPFLAGS -I${BREW_PREFIX}/opt/zlib/include"
export CPPFLAGS="$CPPFLAGS -I${BREW_PREFIX}/opt/bzip2/include"
# pycurl curl/openssl backend dependencies
export LDFLAGS="$LDFLAGS -L${BREW_PREFIX}/opt/openssl@3/lib"
export CPPFLAGS="$CPPFLAGS -I${BREW_PREFIX}/opt/openssl@3/include"
export PYCURL_CURL_CONFIG=/usr/bin/curl-config
export PYCURL_SSL_LIBRARY=openssl
# install python dependencies
$DIR/install_python_dependencies.sh
echo "[ ] installed python dependencies t=$SECONDS"
echo
echo "---- OPENPILOT SETUP DONE ----"
echo "Open a new shell or configure your active shell env by running:"
echo "source $RC_FILE"

1
iqpilot/tools/maneuvers/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/reports/

View File

@@ -0,0 +1,87 @@
# Lateral Maneuvers Testing Tool
> [!WARNING]
> Use caution when using this tool.
Test your vehicle's lateral control tuning with this tool. The tool will test the vehicle's ability to follow a few lateral maneuvers and includes a tool to generate a report from the route.
## Instructions
1. Check out a development branch such as `master-mici` on your device. The toggle is hidden on release branches.
2. The full maneuver suite runs at 20 and 30 mph.
3. Enable "Lateral Maneuver Mode" in Settings > Developer on the device while offroad. Alternatively, set the parameter manually:
```sh
echo -n 1 > /data/params/d/LateralManeuverMode
```
To run only some of the maneuvers, set `LateralManeuverFilter` to a substring of their
descriptions. Aborts mean a session often never reaches the later maneuvers, so target the one
you need directly. Unset or unmatched runs the full suite.
```sh
echo -n 'sine 0.5Hz 30mph' > /data/params/d/LateralManeuverFilter # comma's published comparison
echo -n '30mph' > /data/params/d/LateralManeuverFilter # all four 30 mph maneuvers
```
4. Turn your vehicle back on. You will see "Lateral Maneuver Mode".
5. Ensure the area ahead is clear, as IQ.Pilot will command lateral acceleration steps in this mode. Once you are ready, set ACC manually to the target speed shown on screen and let IQ.Pilot stabilize lateral. After 2 seconds of steady straight driving on a road under 250 m radius and under 6.8° of roll, the maneuver will begin automatically. IQ.Pilot lateral control stays engaged between maneuvers normally while waiting for the next maneuver's readiness conditions. The maneuver will be aborted and repeated if speed is out of range, the steering wheel or gas is touched, or IQ.Pilot disengages.
6. When the testing is complete, you'll see an alert that says "Maneuvers Finished." Complete the route by pulling over and turning off the vehicle.
7. Locate the route(s) — they will stand out with lots of orange intervals in their timeline. Ensure "All logs" show as "uploaded."
8. Gather the route ID and then run the report generator. The file will be exported to the same directory:
```sh
$ python iqpilot/tools/maneuvers/lateral_report.py 98395b7c5b27882e/000001cc--5a73bde686
processing report for KIA_EV6
plotting maneuver: step right 20mph, runs: 3
plotting maneuver: step left 20mph, runs: 3
plotting maneuver: sine 0.5Hz 20mph, runs: 3
plotting maneuver: step right 30mph, runs: 3
Opening report: iqpilot/tools/maneuvers/reports/lateral/KIA_EV6_98395b7c5b27882e_000001cc--5a73bde686.html
```
The IQ.Pilot `lateral_report.py` also takes a path to a local `rlog.zst` or a directory of them, supports
auto-detection of lateral sweeps in any route without `alertDebug` markers (pass `--auto`), and ranks the
top-N highest-peak sweeps by speed/peak filters. See `lateral_report.py --help`.
## Blog-style response plot
`lateral_response_plot.py` renders the "requested vs actual + 50% response time" figure comma
publishes, for one or more routes on the same axes:
```sh
$ python iqpilot/tools/maneuvers/lateral_response_plot.py '<route>' \
--maneuver 'sine 0.5Hz 30mph' --label 'IQ.Lvbs angle — VW Golf MK7' --out response.png
```
Three stacked panels sharing a time axis: lateral acceleration (comma's panel, with the 50% marker),
steering wheel angle (commanded vs measured, the commanded trace only exists on angle-control cars),
and steering wheel rate. `--accel-only` drops to comma's single panel.
`controlsd` derives curvature as `-calc_curvature(steeringAngleDeg)`, so the raw wheel angle always
reads opposite to lateral acceleration. The angle and rate panels are flipped to match the
acceleration panel; pass `--raw-angle` to plot the raw log sign instead.
The 50% response time is only comparable between runs of the **same maneuver at the same speed**. A
step and a sine of equal amplitude do not produce comparable numbers: the step's request rises
instantly, so its 50% crossing measures rack rise time alone, while the 0.5 Hz sine's own request
takes ~0.167 s to reach 50%. comma's published 350 ms (ID.4) and 423 ms (Model Y) are both from the
0.5 Hz sine at 30 mph.
## Testing the tooling without a car
`simulate_lateral.py` runs `lateral_maneuversd` as a real process against a synthetic steering rack and writes an
rlog that `lateral_report.py` reads. Use it to verify the daemon and the report generator after changing either:
```sh
$ python iqpilot/tools/maneuvers/simulate_lateral.py --out /tmp/lat/rlog.zst
$ python iqpilot/tools/maneuvers/lateral_report.py /tmp/lat/rlog.zst
```
The full suite takes about 5 minutes of wall clock; `--max-maneuvers N` stops early.

View File

@@ -0,0 +1,60 @@
# Longitudinal Maneuvers Testing Tool
Test your vehicle's longitudinal control tuning with this tool. The tool will test the vehicle's ability to follow a few longitudinal maneuvers and includes a tool to generate a report from the route.
<details><summary>Sample snapshot of a report.</summary><img width="600px" src="https://github.com/user-attachments/assets/d18d0c7d-2bde-44c1-8e86-1741ed442ad8"></details>
## Instructions
1. Check out a development branch such as `master-mici` on your device. The toggle is hidden on release branches.
2. Locate either a large empty parking lot or road devoid of any car or foot traffic. Flat, straight road is preferred. The full maneuver suite can take 1 mile or more if left running, however it is recommended to disengage IQ.Pilot between maneuvers and turn around if there is not enough space.
3. Turn off the vehicle and enable "Longitudinal Maneuver Mode" in Settings > Developer. The toggle requires IQ.Pilot longitudinal control and only enables while offroad. Alternatively, set the parameter manually:
```sh
echo -n 1 > /data/params/d/LongitudinalManeuverMode
```
4. Turn your vehicle back on. You will see the "Longitudinal Maneuver Mode" alert:
![videoframe_6652](https://github.com/user-attachments/assets/e9d4c95a-cd76-4ab7-933e-19937792fa0f)
5. Ensure the road ahead is clear, as openpilot will not brake for any obstructions in this mode. Once you are ready, press "Set" on your steering wheel to start the tests. The tests will run for about 4 minutes. If you need to pause the tests, press "Cancel" on your steering wheel. You can resume the tests by pressing "Resume" on your steering wheel.
**Note:** For GM cars, it is recommended to hold down the resume button for all low-speed tests (starting, stopping and creep) to avoid the car entering standstill.
![cog-clip-00 01 11 250-00 01 22 250](https://github.com/user-attachments/assets/c312c1cc-76e8-46e1-a05e-bb9dfb58994f)
6. When the testing is complete, you'll see an alert that says "Maneuvers Finished." Complete the route by pulling over and turning off the vehicle.
![fin2](https://github.com/user-attachments/assets/c06960ae-7cfb-44af-beaa-4dc28848e49d)
7. Visit https://connect.comma.ai and locate the route(s). They will stand out with lots of orange intervals in their timeline. Ensure "All logs" show as "uploaded."
![image](https://github.com/user-attachments/assets/cfe4c6d9-752f-4b24-b421-4b90a01933dc)
8. Gather the route ID and then run the report generator. The file will be exported to the same directory:
```sh
$ python iqpilot/tools/maneuvers/longitudinal_report.py 57048cfce01d9625/0000010e--5b26bc3be7 'pcm accel compensation'
processing report for LEXUS_ES_TSS2
plotting maneuver: start from stop, runs: 4
plotting maneuver: creep: alternate between +1m/s^2 and -1m/s^2, runs: 2
plotting maneuver: gas step response: +1m/s^2 from 20mph, runs: 2
Report written to iqpilot/tools/maneuvers/reports/longitudinal/LEXUS_ES_TSS2_57048cfce01d9625_0000010e--5b26bc3be7.html
```
`longitudinal_report.py` also takes a path to a local `rlog.zst` or a directory of them.
## Testing the tooling without a car
`simulate_longitudinal.py` runs `maneuversd` as a real process against a synthetic powertrain and writes an rlog
that `longitudinal_report.py` reads. Use it to verify the daemon and the report generator after changing either:
```sh
$ python iqpilot/tools/maneuvers/simulate_longitudinal.py --out /tmp/long/rlog.zst
$ python iqpilot/tools/maneuvers/longitudinal_report.py /tmp/long/rlog.zst
```
The full suite takes about 4 minutes of wall clock; `--max-maneuvers N` stops early.

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env python3
import numpy as np
from dataclasses import dataclass
from iqpilot.cereal import messaging, car
from iqpilot.common.constants import CV
from iqpilot.common.realtime import DT_MDL, Ratekeeper
from iqpilot.common.params import Params
from iqpilot.common.swaglog import cloudlog
from iqpilot.selfdrive.controls.lib.drive_helpers import MIN_SPEED
from iqpilot.tools.maneuvers.longitudinal_maneuversd import Action, Maneuver as _Maneuver
# thresholds for starting maneuvers
MAX_SPEED_DEV = 0.7 # deviation in m/s
MAX_CURV = 0.004 # 250 m radius
MAX_ROLL = 0.12 # 6.8°
TIMER = 2.0 # sec stable conditions before starting maneuver
# The curvature step yanks the rim and spikes driver torque for a frame or two, which single-frame
# aborts read as a driver grab. Measured on VW_GOLF_MK7: 9/9 maneuvers died 0.15s in with the driver
# near hands-off. The EPS torque signal is also noisy enough to clip the ALC override threshold for
# ~10 ms at a time, blipping steeringPressed on its own, so require the hold to exceed 0.2 s of
# continuous frames — longer than any sensor blip or step reaction, far shorter than a real grab.
STEER_PRESSED_ABORT_S = 0.25
STEER_PRESSED_FRAMES = int(STEER_PRESSED_ABORT_S / DT_MDL) # 5 frames at 20 Hz
@dataclass
class Maneuver(_Maneuver):
_baseline_curvature: float = 0.0
def get_accel(self, v_ego: float, lat_active: bool, curvature: float, roll: float) -> float:
self._run_completed = False
# only start maneuver on straight, flat roads
ready = abs(v_ego - self.initial_speed) < MAX_SPEED_DEV and lat_active and abs(curvature) < MAX_CURV and abs(roll) < MAX_ROLL
self._ready_cnt = (self._ready_cnt + 1) if ready else max(self._ready_cnt - 1, 0)
if self._ready_cnt > (TIMER / DT_MDL):
if not self._active:
self._baseline_curvature = curvature
self._active = True
if not self._active:
return 0.0
return self._step()
def reset(self):
super().reset()
self._ready_cnt = 0
def _sine_action(amplitude, period, duration):
t = np.linspace(0, duration, int(duration / DT_MDL) + 1)
a = amplitude * np.sin(2 * np.pi * t / period)
return Action(a.tolist(), t.tolist())
MANEUVERS = [
Maneuver(
"step right 20mph",
[Action([0.5], [1.0]), Action([-0.5], [1.5])],
repeat=2,
initial_speed=20. * CV.MPH_TO_MS,
),
Maneuver(
"step left 20mph",
[Action([-0.5], [1.0]), Action([0.5], [1.5])],
repeat=2,
initial_speed=20. * CV.MPH_TO_MS,
),
Maneuver(
"sine 0.5Hz 20mph",
[_sine_action(1.0, 2.0, 2.0), Action([0.0], [0.5])],
repeat=2,
initial_speed=20. * CV.MPH_TO_MS,
),
Maneuver(
"jitter 20mph",
[Action([-0.5 if i % 2 == 0 else 0.5], [0.1]) for i in range(10)],
repeat=2,
initial_speed=20. * CV.MPH_TO_MS,
),
Maneuver(
"step right 30mph",
[Action([0.5], [1.0]), Action([-0.5], [1.5])],
repeat=2,
initial_speed=30. * CV.MPH_TO_MS,
),
Maneuver(
"step left 30mph",
[Action([-0.5], [1.0]), Action([0.5], [1.5])],
repeat=2,
initial_speed=30. * CV.MPH_TO_MS,
),
Maneuver(
"sine 0.5Hz 30mph",
[_sine_action(1.0, 2.0, 2.0), Action([0.0], [0.5])],
repeat=2,
initial_speed=30. * CV.MPH_TO_MS,
),
Maneuver(
"jitter 30mph",
[Action([-0.5 if i % 2 == 0 else 0.5], [0.1]) for i in range(10)],
repeat=2,
initial_speed=30. * CV.MPH_TO_MS,
),
]
def select_maneuvers(params) -> list[Maneuver]:
# LateralManeuverFilter runs only the maneuvers whose description contains it, so a session can
# target one maneuver (e.g. the 0.5 Hz sine at 30 mph) without driving the whole suite to reach it
needle = (params.get("LateralManeuverFilter") or "").strip()
if not needle:
return MANEUVERS
selected = [m for m in MANEUVERS if needle.lower() in m.description.lower()]
if not selected:
cloudlog.error(f"LateralManeuverFilter {needle!r} matched no maneuvers, running the full suite")
return MANEUVERS
cloudlog.info(f"LateralManeuverFilter {needle!r} selected: {[m.description for m in selected]}")
return selected
def main():
params = Params()
cloudlog.info("lateral_maneuversd is waiting for CarParams")
messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams)
# iqpilot: subscribe only to the services we actually read and drive timing with a
# Ratekeeper instead of polling modelV2. msgq caps each topic at NUM_READERS (15) and
# evicts ALL subscribers when exceeded; iqpilot runs many daemons, and unlike longitudinal
# maneuver mode (which disables plannerd), lateral mode keeps plannerd running. Subscribing
# to selfdriveState/modelV2 here (selfdriveState is unused; modelV2 was only a poll source)
# tips those topics past 15 → eviction storm → UI/speed render drops to a few fps.
sm = messaging.SubMaster(['carState', 'carControl', 'controlsState'])
pm = messaging.PubMaster(['lateralManeuverPlan', 'alertDebug'])
rk = Ratekeeper(int(1. / DT_MDL), print_delay_threshold=None) # 20 Hz, matches DT_MDL maneuver timing
maneuvers = iter(select_maneuvers(params))
maneuver = None
complete_cnt = 0
aborted_cnt = 0
steer_pressed_cnt = 0
abort_reason = ''
display_holdoff = 0
prev_text = ''
while True:
sm.update(0)
if maneuver is None:
maneuver = next(maneuvers, None)
alert_msg = messaging.new_message('alertDebug')
alert_msg.valid = True
plan_send = messaging.new_message('lateralManeuverPlan')
accel = 0
v_ego = max(sm['carState'].vEgo, 0)
curvature = sm['controlsState'].desiredCurvature
if complete_cnt > 0:
complete_cnt -= 1
alert_msg.alertDebug.alertText1 = 'Completed'
alert_msg.alertDebug.alertText2 = maneuver.description
elif maneuver is not None:
# any driver input aborts the maneuver, but only a sustained hold counts as steering override
CS = sm['carState']
steer_pressed_cnt = (steer_pressed_cnt + 1) if CS.steeringPressed else 0
steer_override = steer_pressed_cnt >= STEER_PRESSED_FRAMES
if steer_override or CS.gasPressed:
aborted_cnt = int(1.0 / DT_MDL)
abort_reason = ('steering pressed' if steer_override else 'gas pressed').ljust(20)
aborted = aborted_cnt > 0
speed_out_of_range = maneuver.active and abs(v_ego - maneuver.initial_speed) > MAX_SPEED_DEV
if aborted or speed_out_of_range:
maneuver.reset()
roll = sm['carControl'].orientationNED[0] if len(sm['carControl'].orientationNED) == 3 else 0.0
accel = maneuver.get_accel(v_ego, sm['carControl'].latActive, curvature, roll)
if maneuver._run_completed:
complete_cnt = int(1.0 / DT_MDL)
alert_msg.alertDebug.alertText1 = 'Complete'
alert_msg.alertDebug.alertText2 = maneuver.description
elif maneuver.active:
action_remaining = maneuver.actions[maneuver._action_index].time_bp[-1] - maneuver._action_frames * DT_MDL
if maneuver.description.startswith('sine'):
freq = maneuver.description.split()[1]
alert_msg.alertDebug.alertText1 = f'Active sine {freq} {max(action_remaining, 0):.1f}s'
else:
alert_msg.alertDebug.alertText1 = f'Active {accel:+.1f}m/s² {max(action_remaining, 0):.1f}s'
alert_msg.alertDebug.alertText2 = maneuver.description
elif aborted_cnt > 0:
aborted_cnt -= 1
alert_msg.alertDebug.alertText1 = abort_reason
elif not (abs(v_ego - maneuver.initial_speed) < MAX_SPEED_DEV and sm['carControl'].latActive):
alert_msg.alertDebug.alertText1 = f'Set speed to {maneuver.initial_speed * CV.MS_TO_MPH:0.0f} mph'
elif maneuver._ready_cnt > 0:
ready_time = max(TIMER - maneuver._ready_cnt * DT_MDL, 0)
alert_msg.alertDebug.alertText1 = f'Starting: {int(ready_time) + 1}'
alert_msg.alertDebug.alertText2 = maneuver.description
else:
curv_ok = abs(curvature) < MAX_CURV
reason = 'road not straight' if not curv_ok else 'road not flat'
alert_msg.alertDebug.alertText1 = f'Waiting: {reason}'
alert_msg.alertDebug.alertText2 = maneuver.description
else:
alert_msg.alertDebug.alertText1 = 'Maneuvers Finished'
# prevent flickering text
setup = ('Set speed', 'Starting', 'Waiting')
text = alert_msg.alertDebug.alertText1
same = text == prev_text or (text.startswith('Starting') and prev_text.startswith('Starting'))
if not same and text.startswith(setup) and prev_text.startswith(setup) and display_holdoff > 0:
alert_msg.alertDebug.alertText1 = prev_text
display_holdoff -= 1
else:
prev_text = text
display_holdoff = int(0.5 / DT_MDL) if text.startswith(setup) else 0
pm.send('alertDebug', alert_msg)
plan_send.valid = maneuver is not None and maneuver.active and complete_cnt == 0
if plan_send.valid:
plan_send.lateralManeuverPlan.desiredCurvature = maneuver._baseline_curvature + accel / max(v_ego, MIN_SPEED) ** 2
pm.send('lateralManeuverPlan', plan_send)
if maneuver is not None and maneuver.finished and complete_cnt == 0:
maneuver = None
rk.keep_time()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,280 @@
#!/usr/bin/env python3
import argparse
import base64
import io
import math
import numpy as np
import os
import webbrowser
from collections import defaultdict
from pathlib import Path
import matplotlib.pyplot as plt
from iqpilot.common.utils import tabulate
from iqpilot.cereal import car
from iqpilot.common.filter_simple import FirstOrderFilter
from iqpilot.selfdrive.controls.lib.latcontrol_torque import LP_FILTER_CUTOFF_HZ
from iqpilot.tools.lib.logreader import LogReader
from iqpilot.system.hardware.hw import Paths
from iqpilot.common.constants import CV
from iqpilot.common.realtime import DT_MDL
from iqpilot.tools.maneuvers.lateral_maneuversd import STEER_PRESSED_FRAMES
from iqpilot.tools.maneuvers.longitudinal_report import format_car_params
ANGLE_CONTROL = (car.CarParams.SteerControlType.angle, car.CarParams.SteerControlType.curvatureDEPRECATED)
STEER_OVERRIDE_S = STEER_PRESSED_FRAMES * DT_MDL
def lat_accel(curvature, v):
return curvature * max(v, 1.0) ** 2
def steering_overridden(t_carState, carState):
# mirrors lateral_maneuversd: the curvature step spikes driver torque for a frame or two on
# cars with a tight override threshold, so only a sustained hold invalidates the run
start = None
for t, cs in zip(t_carState, carState, strict=True):
if not cs.steeringPressed:
start = None
elif start is None:
start = t
elif t - start >= STEER_OVERRIDE_S:
return True
return False
def report(platform, route, _description, CP, ID, maneuvers):
output_path = Path(__file__).resolve().parent / "reports" / "lateral"
output_fn = output_path / f"{platform}_{route.replace('/', '_').replace('|', '_')}.html"
output_path.mkdir(parents=True, exist_ok=True)
target_cross_times = defaultdict(list)
builder = [
"<style>summary { cursor: pointer; }\n td, th { padding: 8px; } </style>\n",
"<h1>Lateral maneuver report</h1>\n",
f"<h3>{platform}</h3>\n",
f"<h3>{route}</h3>\n",
f"<h3>{ID.gitCommit}, {ID.gitBranch}, {ID.gitRemote}</h3>\n",
]
if _description is not None:
builder.append(f"<h3>Description: {_description}</h3>\n")
builder.append(f"<details><summary><h3 style='display: inline-block;'>CarParams</h3></summary><pre>{format_car_params(CP)}</pre></details>\n")
builder.append('{ summary }') # to be replaced below
for description, runs in maneuvers:
# filter incomplete runs
completed_runs = [msgs for msgs in runs
if any(m.alertDebug.alertText1 == 'Complete' for m in msgs if m.which() == 'alertDebug')]
print(f'plotting maneuver: {description}, runs: {len(completed_runs)}')
if not completed_runs:
continue
builder.append("<div style='border-top: 1px solid #000; margin: 20px 0;'></div>\n")
builder.append(f"<h2>{description}</h2>\n")
for run, msgs in enumerate(completed_runs):
last_active = max(m.logMonoTime for m in msgs if m.which() == 'lateralManeuverPlan' and m.valid)
msgs = [m for m in msgs if m.logMonoTime <= last_active]
t_carControl, carControl = zip(*[(m.logMonoTime, m.carControl) for m in msgs if m.which() == 'carControl'], strict=True)
t_carState, carState = zip(*[(m.logMonoTime, m.carState) for m in msgs if m.which() == 'carState'], strict=True)
t_controlsState, controlsState = zip(*[(m.logMonoTime, m.controlsState) for m in msgs if m.which() == 'controlsState'], strict=True)
t_lateralPlan, lateralPlan = zip(*[(m.logMonoTime, m.lateralManeuverPlan) for m in msgs if m.which() == 'lateralManeuverPlan' and m.valid], strict=True)
t_carOutput, carOutput = zip(*[(m.logMonoTime, m.carOutput) for m in msgs if m.which() == 'carOutput'], strict=True)
# make time relative seconds
t_carControl = [(t - t_carControl[0]) / 1e9 for t in t_carControl]
t_carState = [(t - t_carState[0]) / 1e9 for t in t_carState]
t_controlsState = [(t - t_controlsState[0]) / 1e9 for t in t_controlsState]
t_lateralPlan = [(t - t_lateralPlan[0]) / 1e9 for t in t_lateralPlan]
t_carOutput = [(t - t_carOutput[0]) / 1e9 for t in t_carOutput]
# maneuver validity
latActive = [m.latActive for m in carControl]
maneuver_valid = all(latActive) and not steering_overridden(t_carState, carState)
_open = 'open' if maneuver_valid else ''
title = f'Run #{int(run)+1}' + (' <span style="color: red">(invalid maneuver!)</span>' if not maneuver_valid else '')
builder.append(f"<details {_open}><summary><h3 style='display: inline-block;'>{title}</h3></summary>\n")
baseline_accel = lat_accel(controlsState[0].curvature, carState[0].vEgo)
v_ego = [m.vEgo for m in carState]
cross_markers = []
if description.startswith(('sine', 'jitter')):
amplitude = max(abs(lat_accel(lp.desiredCurvature, v) - baseline_accel)
for lp, v in zip(lateralPlan, v_ego, strict=False))
threshold = amplitude * 0.5
builder.append('<h3 style="font-weight: normal">50% peak')
for t, cs, v in zip(t_controlsState, controlsState, v_ego, strict=False):
actual = lat_accel(cs.curvature, v) - baseline_accel
if abs(actual) > threshold:
builder.append(f', <strong>crossed in {t:.3f}s</strong>')
cross_markers.append((t, actual + baseline_accel))
if maneuver_valid:
target_cross_times[description].append(t)
break
else:
builder.append(', <strong>not crossed</strong>')
builder.append('</h3>')
if maneuver_valid:
target_cross_times.setdefault(description, [])
else:
action_targets = [(0, lat_accel(lateralPlan[0].desiredCurvature, v_ego[0]) - baseline_accel)]
for i in range(1, min(len(lateralPlan), len(v_ego))):
if abs(lateralPlan[i].desiredCurvature - lateralPlan[i - 1].desiredCurvature) > 0.001:
desired = lat_accel(lateralPlan[i].desiredCurvature, v_ego[i]) - baseline_accel
action_targets.append((i, desired))
for j, (start_i, act_target) in enumerate(action_targets):
start_time = t_lateralPlan[start_i]
end_time = t_lateralPlan[action_targets[j + 1][0]] if j + 1 < len(action_targets) else t_controlsState[-1]
builder.append(f'<h3 style="font-weight: normal">aTarget: {round(act_target, 1)} m/s^2')
prev_crossed = False
for t, cs, v in zip(t_controlsState, controlsState, v_ego, strict=False):
if not (start_time <= t <= end_time):
continue
actual_accel = lat_accel(cs.curvature, v) - baseline_accel
crossed = (0 < act_target < actual_accel) or (0 > act_target > actual_accel)
if crossed and prev_crossed:
cross_time = t - start_time
builder.append(f', <strong>crossed in {cross_time:.3f}s</strong>')
cross_markers.append((t, act_target + baseline_accel))
if maneuver_valid:
target_cross_times[description].append(cross_time)
break
prev_crossed = crossed
else:
builder.append(', <strong>not crossed</strong>')
builder.append('</h3>')
if maneuver_valid:
target_cross_times.setdefault(description, [])
plt.rcParams['font.size'] = 40
fig = plt.figure(figsize=(30, 40))
ax = fig.subplots(5, 1, sharex=True, gridspec_kw={'height_ratios': [5, 5, 3, 3, 3]})
ax[0].grid(linewidth=4)
desired_label = 'lateralManeuverPlan.desiredCurvature * vEgo^2'
desired_lat_accel = [lat_accel(m.desiredCurvature, v) for m, v in zip(lateralPlan, v_ego, strict=False)]
if description.startswith(('sine', 'jitter')):
ax[0].plot(t_lateralPlan[:len(desired_lat_accel)], desired_lat_accel, 'C1', label=desired_label, linewidth=6)
else:
t_desired = [t_lateralPlan[0]] + t_lateralPlan[:len(desired_lat_accel)]
desired_lat_accel = [baseline_accel] + desired_lat_accel
ax[0].step(t_desired, desired_lat_accel, 'C1', label=desired_label, linewidth=6, where='post')
actual_lat_accel = [lat_accel(cs.curvature, v) for cs, v in zip(controlsState, v_ego, strict=False)]
ax[0].plot(t_controlsState[:len(actual_lat_accel)], actual_lat_accel, 'g', label='controlsState.curvature * vEgo^2', linewidth=6)
ax[0].set_ylabel('Lateral Accel (m/s^2)')
for ct, cv in cross_markers:
ax[0].plot(ct, cv, marker='o', markersize=50, markeredgewidth=7, markeredgecolor='black', markerfacecolor='None')
ax[0].legend(prop={'size': 30})
ax[1].grid(linewidth=4)
if CP.steerControlType in ANGLE_CONTROL:
steer_field, steer_ylabel = 'steeringAngleDeg', 'Steer angle (deg)'
else:
steer_field, steer_ylabel = 'torque', 'Steer torque'
ax[1].plot(t_carControl, [getattr(m.actuators, steer_field) for m in carControl], 'C1', label=f'carControl.actuators.{steer_field}', linewidth=6)
ax[1].plot(t_carOutput, [getattr(m.actuatorsOutput, steer_field) for m in carOutput], 'g', label=f'carOutput.actuatorsOutput.{steer_field}', linewidth=6)
ax[1].set_ylabel(steer_ylabel)
ax[1].legend(prop={'size': 30})
ax[2].grid(linewidth=4)
ax[2].plot(t_carState, [v * CV.MS_TO_MPH for v in v_ego], label='carState.vEgo', linewidth=6)
ax[2].set_ylabel('Velocity (mph)')
ax[2].yaxis.set_major_formatter(plt.FormatStrFormatter('%.1f'))
ax[2].legend()
t_accel = np.array(t_controlsState[:len(actual_lat_accel)])
raw_jerk = np.gradient(actual_lat_accel, t_accel)
dt_avg = np.mean(np.diff(t_accel))
jerk_filter = FirstOrderFilter(0.0, 1 / (2 * np.pi * LP_FILTER_CUTOFF_HZ), dt_avg)
filtered_jerk = [jerk_filter.update(j) for j in raw_jerk]
ax[3].grid(linewidth=4)
ax[3].plot(t_accel, filtered_jerk, label='d/dt(controlsState.curvature * vEgo^2)', linewidth=6)
ax[3].set_ylabel('Jerk (m/s^3)')
ax[3].legend()
ax[4].grid(linewidth=4)
ax[4].plot(t_carControl, [math.degrees(m.orientationNED[0]) if len(m.orientationNED) == 3 else 0.0 for m in carControl],
label='carControl.orientationNED[0]', linewidth=6)
ax[4].set_ylabel('Roll (deg)')
ax[4].legend()
ax[-1].set_xlabel("Time (s)")
fig.tight_layout()
buffer = io.BytesIO()
fig.savefig(buffer, format='webp')
plt.close(fig)
buffer.seek(0)
builder.append(f"<img src='data:image/webp;base64,{base64.b64encode(buffer.getvalue()).decode()}' style='width:100%; max-width:800px;'>\n")
builder.append("</details>\n")
summary = ["<h2>Summary</h2>\n"]
cols = ['maneuver', 'crossed', 'mean', 'min', 'max']
table = []
for description, times in target_cross_times.items():
l = [description, len(times)]
if len(times):
l.extend([round(sum(times) / len(times), 2), round(min(times), 2), round(max(times), 2)])
table.append(l)
summary.append(tabulate(table, headers=cols, tablefmt='html', numalign='left') + '\n')
sum_idx = builder.index('{ summary }')
builder[sum_idx:sum_idx + 1] = summary
with open(output_fn, "w") as f:
f.write(''.join(builder))
print(f"\nOpening report: {output_fn}\n")
webbrowser.open_new_tab(str(output_fn))
def open_route(route: str) -> LogReader:
if os.path.isdir(route):
rlogs = sorted(str(p) for p in Path(route).glob("*rlog.zst"))
if not rlogs:
raise SystemExit(f"no *rlog.zst files in {route}")
print(f"loading {len(rlogs)} rlogs from {route}")
return LogReader(rlogs, only_union_types=True)
if route.endswith(('.zst', '.bz2', '.log')) and not os.path.exists(route):
raise SystemExit(f"no such file: {route}")
if os.path.exists(route) or '/' in route or '|' in route:
return LogReader(route, only_union_types=True)
segs = [seg for seg in os.listdir(Paths.log_root()) if route in seg]
return LogReader([os.path.join(Paths.log_root(), seg, 'rlog.zst') for seg in segs], only_union_types=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Generate lateral maneuver report from route')
parser.add_argument('route', type=str, help='Route name, local rlog path, or directory of rlogs')
parser.add_argument('description', type=str, nargs='?')
args = parser.parse_args()
lr = open_route(args.route)
CP = lr.first('carParams')
ID = lr.first('initData')
platform = CP.carFingerprint
print('processing report for', platform)
maneuvers: list[tuple[str, list[list]]] = []
active_prev = False
description_prev = None
for msg in lr:
if msg.which() == 'alertDebug':
active = 'Active' in msg.alertDebug.alertText1 or msg.alertDebug.alertText1 == 'Complete'
if active and not active_prev:
if msg.alertDebug.alertText2 == description_prev:
maneuvers[-1][1].append([])
else:
maneuvers.append((msg.alertDebug.alertText2, [[]]))
description_prev = maneuvers[-1][0]
active_prev = active
if active_prev:
maneuvers[-1][1][-1].append(msg)
report(platform, args.route, args.description, CP, ID, maneuvers)

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Blog-style lateral actuator response plot.
Renders the single-panel "requested vs actual lateral acceleration + 50% response time" figure
comma publishes for lateral maneuver comparisons, for one or more routes on the same axes.
./iqpilot/tools/maneuvers/lateral_response_plot.py 1ce1b50dd82993a1'|'00000011--6cb007b200/0:4 \
--maneuver 'sine 0.5Hz 30mph' --label 'IQ.Lvbs angle (Golf MK7)'
The 50% response time is only comparable between runs of the SAME maneuver at the SAME speed;
a step and a sine of equal amplitude do not produce comparable numbers.
"""
import argparse
from pathlib import Path
from typing import NamedTuple
import matplotlib.pyplot as plt
import numpy as np
from iqpilot.tools.maneuvers.lateral_report import lat_accel, open_route, steering_overridden
SERIES_COLORS = ('#2ca02c', '#ff7f0e', '#1f77b4', '#d62728')
REQUESTED_COLOR = '#999999'
def completed_runs(msgs, maneuver):
runs, active_prev, desc_prev = [], False, None
for m in msgs:
if m.which() == 'alertDebug':
active = 'Active' in m.alertDebug.alertText1 or m.alertDebug.alertText1 == 'Complete'
if active and not active_prev:
if m.alertDebug.alertText2 == desc_prev:
runs[-1][1].append([])
else:
runs.append((m.alertDebug.alertText2, [[]]))
desc_prev = runs[-1][0]
active_prev = active
if active_prev:
runs[-1][1][-1].append(m)
out = []
for description, windows in runs:
if maneuver is not None and description != maneuver:
continue
for w in windows:
if any(m.alertDebug.alertText1 == 'Complete' for m in w if m.which() == 'alertDebug'):
out.append((description, w))
return out
class Run(NamedTuple):
t_requested: np.ndarray
requested: np.ndarray
t_actual: np.ndarray
actual: np.ndarray
t_wheel: np.ndarray
angle: np.ndarray
rate: np.ndarray
t_angle_cmd: np.ndarray
angle_cmd: np.ndarray | None
v_mean: float
valid: bool
def extract(msgs, raw_angle: bool = False) -> Run:
"""requested/actual lateral accel and wheel angle on a common relative timebase, baseline removed"""
t_cs, carState = zip(*[(m.logMonoTime, m.carState) for m in msgs if m.which() == 'carState'], strict=True)
t_ct, controlsState = zip(*[(m.logMonoTime, m.controlsState) for m in msgs if m.which() == 'controlsState'], strict=True)
t_cc, carControl = zip(*[(m.logMonoTime, m.carControl) for m in msgs if m.which() == 'carControl'], strict=True)
t_lp, lateralPlan = zip(*[(m.logMonoTime, m.lateralManeuverPlan) for m in msgs
if m.which() == 'lateralManeuverPlan' and m.valid], strict=True)
t0 = t_lp[0]
def rel(ts):
return np.array([(t - t0) / 1e9 for t in ts])
t_cs_s, t_ct_s, t_cc_s, t_lp_s = rel(t_cs), rel(t_ct), rel(t_cc), rel(t_lp)
v_ego = np.array([m.vEgo for m in carState])
v_at_lp = np.interp(t_lp_s, t_cs_s, v_ego)
v_at_ct = np.interp(t_ct_s, t_cs_s, v_ego)
baseline = lat_accel(controlsState[0].curvature, carState[0].vEgo)
requested = np.array([lat_accel(m.desiredCurvature, v) for m, v in zip(lateralPlan, v_at_lp, strict=True)]) - baseline
actual = np.array([lat_accel(m.curvature, v) for m, v in zip(controlsState, v_at_ct, strict=True)]) - baseline
# controlsd derives curvature as -calc_curvature(steeringAngleDeg), so raw wheel angle always reads
# opposite to lateral accel; flip it unless the caller wants the raw signal
sign = 1.0 if raw_angle else -1.0
angle = sign * (np.array([m.steeringAngleDeg for m in carState]) - carState[0].steeringAngleDeg)
rate = sign * np.array([m.steeringRateDeg for m in carState])
if not np.any(rate): # not all brands populate steeringRateDeg
rate = np.gradient(angle, t_cs_s)
# angle command only exists on angle-control cars
cmd = sign * (np.array([m.actuators.steeringAngleDeg for m in carControl]) - carControl[0].actuators.steeringAngleDeg)
angle_cmd = cmd if np.any(cmd) else None
window = lambda t: (t >= 0) & (t <= t_lp_s[-1]) # noqa: E731
k_ct, k_cs, k_cc = window(t_ct_s), window(t_cs_s), window(t_cc_s)
lat_active = all(m.latActive for m in carControl)
# steering_overridden takes seconds, not raw logMonoTime
overridden = steering_overridden(t_cs_s.tolist(), carState)
return Run(t_lp_s, requested, t_ct_s[k_ct], actual[k_ct],
t_cs_s[k_cs], angle[k_cs], rate[k_cs],
t_cc_s[k_cc], angle_cmd[k_cc] if angle_cmd is not None else None,
float(np.mean(v_ego)), lat_active and not overridden)
def response_time(t_actual, actual, requested):
"""time to first reach 50% of the requested peak, comma's metric"""
amplitude = float(np.max(np.abs(requested)))
if amplitude < 1e-3:
return None, amplitude
threshold = 0.5 * amplitude
crossed = np.flatnonzero(np.abs(actual) > threshold)
if not len(crossed):
return None, amplitude
return float(t_actual[crossed[0]]), amplitude
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('routes', nargs='+', help='route, local rlog path, or directory (one per series)')
parser.add_argument('--label', action='append', default=[], help='series label, repeat to match routes')
parser.add_argument('--maneuver', default='sine 0.5Hz 30mph', help='maneuver description to plot')
parser.add_argument('--run', type=int, default=0, help='which completed run to plot (default first)')
parser.add_argument('--out', type=Path, default=Path('lateral_response.png'))
parser.add_argument('--title', default=None)
parser.add_argument('--accel-only', action='store_true',
help="just comma's single lateral-accel panel, without wheel angle and rate")
parser.add_argument('--raw-angle', action='store_true',
help='plot wheel angle in the raw log sign instead of aligned to lateral accel')
args = parser.parse_args()
if args.accel_only:
fig, ax0 = plt.subplots(figsize=(9, 5.5), dpi=200)
ax_angle = ax_rate = None
axes = [ax0]
else:
fig, axes = plt.subplots(3, 1, figsize=(9, 10), dpi=200, sharex=True,
gridspec_kw={'height_ratios': [3, 2, 2]})
ax0, ax_angle, ax_rate = axes
ax = ax0
annotations = []
plotted_requested = False
plotted_cmd = False
for i, route in enumerate(args.routes):
label = args.label[i] if i < len(args.label) else route
color = SERIES_COLORS[i % len(SERIES_COLORS)]
msgs = list(open_route(route))
runs = completed_runs(msgs, args.maneuver)
if not runs:
have = sorted({d for d, _ in completed_runs(msgs, None)})
raise SystemExit(f"{route}: no completed '{args.maneuver}' runs. completed maneuvers in this route: {have or 'none'}")
if args.run >= len(runs):
raise SystemExit(f"{route}: only {len(runs)} completed '{args.maneuver}' run(s), --run {args.run} out of range")
description, msgs_run = runs[args.run]
r = extract(msgs_run, args.raw_angle)
t_req, requested, t_act, actual, v_mean, valid = r.t_requested, r.requested, r.t_actual, r.actual, r.v_mean, r.valid
cross, amplitude = response_time(t_act, actual, requested)
if not plotted_requested:
ax.plot(t_req, requested, color=REQUESTED_COLOR, linestyle=':', linewidth=2.5, label='requested', zorder=1)
plotted_requested = True
ax.plot(t_act, actual, color=color, linewidth=2.5, label=label, zorder=3)
if ax_angle is not None:
if r.angle_cmd is not None and not plotted_cmd:
ax_angle.plot(r.t_angle_cmd, r.angle_cmd, color=REQUESTED_COLOR, linestyle=':', linewidth=2.5,
label='commanded', zorder=1)
plotted_cmd = True
ax_angle.plot(r.t_wheel, r.angle, color=color, linewidth=2.5, zorder=3)
ax_rate.plot(r.t_wheel, r.rate, color=color, linewidth=2.5, zorder=3)
if cross is not None:
y = float(np.interp(cross, t_act, actual))
ax.axvline(cross, color=color, linestyle='--', linewidth=1.5, ymax=0.92, zorder=2)
ax.plot(cross, y, marker='o', markersize=9, markeredgewidth=2,
markeredgecolor=color, markerfacecolor='none', zorder=4)
annotations.append((f'50% response in {cross:.3f} s', color))
else:
annotations.append(('50% response not reached', color))
flag = '' if valid else ' (INVALID: lat not active or steering overridden)'
cross_str = f'{cross:.3f} s' if cross else 'n/a'
print(', '.join([
f"{label}: {description}",
f"run {args.run}",
f"{v_mean * 2.23694:.1f} mph",
f"peak requested {amplitude:.2f} m/s^2",
f"50% in {cross_str}",
f"peak wheel {np.abs(r.angle).max():.1f} deg",
f"peak rate {np.abs(r.rate).max():.0f} deg/s{flag}",
]))
# headroom so the annotation block never sits on the trace
lo, hi = ax.get_ylim()
ax.set_ylim(lo, hi + (hi - lo) * 0.10 * max(len(annotations), 1))
for j, (text, color) in enumerate(annotations):
ax.text(0.03, 0.965 - j * 0.06, text, transform=ax.transAxes, color=color,
fontsize=11, fontweight='bold', va='top',
bbox={'facecolor': 'white', 'edgecolor': 'none', 'alpha': 0.75, 'pad': 2})
ax.set_ylabel('Lateral acceleration (m/s²)')
if ax_angle is not None:
ax_angle.set_ylabel('Steering wheel angle (deg)')
ax_rate.set_ylabel('Steering wheel rate (deg/s)')
if not args.raw_angle:
fig.text(0.5, 0.005, 'wheel angle sign aligned to lateral accel (raw log sign is inverted; --raw-angle to keep it)',
ha='center', fontsize=8, color='#777777')
if plotted_cmd:
ax_angle.legend(loc='upper right', frameon=False, fontsize=9)
axes[-1].set_xlabel('Time (s)')
for a in axes:
a.grid(True, color='#dddddd', linewidth=0.8)
a.set_axisbelow(True)
for side in ('top', 'right'):
a.spines[side].set_visible(False)
ax.legend(loc='upper center', bbox_to_anchor=(0.5, 1.12 if args.accel_only else 1.10),
ncol=4, frameon=False, fontsize=10)
if args.title:
ax.set_title(args.title, pad=28)
fig.tight_layout()
fig.savefig(args.out, bbox_inches='tight')
print(f"\nwrote {args.out}")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
import numpy as np
from dataclasses import dataclass
from iqpilot.cereal import messaging
from iqpilot.common.constants import CV
from iqpilot.common.realtime import DT_MDL
from iqpilot.common.params import Params
from iqpilot.common.swaglog import cloudlog
from iqpilot.selfdrive.controls.lib.drive_helpers import should_stop
@dataclass
class Action:
accel_bp: list[float] # m/s^2
time_bp: list[float] # seconds
def __post_init__(self):
assert len(self.accel_bp) == len(self.time_bp)
@dataclass
class Maneuver:
description: str
actions: list[Action]
repeat: int = 0
initial_speed: float = 0. # m/s
_active: bool = False
_finished: bool = False
_run_completed: bool = False
_action_index: int = 0
_action_frames: int = 0
_ready_cnt: int = 0
_repeated: int = 0
def _step(self) -> float:
self._run_completed = False
action = self.actions[self._action_index]
action_accel = np.interp(self._action_frames * DT_MDL, action.time_bp, action.accel_bp)
self._action_frames += 1
# reached duration of action
if self._action_frames > (action.time_bp[-1] / DT_MDL):
# next action
if self._action_index < len(self.actions) - 1:
self._action_index += 1
self._action_frames = 0
# repeat maneuver
elif self._repeated < self.repeat:
self._repeated += 1
self._run_completed = True
self.reset()
# finish maneuver
else:
self._run_completed = True
self._finished = True
return float(action_accel)
def get_accel(self, v_ego: float, long_active: bool, standstill: bool, cruise_standstill: bool) -> float:
ready = abs(v_ego - self.initial_speed) < 0.3 and long_active and not cruise_standstill
if self.initial_speed < 0.01:
ready = ready and standstill
self._ready_cnt = (self._ready_cnt + 1) if ready else 0
if self._ready_cnt > (3. / DT_MDL):
self._active = True
if not self._active:
return min(max(self.initial_speed - v_ego, -2.), 2.)
return self._step()
def reset(self):
self._active = False
self._action_frames = 0
self._action_index = 0
@property
def finished(self):
return self._finished
@property
def active(self):
return self._active
MANEUVERS = [
Maneuver(
"come to stop",
[Action([-0.5], [12])],
repeat=2,
initial_speed=5.,
),
Maneuver(
"start from stop",
[Action([1.5], [6])],
repeat=2,
initial_speed=0.,
),
Maneuver(
"creep: alternate between +1m/s^2 and -1m/s^2",
[
Action([1], [3]), Action([-1], [3]),
Action([1], [3]), Action([-1], [3]),
Action([1], [3]), Action([-1], [3]),
],
repeat=2,
initial_speed=0.,
),
Maneuver(
"brake step response: -1m/s^2 from 20mph",
[Action([-1], [3])],
repeat=2,
initial_speed=20. * CV.MPH_TO_MS,
),
Maneuver(
"brake step response: -4m/s^2 from 20mph",
[Action([-4], [3])],
repeat=2,
initial_speed=20. * CV.MPH_TO_MS,
),
Maneuver(
"gas step response: +1m/s^2 from 20mph",
[Action([1], [3])],
repeat=2,
initial_speed=20. * CV.MPH_TO_MS,
),
Maneuver(
"gas step response: +4m/s^2 from 20mph",
[Action([4], [3])],
repeat=2,
initial_speed=20. * CV.MPH_TO_MS,
),
]
def main():
params = Params()
cloudlog.info("maneuversd is waiting for CarParams")
params.get("CarParams", block=True)
sm = messaging.SubMaster(['carState', 'carControl', 'controlsState', 'selfdriveState', 'modelV2'], poll='modelV2')
pm = messaging.PubMaster(['longitudinalPlan', 'iqPlan', 'driverAssistance', 'alertDebug'])
maneuvers = iter(MANEUVERS)
maneuver = None
while True:
sm.update()
if maneuver is None:
maneuver = next(maneuvers, None)
alert_msg = messaging.new_message('alertDebug')
alert_msg.valid = True
plan_send = messaging.new_message('longitudinalPlan')
plan_send.valid = sm.all_checks()
longitudinalPlan = plan_send.longitudinalPlan
accel = 0
v_ego = max(sm['carState'].vEgo, 0)
if maneuver is not None:
accel = maneuver.get_accel(v_ego, sm['carControl'].longActive, sm['carState'].standstill, sm['carState'].cruiseState.standstill)
if maneuver.active:
alert_msg.alertDebug.alertText1 = f'Maneuver Active: {accel:0.2f} m/s^2'
else:
alert_msg.alertDebug.alertText1 = f'Setting up to {maneuver.initial_speed * CV.MS_TO_MPH:0.2f} mph'
alert_msg.alertDebug.alertText2 = f'{maneuver.description}'
else:
alert_msg.alertDebug.alertText1 = 'Maneuvers Finished'
pm.send('alertDebug', alert_msg)
longitudinalPlan.aTarget = accel
longitudinalPlan.shouldStop = should_stop(v_ego, accel)
longitudinalPlan.allowBrake = True
longitudinalPlan.allowThrottle = True
longitudinalPlan.hasLead = True
longitudinalPlan.speeds = [0.2] # triggers carControl.cruiseControl.resume in controlsd
pm.send('longitudinalPlan', plan_send)
plan_iq_send = messaging.new_message('iqPlan')
plan_iq_send.valid = True
pm.send('iqPlan', plan_iq_send)
assistance_send = messaging.new_message('driverAssistance')
assistance_send.valid = True
pm.send('driverAssistance', assistance_send)
if maneuver is not None and maneuver.finished:
maneuver = None

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
import argparse
import base64
import io
import os
import math
import pprint
import webbrowser
from collections import defaultdict
from pathlib import Path
import matplotlib.pyplot as plt
from iqpilot.common.utils import tabulate
from iqpilot.tools.lib.logreader import LogReader
from iqpilot.system.hardware.hw import Paths
def format_car_params(CP):
return pprint.pformat({k: v for k, v in CP.to_dict().items() if not k.endswith('DEPRECATED')}, indent=2)
def report(platform, route, _description, CP, ID, maneuvers):
output_path = Path(__file__).resolve().parent / "reports" / "longitudinal"
output_fn = output_path / f"{platform}_{route.replace('/', '_')}.html"
output_path.mkdir(parents=True, exist_ok=True)
target_cross_times = defaultdict(list)
builder = [
"<style>summary { cursor: pointer; }\n td, th { padding: 8px; } </style>\n",
"<h1>Longitudinal maneuver report</h1>\n",
f"<h3>{platform}</h3>\n",
f"<h3>{route}</h3>\n",
f"<h3>{ID.gitCommit}, {ID.gitBranch}, {ID.gitRemote}</h3>\n",
]
if _description is not None:
builder.append(f"<h3>Description: {_description}</h3>\n")
builder.append(f"<details><summary><h3 style='display: inline-block;'>CarParams</h3></summary><pre>{format_car_params(CP)}</pre></details>\n")
builder.append('{ summary }') # to be replaced below
for description, runs in maneuvers:
print(f'plotting maneuver: {description}, runs: {len(runs)}')
builder.append("<div style='border-top: 1px solid #000; margin: 20px 0;'></div>\n")
builder.append(f"<h2>{description}</h2>\n")
for run, msgs in enumerate(runs):
t_carControl, carControl = zip(*[(m.logMonoTime, m.carControl) for m in msgs if m.which() == 'carControl'], strict=True)
t_carOutput, carOutput = zip(*[(m.logMonoTime, m.carOutput) for m in msgs if m.which() == 'carOutput'], strict=True)
t_carState, carState = zip(*[(m.logMonoTime, m.carState) for m in msgs if m.which() == 'carState'], strict=True)
t_deviceMotion, deviceMotion = zip(*[(m.logMonoTime, m.deviceMotion) for m in msgs if m.which() == 'deviceMotion'], strict=True)
t_longitudinalPlan, longitudinalPlan = zip(*[(m.logMonoTime, m.longitudinalPlan) for m in msgs if m.which() == 'longitudinalPlan'], strict=True)
# make time relative seconds
t_carControl = [(t - t_carControl[0]) / 1e9 for t in t_carControl]
t_carOutput = [(t - t_carOutput[0]) / 1e9 for t in t_carOutput]
t_carState = [(t - t_carState[0]) / 1e9 for t in t_carState]
t_deviceMotion = [(t - t_deviceMotion[0]) / 1e9 for t in t_deviceMotion]
t_longitudinalPlan = [(t - t_longitudinalPlan[0]) / 1e9 for t in t_longitudinalPlan]
# maneuver validity
longActive = [m.longActive for m in carControl]
maneuver_valid = all(longActive) and (not any(cs.cruiseState.standstill for cs in carState) or CP.autoResumeSng)
_open = 'open' if maneuver_valid else ''
title = f'Run #{int(run)+1}' + (' <span style="color: red">(invalid maneuver!)</span>' if not maneuver_valid else '')
builder.append(f"<details {_open}><summary><h3 style='display: inline-block;'>{title}</h3></summary>\n")
# get first acceleration target and first intersection
aTarget = longitudinalPlan[0].aTarget
target_cross_time = None
builder.append(f'<h3 style="font-weight: normal">Initial aTarget: {round(aTarget, 2)} m/s^2')
# Localizer is noisy, require two consecutive 20Hz frames above threshold
prev_crossed = False
for t, lp in zip(t_deviceMotion, deviceMotion, strict=True):
crossed = (0 < aTarget < lp.accelerationDevice.x) or (0 > aTarget > lp.accelerationDevice.x)
if crossed and prev_crossed:
builder.append(f', <strong>crossed in {t:.3f}s</strong>')
target_cross_time = t
if maneuver_valid:
target_cross_times[description].append(t)
break
prev_crossed = crossed
else:
builder.append(', <strong>not crossed</strong>')
builder.append('</h3>')
pitches = [math.degrees(m.orientationNED[1]) for m in carControl]
builder.append(f'<h3 style="font-weight: normal">Average pitch: <strong>{sum(pitches) / len(pitches):0.2f} degrees</strong></h3>')
plt.rcParams['font.size'] = 40
fig = plt.figure(figsize=(30, 26))
ax = fig.subplots(4, 1, sharex=True, gridspec_kw={'height_ratios': [5, 3, 1, 1]})
ax[0].grid(linewidth=4)
ax[0].plot(t_carControl, [m.actuators.accel for m in carControl], label='carControl.actuators.accel', linewidth=6)
ax[0].plot(t_carOutput, [m.actuatorsOutput.accel for m in carOutput], label='carOutput.actuatorsOutput.accel', linewidth=6)
ax[0].plot(t_longitudinalPlan, [m.aTarget for m in longitudinalPlan], label='longitudinalPlan.aTarget', linewidth=6)
ax[0].plot(t_carState, [m.aEgo for m in carState], label='carState.aEgo', linewidth=6)
ax[0].plot(t_deviceMotion, [m.accelerationDevice.x for m in deviceMotion], label='deviceMotion.accelerationDevice.x', linewidth=6)
# TODO localizer accel
ax[0].set_ylabel('Acceleration (m/s^2)')
#ax[0].set_ylim(-6.5, 6.5)
ax[0].legend(prop={'size': 30})
if target_cross_time is not None:
ax[0].plot(target_cross_time, aTarget, marker='o', markersize=50, markeredgewidth=7, markeredgecolor='black', markerfacecolor='None')
ax[1].grid(linewidth=4)
ax[1].plot(t_carState, [m.vEgo for m in carState], 'g', label='vEgo', linewidth=6)
ax[1].set_ylabel('Velocity (m/s)')
ax[1].legend()
ax[2].plot(t_carControl, longActive, label='longActive', linewidth=6)
ax[3].plot(t_carState, [m.gasPressed for m in carState], label='gasPressed', linewidth=6)
ax[3].plot(t_carState, [m.brakePressed for m in carState], label='brakePressed', linewidth=6)
for i in (2, 3):
ax[i].set_yticks([0, 1], minor=False)
ax[i].set_ylim(-1, 2)
ax[i].legend()
ax[-1].set_xlabel("Time (s)")
fig.tight_layout()
buffer = io.BytesIO()
fig.savefig(buffer, format='webp')
plt.close(fig)
buffer.seek(0)
builder.append(f"<img src='data:image/webp;base64,{base64.b64encode(buffer.getvalue()).decode()}' style='width:100%; max-width:800px;'>\n")
builder.append("</details>\n")
summary = ["<h2>Summary</h2>\n"]
cols = ['maneuver', 'crossed', 'runs', 'mean', 'min', 'max']
table = []
for description, runs in maneuvers:
times = target_cross_times[description]
l = [description, len(times), len(runs)]
if len(times):
l.extend([round(sum(times) / len(times), 2), round(min(times), 2), round(max(times), 2)])
table.append(l)
summary.append(tabulate(table, headers=cols, tablefmt='html', numalign='left') + '\n')
sum_idx = builder.index('{ summary }')
builder[sum_idx:sum_idx + 1] = summary
with open(output_fn, "w") as f:
f.write(''.join(builder))
print(f"\nOpening report: {output_fn}\n")
webbrowser.open_new_tab(str(output_fn))
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Generate longitudinal maneuver report from route')
parser.add_argument('route', type=str, help='Route name (e.g. 00000000--5f742174be)')
parser.add_argument('description', type=str, nargs='?')
args = parser.parse_args()
if '/' in args.route or '|' in args.route:
lr = LogReader(args.route)
else:
segs = [seg for seg in os.listdir(Paths.log_root()) if args.route in seg]
lr = LogReader([os.path.join(Paths.log_root(), seg, 'rlog.zst') for seg in segs])
CP = lr.first('carParams')
ID = lr.first('initData')
platform = CP.carFingerprint
print('processing report for', platform)
maneuvers: list[tuple[str, list[list]]] = []
active_prev = False
description_prev = None
for msg in lr:
if msg.which() == 'alertDebug':
active = 'Maneuver Active' in msg.alertDebug.alertText1
if active and not active_prev:
if msg.alertDebug.alertText2 == description_prev:
maneuvers[-1][1].append([])
else:
maneuvers.append((msg.alertDebug.alertText2, [[]]))
description_prev = maneuvers[-1][0]
active_prev = active
if active_prev:
maneuvers[-1][1][-1].append(msg)
report(platform, args.route, args.description, CP, ID, maneuvers)

View File

@@ -0,0 +1,18 @@
from enum import IntEnum
class Axis(IntEnum):
TIME = 0
EGO_POSITION = 1
LEAD_DISTANCE= 2
EGO_V = 3
LEAD_V = 4
EGO_A = 5
D_REL = 6
axis_labels = {Axis.TIME: 'Time (s)',
Axis.EGO_POSITION: 'Ego position (m)',
Axis.LEAD_DISTANCE: 'Lead absolute position (m)',
Axis.EGO_V: 'Ego Velocity (m/s)',
Axis.LEAD_V: 'Lead Velocity (m/s)',
Axis.EGO_A: 'Ego acceleration (m/s^2)',
Axis.D_REL: 'Lead distance (m)'}

View File

@@ -0,0 +1,293 @@
import io
import sys
import numpy as np
import matplotlib.pyplot as plt
from iqpilot.common.realtime import DT_MDL
from iqpilot.selfdrive.controls.tests.test_following_distance import desired_follow_distance
from iqpilot.tools.maneuvers.maneuver_helpers import Axis, axis_labels
from iqpilot.selfdrive.test.longitudinal_maneuvers.maneuver import Maneuver
def get_html_from_results(results, labels, AXIS):
fig, ax = plt.subplots(figsize=(16, 8))
for idx, key in enumerate(results.keys()):
ax.plot(results[key][:, Axis.TIME], results[key][:, AXIS], label=labels[idx])
ax.set_xlabel(axis_labels[Axis.TIME])
ax.set_ylabel(axis_labels[AXIS])
ax.legend(bbox_to_anchor=(1.02, 1), loc='upper left', borderaxespad=0)
ax.grid(True, linestyle='--', alpha=0.7)
ax.text(-0.075, 0.5, '.', transform=ax.transAxes, color='none')
fig_buffer = io.StringIO()
fig.savefig(fig_buffer, format='svg', bbox_inches='tight')
plt.close(fig)
return fig_buffer.getvalue() + '<br/>'
def generate_mpc_tuning_report():
htmls = []
results = {}
name = 'Resuming behind lead'
labels = []
for lead_accel in np.linspace(1.0, 4.0, 4):
man = Maneuver(
'',
duration=11,
initial_speed=0.0,
lead_relevancy=True,
initial_distance_lead=desired_follow_distance(0.0, 0.0),
speed_lead_values=[0.0, 10 * lead_accel],
cruise_values=[100, 100],
prob_lead_values=[1.0, 1.0],
breakpoints=[1., 11],
)
valid, results[lead_accel] = man.evaluate()
labels.append(f'{lead_accel} m/s^2 lead acceleration')
htmls.append(f'<h1>{name}</h1>')
htmls.append(get_html_from_results(results, labels, Axis.EGO_V))
htmls.append(get_html_from_results(results, labels, Axis.EGO_A))
results = {}
name = 'Approaching stopped car from 140m'
labels = []
for speed in np.arange(0, 45, 5):
man = Maneuver(
name,
duration=30.,
initial_speed=float(speed),
lead_relevancy=True,
initial_distance_lead=140.,
speed_lead_values=[0.0, 0.],
breakpoints=[0., 30.],
)
valid, results[speed] = man.evaluate()
labels.append(f'{speed} m/s approach speed')
htmls.append(f'<h1>{name}</h1>')
htmls.append(get_html_from_results(results, labels, Axis.EGO_A))
htmls.append(get_html_from_results(results, labels, Axis.D_REL))
results = {}
name = 'Following 5s (triangular) oscillating lead'
labels = []
speed = np.int64(10)
for oscil in np.arange(0, 10, 1):
man = Maneuver(
'',
duration=30.,
initial_speed=float(speed),
lead_relevancy=True,
initial_distance_lead=desired_follow_distance(speed, speed),
speed_lead_values=[speed, speed, speed - oscil, speed + oscil, speed - oscil, speed + oscil, speed - oscil],
breakpoints=[0., 2., 5, 8, 15, 18, 25.],
)
valid, results[oscil] = man.evaluate()
labels.append(f'{oscil} m/s oscillation size')
htmls.append(f'<h1>{name}</h1>')
htmls.append(get_html_from_results(results, labels, Axis.D_REL))
htmls.append(get_html_from_results(results, labels, Axis.EGO_V))
htmls.append(get_html_from_results(results, labels, Axis.EGO_A))
results = {}
name = 'Following 5s (sinusoidal) oscillating lead'
labels = []
speed = np.int64(10)
duration = float(30)
f_osc = 1. / 5
for oscil in np.arange(0, 10, 1):
bps = DT_MDL * np.arange(int(duration / DT_MDL))
lead_speeds = speed + oscil * np.sin(2 * np.pi * f_osc * bps)
man = Maneuver(
'',
duration=duration,
initial_speed=float(speed),
lead_relevancy=True,
initial_distance_lead=desired_follow_distance(speed, speed),
speed_lead_values=lead_speeds,
breakpoints=bps,
)
valid, results[oscil] = man.evaluate()
labels.append(f'{oscil} m/s oscillation size')
htmls.append(f'<h1>{name}</h1>')
htmls.append(get_html_from_results(results, labels, Axis.D_REL))
htmls.append(get_html_from_results(results, labels, Axis.EGO_V))
htmls.append(get_html_from_results(results, labels, Axis.EGO_A))
results = {}
name = 'Speed profile when converging to steady state lead at 30m/s'
labels = []
for distance in np.arange(20, 140, 10):
man = Maneuver(
'',
duration=50,
initial_speed=30.0,
lead_relevancy=True,
initial_distance_lead=distance,
speed_lead_values=[30.0],
breakpoints=[0.],
)
valid, results[distance] = man.evaluate()
labels.append(f'{distance} m initial distance')
htmls.append(f'<h1>{name}</h1>')
htmls.append(get_html_from_results(results, labels, Axis.EGO_V))
htmls.append(get_html_from_results(results, labels, Axis.D_REL))
results = {}
name = 'Speed profile when converging to steady state lead at 20m/s'
labels = []
for distance in np.arange(20, 140, 10):
man = Maneuver(
'',
duration=50,
initial_speed=20.0,
lead_relevancy=True,
initial_distance_lead=distance,
speed_lead_values=[20.0],
breakpoints=[0.],
)
valid, results[distance] = man.evaluate()
labels.append(f'{distance} m initial distance')
htmls.append(f'<h1>{name}</h1>')
htmls.append(get_html_from_results(results, labels, Axis.EGO_V))
htmls.append(get_html_from_results(results, labels, Axis.D_REL))
results = {}
name = 'Following car at 30m/s that comes to a stop'
labels = []
for stop_time in np.arange(4, 14, 1):
man = Maneuver(
'',
duration=30,
initial_speed=30.0,
cruise_values=[30.0, 30.0, 30.0],
lead_relevancy=True,
initial_distance_lead=60.0,
speed_lead_values=[30.0, 30.0, 0.0],
breakpoints=[0., 5., 5 + stop_time],
)
valid, results[stop_time] = man.evaluate()
labels.append(f'{stop_time} seconds stop time')
htmls.append(f'<h1>{name}</h1>')
htmls.append(get_html_from_results(results, labels, Axis.EGO_A))
htmls.append(get_html_from_results(results, labels, Axis.D_REL))
results = {}
name = 'Response to cut-in at half follow distance'
labels = []
for speed in np.arange(0, 40, 5):
man = Maneuver(
'',
duration=20,
initial_speed=float(speed),
cruise_values=[speed, speed, speed],
lead_relevancy=True,
initial_distance_lead=desired_follow_distance(speed, speed) / 2,
speed_lead_values=[speed, speed, speed],
prob_lead_values=[0.0, 0.0, 1.0],
breakpoints=[0., 5.0, 5.01],
)
valid, results[speed] = man.evaluate()
labels.append(f'{speed} m/s speed')
htmls.append(f'<h1>{name}</h1>')
htmls.append(get_html_from_results(results, labels, Axis.EGO_A))
htmls.append(get_html_from_results(results, labels, Axis.D_REL))
results = {}
name = 'Follow a lead that accelerates at 2m/s^2 until steady state speed'
labels = []
for speed in np.arange(0, 40, 5):
man = Maneuver(
'',
duration=60,
initial_speed=0.0,
lead_relevancy=True,
initial_distance_lead=desired_follow_distance(0.0, 0.0),
speed_lead_values=[0.0, 0.0, speed],
prob_lead_values=[1.0, 1.0, 1.0],
breakpoints=[0., 1.0, speed / 2],
)
valid, results[speed] = man.evaluate()
labels.append(f'{speed} m/s speed')
htmls.append(f'<h1>{name}</h1>')
htmls.append(get_html_from_results(results, labels, Axis.EGO_V))
htmls.append(get_html_from_results(results, labels, Axis.EGO_A))
results = {}
name = 'From stop to cruise'
labels = []
for speed in np.arange(0, 40, 5):
man = Maneuver(
'',
duration=50,
initial_speed=0.0,
lead_relevancy=True,
initial_distance_lead=desired_follow_distance(0.0, 0.0),
speed_lead_values=[0.0, 0.0],
cruise_values=[0.0, speed],
prob_lead_values=[0.0, 0.0],
breakpoints=[1., 1.01],
)
valid, results[speed] = man.evaluate()
labels.append(f'{speed} m/s speed')
htmls.append(f'<h1>{name}</h1>')
htmls.append(get_html_from_results(results, labels, Axis.EGO_V))
htmls.append(get_html_from_results(results, labels, Axis.EGO_A))
results = {}
name = 'From cruise to min'
labels = []
for speed in np.arange(10, 40, 5):
man = Maneuver(
'',
duration=50,
initial_speed=float(speed),
lead_relevancy=True,
initial_distance_lead=desired_follow_distance(0.0, 0.0),
speed_lead_values=[0.0, 0.0],
cruise_values=[speed, 10.0],
prob_lead_values=[0.0, 0.0],
breakpoints=[1., 1.01],
)
valid, results[speed] = man.evaluate()
labels.append(f'{speed} m/s speed')
htmls.append(f'<h1>{name}</h1>')
htmls.append(get_html_from_results(results, labels, Axis.EGO_V))
htmls.append(get_html_from_results(results, labels, Axis.EGO_A))
return htmls
if __name__ == '__main__':
htmls = generate_mpc_tuning_report()
if len(sys.argv) < 2:
file_name = 'long_mpc_tune_report.html'
else:
file_name = sys.argv[1]
with open(file_name, 'w') as f:
f.write('<h1>MPC longitudinal tuning report</h1>')
for html in htmls:
f.write(html)

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Closed-loop offline harness for the maneuver daemons.
Runs maneuversd / lateral_maneuversd as real subprocesses over msgq, drives them with a
synthetic vehicle, and records every message to an rlog that the report generators can read.
Used to validate the maneuver tooling without a car.
"""
import math
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import NamedTuple
import numpy as np
import zstandard as zstd
from iqpilot.cereal import car, messaging
from iqpilot.common.params import Params
from iqpilot.common.realtime import DT_CTRL, Ratekeeper
from iqpilot.common.basedir import BASEDIR
PUB_100HZ = ('carState', 'carControl', 'carOutput', 'controlsState', 'selfdriveState')
PUB_20HZ = ('modelV2', 'deviceMotion', 'vehicleParameters')
SUB = ('alertDebug', 'longitudinalPlan', 'lateralManeuverPlan')
STEER_RATIO = 15.0
WHEELBASE = 2.78
class LongPlan(NamedTuple):
aTarget: float
shouldStop: bool
class LatPlan(NamedTuple):
desiredCurvature: float
class Plant:
"""Vehicle model. Subclasses consume the daemon's plan and fill the published messages."""
sim = None
PLAN = 'longitudinalPlan'
def __init__(self, v_ego: float = 0.0):
self.v_ego = v_ego
self.a_ego = 0.0
self.curvature = 0.0 # commanded, controlsState.desiredCurvature
self.achieved_curvature = 0.0 # measured, controlsState.curvature
self.lat_accel = 0.0
self.long_active = True
self.lat_active = True
self.steering_pressed = False
self.gas_pressed = False
def step(self, dt: float, plan) -> None:
raise NotImplementedError
def _angle(self, curvature: float) -> float:
return math.degrees(curvature * WHEELBASE * STEER_RATIO)
def _torque(self, curvature: float) -> float:
return float(np.clip(curvature * max(self.v_ego, 1.0) ** 2 / 3.0, -1.0, 1.0))
def fill_car_state(self, cs) -> None:
cs.vEgo = float(self.v_ego)
cs.vEgoRaw = float(self.v_ego)
cs.vEgoCluster = float(self.v_ego)
cs.aEgo = float(self.a_ego)
cs.standstill = self.v_ego < 0.01
cs.steeringPressed = self.steering_pressed
cs.gasPressed = self.gas_pressed
cs.steeringAngleDeg = self._angle(self.achieved_curvature)
cs.cruiseState.enabled = True
cs.cruiseState.available = True
cs.cruiseState.speed = float(max(self.v_ego, 1.0))
def fill_car_control(self, cc) -> None:
cc.enabled = True
cc.latActive = self.lat_active
cc.longActive = self.long_active
cc.orientationNED = [0.0, 0.0, 0.0]
cc.actuators.curvature = float(self.curvature)
cc.actuators.accel = float(self.a_ego)
cc.actuators.steeringAngleDeg = self._angle(self.curvature)
cc.actuators.torque = self._torque(self.curvature)
class ManeuverSim:
def __init__(self, module: str, plant: Plant, fingerprint: str = "TOYOTA_SIENNA",
max_maneuvers: int = 0, timeout: float = 600.0, verbose: bool = True):
self.module = module
self.plant = plant
plant.sim = self
self.fingerprint = fingerprint
self.max_maneuvers = max_maneuvers
self.timeout = timeout
self.verbose = verbose
self.events: list[bytes] = []
self.alert1 = ''
self.alert2 = ''
self.seen_maneuvers: list[str] = []
self.finished = False
def _write_car_params(self):
CP = car.CarParams.new_message()
CP.carFingerprint = self.fingerprint
CP.brand = "toyota"
CP.openpilotLongitudinalControl = True
CP.autoResumeSng = True
CP.steerRatio = STEER_RATIO
CP.wheelbase = WHEELBASE
Params().put("CarParams", CP.to_bytes())
return CP
def _head_events(self, CP):
init = messaging.new_message('initData')
init.valid = True
init.initData.gitCommit = "simulated"
init.initData.gitBranch = "sim"
init.initData.gitRemote = "iqpilot-sim"
self.events.append(init.to_bytes())
cpm = messaging.new_message('carParams')
cpm.valid = True
cpm.carParams = CP
self.events.append(cpm.to_bytes())
def _launch(self):
env = dict(os.environ)
env["PYTHONPATH"] = str(BASEDIR) + os.pathsep + env.get("PYTHONPATH", "")
return subprocess.Popen([sys.executable, "-c", f"from {self.module} import main; main()"],
cwd=str(BASEDIR), env=env, start_new_session=True)
def _on_alert(self, ad):
text1, text2 = ad.alertText1, ad.alertText2
if (text1, text2) != (self.alert1, self.alert2):
if self.verbose:
print(f" [{time.monotonic() - self.t_start:6.1f}s] {text1!r} | {text2!r}")
if text2 and text2 not in self.seen_maneuvers:
self.seen_maneuvers.append(text2)
if text1 == 'Maneuvers Finished':
self.finished = True
self.alert1, self.alert2 = text1, text2
def run(self, out: Path) -> Path:
self._head_events(self._write_car_params())
pm = messaging.PubMaster(list(PUB_100HZ) + list(PUB_20HZ))
socks = {s: messaging.sub_sock(s, conflate=False, timeout=0) for s in SUB}
proc = self._launch()
self.t_start = time.monotonic()
rk = Ratekeeper(int(1.0 / DT_CTRL), print_delay_threshold=None)
plans: dict[str, object | None] = {'longitudinalPlan': None, 'lateralManeuverPlan': None}
frame = 0
try:
while True:
for s, sock in socks.items():
while True:
raw = sock.receive(non_blocking=True)
if raw is None:
break
self.events.append(raw)
evt = messaging.log_from_bytes(raw)
if s == 'alertDebug':
self._on_alert(evt.alertDebug)
elif s == 'longitudinalPlan':
plans[s] = LongPlan(evt.longitudinalPlan.aTarget, evt.longitudinalPlan.shouldStop)
elif s == 'lateralManeuverPlan':
plans[s] = LatPlan(evt.lateralManeuverPlan.desiredCurvature) if evt.valid else None
self.plant.step(DT_CTRL, plans[self.plant.PLAN])
for s in PUB_100HZ:
raw = self._build(s).to_bytes()
self.events.append(raw)
pm.send(s, raw)
if frame % 5 == 0:
for s in PUB_20HZ:
raw = self._build(s).to_bytes()
self.events.append(raw)
pm.send(s, raw)
frame += 1
if self.finished:
break
if self.max_maneuvers and len(self.seen_maneuvers) > self.max_maneuvers:
break
if time.monotonic() - self.t_start > self.timeout:
print(" timed out")
break
rk.keep_time()
finally:
if proc.poll() is None:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
proc.wait(timeout=5)
for sock in socks.values():
del sock
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(zstd.compress(b"".join(self.events), 10))
return out
def _build(self, s: str):
msg = messaging.new_message(s)
msg.valid = True
if s == 'carState':
self.plant.fill_car_state(msg.carState)
elif s == 'carControl':
self.plant.fill_car_control(msg.carControl)
elif s == 'carOutput':
msg.carOutput.actuatorsOutput.accel = float(self.plant.a_ego)
msg.carOutput.actuatorsOutput.curvature = float(self.plant.curvature)
msg.carOutput.actuatorsOutput.steeringAngleDeg = self.plant._angle(self.plant.achieved_curvature)
msg.carOutput.actuatorsOutput.torque = self.plant._torque(self.plant.achieved_curvature)
elif s == 'controlsState':
msg.controlsState.curvature = float(self.plant.achieved_curvature)
msg.controlsState.desiredCurvature = float(self.plant.curvature)
elif s == 'selfdriveState':
msg.selfdriveState.enabled = True
msg.selfdriveState.active = True
msg.selfdriveState.state = 'enabled'
elif s == 'modelV2':
msg.modelV2.frameId = 0
msg.modelV2.action.desiredCurvature = 0.0
elif s == 'deviceMotion':
msg.deviceMotion.accelerationDevice.x = float(self.plant.a_ego)
msg.deviceMotion.accelerationDevice.y = float(self.plant.lat_accel)
msg.deviceMotion.velocityDevice.x = float(self.plant.v_ego)
msg.deviceMotion.inputsOK = True
msg.deviceMotion.posenetOK = True
msg.deviceMotion.sensorsOK = True
elif s == 'vehicleParameters':
msg.vehicleParameters.valid = True
msg.vehicleParameters.roll = 0.0
msg.vehicleParameters.steerRatio = STEER_RATIO
return msg

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Run lateral_maneuversd against a synthetic lateral plant and write an rlog.
./iqpilot/tools/maneuvers/simulate_lateral.py --out /tmp/lat_rlog.zst
./iqpilot/tools/maneuvers/lateral_report.py /tmp/lat_rlog.zst
"""
import argparse
import re
from pathlib import Path
from iqpilot.common.constants import CV
from iqpilot.tools.maneuvers.lateral_maneuversd import MANEUVERS
from iqpilot.tools.maneuvers.sim_harness import ManeuverSim, Plant
CURV_TAU = 0.05 # controlsd curvature command tracking
RACK_WN = 8.0 # steering rack + tire natural frequency (rad/s)
RACK_ZETA = 0.7 # underdamped, so achieved curvature overshoots like a real rack
CRUISE_ACCEL = 1.2
SET_SPEED_RE = re.compile(r"Set speed to (\d+) mph")
class LateralPlant(Plant):
PLAN = 'lateralManeuverPlan'
def __init__(self, steer_input: float = 0.0):
super().__init__(v_ego=MANEUVERS[0].initial_speed)
self.sim = None
self._rack_rate = 0.0
self.target_speed = MANEUVERS[0].initial_speed
self._by_description = {m.description: m.initial_speed for m in MANEUVERS}
# seconds of steeringPressed to assert at each maneuver start, mimicking the driver-torque
# spike a curvature step produces on a car with a tight override threshold
self.steer_input = steer_input
self._steer_hold = 0.0
self._was_active = False
def _update_target(self):
if self.sim is None:
return
speed = self._by_description.get(self.sim.alert2)
if speed is None:
match = SET_SPEED_RE.search(self.sim.alert1)
speed = float(match.group(1)) * CV.MPH_TO_MS if match else None
if speed is not None:
self.target_speed = speed
def step(self, dt, plan):
self._update_target()
active = plan is not None
if self.steer_input > 0 and active and not self._was_active:
self._steer_hold = self.steer_input
self._was_active = active
self.steering_pressed = self._steer_hold > 0
self._steer_hold = max(self._steer_hold - dt, 0.0)
err = self.target_speed - self.v_ego
self.a_ego = max(min(err / 1.0, CRUISE_ACCEL), -CRUISE_ACCEL)
self.v_ego = max(self.v_ego + self.a_ego * dt, 0.0)
desired_curvature = float(plan.desiredCurvature) if plan is not None else 0.0
self.curvature += (dt / (CURV_TAU + dt)) * (desired_curvature - self.curvature)
self._rack_rate += dt * (RACK_WN ** 2 * (self.curvature - self.achieved_curvature) - 2 * RACK_ZETA * RACK_WN * self._rack_rate)
self.achieved_curvature += dt * self._rack_rate
self.lat_accel = self.achieved_curvature * max(self.v_ego, 1.0) ** 2
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=Path("/tmp/lateral_maneuvers_sim/rlog.zst"))
parser.add_argument("--max-maneuvers", type=int, default=0, help="stop after N maneuvers (0 = all)")
parser.add_argument("--timeout", type=float, default=900.0)
parser.add_argument("--steer-input", type=float, default=0.0,
help="seconds of steeringPressed to assert at each maneuver start (0 = hands off)")
args = parser.parse_args()
sim = ManeuverSim("iqpilot.tools.maneuvers.lateral_maneuversd", LateralPlant(args.steer_input),
max_maneuvers=args.max_maneuvers, timeout=args.timeout)
out = sim.run(args.out)
print(f"\nmaneuvers seen: {sim.seen_maneuvers}")
print(f"rlog: {out} ({out.stat().st_size / 1e6:.1f} MB)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Run maneuversd against a synthetic longitudinal plant and write an rlog.
./iqpilot/tools/maneuvers/simulate_longitudinal.py --out /tmp/long_rlog.zst
./iqpilot/tools/maneuvers/longitudinal_report.py /tmp/long_rlog.zst
"""
import argparse
from pathlib import Path
from iqpilot.tools.maneuvers.sim_harness import ManeuverSim, Plant
WN = 6.0 # powertrain natural frequency (rad/s)
ZETA = 0.6 # underdamped, so actual accel overshoots the target like a real car
class LongitudinalPlant(Plant):
def __init__(self):
super().__init__()
self.jerk = 0.0
def step(self, dt, plan):
a_target = float(plan.aTarget) if plan is not None else 0.0
if plan is not None and plan.shouldStop:
a_target = min(a_target, -0.5)
self.jerk += dt * (WN ** 2 * (a_target - self.a_ego) - 2 * ZETA * WN * self.jerk)
self.a_ego += dt * self.jerk
self.v_ego = max(self.v_ego + self.a_ego * dt, 0.0)
if self.v_ego <= 0.0:
self.a_ego = min(self.a_ego, 0.0)
self.jerk = min(self.jerk, 0.0)
self.lat_accel = 0.0
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=Path("/tmp/longitudinal_maneuvers_sim/rlog.zst"))
parser.add_argument("--max-maneuvers", type=int, default=0, help="stop after N maneuvers (0 = all)")
parser.add_argument("--timeout", type=float, default=900.0)
args = parser.parse_args()
sim = ManeuverSim("iqpilot.tools.maneuvers.longitudinal_maneuversd", LongitudinalPlant(),
max_maneuvers=args.max_maneuvers, timeout=args.timeout)
out = sim.run(args.out)
print(f"\nmaneuvers seen: {sim.seen_maneuvers}")
print(f"rlog: {out} ({out.stat().st_size / 1e6:.1f} MB)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,108 @@
import os
import sys
import SCons.Script.Main as _main
try:
from site_tools import pretty as _pretty
except Exception:
_pretty = None
def _tty():
return sys.stdout.isatty() and not os.environ.get("NO_COLOR")
_DIM = "\033[2;38;5;246m"
_BLUE = "\033[38;5;111m"
_GREEN = "\033[38;5;114m"
_RED = "\033[38;5;203m"
_RST = "\033[0m"
_READING = "scons: Reading SConscript files ..."
_PHASES = {
_READING: f"{_DIM}reading sconscripts…{_RST}",
"scons: done reading SConscript files.": f"{_DIM}sconscripts read{_RST}",
"scons: Building targets ...": f"{_BLUE}building…{_RST}",
"scons: done building targets.": f"{_GREEN}✓ build complete{_RST}",
"scons: done building targets (errors occurred during build).": f"{_RED}✗ build failed{_RST}",
"scons: writing .sconsign file.": f"{_DIM}writing .sconsign{_RST}",
"scons: Cleaning targets ...": f"{_DIM}cleaning…{_RST}",
"scons: done cleaning targets.": f"{_GREEN}✓ clean complete{_RST}",
"scons: done cleaning targets (errors occurred during clean).": f"{_RED}✗ clean failed{_RST}",
}
def _phase(text):
if _tty() and isinstance(text, str) and text in _PHASES:
return _PHASES[text]
return text
def _clean(text):
if not (_tty() and _pretty and isinstance(text, str)):
return text
for prefix in ("Removed directory ", "Removed "):
if text.startswith(prefix):
return _pretty._format("CLEAN", text[len(prefix):])
return text
class _Restyle:
# delegates unknown attrs (.set_mode etc.) to the wrapped DisplayEngine
def __init__(self, orig, transform):
self._orig = orig
self._transform = transform
def __call__(self, text, *args, **kwargs):
return self._orig(self._transform(text), *args, **kwargs)
def __getattr__(self, name):
return getattr(self._orig, name)
if not isinstance(_main.progress_display, _Restyle):
_main.progress_display = _Restyle(_main.progress_display, _phase)
if not isinstance(_main.display, _Restyle):
_main.display = _Restyle(_main.display, _clean)
# SConstruct imports this instead of scons auto-loading a root site_scons dir, so the reading
# banner is already on screen by now; rewrite that one line in place
if not getattr(_main, "_iq_banner_restyled", False):
_main._iq_banner_restyled = True
if _tty() and _main.progress_display.print_it:
sys.stdout.write(f"\033[F\033[2K{_PHASES[_READING]}\n")
sys.stdout.flush()
# drop only "Could not remove ... No such file" during clean; real errors still print
import builtins as _builtins
def _wrap_clean(orig):
def wrapper(self, *args, **kwargs):
if getattr(_builtins.print, "_iq_clean", False):
return orig(self, *args, **kwargs)
real = _builtins.print
def filtered(*a, **k):
if a and isinstance(a[0], str) and a[0].startswith("scons: Could not remove"):
if "No such file" in " ".join(str(x) for x in a):
return
return real(*a, **k)
filtered._iq_clean = True
_builtins.print = filtered
try:
return orig(self, *args, **kwargs)
finally:
_builtins.print = real
return wrapper
if not getattr(_main.CleanTask.fs_delete, "_iq_wrapped", False):
_main.CleanTask.fs_delete = _wrap_clean(_main.CleanTask.fs_delete)
_main.CleanTask.remove = _wrap_clean(_main.CleanTask.remove)
_main.CleanTask.fs_delete._iq_wrapped = True
_main.CleanTask.remove._iq_wrapped = True

View File

@@ -0,0 +1,82 @@
import re
import sys
import SCons
from SCons.Action import Action
from SCons.Scanner import Scanner
import numpy as np
pyx_from_import_re = re.compile(r'^from\s+(\S+)\s+cimport', re.M)
pyx_import_re = re.compile(r'^cimport\s+(\S+)', re.M)
cdef_import_re = re.compile(r'^cdef extern from\s+.(\S+).:', re.M)
np_version = SCons.Script.Value(np.__version__)
def pyx_scan(node, env, path, arg=None):
contents = node.get_text_contents()
env.Depends(str(node).split('.')[0] + env['CYTHONCFILESUFFIX'], np_version)
# from <module> cimport ...
matches = pyx_from_import_re.findall(contents)
# cimport <module>
matches += pyx_import_re.findall(contents)
# Modules can be either .pxd or .pyx files
files = [m.replace('.', '/') + '.pxd' for m in matches]
files += [m.replace('.', '/') + '.pyx' for m in matches]
# cdef extern from <file>
files += cdef_import_re.findall(contents)
# Handle relative imports
cur_dir = str(node.get_dir())
files = [cur_dir + f if f.startswith('/') else f for f in files]
# Filter out non-existing files (probably system imports)
files = [f for f in files if env.File(f).exists()]
return env.File(files)
pyxscanner = Scanner(function=pyx_scan, skeys=['.pyx', '.pxd'], recursive=True)
cythonAction = Action("$CYTHONCOM", "$CYTHONCOMSTR")
def create_builder(env):
try:
cython = env['BUILDERS']['Cython']
except KeyError:
cython = SCons.Builder.Builder(
action=cythonAction,
emitter={},
suffix=cython_suffix_emitter,
single_source=1
)
env.Append(SCANNERS=pyxscanner)
env['BUILDERS']['Cython'] = cython
return cython
def cython_suffix_emitter(env, source):
return "$CYTHONCFILESUFFIX"
def generate(env):
env["CYTHON"] = f'"{sys.executable}" -m Cython.Build.Cythonize'
# drop cythonize's stdout progress; errors stay on stderr. kept under --verbose
try:
from SCons.Script import GetOption
quiet = not GetOption("verbose")
except Exception:
quiet = True
env["CYTHONCOM"] = "$CYTHON $CYTHONFLAGS $SOURCE" + (" > /dev/null" if quiet else "")
env["CYTHONCFILESUFFIX"] = ".cpp"
c_file, _ = SCons.Tool.createCFileBuilders(env)
c_file.suffix['.pyx'] = cython_suffix_emitter
c_file.add_action('.pyx', cythonAction)
c_file.suffix['.py'] = cython_suffix_emitter
c_file.add_action('.py', cythonAction)
create_builder(env)
def exists(env):
return True

View File

@@ -0,0 +1,122 @@
import os
import sys
from SCons.Action import Action
from SCons.Script import GetOption
_GRAD = {
"CC": ((95, 215, 255), (70, 130, 245)),
"CXX": ((95, 205, 255), (130, 110, 250)),
"LINK": ((95, 240, 150), (40, 200, 120)),
"AR": ((120, 235, 220), (40, 175, 185)),
"RANLIB": ((140, 240, 225), (45, 165, 180)),
"SKIP": ((150, 160, 185), (100, 110, 145)),
"CLEAN": ((200, 130, 145), (120, 120, 155)),
"OBJCOPY": ((255, 200, 90), (255, 120, 50)),
"SIGN": ((255, 190, 90), (230, 80, 70)),
"CYTHON": ((215, 130, 255), (140, 80, 250)),
"CAPNP": ((255, 120, 225), (190, 90, 255)),
"RCC": ((200, 160, 255), (150, 110, 250)),
"FONTS": ((130, 190, 255), (90, 120, 250)),
"GEN": ((160, 160, 255), (120, 90, 250)),
"CDB": ((150, 175, 210), (105, 135, 185)),
"MODEL": ((255, 170, 70), (240, 60, 60)),
"META": ((255, 205, 110), (230, 130, 80)),
"MOC": ((255, 140, 205), (215, 90, 240)),
"UIC": ((255, 160, 190), (225, 110, 225)),
"MO": ((100, 235, 200), (55, 200, 155)),
}
_DEFAULT = ((120, 200, 255), (90, 140, 250))
_TARGET_RGB = (150, 152, 178)
def _mode():
if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
return None
if os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit"):
return "true"
return "256"
def _fg(rgb, mode):
r, g, b = rgb
if mode == "true":
return f"\033[38;2;{r};{g};{b}m"
if abs(r - g) < 12 and abs(g - b) < 12 and abs(r - b) < 12:
idx = 232 + min(23, round((r + g + b) / 3 / 255 * 23))
else:
idx = 16 + 36 * round(r / 255 * 5) + 6 * round(g / 255 * 5) + round(b / 255 * 5)
return f"\033[38;5;{idx}m"
def _gradient(word, start, end, mode):
n = max(1, len(word) - 1)
out = []
for i, ch in enumerate(word):
t = i / n
rgb = tuple(int(s + (e - s) * t) for s, e in zip(start, end))
out.append(f"\033[1m{_fg(rgb, mode)}{ch}")
return "".join(out) + "\033[0m"
def _format(label, body):
mode = _mode()
if mode is None:
return f"{label:>8} {body}"
start, end = _GRAD.get(label, _DEFAULT)
pad = " " * max(0, 8 - len(label))
word = _gradient(label, start, end, mode)
return f"{pad}{word} {_fg(_TARGET_RGB, mode)}{body}\033[0m"
def _line(label):
return _format(label, "$TARGET")
def _verbose():
try:
return bool(GetOption("verbose"))
except Exception:
return False
def generate(env):
def pretty_action(e, cmd, label, logfile=None, capture_stderr=False):
if _verbose():
return Action(cmd)
if callable(cmd):
return Action(cmd, _line(label))
if logfile:
cmd = f"{cmd} > {logfile}" + (" 2>&1" if capture_stderr else "")
return Action(cmd, _line(label))
env.AddMethod(pretty_action, "PrettyAction")
env.AddMethod(lambda e, label, msg: _format(label, msg), "PrettyNote")
# real errors are exceptions, not warnings, so they still surface with these ignored
env["PYWARN"] = "" if _verbose() else "PYTHONWARNINGS=ignore::UserWarning"
if _verbose():
return
try:
import SCons.CacheDir as _cachedir
_cachedir.CacheRetrieve.strfunction = lambda target, source, env: ""
except Exception:
pass
env["CCCOMSTR"] = _line("CC")
env["SHCCCOMSTR"] = _line("CC")
env["CXXCOMSTR"] = _line("CXX")
env["SHCXXCOMSTR"] = _line("CXX")
env["ASCOMSTR"] = _line("CC")
env["ASPPCOMSTR"] = _line("CC")
env["LINKCOMSTR"] = _line("LINK")
env["SHLINKCOMSTR"] = _line("LINK")
env["ARCOMSTR"] = _line("AR")
env["RANLIBCOMSTR"] = _line("RANLIB")
env["CYTHONCOMSTR"] = _line("CYTHON")
env["COMPILATIONDB_COMSTR"] = _line("CDB")
def exists(env):
return True

187
iqpilot/tools/setup.sh Executable file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env bash
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
BOLD='\033[1m'
NC='\033[0m'
if [ -z "$OPENPILOT_ROOT" ]; then
# default to current directory for installation
OPENPILOT_ROOT="$(pwd)/openpilot"
fi
function show_motd() {
cat << 'EOF'
.~ssos+.
+8888888888i,
{888888888888o.
h8888888888888k
t888888888s888k
`t88888d/ h88k
``` h88l
,88k`
.d8h`
+d8h
_+d8h`
;y8h+`
|-`
openpilot installer
EOF
}
function sentry_send_event() {
SENTRY_KEY=dd0cba62ba0ac07ff9f388f8f1e6a7f4
SENTRY_URL=https://sentry.io/api/4507726145781760/store/
EVENT=$1
EVENT_TYPE=${2:-$EVENT}
EVENT_LOG=${3:-"NA"}
PLATFORM=$(uname -s)
ARCH=$(uname -m)
SYSTEM=$(uname -a)
if [[ $PLATFORM == "Darwin" ]]; then
OS="macos"
elif [[ $PLATFORM == "Linux" ]]; then
OS="linux"
fi
if [[ $ARCH == armv8* ]] || [[ $ARCH == arm64* ]] || [[ $ARCH == aarch64* ]]; then
ARCH="aarch64"
elif [[ $ARCH == "x86_64" ]] || [[ $ARCH == i686* ]]; then
ARCH="x86"
fi
PYTHON_VERSION=$(echo $(python3 --version 2> /dev/null || echo "NA"))
BRANCH=$(echo $(git -C $OPENPILOT_ROOT rev-parse --abbrev-ref HEAD 2> /dev/null || echo "NA"))
COMMIT=$(echo $(git -C $OPENPILOT_ROOT rev-parse HEAD 2> /dev/null || echo "NA"))
curl -s -o /dev/null -X POST -g --data "{ \"exception\": { \"values\": [{ \"type\": \"$EVENT\" }] }, \"tags\" : { \"event_type\" : \"$EVENT_TYPE\", \"event_log\" : \"$EVENT_LOG\", \"os\" : \"$OS\", \"arch\" : \"$ARCH\", \"python_version\" : \"$PYTHON_VERSION\" , \"git_branch\" : \"$BRANCH\", \"git_commit\" : \"$COMMIT\", \"system\" : \"$SYSTEM\" } }" \
-H 'Content-Type: application/json' \
-H "X-Sentry-Auth: Sentry sentry_version=7, sentry_key=$SENTRY_KEY, sentry_client=op_setup/0.1" \
$SENTRY_URL 2> /dev/null
}
function check_stdin() {
if [ -t 0 ]; then
INTERACTIVE=1
else
echo "Checking for valid invocation..."
echo -e " ↳ [${RED}${NC}] stdin not found! Running in non-interactive mode."
echo -e " Run ${BOLD}'bash <(curl -fsSL openpilot.comma.ai)'${NC} to run in interactive mode.\n"
fi
}
function ask_dir() {
echo -n "Enter directory in which to install openpilot (default $OPENPILOT_ROOT): "
if [[ -z $INTERACTIVE ]]; then
echo -e "\nBecause your are running in non-interactive mode, the installation"
echo -e "will default to $OPENPILOT_ROOT\n"
return 0
fi
read
if [[ ! -z "$REPLY" ]]; then
mkdir -p $REPLY
OPENPILOT_ROOT="$(realpath $REPLY)/openpilot"
fi
}
function check_dir() {
echo "Checking for installation directory..."
if [ -d "$OPENPILOT_ROOT" ]; then
echo -e " ↳ [${RED}${NC}] Installation destination $OPENPILOT_ROOT already exists!"
# not a valid clone, can't continue
if [[ ! -z "$(ls -A $OPENPILOT_ROOT)" && ! -f "$OPENPILOT_ROOT/launch_openpilot.sh" ]]; then
echo -e " $OPENPILOT_ROOT already contains files but does not seems"
echo -e " to be a valid openpilot git clone. Choose another location for"
echo -e " installing openpilot!\n"
return 1
fi
# already a "valid" openpilot clone, skip cloning again
if [[ ! -z "$(ls -A $OPENPILOT_ROOT)" ]]; then
SKIP_GIT_CLONE=1
fi
# by default, don't try installing in already existing directory
if [[ -z $INTERACTIVE ]]; then
return 0
fi
read -p " Would you like to attempt installation anyway? [Y/n] " -n 1 -r
echo -e "\n"
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
return 1
fi
return 0
fi
echo -e " ↳ [${GREEN}${NC}] Successfully chosen $OPENPILOT_ROOT as installation directory\n"
}
function check_git() {
echo "Checking for git..."
if ! command -v "git" > /dev/null 2>&1; then
echo -e " ↳ [${RED}${NC}] git not found on your system, can't continue!"
sentry_send_event "SETUP_FAILURE" "ERROR_GIT_NOT_FOUND"
return 1
else
echo -e " ↳ [${GREEN}${NC}] git found.\n"
fi
}
function git_clone() {
st="$(date +%s)"
echo "Cloning openpilot..."
if $(git clone --filter=blob:none https://github.com/commaai/openpilot.git "$OPENPILOT_ROOT"); then
if [[ -f $OPENPILOT_ROOT/launch_openpilot.sh ]]; then
et="$(date +%s)"
echo -e " ↳ [${GREEN}${NC}] Successfully cloned openpilot in $((et - st)) seconds.\n"
return 0
fi
fi
echo -e " ↳ [${RED}${NC}] failed to clone openpilot!"
sentry_send_event "SETUP_FAILURE" "ERROR_GIT_CLONE"
return 1
}
function install_with_iq() {
cd $OPENPILOT_ROOT
$OPENPILOT_ROOT/iqpilot/tools/iq.sh install
LOG_FILE=$(mktemp)
if ! $OPENPILOT_ROOT/iqpilot/tools/iq.sh --log $LOG_FILE setup; then
echo -e "\n[${RED}${NC}] failed to install openpilot!"
ERROR_TYPE="$(cat "$LOG_FILE" | sed '1p;d')"
ERROR_LOG="$(cat "$LOG_FILE" | sed '2p;d')"
sentry_send_event "SETUP_FAILURE" "$ERROR_TYPE" "$ERROR_LOG" || true
return 1
else
sentry_send_event "SETUP_SUCCESS" || true
fi
echo -e "\n----------------------------------------------------------------------"
echo -e "[${GREEN}${NC}] openpilot was successfully installed into ${BOLD}$OPENPILOT_ROOT${NC}"
echo -e "Checkout the docs at https://docs.comma.ai"
echo -e "Checkout how to contribute at https://github.com/commaai/openpilot/blob/master/docs/CONTRIBUTING.md"
}
show_motd
check_stdin
ask_dir
check_dir
check_git
[ -z $SKIP_GIT_CLONE ] && git_clone
install_with_iq

View File

@@ -0,0 +1,178 @@
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
"""
import os
import pty
import select
import shutil
import subprocess
import time
from pathlib import Path
import pytest
import iqpilot.cereal.messaging as messaging
from iqpilot.cereal import log
TOOLS_DIR = Path(__file__).parent
CABANA_BIN = TOOLS_DIR / "cabana" / "_cabana"
JOTPLUGGLER_BIN = TOOLS_DIR / "jotpluggler" / "jotpluggler"
DONGLE_ID = "0000000000000000"
TIMESTAMP = "2024-01-01--00-00-00"
ROUTE = f"{DONGLE_ID}|{TIMESTAMP}"
def write_rlog(path: Path, n_frames: int = 200):
with open(path, "wb") as f:
cp = messaging.new_message('carParams')
cp.carParams.carFingerprint = "TOYOTA_RAV4_TSS2"
cp.carParams.brand = "toyota"
f.write(cp.to_bytes())
for i in range(n_frames):
msg = messaging.new_message('can', 2)
msg.logMonoTime = int(i * 1e7)
for j, addr in enumerate((0x1D2, 0x260)):
msg.can[j].address = addr
msg.can[j].src = 0
msg.can[j].dat = bytes([i % 256] * 8)
f.write(msg.to_bytes())
def write_video_rlog(path: Path, n_frames: int):
with open(path, "wb") as f:
cp = messaging.new_message('carParams')
cp.logMonoTime = 1_000_000_000
cp.carParams.carFingerprint = "TOYOTA_RAV4_TSS2"
cp.carParams.brand = "toyota"
f.write(cp.to_bytes())
for i in range(n_frames):
timestamp = 1_000_000_000 + i * 50_000_000
msg = messaging.new_message('can', 1)
msg.logMonoTime = timestamp
msg.can[0].address = 0x1D2
msg.can[0].src = 0
msg.can[0].dat = bytes([i % 256] * 8)
f.write(msg.to_bytes())
idx = messaging.new_message('roadEncodeIdx')
idx.logMonoTime = timestamp
idx.roadEncodeIdx.frameId = i
idx.roadEncodeIdx.type = 'fullHEVC'
idx.roadEncodeIdx.encodeId = i
idx.roadEncodeIdx.segmentNum = 0
idx.roadEncodeIdx.segmentId = i
idx.roadEncodeIdx.segmentIdEncode = i
idx.roadEncodeIdx.timestampSof = timestamp
idx.roadEncodeIdx.timestampEof = timestamp + 10_000_000
f.write(idx.to_bytes())
@pytest.fixture(scope="module")
def local_route(tmp_path_factory):
data_dir = tmp_path_factory.mktemp("routes")
for seg in range(2):
seg_dir = data_dir / f"{DONGLE_ID}|{TIMESTAMP}--{seg}"
seg_dir.mkdir()
write_rlog(seg_dir / "rlog")
return data_dir
@pytest.fixture(scope="module")
def local_video_route(tmp_path_factory):
data_dir = tmp_path_factory.mktemp("video_routes")
seg_dir = data_dir / f"{DONGLE_ID}|{TIMESTAMP}--0"
seg_dir.mkdir()
frame_count = 20
result = subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi",
"-i", "testsrc=size=320x180:rate=20", "-frames:v", str(frame_count),
"-pix_fmt", "yuv420p", "-c:v", "libx265", "-preset", "ultrafast",
"-x265-params", "pools=1:frame-threads=1:log-level=error", "-f", "hevc",
str(seg_dir / "fcamera.hevc"),
], capture_output=True, text=True)
if result.returncode != 0:
pytest.skip(result.stderr)
write_video_rlog(seg_dir / "rlog", frame_count)
return data_dir
def run(cmd, timeout=180):
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=os.environ.copy(),
cwd=TOOLS_DIR.parent)
def cabana_command(*args):
command = [str(CABANA_BIN), *args]
if os.uname().sysname == "Linux" and shutil.which("xvfb-run"):
command = ["xvfb-run", "-a", *command]
return command
def cabana_output_until(args, expected, timeout=60):
master, slave = pty.openpty()
proc = subprocess.Popen(cabana_command(*args), stdout=slave, stderr=slave,
env=os.environ.copy(), cwd=TOOLS_DIR.parent)
os.close(slave)
output = bytearray()
deadline = time.monotonic() + timeout
try:
while time.monotonic() < deadline:
ready, _, _ = select.select([master], [], [], min(1, deadline - time.monotonic()))
if not ready:
if proc.poll() is not None:
break
continue
try:
output.extend(os.read(master, 4096))
except OSError:
break
if expected.encode() in output:
break
finally:
proc.kill()
proc.wait()
os.close(master)
return output.decode(errors="replace")
def test_jotpluggler_renders_a_local_route(local_route, tmp_path):
assert JOTPLUGGLER_BIN.exists(), "jotpluggler not built"
out = tmp_path / "plot.png"
result = run([str(JOTPLUGGLER_BIN), "--data-dir", str(local_route),
"--sync-load", "--output", str(out), ROUTE])
assert result.returncode == 0, result.stdout + result.stderr
assert out.is_file(), result.stdout + result.stderr
assert out.stat().st_size > 5000, f"suspiciously small render: {out.stat().st_size} bytes"
def test_cabana_loads_a_local_route(local_route):
assert CABANA_BIN.exists(), "cabana not built"
loaded = f"loaded route {ROUTE} with 2 valid segments"
out = cabana_output_until(("--data_dir", str(local_route), "--no-vipc", ROUTE), loaded)
assert "failed to load route" not in out, out
assert "invalid route format" not in out, out
assert loaded in out, out
def test_cabana_replays_local_video(local_video_route):
expected = "camera[0] vipc send #1"
out = cabana_output_until(("--data_dir", str(local_video_route), ROUTE), expected)
assert "failed to get frame" not in out, out
assert expected in out, out
def test_replay_logreader_reports_load_stats(local_route):
assert shutil.which("python3") is not None
header = (TOOLS_DIR / "replay" / "logreader.h").read_text()
for accessor in ("compressed_size", "decompressed_size", "download_seconds",
"decompress_seconds", "parse_seconds"):
assert f"{accessor}() const" in header
def test_can_capnp_field_matches_extractor_codegen():
assert 'busTimeDEPRECATED' in log.CanData.schema.fields
gen = (TOOLS_DIR / "jotpluggler" / "generate_event_extractors.py").read_text()
assert "getBusTimeDEPRECATED()" in gen

View File

@@ -0,0 +1,112 @@
# Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
import os
from pathlib import Path
import subprocess
IQ_COMMAND = Path(__file__).parents[1] / "iq.sh"
def make_checkout(tmp_path: Path) -> Path:
checkout = tmp_path / "checkout"
(checkout / "iqpilot").mkdir(parents=True)
(checkout / "launch_iqpilot.sh").touch()
return checkout
def run_pkg(checkout: Path, path: Path) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["PATH"] = f"{path}:{env['PATH']}"
return subprocess.run(
["bash", str(IQ_COMMAND), "--dir", str(checkout), "pkg"],
check=False,
capture_output=True,
env=env,
text=True,
)
def run_iq(checkout: Path, command: str, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["bash", str(IQ_COMMAND), "--dir", str(checkout), command, *args],
check=False,
capture_output=True,
text=True,
)
def test_public_checkout_skips_private_package_source_setup(tmp_path: Path):
checkout = make_checkout(tmp_path)
result = run_pkg(checkout, tmp_path)
assert result.returncode == 0
assert "private package sources are not present" in result.stdout
assert "setup_private_packages.py" not in result.stderr
def test_internal_checkout_runs_private_package_source_setup(tmp_path: Path):
checkout = make_checkout(tmp_path)
setup_script = checkout / "iqpilot/tools/scripts/setup_private_packages.py"
setup_script.parent.mkdir(parents=True)
setup_script.touch()
python_log = tmp_path / "python.log"
python = tmp_path / "python3"
python.write_text(f"#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" > {python_log!s}\n")
python.chmod(0o755)
result = run_pkg(checkout, tmp_path)
assert result.returncode == 0
assert python_log.read_text().strip() == "iqpilot/tools/scripts/setup_private_packages.py"
def test_public_checkout_runs_bundled_package_installer(tmp_path: Path):
checkout = make_checkout(tmp_path)
installer_log = tmp_path / "installer.log"
installer = checkout / "artifacts/runtime/ensure_private_installed.sh"
installer.parent.mkdir(parents=True)
installer.write_text(f"printf installed > {installer_log!s}\n")
result = run_pkg(checkout, tmp_path)
assert result.returncode == 0
assert installer_log.read_text() == "installed"
def test_cabana_command_runs_launcher_from_checkout(tmp_path: Path):
checkout = make_checkout(tmp_path)
log = tmp_path / "cabana.log"
launcher = checkout / "iqpilot/tools/cabana/cabana"
launcher.parent.mkdir(parents=True)
launcher.write_text(f"#!/usr/bin/env bash\nprintf '%s\\n' \"$PWD|$*\" > {log!s}\n")
launcher.chmod(0o755)
result = run_iq(checkout, "cabana", "--msgq")
assert result.returncode == 0
assert log.read_text().strip() == f"{checkout}|--msgq"
def test_juggle_command_runs_launcher_from_checkout(tmp_path: Path):
checkout = make_checkout(tmp_path)
log = tmp_path / "juggle.log"
launcher = checkout / "iqpilot/tools/jotpluggler/pluggle.py"
launcher.parent.mkdir(parents=True)
launcher.write_text(f"#!/usr/bin/env bash\nprintf '%s\\n' \"$PWD|$*\" > {log!s}\n")
launcher.chmod(0o755)
result = run_iq(checkout, "juggle", "route")
assert result.returncode == 0
assert log.read_text().strip() == f"{checkout}|route"
def test_desktop_tool_missing_from_host_checkout_fails(tmp_path: Path):
checkout = make_checkout(tmp_path)
result = run_iq(checkout, "cabana")
assert result.returncode == 1
assert "launcher is missing" in result.stderr

10
iqpilot/tools/ubuntu_setup.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
# NOTE: this is used in a docker build, so do not run any scripts here.
"$DIR"/install_ubuntu_dependencies.sh
"$DIR"/install_python_dependencies.sh