From 67a2b4955ee36d5708255b3148d04513899bf2e7 Mon Sep 17 00:00:00 2001 From: "IQ.Lvbs CI [bot]" Date: Mon, 31 Aug 2026 10:53:00 -0500 Subject: [PATCH] IQ.Pilot Release Commit @ 9ba2ea7 --- .../iqpilot_updater_private/manifest.json | 20 +- .../iqpilot_updater_private/manifest.json.sig | 2 +- ...maps_blob.cpython-312-aarch64-linux-gnu.so | Bin 0 -> 67616 bytes ..._pat_blob.cpython-312-aarch64-linux-gnu.so | Bin 67616 -> 67616 bytes ..._pat_blob.cpython-312-aarch64-linux-gnu.so | Bin 67616 -> 67616 bytes ...it_remote.cpython-312-aarch64-linux-gnu.so | Bin 136576 -> 136608 bytes .../iqdbc/iqdbc/car/honda/interface.py | 7 +- .../iqdbc/iqdbc/car/honda/radar_interface.py | 17 +- .../iqdbc/iqdbc/car/honda/radar_scan.py | 396 ++++++++++++++ .../iqdbc/car/honda/tests/test_radar_scan.py | 490 ++++++++++++++++++ .../car/honda/tests/test_radar_scan_dbc.py | 84 +++ .../iqdbc/iqdbc/car/honda/values.py | 24 +- .../dbc/generator/honda/honda_radar_scan.dbc | 436 ++++++++++++++++ .../dbc/generator/honda/honda_radar_scan.py | 109 ++++ iqpilot/common/params_keys.h | 1 + iqpilot/iq_maps/tile_bundle_downloader.py | 35 +- iqpilot/selfdrive/car/interfaces.py | 17 +- iqpilot/selfdrive/controls/radard.py | 214 +++++++- .../controls/tests/test_scan_radar_leads.py | 191 +++++++ iqpilot/selfdrive/iqmodeld/egpu_prefetch.py | 17 +- .../ui/mici/layouts/settings/models.py | 9 +- iqpilot/system/manager/process_config.py | 7 +- 22 files changed, 2011 insertions(+), 65 deletions(-) create mode 100755 artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_hf_maps_blob.cpython-312-aarch64-linux-gnu.so create mode 100644 artifacts/package_sources/iqdbc/iqdbc/car/honda/radar_scan.py create mode 100644 artifacts/package_sources/iqdbc/iqdbc/car/honda/tests/test_radar_scan.py create mode 100644 artifacts/package_sources/iqdbc/iqdbc/car/honda/tests/test_radar_scan_dbc.py create mode 100644 artifacts/package_sources/iqdbc/iqdbc/dbc/generator/honda/honda_radar_scan.dbc create mode 100644 artifacts/package_sources/iqdbc/iqdbc/dbc/generator/honda/honda_radar_scan.py create mode 100644 iqpilot/selfdrive/controls/tests/test_scan_radar_leads.py diff --git a/artifacts/iqpilot_updater_private/manifest.json b/artifacts/iqpilot_updater_private/manifest.json index 2fc01871e..44989ecc9 100644 --- a/artifacts/iqpilot_updater_private/manifest.json +++ b/artifacts/iqpilot_updater_private/manifest.json @@ -14,20 +14,25 @@ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "size": 0 }, + "python/iqpilot_private/updater/_hf_maps_blob.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "f18e3a7886a2e6835c91936cc4a11b118c02576f827683cff2a8d8788c3d1628", + "size": 67616 + }, "python/iqpilot_private/updater/_iqos_pat_blob.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "949c976482e5168aa1898087a5a0c02f523e4b8a43940eaadba56e5df81819f2", + "sha256": "9bb73ab877e3c87f415b07a780c807f6f686bf5d5c01f4f4866260d2812f0b8d", "size": 67616 }, "python/iqpilot_private/updater/_pat_blob.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "908a448f4ddad2d95ef524a771ee0038c62e72c44598cc8db2755c178b950de0", + "sha256": "8e33ab06991537400b3c50d2bfe3483627b50c999d6ad0c44d27d09eacac9235", "size": 67616 }, "python/iqpilot_private/updater/git_remote.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "c3495a4480205bbf1e21e7ea0b55a27409be1da68e974ef070946bfd726c8c90", - "size": 136576 + "sha256": "55db05006382a9212c300db2236590260b66489d904e19656f2cf03309de89bf", + "size": 136608 }, "runtime": { "entries": { @@ -38,8 +43,9 @@ } }, "signatures": { - "python/iqpilot_private/updater/_iqos_pat_blob.cpython-312-aarch64-linux-gnu.so": "2BgXuTkWa3sjvPsyTgFHVBaZYOE+6MmRE+/lUlINyD05fHG6qgf6GJPeYyy6TPNZqhBWoI5z/NmRjAoVzzsSDQ==", - "python/iqpilot_private/updater/_pat_blob.cpython-312-aarch64-linux-gnu.so": "EvASRs8Z45SknoiFmd9FpFV0boTy2L+OIJqEQMfVq88jglU/RVs5RNg/JW3SrghodMvxgTIXzBrZy2OggDPRCQ==", - "python/iqpilot_private/updater/git_remote.cpython-312-aarch64-linux-gnu.so": "wbN3CwOrLv0iX3a2nSmr4dTr3kEHrSEPmQ+S6IiccUzgVo8rBQXRe0dduyiQZzXrnEBYRAvz66WXfyWPrWs8AQ==" + "python/iqpilot_private/updater/_hf_maps_blob.cpython-312-aarch64-linux-gnu.so": "8MfmjOuuJgGPd2959u6zSnbsfCGIcY8ut4vH4JW//Vqb51508zKFSZYbmsKYFSYmUYcsNrDdKlzG7cyxwi25CQ==", + "python/iqpilot_private/updater/_iqos_pat_blob.cpython-312-aarch64-linux-gnu.so": "7ygpLdx/E5TgYoL8OINkv4mYLVkAHlAG0Wh4ZBnXCdTi+fd1bySI+7WmQczoQMlk0VjtIqyJTus+tsRkXEjQDg==", + "python/iqpilot_private/updater/_pat_blob.cpython-312-aarch64-linux-gnu.so": "79iU094xiuJ25sF9qXaLqTS/f9AidQhDoTlj2HVBIWt6biVmBY0MpgHOXK7lhCskykR7H/8j9zp2SsA2dToaDA==", + "python/iqpilot_private/updater/git_remote.cpython-312-aarch64-linux-gnu.so": "rxKmOjUbaJRdo65i8bDae/zEDUXIWvOay4k+KJ3vuH1Z+jQQvRXuWjKAMtJX7a7v/zzTcg7ijY2CBfXEFZJRCQ==" } } diff --git a/artifacts/iqpilot_updater_private/manifest.json.sig b/artifacts/iqpilot_updater_private/manifest.json.sig index 75f9dd1bf..76f1c4305 100644 --- a/artifacts/iqpilot_updater_private/manifest.json.sig +++ b/artifacts/iqpilot_updater_private/manifest.json.sig @@ -1 +1 @@ -JJCbPdLrTbJu+aVPIcsTJTbsltzP0Zl97r6EM77B7ebCsnS9yU1Tx9+NPui1OVVPiQAigyDxi/TZZyIYBWigDw== +0OdQnuSdywA/tTTIVWGwz2gpZ5H50RoDjjWrXlgYDivBPpTU83nj3jbFtFenrzVNDouRC/PbTvEFNwcnHAe8DA== diff --git a/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_hf_maps_blob.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_hf_maps_blob.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 0000000000000000000000000000000000000000..bd904d978eb84635c1df98bd72459bb3f09294ef GIT binary patch literal 67616 zcmeHNe^gZ0oxd}Kh>{pUerg&Xs4+_tqQ)Pj&5s8tXc8jv}rv}(`zM2&daLm37k>T1PnLrsOM8Rn`Cl8G#WLo8SZ~9K*lXc6OkMwNV=QY*oP2r~PrTyk*JogVHJoGG zbYsZZB(Z-f3BDo;9zv|E)msI;_$u{oOk)2N5PP*a|Cj{tOM*8iiN8FFeJ^OgTD_;y z-mCGyB}tq&lGrayVjoYE=NFRj{Np6{|B@trd6GDfCu#3nN#Z|~q+O_%X}G6fo1n;? z4njVdB+vJuyBhyLPm*VCl6GY#v9C#jzmo)iDoLKhNqBf9Nt}I2+V!m@_Ld~=x|AfJ ztR(okB=NtSB%jtK_OT@KtCQfjAijos`n5Po{C`ivXIT>ao09P3NP@qWBz~8Hr?bu? zGt+Bi!gl-SR-e}%3^@WJyPetB)vvd^+yVDyPcY;T)UU5>@p;|#j>Z=1ei{}wHtLa$R>h~dDxy!ZQ=W64=H68ZVZbwUt&k6O0#w~7V$X@LZm4`xsx=_I5 z-E5dVfXX1YIYY2o?SW05JG3t3ZWT5)9c$X1cJAb9Yzw*91OmPQEK%B_aQj0ZpLe|@ z5U9YTq| zGYipu)4L32>)oxsK*vKK_jV(+DFPQwszhH`wRxSqM*x)vHp?EV@&#HQyaQxrtKB@o zjrCP!3TX6>cb&JU#o^>5N`zhO2sT3!If897L4)&2BS9zZj3ET8D~6CduA~ho7-Li& zB^$jMM$WBnmpNR}K>K<*fU5$IR@p1{0c2RgJ9{nW4Fd9x+2C}x1p;o~?UkH;#9!0u z4|Q<09H@M38_tbzhJx2nE9QtgewrMf7O2;GJ(!`*P4-rYKWJ}k@ilTEV|uN2H`%$G z`2)^S3v2TE-CowT*&Pb9U?>ps0dltZf|!Qxc38D|8l9yC}TYLvdeE$NozGKsHUEp$`D($7*FZQnz z*B6;_PENzetyti3#irp*;Ax_iaR&3uIQi3XSAmH?lQ0>wpk3;BL#KkLo2JI!BMP3a z;N1#dsNlyGTvPCV1y?_9PAK@ziv6I1-=^Rv6@00J#}s^pf-{U8ep%68boblC~}!PR|bvx2MpQ@?_%`~7wWS3lXp3ZA3XyH~+;6@0&fi=k|2pHc99#lBO) z#Sk~NM-+UXV&ARc^A-GZG;86T+(+Kf zoz1lB@eC_7bcUP?xmb~BL#|NdIglF_IScY`Mb3qMSdr&J9#G^PAde_=F=VS{s{R`x z7b|iJ$Q6q0h1{sf0m!=*c^l-ziu?%V z0Y!cc@`xhuhHOols{fxL7c24?AXg~zmmxPQ@)MAEEArEj4=eJskOvg`>ySqj`7mVb zjH&v+3AtF2qmU~U`8$vs75V#+cPsLXkPj>J4 zFEUntFLVo1SmszFk@p*$rDPN(kHKKaU{?scqHpY)7lV$^S2>68Oe79;GS*{ZdS6ir zTdE@#c3ORMUdK#b{16*o1^w&L#mgD%y&Lh(I6pMjg3&rl*QhO}Wb9#U*O<)`y_qgW*SX)El<4csP;hjlr%EG2^J;i1mkR{KTfmKMpL+*z&a~@pEl0c2C&$ zg|kNM%DcuMuIQTB@grlMn%FUwFVBhRdmr~n)8j=b3sczl8f>h71nX4!2|a!fWC!xp zPp~-lJ=ulKat2%QdZX^s)p79AUDQ7(!gIJ5{@CkeLw7buE04EXn31nxqi00^1MRzj zIvWf;C)x%3xhy<{^)8WWezP&g z=6`rDa2p05S?AoyGT1#;!6M;Q7T4yn9s`ffjoc#r3s}$YRNWXC4al`f%Z_YC{r&ml ztBf|rFkT-E4}~3_=N6+K@4wytM2xYY72fGThhwICv##(Euj3H>!gE+WW@WuE@^SU| z8Fl#f>3;w1O~3p}Lv{rF{O;#Ev(c}t?|h{t@;OVm)(I{w1>qsyf4uK2^qk0c#2JQM z48AZYhVJ9}TuQ7wgqYadcK22kAVv)(#e?!T)joqbzN58rhV_Iz$>nXCtU*lzttxsk_!jRWgUe#{{mufdo@Ini|J7<{D;A6!^}cwPC?6zIY{ z51Z*1=6Rq$IKLAUeEu5qeUOQ@paN|ziseQYpdT3e*9Ctb=Q|=!IBhD9mS)B|gE)61 z&e55yhxh*nyIF+y|0O4jy3P4*XePzp=Z~e{C)W|S)|uGf1j4m;)OS}KOdh+)blvIGQ&f7J`k>8@!nihkI~{$0 z9_y1i=CZLa?!)`42|oS6 z`Fqi@zXA3YdVCys*gC%IPbhg_7-v1hcSFaY5%(KHkrI8tVz84&K*%UK~he%U2^FpC@~; z=ld!2d!NwzMhxGXc%P<~oQJ;9!nXc#Cf=Qc+CKK^qfZv`eSp=nwH(;_=7w?PYQ9Ub zkHC2C<9)P!HtS`#>+!tXS)7men5`f>%P4cB+mOqf$YBQLpQ7HyXd4TMhh9Tn;aRM= z5qds``MkAZ3{>D;pw(=U0zxa-tT-}du&-5r(q_Bo*U@z_O(bJ1$ajM*@r5dSpn z-bedmXA?cE4zNCL1J*wHuui=X>ubpf@^}|x>u0D7bw2nW_LHt`y=!&$%SS#y{PzY| z?t2q)_!!}NVQtN96Ky@;ymDOcWPR*U2_sK0>^YAGxW9&H^mjMIJO91Mb-c^Vju7j_5q*l!k!_*_n(L{$Qn{3k6cRR@o&t8 zZ3gzJ;acq9x+@SnjOU3RnOzf=DUnLl@!lR5=VN#d_~w1Za2NGsti|R<{uy=RUD`AD z@Z7Gi-23IB6)n%7>%IRM=SJ(Yy7(9wUBDtaC4W5k^!P59jmZ=hb{)*>e{AcZTowNq!akBF0`VzO?g~<=5=mS9GcT$>Xp$ zxISAQZ!~F{bkZ%c>P=+;_%4i^k?(BuQ=<`43yO!K<~nm^!kx`RRd@Z-ff z2(QO(H+%v(LNqQ@;&_t>=T`z{%op%%_INq&$DxN~vsC#V0k=0~;-Tg#`(Rt6nV&s~ zb3A7=s&P8_nGzO0ewpVV+TttDMHXw#j-cjf;U|8)J#Me&F%Bf%u2QYmeX9pYB_14A z28~D>IPm&HTCmN}4=rJXxQ%U|mXJr*!kDuo)Qn@&fO&k!^OP25t}dV3jDn*rC;l^r z=QDm!i!WryY27v)IhMBhT_^&jSLBI$oW52Vx;1_%sMQ#iXxq%gKW&k#bdgK*HOU@Y zyr^{Pk|n{#+M=K)61mbA@mx4dYsH~vQ1J5z&Uyf|Dx4vqN70`q6B>doTM{gtvR{nx zfHAPTtHvo3j%b6Wnt24O$x6@>+IDd5^m&^+o7-^UrET|wnlZ54TBX+Na0YzZ z;>u6euifx~y>i3)n%XsWb@saY+I0_9*WI^44RZwtCxnr_3vY^Y7p!;JH{K#j*G ze(bZrUdPQ#?8mTeH2*&gKNH|GFZu8H@)^H1uv>7qpIWr%)xhp0Y|YCj3T~e9hr_?L zq?Bl{>^*nVRgso8OIx%$cIK4}xBTL+i?5!X`R@NXaZ zzdB%hy6Ds!Z=L+w#pm|CI{wWYjyf{$e|E#S@=yGr^-x!+_TG2D`p##w4*ufzr}Ff9 zFU*=X{OxVufBg39ZEqJ2JpW$P&0ndCrumP(cya9gx#wSK_#v807coE#5Cg;jF+dCu z1H=F^KnxHA!~iis3=jjv05L!e5Cg;jF+dCu1H=F^KnxHA!~iis3=jjv05L!e5Cg;j zF+dCu1H=F^KnxHA!~iis3=jjv05L!e5Cg;jF+dCu1H=F^KnxHA!~iis3=jjv05L!e z5Cg;jF+dCu1H=F^KnxHA!~iis3=jjv05L!e5Cg;jF+dCu1H=F^KnxHA!~iis3=jjv z05L!e5Cg;jF+dCu1H=F^KnxHA!~iis3=jjv05L!e5Cg;jF+dCu1H=F^KnxHA!~iis z3=jjv05L!e5Cg;jF+dCu1H=F^KnxHA!~iis3=jjv05L!e5Cg;jF+dCu1H=F^KnxHA z!~iis3=jjv05L!e5Cg;jF+dCu1H=F^KnxHA!~iis3=jjv05L!e5Cg;jF+dCu1H=F^ zKnxHA!~iis3=jjv05L!e5Cg;jF+dCu1H=F^KnxHA!~iis3=jjv05L!e{6A)(yU1+( z{sm^Ki_#)E8MB!WQcei@8llgaYd%~n_m8$C*<}y=7Yq$ zMZ;yDCq#a;gnhm!FDK0W{lY#WWLfW7A)ghA#)T~7h%kS%qF?a*Hw*X|0xc2af=ZL9 z|0^E$TFeoL@n>9Oxc-ISV@zLkb(3+c7{Ri)$GuA~4AcEegU!KEAmnId zrJKENrOl3DGb?ple^BaR7wX=g^8>OYC^fr9s)tY5w#`-T4?Z=9l+ zeloB9&~X##zhCSt_9wU(W1Gxk@~`xlS?>qS(*? literal 0 HcmV?d00001 diff --git a/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_iqos_pat_blob.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_iqos_pat_blob.cpython-312-aarch64-linux-gnu.so index beca065eb46c3ec6b0e3ec80904f09e1ec6289fb..260d66a4b87b6caea248c943fb2a9c8baeaad01f 100755 GIT binary patch delta 39 vcmZ25fn~u2mJM-g66Tg>2B{_n1}SNl$*C!(=B7qwsRm|7Nt?^mRy_m&`Ued4 delta 39 vcmZ25fn~u2mJM-g5~)d+CMjuY$refGW@gEj7N(X4CPpU7CY#IDRy_m&2A~Z# diff --git a/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_pat_blob.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/_pat_blob.cpython-312-aarch64-linux-gnu.so index caf61ce78baaf74b8d2060e199ef4b4ac766225f..ac5c3e8a2aeea09be229f2a52781ccca32535041 100755 GIT binary patch delta 39 vcmZ25fn~u2mJNPt5{70e=BAd3DM{u@DQPLGmSz@ark2S`Nt@Huc0B|D1nmv9 delta 39 vcmZ25fn~u2mJNPt5(cKJ7Kz4&<_5-QX~qVo28M=-1{Oxi$(z&Ec0B|D?t2Vp diff --git a/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/git_remote.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_updater_private/python/iqpilot_private/updater/git_remote.cpython-312-aarch64-linux-gnu.so index f1f2a6d89897c536458ffab32d86fb1ed2cc74bf..7b8b609a07cc991910fee72ca031cd846b7ea4fa 100755 GIT binary patch delta 26771 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

ms&TEO87PhhCN{$P;U42@G(HBeheq=*J2Eat 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 N9?EN(X_%`J`9HNlR0RM4 diff --git a/artifacts/package_sources/iqdbc/iqdbc/car/honda/interface.py b/artifacts/package_sources/iqdbc/iqdbc/car/honda/interface.py index 8c15c59ff..7b3003e54 100755 --- a/artifacts/package_sources/iqdbc/iqdbc/car/honda/interface.py +++ b/artifacts/package_sources/iqdbc/iqdbc/car/honda/interface.py @@ -5,7 +5,8 @@ from iqdbc.car.common.conversions import Conversions as CV from iqdbc.car.disable_ecu import disable_ecu, clear_all_dtcs, clear_ecu_dtcs from iqdbc.car.honda.hondacan import CanBus from iqdbc.car.honda.values import CarControllerParams, HondaFlags, CAR, HONDA_BOSCH, HONDA_BOSCH_CANFD, \ - HONDA_NIDEC_ALT_SCM_MESSAGES, HONDA_BOSCH_RADARLESS, HondaSafetyFlags + HONDA_NIDEC_ALT_SCM_MESSAGES, HONDA_BOSCH_RADARLESS, \ + HONDA_RADAR_SCAN_VERIFIED, HondaSafetyFlags from iqdbc.car.honda.carcontroller import CarController from iqdbc.car.honda.carstate import CarState from iqdbc.car.honda.radar_interface import RadarInterface @@ -48,7 +49,9 @@ class CarInterface(CarInterfaceBase): cfgs.insert(0, get_safety_config(structs.CarParams.SafetyModel.noOutput)) ret.safetyConfigs = cfgs - ret.radarUnavailable = True + # The object scan survives openpilot longitudinal: the radar disable is subnet-scoped to the + # powertrain bus, while the scan rides the camera-side ACC-CAN + ret.radarUnavailable = docs or candidate not in HONDA_RADAR_SCAN_VERIFIED # Disable the radar and let openpilot control longitudinal # WARNING: THIS DISABLES AEB! # If Bosch radarless, this blocks ACC messages from the camera diff --git a/artifacts/package_sources/iqdbc/iqdbc/car/honda/radar_interface.py b/artifacts/package_sources/iqdbc/iqdbc/car/honda/radar_interface.py index 6eb47073a..d0ed30ece 100755 --- a/artifacts/package_sources/iqdbc/iqdbc/car/honda/radar_interface.py +++ b/artifacts/package_sources/iqdbc/iqdbc/car/honda/radar_interface.py @@ -2,6 +2,7 @@ from iqdbc.can import CANParser from iqdbc.car import Bus, structs from iqdbc.car.interfaces import RadarInterfaceBase +from iqdbc.car.honda.radar_scan import SCAN_DBC_NAME, HondaRadarScanner from iqdbc.car.honda.values import DBC @@ -18,13 +19,20 @@ class RadarInterface(RadarInterfaceBase): self.radar_fault = False self.radar_wrong_config = False self.radar_off_can = CP.radarUnavailable + self.scanner = None - # Nidec if self.radar_off_can: self.rcp = None + self.trigger_msg = 0x445 + elif DBC[CP.carFingerprint].get(Bus.radar) == SCAN_DBC_NAME: + self.scanner = HondaRadarScanner(CP) + self.rcp = self.scanner.rcp + self.pts = self.scanner.pts + self.trigger_msg = self.scanner.trigger_msg else: + # Nidec self.rcp = _create_nidec_can_parser(CP.carFingerprint) - self.trigger_msg = 0x445 + self.trigger_msg = 0x445 self.updated_messages = set() def update(self, can_strings): @@ -37,6 +45,8 @@ class RadarInterface(RadarInterfaceBase): self.updated_messages.update(vls) if self.trigger_msg not in self.updated_messages: + if self.scanner is not None and self.scanner.sweep_overdue(): + return self.scanner.quiet_bus_radardata() return None rr = self._update(self.updated_messages) @@ -44,6 +54,9 @@ class RadarInterface(RadarInterfaceBase): return rr def _update(self, updated_messages): + if self.scanner is not None: + return self.scanner.process_sweep(updated_messages) + ret = structs.RadarData() for ii in sorted(updated_messages): diff --git a/artifacts/package_sources/iqdbc/iqdbc/car/honda/radar_scan.py b/artifacts/package_sources/iqdbc/iqdbc/car/honda/radar_scan.py new file mode 100644 index 000000000..cba826ab5 --- /dev/null +++ b/artifacts/package_sources/iqdbc/iqdbc/car/honda/radar_scan.py @@ -0,0 +1,396 @@ +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos +""" +import math +from collections import deque +from dataclasses import dataclass, field + +from iqdbc.can import CANParser +from iqdbc.car import Bus, structs +from iqdbc.car.honda.hondacan import CanBus +from iqdbc.car.honda.values import DBC +from iqdbc.dbc.generator.honda.honda_radar_scan import QUARTET_KINDS, SCAN_SLOTS, frame_address + +SCAN_DBC_NAME = 'honda_radar_scan_generated' +SWEEP_HZ = 15 + +SLOT_ADDRS = [tuple(frame_address(slot, kind) for kind in (*QUARTET_KINDS, "MOTION")) for slot in range(SCAN_SLOTS)] +ALL_SCAN_ADDRS = [addr for addrs in SLOT_ADDRS for addr in addrs] +# slot 15's IDENT frame closes every observed sweep's quartet family; its MOTION companion follows +# but must never gate an otherwise valid sweep, so the quartet frame stays the trigger +SWEEP_TRIGGER_ADDR = SLOT_ADDRS[SCAN_SLOTS - 1][3] + +DIST_LSB_M = 0.05712 +DIST_BIAS_M = -3.0 +BEARING_LSB_RAD = 1.0 / 2048.0 +BEARING_ZERO = 1024 + +STATE_INVALID = 0xF +DIST_RAW_INVALID = 0xFFF +BEARING_RAW_INVALID = 0x7FF +AGE_RAW_INVALID = 0xFFF +HANDLE_MIN = 1 +HANDLE_MAX = 0x3F + +CLOSING_SPEED_RAW_INVALID = 0x7FE +CLOSING_SPEED_RAW_MIN = 0 +CLOSING_SPEED_RAW_MAX = 1728 +CLOSING_SPEED_RAW_ZERO = 864 +CLOSING_SPEED_LSB_MPS = 1.0 / 64.0 +# replay-derived quality gate: the native speed field degrades gradually with its sigma companion; +# above this the field is no longer authoritative and the decoder coasts instead +CLOSING_SPEED_SIGMA_TRUST_MAX = 511 + +DIST_RATIO_RAW_INVALID = 0x3FF +DIST_RATIO_LSB = 0.001 +DIST_RATIO_BIAS = 0.5 + +# replay-derived acceptance gates, not recovered firmware constants; innovation is measured from the +# previous ACCEPTED observation so a reset can never become the baseline for following sweeps +DIST_SIGMA_DEGRADED_RAW = 4 +DIST_INNOVATION_SOFT_M = 2.0 +DIST_INNOVATION_HARD_M = 5.0 +RAW_RATE_LIMIT_MPS = 50.0 + +HISTORY_LEN = 8 +QUIET_TIMEOUT_S = 0.20 + + +def decode_closing_speed(raw, sigma_raw=None): + if raw is None: + return None + raw = int(raw) + if raw == CLOSING_SPEED_RAW_INVALID or not CLOSING_SPEED_RAW_MIN <= raw <= CLOSING_SPEED_RAW_MAX: + return None + if sigma_raw is not None and int(sigma_raw) > CLOSING_SPEED_SIGMA_TRUST_MAX: + return None + return (raw - CLOSING_SPEED_RAW_ZERO) * CLOSING_SPEED_LSB_MPS + + +def decode_dist_ratio(raw): + if raw is None: + return None + raw = int(raw) + if raw == DIST_RATIO_RAW_INVALID or not 0 <= raw < DIST_RATIO_RAW_INVALID: + return None + return DIST_RATIO_BIAS + DIST_RATIO_LSB * raw + + +def ratio_implied_rate(raw, dist, dt): + ratio = decode_dist_ratio(raw) + if ratio is None or dt <= 0.0 or not math.isfinite(dist): + return None + return dist * (1.0 - ratio) / dt + + +def reading_degraded(dist_sigma_raw, presence_raw, speed_sigma_raw): + geometry_bad = dist_sigma_raw >= DIST_SIGMA_DEGRADED_RAW or presence_raw in (0, 0x7F) + motion_bad = speed_sigma_raw is not None and speed_sigma_raw > CLOSING_SPEED_SIGMA_TRUST_MAX + return geometry_bad or motion_bad + + +@dataclass +class SlotReading: + slot: int + cycle: int + age: int + handle: int + handle_ok: bool + coherent: bool + dist_raw: int = 0 + bearing_raw: int = 0 + dist_sigma_raw: int = 0 + presence_raw: int = 0 + speed_raw: int | None = None + speed_sigma_raw: int | None = None + ratio_raw: int | None = None + + +@dataclass +class ObjectLedger: + handle: int + prev_cycle: int | None = None + prev_age: int | None = None + last_seen_nanos: int | None = None + wire_slot: int | None = None + history: deque = field(default_factory=lambda: deque(maxlen=HISTORY_LEN)) + held_speed: float | None = None + held_speed_nanos: int | None = None + + def continues_incarnation(self, cycle: int, age: int) -> bool: + if self.prev_cycle is None or self.prev_age is None: + return False + cycle_delta = (cycle - self.prev_cycle) & 0xF + age_delta = (age - self.prev_age) & 0xFFF + return age_delta == 2 * cycle_delta + + def restart_incarnation(self): + self.history.clear() + self.held_speed = None + self.held_speed_nanos = None + + def held_speed_fresh(self, now: int) -> bool: + return (self.held_speed is not None and self.held_speed_nanos is not None and + (now - self.held_speed_nanos) * 1e-9 <= QUIET_TIMEOUT_S) + + +def create_scan_parser(CP) -> CANParser: + # the object scan is physically on the camera-side ACC-CAN + return CANParser(DBC[CP.carFingerprint][Bus.radar], [(addr, SWEEP_HZ) for addr in ALL_SCAN_ADDRS], CanBus(CP).camera) + + +class HondaRadarScanner: + def __init__(self, CP): + self.rcp = create_scan_parser(CP) + self.trigger_msg = SWEEP_TRIGGER_ADDR + self.pts: dict[int, structs.RadarData.RadarPoint] = {} + self._ledgers: dict[int, ObjectLedger] = {} + self._slot_handles: list[int | None] = [None] * SCAN_SLOTS + self._last_sweep_nanos = -1 + + def sweep_overdue(self) -> bool: + if self._last_sweep_nanos < 0: + return False + return (self.rcp._last_update_nanos - self._last_sweep_nanos) * 1e-9 > QUIET_TIMEOUT_S + + def quiet_bus_radardata(self) -> structs.RadarData: + # whole-bus silence: drop everything and emit an EMPTY RadarData (not None) so radard sheds any + # lead within a cycle instead of freezing a phantom + self.pts.clear() + self._ledgers.clear() + self._slot_handles = [None] * SCAN_SLOTS + self._last_sweep_nanos = -1 + ret = structs.RadarData() + if not self.rcp.can_valid: + ret.errors.canError = True + ret.errors.radarUnavailableTemporary = True + return ret + + def _drop_ledger(self, handle: int): + self._ledgers.pop(handle, None) + self.pts.pop(handle, None) + for slot, bound in enumerate(self._slot_handles): + if bound == handle: + self._slot_handles[slot] = None + + def _drop_expired_ledgers(self, now: int): + for handle, ledger in list(self._ledgers.items()): + if ledger.last_seen_nanos is not None and (now - ledger.last_seen_nanos) * 1e-9 > QUIET_TIMEOUT_S: + self._drop_ledger(handle) + + def _read_slot(self, slot: int, updated_messages) -> SlotReading | None: + pos, shape, life, ident, motion = SLOT_ADDRS[slot] + if not all(addr in updated_messages for addr in (pos, shape, life, ident)): + # a missing CAN frame is not a lifecycle event; ledgers expire on their own staleness only + return None + + v_pos, v_shape, v_life, v_ident = (self.rcp.vl[a] for a in (pos, shape, life, ident)) + cycle = int(v_pos['CYCLE']) + if not (cycle == int(v_shape['CYCLE']) == int(v_life['CYCLE']) == int(v_ident['CYCLE'])): + # the quartet doesn't share one radar cycle: not a coherent observation this window + return None + + state = int(v_pos['SCAN_STATE']) + dist_raw = int(v_pos['DIST_RAW']) + bearing_raw = int(v_pos['BEARING_RAW']) + age = int(v_life['AGE_RAW']) + handle = int(v_ident['OBJECT_HANDLE']) + reading = SlotReading( + slot=slot, + cycle=cycle, + age=age, + handle=handle, + handle_ok=HANDLE_MIN <= handle <= HANDLE_MAX, + coherent=(state != STATE_INVALID and dist_raw != DIST_RAW_INVALID and + bearing_raw != BEARING_RAW_INVALID and age != AGE_RAW_INVALID), + dist_raw=dist_raw, + bearing_raw=bearing_raw, + dist_sigma_raw=int(v_pos['DIST_SIGMA_RAW']), + presence_raw=int(v_shape['PRESENCE_RAW']), + ) + + # the MOTION companion only contributes when it rides the same cycle; its absence never + # invalidates the quartet, it only removes independent motion evidence + if motion in updated_messages: + v_motion = self.rcp.vl[motion] + if int(v_motion['CYCLE']) == cycle: + reading.speed_raw = int(v_motion['CLOSING_SPEED_RAW']) + reading.speed_sigma_raw = int(v_motion['CLOSING_SPEED_SIGMA_RAW']) + reading.ratio_raw = int(v_motion['DIST_RATIO_RAW']) + return reading + + def _elect_by_handle(self, readings: list[SlotReading]) -> dict[int, SlotReading]: + # one CAN identity can never yield two points; ties prefer the wire slot already bound to the + # ledger, then the lower slot for deterministic handling of a malformed duplicate + elected: dict[int, SlotReading] = {} + for reading in readings: + if not (reading.coherent and reading.handle_ok): + # an invalid observation ends publication for the slot's current occupant without destroying + # persistent state; the object may be multiplexed elsewhere or return before its deadline + hidden = {self._slot_handles[reading.slot]} + if reading.handle_ok: + hidden.add(reading.handle) + for handle in hidden - {None}: + self.pts.pop(handle, None) + continue + current = elected.get(reading.handle) + if current is None: + elected[reading.handle] = reading + continue + bound_slot = self._ledgers[reading.handle].wire_slot if reading.handle in self._ledgers else None + current_rank = (0 if bound_slot == current.slot else 1, current.slot) + candidate_rank = (0 if bound_slot == reading.slot else 1, reading.slot) + if candidate_rank < current_rank: + elected[reading.handle] = reading + return elected + + def _bind_slot(self, ledger: ObjectLedger, reading: SlotReading, now: int): + ledger.prev_cycle = reading.cycle + ledger.prev_age = reading.age + ledger.last_seen_nanos = now + ledger.wire_slot = reading.slot + for slot, bound in enumerate(self._slot_handles): + if slot != reading.slot and bound == ledger.handle: + self._slot_handles[slot] = None + self._slot_handles[reading.slot] = ledger.handle + + def _coast_point(self, ledger: ObjectLedger, now: int, dist: float, y_rel: float): + # coasting keeps trustworthy geometry visible with the last authoritative motion, unmeasured, + # instead of publishing a synthesized rate; without fresh held motion the point drops + if ledger.held_speed_fresh(now): + point = self.pts.get(ledger.handle) + if point is not None: + point.dRel = dist + point.yRel = y_rel + point.vRel = ledger.held_speed + point.measured = False + return + ledger.held_speed = None + ledger.held_speed_nanos = None + self.pts.pop(ledger.handle, None) + + def process_sweep(self, updated_messages) -> structs.RadarData: + ret = structs.RadarData() + if not self.rcp.can_valid: + ret.errors.canError = True + + now = self.rcp._last_update_nanos + self._last_sweep_nanos = now + self._drop_expired_ledgers(now) + + readings = [r for r in (self._read_slot(slot, updated_messages) for slot in range(SCAN_SLOTS)) if r is not None] + elected = self._elect_by_handle(readings) + elected_handles = set(elected) + + for handle, reading in sorted(elected.items(), key=lambda item: item[1].slot): + # a wire-slot replacement ends publication for the old occupant, but not its persistent state + old_handle = self._slot_handles[reading.slot] + if old_handle is not None and old_handle != handle and old_handle not in elected_handles: + self.pts.pop(old_handle, None) + + ledger = self._ledgers.get(handle) + if ledger is None: + ledger = ObjectLedger(handle=handle) + self._ledgers[handle] = ledger + + if not ledger.continues_incarnation(reading.cycle, reading.age): + # the CAN identity stays the external key, but a lifecycle discontinuity starts a new + # incarnation and must not inherit the previous object's range-rate history + ledger.restart_incarnation() + self.pts.pop(handle, None) + + dist = DIST_LSB_M * reading.dist_raw + DIST_BIAS_M + bearing = BEARING_LSB_RAD * (reading.bearing_raw - BEARING_ZERO) + # the radar consumes range as a forward-axis quantity; positive bearing is left of center, + # matching the RadarPoint.yRel sign contract + y_rel = dist * math.tan(bearing) + + now_s = now * 1e-9 + native_speed = decode_closing_speed(reading.speed_raw, reading.speed_sigma_raw) + unqualified_speed = decode_closing_speed(reading.speed_raw) + # a live native speed rejected only by its sigma companion: geometry acceptance is still decided + # on the same terms as every other sweep (high sigma correlates with bad/discontinuous range), + # and only then does the point coast instead of publishing a synthesized rate + sigma_veto = (native_speed is None and unqualified_speed is not None and + reading.speed_sigma_raw is not None and + reading.speed_sigma_raw > CLOSING_SPEED_SIGMA_TRUST_MAX) + + degraded = reading_degraded(reading.dist_sigma_raw, reading.presence_raw, reading.speed_sigma_raw) + previous = ledger.history[-1] if ledger.history else None + ratio_rate = None + dist_rejected = False + if previous is not None: + prev_time, prev_dist = previous + dt = now_s - prev_time + if dt <= 0.0: + dist_rejected = True + else: + ratio = decode_dist_ratio(reading.ratio_raw) + ratio_rate = ratio_implied_rate(reading.ratio_raw, dist, dt) + + residuals_m = [] + if native_speed is not None: + residuals_m.append(abs(dist - (prev_dist + native_speed * dt))) + if ratio is not None: + residuals_m.append(abs(prev_dist - dist * ratio)) + + if residuals_m: + innovation_m = min(residuals_m) + dist_rejected = (innovation_m > DIST_INNOVATION_HARD_M or + (degraded and innovation_m > DIST_INNOVATION_SOFT_M)) + else: + dist_rejected = abs((dist - prev_dist) / dt) > RAW_RATE_LIMIT_MPS + + if dist_rejected: + # keep the last accepted point briefly as an unmeasured coast; rejected geometry is never + # published and never becomes the baseline for a later derivative + accepted_fresh = previous is not None and now_s - previous[0] <= QUIET_TIMEOUT_S + point = self.pts.get(handle) + if accepted_fresh and point is not None: + point.measured = False + else: + self.pts.pop(handle, None) + self._bind_slot(ledger, reading, now) + continue + + if sigma_veto: + self._coast_point(ledger, now, dist, y_rel) + self._bind_slot(ledger, reading, now) + continue + + # with neither a qualified native speed nor a usable ratio, the only remaining source is the + # raw one-sweep derivative, which must never become an authoritative measurement: coast instead. + # A true birth (no previous accepted sample) cannot mature this cycle regardless, so only + # intercept once a derivative would have something to poison + if native_speed is None and (ratio_rate is None or degraded) and previous is not None: + self._coast_point(ledger, now, dist, y_rel) + self._bind_slot(ledger, reading, now) + continue + + ledger.history.append((now_s, dist)) + + speed = native_speed if native_speed is not None else ratio_rate + ledger.held_speed = speed + ledger.held_speed_nanos = now + + # a birth observation has no range rate yet: keep it as history, publish only once a second + # coherent observation of the same identity supplies a finite rate + matured = len(ledger.history) >= 2 and math.isfinite(speed) + if matured: + if handle not in self.pts: + point = structs.RadarData.RadarPoint() + point.trackId = handle + point.aRel = float('nan') + point.yvRel = float('nan') + self.pts[handle] = point + self.pts[handle].dRel = dist + self.pts[handle].yRel = y_rel + self.pts[handle].vRel = speed + self.pts[handle].measured = True + else: + self.pts.pop(handle, None) + + self._bind_slot(ledger, reading, now) + + ret.points = [self.pts[handle] for handle in sorted(self.pts)] + return ret diff --git a/artifacts/package_sources/iqdbc/iqdbc/car/honda/tests/test_radar_scan.py b/artifacts/package_sources/iqdbc/iqdbc/car/honda/tests/test_radar_scan.py new file mode 100644 index 000000000..88ecf1d24 --- /dev/null +++ b/artifacts/package_sources/iqdbc/iqdbc/car/honda/tests/test_radar_scan.py @@ -0,0 +1,490 @@ +import math + +import pytest + +from iqdbc.can import CANParser +from iqdbc.car.honda.radar_scan import (AGE_RAW_INVALID, ALL_SCAN_ADDRS, BEARING_RAW_INVALID, BEARING_ZERO, + CLOSING_SPEED_RAW_INVALID, CLOSING_SPEED_RAW_ZERO, + CLOSING_SPEED_SIGMA_TRUST_MAX, DIST_BIAS_M, DIST_LSB_M, + DIST_RATIO_RAW_INVALID, DIST_RAW_INVALID, HondaRadarScanner, + QUIET_TIMEOUT_S, SCAN_DBC_NAME, SCAN_SLOTS, STATE_INVALID, + SWEEP_TRIGGER_ADDR, decode_closing_speed, decode_dist_ratio) +from iqdbc.dbc.generator.honda.honda_radar_scan import FRAME_SIGNALS, frame_address + +BUS = 2 +SWEEP_DT_NS = 66_000_000 + + +def set_bits(data, start_bit, size, value): + value = int(value) & ((1 << size) - 1) + pos = start_bit + for i in range(size): + bit = (value >> (size - 1 - i)) & 1 + byte_i, bit_i = pos // 8, pos % 8 + if bit: + data[byte_i] |= (1 << bit_i) + pos = pos - 1 if bit_i > 0 else pos + 15 + + +GEOMETRY = {kind: {name: (start, size) for name, start, size in sigs} for kind, sigs in FRAME_SIGNALS.items()} + + +def build_frame(slot, kind, **fields): + data = bytearray(8) + for name, value in fields.items(): + set_bits(data, *GEOMETRY[kind][name], value) + return (frame_address(slot, kind), bytes(data), BUS) + + +def quartet(slot, cycle, dist_raw=1000, bearing_raw=BEARING_ZERO, state=1, dist_sigma=0, presence=40, + age=100, handle=5): + return [ + build_frame(slot, "POS", SCAN_STATE=state, CYCLE=cycle, DIST_RAW=dist_raw, BEARING_RAW=bearing_raw, + DIST_SIGMA_RAW=dist_sigma), + build_frame(slot, "SHAPE", CYCLE=cycle, PRESENCE_RAW=presence), + build_frame(slot, "LIFE", CYCLE=cycle, AGE_RAW=age), + build_frame(slot, "IDENT", CYCLE=cycle, OBJECT_HANDLE=handle), + ] + + +def motion_frame(slot, cycle, speed_raw=CLOSING_SPEED_RAW_ZERO, sigma_raw=0, ratio_raw=500): + return build_frame(slot, "MOTION", CYCLE=cycle, CLOSING_SPEED_RAW=speed_raw, + CLOSING_SPEED_SIGMA_RAW=sigma_raw, DIST_RATIO_RAW=ratio_raw) + + +def closing_sweep(slot_msgs, cycle): + # slot 15's quartet closes every sweep so the trigger fires + msgs = list(slot_msgs) + if not any(m[0] == SWEEP_TRIGGER_ADDR for m in msgs): + msgs += quartet(15, cycle, state=STATE_INVALID, dist_raw=DIST_RAW_INVALID, + bearing_raw=BEARING_RAW_INVALID, age=AGE_RAW_INVALID, handle=0) + return msgs + + +class ScanHarness: + def __init__(self): + self.scanner = object.__new__(HondaRadarScanner) + self.scanner.rcp = CANParser(SCAN_DBC_NAME, [(a, 15) for a in ALL_SCAN_ADDRS], BUS) + self.scanner.trigger_msg = SWEEP_TRIGGER_ADDR + self.scanner.pts = {} + self.scanner._ledgers = {} + self.scanner._slot_handles = [None] * SCAN_SLOTS + self.scanner._last_sweep_nanos = -1 + self.updated = set() + self.nanos = 0 + self.cycle = 0 + + def feed(self, msgs, dt_ns=SWEEP_DT_NS): + self.nanos += dt_ns + vls = self.scanner.rcp.update([self.nanos, list(msgs)]) + self.updated.update(vls) + if self.scanner.trigger_msg not in self.updated: + if self.scanner.sweep_overdue(): + return self.scanner.quiet_bus_radardata() + return None + result = self.scanner.process_sweep(self.updated) + self.updated.clear() + return result + + def sweep(self, slot_msgs=(), cycle_step=1, dt_ns=SWEEP_DT_NS): + self.cycle = (self.cycle + cycle_step) & 0xF + return self.feed(closing_sweep(slot_msgs, self.cycle), dt_ns=dt_ns) + + def object_sweep(self, slot=0, handle=5, dist_raw=1000, with_motion=True, cycle_step=1, age_step=None, + dt_ns=SWEEP_DT_NS, **kwargs): + if age_step is None: + age_step = 2 * cycle_step + self._age = (getattr(self, "_age", 100) + age_step) & 0xFFF + cycle = (self.cycle + cycle_step) & 0xF + msgs = quartet(slot, cycle, dist_raw=dist_raw, age=self._age, handle=handle, **kwargs) + if with_motion: + msgs.append(motion_frame(slot, cycle)) + return self.sweep(msgs, cycle_step=cycle_step, dt_ns=dt_ns) + + +class TestFieldDecoding: + def test_dist_conversion(self): + assert DIST_LSB_M * 1000 + DIST_BIAS_M == pytest.approx(54.12) + + def test_closing_speed_decode_and_domain(self): + assert decode_closing_speed(CLOSING_SPEED_RAW_ZERO) == 0.0 + assert decode_closing_speed(CLOSING_SPEED_RAW_ZERO + 64) == 1.0 + assert decode_closing_speed(CLOSING_SPEED_RAW_INVALID) is None + assert decode_closing_speed(1729) is None + assert decode_closing_speed(None) is None + + def test_closing_speed_sigma_veto(self): + assert decode_closing_speed(CLOSING_SPEED_RAW_ZERO, CLOSING_SPEED_SIGMA_TRUST_MAX) == 0.0 + assert decode_closing_speed(CLOSING_SPEED_RAW_ZERO, CLOSING_SPEED_SIGMA_TRUST_MAX + 1) is None + + def test_dist_ratio_decode(self): + assert decode_dist_ratio(500) == pytest.approx(1.0) + assert decode_dist_ratio(DIST_RATIO_RAW_INVALID) is None + assert decode_dist_ratio(None) is None + + def test_bearing_sign_convention(self): + h = ScanHarness() + h.object_sweep(bearing_raw=BEARING_ZERO + 100) + result = h.object_sweep(bearing_raw=BEARING_ZERO + 100) + assert result.points[0].yRel > 0 # left of center is positive + dist = result.points[0].dRel + assert result.points[0].yRel == pytest.approx(dist * math.tan(100 / 2048)) + + def test_bearing_right_of_center_is_negative(self): + h = ScanHarness() + h.object_sweep(bearing_raw=BEARING_ZERO - 100) + result = h.object_sweep(bearing_raw=BEARING_ZERO - 100) + assert result.points[0].yRel < 0 + + def test_boresight_is_zero(self): + h = ScanHarness() + h.object_sweep(bearing_raw=BEARING_ZERO) + result = h.object_sweep(bearing_raw=BEARING_ZERO) + assert result.points[0].yRel == 0.0 + + +class TestPublicationRules: + def test_birth_is_withheld_until_second_observation(self): + h = ScanHarness() + result = h.object_sweep() + assert len(result.points) == 0 + result = h.object_sweep() + assert len(result.points) == 1 + point = result.points[0] + assert point.trackId == 5 + assert point.measured + assert math.isnan(point.aRel) and math.isnan(point.yvRel) + + def test_handle_is_wire_identity_not_synthetic(self): + h = ScanHarness() + h.object_sweep(handle=0x22) + result = h.object_sweep(handle=0x22) + assert result.points[0].trackId == 0x22 + + @pytest.mark.parametrize("field,value", [("state", STATE_INVALID), ("dist_raw", DIST_RAW_INVALID), + ("bearing_raw", BEARING_RAW_INVALID)]) + def test_sentinels_invalidate_observation(self, field, value): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + kwargs = {field: value} + result = h.object_sweep(**kwargs) + assert len(result.points) == 0 + + def test_age_sentinel_invalidates_observation(self): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + cycle = (h.cycle + 1) & 0xF + msgs = quartet(0, cycle, age=AGE_RAW_INVALID, handle=5) + [motion_frame(0, cycle)] + result = h.sweep(msgs) + assert len(result.points) == 0 + + @pytest.mark.parametrize("handle", [0, 0x40, 0xFF]) + def test_out_of_range_handle_invalidates(self, handle): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + result = h.object_sweep(handle=handle) + assert len(result.points) == 0 + + def test_incomplete_quartet_is_not_an_observation(self): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + cycle = (h.cycle + 1) & 0xF + h._age = (h._age + 2) & 0xFFF + msgs = quartet(0, cycle, age=h._age, handle=5)[:3] # drop IDENT + result = h.sweep(msgs) + # a dropped CAN frame is not a lifecycle event: the published point persists untouched + assert len(result.points) == 1 + result = h.object_sweep() + assert len(result.points) == 1 + assert result.points[0].measured + + def test_cycle_mismatch_across_quartet_is_incoherent(self): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + cycle = (h.cycle + 1) & 0xF + msgs = quartet(0, cycle, age=200, handle=5) + bad_life = build_frame(0, "LIFE", CYCLE=(cycle + 1) & 0xF, AGE_RAW=200) + msgs[2] = bad_life + result = h.sweep(msgs) + # an incoherent quartet is not an observation: the published point persists untouched + assert len(result.points) == 1 + assert result.points[0].measured + + +class TestLifecycle: + def test_age_advances_two_per_cycle_keeps_identity(self): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + result = h.object_sweep() + assert len(result.points) == 1 + + def test_continuity_across_skipped_cycles(self): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + result = h.object_sweep(cycle_step=3, age_step=6) + assert len(result.points) == 1 + + def test_cycle_and_age_wraparound_stay_same_incarnation(self): + h = ScanHarness() + h.cycle = 14 + h._age = 4094 + h.object_sweep() # cycle 15, age 4094+2 wraps + h.object_sweep() # cycle 0 + result = h.object_sweep() + assert len(result.points) == 1 + + def test_lifecycle_break_starts_new_incarnation(self): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + # same handle, age jumps arbitrarily: history must not carry over, so no publication this sweep + result = h.object_sweep(age_step=500) + assert len(result.points) == 0 + result = h.object_sweep() + assert len(result.points) == 1 + + def test_death_then_rebirth_reuses_handle_with_clean_history(self): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + for _ in range(4): + h.sweep() # object absent long enough to expire its ledger + result = h.object_sweep() + assert len(result.points) == 0 + result = h.object_sweep() + assert len(result.points) == 1 + + +class TestMotionPolicy: + def test_native_speed_is_published(self): + h = ScanHarness() + speed_raw = CLOSING_SPEED_RAW_ZERO + 128 + cycle = (h.cycle + 1) & 0xF + h.sweep(quartet(0, cycle, age=100, handle=5) + [motion_frame(0, cycle, speed_raw=speed_raw)]) + cycle = (h.cycle + 1) & 0xF + result = h.sweep(quartet(0, cycle, age=102, handle=5) + [motion_frame(0, cycle, speed_raw=speed_raw)]) + assert result.points[0].vRel == pytest.approx(2.0) + assert result.points[0].measured + + def test_missing_motion_frame_never_invalidates_geometry(self): + h = ScanHarness() + h.object_sweep(with_motion=False) + result = h.object_sweep(with_motion=False) + # without any motion source and no held speed, the point is withheld rather than synthesized + assert len(result.points) == 0 + + def test_stale_motion_cycle_is_ignored(self): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + cycle = (h.cycle + 1) & 0xF + h._age = (h._age + 2) & 0xFFF + msgs = quartet(0, cycle, age=h._age, handle=5) + [motion_frame(0, (cycle - 1) & 0xF)] + result = h.sweep(msgs) + # motion from another cycle contributes nothing: coasts on held speed, unmeasured + assert len(result.points) == 1 + assert not result.points[0].measured + + def test_high_sigma_speed_coasts_instead_of_synthesizing(self): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + cycle = (h.cycle + 1) & 0xF + h._age = (h._age + 2) & 0xFFF + msgs = quartet(0, cycle, age=h._age, handle=5) + \ + [motion_frame(0, cycle, sigma_raw=CLOSING_SPEED_SIGMA_TRUST_MAX + 1)] + result = h.sweep(msgs) + assert len(result.points) == 1 + assert not result.points[0].measured + assert result.points[0].vRel == pytest.approx(0.0) # the held speed, not a derivative + + def test_ratio_field_supplies_speed_when_native_missing(self): + h = ScanHarness() + dist_raw = 1000 + cycle = (h.cycle + 1) & 0xF + h.sweep(quartet(0, cycle, dist_raw=dist_raw, age=100, handle=5) + + [motion_frame(0, cycle, speed_raw=CLOSING_SPEED_RAW_INVALID, ratio_raw=490)]) + cycle = (h.cycle + 1) & 0xF + result = h.sweep(quartet(0, cycle, dist_raw=dist_raw, age=102, handle=5) + + [motion_frame(0, cycle, speed_raw=CLOSING_SPEED_RAW_INVALID, ratio_raw=490)]) + assert len(result.points) == 1 + dist = DIST_LSB_M * dist_raw + DIST_BIAS_M + dt = SWEEP_DT_NS * 1e-9 + assert result.points[0].vRel == pytest.approx(dist * (1.0 - 0.99) / dt) + assert result.points[0].measured + + def test_fast_clean_range_rate_without_sources_is_withheld(self): + h = ScanHarness() + h.object_sweep(with_motion=False, dist_raw=1000) + # large clean jump with no motion evidence: raw-rate limit rejects the range outright + result = h.object_sweep(with_motion=False, dist_raw=3000) + assert len(result.points) == 0 + + +class TestRangeAcceptance: + def test_discontinuity_is_rejected_and_never_becomes_baseline(self): + h = ScanHarness() + h.object_sweep(dist_raw=1000) + h.object_sweep(dist_raw=1002) + # jump far beyond the hard innovation gate while claiming zero closing speed + result = h.object_sweep(dist_raw=3000) + assert len(result.points) == 1 + assert not result.points[0].measured + # the rejected range did not become the derivative baseline: returning to the + # consistent range publishes measured again + result = h.object_sweep(dist_raw=1004) + assert result.points[0].measured + + def test_small_innovation_accepted(self): + h = ScanHarness() + h.object_sweep(dist_raw=1000) + result = h.object_sweep(dist_raw=1005) + assert result.points[0].measured + + +class TestSlotsAndIdentity: + def test_slot_migration_preserves_identity(self): + h = ScanHarness() + h.object_sweep(slot=2) + h.object_sweep(slot=2) + result = h.object_sweep(slot=9) + assert len(result.points) == 1 + assert result.points[0].trackId == 5 + + def test_duplicate_identity_prefers_bound_slot(self): + h = ScanHarness() + h.object_sweep(slot=2, dist_raw=1000) + h.object_sweep(slot=2, dist_raw=1002) + cycle = (h.cycle + 1) & 0xF + h._age = (h._age + 2) & 0xFFF + msgs = quartet(2, cycle, dist_raw=1004, age=h._age, handle=5) + [motion_frame(2, cycle)] + \ + quartet(9, cycle, dist_raw=2000, age=h._age, handle=5) + [motion_frame(9, cycle)] + result = h.sweep(msgs) + assert len(result.points) == 1 + assert result.points[0].dRel == pytest.approx(DIST_LSB_M * 1004 + DIST_BIAS_M) + + def test_slot_replacement_hides_old_occupant(self): + h = ScanHarness() + h.object_sweep(slot=3, handle=7) + h.object_sweep(slot=3, handle=7) + # a different identity takes the slot; the old one is hidden but not destroyed + result = h.object_sweep(slot=3, handle=9, age_step=333) + assert all(p.trackId != 7 for p in result.points) + + def test_one_identity_never_two_points(self): + h = ScanHarness() + cycle = (h.cycle + 1) & 0xF + msgs = quartet(1, cycle, age=100, handle=5) + [motion_frame(1, cycle)] + \ + quartet(6, cycle, age=100, handle=5) + [motion_frame(6, cycle)] + h.sweep(msgs) + cycle = (h.cycle + 1) & 0xF + msgs = quartet(1, cycle, age=102, handle=5) + [motion_frame(1, cycle)] + \ + quartet(6, cycle, age=102, handle=5) + [motion_frame(6, cycle)] + result = h.sweep(msgs) + assert len(result.points) == 1 + + +class TestBusSilence: + def test_quiet_bus_publishes_empty_not_none(self): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + result = None + for _ in range(30): + result = h.feed([], dt_ns=10_000_000) + if result is not None: + break + assert result is not None + assert result.errors.radarUnavailableTemporary + assert len(result.points) == 0 + + def test_recovery_after_silence_starts_fresh(self): + h = ScanHarness() + h.object_sweep() + h.object_sweep() + for _ in range(30): + if h.feed([], dt_ns=10_000_000) is not None: + break + result = h.object_sweep() + assert len(result.points) == 0 + result = h.object_sweep() + assert len(result.points) == 1 + + def test_no_stale_publication_before_first_sweep(self): + h = ScanHarness() + for _ in range(50): + assert h.feed([], dt_ns=10_000_000) is None + + +class TestQuietTimeoutValue: + def test_timeout_is_about_three_sweeps(self): + assert QUIET_TIMEOUT_S == pytest.approx(3 / 15, abs=0.01) + + +class TestScanInterfaceGating: + def build(self, candidate, alpha_long=False, docs=False): + from iqdbc.car import gen_empty_fingerprint + from iqdbc.car.honda.interface import CarInterface + CP = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], alpha_long, False, docs) + return CP + + def test_verified_platform_has_radar(self): + from iqdbc.car.honda.values import CAR + for car in (CAR.HONDA_CIVIC_BOSCH, CAR.HONDA_ACCORD, CAR.HONDA_CRV_5G): + assert not self.build(car).radarUnavailable + + def test_radar_survives_openpilot_longitudinal(self): + from iqdbc.car.honda.values import CAR + CP = self.build(CAR.HONDA_CIVIC_BOSCH, alpha_long=True) + assert CP.openpilotLongitudinalControl + assert not CP.radarUnavailable + + def test_unverified_family_platform_stays_off(self): + from iqdbc.car.honda.values import CAR + for car in (CAR.HONDA_E, CAR.HONDA_INSIGHT, CAR.HONDA_NBOX_2G, CAR.ACURA_RDX_3G, CAR.HONDA_CRV_HYBRID): + assert self.build(car).radarUnavailable + + def test_radarless_and_canfd_stay_off(self): + from iqdbc.car.honda.values import CAR + assert self.build(CAR.HONDA_CIVIC_2022).radarUnavailable + assert self.build(CAR.HONDA_CRV_6G).radarUnavailable + + def test_docs_never_claim_radar(self): + from iqdbc.car.honda.values import CAR + assert self.build(CAR.HONDA_CIVIC_BOSCH, docs=True).radarUnavailable + + def test_radar_interface_routes_scanner(self): + from iqdbc.car import gen_empty_fingerprint + from iqdbc.car.honda.interface import CarInterface + from iqdbc.car.honda.values import CAR + CP = self.build(CAR.HONDA_CIVIC_BOSCH) + CP_IQ = CarInterface.get_params_iq(CP, CAR.HONDA_CIVIC_BOSCH, gen_empty_fingerprint(), [], False, False, False) + ri = CarInterface.RadarInterface(CP, CP_IQ) + assert ri.scanner is not None + assert ri.trigger_msg == SWEEP_TRIGGER_ADDR + + def test_radar_interface_keeps_nidec_path(self): + from iqdbc.car import gen_empty_fingerprint + from iqdbc.car.honda.interface import CarInterface + from iqdbc.car.honda.values import CAR + CP = self.build(CAR.HONDA_CIVIC) + CP_IQ = CarInterface.get_params_iq(CP, CAR.HONDA_CIVIC, gen_empty_fingerprint(), [], False, False, False) + ri = CarInterface.RadarInterface(CP, CP_IQ) + assert ri.scanner is None + assert ri.trigger_msg == 0x445 + + def test_radar_interface_sleeps_when_unavailable(self): + from iqdbc.car import gen_empty_fingerprint + from iqdbc.car.honda.interface import CarInterface + from iqdbc.car.honda.values import CAR + CP = self.build(CAR.HONDA_E) + CP_IQ = CarInterface.get_params_iq(CP, CAR.HONDA_E, gen_empty_fingerprint(), [], False, False, False) + ri = CarInterface.RadarInterface(CP, CP_IQ) + assert ri.scanner is None and ri.rcp is None diff --git a/artifacts/package_sources/iqdbc/iqdbc/car/honda/tests/test_radar_scan_dbc.py b/artifacts/package_sources/iqdbc/iqdbc/car/honda/tests/test_radar_scan_dbc.py new file mode 100644 index 000000000..9ec752e56 --- /dev/null +++ b/artifacts/package_sources/iqdbc/iqdbc/car/honda/tests/test_radar_scan_dbc.py @@ -0,0 +1,84 @@ +from iqdbc.can.dbc import DBC as DbcFile +from iqdbc.car import Bus +from iqdbc.car.honda.values import CAR, DBC, HONDA_RADAR_SCAN_CAPABLE, HONDA_RADAR_SCAN_VERIFIED +from iqdbc.dbc.generator.honda.honda_radar_scan import (FRAME_SIGNALS, QUARTET_KINDS, SCAN_SLOTS, + frame_address, motion_address, quartet_base_address) + +SCAN_DBC_NAME = 'honda_radar_scan_generated' + + +class TestScanAddressing: + def test_quartet_bases(self): + assert [quartet_base_address(s) for s in range(SCAN_SLOTS)] == \ + [0x280, 0x284, 0x288, 0x28C, 0x2D0, 0x2D4, 0x2D8, 0x2DC, 0x2E0, 0x2E4, 0x2E8, 0x2EC, 0x2F0, 0x2F4, 0x2F8, 0x2FC] + + def test_motion_addresses(self): + assert [motion_address(s) for s in range(SCAN_SLOTS)] == \ + [0x2C8, 0x2C9, 0x2CA, 0x2CB, 0x2CC, 0x2CD, 0x2CE, 0x2CF, 0x290, 0x291, 0x292, 0x293, 0x294, 0x295, 0x296, 0x297] + + def test_eighty_unique_addresses(self): + addrs = [frame_address(s, k) for s in range(SCAN_SLOTS) for k in (*QUARTET_KINDS, "MOTION")] + assert len(addrs) == 80 + assert len(set(addrs)) == 80 + + def test_quartet_kind_order(self): + for slot in range(SCAN_SLOTS): + base = quartet_base_address(slot) + assert [frame_address(slot, k) for k in QUARTET_KINDS] == [base, base + 1, base + 2, base + 3] + + +class TestScanDbcGeometry: + def setup_method(self): + self.dbc = DbcFile(SCAN_DBC_NAME) + + def geometry(self, addr): + msg = self.dbc.addr_to_msg[addr] + return {sig.name: (sig.start_bit, sig.size) for sig in msg.sigs.values()} + + def test_every_frame_present_with_size_8(self): + for slot in range(SCAN_SLOTS): + for kind in (*QUARTET_KINDS, "MOTION"): + msg = self.dbc.addr_to_msg[frame_address(slot, kind)] + assert msg.name == f"RADAR_SCAN_{slot:02d}_{kind}" + assert msg.size == 8 + + def test_bit_geometry_matches_spec(self): + expected = {kind: {name: (start, size) for name, start, size in sigs} for kind, sigs in FRAME_SIGNALS.items()} + for slot in range(SCAN_SLOTS): + for kind in (*QUARTET_KINDS, "MOTION"): + assert self.geometry(frame_address(slot, kind)) == expected[kind], (slot, kind) + + def test_pos_frame_field_widths(self): + geo = self.geometry(frame_address(0, "POS")) + assert geo["DIST_RAW"] == (23, 12) + assert geo["BEARING_RAW"] == (39, 11) + assert geo["SCAN_STATE"] == (15, 4) + assert geo["DIST_SIGMA_RAW"] == (7, 7) + + def test_ident_handle_is_byte_six(self): + geo = self.geometry(frame_address(0, "IDENT")) + assert geo["OBJECT_HANDLE"] == (55, 8) + + def test_motion_field_widths(self): + geo = self.geometry(frame_address(0, "MOTION")) + assert geo["CLOSING_SPEED_RAW"] == (7, 11) + assert geo["CLOSING_SPEED_SIGMA_RAW"] == (23, 10) + assert geo["DIST_RATIO_RAW"] == (55, 10) + + def test_cycle_positions_per_kind(self): + positions = {"POS": (27, 4), "SHAPE": (28, 4), "LIFE": (11, 4), "IDENT": (12, 4), "MOTION": (12, 4)} + for kind, expected in positions.items(): + assert self.geometry(frame_address(3, kind))["CYCLE"] == expected + + +class TestScanPlatformWiring: + def test_scan_dbc_on_exactly_the_capable_family(self): + for car in CAR: + has_scan_dbc = DBC[car].get(Bus.radar) == SCAN_DBC_NAME + assert has_scan_dbc == (car in HONDA_RADAR_SCAN_CAPABLE), car + + def test_verified_platforms_are_capable(self): + assert HONDA_RADAR_SCAN_VERIFIED <= HONDA_RADAR_SCAN_CAPABLE + + def test_verified_set(self): + assert HONDA_RADAR_SCAN_VERIFIED == {CAR.HONDA_ACCORD, CAR.HONDA_CIVIC_BOSCH, CAR.HONDA_CRV_5G} diff --git a/artifacts/package_sources/iqdbc/iqdbc/car/honda/values.py b/artifacts/package_sources/iqdbc/iqdbc/car/honda/values.py index de27848f8..5b29df2dd 100644 --- a/artifacts/package_sources/iqdbc/iqdbc/car/honda/values.py +++ b/artifacts/package_sources/iqdbc/iqdbc/car/honda/values.py @@ -162,7 +162,7 @@ class CAR(Platforms): HondaCarDocs("Honda N-Box 2018", "All", min_steer_speed=5.), ], CarSpecs(mass=890., wheelbase=2.520, steerRatio=18.64), - {Bus.pt: 'acura_rdx_2020_can_generated'}, + {Bus.pt: 'acura_rdx_2020_can_generated', Bus.radar: 'honda_radar_scan_generated'}, ) HONDA_ACCORD = HondaBoschPlatformConfig( [ @@ -172,7 +172,7 @@ class CAR(Platforms): ], # steerRatio: 11.82 is spec end-to-end CarSpecs(mass=3279 * CV.LB_TO_KG, wheelbase=2.83, steerRatio=16.33, centerToFrontRatio=0.39, tireStiffnessFactor=0.8467), - {Bus.pt: 'honda_civic_hatchback_ex_2017_can_generated'}, + {Bus.pt: 'honda_civic_hatchback_ex_2017_can_generated', Bus.radar: 'honda_radar_scan_generated'}, flags=HondaFlags.ALLOW_MANUAL_TRANS, ) HONDA_ACCORD_11G = HondaBoschCANFDPlatformConfig( @@ -190,13 +190,13 @@ class CAR(Platforms): HondaCarDocs("Honda Civic Hatchback 2019-21", "All", min_steer_speed=12. * CV.MPH_TO_MS), ], CarSpecs(mass=1326, wheelbase=2.7, steerRatio=15.38, centerToFrontRatio=0.4), # steerRatio: 10.93 is end-to-end spec - {Bus.pt: 'honda_civic_hatchback_ex_2017_can_generated'}, + {Bus.pt: 'honda_civic_hatchback_ex_2017_can_generated', Bus.radar: 'honda_radar_scan_generated'}, flags=HondaFlags.ALLOW_MANUAL_TRANS, ) HONDA_CIVIC_BOSCH_DIESEL = HondaBoschPlatformConfig( [], # don't show in docs HONDA_CIVIC_BOSCH.specs, - {Bus.pt: 'honda_civic_hatchback_ex_2017_can_generated'}, + {Bus.pt: 'honda_civic_hatchback_ex_2017_can_generated', Bus.radar: 'honda_radar_scan_generated'}, ) HONDA_CIVIC_2022 = HondaBoschPlatformConfig( [ @@ -215,7 +215,8 @@ class CAR(Platforms): [HondaCarDocs("Honda CR-V 2017-22", min_steer_speed=15. * CV.MPH_TO_MS)], # steerRatio: 12.3 is spec end-to-end CarSpecs(mass=3410 * CV.LB_TO_KG, wheelbase=2.66, steerRatio=16.0, centerToFrontRatio=0.41, tireStiffnessFactor=0.677), - {Bus.pt: 'honda_civic_hatchback_ex_2017_can_generated', Bus.body: 'honda_crv_ex_2017_body_generated'}, + {Bus.pt: 'honda_civic_hatchback_ex_2017_can_generated', Bus.body: 'honda_crv_ex_2017_body_generated', + Bus.radar: 'honda_radar_scan_generated'}, flags=HondaFlags.BOSCH_ALT_BRAKE, ) HONDA_CRV_6G = HondaBoschCANFDPlatformConfig( @@ -229,7 +230,7 @@ class CAR(Platforms): [HondaCarDocs("Honda CR-V Hybrid 2017-22", min_steer_speed=12. * CV.MPH_TO_MS)], # mass: mean of 4 models in kg, steerRatio: 12.3 is spec end-to-end CarSpecs(mass=1667, wheelbase=2.66, steerRatio=16, centerToFrontRatio=0.41, tireStiffnessFactor=0.677), - {Bus.pt: 'honda_civic_hatchback_ex_2017_can_generated'}, + {Bus.pt: 'honda_civic_hatchback_ex_2017_can_generated', Bus.radar: 'honda_radar_scan_generated'}, ) HONDA_HRV_3G = HondaBoschPlatformConfig( [HondaCarDocs("Honda HR-V 2023-25", "All")], @@ -246,18 +247,18 @@ class CAR(Platforms): ACURA_RDX_3G = HondaBoschPlatformConfig( [HondaCarDocs("Acura RDX 2019-21", "All", min_steer_speed=3. * CV.MPH_TO_MS)], CarSpecs(mass=4068 * CV.LB_TO_KG, wheelbase=2.75, steerRatio=11.95, centerToFrontRatio=0.41, tireStiffnessFactor=0.677), # as spec - {Bus.pt: 'acura_rdx_2020_can_generated'}, + {Bus.pt: 'acura_rdx_2020_can_generated', Bus.radar: 'honda_radar_scan_generated'}, flags=HondaFlags.BOSCH_ALT_BRAKE, ) HONDA_INSIGHT = HondaBoschPlatformConfig( [HondaCarDocs("Honda Insight 2019-22", "All", min_steer_speed=3. * CV.MPH_TO_MS)], CarSpecs(mass=2987 * CV.LB_TO_KG, wheelbase=2.7, steerRatio=15.0, centerToFrontRatio=0.39, tireStiffnessFactor=0.82), # as spec - {Bus.pt: 'honda_insight_ex_2019_can_generated'}, + {Bus.pt: 'honda_insight_ex_2019_can_generated', Bus.radar: 'honda_radar_scan_generated'}, ) HONDA_E = HondaBoschPlatformConfig( [HondaCarDocs("Honda e 2020", "All", min_steer_speed=3. * CV.MPH_TO_MS)], CarSpecs(mass=3338.8 * CV.LB_TO_KG, wheelbase=2.5, centerToFrontRatio=0.5, steerRatio=16.71, tireStiffnessFactor=0.82), - {Bus.pt: 'acura_rdx_2020_can_generated'}, + {Bus.pt: 'acura_rdx_2020_can_generated', Bus.radar: 'honda_radar_scan_generated'}, ) HONDA_PILOT_4G = HondaBoschCANFDPlatformConfig( [HondaCarDocs("Honda Pilot 2023-25", "All")], @@ -387,6 +388,11 @@ HONDA_BOSCH_CANFD = CAR.with_flags(HondaFlags.BOSCH_CANFD) HONDA_BOSCH_ALT_RADAR = CAR.with_flags(HondaFlags.BOSCH_ALT_RADAR) HONDA_BOSCH_TJA_CONTROL = CAR.with_flags(HondaFlags.BOSCH_TJA_CONTROL) +# Bosch harness family whose radar broadcasts the decodable 16-slot object scan +HONDA_RADAR_SCAN_CAPABLE = HONDA_BOSCH - HONDA_BOSCH_RADARLESS - HONDA_BOSCH_CANFD - HONDA_BOSCH_ALT_RADAR +# scan decode stays off per platform until a real route capture has been validated +HONDA_RADAR_SCAN_VERIFIED = frozenset({CAR.HONDA_ACCORD, CAR.HONDA_CIVIC_BOSCH, CAR.HONDA_CRV_5G}) + DBC = CAR.create_dbc_map() diff --git a/artifacts/package_sources/iqdbc/iqdbc/dbc/generator/honda/honda_radar_scan.dbc b/artifacts/package_sources/iqdbc/iqdbc/dbc/generator/honda/honda_radar_scan.dbc new file mode 100644 index 000000000..e49279cc3 --- /dev/null +++ b/artifacts/package_sources/iqdbc/iqdbc/dbc/generator/honda/honda_radar_scan.dbc @@ -0,0 +1,436 @@ +VERSION "honda_radar_scan -- Honda Bosch radar object scan: 16 object slots, each a POS/SHAPE/LIFE/IDENT frame quartet plus a synchronized MOTION companion. Raw counts only; physical conversion lives in iqdbc/car/honda/radar_scan.py" + +NS_ : + NS_DESC_ + CM_ + BA_DEF_ + BA_ + VAL_ + CAT_DEF_ + CAT_ + FILTER + BA_DEF_DEF_ + EV_DATA_ + ENVVAR_DATA_ + SGTYPE_ + SGTYPE_VAL_ + BA_DEF_SGTYPE_ + BA_SGTYPE_ + SIG_TYPE_REF_ + VAL_TABLE_ + SIG_GROUP_ + SIG_VALTYPE_ + SIGTYPE_VALTYPE_ + BO_TX_BU_ + BA_DEF_REL_ + BA_REL_ + BA_DEF_DEF_REL_ + BU_SG_REL_ + BU_EV_REL_ + BU_BO_REL_ + SG_MUL_VAL_ + +BS_: + +BU_: SCANNER XXX + + +BO_ 640 RADAR_SCAN_00_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 641 RADAR_SCAN_00_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 642 RADAR_SCAN_00_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 643 RADAR_SCAN_00_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 712 RADAR_SCAN_00_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 644 RADAR_SCAN_01_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 645 RADAR_SCAN_01_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 646 RADAR_SCAN_01_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 647 RADAR_SCAN_01_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 713 RADAR_SCAN_01_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 648 RADAR_SCAN_02_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 649 RADAR_SCAN_02_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 650 RADAR_SCAN_02_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 651 RADAR_SCAN_02_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 714 RADAR_SCAN_02_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 652 RADAR_SCAN_03_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 653 RADAR_SCAN_03_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 654 RADAR_SCAN_03_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 655 RADAR_SCAN_03_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 715 RADAR_SCAN_03_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 720 RADAR_SCAN_04_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 721 RADAR_SCAN_04_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 722 RADAR_SCAN_04_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 723 RADAR_SCAN_04_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 716 RADAR_SCAN_04_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 724 RADAR_SCAN_05_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 725 RADAR_SCAN_05_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 726 RADAR_SCAN_05_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 727 RADAR_SCAN_05_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 717 RADAR_SCAN_05_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 728 RADAR_SCAN_06_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 729 RADAR_SCAN_06_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 730 RADAR_SCAN_06_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 731 RADAR_SCAN_06_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 718 RADAR_SCAN_06_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 732 RADAR_SCAN_07_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 733 RADAR_SCAN_07_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 734 RADAR_SCAN_07_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 735 RADAR_SCAN_07_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 719 RADAR_SCAN_07_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 736 RADAR_SCAN_08_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 737 RADAR_SCAN_08_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 738 RADAR_SCAN_08_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 739 RADAR_SCAN_08_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 656 RADAR_SCAN_08_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 740 RADAR_SCAN_09_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 741 RADAR_SCAN_09_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 742 RADAR_SCAN_09_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 743 RADAR_SCAN_09_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 657 RADAR_SCAN_09_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 744 RADAR_SCAN_10_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 745 RADAR_SCAN_10_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 746 RADAR_SCAN_10_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 747 RADAR_SCAN_10_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 658 RADAR_SCAN_10_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 748 RADAR_SCAN_11_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 749 RADAR_SCAN_11_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 750 RADAR_SCAN_11_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 751 RADAR_SCAN_11_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 659 RADAR_SCAN_11_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 752 RADAR_SCAN_12_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 753 RADAR_SCAN_12_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 754 RADAR_SCAN_12_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 755 RADAR_SCAN_12_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 660 RADAR_SCAN_12_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 756 RADAR_SCAN_13_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 757 RADAR_SCAN_13_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 758 RADAR_SCAN_13_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 759 RADAR_SCAN_13_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 661 RADAR_SCAN_13_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 760 RADAR_SCAN_14_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 761 RADAR_SCAN_14_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 762 RADAR_SCAN_14_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 763 RADAR_SCAN_14_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 662 RADAR_SCAN_14_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX + +BO_ 764 RADAR_SCAN_15_POS: 8 SCANNER + SG_ SCAN_STATE : 15|4@0+ (1,0) [0|15] "" XXX + SG_ CYCLE : 27|4@0+ (1,0) [0|15] "" XXX + SG_ DIST_RAW : 23|12@0+ (1,0) [0|4095] "" XXX + SG_ BEARING_RAW : 39|11@0+ (1,0) [0|2047] "" XXX + SG_ DIST_SIGMA_RAW : 7|7@0+ (1,0) [0|127] "" XXX + +BO_ 765 RADAR_SCAN_15_SHAPE: 8 SCANNER + SG_ CYCLE : 28|4@0+ (1,0) [0|15] "" XXX + SG_ PRESENCE_RAW : 46|7@0+ (1,0) [0|127] "" XXX + +BO_ 766 RADAR_SCAN_15_LIFE: 8 SCANNER + SG_ CYCLE : 11|4@0+ (1,0) [0|15] "" XXX + SG_ AGE_RAW : 7|12@0+ (1,0) [0|4095] "" XXX + +BO_ 767 RADAR_SCAN_15_IDENT: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ OBJECT_HANDLE : 55|8@0+ (1,0) [0|255] "" XXX + +BO_ 663 RADAR_SCAN_15_MOTION: 8 SCANNER + SG_ CYCLE : 12|4@0+ (1,0) [0|15] "" XXX + SG_ CLOSING_SPEED_RAW : 7|11@0+ (1,0) [0|2047] "" XXX + SG_ CLOSING_SPEED_SIGMA_RAW : 23|10@0+ (1,0) [0|1023] "" XXX + SG_ DIST_RATIO_RAW : 55|10@0+ (1,0) [0|1023] "" XXX diff --git a/artifacts/package_sources/iqdbc/iqdbc/dbc/generator/honda/honda_radar_scan.py b/artifacts/package_sources/iqdbc/iqdbc/dbc/generator/honda/honda_radar_scan.py new file mode 100644 index 000000000..5a9dbc906 --- /dev/null +++ b/artifacts/package_sources/iqdbc/iqdbc/dbc/generator/honda/honda_radar_scan.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos +""" +import os + +SCAN_SLOTS = 16 + +FRAME_SIGNALS = { + "POS": [ + ("SCAN_STATE", 15, 4), + ("CYCLE", 27, 4), + ("DIST_RAW", 23, 12), + ("BEARING_RAW", 39, 11), + ("DIST_SIGMA_RAW", 7, 7), + ], + "SHAPE": [ + ("CYCLE", 28, 4), + ("PRESENCE_RAW", 46, 7), + ], + "LIFE": [ + ("CYCLE", 11, 4), + ("AGE_RAW", 7, 12), + ], + "IDENT": [ + ("CYCLE", 12, 4), + ("OBJECT_HANDLE", 55, 8), + ], + "MOTION": [ + ("CYCLE", 12, 4), + ("CLOSING_SPEED_RAW", 7, 11), + ("CLOSING_SPEED_SIGMA_RAW", 23, 10), + ("DIST_RATIO_RAW", 55, 10), + ], +} + +QUARTET_KINDS = ("POS", "SHAPE", "LIFE", "IDENT") + + +def quartet_base_address(slot: int) -> int: + return 0x280 + 4 * slot if slot < 4 else 0x2D0 + 4 * (slot - 4) + + +def motion_address(slot: int) -> int: + return 0x2C8 + slot if slot < 8 else 0x290 + (slot - 8) + + +def frame_address(slot: int, kind: str) -> int: + if kind == "MOTION": + return motion_address(slot) + return quartet_base_address(slot) + QUARTET_KINDS.index(kind) + + +HEADER = """VERSION "honda_radar_scan -- Honda Bosch radar object scan: 16 object slots, each a POS/SHAPE/LIFE/IDENT \ +frame quartet plus a synchronized MOTION companion. Raw counts only; physical conversion lives in \ +iqdbc/car/honda/radar_scan.py" + +NS_ : +\tNS_DESC_ +\tCM_ +\tBA_DEF_ +\tBA_ +\tVAL_ +\tCAT_DEF_ +\tCAT_ +\tFILTER +\tBA_DEF_DEF_ +\tEV_DATA_ +\tENVVAR_DATA_ +\tSGTYPE_ +\tSGTYPE_VAL_ +\tBA_DEF_SGTYPE_ +\tBA_SGTYPE_ +\tSIG_TYPE_REF_ +\tVAL_TABLE_ +\tSIG_GROUP_ +\tSIG_VALTYPE_ +\tSIGTYPE_VALTYPE_ +\tBO_TX_BU_ +\tBA_DEF_REL_ +\tBA_REL_ +\tBA_DEF_DEF_REL_ +\tBU_SG_REL_ +\tBU_EV_REL_ +\tBU_BO_REL_ +\tSG_MUL_VAL_ + +BS_: + +BU_: SCANNER XXX + +""" + + +def render() -> str: + parts = [HEADER] + for slot in range(SCAN_SLOTS): + for kind in (*QUARTET_KINDS, "MOTION"): + addr = frame_address(slot, kind) + parts.append(f"\nBO_ {addr} RADAR_SCAN_{slot:02d}_{kind}: 8 SCANNER\n") + for name, start_bit, size in FRAME_SIGNALS[kind]: + parts.append(f" SG_ {name} : {start_bit}|{size}@0+ (1,0) [0|{(1 << size) - 1}] \"\" XXX\n") + return "".join(parts) + + +if __name__ == "__main__": + out_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "honda_radar_scan.dbc") + with open(out_path, "w", encoding="utf-8") as f: + f.write(render()) diff --git a/iqpilot/common/params_keys.h b/iqpilot/common/params_keys.h index ffe56c284..1ce292e0a 100644 --- a/iqpilot/common/params_keys.h +++ b/iqpilot/common/params_keys.h @@ -175,6 +175,7 @@ inline static std::unordered_map keys = { {"IQCarParamsPersistent", {PERSISTENT, BYTES}}, {"IQCarParamsPersistentV2", {PERSISTENT, BYTES}}, {"IQLongLearnedFactors", {PERSISTENT, JSON}}, + {"IQHondaRadarScan", {PERSISTENT, BOOL, "1"}}, {"CarPlatformBundle", {PERSISTENT, JSON}}, {"Konn3ktVwOdometers", {PERSISTENT, JSON}}, {"Konn3ktVehicleOdometers", {PERSISTENT, JSON}}, diff --git a/iqpilot/iq_maps/tile_bundle_downloader.py b/iqpilot/iq_maps/tile_bundle_downloader.py index b9c021335..b78a44b1a 100644 --- a/iqpilot/iq_maps/tile_bundle_downloader.py +++ b/iqpilot/iq_maps/tile_bundle_downloader.py @@ -24,6 +24,10 @@ except Exception: # ProprietaryModuleMissing or import errors in stripped build # Tile bundles live as LFS objects in the PRIVATE repo IQ.Lvbs/iqmaps (R2 is gone). # Anonymous access 404s by design; devices authenticate with the embedded read-only PAT # carried by the closed-source updater bundle (same fetch account as the OS images). +# Hugging Face is primary: it is CDN-served, so device downloads no longer come off the +# gitea box's home uplink. The gitea copies stay as failover -- if HF ever suspends the +# repo the fleet silently falls back instead of losing maps entirely. +HF_TILE_BUNDLE_BASE_URL = "https://huggingface.co/datasets/T3vl/iqmaps/resolve/main" DEFAULT_TILE_BUNDLE_BASE_URL = "https://git.konn3kt.com/IQ.Lvbs/iqmaps/raw/branch/master" FALLBACK_TILE_BUNDLE_BASE_URL = "https://gitlvb.teallvbs.xyz/IQ.Lvbs/iqmaps/raw/branch/master" @@ -47,7 +51,9 @@ def candidate_base_urls(params: Params) -> list[str]: override = (override or "").strip() if override: return [override.rstrip("/")] - urls: list[str] = [] + # HF first (CDN, and it keeps device traffic off the gitea box's uplink); the bundle's + # own endpoints and the self-hosted defaults follow as failover. + urls: list[str] = [HF_TILE_BUNDLE_BASE_URL] if _private_base_urls is not None: try: urls.extend(url.rstrip("/") for url in _private_base_urls()) @@ -55,7 +61,12 @@ def candidate_base_urls(params: Params) -> list[str]: pass urls.append(DEFAULT_TILE_BUNDLE_BASE_URL) urls.append(FALLBACK_TILE_BUNDLE_BASE_URL) - return urls + seen: set[str] = set() + return [u for u in urls if not (u in seen or seen.add(u))] + + +def _is_hf(url: str) -> bool: + return "huggingface.co" in url.lower() def _maps_auth_module(): @@ -83,6 +94,16 @@ def _maps_auth_module(): def request_headers(url: str) -> dict: mod = _maps_auth_module() if mod is not None: + if _is_hf(url): + # HF wants a bearer token, not basic auth; a build whose bundle predates HF + # hosting simply gets nothing here and falls through to the gitea mirrors. + try: + token = mod.map_tiles_hf_token() + if token: + return {"Authorization": f"Bearer {token}"} + except Exception: + pass + return {} try: headers = mod.map_tiles_headers(url) if headers: @@ -302,10 +323,12 @@ class TileBundleDownloader: # They stream back-to-back into ONE .part file: concatenating afterwards would need # double the free space, which devices do not have. base = f"{base_url}/{remote_path.lstrip('/')}" - if objects: - urls = [None] * len(objects) # resolved per-attempt from the oid + count = len(objects) if objects else parts + if objects and not _is_hf(base_url): + urls = [None] * count # gitea: resolved per-attempt from the oid else: - urls = [base] if parts <= 1 else [f"{base}.p{i:02d}" for i in range(parts)] + # HF (and plain mirrors) serve the same chunks as ordinary .pNN files + urls = [base] if count <= 1 else [f"{base}.p{i:02d}" for i in range(count)] part_path = final_path.with_name(final_path.name + ".part") part_path.parent.mkdir(parents=True, exist_ok=True) @@ -335,7 +358,7 @@ class TileBundleDownloader: mode = "ab" if resume_from else "wb" with open(part_path, mode) as f: for index in range(first_part, len(urls)): - if objects: + if objects and not _is_hf(base_url): url_headers = request_headers(base_url) auth = None if url_headers else request_auth() object_url, object_headers = _resolve_oid_url( diff --git a/iqpilot/selfdrive/car/interfaces.py b/iqpilot/selfdrive/car/interfaces.py index b61ee6820..d4afb38cb 100644 --- a/iqpilot/selfdrive/car/interfaces.py +++ b/iqpilot/selfdrive/car/interfaces.py @@ -2,7 +2,7 @@ Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos """ from iqdbc.car import structs as _dbc -from iqpilot.common.params import Params as _Store +from iqpilot.common.params import Params as _Store, UnknownKeyName as _UnknownKey from iqpilot.common.swaglog import cloudlog as _log from iqpilot.selfdrive.controls.lib.latcontrol_torque import get_nn_model_path as _resolve_nn @@ -70,8 +70,23 @@ def _cleanup_unsupported_params(cp, cp_iq, store=None) -> None: set_speed_limit_controller_availability(cp, cp_iq, store) +def _apply_radar_scan_switch(cp, store) -> None: + if cp.brand != "honda" or cp.radarUnavailable: + return + try: + switch = store.get("IQHondaRadarScan") + except _UnknownKey: + # params store predates the key (mid-update): the switch defaults on + return + # opt-out kill switch: only an explicit "0" disables, so a never-materialized param stays on + if switch in (b"0", "0"): + _log.warning("IQHondaRadarScan disabled, running without the radar object scan") + cp.radarUnavailable = True + + def apply_iq_car_config(ci, store=None) -> None: store = store or _Store() if _stamp_lateral_model(ci.CP, ci.CP_IQ, store): ci.configure_torque_tune(ci.CP.carFingerprint, ci.CP.lateralTuning) _cleanup_unsupported_params(ci.CP, ci.CP_IQ, store) + _apply_radar_scan_switch(ci.CP, store) diff --git a/iqpilot/selfdrive/controls/radard.py b/iqpilot/selfdrive/controls/radard.py index e81680b4d..f698ffcb4 100755 --- a/iqpilot/selfdrive/controls/radard.py +++ b/iqpilot/selfdrive/controls/radard.py @@ -13,6 +13,7 @@ from iqpilot.common.swaglog import cloudlog from iqpilot.common.simple_kalman import KF1D from iqdbc.car import structs +from iqdbc.car.honda.values import HONDA_RADAR_SCAN_CAPABLE from iqdbc.car.hyundai.values import HyundaiFlags, HyundaiFlagsIQ from iqpilot.selfdrive.controls.lib.custom_stop_distance import CustomStopDistance @@ -29,6 +30,19 @@ V_EGO_STATIONARY = 4. # no stationary object flag below this speed RADAR_TO_CENTER = 2.7 # (deprecated) RADAR is ~ 2.7m ahead from center of car RADAR_TO_CAMERA = 1.52 # RADAR is ~ 1.5m ahead from center of mesh frame +# Honda radar object scan: 15Hz sweeps consumed at the 20Hz model rate, so measurement absorption is +# gated on fresh sweep data and lead selection carries continuity/staleness evidence +SCAN_SWEEP_DT = 1.0 / 15 +SCAN_LEAD_PROB = 0.35 +SCAN_LEAD_MIN_CYCLES = 3 +SCAN_CHALLENGER_STALE_CYCLES = 2 +SCAN_DISTANCE_STALE_CYCLES = 3 +SCAN_DISTANCE_STALE_M = 25.0 + + +def uses_scan_radar(CP) -> bool: + return CP.brand == "honda" and CP.carFingerprint in HONDA_RADAR_SCAN_CAPABLE and not CP.radarUnavailable + class KalmanParams: def __init__(self, dt: float): @@ -62,7 +76,8 @@ class Track: self.K_K = kalman_params.K self.kf = KF1D([[v_lead], [0.0]], self.K_A, self.K_C, self.K_K) - def update(self, d_rel: float, y_rel: float, v_rel: float, v_lead: float, measured: float): + def update(self, d_rel: float, y_rel: float, v_rel: float, v_lead: float, measured: float, + absorb_measurement: bool = True): # relative values, copy self.dRel = d_rel # LONG_DIST self.yRel = y_rel # -LAT_DIST @@ -70,18 +85,19 @@ class Track: self.vLead = v_lead self.measured = measured # measured or estimate - # computed velocity and accelerations - if self.cnt > 0: + # a repeated scan payload between 15Hz sweeps must not be absorbed as a second measurement + if absorb_measurement and self.cnt > 0: self.kf.update(self.vLead) self.vLeadK = float(self.kf.x[SPEED][0]) self.aLeadK = float(self.kf.x[ACCEL][0]) - # Learn if constant acceleration - if abs(self.aLeadK) < 0.5: - self.aLeadTau.x = _LEAD_ACCEL_TAU - else: - self.aLeadTau.update(0.0) + if absorb_measurement: + # Learn if constant acceleration + if abs(self.aLeadK) < 0.5: + self.aLeadTau.x = _LEAD_ACCEL_TAU + else: + self.aLeadTau.update(0.0) self.cnt += 1 @@ -119,18 +135,35 @@ def laplacian_pdf(x: float, mu: float, b: float): return math.exp(-abs(x-mu)/b) +def model_association_score(track: Track, lead: capnp._DynamicStructReader, v_ego: float) -> float: + offset_vision_dist = lead.x[0] - RADAR_TO_CAMERA + prob_d = laplacian_pdf(track.dRel, offset_vision_dist, lead.xStd[0]) + prob_y = laplacian_pdf(track.yRel, -lead.y[0], lead.yStd[0]) + prob_v = laplacian_pdf(track.vRel + v_ego, lead.v[0], lead.vStd[0]) + + # This isn't exactly right, but it's a good heuristic + return prob_d * prob_y * prob_v + + +def track_agrees_with_model(track: Track, lead: capnp._DynamicStructReader, v_ego: float, strict: bool) -> bool: + dist_scale, dist_floor, vel_limit, y_std_scale, y_floor = \ + (0.25, 5.0, 10.0, 1.0, 1.0) if strict else (0.40, 8.0, 13.0, 2.0, 1.5) + vision_dist = lead.x[0] - RADAR_TO_CAMERA + dist_ok = abs(track.dRel - vision_dist) < max(abs(vision_dist) * dist_scale, dist_floor) + vel_ok = (abs(track.vRel + v_ego - lead.v[0]) < vel_limit) or (v_ego + track.vRel > 3) + lat_ok = abs(track.yRel + lead.y[0]) < max(y_floor, y_std_scale * max(float(lead.yStd[0]), 0.2)) + return dist_ok and vel_ok and lat_ok + + +def scan_low_speed_candidate(track: Track, v_ego: float) -> bool: + # require a few real cycles before a radar-only low-speed takeover + return track.cnt >= SCAN_LEAD_MIN_CYCLES and track.potential_low_speed_lead(v_ego) + + def match_vision_to_track(v_ego: float, lead: capnp._DynamicStructReader, tracks: dict[int, Track]): offset_vision_dist = lead.x[0] - RADAR_TO_CAMERA - def prob(c): - prob_d = laplacian_pdf(c.dRel, offset_vision_dist, lead.xStd[0]) - prob_y = laplacian_pdf(c.yRel, -lead.y[0], lead.yStd[0]) - prob_v = laplacian_pdf(c.vRel + v_ego, lead.v[0], lead.vStd[0]) - - # This isn't exactly right, but it's a good heuristic - return prob_d * prob_y * prob_v - - track = max(tracks.values(), key=prob) + track = max(tracks.values(), key=lambda c: model_association_score(c, lead, v_ego)) # if no 'sane' match is found return -1 # stationary radar points can be false positives @@ -161,22 +194,60 @@ def get_RadarState_from_vision(lead_msg: capnp._DynamicStructReader, v_ego: floa def get_lead(v_ego: float, ready: bool, tracks: dict[int, Track], lead_msg: capnp._DynamicStructReader, - model_v_ego: float, CP: structs.CarParams, CP_IQ: structs.IQCarParams, low_speed_override: bool = True) -> dict[str, Any]: + model_v_ego: float, CP: structs.CarParams, CP_IQ: structs.IQCarParams, low_speed_override: bool = True, + scan_radar: bool = False, filtered_prob: float | None = None, held_track_id: int = -1) -> dict[str, Any]: + lead_prob = float(lead_msg.prob if filtered_prob is None else filtered_prob) + prob_threshold = SCAN_LEAD_PROB if scan_radar else .5 + # Determine leads, this is where the essential logic happens - if len(tracks) > 0 and ready and lead_msg.prob > .5: + if len(tracks) > 0 and ready and lead_prob > prob_threshold: track = match_vision_to_track(v_ego, lead_msg, tracks) else: track = None lead_dict = {'status': False} if track is not None: - lead_dict = track.get_RadarState(lead_msg.prob) + lead_dict = track.get_RadarState(lead_prob) lead_dict = get_custom_yrel(CP, CP_IQ, lead_dict, lead_msg) - elif (track is None) and ready and (lead_msg.prob > .5): + elif (track is None) and ready and (lead_prob > prob_threshold): lead_dict = get_RadarState_from_vision(lead_msg, v_ego, model_v_ego) if low_speed_override: - low_speed_tracks = [c for c in tracks.values() if c.potential_low_speed_lead(v_ego)] + if scan_radar: + low_speed_tracks = [c for c in tracks.values() if scan_low_speed_candidate(c, v_ego)] + else: + low_speed_tracks = [c for c in tracks.values() if c.potential_low_speed_lead(v_ego)] + + model_lead_available = ready and lead_prob > prob_threshold + + if scan_radar: + # Keep the held radar lead through ordinary model-probability fluctuations while it stays + # coherent. With a valid model lead it must still agree with it; without one, a mature radar + # track remains eligible for continuity + held = tracks.get(held_track_id) + if held is not None and scan_low_speed_candidate(held, v_ego): + held_matches_model = (not model_lead_available or + track_agrees_with_model(held, lead_msg, v_ego, strict=True)) + held_is_current = (not lead_dict.get('status', False) or + lead_dict.get('radarTrackId', -1) == held_track_id or + (lead_dict.get('status', False) and not lead_dict.get('radar', False))) + if held_is_current and held_matches_model: + lead_dict = held.get_RadarState(lead_prob) + + def candidate_established(candidate: Track) -> bool: + if candidate.cnt < SCAN_LEAD_MIN_CYCLES: + return False + if not lead_dict.get('status', False): + # a mature centered scan point may provide the radar-only low-speed lead + return True + if lead_dict.get('radarTrackId', -1) == candidate.identifier: + return True + # never replace an established lead with an unrelated closer point without model evidence + # to arbitrate them + return model_lead_available and track_agrees_with_model(candidate, lead_msg, v_ego, strict=True) + + low_speed_tracks = [c for c in low_speed_tracks if candidate_established(c)] + if len(low_speed_tracks) > 0: closest_track = min(low_speed_tracks, key=lambda c: c.dRel) @@ -204,7 +275,16 @@ class RadarD: self.current_time = 0.0 self.tracks: dict[int, Track] = {} - self.kalman_params = KalmanParams(DT_MDL) + self.scan_radar = uses_scan_radar(CP) + # the lead KF absorbs scan measurements at the physical 15Hz sweep cadence; lead probability + # filtering stays on model-loop timing + self.kalman_params = KalmanParams(SCAN_SWEEP_DT if self.scan_radar else DT_MDL) + self.lead_prob_filters = [FirstOrderFilter(0.0, 0.2, DT_MDL) for _ in range(2)] + self.held_lead_ids = [-1, -1] + self._held_evidence_ids = [-1, -1] + self._challenger_stale_counts = [0, 0] + self._distance_stale_counts = [0, 0] + self._last_tracks_frame = -1 self.v_ego = 0.0 self.v_ego_hist = deque([0.0], maxlen=int(round(delay / DT_MDL))+1) @@ -217,6 +297,53 @@ class RadarD: self.custom_stop_distance = CustomStopDistance() + def _refresh_held_lead_evidence(self, lead_index: int, lead: capnp._DynamicStructReader, + lead_prob: float) -> None: + held_id = self.held_lead_ids[lead_index] + if self._held_evidence_ids[lead_index] != held_id: + self._reset_held_evidence(lead_index, held_id) + + held = self.tracks.get(held_id) + if held_id < 0 or held is None or not self.ready or lead_prob <= SCAN_LEAD_PROB: + self._reset_held_evidence(lead_index, held_id) + return + + strict_match = track_agrees_with_model(held, lead, self.v_ego, strict=True) + relaxed_match = track_agrees_with_model(held, lead, self.v_ego, strict=False) + + # evidence arm 1: another live track scores better against the model while the held one no + # longer passes even relaxed continuity. Releasing the hold never selects that challenger; the + # strict-match path in get_lead stays the only way it becomes the radar lead + if relaxed_match: + self._challenger_stale_counts[lead_index] = 0 + else: + best = max(self.tracks.values(), key=lambda c: model_association_score(c, lead, self.v_ego)) + if best.identifier != held_id and \ + model_association_score(best, lead, self.v_ego) > model_association_score(held, lead, self.v_ego): + self._challenger_stale_counts[lead_index] += 1 + else: + self._challenger_stale_counts[lead_index] = 0 + + # evidence arm 2: gross absolute range disagreement, with a strict match staying authoritative + # even when model uncertainty would permit the error + distance_mismatch = abs(held.dRel - (lead.x[0] - RADAR_TO_CAMERA)) + if strict_match: + self._distance_stale_counts[lead_index] = 0 + elif distance_mismatch > SCAN_DISTANCE_STALE_M: + self._distance_stale_counts[lead_index] += 1 + else: + self._distance_stale_counts[lead_index] = 0 + + if (self._challenger_stale_counts[lead_index] >= SCAN_CHALLENGER_STALE_CYCLES or + self._distance_stale_counts[lead_index] >= SCAN_DISTANCE_STALE_CYCLES): + self.held_lead_ids[lead_index] = -1 + self._reset_held_evidence(lead_index) + + def _reset_held_evidence(self, lead_index: int, held_id: int = -1) -> None: + self._held_evidence_ids[lead_index] = held_id + self._challenger_stale_counts[lead_index] = 0 + self._distance_stale_counts[lead_index] = 0 + def update(self, sm: messaging.SubMaster, rr: car.RadarData): self.ready = sm.seen['modelV2'] self.current_time = 1e-9*max(sm.logMonoTime.values()) @@ -227,6 +354,11 @@ class RadarD: self.v_ego_hist.append(self.v_ego) self.last_v_ego_frame = sm.recv_frame['carState'] + sweep_fresh = True + if self.scan_radar: + sweep_fresh = sm.recv_frame['radarTracks'] != self._last_tracks_frame + self._last_tracks_frame = sm.recv_frame['radarTracks'] + ar_pts = {pt.trackId: [pt.dRel, pt.yRel, pt.vRel, pt.measured] for pt in rr.points} # *** remove missing points from meta data *** @@ -244,7 +376,11 @@ class RadarD: # create the track if it doesn't exist or it's a new track if ids not in self.tracks: self.tracks[ids] = Track(ids, v_lead, self.kalman_params) - self.tracks[ids].update(rpt[0], rpt[1], rpt[2], v_lead, rpt[3]) + if self.scan_radar: + measured = bool(rpt[3] and sweep_fresh) + self.tracks[ids].update(rpt[0], rpt[1], rpt[2], v_lead, measured, absorb_measurement=measured) + else: + self.tracks[ids].update(rpt[0], rpt[1], rpt[2], v_lead, rpt[3]) # *** publish radarState *** self.radar_state_valid = sm.all_checks() @@ -259,8 +395,34 @@ class RadarD: model_v_ego = self.v_ego leads_v3 = sm['modelV2'].leadsV3 if len(leads_v3) > 1: - lead_one = get_lead(self.v_ego, self.ready, self.tracks, leads_v3[0], model_v_ego, self.CP, self.CP_IQ, low_speed_override=True) - lead_two = get_lead(self.v_ego, self.ready, self.tracks, leads_v3[1], model_v_ego, self.CP, self.CP_IQ, low_speed_override=False) + if self.scan_radar: + for i in range(2): + lead_prob = float(leads_v3[i].prob) + # probability rises instantly, decays filtered: a one-cycle model dip must not drop the lead + if lead_prob > self.lead_prob_filters[i].x: + self.lead_prob_filters[i].x = lead_prob + else: + self.lead_prob_filters[i].update(lead_prob) + self._refresh_held_lead_evidence(i, leads_v3[i], self.lead_prob_filters[i].x) + + lead_one = get_lead(self.v_ego, self.ready, self.tracks, leads_v3[0], model_v_ego, self.CP, self.CP_IQ, + low_speed_override=True, scan_radar=True, filtered_prob=self.lead_prob_filters[0].x, + held_track_id=self.held_lead_ids[0]) + lead_two = get_lead(self.v_ego, self.ready, self.tracks, leads_v3[1], model_v_ego, self.CP, self.CP_IQ, + low_speed_override=False, scan_radar=True, filtered_prob=self.lead_prob_filters[1].x, + held_track_id=self.held_lead_ids[1]) + + for i, lead in enumerate((lead_one, lead_two)): + if lead.get('status', False) and lead.get('radar', False): + track_id = int(lead.get('radarTrackId', -1)) + if track_id != self.held_lead_ids[i]: + self._reset_held_evidence(i, track_id) + self.held_lead_ids[i] = track_id + elif (not lead.get('status', False)) or (self.held_lead_ids[i] not in self.tracks): + self.held_lead_ids[i] = -1 + else: + lead_one = get_lead(self.v_ego, self.ready, self.tracks, leads_v3[0], model_v_ego, self.CP, self.CP_IQ, low_speed_override=True) + lead_two = get_lead(self.v_ego, self.ready, self.tracks, leads_v3[1], model_v_ego, self.CP, self.CP_IQ, low_speed_override=False) self.radar_state.leadOne = self.custom_stop_distance.apply_lead(lead_one) self.radar_state.leadTwo = self.custom_stop_distance.apply_lead(lead_two) diff --git a/iqpilot/selfdrive/controls/tests/test_scan_radar_leads.py b/iqpilot/selfdrive/controls/tests/test_scan_radar_leads.py new file mode 100644 index 000000000..dac15b219 --- /dev/null +++ b/iqpilot/selfdrive/controls/tests/test_scan_radar_leads.py @@ -0,0 +1,191 @@ +from types import SimpleNamespace + +import pytest + +from iqdbc.car.honda.values import CAR +from iqpilot.selfdrive.controls.radard import (SCAN_CHALLENGER_STALE_CYCLES, SCAN_DISTANCE_STALE_CYCLES, + SCAN_LEAD_MIN_CYCLES, SCAN_LEAD_PROB, SCAN_SWEEP_DT, + KalmanParams, RadarD, Track, get_lead, + scan_low_speed_candidate, track_agrees_with_model, + uses_scan_radar) + +DT_MDL = 0.05 + + +def honda_cp(fingerprint=CAR.HONDA_CIVIC_BOSCH, radar_unavailable=False, brand="honda"): + return SimpleNamespace(brand=brand, carFingerprint=fingerprint, radarUnavailable=radar_unavailable, flags=0) + + +def model_lead(x=30.0, y=0.0, v=10.0, prob=0.9, x_std=2.0, y_std=0.5, v_std=1.0): + return SimpleNamespace(x=[x], y=[y], v=[v], a=[0.0], prob=prob, xStd=[x_std], yStd=[y_std], vStd=[v_std]) + + +def make_track(identifier, d_rel, y_rel=0.0, v_rel=0.0, v_ego=10.0, cycles=SCAN_LEAD_MIN_CYCLES, measured=True): + track = Track(identifier, v_rel + v_ego, KalmanParams(SCAN_SWEEP_DT)) + for _ in range(cycles): + track.update(d_rel, y_rel, v_rel, v_rel + v_ego, measured) + return track + + +class TestScanRadarGating: + def test_capable_verified_car_uses_scan(self): + assert uses_scan_radar(honda_cp()) + + def test_radar_unavailable_disables(self): + assert not uses_scan_radar(honda_cp(radar_unavailable=True)) + + def test_non_family_car_never_scans(self): + assert not uses_scan_radar(honda_cp(fingerprint=CAR.HONDA_CIVIC)) + assert not uses_scan_radar(honda_cp(fingerprint=CAR.HONDA_CIVIC_2022)) + + def test_other_brand_never_scans(self): + assert not uses_scan_radar(honda_cp(brand="toyota")) + + +class TestTrackMeasurementGating: + def test_repeated_payload_not_absorbed_twice(self): + kp = KalmanParams(SCAN_SWEEP_DT) + absorbed = Track(1, 10.0, kp) + starved = Track(1, 10.0, kp) + absorbed.update(30.0, 0.0, 5.0, 15.0, True) + starved.update(30.0, 0.0, 5.0, 15.0, True) + v_after_first = starved.vLeadK + + for _ in range(5): + absorbed.update(30.0, 0.0, 5.0, 15.0, True, absorb_measurement=True) + starved.update(30.0, 0.0, 5.0, 15.0, False, absorb_measurement=False) + + assert starved.vLeadK == v_after_first + assert absorbed.vLeadK != v_after_first + assert starved.cnt == absorbed.cnt + + +class TestLowSpeedCandidate: + def test_needs_minimum_cycles(self): + young = make_track(1, 10.0, v_ego=2.0, cycles=SCAN_LEAD_MIN_CYCLES - 1) + mature = make_track(2, 10.0, v_ego=2.0) + assert not scan_low_speed_candidate(young, 2.0) + assert scan_low_speed_candidate(mature, 2.0) + + def test_geometry_still_applies(self): + offset = make_track(3, 10.0, y_rel=2.0, v_ego=2.0) + assert not scan_low_speed_candidate(offset, 2.0) + + +class TestModelAgreement: + def test_strict_tighter_than_relaxed(self): + track = make_track(1, 40.0, v_ego=10.0) + # ~15m disagreement: beyond the strict 25% envelope, inside the relaxed 40% one + lead = model_lead(x=56.5) + assert not track_agrees_with_model(track, lead, 10.0, strict=True) + assert track_agrees_with_model(track, lead, 10.0, strict=False) + + +class TestScanLeadSelection: + def make_cp(self): + return honda_cp(), SimpleNamespace() + + def test_held_lead_survives_probability_dip(self): + CP, CP_IQ = self.make_cp() + track = make_track(7, 10.0, v_ego=2.0) + lead = model_lead(x=11.5, v=2.0, prob=0.05) + # raw prob is below threshold, the filtered prob is passed in above it + result = get_lead(2.0, True, {7: track}, lead, 2.0, CP, CP_IQ, low_speed_override=True, + scan_radar=True, filtered_prob=0.6, held_track_id=7) + assert result['status'] and result['radarTrackId'] == 7 + + def test_unrelated_closer_point_cannot_usurp_without_model_evidence(self): + CP, CP_IQ = self.make_cp() + held = make_track(7, 10.0, v_ego=2.0) + interloper = make_track(9, 4.0, y_rel=0.5, v_ego=2.0) + lead = model_lead(x=11.5, v=2.0, prob=0.9) + result = get_lead(2.0, True, {7: held, 9: interloper}, lead, 2.0, CP, CP_IQ, low_speed_override=True, + scan_radar=True, filtered_prob=0.9, held_track_id=7) + assert result['radarTrackId'] == 7 + + def test_matching_closer_point_takes_over(self): + CP, CP_IQ = self.make_cp() + held = make_track(7, 10.0, v_ego=2.0) + closer = make_track(9, 4.0, v_ego=2.0) + lead = model_lead(x=5.5, v=2.0, prob=0.9) # model agrees with the closer car + result = get_lead(2.0, True, {7: held, 9: closer}, lead, 2.0, CP, CP_IQ, low_speed_override=True, + scan_radar=True, filtered_prob=0.9, held_track_id=7) + assert result['radarTrackId'] == 9 + + def test_radar_only_takeover_when_no_model_lead(self): + CP, CP_IQ = self.make_cp() + track = make_track(4, 8.0, v_ego=2.0) + lead = model_lead(prob=0.0) + result = get_lead(2.0, True, {4: track}, lead, 2.0, CP, CP_IQ, low_speed_override=True, + scan_radar=True, filtered_prob=0.0, held_track_id=-1) + assert result['status'] and result['radarTrackId'] == 4 + + def test_young_track_cannot_lead_alone(self): + CP, CP_IQ = self.make_cp() + track = make_track(4, 8.0, v_ego=2.0, cycles=1) + lead = model_lead(prob=0.0) + result = get_lead(2.0, True, {4: track}, lead, 2.0, CP, CP_IQ, low_speed_override=True, + scan_radar=True, filtered_prob=0.0, held_track_id=-1) + assert not result['status'] + + def test_non_scan_behavior_unchanged(self): + CP, CP_IQ = self.make_cp() + track = make_track(4, 8.0, v_ego=2.0, cycles=1) + lead = model_lead(prob=0.0) + result = get_lead(2.0, True, {4: track}, lead, 2.0, CP, CP_IQ, low_speed_override=True) + assert result['status'] # legacy path has no maturity gate + + +class TestHeldLeadStaleness: + def make_radard(self, tracks): + rd = object.__new__(RadarD) + rd.tracks = tracks + rd.ready = True + rd.v_ego = 10.0 + rd.held_lead_ids = [7, -1] + rd._held_evidence_ids = [7, -1] + rd._challenger_stale_counts = [0, 0] + rd._distance_stale_counts = [0, 0] + return rd + + def test_relaxed_match_keeps_hold(self): + held = make_track(7, 30.0, v_ego=10.0) + rd = self.make_radard({7: held}) + for _ in range(5): + rd._refresh_held_lead_evidence(0, model_lead(x=32.0), 0.9) + assert rd.held_lead_ids[0] == 7 + + def test_better_challenger_releases_hold(self): + held = make_track(7, 80.0, v_ego=10.0) + challenger = make_track(9, 30.0, v_ego=10.0) + rd = self.make_radard({7: held, 9: challenger}) + lead = model_lead(x=31.5) + for _ in range(SCAN_CHALLENGER_STALE_CYCLES): + rd._refresh_held_lead_evidence(0, lead, 0.9) + assert rd.held_lead_ids[0] == -1 + + def test_gross_distance_disagreement_releases_hold(self): + held = make_track(7, 80.0, v_ego=10.0) + rd = self.make_radard({7: held}) + lead = model_lead(x=31.5, x_std=60.0, y_std=30.0, v_std=30.0) # huge model uncertainty + for _ in range(SCAN_DISTANCE_STALE_CYCLES): + rd._refresh_held_lead_evidence(0, lead, 0.9) + assert rd.held_lead_ids[0] == -1 + + def test_low_probability_resets_evidence_without_release(self): + held = make_track(7, 80.0, v_ego=10.0) + rd = self.make_radard({7: held}) + for _ in range(10): + rd._refresh_held_lead_evidence(0, model_lead(x=31.5), SCAN_LEAD_PROB) + assert rd.held_lead_ids[0] == 7 + assert rd._distance_stale_counts[0] == 0 + + +class TestProbFilterTiming: + def test_rises_instantly_decays_slowly(self): + from iqpilot.common.filter_simple import FirstOrderFilter + f = FirstOrderFilter(0.0, 0.2, DT_MDL) + f.x = max(f.x, 0.9) + assert f.x == pytest.approx(0.9) + f.update(0.0) + assert f.x > 0.6 diff --git a/iqpilot/selfdrive/iqmodeld/egpu_prefetch.py b/iqpilot/selfdrive/iqmodeld/egpu_prefetch.py index e10432371..00084a774 100644 --- a/iqpilot/selfdrive/iqmodeld/egpu_prefetch.py +++ b/iqpilot/selfdrive/iqmodeld/egpu_prefetch.py @@ -10,7 +10,8 @@ import time from iqpilot.common.params import Params from iqpilot.common.swaglog import cloudlog -from iqpilot.selfdrive.iqmodeld.egpu_helpers import download_precompiled, egpu_policy_pkl_path, egpu_selected, patch_tinygrad_fetch_fw, usbgpu_present +from iqpilot.selfdrive.iqmodeld.egpu_helpers import (download_precompiled, egpu_oob_pkl_path, egpu_policy_pkl_path, patch_tinygrad_fetch_fw, + usbgpu_present) from iqpilot.selfdrive.iqmodeld.egpu_model import resolve_egpu_model POLL_S = 30.0 @@ -28,7 +29,8 @@ def _selected_meta(params: Params) -> dict | None: def _drop_stale_partials(keep: str) -> None: root = os.path.dirname(keep) - for path in glob.glob(os.path.join(root, "egpu_*_amd_policy.pkl.part")) + glob.glob(os.path.join(root, "big_driving_supercombo_*.onnx.part")): + partial_globs = ("egpu_*_amd_policy.pkl.part", "egpu_*_amd_policy_oob.pkl.part", "big_driving_supercombo_*.onnx.part") + for path in [p for g in partial_globs for p in glob.glob(os.path.join(root, g))]: if not path.startswith(keep): try: os.remove(path) @@ -37,15 +39,16 @@ def _drop_stale_partials(keep: str) -> None: def prefetch_once(params: Params) -> bool: - if not usbgpu_present() or not egpu_selected(params): + if params.get_bool("IQEgpuDisabled"): return False meta = _selected_meta(params) if meta is None: return False - dst = egpu_policy_pkl_path(meta) + oob = bool(meta.get("egpu_oob_artifact")) + dst = egpu_oob_pkl_path(meta) if oob else egpu_policy_pkl_path(meta) if os.path.isfile(dst): return True - if not meta.get("egpu_policy_artifact"): + if not oob and not meta.get("egpu_policy_artifact"): return False _drop_stale_partials(dst) params.put("UsbGpuSetupProgress", "0.0") @@ -56,8 +59,8 @@ def prefetch_once(params: Params) -> bool: last[0] = p params.put("UsbGpuSetupProgress", f"{p:.3f}") - cloudlog.warning(f"egpu_prefetch downloading {meta['key']} policy artifact offroad") - out = download_precompiled(meta, progress_cb=_prog, policy=True) + cloudlog.warning(f"egpu_prefetch downloading {meta['key']} {'streamable' if oob else 'policy'} artifact offroad") + out = download_precompiled(meta, progress_cb=_prog, policy=not oob, oob=oob) cloudlog.warning(f"egpu_prefetch ready -> {out}") return out is not None diff --git a/iqpilot/selfdrive/ui/mici/layouts/settings/models.py b/iqpilot/selfdrive/ui/mici/layouts/settings/models.py index 9586367df..d0ce457c0 100644 --- a/iqpilot/selfdrive/ui/mici/layouts/settings/models.py +++ b/iqpilot/selfdrive/ui/mici/layouts/settings/models.py @@ -137,7 +137,7 @@ class ModelsLayoutMici(NavScroller): self._redownload.set_enabled(self._can_redownload) self._refresh = BigButton(tr("reload model list")) - self._refresh.set_click_callback(lambda: ui_state.params.put("ModelManager_LastSyncTime", 0)) + self._refresh.set_click_callback(self._reload_model_lists) self._supercombo = GreyBigButton(tr("combined model")) self._supercombo.set_visible(False) @@ -355,10 +355,13 @@ class ModelsLayoutMici(NavScroller): btns = [_ModelButton(b, self._select_model, self._toggle_favorite, b.ref in favorites) for b in bundles] gui_app.push_widget(_ModelSelectPanel(btns)) + def _reload_model_lists(self): + ui_state.params.put("ModelManager_LastSyncTime", 0) + _refresh_big_catalog() + def _show_big_models(self): + _refresh_big_catalog() options = _big_options() - if len(options) <= 1: - _refresh_big_catalog() off = BigButton(tr("Off")) off.set_click_callback(lambda: self._select_big(None)) btns = [off] diff --git a/iqpilot/system/manager/process_config.py b/iqpilot/system/manager/process_config.py index 77b650722..2b1de9aee 100644 --- a/iqpilot/system/manager/process_config.py +++ b/iqpilot/system/manager/process_config.py @@ -138,6 +138,11 @@ def egpu_enabled(started, params, CP: car.CarParams) -> bool: return (resolve_backend(params.get_bool("IQEmacEnabled"), egpu_selected(params), _egpu_present(params)) == "egpu" and _egpu_present(params)) +def egpu_prefetch_enabled(started, params, CP: car.CarParams) -> bool: + if params.get_bool("IQEgpuDisabled"): + return False + return resolve_backend(params.get_bool("IQEmacEnabled"), True, _egpu_present(params)) == "egpu" + def big_model_enabled(started, params, CP: car.CarParams) -> bool: return params.get_bool("IQEmacEnabled") or egpu_selected(params) @@ -223,7 +228,7 @@ procs += [ PythonProcess("iqegpumodeld", "iqpilot.selfdrive.iqmodeld.iqegpumodeld", and_(only_onroad, and_(is_tinygrad_model, egpu_enabled)), restart_if_crash=True), PythonProcess("egpu_prefetch", "iqpilot.selfdrive.iqmodeld.egpu_prefetch", - and_(only_offroad, and_(is_tinygrad_model, egpu_enabled)), restart_if_crash=True), + and_(only_offroad, and_(is_tinygrad_model, egpu_prefetch_enabled)), restart_if_crash=True), BundleProcess("backup_manager_k3", "iqpilot_hephaestusd_private", "iqpilot_private.konn3kt.backups.backup_orchestrator", and_(only_offroad, hephaestus_ready_shim, not_low_power)),