This commit is contained in:
firestar5683
2026-07-20 12:23:38 -05:00
parent f265aa3f7b
commit 4ac7fcaf5c
10 changed files with 1239 additions and 286 deletions
@@ -95,6 +95,14 @@ def queue_row(row: dict[str, str]) -> dict[str, str]:
read_sources = "model"
if row.get("full_detection", ""):
read_sources += ";full_detection"
if row.get("event_type", "") == "visionPublish":
read_sources += ";logged_vision_publish"
review_reasons = ["corrected_source_timing"]
if row.get("event_type", "") == "visionPublish":
review_reasons.extend(("route_vision_publish", f"published_{row.get('published_speed', '')}"))
else:
review_reasons.append("route_bookmark")
item = dict.fromkeys(FIELDNAMES, "")
item.update({
@@ -121,8 +129,10 @@ def queue_row(row: dict[str, str]) -> dict[str, str]:
"read_sources": read_sources,
"read_support_count": "1",
"is_regulatory": row.get("is_regulatory", ""),
"map_current_speed_limit_mph": row.get("map_speed", ""),
"map_next_speed_limit_mph": row.get("next_speed", ""),
"review_priority": row.get("score", ""),
"review_reasons": "route_bookmark;corrected_source_timing",
"review_reasons": ";".join(review_reasons),
})
return item
@@ -56,19 +56,23 @@ def configure_models(models_dir: Path | None):
slv.US_CLASSIFIER_MODEL_PATH = classifier_path
def iter_video_samples(clip_path: Path, start_s: float, end_s: float, sample_every: float):
def iter_video_samples(clip_path: Path, start_s: float, end_s: float, sample_every: float, seek: bool = False):
capture = cv2.VideoCapture(str(clip_path))
fps = common.source_video_fps(clip_path, capture.get(cv2.CAP_PROP_FPS))
start_frame = max(int(start_s * fps), 0)
end_frame = max(int(end_s * fps), start_frame)
frame_index = 0
while frame_index < start_frame:
ok, _ = capture.read()
if not ok:
capture.release()
return
frame_index += 1
if seek:
capture.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
frame_index = max(round(capture.get(cv2.CAP_PROP_POS_FRAMES)), 0)
else:
while frame_index < start_frame:
ok, _ = capture.read()
if not ok:
capture.release()
return
frame_index += 1
next_sample_s = start_s
while frame_index <= end_frame:
@@ -86,7 +90,14 @@ def iter_video_samples(clip_path: Path, start_s: float, end_s: float, sample_eve
capture.release()
def iter_context_frames(clip_root: Path, window: ebl.BookmarkWindow, search_before: float, search_after: float, sample_every: float):
def iter_context_frames(
clip_root: Path,
window: ebl.BookmarkWindow,
search_before: float,
search_after: float,
sample_every: float,
seek: bool = False,
):
ranges: list[tuple[Path, float, float]] = []
start_s = window.segment_offset_s - search_before
end_s = window.segment_offset_s + search_after
@@ -102,7 +113,7 @@ def iter_context_frames(clip_root: Path, window: ebl.BookmarkWindow, search_befo
ranges.append((current_clip, max(start_s, 0.0), min(end_s, 60.0)))
for clip_path, range_start_s, range_end_s in ranges:
for source_time_s, frame_bgr in iter_video_samples(clip_path, range_start_s, range_end_s, sample_every):
for source_time_s, frame_bgr in iter_video_samples(clip_path, range_start_s, range_end_s, sample_every, seek=seek):
if clip_path.parent.name.endswith(f"--{window.segment - 1}"):
relative_time_s = source_time_s - 60.0
else:
@@ -30,6 +30,7 @@ else:
DEFAULT_WORKSPACE = Path("/Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean")
ROUTE_ID_RE = re.compile(r"([0-9a-f]{16})/([^/]+)")
BOOKMARK_TYPES = ("bookmarkButton", "userBookmark")
MS_TO_MPH = 2.2369362920544
def parse_args() -> argparse.Namespace:
@@ -38,6 +39,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
parser.add_argument("--clip-root", type=Path, default=preferred_clip_root(), help="Downloaded route clip root.")
parser.add_argument("--output-dir", type=Path, help="Output directory. Defaults to <workspace>/review/connect_route_bookmarks.")
parser.add_argument(
"--event-types",
choices=("bookmark", "vision", "both"),
default="bookmark",
help="Mine user bookmarks, logged Vision publications, or both.",
)
parser.add_argument("--models-dir", type=Path, help="Optional directory containing speed_limit_us_detector.onnx and speed_limit_us_value_classifier.onnx.")
parser.add_argument("--lead-in", type=float, default=7.0, help="Seconds before each bookmark to sample into review sheets.")
parser.add_argument("--sample-every", type=float, default=0.5, help="Seconds between sampled lead-in frames.")
@@ -47,6 +54,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--localize-sample-every", type=float, default=0.25, help="Seconds between frames while searching for the best sign candidate.")
parser.add_argument("--top-k", type=int, default=1, help="Number of localized candidates to keep per bookmark.")
parser.add_argument("--model-only", action="store_true", help="Match the production detector/classifier path without crop OCR.")
parser.add_argument("--seek-sampling", action="store_true", help="Seek directly to each context window instead of decoding from clip start.")
parser.add_argument("--skip-contact-sheets", action="store_true", help="Skip lead-in frames and contact sheets when only a review queue is needed.")
parser.add_argument("--overwrite", action="store_true", help="Overwrite any existing outputs.")
return parser.parse_args()
@@ -67,15 +76,20 @@ def read_log_bytes(path: Path) -> bytes:
return path.read_bytes()
def load_route_bookmarks(clip_root: Path, log_id: str) -> list[dict]:
def load_route_bookmarks(clip_root: Path, log_id: str, event_types: str = "bookmark") -> list[dict]:
segment_dirs = sorted(clip_root.glob(f"{log_id}--*"), key=lambda path: int(path.name.rsplit("--", 1)[-1]))
if not segment_dirs:
raise FileNotFoundError(f"No downloaded segments found for {log_id} under {clip_root}")
route_start_monotime = None
raw_events: list[tuple[float, str]] = []
raw_events: list[dict] = []
last_vision_speed: int | None = None
for segment_dir in segment_dirs:
log_path = next((segment_dir / name for name in ("rlog.zst", "rlog.bz2", "qlog.zst", "qlog.bz2") if (segment_dir / name).exists()), None)
log_names = (
("qlog.zst", "qlog.bz2", "rlog.zst", "rlog.bz2")
if event_types == "vision"
else ("rlog.zst", "rlog.bz2", "qlog.zst", "qlog.bz2")
)
log_path = next((segment_dir / name for name in log_names if (segment_dir / name).exists()), None)
if log_path is None:
continue
@@ -86,35 +100,66 @@ def load_route_bookmarks(clip_root: Path, log_id: str) -> list[dict]:
continue
if not events:
continue
if route_start_monotime is None:
route_start_monotime = events[0].logMonoTime
segment = int(segment_dir.name.rsplit("--", 1)[-1])
road_camera_times = [event.logMonoTime for event in events if event.which() == "roadCameraState"]
fallback_times = [event.logMonoTime for event in events if event.which() != "initData"]
segment_start_monotime = min(road_camera_times or fallback_times or [events[0].logMonoTime])
for event in events:
event_type = event.which()
if event_type not in BOOKMARK_TYPES:
if event_types in ("bookmark", "both") and event_type in BOOKMARK_TYPES:
segment_offset_s = max((event.logMonoTime - segment_start_monotime) / 1e9, 0.0)
route_time_s = segment * 60.0 + segment_offset_s
raw_events.append({
"event_type": event_type,
"route_time_s": route_time_s,
"segment": segment,
"segment_offset_s": segment_offset_s,
"published_speed": "",
"map_speed": "",
"mapbox_speed": "",
"next_speed": "",
})
if event_types not in ("vision", "both") or event_type != "starpilotPlan":
continue
plan = event.starpilotPlan
source = str(plan.slcSpeedLimitSource)
if source != "Vision":
last_vision_speed = None
continue
route_time_s = (event.logMonoTime - route_start_monotime) / 1e9
raw_events.append((route_time_s, event_type))
raw_events.sort(key=lambda item: item[0])
published_speed = round(float(plan.slcSpeedLimit) * MS_TO_MPH)
if published_speed <= 0 or published_speed == last_vision_speed:
continue
last_vision_speed = published_speed
segment_offset_s = max((event.logMonoTime - segment_start_monotime) / 1e9, 0.0)
raw_events.append({
"event_type": "visionPublish",
"route_time_s": segment * 60.0 + segment_offset_s,
"segment": segment,
"segment_offset_s": segment_offset_s,
"published_speed": published_speed,
"map_speed": round(float(plan.slcMapSpeedLimit) * MS_TO_MPH),
"mapbox_speed": round(float(plan.slcMapboxSpeedLimit) * MS_TO_MPH),
"next_speed": round(float(plan.slcNextSpeedLimit) * MS_TO_MPH),
})
raw_events.sort(key=lambda item: item["route_time_s"])
deduped: list[dict] = []
for route_time_s, event_type in raw_events:
if deduped and abs(route_time_s - deduped[-1]["route_time_s"]) <= 0.5:
if event_type == "userBookmark":
deduped[-1]["event_type"] = event_type
deduped[-1]["route_time_s"] = route_time_s
deduped[-1]["segment"] = max(int(route_time_s // 60.0), 0)
deduped[-1]["segment_offset_s"] = route_time_s - deduped[-1]["segment"] * 60.0
for raw_event in raw_events:
if deduped and abs(raw_event["route_time_s"] - deduped[-1]["route_time_s"]) <= 0.5:
if raw_event["event_type"] == "userBookmark":
deduped[-1].update(raw_event)
elif raw_event["event_type"] == "visionPublish":
deduped[-1].update({
key: raw_event[key]
for key in ("published_speed", "map_speed", "mapbox_speed", "next_speed")
})
if deduped[-1]["event_type"] not in BOOKMARK_TYPES:
deduped[-1]["event_type"] = raw_event["event_type"]
continue
segment = max(int(route_time_s // 60.0), 0)
segment_offset_s = route_time_s - segment * 60.0
deduped.append({
"event_type": event_type,
"route_time_s": route_time_s,
"segment": segment,
"segment_offset_s": segment_offset_s,
})
deduped.append(raw_event)
return deduped
@@ -140,6 +185,11 @@ def write_localized_manifest(path: Path, rows: list[dict]) -> None:
"frame_path",
"crop_path",
"box",
"event_type",
"published_speed",
"map_speed",
"mapbox_speed",
"next_speed",
])
writer.writeheader()
writer.writerows(rows)
@@ -151,6 +201,13 @@ def fmt_detection(result) -> str:
return f"{result[0]}@{result[1]:.3f}"
def manifest_path(path: Path, workspace: Path) -> str:
try:
return str(path.relative_to(workspace))
except ValueError:
return str(path)
def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
@@ -170,12 +227,12 @@ def main() -> int:
for raw_route in args.routes:
dongle_id, log_id = parse_route_id(raw_route)
session_id = f"connect_{dongle_id}_{log_id}"
bookmarks = load_route_bookmarks(clip_root, log_id)
bookmarks = load_route_bookmarks(clip_root, log_id, args.event_types)
if not bookmarks:
print(f"{raw_route}: no bookmark events found in downloaded rlogs")
continue
print(f"{raw_route}: found {len(bookmarks)} bookmark(s)")
print(f"{raw_route}: found {len(bookmarks)} event(s)")
for bookmark_number, bookmark in enumerate(bookmarks, start=1):
window = BookmarkWindow(
bookmark_number=bookmark_number,
@@ -186,13 +243,15 @@ def main() -> int:
spans_previous_segment=float(bookmark["segment_offset_s"]) - args.lead_in < 0.0,
)
sampled_frames = extract_window_frames({
"route": log_id,
"segment": window.segment,
"segmentOffsetS": window.segment_offset_s,
"leadinStartS": window.leadin_start_s,
"spansPreviousSegment": window.spans_previous_segment,
}, clip_root, args.sample_every, args.max_samples)
sampled_frames = []
if not args.skip_contact_sheets:
sampled_frames = extract_window_frames({
"route": log_id,
"segment": window.segment,
"segmentOffsetS": window.segment_offset_s,
"leadinStartS": window.leadin_start_s,
"spansPreviousSegment": window.spans_previous_segment,
}, clip_root, args.sample_every, args.max_samples)
contact_sheet_frames = []
contact_sheet_labels = []
@@ -213,8 +272,8 @@ def main() -> int:
"segment": window.segment,
"segment_offset_s": f"{window.segment_offset_s:.3f}",
"sample_offset_s": f"{sample['relative_offset_s']:.3f}",
"frame_path": str(frame_path.relative_to(workspace)),
"contact_sheet_path": str(contact_sheet_path.relative_to(workspace)),
"frame_path": manifest_path(frame_path, workspace),
"contact_sheet_path": manifest_path(contact_sheet_path, workspace),
"source_video_path": str(sample["source_video"]),
"event_type": bookmark["event_type"],
"route_time_s": f"{bookmark['route_time_s']:.3f}",
@@ -230,6 +289,7 @@ def main() -> int:
args.search_before,
args.search_after,
args.localize_sample_every,
seek=args.seek_sampling,
):
scored = score_frame(daemon, frame_bgr, use_ocr=not args.model_only)
if scored is None:
@@ -271,6 +331,11 @@ def main() -> int:
"frame_path": str(frame_path),
"crop_path": str(crop_path),
"box": ",".join(str(value) for value in scored["box"]),
"event_type": bookmark["event_type"],
"published_speed": bookmark["published_speed"],
"map_speed": bookmark["map_speed"],
"mapbox_speed": bookmark["mapbox_speed"],
"next_speed": bookmark["next_speed"],
})
ensure_dir(leadin_manifest_path.parent)
+26 -203
View File
@@ -5,8 +5,7 @@ from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N
from openpilot.common.pid import PIDController
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.common.filter_simple import FirstOrderFilter
from opendbc.car.gm.values import CAR, CarControllerParams, GMFlags
from openpilot.starpilot.common.testing_grounds import testing_ground
from openpilot.selfdrive.controls.lib.longcontrol_vehicle_tunes import LongControlVehicleTuning
CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N]
clip = np.clip
@@ -17,8 +16,7 @@ STOPPING_RELEASE_STRONG_ACCEL = 0.45
MOVING_STOP_FOLLOW_MIN_GAP = 0.25
NEGATIVE_TARGET_CREEP_GUARD_SPEED = 0.35
NEGATIVE_TARGET_CREEP_GUARD_DECEL = 0.40
BOLT_ACC_PEDAL_REGEN_LIMIT_BP = [0.0, 1.5, 4.0, 8.0, 15.0, 30.0]
BOLT_ACC_PEDAL_REGEN_LIMIT_V = [-0.93, -1.28, -1.98, -2.58, -2.86, -2.95]
MODE_TRANSITION_MAX_DECEL = 4.0
LongCtrlState = car.CarControl.Actuators.LongControlState
@@ -32,55 +30,6 @@ def apply_deadzone(error, deadzone):
return error
def get_bolt_acc_pedal_friction_bias(output_accel, a_target, v_ego):
if output_accel >= -0.05 or a_target >= -0.80 or v_ego <= 5.0:
return 0.0
authority_gap = max(0.0, abs(a_target) - abs(output_accel))
if authority_gap <= 0.25:
return 0.0
speed_factor = interp(v_ego, [5.0, 10.0, 15.0, 25.0], [0.0, 0.55, 0.85, 1.0])
max_bias = interp(abs(a_target), [0.8, 1.4, 2.2, 3.5], [0.0, 0.14, 0.42, 0.70])
return float(min(authority_gap * 0.30, max_bias) * speed_factor)
def get_bolt_acc_pedal_friction_floor(a_target, v_ego, pedal_regen_limit):
if v_ego <= 5.0 or a_target >= (pedal_regen_limit - 0.05):
return None
friction_request = max(0.0, pedal_regen_limit - a_target)
if friction_request <= 0.10:
return None
speed_factor = interp(v_ego, [5.0, 8.0, 12.0, 18.0, 25.0], [0.0, 0.45, 0.75, 0.90, 1.0])
demand_factor = interp(friction_request, [0.10, 0.25, 0.50, 0.90, 1.30], [0.0, 0.22, 0.50, 0.78, 1.0])
floor_fraction = float(clip(speed_factor * demand_factor, 0.0, 1.0))
return float(pedal_regen_limit - (friction_request * floor_fraction))
def get_bolt_acc_pedal_feedforward_gain(feedforward_gain, a_target, v_ego, pedal_regen_limit, last_output_accel):
effective_gain = feedforward_gain
if a_target >= 0.0:
return effective_gain
restore = 0.0
if a_target < pedal_regen_limit:
friction_gap = pedal_regen_limit - a_target
restore = float(interp(friction_gap, [0.0, 0.25, 0.75], [0.0, 0.6, 1.0]))
if v_ego > 5.0 and a_target < -1.10:
authority_gap = max(0.0, abs(a_target) - abs(min(last_output_accel, 0.0)))
target_restore = float(interp(abs(a_target), [1.1, 1.6, 2.2, 3.0], [0.0, 0.25, 0.55, 1.0]))
gap_restore = float(interp(authority_gap, [0.2, 0.6, 1.0, 1.6], [0.0, 0.25, 0.60, 1.0]))
speed_factor = float(interp(v_ego, [5.0, 8.0, 12.0, 18.0], [0.0, 0.35, 0.75, 1.0]))
restore = max(restore, max(target_restore, gap_restore) * speed_factor)
return float(feedforward_gain + ((1.0 - feedforward_gain) * clip(restore, 0.0, 1.0)))
def long_control_state_trans(CP, active, long_control_state, v_ego,
should_stop, brake_pressed, cruise_standstill, starpilot_toggles,
allow_stopping_release=True):
@@ -174,26 +123,8 @@ class LongControl:
self.v_pid = 0.0
self._mode_setup()
self.last_output_accel = 0.0
self.last_a_target = 0.0
self.integrator_hold_frames = 0
self.stop_release_counter = 0
self.is_gm_pedal_long = bool(
CP.brand == "gm" and CP.enableGasInterceptorDEPRECATED and (CP.flags & GMFlags.PEDAL_LONG.value)
)
self.is_volt = bool(
CP.brand == "gm" and str(CP.carFingerprint).startswith("CHEVROLET_VOLT")
)
self.is_gm_stock_truck = bool(
CP.brand == "gm" and
getattr(CP, "carFingerprint", None) in (CAR.CHEVROLET_SILVERADO, CAR.CHEVROLET_SILVERADO_CC) and
not CP.enableGasInterceptorDEPRECATED
)
self.is_bolt_acc_pedal_friction_car = bool(
CP.brand == "gm" and
CP.enableGasInterceptorDEPRECATED and
getattr(CP, "carFingerprint", None) == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL and
(CP.flags & GMFlags.PEDAL_LONG.value)
)
self.vehicle_tuning = LongControlVehicleTuning(CP)
def update_mpc_mode(self, experimental_mode):
new_mode = 'blended' if experimental_mode else 'acc'
@@ -224,8 +155,7 @@ class LongControl:
def reset(self, preserve_stop_release=False):
self.pid.reset()
self.last_a_target = 0.0
self.integrator_hold_frames = 0
self.vehicle_tuning.reset()
if not preserve_stop_release:
self.stop_release_counter = 0
@@ -269,45 +199,6 @@ class LongControl:
follow_step = interp(CS.vEgo, [follow_min_speed, 3.0, 6.0, 10.0], [0.02, 0.03, 0.05, 0.07])
return max(float(a_target), output_accel - float(follow_step))
def _get_pedal_long_freeze(self, a_target, error, v_ego, accel_limits):
volt_test_tune_handoff = self.is_volt and testing_ground.use_2
if not self.is_gm_pedal_long and not volt_test_tune_handoff:
self.last_a_target = a_target
self.integrator_hold_frames = 0
return False
if self.is_gm_pedal_long:
handoff_threshold = interp(v_ego, [0.0, 4.0, 12.0, 25.0], [0.35, 0.45, 0.55, 0.70])
hold_frames = int(round(interp(v_ego, [0.0, 4.0, 12.0, 25.0], [25.0, 20.0, 14.0, 10.0])))
else:
handoff_threshold = interp(v_ego, [0.0, 4.0, 12.0, 25.0], [0.24, 0.30, 0.38, 0.48])
hold_frames = int(round(interp(v_ego, [0.0, 4.0, 12.0, 25.0], [12.0, 10.0, 8.0, 6.0])))
if abs(a_target - self.last_a_target) > handoff_threshold:
self.integrator_hold_frames = max(self.integrator_hold_frames, hold_frames)
self.last_a_target = a_target
if self.integrator_hold_frames > 0:
self.integrator_hold_frames -= 1
sat_buffer = 0.03
at_neg_sat = self.last_output_accel <= (accel_limits[0] + sat_buffer)
at_pos_sat = self.last_output_accel >= (accel_limits[1] - sat_buffer)
sat_pushing_lower = at_neg_sat and error < -0.05
sat_pushing_upper = at_pos_sat and error > 0.05
return self.integrator_hold_frames > 0 or sat_pushing_lower or sat_pushing_upper
def _shape_volt_test_tune_integrator(self, error, v_ego):
if not (self.is_volt and testing_ground.use_2):
return
# Bleed stale I quickly when the target reverses against stored integrator.
if self.pid.i * error < 0.0 and abs(error) > 0.05:
bleed = interp(v_ego, [0.0, 4.0, 12.0, 25.0], [0.82, 0.86, 0.90, 0.94])
self.pid.i *= bleed
def _trim_positive_overshoot_integrator(self, a_target, error, CS):
if self.pid.i <= 0.0:
return
@@ -322,74 +213,6 @@ class LongControl:
bleed = interp(abs(error), [0.25, 0.75, 1.5], [0.55, 0.25, 0.0])
self.pid.i *= bleed
def _trim_gm_truck_positive_hold_integrator(self, a_target, error, CS):
if not self.is_gm_stock_truck or self.pid.i <= 0.0:
return
if self.last_output_accel <= 0.10:
return
light_accel_threshold = float(interp(CS.vEgo, [8.0, 15.0, 25.0], [0.03, 0.06, 0.10]))
if a_target > light_accel_threshold:
return
if CS.vEgo <= NEGATIVE_TARGET_CREEP_GUARD_SPEED and a_target > -NEGATIVE_TARGET_CREEP_GUARD_DECEL:
return
# Stock-ACC trucks are especially sensitive to hanging onto light positive
# torque after the planner has already crossed back to coast or mild decel.
# Bleed stale positive I faster in that narrow window so the command can
# settle instead of wobbling the converter lock state.
authority_mismatch = self.last_output_accel - max(a_target, 0.0)
if authority_mismatch <= 0.08 and error > -0.08:
return
target_factor = float(interp(a_target, [-0.30, -0.10, -0.02, light_accel_threshold], [0.20, 0.35, 0.60, 0.98]))
if error < -0.20:
target_factor *= 0.75
self.pid.i *= target_factor
def _trim_gm_truck_negative_hold_integrator(self, a_target, error, CS):
if not self.is_gm_stock_truck or self.pid.i >= -0.02:
return
if CS.vEgo < 12.0 or a_target <= -0.85:
return
if error <= 0.04:
return
authority_mismatch = float(a_target) - float(self.last_output_accel)
if authority_mismatch <= 0.10:
return
release = float(interp(
max(authority_mismatch, error),
[0.10, 0.25, 0.50],
[0.0008, 0.0020, 0.0040],
))
self.pid.i = min(0.0, self.pid.i + release)
def _apply_pedal_long_brake_bias(self, output_accel, a_target, CS):
if not self.is_gm_pedal_long:
return output_accel
if output_accel >= -0.05 or a_target >= -0.80:
return output_accel
if CS.vEgo <= 5.0:
return output_accel
authority_gap = max(0.0, abs(a_target) - abs(output_accel))
if self.is_bolt_acc_pedal_friction_car:
pedal_regen_limit = float(interp(CS.vEgo, BOLT_ACC_PEDAL_REGEN_LIMIT_BP, BOLT_ACC_PEDAL_REGEN_LIMIT_V))
bias = get_bolt_acc_pedal_friction_bias(output_accel, a_target, CS.vEgo)
floor = get_bolt_acc_pedal_friction_floor(a_target, CS.vEgo, pedal_regen_limit)
if floor is not None:
bias = max(bias, output_accel - floor)
return output_accel - float(max(bias, 0.0))
if authority_gap <= 0.40:
return output_accel
speed_factor = interp(CS.vEgo, [5.0, 12.0, 25.0], [0.0, 0.7, 1.0])
max_bias = interp(abs(a_target), [0.8, 2.0, 3.5], [0.0, 0.10, 0.20])
bias = min(authority_gap * 0.12, max_bias) * speed_factor
return output_accel - float(bias)
@staticmethod
def _cap_positive_output_on_negative_target(output_accel, a_target, error, CS):
if output_accel <= 0.0:
@@ -404,17 +227,6 @@ class LongControl:
positive_cap = interp(a_target, [-1.5, -0.6, -0.1], [0.0, 0.0, 0.05])
return min(output_accel, float(positive_cap))
def _get_longitudinal_feedforward(self, a_target, v_ego):
feedforward = a_target * self.feedforward_gain
if not self.is_bolt_acc_pedal_friction_car or a_target >= 0.0:
return feedforward
pedal_regen_limit = float(interp(v_ego, BOLT_ACC_PEDAL_REGEN_LIMIT_BP, BOLT_ACC_PEDAL_REGEN_LIMIT_V))
effective_gain = get_bolt_acc_pedal_feedforward_gain(
self.feedforward_gain, a_target, v_ego, pedal_regen_limit, self.last_output_accel,
)
return a_target * effective_gain
def update(self, active, CS, a_target, should_stop, accel_limits, starpilot_toggles, has_lead=False):
"""Update longitudinal control. This updates the state machine and runs a PID loop"""
self.pid.neg_limit = accel_limits[0]
@@ -447,16 +259,24 @@ class LongControl:
else: # LongCtrlState.pid
error = a_target - CS.aEgo
self.update_mpc_mode(self.experimental_mode)
self._shape_volt_test_tune_integrator(error, CS.vEgo)
self.vehicle_tuning.shape_volt_test_tune_integrator(self.pid, error, CS.vEgo)
self._trim_positive_overshoot_integrator(a_target, error, CS)
self._trim_gm_truck_positive_hold_integrator(a_target, error, CS)
self._trim_gm_truck_negative_hold_integrator(a_target, error, CS)
feedforward = self._get_longitudinal_feedforward(a_target, CS.vEgo)
freeze_integrator = self._get_pedal_long_freeze(a_target, error, CS.vEgo, accel_limits)
self.vehicle_tuning.trim_gm_truck_positive_hold_integrator(
self.pid, self.last_output_accel, a_target, error, CS,
)
self.vehicle_tuning.trim_gm_truck_negative_hold_integrator(
self.pid, self.last_output_accel, a_target, error, CS,
)
feedforward = self.vehicle_tuning.get_longitudinal_feedforward(
self.feedforward_gain, self.last_output_accel, a_target, CS.vEgo,
)
freeze_integrator = self.vehicle_tuning.get_integrator_freeze(
self.last_output_accel, a_target, error, CS.vEgo, accel_limits,
)
raw_output_accel = self.pid.update(error, speed=CS.vEgo, feedforward=feedforward,
freeze_integrator=freeze_integrator)
raw_output_accel = self._cap_positive_output_on_negative_target(raw_output_accel, a_target, error, CS)
raw_output_accel = self._apply_pedal_long_brake_bias(raw_output_accel, a_target, CS)
raw_output_accel = self.vehicle_tuning.apply_pedal_long_brake_bias(raw_output_accel, a_target, CS)
if self.transitioning and self.prev_mode == 'acc' and self.current_mode == 'blended':
@@ -464,7 +284,7 @@ class LongControl:
progress = min(1.0, self.mode_transition_timer / self.mode_transition_duration)
# Soften transition at low urgency, but keep sharp for high decel
# 20% smoother for chill decel (lower exponent)
urgency = abs(raw_output_accel / CarControllerParams.ACCEL_MIN)
urgency = abs(raw_output_accel / -MODE_TRANSITION_MAX_DECEL)
urgency_smooth = min(1.0, urgency ** 0.4) # 20% smoother for chill decel
blend_factor = 1.0 - (1.0 - progress) * (1.0 - urgency_smooth)
output_accel = self.last_output_accel + (raw_output_accel - self.last_output_accel) * blend_factor
@@ -480,8 +300,7 @@ class LongControl:
"""Reset PID controller and change setpoint"""
self.pid.reset()
self.v_pid = v_pid
self.last_a_target = 0.0
self.integrator_hold_frames = 0
self.vehicle_tuning.reset()
def update_old_long(self, active, CS, long_plan, accel_limits, t_since_plan, starpilot_toggles):
"""Update longitudinal control. This updates the state machine and runs a PID loop"""
@@ -536,8 +355,12 @@ class LongControl:
deadzone = interp(CS.vEgo, self.CP.longitudinalTuning.deadzoneBP, self.CP.longitudinalTuning.deadzoneV)
error = self.v_pid - CS.vEgo
error_deadzone = apply_deadzone(error, deadzone)
freeze_integrator = prevent_overshoot or self._get_pedal_long_freeze(a_target, error_deadzone, CS.vEgo, accel_limits)
feedforward = self._get_longitudinal_feedforward(a_target, CS.vEgo)
freeze_integrator = prevent_overshoot or self.vehicle_tuning.get_integrator_freeze(
self.last_output_accel, a_target, error_deadzone, CS.vEgo, accel_limits,
)
feedforward = self.vehicle_tuning.get_longitudinal_feedforward(
self.feedforward_gain, self.last_output_accel, a_target, CS.vEgo,
)
output_accel = self.pid.update(error_deadzone, speed=CS.vEgo,
feedforward=feedforward,
freeze_integrator=freeze_integrator)
@@ -0,0 +1,201 @@
import numpy as np
from opendbc.car.gm.values import CAR, GMFlags
from openpilot.starpilot.common.testing_grounds import testing_ground
clip = np.clip
interp = np.interp
BOLT_ACC_PEDAL_REGEN_LIMIT_BP = [0.0, 1.5, 4.0, 8.0, 15.0, 30.0]
BOLT_ACC_PEDAL_REGEN_LIMIT_V = [-0.93, -1.28, -1.98, -2.58, -2.86, -2.95]
NEGATIVE_TARGET_CREEP_GUARD_SPEED = 0.35
NEGATIVE_TARGET_CREEP_GUARD_DECEL = 0.40
def get_bolt_acc_pedal_friction_bias(output_accel, a_target, v_ego):
if output_accel >= -0.05 or a_target >= -0.80 or v_ego <= 5.0:
return 0.0
authority_gap = max(0.0, abs(a_target) - abs(output_accel))
if authority_gap <= 0.25:
return 0.0
speed_factor = interp(v_ego, [5.0, 10.0, 15.0, 25.0], [0.0, 0.55, 0.85, 1.0])
max_bias = interp(abs(a_target), [0.8, 1.4, 2.2, 3.5], [0.0, 0.14, 0.42, 0.70])
return float(min(authority_gap * 0.30, max_bias) * speed_factor)
def get_bolt_acc_pedal_friction_floor(a_target, v_ego, pedal_regen_limit):
if v_ego <= 5.0 or a_target >= (pedal_regen_limit - 0.05):
return None
friction_request = max(0.0, pedal_regen_limit - a_target)
if friction_request <= 0.10:
return None
speed_factor = interp(v_ego, [5.0, 8.0, 12.0, 18.0, 25.0], [0.0, 0.45, 0.75, 0.90, 1.0])
demand_factor = interp(friction_request, [0.10, 0.25, 0.50, 0.90, 1.30], [0.0, 0.22, 0.50, 0.78, 1.0])
floor_fraction = float(clip(speed_factor * demand_factor, 0.0, 1.0))
return float(pedal_regen_limit - (friction_request * floor_fraction))
def get_bolt_acc_pedal_feedforward_gain(feedforward_gain, a_target, v_ego, pedal_regen_limit, last_output_accel):
effective_gain = feedforward_gain
if a_target >= 0.0:
return effective_gain
restore = 0.0
if a_target < pedal_regen_limit:
friction_gap = pedal_regen_limit - a_target
restore = float(interp(friction_gap, [0.0, 0.25, 0.75], [0.0, 0.6, 1.0]))
if v_ego > 5.0 and a_target < -1.10:
authority_gap = max(0.0, abs(a_target) - abs(min(last_output_accel, 0.0)))
target_restore = float(interp(abs(a_target), [1.1, 1.6, 2.2, 3.0], [0.0, 0.25, 0.55, 1.0]))
gap_restore = float(interp(authority_gap, [0.2, 0.6, 1.0, 1.6], [0.0, 0.25, 0.60, 1.0]))
speed_factor = float(interp(v_ego, [5.0, 8.0, 12.0, 18.0], [0.0, 0.35, 0.75, 1.0]))
restore = max(restore, max(target_restore, gap_restore) * speed_factor)
return float(feedforward_gain + ((1.0 - feedforward_gain) * clip(restore, 0.0, 1.0)))
class LongControlVehicleTuning:
def __init__(self, CP):
self.is_gm_pedal_long = bool(
CP.brand == "gm" and CP.enableGasInterceptorDEPRECATED and (CP.flags & GMFlags.PEDAL_LONG.value)
)
self.is_volt = bool(
CP.brand == "gm" and str(CP.carFingerprint).startswith("CHEVROLET_VOLT")
)
self.is_gm_stock_truck = bool(
CP.brand == "gm" and
getattr(CP, "carFingerprint", None) in (CAR.CHEVROLET_SILVERADO, CAR.CHEVROLET_SILVERADO_CC) and
not CP.enableGasInterceptorDEPRECATED
)
self.is_bolt_acc_pedal_friction_car = bool(
CP.brand == "gm" and
CP.enableGasInterceptorDEPRECATED and
getattr(CP, "carFingerprint", None) == CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL and
(CP.flags & GMFlags.PEDAL_LONG.value)
)
self.reset()
def reset(self):
self.last_a_target = 0.0
self.integrator_hold_frames = 0
def get_integrator_freeze(self, last_output_accel, a_target, error, v_ego, accel_limits):
volt_test_tune_handoff = self.is_volt and testing_ground.use_2
if not self.is_gm_pedal_long and not volt_test_tune_handoff:
self.last_a_target = a_target
self.integrator_hold_frames = 0
return False
if self.is_gm_pedal_long:
handoff_threshold = interp(v_ego, [0.0, 4.0, 12.0, 25.0], [0.35, 0.45, 0.55, 0.70])
hold_frames = int(round(interp(v_ego, [0.0, 4.0, 12.0, 25.0], [25.0, 20.0, 14.0, 10.0])))
else:
handoff_threshold = interp(v_ego, [0.0, 4.0, 12.0, 25.0], [0.24, 0.30, 0.38, 0.48])
hold_frames = int(round(interp(v_ego, [0.0, 4.0, 12.0, 25.0], [12.0, 10.0, 8.0, 6.0])))
if abs(a_target - self.last_a_target) > handoff_threshold:
self.integrator_hold_frames = max(self.integrator_hold_frames, hold_frames)
self.last_a_target = a_target
if self.integrator_hold_frames > 0:
self.integrator_hold_frames -= 1
sat_buffer = 0.03
at_neg_sat = last_output_accel <= (accel_limits[0] + sat_buffer)
at_pos_sat = last_output_accel >= (accel_limits[1] - sat_buffer)
sat_pushing_lower = at_neg_sat and error < -0.05
sat_pushing_upper = at_pos_sat and error > 0.05
return self.integrator_hold_frames > 0 or sat_pushing_lower or sat_pushing_upper
def shape_volt_test_tune_integrator(self, pid, error, v_ego):
if not (self.is_volt and testing_ground.use_2):
return
if pid.i * error < 0.0 and abs(error) > 0.05:
bleed = interp(v_ego, [0.0, 4.0, 12.0, 25.0], [0.82, 0.86, 0.90, 0.94])
pid.i *= bleed
def trim_gm_truck_positive_hold_integrator(self, pid, last_output_accel, a_target, error, CS):
if not self.is_gm_stock_truck or pid.i <= 0.0:
return
if last_output_accel <= 0.10:
return
light_accel_threshold = float(interp(CS.vEgo, [8.0, 15.0, 25.0], [0.03, 0.06, 0.10]))
if a_target > light_accel_threshold:
return
if CS.vEgo <= NEGATIVE_TARGET_CREEP_GUARD_SPEED and a_target > -NEGATIVE_TARGET_CREEP_GUARD_DECEL:
return
authority_mismatch = last_output_accel - max(a_target, 0.0)
if authority_mismatch <= 0.08 and error > -0.08:
return
target_factor = float(interp(a_target, [-0.30, -0.10, -0.02, light_accel_threshold], [0.20, 0.35, 0.60, 0.98]))
if error < -0.20:
target_factor *= 0.75
pid.i *= target_factor
def trim_gm_truck_negative_hold_integrator(self, pid, last_output_accel, a_target, error, CS):
if not self.is_gm_stock_truck or pid.i >= -0.02:
return
if CS.vEgo < 12.0 or a_target <= -0.85:
return
if error <= 0.04:
return
authority_mismatch = float(a_target) - float(last_output_accel)
if authority_mismatch <= 0.10:
return
release = float(interp(
max(authority_mismatch, error),
[0.10, 0.25, 0.50],
[0.0008, 0.0020, 0.0040],
))
pid.i = min(0.0, pid.i + release)
def apply_pedal_long_brake_bias(self, output_accel, a_target, CS):
if not self.is_gm_pedal_long:
return output_accel
if output_accel >= -0.05 or a_target >= -0.80:
return output_accel
if CS.vEgo <= 5.0:
return output_accel
authority_gap = max(0.0, abs(a_target) - abs(output_accel))
if self.is_bolt_acc_pedal_friction_car:
pedal_regen_limit = float(interp(CS.vEgo, BOLT_ACC_PEDAL_REGEN_LIMIT_BP, BOLT_ACC_PEDAL_REGEN_LIMIT_V))
bias = get_bolt_acc_pedal_friction_bias(output_accel, a_target, CS.vEgo)
floor = get_bolt_acc_pedal_friction_floor(a_target, CS.vEgo, pedal_regen_limit)
if floor is not None:
bias = max(bias, output_accel - floor)
return output_accel - float(max(bias, 0.0))
if authority_gap <= 0.40:
return output_accel
speed_factor = interp(CS.vEgo, [5.0, 12.0, 25.0], [0.0, 0.7, 1.0])
max_bias = interp(abs(a_target), [0.8, 2.0, 3.5], [0.0, 0.10, 0.20])
bias = min(authority_gap * 0.12, max_bias) * speed_factor
return output_accel - float(bias)
def get_longitudinal_feedforward(self, feedforward_gain, last_output_accel, a_target, v_ego):
feedforward = a_target * feedforward_gain
if not self.is_bolt_acc_pedal_friction_car or a_target >= 0.0:
return feedforward
pedal_regen_limit = float(interp(v_ego, BOLT_ACC_PEDAL_REGEN_LIMIT_BP, BOLT_ACC_PEDAL_REGEN_LIMIT_V))
effective_gain = get_bolt_acc_pedal_feedforward_gain(
feedforward_gain, a_target, v_ego, pedal_regen_limit, last_output_accel,
)
return a_target * effective_gain
+64 -35
View File
@@ -4,6 +4,7 @@ from cereal import car
import pytest
import openpilot.selfdrive.controls.lib.longcontrol as longcontrol
import openpilot.selfdrive.controls.lib.longcontrol_vehicle_tunes as vehicle_tunes
from opendbc.car.gm.values import CAR, GMFlags
from openpilot.selfdrive.controls.lib.longcontrol import (
LongControl,
@@ -396,13 +397,15 @@ def test_volt_testing_ground_handoff_freezes_integrator(monkeypatch):
CP.longitudinalTuning.kiBP = [0.0]
CP.longitudinalTuning.kiV = [0.03]
monkeypatch.setattr(longcontrol, "testing_ground", SimpleNamespace(use_2=True))
monkeypatch.setattr(vehicle_tunes, "testing_ground", SimpleNamespace(use_2=True))
lc = LongControl(CP)
freeze = lc._get_pedal_long_freeze(a_target=0.7, error=0.7, v_ego=8.0, accel_limits=(-3.0, 2.0))
freeze = lc.vehicle_tuning.get_integrator_freeze(
lc.last_output_accel, a_target=0.7, error=0.7, v_ego=8.0, accel_limits=(-3.0, 2.0),
)
assert freeze
assert lc.integrator_hold_frames > 0
assert lc.vehicle_tuning.integrator_hold_frames > 0
def test_non_interceptor_volt_testing_ground_handoff_freezes_integrator(monkeypatch):
@@ -415,13 +418,15 @@ def test_non_interceptor_volt_testing_ground_handoff_freezes_integrator(monkeypa
CP.longitudinalTuning.kiBP = [0.0]
CP.longitudinalTuning.kiV = [0.03]
monkeypatch.setattr(longcontrol, "testing_ground", SimpleNamespace(use_2=True))
monkeypatch.setattr(vehicle_tunes, "testing_ground", SimpleNamespace(use_2=True))
lc = LongControl(CP)
freeze = lc._get_pedal_long_freeze(a_target=0.7, error=0.7, v_ego=8.0, accel_limits=(-3.0, 2.0))
freeze = lc.vehicle_tuning.get_integrator_freeze(
lc.last_output_accel, a_target=0.7, error=0.7, v_ego=8.0, accel_limits=(-3.0, 2.0),
)
assert freeze
assert lc.integrator_hold_frames > 0
assert lc.vehicle_tuning.integrator_hold_frames > 0
def test_negative_target_unwinds_positive_accel_command_after_sign_flip():
@@ -502,7 +507,7 @@ def test_pedal_long_brake_bias_adds_small_negative_nudge_for_strong_decel_reques
lc = LongControl(CP)
CS = car.CarState.new_message(vEgo=20.0, aEgo=0.0, brakePressed=False)
biased = lc._apply_pedal_long_brake_bias(-1.0, -3.0, CS)
biased = lc.vehicle_tuning.apply_pedal_long_brake_bias(-1.0, -3.0, CS)
assert biased < -1.0
assert biased == pytest.approx(-1.15, abs=0.03)
@@ -521,8 +526,8 @@ def test_pedal_long_brake_bias_does_not_touch_non_pedal_or_mild_decel():
lc = LongControl(CP)
CS = car.CarState.new_message(vEgo=20.0, aEgo=0.0, brakePressed=False)
assert lc._apply_pedal_long_brake_bias(-1.0, -3.0, CS) == -1.0
assert lc._apply_pedal_long_brake_bias(-0.4, -0.6, CS) == -0.4
assert lc.vehicle_tuning.apply_pedal_long_brake_bias(-1.0, -3.0, CS) == -1.0
assert lc.vehicle_tuning.apply_pedal_long_brake_bias(-0.4, -0.6, CS) == -0.4
def test_bolt_acc_pedal_friction_feedforward_preserves_regen_scaling_within_envelope():
@@ -536,7 +541,9 @@ def test_bolt_acc_pedal_friction_feedforward_preserves_regen_scaling_within_enve
lc = LongControl(CP)
assert lc._get_longitudinal_feedforward(-1.8, 4.73) == pytest.approx(-0.36)
assert lc.vehicle_tuning.get_longitudinal_feedforward(
lc.feedforward_gain, lc.last_output_accel, -1.8, 4.73,
) == pytest.approx(-0.36)
def test_bolt_acc_pedal_friction_feedforward_restores_full_gain_beyond_regen_envelope():
@@ -550,7 +557,9 @@ def test_bolt_acc_pedal_friction_feedforward_restores_full_gain_beyond_regen_env
lc = LongControl(CP)
assert lc._get_longitudinal_feedforward(-3.22, 4.73) == pytest.approx(-3.22)
assert lc.vehicle_tuning.get_longitudinal_feedforward(
lc.feedforward_gain, lc.last_output_accel, -3.22, 4.73,
) == pytest.approx(-3.22)
def test_bolt_acc_pedal_friction_feedforward_blends_back_in_for_small_friction_request():
@@ -563,19 +572,21 @@ def test_bolt_acc_pedal_friction_feedforward_blends_back_in_for_small_friction_r
CP.longitudinalTuning.kfDEPRECATED = 0.20
lc = LongControl(CP)
pedal_regen_limit = float(longcontrol.interp(20.0, longcontrol.BOLT_ACC_PEDAL_REGEN_LIMIT_BP,
longcontrol.BOLT_ACC_PEDAL_REGEN_LIMIT_V))
pedal_regen_limit = float(vehicle_tunes.interp(20.0, vehicle_tunes.BOLT_ACC_PEDAL_REGEN_LIMIT_BP,
vehicle_tunes.BOLT_ACC_PEDAL_REGEN_LIMIT_V))
a_target = pedal_regen_limit - 0.10
expected_gain = longcontrol.get_bolt_acc_pedal_feedforward_gain(0.20, a_target, 20.0, pedal_regen_limit, 0.0)
expected_gain = vehicle_tunes.get_bolt_acc_pedal_feedforward_gain(0.20, a_target, 20.0, pedal_regen_limit, 0.0)
expected = a_target * expected_gain
assert lc._get_longitudinal_feedforward(a_target, 20.0) == pytest.approx(expected)
assert lc.vehicle_tuning.get_longitudinal_feedforward(
lc.feedforward_gain, lc.last_output_accel, a_target, 20.0,
) == pytest.approx(expected)
def test_bolt_acc_pedal_friction_floor_holds_friction_only_authority():
pedal_regen_limit = float(longcontrol.interp(9.85, longcontrol.BOLT_ACC_PEDAL_REGEN_LIMIT_BP,
longcontrol.BOLT_ACC_PEDAL_REGEN_LIMIT_V))
floor = longcontrol.get_bolt_acc_pedal_friction_floor(-3.47, 9.85, pedal_regen_limit)
pedal_regen_limit = float(vehicle_tunes.interp(9.85, vehicle_tunes.BOLT_ACC_PEDAL_REGEN_LIMIT_BP,
vehicle_tunes.BOLT_ACC_PEDAL_REGEN_LIMIT_V))
floor = vehicle_tunes.get_bolt_acc_pedal_friction_floor(-3.47, 9.85, pedal_regen_limit)
assert floor is not None
assert floor < pedal_regen_limit
@@ -600,32 +611,32 @@ def test_bolt_acc_pedal_friction_bias_applies_floor_only_on_experimental_fingerp
bolt_cc_lc = LongControl(bolt_cc_cp)
CS = car.CarState.new_message(vEgo=9.85, aEgo=-2.0, brakePressed=False)
pedal_regen_limit = float(longcontrol.interp(CS.vEgo, longcontrol.BOLT_ACC_PEDAL_REGEN_LIMIT_BP,
longcontrol.BOLT_ACC_PEDAL_REGEN_LIMIT_V))
floor = longcontrol.get_bolt_acc_pedal_friction_floor(-3.47, CS.vEgo, pedal_regen_limit)
pedal_regen_limit = float(vehicle_tunes.interp(CS.vEgo, vehicle_tunes.BOLT_ACC_PEDAL_REGEN_LIMIT_BP,
vehicle_tunes.BOLT_ACC_PEDAL_REGEN_LIMIT_V))
floor = vehicle_tunes.get_bolt_acc_pedal_friction_floor(-3.47, CS.vEgo, pedal_regen_limit)
assert floor is not None
pedal_biased = pedal_lc._apply_pedal_long_brake_bias(-1.85, -3.47, CS)
bolt_cc_biased = bolt_cc_lc._apply_pedal_long_brake_bias(-1.85, -3.47, CS)
pedal_biased = pedal_lc.vehicle_tuning.apply_pedal_long_brake_bias(-1.85, -3.47, CS)
bolt_cc_biased = bolt_cc_lc.vehicle_tuning.apply_pedal_long_brake_bias(-1.85, -3.47, CS)
assert pedal_biased == pytest.approx(floor)
assert bolt_cc_biased > pedal_biased + 0.5
def test_bolt_acc_pedal_feedforward_gain_stays_base_for_mild_regen():
gain = longcontrol.get_bolt_acc_pedal_feedforward_gain(0.2, -1.0, 10.0, -2.75, -0.4)
gain = vehicle_tunes.get_bolt_acc_pedal_feedforward_gain(0.2, -1.0, 10.0, -2.75, -0.4)
assert gain == pytest.approx(0.2)
def test_bolt_acc_pedal_feedforward_gain_restores_for_authority_gap():
gain = longcontrol.get_bolt_acc_pedal_feedforward_gain(0.2, -1.83, 12.38, -2.79, -0.70)
gain = vehicle_tunes.get_bolt_acc_pedal_feedforward_gain(0.2, -1.83, 12.38, -2.79, -0.70)
assert gain > 0.55
def test_bolt_acc_pedal_feedforward_gain_restores_near_friction_handoff():
gain = longcontrol.get_bolt_acc_pedal_feedforward_gain(0.2, -2.63, 9.35, -2.69, -1.30)
gain = vehicle_tunes.get_bolt_acc_pedal_feedforward_gain(0.2, -2.63, 9.35, -2.69, -1.30)
assert gain > 0.45
@@ -641,7 +652,9 @@ def test_bolt_cc_pedal_friction_feedforward_remains_fully_scaled_by_kf():
lc = LongControl(CP)
assert lc._get_longitudinal_feedforward(-3.22, 4.73) == pytest.approx(-0.644)
assert lc.vehicle_tuning.get_longitudinal_feedforward(
lc.feedforward_gain, lc.last_output_accel, -3.22, 4.73,
) == pytest.approx(-0.644)
def test_gm_stock_truck_positive_i_bleeds_on_coast_request():
@@ -660,7 +673,9 @@ def test_gm_stock_truck_positive_i_bleeds_on_coast_request():
CS = car.CarState.new_message(vEgo=20.0, aEgo=0.0, brakePressed=False)
CS.cruiseState.standstill = False
lc._trim_gm_truck_positive_hold_integrator(-0.02, -0.02, CS)
lc.vehicle_tuning.trim_gm_truck_positive_hold_integrator(
lc.pid, lc.last_output_accel, -0.02, -0.02, CS,
)
assert lc.pid.i < 0.25
@@ -681,7 +696,9 @@ def test_gm_stock_truck_positive_i_bleeds_during_light_highway_accel_request():
CS = car.CarState.new_message(vEgo=20.0, aEgo=0.0, brakePressed=False)
CS.cruiseState.standstill = False
lc._trim_gm_truck_positive_hold_integrator(0.05, 0.05, CS)
lc.vehicle_tuning.trim_gm_truck_positive_hold_integrator(
lc.pid, lc.last_output_accel, 0.05, 0.05, CS,
)
assert lc.pid.i < 0.25
@@ -702,7 +719,9 @@ def test_gm_stock_truck_positive_i_trim_keeps_meaningful_accel_request():
CS = car.CarState.new_message(vEgo=20.0, aEgo=0.0, brakePressed=False)
CS.cruiseState.standstill = False
lc._trim_gm_truck_positive_hold_integrator(0.12, 0.12, CS)
lc.vehicle_tuning.trim_gm_truck_positive_hold_integrator(
lc.pid, lc.last_output_accel, 0.12, 0.12, CS,
)
assert lc.pid.i == pytest.approx(0.25, abs=1e-9)
@@ -723,7 +742,9 @@ def test_gm_stock_truck_positive_i_trim_preserves_low_speed_launch():
CS = car.CarState.new_message(vEgo=5.0, aEgo=0.0, brakePressed=False)
CS.cruiseState.standstill = False
lc._trim_gm_truck_positive_hold_integrator(0.05, 0.05, CS)
lc.vehicle_tuning.trim_gm_truck_positive_hold_integrator(
lc.pid, lc.last_output_accel, 0.05, 0.05, CS,
)
assert lc.pid.i == pytest.approx(0.25, abs=1e-9)
@@ -739,7 +760,9 @@ def test_gm_stock_truck_negative_i_unwinds_when_already_overbraking():
lc.last_output_accel = -0.66
CS = car.CarState.new_message(vEgo=29.6, aEgo=-0.49, brakePressed=False)
lc._trim_gm_truck_negative_hold_integrator(-0.44, 0.05, CS)
lc.vehicle_tuning.trim_gm_truck_negative_hold_integrator(
lc.pid, lc.last_output_accel, -0.44, 0.05, CS,
)
assert -0.22 < lc.pid.i < 0.0
@@ -755,7 +778,9 @@ def test_gm_stock_truck_negative_i_stays_when_decel_is_not_achieved():
lc.last_output_accel = -0.66
CS = car.CarState.new_message(vEgo=29.6, aEgo=0.05, brakePressed=False)
lc._trim_gm_truck_negative_hold_integrator(-0.44, -0.49, CS)
lc.vehicle_tuning.trim_gm_truck_negative_hold_integrator(
lc.pid, lc.last_output_accel, -0.44, -0.49, CS,
)
assert lc.pid.i == pytest.approx(-0.22, abs=1e-9)
@@ -771,7 +796,9 @@ def test_gm_stock_truck_negative_i_stays_for_urgent_braking():
lc.last_output_accel = -1.30
CS = car.CarState.new_message(vEgo=29.6, aEgo=-1.20, brakePressed=False)
lc._trim_gm_truck_negative_hold_integrator(-1.00, 0.20, CS)
lc.vehicle_tuning.trim_gm_truck_negative_hold_integrator(
lc.pid, lc.last_output_accel, -1.00, 0.20, CS,
)
assert lc.pid.i == pytest.approx(-0.22, abs=1e-9)
@@ -787,7 +814,9 @@ def test_gm_stock_truck_negative_i_trim_does_not_affect_other_gm_cars():
lc.last_output_accel = -0.66
CS = car.CarState.new_message(vEgo=29.6, aEgo=-0.49, brakePressed=False)
lc._trim_gm_truck_negative_hold_integrator(-0.44, 0.05, CS)
lc.vehicle_tuning.trim_gm_truck_negative_hold_integrator(
lc.pid, lc.last_output_accel, -0.44, 0.05, CS,
)
assert lc.pid.i == pytest.approx(-0.22, abs=1e-9)
+25 -1
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from typing import Protocol
LONG_PITCH_KEY = "LongPitch"
LANE_CHANGE_SMOOTHING_KEY = "LaneChangeSmoothing"
STEER_KP_KEY = "SteerKP"
STEER_KP_STOCK_KEY = "SteerKPStock"
USE_OLD_UI_KEY = "UseOldUI"
@@ -31,6 +32,7 @@ USE_OLD_UI_MIGRATION_MARKER = ".starpilot_use_old_ui_migration_v2"
LATERAL_METHOD_REBRAND_MIGRATION_MARKER = ".starpilot_lateral_method_rebrand_v1"
VISION_SPEED_LIMIT_DETECTION_MIGRATION_MARKER = ".starpilot_vision_speed_limit_detection_v1"
DEVELOPER_METRIC_DISPLAY_MIGRATION_MARKER = ".starpilot_developer_metric_display_off_v1"
LANE_CHANGE_SMOOTHING_MIGRATION_MARKER = ".starpilot_lane_change_smoothing_default_v1"
MARKER_DIRNAME = ".starpilot_param_migrations"
LATERAL_METHOD_PARAM_SUFFIXES = (
@@ -48,8 +50,10 @@ LEGACY_RELAXED_FOLLOW_DEFAULT = 1.75
LEGACY_RELAXED_FOLLOW_HIGH_DEFAULT = 1.75
LEGACY_JERK_DEFAULT = 50.0
LEGACY_ACCELERATION_PROFILE_DEFAULT = 2
LEGACY_LANE_CHANGE_SMOOTHING_DEFAULT = 10
STANDARD_ACCELERATION_PROFILE = 0
DEFAULT_LANE_CHANGE_SMOOTHING = 5
BRANCH_BOOL_MIGRATIONS = {
"CEStoppedLead": (LEGACY_CE_STOPPED_LEAD_DEFAULT, False),
@@ -118,6 +122,10 @@ def _developer_metric_display_marker_path(params: ParamsLike) -> Path:
return _marker_dir_path(params) / DEVELOPER_METRIC_DISPLAY_MIGRATION_MARKER
def _lane_change_smoothing_marker_path(params: ParamsLike) -> Path:
return _marker_dir_path(params) / LANE_CHANGE_SMOOTHING_MIGRATION_MARKER
def _marker_dir_path(params: ParamsLike) -> Path:
params_path = Path(params.get_param_path())
# Params.clear_all() removes unknown files inside the params directory, so
@@ -250,13 +258,26 @@ def _apply_developer_metric_display_migration(params: ParamsLike, marker: Path)
marker.touch()
def _apply_lane_change_smoothing_default_migration(params: ParamsLike, marker: Path) -> None:
if marker.exists():
return
marker.parent.mkdir(parents=True, exist_ok=True)
if params.get_int(LANE_CHANGE_SMOOTHING_KEY) == LEGACY_LANE_CHANGE_SMOOTHING_DEFAULT:
params.put_int(LANE_CHANGE_SMOOTHING_KEY, DEFAULT_LANE_CHANGE_SMOOTHING)
marker.touch()
def apply_launch_param_migrations(params: ParamsLike, marker_path: Path | None = None,
branch_defaults_marker_path: Path | None = None,
acceleration_profile_marker_path: Path | None = None,
use_old_ui_marker_path: Path | None = None,
lateral_method_rebrand_marker_path: Path | None = None,
vision_speed_limit_detection_marker_path: Path | None = None,
developer_metric_display_marker_path: Path | None = None) -> None:
developer_metric_display_marker_path: Path | None = None,
lane_change_smoothing_marker_path: Path | None = None) -> None:
_apply_legacy_launch_param_migrations(params, marker_path or _default_marker_path(params))
# Keep branch-default rollout on its own marker so older installs that already
# have the legacy marker still receive this one-time param reset.
@@ -274,6 +295,9 @@ def apply_launch_param_migrations(params: ParamsLike, marker_path: Path | None =
_apply_developer_metric_display_migration(
params, developer_metric_display_marker_path or _developer_metric_display_marker_path(params)
)
_apply_lane_change_smoothing_default_migration(
params, lane_change_smoothing_marker_path or _lane_change_smoothing_marker_path(params)
)
def main() -> int:
@@ -5,7 +5,9 @@ from openpilot.system.manager.launch_param_migrations import (
BRANCH_DEFAULTS_MIGRATION_MARKER,
DEVELOPER_METRIC_DISPLAY_KEYS,
DEVELOPER_METRIC_DISPLAY_MIGRATION_MARKER,
DEFAULT_LANE_CHANGE_SMOOTHING,
DEFAULT_STEER_KP,
LANE_CHANGE_SMOOTHING_MIGRATION_MARKER,
LAUNCH_PARAM_MIGRATION_MARKER,
LATERAL_METHOD_REBRAND_MIGRATION_MARKER,
MARKER_DIRNAME,
@@ -309,3 +311,33 @@ def test_apply_launch_param_migrations_preserves_developer_metric_display_after_
for key in DEVELOPER_METRIC_DISPLAY_KEYS:
assert params.get_bool(key)
def test_apply_launch_param_migrations_updates_legacy_lane_change_smoothing_once(tmp_path):
params = FileBackedFakeParams(tmp_path / "params")
params.put_int("LaneChangeSmoothing", 10)
apply_launch_param_migrations(params)
assert params.get_int("LaneChangeSmoothing") == DEFAULT_LANE_CHANGE_SMOOTHING
assert marker_path(tmp_path, LANE_CHANGE_SMOOTHING_MIGRATION_MARKER).is_file()
def test_apply_launch_param_migrations_preserves_custom_lane_change_smoothing(tmp_path):
params = FileBackedFakeParams(tmp_path / "params")
params.put_int("LaneChangeSmoothing", 7)
apply_launch_param_migrations(params)
assert params.get_int("LaneChangeSmoothing") == 7
assert marker_path(tmp_path, LANE_CHANGE_SMOOTHING_MIGRATION_MARKER).is_file()
def test_apply_launch_param_migrations_does_not_reapply_lane_change_smoothing_after_marker(tmp_path):
params = FileBackedFakeParams(tmp_path / "params")
params.put_int("LaneChangeSmoothing", 10)
marker_path(tmp_path, LANE_CHANGE_SMOOTHING_MIGRATION_MARKER).touch()
apply_launch_param_migrations(params)
assert params.get_int("LaneChangeSmoothing") == 10
@@ -0,0 +1,622 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
from openpilot.tools.lib.logreader import LogReader, ReadMode, parse_direct, parse_indirect
from openpilot.tools.lib.route import SegmentRange
ACTIVE_DEADBAND = 0.08
SOURCE_SWITCH_SCORE = 1.5
DEFAULT_BOOKMARK_BEFORE = 12.0
DEFAULT_BOOKMARK_AFTER = 3.0
@dataclass
class LongitudinalSample:
segment: int
time_s: float
mono_time: int
long_active: bool = False
v_ego: float = 0.0
a_ego: float = 0.0
gas_pressed: bool = False
brake_pressed: bool = False
plan_accel: float = 0.0
command_accel: float = 0.0
output_accel: float = 0.0
p_term: float = 0.0
i_term: float = 0.0
f_term: float = 0.0
long_state: str = "unknown"
source: str = "unknown"
should_stop: bool = False
allow_throttle: bool = True
allow_brake: bool = True
has_lead: bool = False
lead_status: bool = False
lead_distance: float | None = None
lead_relative_velocity: float = 0.0
lead_velocity: float = 0.0
lead_acceleration: float = 0.0
lead_probability: float = 0.0
lead_radar: bool = False
lead_track_id: int = -1
lead_two_status: bool = False
lead_two_distance: float | None = None
experimental_mode: bool = False
forcing_stop: bool = False
red_light: bool = False
tracking_lead: bool = False
curve_controller_active: bool = False
cruise_target: float = 0.0
desired_follow_distance: float = 0.0
@dataclass
class Finding:
kind: str
severity: str
score: float
evidence: list[str] = field(default_factory=list)
@dataclass
class WindowReport:
label: str
segment: int
event_time_s: float
start_time_s: float
end_time_s: float
sample_count: int
summary: dict[str, Any]
findings: list[Finding]
@dataclass
class SegmentData:
segment: int
source: str
samples: list[LongitudinalSample]
bookmarks: list[float]
car_params: dict[str, Any] | None
settings: dict[str, Any]
def safe_float(value: Any, default: float = 0.0) -> float:
try:
result = float(value)
except (TypeError, ValueError):
return default
return result if math.isfinite(result) else default
def safe_attr(obj: Any, name: str, default: Any = None) -> Any:
if obj is None:
return default
try:
return getattr(obj, name)
except (AttributeError, RuntimeError):
return default
def state_changes(values: list[Any]) -> int:
return sum(current != previous for previous, current in zip(values, values[1:], strict=False))
def threshold_sign_changes(values: list[float], deadband: float = ACTIVE_DEADBAND) -> int:
signs: list[int] = []
for value in values:
sign = 1 if value > deadband else -1 if value < -deadband else 0
if sign and (not signs or sign != signs[-1]):
signs.append(sign)
return max(0, len(signs) - 1)
def direction_reversals(values: list[float], minimum_step: float = 0.06) -> int:
directions: list[int] = []
for previous, current in zip(values, values[1:], strict=False):
delta = current - previous
direction = 1 if delta > minimum_step else -1 if delta < -minimum_step else 0
if direction and (not directions or direction != directions[-1]):
directions.append(direction)
return max(0, len(directions) - 1)
def maximum_rate(values: list[float], times: list[float]) -> float:
rates = [
abs((current - previous) / max(time - previous_time, 1e-3))
for previous, current, previous_time, time in zip(values, values[1:], times, times[1:], strict=False)
if 0.0 < time - previous_time < 0.5
]
return max(rates, default=0.0)
def percentile(values: list[float], quantile: float, default: float = 0.0) -> float:
return float(np.percentile(values, quantile)) if values else default
def severity_for(kind: str, score: float) -> str:
if kind == "unsafe_stop_release" and score >= 4.0:
return "critical"
if kind == "late_lead_response" and score >= 5.0:
return "high"
if score >= 7.0:
return "high"
if score >= 3.0:
return "medium"
return "low"
def analyze_samples(samples: list[LongitudinalSample], label: str, event_time_s: float) -> WindowReport:
if not samples:
raise ValueError("Cannot analyze an empty longitudinal window")
active_samples = [sample for sample in samples if sample.long_active]
relevant = active_samples if active_samples else samples
times = [sample.time_s for sample in relevant]
plans = [sample.plan_accel for sample in relevant]
commands = [sample.command_accel for sample in relevant]
actual = [sample.a_ego for sample in relevant]
integrals = [sample.i_term for sample in relevant]
sources = [sample.source for sample in relevant]
lead_statuses = [sample.lead_status for sample in relevant]
experiment_states = [sample.experimental_mode for sample in relevant]
stop_states = [sample.should_stop for sample in relevant]
tracking_states = [sample.tracking_lead for sample in relevant]
plan_sign_flips = threshold_sign_changes(plans)
command_sign_flips = threshold_sign_changes(commands)
plan_reversals = direction_reversals(plans)
command_reversals = direction_reversals(commands)
source_switches = state_changes(sources)
lead_status_flips = state_changes(lead_statuses)
experimental_flips = state_changes(experiment_states)
stop_flips = state_changes(stop_states)
tracking_flips = state_changes(tracking_states)
lead_track_ids = [sample.lead_track_id for sample in relevant if sample.lead_status and sample.lead_track_id >= 0]
lead_track_switches = state_changes(lead_track_ids)
lead_velocity_jumps = sum(
abs(current.lead_velocity - previous.lead_velocity) > 1.25
for previous, current in zip(relevant, relevant[1:], strict=False)
if previous.lead_status and current.lead_status and 0.0 < current.time_s - previous.time_s < 0.5
)
lead_distance_jumps = sum(
abs((current.lead_distance or 0.0) - (previous.lead_distance or 0.0)) > 6.0
for previous, current in zip(relevant, relevant[1:], strict=False)
if previous.lead_status and current.lead_status and 0.0 < current.time_s - previous.time_s < 0.5
)
stale_positive_i = [sample for sample in relevant if sample.plan_accel < -0.08 and sample.i_term > 0.08]
stale_negative_i = [
sample for sample in relevant
if sample.plan_accel > -0.05 and sample.i_term < -0.08 and sample.command_accel < sample.plan_accel - 0.10
]
opposite_commands = [
sample for sample in relevant
if abs(sample.plan_accel) > 0.10 and abs(sample.command_accel) > 0.10 and sample.plan_accel * sample.command_accel < 0.0
]
interface_adjustments = [abs(sample.output_accel - sample.command_accel) for sample in relevant]
response_errors = [abs(sample.a_ego - sample.output_accel) for sample in relevant]
late_response_shortfalls: list[float] = []
minimum_ttc: float | None = None
for sample in relevant:
if not sample.lead_status or sample.lead_distance is None:
continue
closing_speed = max(-sample.lead_relative_velocity, sample.v_ego - sample.lead_velocity, 0.0)
if closing_speed < 1.5:
continue
ttc = sample.lead_distance / max(closing_speed, 0.1)
minimum_ttc = ttc if minimum_ttc is None else min(minimum_ttc, ttc)
if ttc >= 4.0:
continue
usable_gap = max(sample.lead_distance - 4.0, 1.0)
required_accel = -(closing_speed ** 2) / (2.0 * usable_gap)
late_response_shortfalls.append(max(0.0, sample.command_accel - required_accel))
unsafe_stop_release = [
sample for sample in relevant
if (sample.should_stop and sample.command_accel > 0.10) or (
sample.lead_status and sample.lead_distance is not None and sample.lead_distance < 12.0 and
sample.lead_velocity < 0.5 and sample.v_ego < 5.0 and sample.command_accel > 0.15
)
]
max_plan_jerk = maximum_rate(plans, times)
max_command_jerk = maximum_rate(commands, times)
controller_gap_p95 = percentile([abs(command - plan) for command, plan in zip(commands, plans, strict=True)], 95)
response_gap_p95 = percentile(response_errors, 95)
interface_gap_p95 = percentile(interface_adjustments, 95)
stale_i_ratio = (len(stale_positive_i) + len(stale_negative_i)) / max(len(relevant), 1)
opposite_ratio = len(opposite_commands) / max(len(relevant), 1)
late_shortfall = max(late_response_shortfalls, default=0.0)
category_scores = {
"unsafe_stop_release": len(unsafe_stop_release) * 1.5,
"late_lead_response": late_shortfall * 2.0 + (1.0 if minimum_ttc is not None and minimum_ttc < 3.0 else 0.0),
"mode_arbitration": experimental_flips * 3.0 + stop_flips * 2.0 + tracking_flips,
"lead_instability": (
source_switches * SOURCE_SWITCH_SCORE + lead_status_flips + lead_track_switches * 2.0 +
lead_velocity_jumps * 0.75 + lead_distance_jumps
),
"controller_integrator": stale_i_ratio * 12.0 + opposite_ratio * 10.0 + max(0.0, controller_gap_p95 - 0.20) * 3.0,
"planner_chatter": (
plan_sign_flips * 1.5 + plan_reversals * 0.65 + command_sign_flips * 0.6 +
max(0.0, max_plan_jerk - 1.5) * 0.35
),
"vehicle_response": max(0.0, response_gap_p95 - 0.35) * 2.5 + max(0.0, interface_gap_p95 - 0.10) * 4.0,
}
evidence = {
"unsafe_stop_release": [
f"{len(unsafe_stop_release)} samples commanded positive acceleration while a stop hold or close stopped lead was active",
],
"late_lead_response": [
f"minimum TTC={minimum_ttc:.2f}s" if minimum_ttc is not None else "no closing lead TTC available",
f"maximum kinematic decel shortfall={late_shortfall:.2f} m/s^2",
],
"mode_arbitration": [
f"experimental flips={experimental_flips}, shouldStop flips={stop_flips}, trackingLead flips={tracking_flips}",
],
"lead_instability": [
f"source switches={source_switches}, lead status flips={lead_status_flips}, radar track switches={lead_track_switches}",
f"lead velocity jumps={lead_velocity_jumps}, lead distance jumps={lead_distance_jumps}",
],
"controller_integrator": [
f"stale-I samples={len(stale_positive_i) + len(stale_negative_i)}/{len(relevant)}, opposite command samples={len(opposite_commands)}",
f"planner-to-command gap p95={controller_gap_p95:.2f} m/s^2, I range={min(integrals):+.2f}..{max(integrals):+.2f}",
],
"planner_chatter": [
f"plan sign flips={plan_sign_flips}, direction reversals={plan_reversals}, max plan jerk={max_plan_jerk:.2f} m/s^3",
f"command sign flips={command_sign_flips}, direction reversals={command_reversals}, max command jerk={max_command_jerk:.2f} m/s^3",
],
"vehicle_response": [
f"command-to-applied gap p95={interface_gap_p95:.2f} m/s^2",
f"applied-to-measured acceleration gap p95={response_gap_p95:.2f} m/s^2",
],
}
findings = [
Finding(kind=kind, severity=severity_for(kind, score), score=round(score, 3), evidence=evidence[kind])
for kind, score in category_scores.items()
if score >= 1.0
]
findings.sort(key=lambda finding: finding.score, reverse=True)
lead_samples = [sample for sample in relevant if sample.lead_status and sample.lead_distance is not None]
summary = {
"activeSamples": len(active_samples),
"speedRangeMps": [round(min(sample.v_ego for sample in relevant), 3), round(max(sample.v_ego for sample in relevant), 3)],
"planAccelRange": [round(min(plans), 3), round(max(plans), 3)],
"commandAccelRange": [round(min(commands), 3), round(max(commands), 3)],
"actualAccelRange": [round(min(actual), 3), round(max(actual), 3)],
"leadDistanceRange": None if not lead_samples else [
round(min(sample.lead_distance for sample in lead_samples if sample.lead_distance is not None), 3),
round(max(sample.lead_distance for sample in lead_samples if sample.lead_distance is not None), 3),
],
"sources": sorted(set(sources)),
"minimumTtc": None if minimum_ttc is None else round(minimum_ttc, 3),
"maxPlanJerk": round(max_plan_jerk, 3),
"maxCommandJerk": round(max_command_jerk, 3),
}
return WindowReport(
label=label,
segment=samples[0].segment,
event_time_s=round(event_time_s, 3),
start_time_s=round(samples[0].time_s, 3),
end_time_s=round(samples[-1].time_s, 3),
sample_count=len(relevant),
summary=summary,
findings=findings,
)
def parse_settings(serialized: str) -> dict[str, Any]:
if not serialized:
return {}
try:
settings = json.loads(serialized)
except (TypeError, json.JSONDecodeError):
return {}
if not isinstance(settings, dict):
return {}
keywords = ("accel", "decel", "follow", "jerk", "longitudinal", "personality", "smooth", "truck")
return {
key: value for key, value in sorted(settings.items())
if any(keyword in key.lower() for keyword in keywords)
}
def snapshot_car_params(CP: Any) -> dict[str, Any]:
tuning = safe_attr(CP, "longitudinalTuning")
return {
"brand": str(safe_attr(CP, "brand", "unknown")),
"carFingerprint": str(safe_attr(CP, "carFingerprint", "unknown")),
"openpilotLongitudinalControl": bool(safe_attr(CP, "openpilotLongitudinalControl", False)),
"longitudinalActuatorDelay": safe_float(safe_attr(CP, "longitudinalActuatorDelay", 0.0)),
"kpBP": list(safe_attr(tuning, "kpBP", [])),
"kpV": list(safe_attr(tuning, "kpV", [])),
"kiBP": list(safe_attr(tuning, "kiBP", [])),
"kiV": list(safe_attr(tuning, "kiV", [])),
"kf": safe_float(safe_attr(tuning, "kfDEPRECATED", 0.0)),
}
def make_sample(segment: int, segment_start_ns: int, mono_time: int, latest: dict[str, Any]) -> LongitudinalSample | None:
required = ("carState", "carControl", "controlsState", "radarState", "starpilotPlan", "longitudinalPlan")
if not all(service in latest for service in required):
return None
car_state = latest["carState"]
car_control = latest["carControl"]
controls_state = latest["controlsState"]
radar_state = latest["radarState"]
starpilot_plan = latest["starpilotPlan"]
long_plan = latest["longitudinalPlan"]
car_output = latest.get("carOutput")
lead = radar_state.leadOne
lead_two = radar_state.leadTwo
command_accel = safe_float(safe_attr(safe_attr(car_control, "actuators"), "accel", 0.0))
output_accel = safe_float(safe_attr(safe_attr(car_output, "actuatorsOutput"), "accel", command_accel), command_accel)
lead_status = bool(safe_attr(lead, "status", False))
lead_two_status = bool(safe_attr(lead_two, "status", False))
return LongitudinalSample(
segment=segment,
time_s=(mono_time - segment_start_ns) / 1e9,
mono_time=mono_time,
long_active=bool(safe_attr(car_control, "longActive", False)),
v_ego=safe_float(safe_attr(car_state, "vEgo", 0.0)),
a_ego=safe_float(safe_attr(car_state, "aEgo", 0.0)),
gas_pressed=bool(safe_attr(car_state, "gasPressed", False)),
brake_pressed=bool(safe_attr(car_state, "brakePressed", False)),
plan_accel=safe_float(safe_attr(long_plan, "aTarget", 0.0)),
command_accel=command_accel,
output_accel=output_accel,
p_term=safe_float(safe_attr(controls_state, "upAccelCmd", 0.0)),
i_term=safe_float(safe_attr(controls_state, "uiAccelCmd", 0.0)),
f_term=safe_float(safe_attr(controls_state, "ufAccelCmd", 0.0)),
long_state=str(safe_attr(controls_state, "longControlState", "unknown")),
source=str(safe_attr(long_plan, "longitudinalPlanSource", "unknown")),
should_stop=bool(safe_attr(long_plan, "shouldStop", False)),
allow_throttle=bool(safe_attr(long_plan, "allowThrottle", True)),
allow_brake=bool(safe_attr(long_plan, "allowBrake", True)),
has_lead=bool(safe_attr(long_plan, "hasLead", False)),
lead_status=lead_status,
lead_distance=safe_float(safe_attr(lead, "dRel", 0.0)) if lead_status else None,
lead_relative_velocity=safe_float(safe_attr(lead, "vRel", 0.0)),
lead_velocity=safe_float(safe_attr(lead, "vLead", 0.0)),
lead_acceleration=safe_float(safe_attr(lead, "aLeadK", 0.0)),
lead_probability=safe_float(safe_attr(lead, "modelProb", 0.0)),
lead_radar=bool(safe_attr(lead, "radar", False)),
lead_track_id=int(safe_attr(lead, "radarTrackId", -1)),
lead_two_status=lead_two_status,
lead_two_distance=safe_float(safe_attr(lead_two, "dRel", 0.0)) if lead_two_status else None,
experimental_mode=bool(safe_attr(starpilot_plan, "experimentalMode", False)),
forcing_stop=bool(safe_attr(starpilot_plan, "forcingStop", False)),
red_light=bool(safe_attr(starpilot_plan, "redLight", False)),
tracking_lead=bool(safe_attr(starpilot_plan, "trackingLead", False)),
curve_controller_active=bool(safe_attr(starpilot_plan, "cscControllingSpeed", False)),
cruise_target=safe_float(safe_attr(starpilot_plan, "vCruise", 0.0)),
desired_follow_distance=safe_float(safe_attr(starpilot_plan, "desiredFollowDistance", 0.0)),
)
def analyze_segment(identifier: str, segment: int, mode: ReadMode) -> SegmentData:
reader = LogReader(identifier, default_mode=mode, sort_by_time=True)
source = ",".join(Path(path).name or path.rsplit("/", 1)[-1] for path in reader.logreader_identifiers)
latest: dict[str, Any] = {}
samples: list[LongitudinalSample] = []
bookmarks: list[float] = []
car_params = None
settings: dict[str, Any] = {}
segment_start_ns: int | None = None
for msg in reader:
mono_time = int(msg.logMonoTime)
if segment_start_ns is None:
segment_start_ns = mono_time
which = msg.which()
if which in ("userBookmark", "bookmarkButton"):
bookmark_time = (mono_time - segment_start_ns) / 1e9
if not bookmarks or bookmark_time - bookmarks[-1] > 0.5:
bookmarks.append(bookmark_time)
continue
if which == "carParams" and car_params is None:
car_params = snapshot_car_params(msg.carParams)
if which in ("carState", "carControl", "carOutput", "controlsState", "radarState", "starpilotPlan", "longitudinalPlan"):
latest[which] = getattr(msg, which)
if which == "starpilotPlan":
settings = parse_settings(str(safe_attr(msg.starpilotPlan, "starpilotToggles", ""))) or settings
if which == "longitudinalPlan" and segment_start_ns is not None:
sample = make_sample(segment, segment_start_ns, mono_time, latest)
if sample is not None:
samples.append(sample)
return SegmentData(
segment=segment,
source=source,
samples=samples,
bookmarks=bookmarks,
car_params=car_params,
settings=settings,
)
def point_anomaly_scores(samples: list[LongitudinalSample]) -> list[tuple[float, float]]:
scores: list[tuple[float, float]] = []
for previous, current in zip(samples, samples[1:], strict=False):
if not current.long_active:
continue
score = 0.0
score += max(0.0, abs(current.plan_accel - previous.plan_accel) - 0.12) * 4.0
score += max(0.0, abs(current.command_accel - previous.command_accel) - 0.15) * 3.0
score += SOURCE_SWITCH_SCORE if current.source != previous.source else 0.0
score += 1.0 if current.lead_status != previous.lead_status else 0.0
score += 2.0 if current.experimental_mode != previous.experimental_mode else 0.0
score += 1.5 if current.should_stop != previous.should_stop else 0.0
if current.plan_accel < -0.08 and current.i_term > 0.08:
score += 1.0
if current.plan_accel > -0.05 and current.i_term < -0.08 and current.command_accel < current.plan_accel - 0.10:
score += 1.0
if score >= 1.0:
scores.append((current.time_s, score))
return scores
def anomaly_episode_times(samples: list[LongitudinalSample], limit: int) -> list[float]:
points = point_anomaly_scores(samples)
if not points:
return []
episodes: list[list[tuple[float, float]]] = [[points[0]]]
for point in points[1:]:
if point[0] - episodes[-1][-1][0] <= 1.5:
episodes[-1].append(point)
else:
episodes.append([point])
ranked = sorted(
((max(episode, key=lambda item: item[1])[0], sum(item[1] for item in episode)) for episode in episodes),
key=lambda item: item[1],
reverse=True,
)
return [time_s for time_s, _ in ranked[:limit]]
def window_samples(samples: list[LongitudinalSample], event_time_s: float, before: float, after: float) -> list[LongitudinalSample]:
return [sample for sample in samples if event_time_s - before <= sample.time_s <= event_time_s + after]
def resolve_segments(identifier: str, mode: ReadMode) -> tuple[str, list[tuple[int, str]]]:
if parse_direct(identifier) is not None:
return identifier, [(0, identifier)]
normalized = parse_indirect(identifier)
segment_range = SegmentRange(normalized)
route = segment_range.route_name
requests = [(segment, f"{route}/{segment}") for segment in segment_range.seg_idxs]
return route, requests
def analyze_route(identifier: str, mode: ReadMode, before: float, after: float, top: int) -> dict[str, Any]:
route, segment_requests = resolve_segments(identifier, mode)
segments = [analyze_segment(request, segment, mode) for segment, request in segment_requests]
reports: list[WindowReport] = []
for segment_data in segments:
for bookmark_number, bookmark_time in enumerate(segment_data.bookmarks, start=1):
samples = window_samples(segment_data.samples, bookmark_time, before, after)
if samples:
reports.append(analyze_samples(samples, f"bookmark {bookmark_number}", bookmark_time))
episode_times = anomaly_episode_times(segment_data.samples, top)
for episode_number, event_time in enumerate(episode_times, start=1):
if any(abs(event_time - bookmark) <= before for bookmark in segment_data.bookmarks):
continue
samples = window_samples(segment_data.samples, event_time, min(before, 5.0), min(after, 2.0))
if samples:
reports.append(analyze_samples(samples, f"route anomaly {episode_number}", event_time))
reports.sort(key=lambda report: (report.segment, report.event_time_s, report.label))
car_params = next((segment.car_params for segment in segments if segment.car_params), None)
settings = next((segment.settings for segment in reversed(segments) if segment.settings), {})
return {
"route": route,
"carParams": car_params,
"settings": settings,
"segments": [
{
"segment": segment.segment,
"source": segment.source,
"samples": len(segment.samples),
"bookmarks": segment.bookmarks,
}
for segment in segments
],
"reports": [asdict(report) for report in reports],
}
def print_report(payload: dict[str, Any]) -> None:
print(f"route={payload['route']}")
car_params = payload.get("carParams")
if car_params:
vehicle_line = f"vehicle={car_params['carFingerprint']} brand={car_params['brand']}"
delay_line = f"longDelay={car_params['longitudinalActuatorDelay']:.3f}s openpilotLong={car_params['openpilotLongitudinalControl']}"
print(f"{vehicle_line} {delay_line}")
print(f"longTune kp={car_params['kpV']} ki={car_params['kiV']} kf={car_params['kf']}")
else:
print("vehicle=unknown (carParams unavailable in selected segments)")
for segment in payload["segments"]:
segment_line = f"segment={segment['segment']} source={segment['source']} samples={segment['samples']}"
print(f"{segment_line} bookmarks={len(segment['bookmarks'])}")
settings = payload.get("settings", {})
if settings:
print("settings=" + json.dumps(settings, sort_keys=True))
reports = payload["reports"]
if not reports:
print("No bookmark windows or route-wide anomalies were found.")
return
for report in reports:
primary = report["findings"][0] if report["findings"] else None
primary_text = "no deterministic fault" if primary is None else f"{primary['kind']} ({primary['severity']}, score={primary['score']:.2f})"
event_line = f"\nseg {report['segment']} {report['label']} @{report['event_time_s']:.2f}s: {primary_text}"
window_line = f" window={report['start_time_s']:.2f}..{report['end_time_s']:.2f}s samples={report['sample_count']}"
accel_line = (
f"speed={report['summary']['speedRangeMps']}m/s plan={report['summary']['planAccelRange']} "
+ f"cmd={report['summary']['commandAccelRange']} actual={report['summary']['actualAccelRange']}"
)
lead_line = f" leadRange={report['summary']['leadDistanceRange']}m sources={report['summary']['sources']}"
print(event_line)
print(f"{window_line} {accel_line}")
print(f"{lead_line} minTTC={report['summary']['minimumTtc']}s")
for finding in report["findings"][:3]:
print(f" {finding['kind']}: {finding['severity']} score={finding['score']:.2f}")
for item in finding["evidence"]:
print(f" {item}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Diagnose longitudinal behavior around route bookmarks and automatically detected anomalies.")
parser.add_argument("routes", nargs="+", help="Comma/Konik route, segment range, Connect URL, or local log file")
parser.add_argument("--mode", choices=("auto", "qlog", "rlog"), default="auto")
parser.add_argument("--before", type=float, default=DEFAULT_BOOKMARK_BEFORE, help="Seconds before each bookmark to inspect")
parser.add_argument("--after", type=float, default=DEFAULT_BOOKMARK_AFTER, help="Seconds after each bookmark to inspect")
parser.add_argument("--top", type=int, default=5, help="Maximum route-wide anomaly episodes per segment")
parser.add_argument("--json-out", type=Path, help="Optional JSON report path")
return parser.parse_args()
def main() -> None:
args = parse_args()
mode = {"auto": ReadMode.AUTO, "qlog": ReadMode.QLOG, "rlog": ReadMode.RLOG}[args.mode]
payloads = [analyze_route(route, mode, args.before, args.after, args.top) for route in args.routes]
for index, payload in enumerate(payloads):
if index:
print("\n" + "=" * 80 + "\n")
print_report(payload)
if args.json_out:
args.json_out.parent.mkdir(parents=True, exist_ok=True)
args.json_out.write_text(json.dumps(payloads, indent=2, sort_keys=True) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
@@ -0,0 +1,136 @@
from dataclasses import replace
import pytest
from openpilot.tools.longitudinal.analyze_route_longitudinal import (
LongitudinalSample,
analyze_samples,
anomaly_episode_times,
parse_settings,
threshold_sign_changes,
)
def make_samples(count: int = 20, **overrides) -> list[LongitudinalSample]:
base = LongitudinalSample(
segment=4,
time_s=0.0,
mono_time=0,
long_active=True,
v_ego=20.0,
a_ego=0.1,
plan_accel=0.1,
command_accel=0.1,
output_accel=0.1,
source="cruise",
)
return [
replace(base, time_s=index * 0.05, mono_time=index * 50_000_000, **overrides)
for index in range(count)
]
def primary_kind(samples: list[LongitudinalSample]) -> str | None:
report = analyze_samples(samples, "test", samples[-1].time_s)
return report.findings[0].kind if report.findings else None
def test_threshold_sign_changes_ignores_zero_crossing_noise():
assert threshold_sign_changes([0.2, 0.03, -0.02, 0.1, -0.2]) == 1
def test_planner_chatter_is_identified_from_repeated_target_reversals():
samples = make_samples()
for index, sample in enumerate(samples):
accel = 0.45 if index % 2 == 0 else -0.45
samples[index] = replace(sample, plan_accel=accel, command_accel=accel, output_accel=accel, a_ego=accel * 0.8)
assert primary_kind(samples) == "planner_chatter"
def test_controller_integrator_is_identified_when_command_opposes_plan():
samples = make_samples(plan_accel=0.25, command_accel=-0.25, output_accel=-0.25, a_ego=-0.2, i_term=-0.40)
assert primary_kind(samples) == "controller_integrator"
def test_lead_instability_is_identified_from_source_and_track_churn():
samples = make_samples(12, lead_status=True, lead_distance=45.0, lead_velocity=18.0, source="lead0", lead_track_id=10)
for index, sample in enumerate(samples):
samples[index] = replace(
sample,
source="lead0" if index % 2 == 0 else "lead1",
lead_track_id=10 if index % 2 == 0 else 11,
lead_velocity=18.0 if index % 2 == 0 else 20.0,
)
assert primary_kind(samples) == "lead_instability"
def test_unsafe_stop_release_is_critical():
samples = make_samples(
6,
v_ego=2.0,
plan_accel=0.3,
command_accel=0.4,
output_accel=0.4,
a_ego=0.2,
should_stop=True,
lead_status=True,
lead_distance=7.0,
lead_velocity=0.0,
)
report = analyze_samples(samples, "test", samples[-1].time_s)
assert report.findings[0].kind == "unsafe_stop_release"
assert report.findings[0].severity == "critical"
def test_late_lead_response_is_identified_from_ttc_and_decel_shortfall():
samples = make_samples(
10,
v_ego=20.0,
plan_accel=0.0,
command_accel=0.0,
output_accel=0.0,
a_ego=0.0,
lead_status=True,
lead_distance=18.0,
lead_relative_velocity=-8.0,
lead_velocity=12.0,
)
assert primary_kind(samples) == "late_lead_response"
def test_normal_steady_follow_has_no_deterministic_finding():
samples = make_samples(
lead_status=True,
lead_distance=40.0,
lead_relative_velocity=0.0,
lead_velocity=20.0,
source="lead0",
)
report = analyze_samples(samples, "test", samples[-1].time_s)
assert report.findings == []
def test_anomaly_episodes_group_nearby_points():
samples = make_samples(80)
samples[10] = replace(samples[10], plan_accel=-0.5)
samples[11] = replace(samples[11], plan_accel=0.5)
samples[60] = replace(samples[60], plan_accel=-0.5)
episodes = anomaly_episode_times(samples, limit=5)
assert len(episodes) == 2
assert episodes[0] == pytest.approx(0.55)
def test_parse_settings_keeps_only_longitudinal_context():
settings = parse_settings('{"FollowDistance": 1.5, "AccelerationProfile": "eco", "LaneWidth": 3.5}')
assert settings == {"AccelerationProfile": "eco", "FollowDistance": 1.5}