diff --git a/.github/ci/tesla_preap_longitudinal_mutations.py b/.github/ci/tesla_preap_longitudinal_mutations.py index f44743a425421f..708692ce6232bd 100644 --- a/.github/ci/tesla_preap_longitudinal_mutations.py +++ b/.github/ci/tesla_preap_longitudinal_mutations.py @@ -3,18 +3,120 @@ import sys import tempfile import xml.etree.ElementTree as ET +from dataclasses import dataclass from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] -SOURCE_PATH = REPO_ROOT / "opendbc_repo" / "opendbc" / "car" / "tesla" / "preap" / "constants.py" -ORIGINAL_KI = b"PEDAL_LONG_KI_V = [0.0, 0.0, 0.0, 0.0]\n" -HISTORICAL_KI = b"PEDAL_LONG_KI_V = [0.05, 0.08, 0.10, 0.15]\n" -TEST_PATH = "selfdrive/controls/tests/test_tesla_preap_longcontrol.py" -MUTATION_TEST_NODES = ( - f"{TEST_PATH}::test_vdas_receives_route_shaped_planner_target_trace_unchanged", - f"{TEST_PATH}::test_road_load_history_cannot_reverse_finite_jerk_negative_planner_target", - f"{TEST_PATH}::test_negative_planner_target_reaches_regen_side_of_coast_anchor", +LONGCONTROL_TEST_PATH = "selfdrive/controls/tests/test_tesla_preap_longcontrol.py" +FOLLOWING_TEST_PATH = "selfdrive/controls/tests/test_tesla_preap_following.py" +NOISE_GATE_TEST_NODE = ( + "opendbc_repo/opendbc/car/tesla/preap/tests/test_virtual_das.py::TestInnerPID::" + + "test_sub_deadband_sign_changing_noise_does_not_accumulate_residual_authority" +) + + +@dataclass(frozen=True) +class HistoricalMutation: + name: str + source_path: str + original: bytes + replacement: bytes + test_nodes: tuple[str, ...] + + +MUTATIONS = ( + HistoricalMutation( + name="historical-outer-ki", + source_path="opendbc_repo/opendbc/car/tesla/preap/constants.py", + original=b"PEDAL_LONG_KI_V = [0.0, 0.0, 0.0, 0.0]\n", + replacement=b"PEDAL_LONG_KI_V = [0.05, 0.08, 0.10, 0.15]\n", + test_nodes=( + f"{LONGCONTROL_TEST_PATH}::test_vdas_receives_route_shaped_planner_target_trace_unchanged", + f"{LONGCONTROL_TEST_PATH}::test_road_load_history_cannot_reverse_finite_jerk_negative_planner_target", + f"{LONGCONTROL_TEST_PATH}::test_negative_planner_target_reaches_regen_side_of_coast_anchor", + ), + ), + HistoricalMutation( + name="adaptive-follow-cap-bypassed", + source_path="selfdrive/controls/lib/longitudinal_planner.py", + original=( + b" cap_strength = get_preap_follow_cap_strength(" + + b"v_ego, lead.dRel, lead.vLead, self.t_follow)\n" + ), + replacement=b" cap_strength = 0.0\n", + test_nodes=( + f"{FOLLOWING_TEST_PATH}::" + + "test_planner_adaptive_cap_changes_the_delivered_acceleration_for_unequal_speed_lead", + ), + ), + HistoricalMutation( + name="longcontrol-feedforward-coupling-bypassed", + source_path="selfdrive/controls/lib/longcontrol.py", + original=b" feedforward=a_target)\n", + replacement=b" feedforward=0.0)\n", + test_nodes=( + f"{FOLLOWING_TEST_PATH}::test_max_follow_full_closed_loop_recovers_gap_with_production_fallback", + ), + ), + HistoricalMutation( + name="hard-inner-error-deadband-restored", + source_path="opendbc_repo/opendbc/car/tesla/preap/virtual_das.py", + original=b" error = self._gate_pid_error_noise(error, freeze_integrator)\n", + replacement=( + b" if abs(error) < PID_ERROR_DEADBAND:\n" + + b" error = 0.0\n" + ), + test_nodes=( + f"{FOLLOWING_TEST_PATH}::test_max_follow_full_closed_loop_recovers_gap_with_production_fallback", + ), + ), + HistoricalMutation( + name="inner-error-noise-gate-call-bypassed", + source_path="opendbc_repo/opendbc/car/tesla/preap/virtual_das.py", + original=b" error = self._gate_pid_error_noise(error, freeze_integrator)\n", + replacement=b" error = error\n", + test_nodes=(NOISE_GATE_TEST_NODE,), + ), + HistoricalMutation( + name="negative-handoff-integral-slew-regressed", + source_path="opendbc_repo/opendbc/car/tesla/preap/virtual_das.py", + original=b"NEGATIVE_HANDOFF_INTEGRAL_SLEW = 0.25 # m/s\xc2\xb3\n", + replacement=b"NEGATIVE_HANDOFF_INTEGRAL_SLEW = 0.20 # m/s\xc2\xb3\n", + test_nodes=( + f"{LONGCONTROL_TEST_PATH}::test_negative_planner_target_reaches_regen_side_of_coast_anchor", + ), + ), + HistoricalMutation( + name="grade-effort-compensation-removed", + source_path="opendbc_repo/opendbc/car/tesla/preap/virtual_das.py", + original=b" a_limited + steady_grade_compensation + transient_pitch_compensation,\n", + replacement=b" a_limited,\n", + test_nodes=( + f"{FOLLOWING_TEST_PATH}::test_plant_aligned_full_closed_loop_grade_compensation_holds_speed", + ), + ), + HistoricalMutation( + name="grade-effort-compensation-sign-flipped", + source_path="opendbc_repo/opendbc/car/tesla/preap/virtual_das.py", + original=b" a_limited + steady_grade_compensation + transient_pitch_compensation,\n", + replacement=b" a_limited - steady_grade_compensation - transient_pitch_compensation,\n", + test_nodes=( + f"{FOLLOWING_TEST_PATH}::test_plant_aligned_full_closed_loop_grade_compensation_holds_speed", + ), + ), + HistoricalMutation( + name="grade-effort-compensation-doubled", + source_path="opendbc_repo/opendbc/car/tesla/preap/virtual_das.py", + original=b" a_limited + steady_grade_compensation + transient_pitch_compensation,\n", + replacement=( + b" a_limited + 2.0 * steady_grade_compensation " + + b"+ 2.0 * transient_pitch_compensation,\n" + ), + test_nodes=( + f"{FOLLOWING_TEST_PATH}::test_plant_aligned_full_closed_loop_grade_compensation_holds_speed", + ), + ), ) @@ -62,11 +164,26 @@ def has_only_assertion_failures(testcases: list[ET.Element]) -> bool: ) +def apply_mutation(mutation: HistoricalMutation) -> tuple[Path, bytes]: + source_path = REPO_ROOT / mutation.source_path + original_source = source_path.read_bytes() + match_count = original_source.count(mutation.original) + if match_count != 1: + raise RuntimeError( + f"{mutation.name}: expected one source match in {mutation.source_path}, found {match_count}" + ) + source_path.write_bytes(original_source.replace(mutation.original, mutation.replacement, 1)) + return source_path, original_source + + def main() -> int: with tempfile.TemporaryDirectory(prefix="tesla-preap-parent-mutation-") as temp_dir: temp_root = Path(temp_dir) baseline_xml = temp_root / "baseline.xml" - baseline = run_pytest((TEST_PATH,), baseline_xml) + baseline = run_pytest( + (LONGCONTROL_TEST_PATH, FOLLOWING_TEST_PATH, NOISE_GATE_TEST_NODE), + baseline_xml, + ) if baseline.returncode != 0: print("BASELINE FAILED: parent longitudinal regression tests did not pass") print(baseline.stdout) @@ -78,50 +195,59 @@ def main() -> int: return 1 print(f"BASELINE PASS: {len(baseline_testcases)} parent tests") - original_source = SOURCE_PATH.read_bytes() - mutation_result = None - mutation_error = None - restored = False - try: - match_count = original_source.count(ORIGINAL_KI) - if match_count != 1: - raise RuntimeError(f"expected one outer-KI source match, found {match_count}") - SOURCE_PATH.write_bytes(original_source.replace(ORIGINAL_KI, HISTORICAL_KI, 1)) - mutation_result = run_pytest(MUTATION_TEST_NODES, temp_root / "historical-outer-ki.xml") - except Exception as exc: # pragma: no cover - failure reporting path - mutation_error = exc - finally: - SOURCE_PATH.write_bytes(original_source) - restored = SOURCE_PATH.read_bytes() == original_source - - if not restored: - print("INVALID: source restoration did not reproduce the original bytes") - return 1 - if mutation_error is not None: - print(f"INVALID: historical outer-KI mutation could not run: {mutation_error}") - return 1 - if mutation_result is None: - print("INVALID: historical outer-KI mutation produced no pytest result") - return 1 - - mutation_xml = temp_root / "historical-outer-ki.xml" - try: - mutation_testcases = junit_testcases(mutation_xml) - except JUnitReportError as exc: - print(f"INVALID: historical-outer-ki {exc}") + survivors = [] + for mutation in MUTATIONS: + source_path = None + original_source = None + mutation_result = None + mutation_error = None + restored = False + try: + source_path, original_source = apply_mutation(mutation) + mutation_result = run_pytest( + mutation.test_nodes, + temp_root / f"{mutation.name}.xml", + ) + except Exception as exc: # pragma: no cover - failure reporting path + mutation_error = exc + finally: + if source_path is not None and original_source is not None: + source_path.write_bytes(original_source) + restored = source_path.read_bytes() == original_source + + if not restored: + print(f"INVALID: {mutation.name} source restoration was not byte-identical") + return 1 + if mutation_error is not None: + print(f"INVALID: {mutation.name} could not run: {mutation_error}") + return 1 + if mutation_result is None: + print(f"INVALID: {mutation.name} produced no pytest result") + return 1 + + mutation_xml = temp_root / f"{mutation.name}.xml" + try: + mutation_testcases = junit_testcases(mutation_xml) + except JUnitReportError as exc: + print(f"INVALID: {mutation.name} {exc}") + return 1 + if mutation_result.returncode == 1 and has_only_assertion_failures(mutation_testcases): + print(f"KILLED: {mutation.name} [{', '.join(mutation.test_nodes)}]") + elif mutation_result.returncode == 0: + survivors.append(mutation.name) + print(f"SURVIVED: {mutation.name} [{', '.join(mutation.test_nodes)}]") + else: + print(f"INVALID: {mutation.name} exited without assertion-only test failures " + + f"(pytest status {mutation_result.returncode})") + print(mutation_result.stdout) + return 1 + + if survivors: + print(f"Historical mutations survived: {', '.join(survivors)}") return 1 - if mutation_result.returncode == 1 and has_only_assertion_failures(mutation_testcases): - print(f"KILLED: historical-outer-ki [{', '.join(MUTATION_TEST_NODES)}]") - print("RESTORED: outer-KI source is byte-identical") - return 0 - if mutation_result.returncode == 0: - print("SURVIVED: historical-outer-ki") - return 1 - - print("INVALID: historical-outer-ki exited without assertion-only test failures " - + f"(pytest status {mutation_result.returncode})") - print(mutation_result.stdout) - return 1 + print(f"ALL KILLED: {len(MUTATIONS)} parent longitudinal mutations") + print("RESTORED: every mutated source is byte-identical") + return 0 if __name__ == "__main__": diff --git a/.github/ci/test_tesla_preap_longitudinal_workflow.py b/.github/ci/test_tesla_preap_longitudinal_workflow.py index a93e0c0eca9181..20732db65aec4e 100644 --- a/.github/ci/test_tesla_preap_longitudinal_workflow.py +++ b/.github/ci/test_tesla_preap_longitudinal_workflow.py @@ -3,6 +3,7 @@ WORKFLOW_PATH = Path(__file__).resolve().parents[1] / "workflows" / "tests.yaml" +PROCESS_REPLAY_PATH = Path(__file__).resolve().parents[2] / "selfdrive" / "test" / "process_replay" / "process_replay.py" def indented_block(document: str, header: str) -> str: @@ -41,9 +42,12 @@ def test_focused_tests_and_mutations_are_pinned(): workflow = WORKFLOW_PATH.read_text() focused_job = indented_block(workflow, " tesla_preap_longitudinal_regression:") required_commands = ( + "selfdrive/controls/tests/test_following_distance.py", + "selfdrive/controls/tests/test_tesla_preap_following.py", "selfdrive/controls/tests/test_tesla_preap_longcontrol.py", "opendbc_repo/opendbc/car/tesla/preap/tests/test_longitudinal_tuning.py", "opendbc_repo/opendbc/car/tesla/preap/tests/test_virtual_das.py", + "opendbc_repo/opendbc/car/tesla/preap/tests/test_vdas_grade_control.py", ) for command in required_commands: @@ -58,11 +62,21 @@ def test_focused_job_cannot_be_skipped_or_soft_failed(): assert not re.search(r"^\s*(?:if|continue-on-error)\s*:", focused_job, re.MULTILINE) +def test_additive_follow_telemetry_is_ignored_by_process_replay(): + process_replay = PROCESS_REPLAY_PATH.read_text() + plannerd_config = re.search(r'proc_name="plannerd",(?P.*?)\n \),', process_replay, re.DOTALL) + assert plannerd_config is not None + + assert '"longitudinalPlan.napFollowDistance"' in plannerd_config.group("body") + assert '"longitudinalPlan.tFollow"' in plannerd_config.group("body") + + def main(): test_nap_branches_run_on_push() test_named_focused_job_is_present() test_focused_tests_and_mutations_are_pinned() test_focused_job_cannot_be_skipped_or_soft_failed() + test_additive_follow_telemetry_is_ignored_by_process_replay() print("Tesla Pre-AP longitudinal workflow contract passed") diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index a1528f29dd369e..ebbb6c8807ee3a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -40,9 +40,12 @@ jobs: - name: Run focused longitudinal tests run: | pytest -q -n 0 \ + selfdrive/controls/tests/test_following_distance.py \ + selfdrive/controls/tests/test_tesla_preap_following.py \ selfdrive/controls/tests/test_tesla_preap_longcontrol.py \ opendbc_repo/opendbc/car/tesla/preap/tests/test_longitudinal_tuning.py \ - opendbc_repo/opendbc/car/tesla/preap/tests/test_virtual_das.py + opendbc_repo/opendbc/car/tesla/preap/tests/test_virtual_das.py \ + opendbc_repo/opendbc/car/tesla/preap/tests/test_vdas_grade_control.py - name: Run historical mutation checks run: python .github/ci/tesla_preap_longitudinal_mutations.py diff --git a/cereal/log.capnp b/cereal/log.capnp index d1bb765f368f1a..5deddb402beb10 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -1266,6 +1266,8 @@ struct LongitudinalPlan @0xe00b5b3eba12876c { shouldStop @37: Bool; allowThrottle @38: Bool; allowBrake @39: Bool; + napFollowDistance @40 :UInt8; + tFollow @41 :Float32; solverExecutionTime @35 :Float32; diff --git a/opendbc_repo b/opendbc_repo index b95793e64e6533..2282f562ec73b0 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit b95793e64e6533f94bfc170b90e6c9b25a6722ff +Subproject commit 2282f562ec73b0513661db0f622251b5083098ad diff --git a/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py b/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py index bdfb4fe9f143dd..9f28855e7e96b1 100755 --- a/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py +++ b/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py @@ -58,6 +58,7 @@ CRUISE_MIN_ACCEL = -1.2 CRUISE_MAX_ACCEL = 1.6 MIN_X_LEAD_FACTOR = 0.5 +NAP_T_FOLLOW = (0.7, 0.9, 1.1, 1.3, 1.5, 1.7, 1.9) def get_jerk_factor(personality=log.LongitudinalPersonality.standard): if personality==log.LongitudinalPersonality.relaxed: @@ -71,9 +72,8 @@ def get_jerk_factor(personality=log.LongitudinalPersonality.standard): def get_T_FOLLOW(personality=log.LongitudinalPersonality.standard, nap_follow_dist=None): - # NAP configurable follow distance: 1-7 maps to 0.7s - 1.9s in 0.2s steps - if nap_follow_dist is not None and 1 <= nap_follow_dist <= 7: - return 0.7 + (nap_follow_dist - 1) * 0.2 + if nap_follow_dist in range(1, len(NAP_T_FOLLOW) + 1): + return NAP_T_FOLLOW[nap_follow_dist - 1] if personality==log.LongitudinalPersonality.relaxed: return 1.75 @@ -317,8 +317,7 @@ def process_lead(self, lead): lead_xv = self.extrapolate_lead(x_lead, v_lead, a_lead, a_lead_tau) return lead_xv - def update(self, radarstate, v_cruise, personality=log.LongitudinalPersonality.standard, nap_follow_dist=None): - t_follow = get_T_FOLLOW(personality, nap_follow_dist) + def update(self, radarstate, v_cruise, t_follow): v_ego = self.x0[1] self.status = radarstate.leadOne.status or radarstate.leadTwo.status diff --git a/selfdrive/controls/lib/longitudinal_planner.py b/selfdrive/controls/lib/longitudinal_planner.py index 6b18f2596e2302..b58822a65d1ab9 100755 --- a/selfdrive/controls/lib/longitudinal_planner.py +++ b/selfdrive/controls/lib/longitudinal_planner.py @@ -9,7 +9,13 @@ from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.modeld.constants import ModelConstants from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState -from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalMpc, LongitudinalPlanSource +from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import ( + LongitudinalMpc, + LongitudinalPlanSource, + get_safe_obstacle_distance, + get_stopped_equivalence_factor, + get_T_FOLLOW, +) from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import T_IDXS as T_IDXS_MPC from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_from_plan from openpilot.selfdrive.car.cruise import V_CRUISE_MAX, V_CRUISE_UNSET @@ -33,9 +39,19 @@ def _get_preap_follow_limit(v_ego): if bp is None: return None return float(np.interp(v_ego, bp, v)) + + +def get_preap_follow_cap_strength(v_ego, lead_distance, lead_speed, t_follow): + lead_obstacle_distance = lead_distance + get_stopped_equivalence_factor(max(lead_speed, 0.0)) + safe_obstacle_distance = get_safe_obstacle_distance(v_ego, t_follow) + equivalent_ratio = lead_obstacle_distance / max(safe_obstacle_distance, 1.0) + return float(np.clip(1.0 - (equivalent_ratio - 1.2) / 0.3, 0.0, 1.0)) + + CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N] ALLOW_THROTTLE_THRESHOLD = 0.4 MIN_ALLOW_THROTTLE_SPEED = 2.5 +NAP_FOLLOW_DISTANCE_RANGE = range(1, 8) # Lookup table for turns _A_TOTAL_MAX_V = [1.7, 3.2] @@ -62,7 +78,7 @@ def limit_accel_in_turns(v_ego, angle_steers, a_target, CP): class LongitudinalPlanner: - def __init__(self, CP, init_v=0.0, init_a=0.0, dt=DT_MDL): + def __init__(self, CP, init_v=0.0, init_a=0.0, dt=DT_MDL, params=None): self.CP = CP self.mpc = LongitudinalMpc(dt=dt) self.fcw = False @@ -71,9 +87,11 @@ def __init__(self, CP, init_v=0.0, init_a=0.0, dt=DT_MDL): self._is_preap = (CP.brand == "tesla" and CP.carFingerprint == "TESLA_MODEL_S_PREAP" and CP.openpilotLongitudinalControl and not CP.pcmCruise) - self._params = Params() + self._params = Params() if params is None else params self.nap_follow_dist = self._params.get("NAPFollowDistance", return_default=True) if self._is_preap else None self.nap_adaptive_accel = self._params.get_bool("NAPAdaptiveAccel") if self._is_preap else False + self.active_nap_follow_dist = self.nap_follow_dist if self._is_preap and self.nap_follow_dist in NAP_FOLLOW_DISTANCE_RANGE else None + self.t_follow = get_T_FOLLOW(nap_follow_dist=self.active_nap_follow_dist) self._frame = 0 self.a_desired = init_a @@ -158,28 +176,25 @@ def update(self, sm): if force_slow_decel: v_cruise = 0.0 - # Pre-AP adaptive accel: only limit accel when close to a lead. - # When the lead is far (>1.5x safe distance), full profile for gap closing. - # When the lead is close (<1.2x safe distance), cap to follow limits to + self.active_nap_follow_dist = self.nap_follow_dist if self._is_preap and self.nap_follow_dist in NAP_FOLLOW_DISTANCE_RANGE else None + self.t_follow = get_T_FOLLOW(sm['selfdriveState'].personality, self.active_nap_follow_dist) + + # Pre-AP adaptive accel: only limit accel when the lead's obstacle-equivalent + # distance is close. Above 1.5x the safe obstacle distance, use the full + # profile for gap closing. Below 1.2x, cap acceleration to follow limits to # prevent overshoot → regen → overshoot oscillation. Blend in between. if self.CP.carFingerprint == "TESLA_MODEL_S_PREAP" and self.nap_adaptive_accel and sm['radarState'].leadOne.status: follow_limit = _get_preap_follow_limit(v_ego) if follow_limit is not None: - from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import get_safe_obstacle_distance, get_T_FOLLOW - t_follow = get_T_FOLLOW(sm['selfdriveState'].personality, self.nap_follow_dist) - safe_dist = get_safe_obstacle_distance(v_ego, t_follow) - lead_dist = sm['radarState'].leadOne.dRel - # ratio: 1.0 = at safe distance, <1.0 = closer, >1.0 = further - ratio = lead_dist / max(safe_dist, 1.0) - # Blend: full cap below 1.2x, no cap above 1.5x, linear between - cap_strength = float(np.clip(1.0 - (ratio - 1.2) / 0.3, 0.0, 1.0)) + lead = sm['radarState'].leadOne + cap_strength = get_preap_follow_cap_strength(v_ego, lead.dRel, lead.vLead, self.t_follow) if cap_strength > 0: blended = accel_clip[1] * (1.0 - cap_strength) + follow_limit * cap_strength accel_clip[1] = min(accel_clip[1], blended) self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality) self.mpc.set_cur_state(self.v_desired_filter.x, self.a_desired) - self.mpc.update(sm['radarState'], v_cruise, personality=sm['selfdriveState'].personality, nap_follow_dist=self.nap_follow_dist) + self.mpc.update(sm['radarState'], v_cruise, t_follow=self.t_follow) self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution) self.a_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.a_solution) @@ -237,5 +252,7 @@ def publish(self, sm, pm): longitudinalPlan.shouldStop = bool(self.output_should_stop) longitudinalPlan.allowBrake = True longitudinalPlan.allowThrottle = bool(self.allow_throttle) + longitudinalPlan.napFollowDistance = self.active_nap_follow_dist or 0 + longitudinalPlan.tFollow = self.t_follow pm.send('longitudinalPlan', plan_send) diff --git a/selfdrive/controls/tests/test_following_distance.py b/selfdrive/controls/tests/test_following_distance.py index 1eb88d72067442..283de8560f4069 100644 --- a/selfdrive/controls/tests/test_following_distance.py +++ b/selfdrive/controls/tests/test_following_distance.py @@ -13,7 +13,7 @@ def desired_follow_distance(v_ego, v_lead, t_follow=None): t_follow = get_T_FOLLOW() return get_safe_obstacle_distance(v_ego, t_follow) - get_stopped_equivalence_factor(v_lead) -def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personality=0): +def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personality=0, nap_follow_dist=None): man = Maneuver( '', duration=t_end, @@ -24,6 +24,7 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit breakpoints=[0.], e2e=e2e, personality=personality, + nap_follow_dist=nap_follow_dist, ) valid, output = man.evaluate() assert valid diff --git a/selfdrive/controls/tests/test_tesla_preap_following.py b/selfdrive/controls/tests/test_tesla_preap_following.py new file mode 100644 index 00000000000000..aecbe6a1c9b18c --- /dev/null +++ b/selfdrive/controls/tests/test_tesla_preap_following.py @@ -0,0 +1,570 @@ +from types import SimpleNamespace + +import numpy as np +import pytest + +from cereal import car, log, messaging +from opendbc.car.tesla.preap import virtual_das +from opendbc.car.tesla.preap.constants import PEDAL_LONG_K_BP, PEDAL_LONG_KI_V, PEDAL_LONG_KP_V +from opendbc.car.tesla.preap.virtual_das import GRAVITY, VirtualDAS +from opendbc.car.tesla.pedal.controller import PEDAL_RAMP_RATE_DOWN, PEDAL_RAMP_RATE_UP +from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState +from openpilot.selfdrive.controls.lib.longcontrol import LongControl +from openpilot.selfdrive.controls.lib import longitudinal_planner +from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import ( + LongitudinalPlanSource, + T_IDXS, + get_safe_obstacle_distance, + get_stopped_equivalence_factor, + get_T_FOLLOW, +) +from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner +from openpilot.selfdrive.controls.tests.test_following_distance import run_following_distance_simulation +from openpilot.selfdrive.modeld.constants import ModelConstants +from openpilot.selfdrive.test.longitudinal_maneuvers.maneuver import Maneuver +from openpilot.selfdrive.test.process_replay.process_replay import get_process_config + + +NAP_FOLLOW_SETTINGS = range(1, 8) +NAP_FOLLOW_TIMES_S = (0.7, 0.9, 1.1, 1.3, 1.5, 1.7, 1.9) +FOLLOW_TEST_SPEED_MPS = 25.0 +STOP_DISTANCE_M = 6.0 +FULL_LOOP_DT_S = 0.01 +FULL_LOOP_PLANNER_DT_S = 0.05 +FULL_LOOP_VDAS_DT_S = 0.02 +FULL_LOOP_PLANT_DELAY_S = 0.40 +FULL_LOOP_PLANT_TAU_S = 0.25 +FULL_LOOP_PEDAL_DI_BP = [-5.0, -2.0, 0.0, 3.0, 8.0, 15.0, 22.0, 27.0, 35.0, 50.0] +FULL_LOOP_NET_ACCEL_BP = [-1.05, -0.62, -0.50, -0.36, 0.0, 0.50, 1.15, 1.65, 2.10, 2.45] +FULL_LOOP_RECOVERY_END_S = 70.0 +FULL_LOOP_UPHILL_RAMP_END_S = 72.0 +FULL_LOOP_UPHILL_HOLD_END_S = 78.0 +FULL_LOOP_CREST_RAMP_END_S = 80.0 +FULL_LOOP_CREST_HOLD_END_S = 86.0 +FULL_LOOP_ROLLING_RAMP_END_S = 88.0 +FULL_LOOP_DURATION_S = 100.0 +FULL_LOOP_UPHILL_PITCH_RAD = float(np.deg2rad(4.0)) +FULL_LOOP_CREST_PITCH_RAD = float(np.deg2rad(-3.0)) +FULL_LOOP_ROLLING_PITCH_RAD = float(np.deg2rad(2.0)) +FULL_LOOP_GRADE_SETTLING_S = ( + FULL_LOOP_PLANT_DELAY_S + FULL_LOOP_PLANT_TAU_S + 5.0 * virtual_das.PITCH_LP_RC +) + + +class _PlannerInputs(dict): + logMonoTime = {"modelV2": 0} + + @staticmethod + def all_checks(service_list): + return set(service_list) == {"carState", "controlsState", "selfdriveState", "radarState"} + + +class _CapturingPubMaster: + def send(self, service, message): + assert service == "longitudinalPlan" + self.message = message + + +class _MutablePlannerParams: + def __init__(self, nap_follow_dist, adaptive_accel=False): + self.nap_follow_dist = nap_follow_dist + self.adaptive_accel = adaptive_accel + + def __bool__(self): + return False + + def get(self, key, return_default=False): + assert return_default + assert key == "NAPFollowDistance" + return self.nap_follow_dist + + def get_bool(self, key): + assert key == "NAPAdaptiveAccel" + return self.adaptive_accel + + +class _ConstantAccelerationMpc: + def __init__(self, speed_mps, acceleration_mps2): + self.v_solution = speed_mps + acceleration_mps2 * T_IDXS + self.a_solution = np.full(len(T_IDXS), acceleration_mps2) + self.j_solution = np.zeros(len(T_IDXS) - 1) + self.params = np.zeros((len(T_IDXS), 6)) + self.source = LongitudinalPlanSource.cruise + self.crash_cnt = 0 + self.solve_time = 0.0 + self.captured_t_follow = None + + @staticmethod + def set_weights(prev_accel_constraint, personality): + pass + + @staticmethod + def set_cur_state(speed_mps, acceleration_mps2): + pass + + def update(self, radar_state, cruise_speed_mps, t_follow): + self.captured_t_follow = t_follow + self.params[:, 4] = t_follow + + +def _make_preap_params(): + params = car.CarParams.new_message() + params.brand = "tesla" + params.carFingerprint = "TESLA_MODEL_S_PREAP" + params.openpilotLongitudinalControl = True + params.pcmCruise = False + params.steerRatio = 15.75 + params.wheelbase = 2.959 + params.longitudinalTuning.kpBP = PEDAL_LONG_K_BP + params.longitudinalTuning.kpV = PEDAL_LONG_KP_V + params.longitudinalTuning.kiBP = PEDAL_LONG_K_BP + params.longitudinalTuning.kiV = PEDAL_LONG_KI_V + params.longitudinalTuning.kf = 1.0 + params.vEgoStarting = 0.1 + return params + + +def _make_planner_inputs(speed_mps): + radar = messaging.new_message("radarState").radarState + controls = messaging.new_message("controlsState").controlsState + selfdrive = messaging.new_message("selfdriveState").selfdriveState + car_state = messaging.new_message("carState").carState + car_control = messaging.new_message("carControl").carControl + live_parameters = messaging.new_message("liveParameters").liveParameters + model = messaging.new_message("modelV2").modelV2 + + controls.longControlState = LongCtrlState.pid + selfdrive.personality = log.LongitudinalPersonality.standard + car_state.vEgo = speed_mps + car_state.vCruise = speed_mps * 3.6 + car_control.orientationNED = [0.0, 0.0, 0.0] + + model.position.x = (speed_mps * np.array(ModelConstants.T_IDXS)).tolist() + model.velocity.x = (speed_mps * np.ones_like(ModelConstants.T_IDXS)).tolist() + model.acceleration.x = np.zeros_like(ModelConstants.T_IDXS).tolist() + model.meta.disengagePredictions.gasPressProbs = [1.0] * 6 + + return _PlannerInputs({ + "radarState": radar, + "controlsState": controls, + "selfdriveState": selfdrive, + "carState": car_state, + "carControl": car_control, + "liveParameters": live_parameters, + "modelV2": model, + }) + + +def _physical_lead_distance(v_ego, v_lead, t_follow, obstacle_ratio): + safe_obstacle_distance = get_safe_obstacle_distance(v_ego, t_follow) + lead_obstacle_distance = obstacle_ratio * safe_obstacle_distance + return lead_obstacle_distance - get_stopped_equivalence_factor(max(v_lead, 0.0)) + + +def _full_loop_pitch(elapsed_s): + if elapsed_s < FULL_LOOP_RECOVERY_END_S: + return 0.0 + if elapsed_s < FULL_LOOP_UPHILL_RAMP_END_S: + return FULL_LOOP_UPHILL_PITCH_RAD * ( + (elapsed_s - FULL_LOOP_RECOVERY_END_S) / + (FULL_LOOP_UPHILL_RAMP_END_S - FULL_LOOP_RECOVERY_END_S) + ) + if elapsed_s < FULL_LOOP_UPHILL_HOLD_END_S: + return FULL_LOOP_UPHILL_PITCH_RAD + if elapsed_s < FULL_LOOP_CREST_RAMP_END_S: + ramp_fraction = ( + (elapsed_s - FULL_LOOP_UPHILL_HOLD_END_S) / + (FULL_LOOP_CREST_RAMP_END_S - FULL_LOOP_UPHILL_HOLD_END_S) + ) + return FULL_LOOP_UPHILL_PITCH_RAD + ramp_fraction * ( + FULL_LOOP_CREST_PITCH_RAD - FULL_LOOP_UPHILL_PITCH_RAD + ) + if elapsed_s < FULL_LOOP_CREST_HOLD_END_S: + return FULL_LOOP_CREST_PITCH_RAD + if elapsed_s < FULL_LOOP_ROLLING_RAMP_END_S: + ramp_fraction = ( + (elapsed_s - FULL_LOOP_CREST_HOLD_END_S) / + (FULL_LOOP_ROLLING_RAMP_END_S - FULL_LOOP_CREST_HOLD_END_S) + ) + return FULL_LOOP_CREST_PITCH_RAD + ramp_fraction * ( + FULL_LOOP_ROLLING_PITCH_RAD - FULL_LOOP_CREST_PITCH_RAD + ) + return FULL_LOOP_ROLLING_PITCH_RAD + + +def _run_full_closed_loop_following( + monkeypatch, + nap_follow_dist=7, + plant_aligned_feedforward=False, + grade_compensation_scale=1.0, +): + monkeypatch.setattr( + virtual_das, + "nap_conf", + SimpleNamespace(get_pedal_profile_values=lambda: [50.0] * len(virtual_das.PEDAL_BP)), + ) + monkeypatch.setattr( + virtual_das, + "get_zero_torque", + lambda: SimpleNamespace(get=lambda _speed_mps: 3.0), + ) + + speed_mps = FOLLOW_TEST_SPEED_MPS + acceleration_mps2 = 0.0 + ego_distance_m = 0.0 + lead_distance_m = 20.0 + lead_speed_mps = FOLLOW_TEST_SPEED_MPS + pedal_di = 8.0 + planner_target_mps2 = 0.0 + vdas_target_mps2 = 0.0 + + params = _MutablePlannerParams(nap_follow_dist=nap_follow_dist, adaptive_accel=True) + car_params = _make_preap_params() + car_params.longitudinalActuatorDelay = FULL_LOOP_PLANT_DELAY_S + planner = LongitudinalPlanner(car_params, init_v=speed_mps, params=params) + long_control = LongControl(car_params) + vdas = VirtualDAS(dt=FULL_LOOP_VDAS_DT_S) + vdas.reset(measured_accel=acceleration_mps2, commanded_accel=0.0, pedal_di_init=pedal_di) + if plant_aligned_feedforward: + vdas._feedforward = lambda acceleration_effort_mps2, _speed_mps: float(np.interp( + acceleration_effort_mps2, + FULL_LOOP_NET_ACCEL_BP, + FULL_LOOP_PEDAL_DI_BP, + )) + if grade_compensation_scale != 1.0: + grade_estimator_update = vdas.grade_estimator.update + + def scaled_grade_estimator_update(orientation_ned): + steady_compensation, transient_compensation = grade_estimator_update(orientation_ned) + return ( + grade_compensation_scale * steady_compensation, + grade_compensation_scale * transient_compensation, + ) + + vdas.grade_estimator.update = scaled_grade_estimator_update + + delay_steps = round(FULL_LOOP_PLANT_DELAY_S / FULL_LOOP_DT_S) + delayed_pedals_di = [pedal_di] * delay_steps + plant_alpha = FULL_LOOP_DT_S / (FULL_LOOP_PLANT_TAU_S + FULL_LOOP_DT_S) + planner_interval_steps = round(FULL_LOOP_PLANNER_DT_S / FULL_LOOP_DT_S) + vdas_interval_steps = round(FULL_LOOP_VDAS_DT_S / FULL_LOOP_DT_S) + samples = [] + pedal_samples = [] + + for step in range(round(FULL_LOOP_DURATION_S / FULL_LOOP_DT_S)): + elapsed_s = step * FULL_LOOP_DT_S + pitch_rad = _full_loop_pitch(elapsed_s) + gap_m = lead_distance_m - ego_distance_m + + if step % planner_interval_steps == 0: + inputs = _make_planner_inputs(float(speed_mps)) + inputs["carState"].aEgo = float(acceleration_mps2) + inputs["carState"].vCruise = lead_speed_mps * 3.6 + inputs["carControl"].orientationNED = [0.0, pitch_rad, 0.0] + lead = inputs["radarState"].leadOne + lead.status = True + lead.dRel = float(max(gap_m, 0.0)) + lead.vRel = float(lead_speed_mps - speed_mps) + lead.vLead = lead_speed_mps + lead.vLeadK = lead_speed_mps + lead.aLeadK = 0.0 + lead.aLeadTau = 1.5 + lead.modelProb = 1.0 + planner.update(inputs) + planner_target_mps2 = float(planner.output_a_target) + + state = car.CarState.new_message() + state.vEgo = float(speed_mps) + state.aEgo = float(acceleration_mps2) + state.brakePressed = False + state.cruiseState.standstill = False + vdas_target_mps2 = float(long_control.update( + active=True, + CS=state, + a_target=planner_target_mps2, + should_stop=planner.output_should_stop, + accel_limits=(-1.5, 0.8), + )) + + if step % vdas_interval_steps == 0: + pedal_di = vdas.update( + vdas_target_mps2, + v_ego=speed_mps, + prev_pedal_di=pedal_di, + a_ego=acceleration_mps2, + freeze_integrator=False, + orientation_ned=[0.0, pitch_rad, 0.0], + ) + pedal_samples.append(pedal_di) + + applied_pedal_di = delayed_pedals_di.pop(0) + delayed_pedals_di.append(pedal_di) + grade_acceleration_mps2 = GRAVITY * np.sin(pitch_rad) + plant_target_mps2 = float(np.interp( + applied_pedal_di, + FULL_LOOP_PEDAL_DI_BP, + FULL_LOOP_NET_ACCEL_BP, + )) - grade_acceleration_mps2 + acceleration_mps2 += plant_alpha * (plant_target_mps2 - acceleration_mps2) + speed_mps = max(0.0, speed_mps + acceleration_mps2 * FULL_LOOP_DT_S) + ego_distance_m += speed_mps * FULL_LOOP_DT_S + lead_distance_m += lead_speed_mps * FULL_LOOP_DT_S + + samples.append(( + elapsed_s, + lead_distance_m - ego_distance_m, + speed_mps, + acceleration_mps2, + planner_target_mps2, + vdas_target_mps2, + pitch_rad, + )) + + return np.array(samples), np.array(pedal_samples) + + +@pytest.mark.parametrize(("lead_speed", "obstacle_ratio", "expected_strength"), [ + (30.0, 1.0, 1.0), + (30.0, 1.2, 1.0), + (30.0, 1.35, 0.5), + (30.0, 1.5, 0.0), + (20.0, 1.35, 0.5), + (-5.0, 1.35, 0.5), +]) +def test_preap_follow_cap_uses_obstacle_equivalent_distance(lead_speed, obstacle_ratio, expected_strength): + speed_mps = 30.0 + t_follow = 1.9 + lead_distance = _physical_lead_distance(speed_mps, lead_speed, t_follow, obstacle_ratio) + + cap_strength = longitudinal_planner.get_preap_follow_cap_strength( + speed_mps, + lead_distance, + lead_speed, + t_follow, + ) + + assert cap_strength == pytest.approx(expected_strength) + + +def test_planner_adaptive_cap_changes_the_delivered_acceleration_for_unequal_speed_lead(): + speed_mps = 30.0 + lead_speed_mps = 35.0 + obstacle_ratio = 1.35 + t_follow = 1.9 + params = _MutablePlannerParams(nap_follow_dist=7, adaptive_accel=True) + planner = LongitudinalPlanner(_make_preap_params(), init_v=speed_mps, params=params) + planner.mpc = _ConstantAccelerationMpc(speed_mps, acceleration_mps2=1.5) + inputs = _make_planner_inputs(speed_mps) + lead = inputs["radarState"].leadOne + lead.status = True + lead.dRel = _physical_lead_distance(speed_mps, lead_speed_mps, t_follow, obstacle_ratio) + lead.vLead = lead_speed_mps + + for _ in range(32): + planner.update(inputs) + + open_road_limit = longitudinal_planner.get_max_accel(speed_mps) + follow_limit = longitudinal_planner._get_preap_follow_limit(speed_mps) + cap_strength = longitudinal_planner.get_preap_follow_cap_strength( + speed_mps, + lead.dRel, + lead_speed_mps, + t_follow, + ) + expected_adaptive_limit = open_road_limit * (1.0 - cap_strength) + follow_limit * cap_strength + + assert cap_strength == pytest.approx(0.5) + assert planner.mpc.captured_t_follow == t_follow + assert planner.output_a_target == pytest.approx(expected_adaptive_limit) + assert planner.output_a_target < open_road_limit + + +def test_planner_publishes_the_follow_policy_used_by_mpc(): + params = _MutablePlannerParams(nap_follow_dist=1) + planner = LongitudinalPlanner(_make_preap_params(), init_v=FOLLOW_TEST_SPEED_MPS, params=params) + inputs = _make_planner_inputs(FOLLOW_TEST_SPEED_MPS) + publisher = _CapturingPubMaster() + + params.nap_follow_dist = 7 + planner._frame = 19 + planner.update(inputs) + planner.publish(inputs, publisher) + + plan = publisher.message.longitudinalPlan + assert plan.napFollowDistance == 7 + assert plan.tFollow == pytest.approx(1.9) + assert planner.t_follow == 1.9 + assert np.all(planner.mpc.params[:, 4] == planner.t_follow) + assert plan.tFollow == pytest.approx(planner.t_follow, abs=1e-6) + + +@pytest.mark.parametrize("nap_follow_dist", [-1, 0, 8]) +def test_invalid_nap_follow_setting_publishes_zero_and_uses_personality(nap_follow_dist): + params = _MutablePlannerParams(nap_follow_dist) + planner = LongitudinalPlanner(_make_preap_params(), init_v=FOLLOW_TEST_SPEED_MPS, params=params) + inputs = _make_planner_inputs(FOLLOW_TEST_SPEED_MPS) + publisher = _CapturingPubMaster() + + planner.update(inputs) + planner.publish(inputs, publisher) + + plan = publisher.message.longitudinalPlan + assert plan.napFollowDistance == 0 + assert planner.t_follow == get_T_FOLLOW(log.LongitudinalPersonality.standard) + assert np.all(planner.mpc.params[:, 4] == planner.t_follow) + assert plan.tFollow == pytest.approx(planner.t_follow, abs=1e-6) + + +def test_non_preap_planner_publishes_zero_and_uses_personality(): + planner_params = _make_preap_params() + planner_params.brand = "honda" + planner_params.carFingerprint = "HONDA_CIVIC" + planner = LongitudinalPlanner(planner_params, init_v=FOLLOW_TEST_SPEED_MPS) + inputs = _make_planner_inputs(FOLLOW_TEST_SPEED_MPS) + inputs["selfdriveState"].personality = log.LongitudinalPersonality.relaxed + publisher = _CapturingPubMaster() + + planner.update(inputs) + planner.publish(inputs, publisher) + + plan = publisher.message.longitudinalPlan + assert plan.napFollowDistance == 0 + assert planner.t_follow == get_T_FOLLOW(log.LongitudinalPersonality.relaxed) + assert np.all(planner.mpc.params[:, 4] == planner.t_follow) + assert plan.tFollow == pytest.approx(planner.t_follow, abs=1e-6) + + +def test_process_replay_ignores_additive_follow_policy_telemetry(): + ignored_fields = get_process_config("plannerd").ignore + + assert "longitudinalPlan.napFollowDistance" in ignored_fields + assert "longitudinalPlan.tFollow" in ignored_fields + + +def test_nap_follow_setting_map_and_physical_gaps_are_strictly_monotonic(): + actual_follow_times = [get_T_FOLLOW(nap_follow_dist=setting) for setting in NAP_FOLLOW_SETTINGS] + physical_gaps = [t_follow * FOLLOW_TEST_SPEED_MPS + STOP_DISTANCE_M for t_follow in actual_follow_times] + + assert actual_follow_times == list(NAP_FOLLOW_TIMES_S) + assert np.all(np.diff(physical_gaps) > 0.0) + + +def test_nap_follow_settings_control_monotonic_maneuver_gaps(): + steady_gaps = [ + run_following_distance_simulation( + FOLLOW_TEST_SPEED_MPS, + t_end=80.0, + nap_follow_dist=nap_follow_dist, + ) + for nap_follow_dist in NAP_FOLLOW_SETTINGS + ] + expected_gaps = [ + t_follow * FOLLOW_TEST_SPEED_MPS + STOP_DISTANCE_M + for t_follow in NAP_FOLLOW_TIMES_S + ] + + assert steady_gaps == pytest.approx(expected_gaps, abs=0.5) + assert np.all(np.diff(steady_gaps) > 0.0) + + +def test_max_follow_setting_recovers_from_a_close_lead_without_closing_first(): + maneuver = Maneuver( + "max follow recovery", + duration=60.0, + initial_speed=FOLLOW_TEST_SPEED_MPS, + lead_relevancy=True, + initial_distance_lead=20.0, + speed_lead_values=[FOLLOW_TEST_SPEED_MPS], + breakpoints=[0.0], + e2e=False, + nap_follow_dist=7, + ) + + valid, output = maneuver.evaluate() + assert valid + assert np.min(output[:, 6]) >= 19.5 + assert output[-1, 6] == pytest.approx(53.5, abs=0.5) + assert output[-1, 3] == pytest.approx(FOLLOW_TEST_SPEED_MPS, abs=0.1) + + +def test_max_follow_full_closed_loop_recovers_gap_with_production_fallback(monkeypatch): + samples, pedal_samples = _run_full_closed_loop_following(monkeypatch, nap_follow_dist=7) + disabled_grade_samples, _ = _run_full_closed_loop_following( + monkeypatch, + nap_follow_dist=7, + grade_compensation_scale=0.0, + ) + elapsed_s, gaps_m, speeds_mps, accelerations_mps2, planner_targets_mps2, vdas_targets_mps2, _ = samples.T + disabled_grade_speeds_mps = disabled_grade_samples[:, 2] + desired_gap_m = get_T_FOLLOW(nap_follow_dist=7) * FOLLOW_TEST_SPEED_MPS + STOP_DISTANCE_M + recovery_window = (elapsed_s >= FULL_LOOP_RECOVERY_END_S - 5.0) & (elapsed_s < FULL_LOOP_RECOVERY_END_S) + grade_window = elapsed_s >= FULL_LOOP_RECOVERY_END_S + settled_rolling_window = elapsed_s >= FULL_LOOP_ROLLING_RAMP_END_S + FULL_LOOP_GRADE_SETTLING_S + final_speed_window = elapsed_s >= FULL_LOOP_DURATION_S - 2.0 + + assert np.min(gaps_m) >= 19.5 + assert np.mean(gaps_m[recovery_window]) == pytest.approx(desired_gap_m, abs=2.0) + assert np.mean(speeds_mps[recovery_window]) == pytest.approx(FOLLOW_TEST_SPEED_MPS, abs=0.2) + assert gaps_m[-1] >= desired_gap_m - 2.0 + assert np.mean(speeds_mps[final_speed_window]) == pytest.approx(FOLLOW_TEST_SPEED_MPS, abs=0.2) + assert np.max(speeds_mps[settled_rolling_window]) <= FOLLOW_TEST_SPEED_MPS + 0.1 + + assert np.min(accelerations_mps2) >= -1.0 + assert np.max(accelerations_mps2) <= 0.8 + assert np.max(np.abs(np.diff(accelerations_mps2) / FULL_LOOP_DT_S)) <= 1.5 + assert np.max(np.diff(pedal_samples)) <= PEDAL_RAMP_RATE_UP + 1e-9 + assert np.min(np.diff(pedal_samples)) >= -PEDAL_RAMP_RATE_DOWN - 1e-9 + assert np.min(pedal_samples) >= FULL_LOOP_PEDAL_DI_BP[0] + assert np.max(pedal_samples) <= FULL_LOOP_PEDAL_DI_BP[-1] + assert np.max(np.abs(planner_targets_mps2 - vdas_targets_mps2)) <= 1e-9 + + compensated_speed_error = np.trapezoid( + np.abs(speeds_mps[grade_window] - FOLLOW_TEST_SPEED_MPS), + elapsed_s[grade_window], + ) + disabled_speed_error = np.trapezoid( + np.abs(disabled_grade_speeds_mps[grade_window] - FOLLOW_TEST_SPEED_MPS), + elapsed_s[grade_window], + ) + assert np.max(np.abs(speeds_mps[grade_window] - FOLLOW_TEST_SPEED_MPS)) <= 1.25 + assert compensated_speed_error <= 0.5 * disabled_speed_error + + +def test_plant_aligned_full_closed_loop_grade_compensation_holds_speed(monkeypatch): + samples, _ = _run_full_closed_loop_following( + monkeypatch, + nap_follow_dist=7, + plant_aligned_feedforward=True, + ) + elapsed_s, gaps_m, speeds_mps, accelerations_mps2, _, vdas_targets_mps2, pitches_rad = samples.T + desired_gap_m = get_T_FOLLOW(nap_follow_dist=7) * FOLLOW_TEST_SPEED_MPS + STOP_DISTANCE_M + uphill_window = ( + (elapsed_s >= FULL_LOOP_UPHILL_RAMP_END_S + FULL_LOOP_GRADE_SETTLING_S) + & (elapsed_s < FULL_LOOP_UPHILL_HOLD_END_S) + ) + crest_window = ( + (elapsed_s >= FULL_LOOP_CREST_RAMP_END_S + FULL_LOOP_GRADE_SETTLING_S) + & (elapsed_s < FULL_LOOP_CREST_HOLD_END_S) + ) + rolling_window = elapsed_s >= FULL_LOOP_ROLLING_RAMP_END_S + FULL_LOOP_GRADE_SETTLING_S + + assert np.min(gaps_m) >= 19.5 + assert gaps_m[-1] >= desired_gap_m - 2.0 + for window, expected_pitch_rad in ( + (uphill_window, FULL_LOOP_UPHILL_PITCH_RAD), + (crest_window, FULL_LOOP_CREST_PITCH_RAD), + (rolling_window, FULL_LOOP_ROLLING_PITCH_RAD), + ): + assert pitches_rad[window] == pytest.approx(np.full(np.count_nonzero(window), expected_pitch_rad)) + assert np.min(speeds_mps[window]) >= FOLLOW_TEST_SPEED_MPS - 0.5 + assert np.max(speeds_mps[window]) <= FOLLOW_TEST_SPEED_MPS + 0.5 + + for phase_end_s in ( + FULL_LOOP_UPHILL_HOLD_END_S, + FULL_LOOP_CREST_HOLD_END_S, + FULL_LOOP_DURATION_S, + ): + tracking_window = (elapsed_s >= phase_end_s - 2.0) & (elapsed_s < phase_end_s) + assert np.mean(np.abs( + accelerations_mps2[tracking_window] - vdas_targets_mps2[tracking_window] + )) <= 0.12 diff --git a/selfdrive/controls/tests/test_tesla_preap_longcontrol.py b/selfdrive/controls/tests/test_tesla_preap_longcontrol.py index 87d064391c96d9..6c57d1c11b3310 100644 --- a/selfdrive/controls/tests/test_tesla_preap_longcontrol.py +++ b/selfdrive/controls/tests/test_tesla_preap_longcontrol.py @@ -240,7 +240,8 @@ def test_negative_planner_target_reaches_regen_side_of_coast_anchor(monkeypatch) settled_pedal_samples = pedal_samples[-settled_sample_count:] assert all(target_mps2 <= -0.15 for target_mps2, _pedal_di in settled_pedal_samples) - assert all(pedal_di <= coast_pedal_di for _target_mps2, pedal_di in settled_pedal_samples), ( + maximum_settled_pedal_di = max(pedal_di for _target_mps2, pedal_di in settled_pedal_samples) + assert maximum_settled_pedal_di <= coast_pedal_di - 0.05, ( f"settled negative planner window reached {max(pedal_di for _, pedal_di in settled_pedal_samples):.3f} DI, " - + f"above the {coast_pedal_di:.3f} DI coast anchor" + + f"without moving meaningfully below the {coast_pedal_di:.3f} DI coast anchor" ) diff --git a/selfdrive/test/longitudinal_maneuvers/maneuver.py b/selfdrive/test/longitudinal_maneuvers/maneuver.py index ba0379f2d725bf..905065bc7a40fd 100644 --- a/selfdrive/test/longitudinal_maneuvers/maneuver.py +++ b/selfdrive/test/longitudinal_maneuvers/maneuver.py @@ -23,6 +23,7 @@ def __init__(self, title, duration, **kwargs): self.enabled = kwargs.get("enabled", True) self.e2e = kwargs.get("e2e", False) self.personality = kwargs.get("personality", 0) + self.nap_follow_dist = kwargs.get("nap_follow_dist") self.force_decel = kwargs.get("force_decel", False) self.duration = duration @@ -38,6 +39,7 @@ def evaluate(self): only_radar=self.only_radar, e2e=self.e2e, personality=self.personality, + nap_follow_dist=self.nap_follow_dist, force_decel=self.force_decel, ) diff --git a/selfdrive/test/longitudinal_maneuvers/plant.py b/selfdrive/test/longitudinal_maneuvers/plant.py index b8c6adb4368028..fbbdab2f42cb10 100755 --- a/selfdrive/test/longitudinal_maneuvers/plant.py +++ b/selfdrive/test/longitudinal_maneuvers/plant.py @@ -2,7 +2,7 @@ import time import numpy as np -from cereal import log +from cereal import car, log import cereal.messaging as messaging from openpilot.common.realtime import Ratekeeper, DT_MDL from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState @@ -11,11 +11,27 @@ from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU +class _ManeuverParams: + def __init__(self, nap_follow_dist): + self.nap_follow_dist = nap_follow_dist + + def get(self, key, return_default=False): + assert return_default + assert key == "NAPFollowDistance" + return self.nap_follow_dist + + @staticmethod + def get_bool(key): + assert key == "NAPAdaptiveAccel" + return False + + class Plant: messaging_initialized = False def __init__(self, lead_relevancy=False, speed=0.0, distance_lead=2.0, - enabled=True, only_lead2=False, only_radar=False, e2e=False, personality=0, force_decel=False): + enabled=True, only_lead2=False, only_radar=False, e2e=False, personality=0, + nap_follow_dist=None, force_decel=False): self.rate = 1. / DT_MDL if not Plant.messaging_initialized: @@ -48,10 +64,22 @@ def __init__(self, lead_relevancy=False, speed=0.0, distance_lead=2.0, time.sleep(0.1) self.sm = messaging.SubMaster(['longitudinalPlan']) - from opendbc.car.honda.values import CAR - from opendbc.car.honda.interface import CarInterface - - self.planner = LongitudinalPlanner(CarInterface.get_non_essential_params(CAR.HONDA_CIVIC), init_v=self.speed) + if nap_follow_dist is None: + from opendbc.car.honda.values import CAR + from opendbc.car.honda.interface import CarInterface + planner_params = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) + params = None + else: + planner_params = car.CarParams.new_message() + planner_params.brand = "tesla" + planner_params.carFingerprint = "TESLA_MODEL_S_PREAP" + planner_params.openpilotLongitudinalControl = True + planner_params.pcmCruise = False + planner_params.steerRatio = 15.75 + planner_params.wheelbase = 2.959 + params = _ManeuverParams(nap_follow_dist) + + self.planner = LongitudinalPlanner(planner_params, init_v=self.speed, params=params) @property def current_time(self): diff --git a/selfdrive/test/process_replay/process_replay.py b/selfdrive/test/process_replay/process_replay.py index d168a7e8002024..8c4d89ca347fe0 100755 --- a/selfdrive/test/process_replay/process_replay.py +++ b/selfdrive/test/process_replay/process_replay.py @@ -479,7 +479,8 @@ def selfdrived_config_callback(params, cfg, lr): proc_name="plannerd", pubs=["modelV2", "carControl", "carState", "controlsState", "liveParameters", "radarState", "selfdriveState"], subs=["longitudinalPlan", "driverAssistance"], - ignore=["logMonoTime", "longitudinalPlan.processingDelay", "longitudinalPlan.solverExecutionTime"], + ignore=["logMonoTime", "longitudinalPlan.processingDelay", "longitudinalPlan.solverExecutionTime", + "longitudinalPlan.napFollowDistance", "longitudinalPlan.tFollow"], init_callback=get_car_params_callback, should_recv_callback=MessageBasedRcvCallback("modelV2"), tolerance=NUMPY_TOLERANCE,