Files
StarPilot/openpilot/starpilot/lateral/tests/test_lane_host.py
T

304 lines
16 KiB
Python

"""Actual host curvature selection, limiter and native controllers on supplied messages."""
import argparse
from dataclasses import asdict, replace
import json
import math
import os
from pathlib import Path
import subprocess
import struct
import sys
import tempfile
import unittest
from openpilot.common.basedir import BASEDIR
def probe(output, reference_method=None):
if not os.environ.get("OPENPILOT_PREFIX", "").startswith("lane-host-") or any(
name in os.environ for name in ("CEREAL_FAKE", "CEREAL_FAKE_PREFIX", "SIMULATION")
):
raise RuntimeError("Native lane-host probe requires isolated IPC without simulation flags")
from openpilot.cereal import messaging
from openpilot.common.realtime import DT_CTRL
from openpilot.common.params import Params
from openpilot.selfdrive.controls.controlsd import Controls
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque
from openpilot.selfdrive.controls.lib.longcontrol import LongControl
from openpilot.starpilot.longitudinal.ioniq6_start import eligible as ioniq6_start_eligible
from openpilot.starpilot.longitudinal.tests.extension_helpers import attach_inputs
from openpilot.starpilot.lateral.lane_centering import ControlMode, LaneCenteringController, LaneCenteringRequest, LaneCenteringSettings
from openpilot.starpilot.lateral.tests.test_lane_centering import model
from openpilot.starpilot.lateral.lane_change_preferences import LiveLaneChangePolicy
from openpilot.starpilot.lateral.lane_change_smoothing import LaneChangeSmoother
from opendbc.car.car_helpers import interfaces
from opendbc.car.toyota.values import CAR
from opendbc.car.vehicle_model import VehicleModel
def host():
# Allocate just the state_control dependencies. Native controllers and SubMaster
# are independent; Params, publication, pose calibration and manager are not run.
control = Controls.__new__(Controls)
from openpilot.starpilot.lateral.pause import LateralPause, PauseSettings
control.lateral_pause = LateralPause(PauseSettings())
attach_inputs(control)
control.lateral_gain_owner = None
control.aol_replay = False
interface = interfaces[CAR.TOYOTA_COROLLA_TSS2]
control.CP = interface.get_non_essential_params(CAR.TOYOTA_COROLLA_TSS2).as_reader()
control.longitudinal_inputs.ioniq6_start_enabled = ioniq6_start_eligible(control.CP)
control.CI = interface(control.CP)
control.VM = VehicleModel(control.CP)
control.LaC = LatControlTorque(control.CP, control.CI, DT_CTRL)
control.LoC = LongControl(control.CP)
control.sm = messaging.SubMaster(["vehicleParameters", "lateralTorqueParameters", "carState", "selfdriveState",
"longitudinalPlan", "modelV2", "lateralManeuverPlan", "lateralDelay", "onroadEvents"],
frequency=100)
control.curvature = control.desired_curvature = 0.0
control.steer_limited_by_safety = False
control.lane_centering_controller = LaneCenteringController()
control.params = Params()
control.lane_change_preferences = LiveLaneChangePolicy(control.params, now_ns=1_000_000_000)
control.lane_change_policy = control.lane_change_preferences.applied
control.lane_change_smoother = LaneChangeSmoother()
control.torque_host = None # Explicit feature-off state for the manually constructed Controls fixture.
control.torque_learning_allowed = False
control.longitudinal_inputs.gm_start_enabled = False
control.longitudinal_inputs.gm_volt_enabled = False
control.longitudinal_inputs.toyota_sienna_replay = False
control.lane_centering_host = None
control.last_lane_centering_result = None
return control
request = LaneCenteringRequest(ControlMode.COMBINED, LaneCenteringSettings(True, 0.0, 0.0, True), DT_CTRL)
changes = ("disabled_setting", "off", "long_only", "lateral_only", "fault", "disabled_host", "override", "signal",
"invalid_model", "stale_model", "clock_reset", "absent_request", "invalid_request", "maneuver")
records = []
def input_bytes(control):
return {name: tuple(event.as_builder().to_bytes() for event in control.sm[name]) if name == "onroadEvents"
else control.sm[name].as_builder().to_bytes() for name in control.sm.services}
for change in changes:
candidate, reference = host(), host()
previous_correction = 0.0
# Each gate starts with positive acquired state; absence/disabled equivalence
# starts disabled from the beginning to compare all controller history exactly.
for tick in range(140):
phase = "warm" if tick < 80 else change
timestamp = 1_000_000_000 + tick * 10_000_000
inputs = []
def message(service, size=None, valid=True, timestamp=timestamp, inputs=inputs):
msg = messaging.new_message(service, size, valid=valid, logMonoTime=timestamp)
inputs.append(msg)
return getattr(msg, service)
cs = message("carState")
cs.vEgo = cs.vEgoRaw = 20.0
cs.vCruise = cs.vCruiseCluster = 100.0
cs.canValid = True
cs.steerFaultTemporary = phase == "fault"
cs.steeringPressed = phase == "override"
cs.leftBlinker = phase == "signal"
state = message("selfdriveState")
state.enabled = state.active = phase != "disabled_host"
state.state = "enabled" if state.enabled else "disabled"
lp = message("vehicleParameters")
lp.stiffnessFactor, lp.steerRatio = 1.0, candidate.CP.steerRatio
message("longitudinalPlan").aTarget = 0.5
message("lateralDelay").lateralDelay = 0.1
message("onroadEvents", 0)
if tick % 5 == 0 and phase != "stale_model":
event = messaging.new_message("modelV2", valid=phase != "invalid_model", logMonoTime=timestamp)
event.modelV2 = model()
event.modelV2.action.desiredCurvature = 0.0002
inputs.append(event)
if phase == "maneuver":
message("lateralManeuverPlan").desiredCurvature = -0.0004
readers = [msg.as_reader() for msg in inputs]
candidate.sm.update_msgs(timestamp / 1e9, readers)
reference.sm.update_msgs(timestamp / 1e9, readers)
before = input_bytes(candidate)
configured = request
if change == "disabled_setting":
configured = replace(request, settings=replace(request.settings, enabled=False))
elif phase == "off":
configured = replace(request, mode=ControlMode.OFF)
elif phase == "long_only":
configured = replace(request, mode=ControlMode.LONGITUDINAL_ONLY)
elif phase == "lateral_only":
configured = replace(request, mode=ControlMode.LATERAL_ONLY)
elif phase == "clock_reset":
configured = replace(request, time_discontinuity=True)
elif phase == "absent_request":
configured = None
elif phase == "invalid_request":
configured = "invalid"
prior_curvature = candidate.desired_curvature
command, lateral = candidate.state_control(lane_centering=configured)
base_command, _ = reference.state_control() if reference_method is None else reference_method(reference)
after = input_bytes(candidate)
if before != after:
raise AssertionError("Host changed its supplied messages")
result = candidate.last_lane_centering_result
if result is not None and result.candidate_curvature is not None:
expected, _ = clip_curvature(cs.vEgo, prior_curvature, result.candidate_curvature, lp.roll)
published_precision = struct.unpack("f", struct.pack("f", expected))[0]
if candidate.desired_curvature != expected or command.actuators.curvature != published_precision:
raise AssertionError("Lane candidate bypassed native curvature limiting")
if result is not None and result.correction != 0.0:
baseline_input = result.candidate_curvature - result.correction
without_lane, _ = clip_curvature(cs.vEgo, prior_curvature, baseline_input, lp.roll)
if not math.isclose(candidate.lane_centering_applied, candidate.desired_curvature - without_lane, abs_tol=1e-12):
raise AssertionError("Lane feedback did not reflect the final command limiter")
elif candidate.lane_centering_applied != 0.0:
raise AssertionError("Inactive lane contribution remained visible")
records.append({"scenario": change, "phase": phase, "tick": tick,
"model_sample": candidate.sm.logMonoTime["modelV2"], "model_checks": candidate.sm.all_checks(["modelV2"]),
"result": asdict(result) if result is not None else None, "previous_correction": previous_correction,
"host": command.to_dict(), "reference": base_command.to_dict(), "controller_active": lateral.active})
previous_correction = result.correction if result is not None else 0.0
Path(output).write_text(json.dumps({"scope": "Native host/controller software composition; supplied authority, no vehicle qualification",
"records": records}, indent=2) + "\n")
class TestLaneHost(unittest.TestCase):
def test_native_host_gates_limiter_and_unchanged_longitudinal_path(self):
ipc_root = "/tmp" if sys.platform == "darwin" else "/dev/shm"
with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory(prefix="msgq_lane-host-", dir=ipc_root) as ipc:
output = Path(tmp) / "observations.json"
env = dict(os.environ, OPENPILOT_PREFIX=Path(ipc).name.removeprefix("msgq_"))
for name in ("CEREAL_FAKE", "CEREAL_FAKE_PREFIX", "SIMULATION"):
env.pop(name, None)
run = subprocess.run([sys.executable, "-m", "openpilot.starpilot.lateral.tests.test_lane_host", "--probe", str(output)],
cwd=BASEDIR, env=env, capture_output=True, text=True, timeout=30)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
records = json.loads(output.read_text())["records"]
self.assertEqual(len(records), 1960)
for row in records:
phase, result, command, reference = row["phase"], row["result"], row["host"], row["reference"]
with self.subTest(scenario=row["scenario"], tick=row["tick"]):
for field in ("enabled", "latActive", "longActive"):
self.assertEqual(command[field], reference[field])
self.assertEqual(command["actuators"]["accel"], reference["actuators"]["accel"])
self.assertTrue(math.isfinite(command["actuators"]["torque"]))
if row["scenario"] == "disabled_setting":
self.assertEqual(command, reference)
elif phase == "warm" and row["tick"] >= 20:
self.assertGreater(result["correction"], 0.0)
self.assertGreater(result["correction"], row["previous_correction"])
elif phase in ("off", "long_only", "fault", "disabled_host", "override", "invalid_model", "clock_reset", "invalid_request"):
self.assertEqual(result["correction"], 0.0)
elif phase == "absent_request":
self.assertIsNone(result)
elif phase == "signal":
self.assertLess(result["correction"], row["previous_correction"])
elif phase == "stale_model" and not row["model_checks"]:
self.assertEqual(result["correction"], 0.0)
elif phase == "maneuver":
self.assertAlmostEqual(result["candidate_curvature"] - result["correction"], -0.0004, places=9)
if phase in ("fault", "disabled_host"):
self.assertFalse(command["latActive"])
self.assertEqual(command["actuators"]["torque"], 0.0)
stale = [row for row in records if row["phase"] == "stale_model"]
self.assertTrue(any(row["model_checks"] for row in stale))
self.assertTrue(any(not row["model_checks"] for row in stale))
held = [row for row in records if row["scenario"] == "lateral_only" and row["tick"] in (21, 22)]
self.assertEqual(held[0]["model_sample"], held[1]["model_sample"])
self.assertGreater(held[1]["result"]["correction"], held[0]["result"]["correction"])
if __name__ == "__main__":
if "--probe" in sys.argv:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--probe", type=Path, required=True)
probe(parser.parse_args().probe)
else:
unittest.main()
def run_tracking_plant(side, road_curvature, enabled):
import numpy as np
from openpilot.starpilot.lateral import lane_centering as lane
from openpilot.starpilot.lateral.lane_clearance import BodyDimensions
from openpilot.starpilot.lateral.tests import test_lane_centering as fixtures
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature as clip
controller = lane.LaneCenteringController(body=BodyDimensions(1.88, 4.855))
speed, dt = 20.0, 0.01
y = heading = previous = 0.0
measured = road_curvature
delayed = [road_curvature] * 10
samples = []
for tick in range(1200):
if tick % 5 == 0:
nominal = road_curvature - 0.001 * (y - (-0.6 if side == 0 else 0.6)) - 0.08 * heading
m = fixtures.model(path_std=0.02)
for index, intercept in enumerate((-5.0, -1.8, 1.8, 5.0)):
m.laneLines[index].y = [intercept - y - heading * x + 0.5 * road_curvature * x * x for x in fixtures.XS]
m.position.y = [0.5 * nominal * x * x for x in fixtures.XS]
m.laneLineProbs = [0.0, 0.95 if tick < 100 or side == 0 else 0.05, 0.95 if tick < 100 or side == 1 else 0.05, 0.0]
m.init('roadEdges', 2)
for edge, intercept in zip(m.roadEdges, (-5.0, 5.0), strict=True):
edge.x = fixtures.XS
edge.y = [intercept - y - heading * x + 0.5 * road_curvature * x * x for x in fixtures.XS]
m.roadEdgeStds = [0.1, 0.1]
m.action.desiredCurvature = nominal
model = m.as_reader()
stamp = 1_000_000_000 + tick * 10_000_000
nominal = model.action.desiredCurvature
desired = nominal
if enabled:
result = controller.update(
lane.LaneCenteringInput(
model,
stamp,
True,
nominal,
speed,
lane.ControlMode.LATERAL_ONLY,
True,
lane.LaneCenteringSettings(True, 0.0, 0.0, True),
dt,
measured_curvature=measured,
lateral_delay_s=0.1,
)
)
assert result.candidate_curvature is not None
desired = result.candidate_curvature
command, _ = clip(speed, previous, desired, 0.0)
baseline, _ = clip(speed, previous, nominal, 0.0)
if enabled:
command = controller.guard_applied(model, baseline, command, measured, speed, 0.1)
assert abs(command) <= 3.0 / speed**2 + 1e-12
assert abs(command - previous) <= 5.0 / speed**2 * dt + 1e-12
previous = command
delayed.append(command)
measured += (delayed.pop(0) - measured) * dt / 0.1
road_speed = speed * math.cos(heading) / (1 - road_curvature * y)
y += speed * math.sin(heading) * dt
heading += (speed * measured - road_curvature * road_speed) * dt
samples.append((y, heading, command))
return {
'meanFinalAbsOffset': float(np.mean([abs(r[0]) for r in samples[-200:]])),
'minimumLineClearance': min(1.8 - abs(r[0]) for r in samples),
'maxAbsHeading': max(abs(r[1]) for r in samples),
'finalOffset': y,
}
class TestLaneTrackingPlant(unittest.TestCase):
def test_single_line_tracking_reduces_bias_with_kinematic_feedback(self):
for side in (0, 1):
for curvature in (0., .004, -.004):
with self.subTest(side=side, curvature=curvature):
original = run_tracking_plant(side, curvature, False)
candidate = run_tracking_plant(side, curvature, True)
self.assertLess(candidate['meanFinalAbsOffset'], original['meanFinalAbsOffset'] * .8)
self.assertGreater(candidate['minimumLineClearance'], original['minimumLineClearance'])
self.assertLessEqual(candidate['maxAbsHeading'], original['maxAbsHeading'] + .001)