1
0
forked from IQ.Lvbs/IQ.Pilot
Files
IQ.Pilot/iqdbc_repo/iqdbc/car/byd/tests/test_byd.py
2026-08-22 23:42:42 -05:00

428 lines
19 KiB
Python

#!/usr/bin/env python3
import unittest
import numpy as np
from iqdbc.can.packer import CANPacker
from iqdbc.can.parser import CANParser
from iqdbc.car.byd import bydcan
from iqdbc.car.byd.carstate import (EPS_STATE_OFF, EPS_STATE_PREPARED, EPS_STATE_ACTUATING,
EPS_STATE_LATCHED_FAULT)
from iqdbc.car.byd.fingerprints import FW_VERSIONS
from iqdbc.car.byd.interface import CarInterface
from iqdbc.car.byd.values import CAR, DBC, BydFlags, BydSafetyFlags, CarControllerParams
from iqdbc.car.fw_versions import match_fw_to_car_exact, build_fw_dict
from iqdbc.car import structs
from iqdbc.car.structs import CarParams
DBC_NAME = DBC[CAR.BYD_SEALION_7]['pt']
Ecu = CarParams.Ecu
def _unpack(dbc_name, msg_name, dat):
"""Decode one frame with the DBC, bypassing the parser's liveness tracking."""
dbc = CANParser(dbc_name, [], 0).dbc
msg = dbc.name_to_msg[msg_name]
out = {}
for sig in msg.sigs.values():
val = 0
if sig.is_little_endian:
for i in range(sig.size):
bit = sig.lsb + i
val |= ((dat[bit // 8] >> (bit % 8)) & 1) << i
else:
be_bits = [j + i * 8 for i in range(64) for j in range(7, -1, -1)]
idx = be_bits.index(sig.start_bit)
for i in range(sig.size):
bit = be_bits[idx + i]
val = (val << 1) | ((dat[bit // 8] >> (bit % 8)) & 1)
if sig.is_signed and (val & (1 << (sig.size - 1))):
val -= (1 << sig.size)
out[sig.name] = val * sig.factor + sig.offset
return out
class TestBydChecksum(unittest.TestCase):
def test_checksum_is_inverted_sum(self):
for dat in (bytearray(8), bytearray(b'\x01' * 8), bytearray(b'\xff' * 8),
bytearray(b'\x12\x34\x56\x78\x9a\xbc\xde\x00')):
self.assertEqual(bydcan.byd_checksum(0, None, dat), (~sum(dat[:7])) & 0xFF)
def test_packer_fills_checksum_and_counter(self):
packer = CANPacker(DBC_NAME)
seen = []
for _ in range(18):
_, dat, _ = packer.make_can_msg("STEERING_MODULE_ADAS", 0, {"STEER_REQ": 1})
self.assertEqual(dat[7], (~sum(dat[:7])) & 0xFF, "checksum not filled by the DBC layer")
seen.append(dat[6] >> 4) # COUNTER is 55|4@0
# rolls 0..15 and wraps, never repeating within a cycle
self.assertEqual(seen[:16], list(range(16)))
self.assertEqual(seen[16:], [0, 1])
class TestBydSteeringControl(unittest.TestCase):
def setUp(self):
self.packer = CANPacker(DBC_NAME)
def test_steer_req_and_angle_round_trip(self):
for angle in (-390.0, -100.5, 0.0, 12.3, 390.0):
for lat_active in (True, False):
_, dat, _ = bydcan.create_steering_control(self.packer, angle, lat_active)
vals = _unpack(DBC_NAME, "STEERING_MODULE_ADAS", dat)
self.assertAlmostEqual(vals["STEER_ANGLE"], angle, places=4)
self.assertEqual(vals["STEER_REQ"], 1 if lat_active else 0)
# STEER_REQ_ACTIVE_LOW is the inverse of STEER_REQ
self.assertEqual(vals["STEER_REQ_ACTIVE_LOW"], 0 if lat_active else 1)
self.assertEqual(vals["E2E_ALIVE_1"], 1)
self.assertEqual(vals["E2E_ALIVE_2"], 1)
def test_rate_limits_zeroed_when_inactive(self):
_, dat, _ = bydcan.create_steering_control(self.packer, 0.0, True)
vals = _unpack(DBC_NAME, "STEERING_MODULE_ADAS", dat)
self.assertEqual(vals["ANGLE_RATE_LIMIT_UPPER"], bydcan.ANGLE_RATE_LIMIT_UPPER)
self.assertEqual(vals["ANGLE_RATE_LIMIT_LOWER"], bydcan.ANGLE_RATE_LIMIT_LOWER)
_, dat, _ = bydcan.create_steering_control(self.packer, 0.0, False)
vals = _unpack(DBC_NAME, "STEERING_MODULE_ADAS", dat)
self.assertEqual(vals["ANGLE_RATE_LIMIT_UPPER"], 0)
self.assertEqual(vals["ANGLE_RATE_LIMIT_LOWER"], 0)
class TestBydLkasHud(unittest.TestCase):
def setUp(self):
self.packer = CANPacker(DBC_NAME)
# a stock frame with bits set in every field we touch and several we must not
self.stock = {
"HMA_STATE": 3, "LEFT_LANE_STATE": 1, "LKS_MODE": 2, "HANDS_ON_WHEEL_REQ": 1,
"TJA_ICA_STATE": 5, "HMA_ON_OFF": 1, "LKAS_OUTPUT": -20, "LKAS_REQ_PREPARE": 1,
"LKAS_ACTIVE": 1, "SLA_STATE": 3, "RIGHT_LANE_STATE": 1, "LKAS_STATE": 0b1000,
"SPEED_LIMIT_VALUE": 100, "LDSW_TYPE": 2, "COUNTER": 9, "CHECKSUM": 0x11,
}
def test_passes_stock_bits_through(self):
# The ADAS modules cross-check this frame; every bit we do not own must survive.
_, dat, _ = bydcan.create_lkas_hud(self.packer, False, self.stock, None)
vals = _unpack(DBC_NAME, "LKAS_HUD_ADAS", dat)
for name in ("HMA_STATE", "LKS_MODE", "HANDS_ON_WHEEL_REQ", "TJA_ICA_STATE", "HMA_ON_OFF",
"LKAS_OUTPUT", "LKAS_REQ_PREPARE", "LKAS_ACTIVE", "SLA_STATE",
"SPEED_LIMIT_VALUE", "LDSW_TYPE"):
self.assertEqual(vals[name], self.stock[name], f"{name} was modified")
def test_hands_on_wheel_req_never_cleared(self):
for lat_active in (True, False):
_, dat, _ = bydcan.create_lkas_hud(self.packer, lat_active, self.stock, None)
vals = _unpack(DBC_NAME, "LKAS_HUD_ADAS", dat)
self.assertEqual(vals["HANDS_ON_WHEEL_REQ"], 1)
def test_active_asserts_eps_arming_bits_only(self):
_, dat, _ = bydcan.create_lkas_hud(self.packer, True, self.stock, None)
vals = _unpack(DBC_NAME, "LKAS_HUD_ADAS", dat)
# low 2 bits become 0b10, the stock upper 2 bits are preserved
self.assertEqual(int(vals["LKAS_STATE"]), 0b1010)
self.assertEqual(int(vals["LEFT_LANE_STATE"]), 1 | 2)
self.assertEqual(int(vals["RIGHT_LANE_STATE"]), 1 | 2)
def test_counter_not_inherited_from_stock(self):
# inheriting the camera's counter would make our 50 Hz stream non-monotonic
counters = []
for _ in range(4):
_, dat, _ = bydcan.create_lkas_hud(self.packer, True, self.stock, None)
counters.append(int(_unpack(DBC_NAME, "LKAS_HUD_ADAS", dat)["COUNTER"]))
self.assertNotEqual(counters, [self.stock["COUNTER"]] * 4)
self.assertEqual(counters, [(counters[0] + i) % 16 for i in range(4)])
def test_checksum_recomputed_not_inherited(self):
_, dat, _ = bydcan.create_lkas_hud(self.packer, True, self.stock, None)
self.assertEqual(dat[7], (~sum(dat[:7])) & 0xFF)
class TestBydAccCmd(unittest.TestCase):
def setUp(self):
self.packer = CANPacker(DBC_NAME)
self.stock = {"ACCEL_CMD": 0.0, "COUNTER": 7, "CHECKSUM": 0x22}
def test_accel_scale_is_physical(self):
# raw x 0.05 - 5 m/s^2, so 0 m/s^2 is raw 100
for accel in (-3.0, -1.5, 0.0, 0.5, 1.5):
_, dat, _ = bydcan.create_acc_cmd(self.packer, accel, True, self.stock)
self.assertEqual(dat[0], round((accel + 5.0) / 0.05))
vals = _unpack(DBC_NAME, "ACC_CMD", dat)
self.assertAlmostEqual(vals["ACCEL_CMD"], accel, places=6)
def test_inactive_commands_zero_accel(self):
_, dat, _ = bydcan.create_acc_cmd(self.packer, -2.0, False, self.stock)
vals = _unpack(DBC_NAME, "ACC_CMD", dat)
self.assertEqual(vals["ACCEL_CMD"], 0.0)
self.assertEqual(dat[0], 100)
self.assertEqual(vals["ACC_ON_1"], 0)
self.assertEqual(vals["ACC_ON_2"], 0)
self.assertEqual(vals["ACC_CONTROLLABLE_AND_ON"], 0)
self.assertEqual(vals["CMD_REQ_ACTIVE_LOW"], 1)
def test_standstill_hold_and_resume(self):
_, dat, _ = bydcan.create_acc_cmd(self.packer, -0.5, True, self.stock, standstill=True)
vals = _unpack(DBC_NAME, "ACC_CMD", dat)
self.assertEqual(vals["STANDSTILL_STATE"], 1)
self.assertEqual(vals["ACC_OVERRIDE_OR_STANDSTILL"], 1)
self.assertEqual(vals["ACC_REQ_NOT_STANDSTILL"], 0)
self.assertEqual(vals["STANDSTILL_RESUME"], 0)
_, dat, _ = bydcan.create_acc_cmd(self.packer, 0.5, True, self.stock, standstill=True, resume=True)
vals = _unpack(DBC_NAME, "ACC_CMD", dat)
self.assertEqual(vals["STANDSTILL_RESUME"], 1)
self.assertEqual(vals["STANDSTILL_STATE"], 0)
self.assertEqual(vals["ACC_REQ_NOT_STANDSTILL"], 1)
def test_regime_pairs(self):
for accel, expected in ((0.0, (0, 0)), (0.05, (0, 0)), (0.8, (12, 5)),
(-1.0, (13, 1)), (-2.5, (1, 1))):
_, dat, _ = bydcan.create_acc_cmd(self.packer, accel, True, self.stock)
vals = _unpack(DBC_NAME, "ACC_CMD", dat)
self.assertEqual((int(vals["ACCEL_FACTOR"]), int(vals["DECEL_FACTOR"])), expected, f"{accel=}")
def test_accel_within_safety_bounds(self):
# the comfort envelope must stay inside what byd.h allows (-3.5 .. +2.0)
self.assertGreaterEqual(CarControllerParams.ACCEL_MIN, -3.5)
self.assertLessEqual(CarControllerParams.ACCEL_MAX, 2.0)
class TestBydEpsState(unittest.TestCase):
"""The 0x1FC decode is the core fix over the Sealion 7 PR, which inherited a stub that
packed these status bits into a fake 16-bit torque value."""
def test_state_nibble_table(self):
for prepared, activated, expected in (
(0, 0, EPS_STATE_OFF),
(1, 0, EPS_STATE_PREPARED),
(0, 1, EPS_STATE_ACTUATING),
(1, 1, EPS_STATE_LATCHED_FAULT),
):
self.assertEqual(EPS_STATE_OFF + prepared + 2 * activated, expected)
def test_steering_torque_signals_exist_and_are_signed(self):
dbc = CANParser(DBC_NAME, [], 0).dbc
sigs = dbc.name_to_msg["STEERING_TORQUE"].sigs
for name in ("LKS_PREPARED", "CRUISE_ACTIVATED", "TORQUE_FAILED", "DRIVER_TORQUE",
"TARGET_ANGLE", "MAIN_TORQUE"):
self.assertIn(name, sigs, f"{name} missing from STEERING_TORQUE")
# driver torque must be signed or override detection cannot see direction
self.assertTrue(sigs["DRIVER_TORQUE"].is_signed)
self.assertTrue(sigs["MAIN_TORQUE"].is_signed)
self.assertEqual(sigs["DRIVER_TORQUE"].start_bit, 4)
self.assertEqual(sigs["DRIVER_TORQUE"].size, 12)
self.assertEqual(sigs["MAIN_TORQUE"].start_bit, 32)
self.assertEqual(sigs["MAIN_TORQUE"].size, 12)
def test_driver_torque_decodes_negative(self):
packer = CANPacker(DBC_NAME)
for torque in (-20.0, -0.5, 0.0, 0.5, 20.0):
_, dat, _ = packer.make_can_msg("STEERING_TORQUE", 0, {"DRIVER_TORQUE": torque})
vals = _unpack(DBC_NAME, "STEERING_TORQUE", dat)
self.assertAlmostEqual(vals["DRIVER_TORQUE"], torque, places=4)
class TestBydWheelSpeeds(unittest.TestCase):
def test_four_independent_wheels(self):
dbc = CANParser(DBC_NAME, [], 0).dbc
sigs = dbc.name_to_msg["WHEEL_SPEEDS"].sigs
for name, start in (("FL", 0), ("FR", 16), ("RL", 28), ("RR", 40)):
self.assertEqual(sigs[name].start_bit, start)
self.assertEqual(sigs[name].size, 12)
self.assertAlmostEqual(sigs[name].factor, 0.0725)
def test_wheel_speeds_round_trip(self):
packer = CANPacker(DBC_NAME)
_, dat, _ = packer.make_can_msg("WHEEL_SPEEDS", 0, {"FL": 50.0, "FR": 51.0, "RL": 52.0, "RR": 53.0})
vals = _unpack(DBC_NAME, "WHEEL_SPEEDS", dat)
for name, expected in (("FL", 50.0), ("FR", 51.0), ("RL", 52.0), ("RR", 53.0)):
self.assertAlmostEqual(vals[name], expected, delta=0.0725)
class TestBydFingerprint(unittest.TestCase):
def test_placeholder_never_matches_a_real_car(self):
# a platform whose ECU dict is empty survives as a candidate for EVERY car, so the
# placeholder must be a version no car reports rather than an empty dict
self.assertTrue(FW_VERSIONS[CAR.BYD_SEALION_7], "empty ECU dict would match every car")
live = build_fw_dict([CarParams.CarFw(ecu=Ecu.engine, fwVersion=b'REAL_CAR_FW', brand='byd',
address=0x7e0, subAddress=0)])
self.assertNotIn(str(CAR.BYD_SEALION_7), match_fw_to_car_exact(live, 'byd'))
def test_fuzzy_match_requires_vds(self):
# WMI + model year alone would claim every BYD of that year
from iqdbc.car.byd.values import match_fw_to_car_fuzzy
self.assertEqual(CAR.BYD_SEALION_7.config.vds_prefixes, set())
vin = "LGX" + "A" * 6 + "R" + "A" * 7 # LGX, 2024 model year
self.assertEqual(match_fw_to_car_fuzzy({}, vin, {}), set())
class TestBydCarController(unittest.TestCase):
"""The EPS latches a fault (state 11) if the 0x1E2 stream stops while it is actuating, and
re-arms only on a STEER_REQ rising edge over a continuous stream. The safety also statically
blocks the camera's own 0x1E2/0x316, so openpilot is the only source of both."""
def _run(self, lat_active, long_active=False, frames=20):
CP = CarInterface.get_non_essential_params("BYD_SEALION_7")
CP_IQ = CarInterface.get_non_essential_params_iq(CP, "BYD_SEALION_7")
CC_obj = structs.CarControl()
CC_obj.enabled = lat_active
CC_obj.latActive = lat_active
CC_obj.longActive = long_active
CC = CC_obj.as_reader()
CC_IQ = structs.IQCarControl()
carcontroller = CarInterface.CarController({'pt': DBC_NAME}, CP, CP_IQ)
carstate = CarInterface.CarState(CP, CP_IQ)
parsers = CarInterface.CarState.get_can_parsers(CP, CP_IQ)
cs_out, _ = carstate.update(parsers)
class _CS:
pass
cs = _CS()
cs.out = cs_out
cs.lkas_hud = carstate.lkas_hud
cs.acc_cmd = carstate.acc_cmd
cs.buttons = carstate.buttons
sent = []
for i in range(frames):
_, can_sends = carcontroller.update(CC, CC_IQ, cs, i * 10_000_000)
sent.append([addr for addr, _, _ in can_sends])
return sent
def test_steering_stream_is_continuous_when_inactive(self):
for lat_active in (True, False):
sent = self._run(lat_active)
steering = [i for i, addrs in enumerate(sent) if 0x1E2 in addrs]
hud = [i for i, addrs in enumerate(sent) if 0x316 in addrs]
# every other frame, whether or not lateral is active
self.assertEqual(steering, list(range(0, 20, 2)), f"{lat_active=}")
self.assertEqual(hud, list(range(0, 20, 2)), f"{lat_active=}")
def test_steer_req_gates_actuation_not_transmission(self):
CP = CarInterface.get_non_essential_params("BYD_SEALION_7")
CP_IQ = CarInterface.get_non_essential_params_iq(CP, "BYD_SEALION_7")
carcontroller = CarInterface.CarController({'pt': DBC_NAME}, CP, CP_IQ)
for lat_active in (False, True):
_, dat, _ = bydcan.create_steering_control(carcontroller.packer, 0.0, lat_active)
vals = _unpack(DBC_NAME, "STEERING_MODULE_ADAS", dat)
self.assertEqual(vals["STEER_REQ"], 1 if lat_active else 0)
def test_no_acc_cmd_without_openpilot_longitudinal(self):
sent = self._run(True, long_active=True)
self.assertFalse(any(0x32E in addrs for addrs in sent),
"0x32E sent while openpilotLongitudinalControl is off")
class TestBydLowSpeedAngleRate(unittest.TestCase):
"""Regression for the 2026-08-05 EPS latch. At 0.29 m/s the planner oscillated and the command
swung -5.9 to +2.4 deg against a stationary wheel in 220 ms; the EPS went from state 9 straight
to a latched 11 and took LKAS with it. The vehicle-model jerk limit cannot catch this because
it scales as 1/v^2."""
def _slew(self, v_ego, targets):
CP = CarInterface.get_non_essential_params("BYD_SEALION_7")
CP_IQ = CarInterface.get_non_essential_params_iq(CP, "BYD_SEALION_7")
cc = CarInterface.CarController({'pt': DBC_NAME}, CP, CP_IQ)
carstate = CarInterface.CarState(CP, CP_IQ)
cs_out, _ = carstate.update(CarInterface.CarState.get_can_parsers(CP, CP_IQ))
cs_out.vEgoRaw = v_ego
cs_out.vEgo = v_ego
cs_out.steeringAngleDeg = 0.1
class _CS:
pass
cs = _CS()
cs.out = cs_out
cs.lkas_hud = carstate.lkas_hud
cs.acc_cmd = carstate.acc_cmd
cs.buttons = carstate.buttons
CC_obj = structs.CarControl()
CC_obj.enabled = True
CC_obj.latActive = True
CC_IQ = structs.IQCarControl()
sent = []
for i, tgt in enumerate(targets):
CC_obj.actuators.steeringAngleDeg = tgt
cc.update(CC_obj.as_reader(), CC_IQ, cs, i * 10_000_000)
sent.append(cc.apply_angle_last)
return sent
# the actual planner output recorded during the fault
OSCILLATION = [-2.9, -5.9, -2.9, 0.1, 1.7, 1.8, 2.4, 2.2] * 3
def test_standstill_slew_is_bounded(self):
v = 0.29 # the speed at which the EPS latched
sent = self._slew(v, self.OSCILLATION)
cap = float(np.interp(v, CarControllerParams.ANGLE_RATE_BP, CarControllerParams.ANGLE_RATE_V))
steps = [abs(b - a) for a, b in zip(sent, sent[1:], strict=False)]
self.assertLessEqual(max(steps), cap + 1e-6,
"command slews faster than the standstill rate cap")
# the uncapped path stepped a full 3.0 deg/frame here
self.assertLess(cap, 1.0)
# and it must never wander far from the stationary wheel
self.assertLess(max(abs(a - 0.1) for a in sent), 2.0,
"command diverged from the measured angle at a standstill")
def test_rate_cap_scales_with_speed(self):
slow = self._slew(0.0, [30.0] * 10)
fast = self._slew(20.0, [30.0] * 10)
slow_step = max(abs(b - a) for a, b in zip(slow, slow[1:], strict=False))
fast_step = max(abs(b - a) for a, b in zip(fast, fast[1:], strict=False))
self.assertLess(slow_step, fast_step, "low-speed cap must be tighter than at speed")
self.assertLessEqual(fast_step, CarControllerParams.ANGLE_LIMITS.MAX_ANGLE_RATE + 1e-6)
class TestBydHarnessType(unittest.TestCase):
"""Longitudinal requires the ACC ECU to sit behind the relay so 0x32E is filterable. That is
a property of the harness, and it cannot be inferred from the fingerprint: fingerprinting
runs with the relay closed, which ties bus 2 to bus 0, so bus 2 shows the whole car either
way. Default must therefore be the camera harness (lateral only)."""
@staticmethod
def _params(cam_bus_addrs, alpha_long=True):
fp = {0: {0x1FC: 8, 0x1F0: 8}, 1: {}, 2: dict.fromkeys(cam_bus_addrs, 8)}
return CarInterface.get_params("BYD_SEALION_7", fp, [], alpha_long, False, False)
def test_defaults_to_camera_harness_lateral_only(self):
CP = self._params([0x1E2, 0x316])
self.assertFalse(CP.flags & BydFlags.GATEWAY_HARNESS)
self.assertFalse(CP.alphaLongitudinalAvailable)
self.assertFalse(CP.openpilotLongitudinalControl)
self.assertFalse(CP.safetyConfigs[0].safetyParam & BydSafetyFlags.LONG_CONTROL)
def test_acc_cmd_on_fingerprint_bus2_does_not_imply_gateway(self):
# the relay is closed while fingerprinting, so bus 2 sees the chassis bus too. Seeing
# 0x32E there must NOT unlock longitudinal.
CP = self._params([0x1E2, 0x316, 0x32E, 0x32D, 0x1FC])
self.assertFalse(CP.flags & BydFlags.GATEWAY_HARNESS)
self.assertFalse(CP.alphaLongitudinalAvailable)
self.assertFalse(CP.openpilotLongitudinalControl)
def test_lateral_still_available_on_camera_harness(self):
CP = self._params([0x1E2, 0x316])
self.assertFalse(CP.dashcamOnly)
self.assertEqual(CP.steerControlType, CarParams.SteerControlType.angle)
class TestBydCarParams(unittest.TestCase):
def test_angle_control_and_no_radar(self):
CP = CarInterface.get_non_essential_params("BYD_SEALION_7")
self.assertEqual(CP.brand, "byd")
self.assertEqual(CP.steerControlType, CarParams.SteerControlType.angle)
self.assertEqual(CP.safetyConfigs[0].safetyModel, CarParams.SafetyModel.byd)
# the BYD-6 harness jumpers the Veoneer private CAN-FD pair straight through
self.assertTrue(CP.radarUnavailable)
self.assertFalse(CP.dashcamOnly)
def test_steer_step_matches_safety_frequency(self):
# byd.h declares .frequency = 50U for the angle limiter
self.assertEqual(CarControllerParams.STEER_STEP, 2)
if __name__ == "__main__":
unittest.main()