using System; using System.Collections.Generic; using System.Drawing; using System.Linq; using System.Net.Http; using System.Numerics; using System.Threading; using System.Threading.Tasks; using ClumsyCore; using ClumsyCore.DTools; using ClumsyCore.Interfaces; using ClumsyCore.Pilot; using ClumsyCore.Utilities; using CommonUsage; using CommonUsage.Chassis; using FundamentalLib; using FundamentalLib.Utilities; using MDCSToolBox.Clumsy.Pilot.MultiWheel; using Newtonsoft.Json; using Vector = ClumsyCore.Utilities.Vector; namespace MultiWheelC; public class PilotDefinition : MultiWheelPilotDefinition { public new float CarLength = 1472f; public new float CarWidth = 948f; public override void StandardInit() { InitMultiVehicleCoordination(); } #region MultiVehicleCoordination (从 MDCSToolBox 内联回 Tutorial) [AsLowerIO(desc = "启用(手动)多车联动")] public bool MultiVehicleManualEnabled; [AsLowerIO(desc = "(手动)多车联动模式")] public int MultiVehicleManualMode; [FieldMember(desc = "多车联动已同步")] public bool MultiVehicleAligned; [AsLowerIO(desc = "多车联动:遥控器Vx")] public float MultiVehicleManualVx; [AsLowerIO(desc = "多车联动:遥控器Vy")] public float MultiVehicleManualVy; [AsLowerIO(desc = "多车联动:遥控器Vth")] public float MultiVehicleManualVth; [AsUpperIO(desc = "多车联动:自动驾驶", timeOutReset = true)] public bool MultiVehicleAutoEnabled; [FieldMember(desc = "多车联动:自动驾驶Vx")] public float MultiVehicleAutoVx; [FieldMember(desc = "多车联动:自动驾驶FrontTh")] public float MultiVehicleAutoFrontTh; [FieldMember(desc = "多车联动:自动驾驶RearTh")] public float MultiVehicleAutoRearTh; public Dictionary MultiVehicleFleet = new(); public VehicleSyncNotification MultiVehicleNotification; [FieldMember(desc = "多车联动:车队姿态x")] public float CenterX; [FieldMember(desc = "多车联动:车队姿态y")] public float CenterY; [FieldMember(desc = "多车联动:车队姿态th")] public float CenterTh; [AsLowerIO(desc = "车号")] public int CarNum = 1; private float _multiVehicleAccumulateTh; private DateTime _multiVehicleLastThTime = DateTime.Now; private readonly object _multiVehicleNotificationLock = new(); private DateTime _multiVehicleLastNotifyTime = DateTime.MinValue; private bool _multiVehicleSyncInitialized; // 诊断日志:节流计时 + 最近一次检测几何(中心/朝向/距离),用于定位剧烈运动来源 private DateTime _mvDbgLastLog = DateTime.MinValue; private float _mvLastDetCenterX, _mvLastDetCenterY, _mvLastDetDir, _mvLastDetDist; // 原地旋转(mode2)实际叠加的车体系纠偏旋量(mm/s, mm/s, deg/s),仅用于诊断日志。 private float _mvRotCompVx, _mvRotCompVy, _mvRotCompOmega; // 原地旋转纠偏 PI 控制器的积分累加器(mm·s, mm·s, deg·s)与上次计算时刻。 private float _rotIntegX, _rotIntegY, _rotIntegTh; private DateTime _rotPiLastTime = DateTime.MinValue; // 原地旋转“两车实际 sim 位姿”诊断采样状态(仅主车,通过 Playground WebAPI 读取)。 private DateTime _rotPoseLastLog = DateTime.MinValue; private DateTime _rotPosePrevTime = DateTime.MinValue; private float _rotPosePrevYaw1, _rotPosePrevYaw2; private bool _rotPoseEpisode; // 本次旋转片段是否已捕获参考量 private float _rotPoseMid0X, _rotPoseMid0Y; // 起转瞬间车队几何中心(世界系),用于度量整体平移漂移 // 写盘诊断日志(Clumsy 侧)。改用 DLog 落盘:同一 topic 的日志会归到以 topic 命名的文件夹, // 由 DLog 统一管理落点/滚动,无需自行维护文件句柄与 session 清空逻辑。 private DateTime _mvDiskLastLog = DateTime.MinValue; private void FleetDiag(string msg) { DLog.Log($"car{CarNum} {msg}", "FleetDiagClumsy"); } // 互识别:邻车两腿检测的滑动窗口(最近 1s,最多 10 帧),用于平滑抖动 private readonly object _neighborDetectLock = new(); private readonly List<(DateTime Time, Vector2 Src, Vector2 Dst)> _neighborDetects = new(); // 互识别:上一帧检测中心,用作下一帧猜测(闭环跟踪),与「多舵轮-2腿检测」MovementTest 一致 private Vector2 _neighborGuess; private bool _neighborGuessValid; private DateTime _neighborGuessTime = DateTime.MinValue; /// /// 互识别钩子:用单线雷达识别邻车的两腿/两轮,换算成本车体坐标系下相对“理应对齐参考点”的偏差。 /// 全对齐时返回 (true, 0, 0, 0);未检测到时返回 (false, ...)。 /// detectDistance = 编队车间距 - 检测中心偏移(即邻车检测中心到本车原点的标称距离,恒为正值,方向由 TwoLegGuessX 符号决定)。 /// private (bool Valid, float Dx, float Dy, float Dth, float NeighborDist) DetectNeighborBias(float detectDistance) { // 检测猜测点与「多舵轮-2腿检测」MovementTest 完全一致:首帧 / 跟丢回落都用 Conf.TwoLegGuessX // (方向与距离都由它一处决定),跟到后用上一帧中心闭环跟踪。 // detectDistance 只用于计算编队对齐偏差/间距,不参与“去哪里找邻车”,避免猜测点偏离导致 ROI 框漏掉真实邻车。 var nominal = new Vector2(Conf.TwoLegGuessX, 0f); var now = DateTime.Now; // 闭环跟踪:1s 内检到过就用上一帧中心作猜测,使绿色 ROI 框收敛到真实邻车(与 MovementTest 行为一致); // 跟丢超时则回落到编队标称位置,避免框漂走。 var guess = (_neighborGuessValid && (now - _neighborGuessTime).TotalSeconds < 1) ? _neighborGuess : nominal; var seg = TwoLegDetect.Detect(Conf.TwoLegLidarName, guess.X, TwoLegDetect.SetFilters(guess.X, guess.Y)); Vector2 avgSrc, avgDst; lock (_neighborDetectLock) { if (seg != null) { _neighborDetects.Add((now, seg.Src, seg.Dst)); _neighborGuess = (seg.Src + seg.Dst) / 2f; _neighborGuessValid = true; _neighborGuessTime = now; } _neighborDetects.RemoveAll(r => (now - r.Time).TotalSeconds > 1); var cnt = _neighborDetects.Count; if (cnt == 0) { _neighborGuessValid = false; Hedingben.ToastText($"邻车未检到 (guess x:{guess.X:F0} y:{guess.Y:F0})", $"MultiVehicle{CarNum}-detect"); return (false, 0f, 0f, 0f, 0f); } if (cnt > 10) _neighborDetects.RemoveRange(0, cnt - 10); avgSrc = new Vector2(_neighborDetects.Average(r => r.Src.X), _neighborDetects.Average(r => r.Src.Y)); avgDst = new Vector2(_neighborDetects.Average(r => r.Dst.X), _neighborDetects.Average(r => r.Dst.Y)); } // 两腿连线中点为邻车检测中心;连线的垂线方向即邻车朝向 var dirVec = avgDst - avgSrc; var center = (avgSrc + avgDst) / 2f; var dir = (float)LessMath.RoundTh(Math.Atan2(dirVec.Y, dirVec.X) / Math.PI * 180 + 90); // 从检测中心沿邻车朝向外推 detectDistance,得到“本车理应所在的参考点”(车体系) var target = LessMath.Transform2D(center, dir, detectDistance, 0); var dx = target.X; var dy = target.Y; var dth = (float)LessMath.ThDiff(dir, 0); var neighborDist = center.Length(); _mvLastDetCenterX = center.X; _mvLastDetCenterY = center.Y; _mvLastDetDir = dir; _mvLastDetDist = neighborDist; var painter = UI.GetPainter("MultiVehicleDetectBias", false); painter.Clear(); painter.DrawLine(Color.DeepPink, center, target, width: 2); painter.DrawText(Color.Yellow, $"dx:{dx:F0} dy:{dy:F0} dth:{dth:F2}", center.X, center.Y); Hedingben.ToastText( $"[联动检测] lidar:{Conf.TwoLegLidarName} guess x:{guess.X:F0} y:{guess.Y:F0} | " + $"中心 x:{center.X:F0} y:{center.Y:F0} dir:{dir:F1} dist:{neighborDist:F0} | 偏差 dx:{dx:F0} dy:{dy:F0} dth:{dth:F2}", $"MultiVehicle{CarNum}-detect"); return (true, dx, dy, dth, neighborDist); } private void InitMultiVehicleCoordination() { if (_multiVehicleSyncInitialized) return; _multiVehicleSyncInitialized = true; var hc = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; PicoHttpServer.AddGetHandler("/multi-vehicle-register", new { CarNum = 0, Info = "" }, query => { try { var infoJson = Uri.UnescapeDataString(query.Info ?? ""); var info = JsonConvert.DeserializeObject(infoJson) ?? throw new Exception("VehicleSyncInfo is null"); lock (MultiVehicleFleet) MultiVehicleFleet[query.CarNum] = info; return JsonConvert.SerializeObject(new { code = 200, message = "ok" }); } catch (Exception e) { DLog.Log($"/multi-vehicle-register error: {e.FormatEx()}", "MultiVehicle"); return JsonConvert.SerializeObject(new { code = 500, message = e.Message }); } }); PicoHttpServer.AddGetHandler("/multi-vehicle-notify", new { Notification = "" }, query => { try { var notifyJson = Uri.UnescapeDataString(query.Notification ?? ""); var notification = JsonConvert.DeserializeObject(notifyJson) ?? throw new Exception("notification is null"); MultiVehicleAligned = notification.Aligned; lock (MultiVehicleFleet) MultiVehicleFleet = notification.Fleet ?? new Dictionary(); lock (_multiVehicleNotificationLock) { MultiVehicleNotification = notification; _multiVehicleLastNotifyTime = DateTime.Now; } return JsonConvert.SerializeObject(new { code = 200, message = "ok" }); } catch (Exception e) { DLog.Log($"/multi-vehicle-notify error: {e.FormatEx()}", "MultiVehicle"); return JsonConvert.SerializeObject(new { code = 500, message = e.Message }); } }); DLog.Log("MultiVehicle coordination initialized, starting loop thread", "MultiVehicle"); Console.WriteLine("[MultiVehicle] coordination initialized, starting loop thread"); new Thread(() => MultiVehicleLoop(hc)) { Name = "MultiVehicle", IsBackground = true }.Start(); } private void MultiVehicleLoop(HttpClient hc) { var carLength = CarLength; var carWidth = CarWidth; var contour = new List { new(carLength / 2f, carWidth / 2f), new(-carLength / 2f, carWidth / 2f), new(-carLength / 2f, -carWidth / 2f), new(carLength / 2f, -carWidth / 2f), }; var chassis = (MultiWheelChassis)Chassis; var sendMotionPainter = UI.GetPainter("MultiWheelChassis-SendMotionVis", false); chassis.SendMotionVisualizer = new Visualizer { LineAction = (color, src, dst, startArrow, endArrow, width) => sendMotionPainter.DrawLine(color, src, dst, startArrow, endArrow, width), TextAction = (color, str, pos) => sendMotionPainter.DrawText(color, str, pos), Clear = () => sendMotionPainter.Clear() }; DLog.Log("MultiVehicleLoop thread running", "MultiVehicle"); Console.WriteLine("[MultiVehicle] loop thread running"); var loopCount = 0L; while (true) { try { TickMultiVehicle(hc, chassis, contour, sendMotionPainter); } catch (Exception e) { DLog.Log($"MultiVehicle loop error: {e.FormatEx()}", "MultiVehicle"); } // 每 ~5s 打一条心跳,确认循环确实在跑(用于区分“循环没跑”与“写盘失败”) if (loopCount++ % Math.Max(1, 5000 / Math.Max(1, Conf.MultiVehicleSyncInterval)) == 0) DLog.Log($"MultiVehicleLoop heartbeat #{loopCount}", "MultiVehicle"); Thread.Sleep(Conf.MultiVehicleSyncInterval); } } private void TickMultiVehicle(HttpClient hc, MultiWheelChassis chassis, List contour, Painter sendMotionPainter) { var isMaster = IsMultiVehicleMaster(); var autoEnabled = false; var manualEnabled = MultiVehicleManualEnabled; if (isMaster) autoEnabled = MultiVehicleAutoEnabled; else { // 从车:自动/手动联动均可由主车广播解锁(无需各自再拨开关)。 // 仅在 notification 新鲜时认账,主车停发后超时即自动停车,避免用旧指令跑飞。 lock (_multiVehicleNotificationLock) { var fresh = (DateTime.Now - _multiVehicleLastNotifyTime).TotalMilliseconds < Math.Max(300, Conf.MultiVehicleSyncInterval * 5); if (fresh && MultiVehicleNotification != null) { autoEnabled = MultiVehicleNotification.AutoEnabled; manualEnabled = manualEnabled || MultiVehicleNotification.ManualEnabled; } } } // 入口诊断(节流 ~300ms):记录从 Medulla 收到的原始 IO 值与门控判定, // 用于确认遥控指令是否真的传到了 Clumsy,以及为何提前 return。 if ((DateTime.Now - _mvDiskLastLog).TotalMilliseconds >= 300) { _mvDiskLastLog = DateTime.Now; int fleetCnt; lock (MultiVehicleFleet) fleetCnt = MultiVehicleFleet.Count; var notifFresh = (DateTime.Now - _multiVehicleLastNotifyTime).TotalMilliseconds < Math.Max(300, Conf.MultiVehicleSyncInterval * 5); FleetDiag( $"ENTRY master={isMaster} | IO: ManualEn={MultiVehicleManualEnabled} Mode={MultiVehicleManualMode} " + $"Vx={MultiVehicleManualVx:0.000} Vy={MultiVehicleManualVy:0.000} Vth={MultiVehicleManualVth:0.0} AutoEn(IO)={MultiVehicleAutoEnabled} " + $"| gate: manualEnabled={manualEnabled} autoEnabled={autoEnabled} notifFresh={notifFresh} " + $"notifManualEn={(MultiVehicleNotification != null ? MultiVehicleNotification.ManualEnabled.ToString() : "null")} " + $"fleetCnt={fleetCnt}/{Conf.MultiVehicleFleetNum} useDetect={Conf.MultiVehicleUseDetect} pos={Conf.PosAvailable}"); } if (!manualEnabled && !autoEnabled) { UI.GetPainter("MultiVehicleFleet-vis", false).Clear(); sendMotionPainter.Clear(); lock (MultiVehicleFleet) MultiVehicleFleet.Clear(); MultiVehicleAutoEnabled = false; _multiVehicleAccumulateTh = 0f; _multiVehicleLastThTime = DateTime.Now; if (!isMaster) FireAndForgetRegister(hc, BuildSelfInfo(false, false, 0, 0, 0, 0, 0, 0, false)); return; } if (isMaster && autoEnabled && !MultiVehicleManualEnabled && !FleetHasPosAvailable()) { MultiVehicleAutoEnabled = false; Hedingben.ToastText("自动多车联动需要至少一台车有 Detour 定位", "MultiVehicle-auto-gate"); return; } var posAvailable = Conf.PosAvailable; float selfX = 0, selfY = 0, selfTh = 0; if (posAvailable) { var carPos = DetourInterface.getCartLocation(); selfX = (float)carPos.x; selfY = (float)carPos.y; selfTh = (float)carPos.th; } var syncTh = Conf.TestCarSyncTh; var syncDistance = Conf.TestCarSyncDistance; var deltaDetectCenter = Conf.DeltaDetectCenter; float fleetVx = 0, fleetFrontTh = 0, fleetRearTh = 0, fleetOmega = 0; var fleetMode = 0; // 0=常规 1=蟹行 2=原地旋转 CenterX = CenterY = CenterTh = 0; if (isMaster) { if (MultiVehicleManualEnabled) { syncTh = 0; fleetMode = MultiVehicleManualMode; if (fleetMode == 2) { // 原地旋转:摇杆左右 → 绕车队中心角速度(deg/s)。底盘 SetOriginBias 已设为车队中心。 fleetOmega = MultiVehicleManualVth * Conf.ManualCarSyncVthFac; fleetVx = 0; fleetFrontTh = 0; fleetRearTh = 0; _multiVehicleAccumulateTh = 0f; _multiVehicleLastThTime = DateTime.Now; } else if (fleetMode == 1) { // 蟹行:把 (前后向Vx, 横向Vy) 合成速度矢量,四轮同向打到该方向(前后舵轮角相同)。 // 舵轮角限制在 ±90°,超出则取反向并令速度取负,避免出现 180° 这类不可达转角。 var vx = MultiVehicleManualVx * Conf.ManualCarSyncVxFac; var vy = MultiVehicleManualVy * Conf.ManualCarSyncVyFac; var speed = (float)Math.Sqrt(vx * vx + vy * vy); var crabAngle = (float)(Math.Atan2(vy, vx) * 180.0 / Math.PI); if (crabAngle > 90f) { crabAngle -= 180f; speed = -speed; } else if (crabAngle < -90f) { crabAngle += 180f; speed = -speed; } fleetVx = speed; fleetFrontTh = crabAngle; fleetRearTh = crabAngle; _multiVehicleAccumulateTh = 0f; _multiVehicleLastThTime = DateTime.Now; } else { fleetVx = MultiVehicleManualVx * Conf.ManualCarSyncVxFac; var targetTh = MultiVehicleManualVth * Conf.ManualCarSyncVthFac; var now = DateTime.Now; var dt = (float)Math.Min(0.2, Math.Max(0, (now - _multiVehicleLastThTime).TotalSeconds)); _multiVehicleLastThTime = now; var sign = Math.Sign(targetTh - _multiVehicleAccumulateTh); _multiVehicleAccumulateTh += sign * Math.Min(Math.Abs(targetTh - _multiVehicleAccumulateTh), Conf.SyncThAccPerSec * dt); fleetFrontTh = _multiVehicleAccumulateTh; fleetRearTh = -fleetFrontTh; } } else { fleetVx = MultiVehicleAutoVx; fleetFrontTh = MultiVehicleAutoFrontTh; fleetRearTh = MultiVehicleAutoRearTh; } } else if (MultiVehicleNotification != null) { VehicleSyncNotification notification; lock (_multiVehicleNotificationLock) notification = MultiVehicleNotification; syncTh = notification.SyncTh; syncDistance = notification.SyncDistance; deltaDetectCenter = notification.DeltaDetectCenter; CenterX = notification.CenterX; CenterY = notification.CenterY; CenterTh = notification.CenterTh; posAvailable = notification.PosAvailable; MultiVehicleAutoEnabled = notification.AutoEnabled; fleetVx = notification.FleetVx; fleetFrontTh = notification.FleetFrontTh; fleetRearTh = notification.FleetRearTh; fleetMode = notification.Mode; fleetOmega = notification.FleetOmega; } var (layoutX, layoutY, layoutTh) = GetLayoutPose(syncTh, syncDistance); if (isMaster) { lock (MultiVehicleFleet) MultiVehicleFleet[CarNum] = BuildSelfInfo(true, posAvailable, selfX, selfY, selfTh, layoutX, layoutY, layoutTh, true); if (TryInferFleetCenter(out var cx, out var cy, out var cth)) { CenterX = cx; CenterY = cy; CenterTh = cth; } } var selfAligned = !Conf.MultiVehicleUseDetect; var detectValid = false; float detectDx = 0, detectDy = 0, detectDth = 0; var currentSpacing = float.NaN; if (Conf.MultiVehicleUseDetect) { var (valid, dx, dy, dth, neighborDist) = DetectNeighborBias(syncDistance - deltaDetectCenter); detectValid = valid; if (valid) { detectDx = dx; detectDy = dy; detectDth = dth; // 检测中心到本车原点距离 + 检测中心偏移 = 两车参考点当前间距 currentSpacing = neighborDist + deltaDetectCenter; selfAligned = Math.Abs(dx) < Conf.SingleCarSyncPrecisionXy && Math.Abs(dy) < Conf.SingleCarSyncPrecisionXy && Math.Abs(dth) < Conf.SingleCarSyncPrecisionTh; } else selfAligned = false; } // 检测不可用(关闭/跟丢)时回落到 SLAM 世界坐标计算间距(两台车都需有定位) if (float.IsNaN(currentSpacing) && posAvailable) currentSpacing = TryGetSlamSpacing(selfX, selfY); if (float.IsNaN(currentSpacing)) Hedingben.ToastText($"间距 当前:N/A 目标:{syncDistance:F0}mm", $"MultiVehicle{CarNum}-spacing"); else Hedingben.ToastText( $"间距 当前:{currentSpacing:F0}mm 目标:{syncDistance:F0}mm 差:{currentSpacing - syncDistance:F0}mm", $"MultiVehicle{CarNum}-spacing"); lock (MultiVehicleFleet) { MultiVehicleAligned = MultiVehicleFleet.Count == Conf.MultiVehicleFleetNum && MultiVehicleFleet.Values.All(v => v.Aligned); } // 安全门:开启互识别时,本车或任一其它车检测不到邻车则整队停车(速度置零)。 // ownDetectOk 是本轮新鲜值;其它车的 DetectOk 来自其上报/主车下发(滑动窗口已给 1s 去抖)。 var ownDetectOk = !Conf.MultiVehicleUseDetect || detectValid; bool othersDetectOk; lock (MultiVehicleFleet) othersDetectOk = MultiVehicleFleet.Where(kv => kv.Key != CarNum).All(kv => kv.Value.DetectOk); var canMove = !Conf.MultiVehicleUseDetect || (ownDetectOk && othersDetectOk); if (!canMove) { fleetVx = 0; fleetFrontTh = 0; fleetRearTh = 0; fleetOmega = 0; Hedingben.ToastText( $"车队停车:{(!ownDetectOk ? "本车" : "其它车")}2腿检测丢失(速度已置零)", $"MultiVehicle{CarNum}-stop"); } else { Hedingben.ToastText("车队检测正常", $"MultiVehicle{CarNum}-stop"); } VisualizeFleet(contour, layoutX, layoutY, layoutTh); var fleetReady = false; var fleetCount = 0; lock (MultiVehicleFleet) { fleetCount = MultiVehicleFleet.Count; fleetReady = fleetCount == Conf.MultiVehicleFleetNum; } // 补偿量提到块外,便于诊断日志统一记录三类来源(检测/SLAM)的贡献。 float xDetectCompensate = 0, yDetectCompensate = 0, thDetectCompensate = 0; float xPosCompensate = 0, yPosCompensate = 0, thPosCompensate = 0; float posBiasX = 0, posBiasY = 0, posBiasTh = 0; if (fleetReady) { chassis.SetOriginBias(layoutX, layoutY, layoutTh); chassis.ControlPointRadius = syncDistance / 2f; if (canMove && Conf.MultiVehicleUseDetect) { xDetectCompensate = Math.Abs(detectDx) > Conf.SingleCarSyncPrecisionXy ? detectDx * Conf.MultiVehicleDetectBiasXFac : 0; yDetectCompensate = Math.Abs(detectDy) > Conf.SingleCarSyncPrecisionXy ? detectDy * Conf.MultiVehicleDetectBiasYFac : 0; thDetectCompensate = Math.Abs(detectDth) > Conf.SingleCarSyncPrecisionTh ? detectDth * Conf.MultiVehicleDetectBiasThFac : 0; xDetectCompensate = ClampBias(xDetectCompensate, Conf.MultiVehicleDetectBiasXThreshold); yDetectCompensate = ClampBias(yDetectCompensate, Conf.MultiVehicleDetectBiasYThreshold); thDetectCompensate = ClampBias(thDetectCompensate, Conf.MultiVehicleDetectBiasThThreshold); } if (canMove && posAvailable && TryInferFleetCenter(out _, out _, out _)) { var supposedPos = LessMath.Transform2D( Tuple.Create(CenterX, CenterY, CenterTh), Tuple.Create(layoutX, layoutY, layoutTh)); var posBias = LessMath.SolveTransform2D(Tuple.Create(selfX, selfY, selfTh), supposedPos); posBiasX = (float)posBias.Item1; posBiasY = (float)posBias.Item2; posBiasTh = (float)posBias.Item3; xPosCompensate = Math.Abs(posBias.Item1) > Conf.SingleCarSyncPrecisionXy ? ClampBias(posBias.Item1 * Conf.MultiVehiclePosBiasXFac, Conf.MultiVehiclePosBiasXThreshold) : 0; yPosCompensate = Math.Abs(posBias.Item2) > Conf.SingleCarSyncPrecisionXy ? ClampBias(posBias.Item2 * Conf.MultiVehiclePosBiasYFac, Conf.MultiVehiclePosBiasYThreshold) : 0; thPosCompensate = Math.Abs(posBias.Item3) > Conf.SingleCarSyncPrecisionTh ? ClampBias(posBias.Item3 * Conf.MultiVehiclePosBiasThFac, Conf.MultiVehiclePosBiasThThreshold) : 0; } sendMotionPainter.Clear(); if (fleetMode == 2) { // 原地旋转:底盘原点已偏置到车队中心,绕队中心旋转,并叠加 PI 闭环纠偏维持队形。 // detect/pos 偏差(dx,dy,dth) 本身就是"本车参考点应在车体系移动到的位置/应转的角",作为误差喂给 PI。 // 纯 P 对抗恒定横向滑移有稳态残差,积分项消除之。检测丢失(canMove=false)时不纠偏并清空积分。 float rotCompVx = 0, rotCompVy = 0, rotCompOmega = 0; var now = DateTime.Now; // dt 限幅,避免首帧/卡顿导致积分突跳 var dt = _rotPiLastTime == DateTime.MinValue ? 0f : (float)Math.Min(0.2, Math.Max(0, (now - _rotPiLastTime).TotalSeconds)); _rotPiLastTime = now; // 仅在"被指令旋转"时才纠偏:松开摇杆(fleetOmega≈0)时绝不再下发补偿, // 否则积分残留会持续驱动车辆平移/旋转,表现为"松杆后轮子来回打、自转停不下来"。 var rotating = Math.Abs(fleetOmega) > Conf.MultiVehicleRotateActiveOmega; if (canMove && rotating) { var errX = detectDx + posBiasX; // mm,车体系:本车纵向(前+)应移动量 var errY = detectDy + posBiasY; // mm,车体系:本车横向(左+)应移动量 var errTh = detectDth + posBiasTh; // deg,本车应转角 rotCompVx = RotatePiTerm(errX, ref _rotIntegX, Conf.SingleCarSyncPrecisionXy, Conf.MultiVehicleRotateCompXyFac, Conf.MultiVehicleRotateCompXyIFac, Conf.MultiVehicleRotateCompXyMax, dt); rotCompVy = RotatePiTerm(errY, ref _rotIntegY, Conf.SingleCarSyncPrecisionXy, Conf.MultiVehicleRotateCompXyFac, Conf.MultiVehicleRotateCompXyIFac, Conf.MultiVehicleRotateCompXyMax, dt); rotCompOmega = RotatePiTerm(errTh, ref _rotIntegTh, Conf.SingleCarSyncPrecisionTh, Conf.MultiVehicleRotateCompThFac, Conf.MultiVehicleRotateCompThIFac, Conf.MultiVehicleRotateCompThMax, dt); } else { // 检测丢失或未指令旋转:清零补偿并复位积分/计时,停止时不再有残留驱动。 _rotIntegX = _rotIntegY = _rotIntegTh = 0; _rotPiLastTime = DateTime.MinValue; } _mvRotCompVx = rotCompVx; _mvRotCompVy = rotCompVy; _mvRotCompOmega = rotCompOmega; chassis.SendRotateMotion(fleetOmega, localCompensateX: rotCompVx, localCompensateY: rotCompVy, localCompensateTh: rotCompOmega); // 仅主车:读取两车实际 sim 位姿,量化"开环横向滑移"来源(节流 ~200ms)。 if (isMaster) LogRotatePoseSample(); } else { _mvRotCompVx = 0; _mvRotCompVy = 0; _mvRotCompOmega = 0; // 退出原地旋转:清空 PI 积分与计时、位姿诊断片段,下次进入重新起算。 _rotIntegX = _rotIntegY = _rotIntegTh = 0; _rotPiLastTime = DateTime.MinValue; _rotPoseEpisode = false; _rotPosePrevTime = DateTime.MinValue; // 常规/蟹行:蟹行时 frontTh==rearTh(四轮同向)即为平移,与常规共用同一下发路径。 chassis.SendMotion(fleetVx, fleetFrontTh, fleetRearTh, localControlRadius: 510, localCompensateX: xDetectCompensate + xPosCompensate, localCompensateY: yDetectCompensate + yPosCompensate, localCompensateTh: thDetectCompensate + thPosCompensate); } } // === 诊断日志(节流 ~300ms)=== // 用于定位“剧烈运动”来源:BASE=遥控基础速度;DETECT=2腿检测补偿;POS=SLAM编队补偿;SEND=最终下发。 // 若 BASE≈0 但 SEND 持续非零,说明是补偿在驱动;再看 DETECT/POS 哪一路的 comp 持续非零即定位到来源。 // 同时落 DLog(tag MultiVehicleDbg) 与屏幕 ToastText(tag MultiVehicle{CarNum}-dbg),后者无需开启磁盘转储即可直接观察。 if ((DateTime.Now - _mvDbgLastLog).TotalMilliseconds >= 300) { _mvDbgLastLog = DateTime.Now; var spacingStr = float.IsNaN(currentSpacing) ? "NaN" : currentSpacing.ToString("F0"); var cx = xDetectCompensate + xPosCompensate; var cy = yDetectCompensate + yPosCompensate; var cth = thDetectCompensate + thPosCompensate; var dbg = $"car{CarNum} master:{isMaster} manual:{manualEnabled} auto:{autoEnabled} pos:{posAvailable} " + $"ready:{fleetReady}({fleetCount}/{Conf.MultiVehicleFleetNum}) canMove:{canMove} useDetect:{Conf.MultiVehicleUseDetect} " + $"| BASE vx:{fleetVx:F3} fTh:{fleetFrontTh:F2} rTh:{fleetRearTh:F2} " + $"| DETECT valid:{detectValid} center({_mvLastDetCenterX:F0},{_mvLastDetCenterY:F0}) dir:{_mvLastDetDir:F1} ndist:{_mvLastDetDist:F0} " + $"dx:{detectDx:F0} dy:{detectDy:F0} dth:{detectDth:F2} spacing:{spacingStr}/{syncDistance:F0} delta:{deltaDetectCenter:F0} guessX:{Conf.TwoLegGuessX:F0} " + $"-> comp x:{xDetectCompensate:F1} y:{yDetectCompensate:F1} th:{thDetectCompensate:F2} " + $"| POS self({selfX:F0},{selfY:F0},{selfTh:F1}) center({CenterX:F0},{CenterY:F0},{CenterTh:F1}) " + $"bias({posBiasX:F0},{posBiasY:F0},{posBiasTh:F1}) -> comp x:{xPosCompensate:F1} y:{yPosCompensate:F1} th:{thPosCompensate:F2} " + $"| LAYOUT({layoutX:F0},{layoutY:F0},{layoutTh:F0}) R:{syncDistance / 2f:F0} " + $"| SEND mode:{fleetMode} omega:{fleetOmega:F1} vx:{fleetVx:F3} fTh:{fleetFrontTh:F2} rTh:{fleetRearTh:F2} cx:{cx:F1} cy:{cy:F1} cth:{cth:F2} " + $"rotComp(vx:{_mvRotCompVx:F1} vy:{_mvRotCompVy:F1} om:{_mvRotCompOmega:F2})"; DLog.Log(dbg, "MultiVehicleDbg"); FleetDiag(dbg); // 屏幕分两行显示,便于直接观察(无需开启 DLog 磁盘转储) Hedingben.ToastText( $"BASE vx{fleetVx:F3} fTh{fleetFrontTh:F1} | SEND vx{fleetVx:F3} c({cx:F0},{cy:F0},{cth:F1}) " + $"| ready{fleetReady} canMove{canMove}", $"MultiVehicle{CarNum}-dbg"); Hedingben.ToastText( $"DET v{detectValid} dx{detectDx:F0} dy{detectDy:F0} dth{detectDth:F1} cmp({xDetectCompensate:F0},{yDetectCompensate:F0},{thDetectCompensate:F1}) " + $"| POS bias({posBiasX:F0},{posBiasY:F0},{posBiasTh:F1}) cmp({xPosCompensate:F0},{yPosCompensate:F0},{thPosCompensate:F1})", $"MultiVehicle{CarNum}-dbg2"); } if (isMaster) { lock (MultiVehicleFleet) { MultiVehicleFleet[CarNum] = BuildSelfInfo(true, posAvailable, selfX, selfY, selfTh, layoutX, layoutY, layoutTh, selfAligned, ownDetectOk); } if (fleetReady) { VehicleSyncNotification notification; lock (MultiVehicleFleet) { notification = new VehicleSyncNotification { PosAvailable = posAvailable, CenterX = CenterX, CenterY = CenterY, CenterTh = CenterTh, Aligned = MultiVehicleAligned, Fleet = new Dictionary(MultiVehicleFleet), FleetVx = fleetVx, FleetFrontTh = fleetFrontTh, FleetRearTh = fleetRearTh, Mode = fleetMode, FleetOmega = fleetOmega, AutoEnabled = MultiVehicleAutoEnabled, ManualEnabled = MultiVehicleManualEnabled, SyncTh = syncTh, SyncDistance = syncDistance, DeltaDetectCenter = deltaDetectCenter }; } foreach (var kv in notification.Fleet) { var ip = kv.Value.Ip; var port = kv.Value.Port > 0 ? kv.Value.Port : WebAPI.port; if (string.IsNullOrWhiteSpace(ip) || kv.Key == CarNum) continue; FireAndForgetNotify(hc, ip, port, notification); } } } else { FireAndForgetRegister(hc, BuildSelfInfo(false, posAvailable, selfX, selfY, selfTh, layoutX, layoutY, layoutTh, selfAligned, ownDetectOk)); } } private bool IsMultiVehicleMaster() => Conf.MultiVehicleMasterEndpoint == "/"; private bool FleetHasPosAvailable() { lock (MultiVehicleFleet) return MultiVehicleFleet.Values.Any(v => v.PosAvailable); } // 基于 SLAM 世界坐标的最近邻车间距(mm);无可用定位邻车时返回 NaN。 private float TryGetSlamSpacing(float selfX, float selfY) { List others; lock (MultiVehicleFleet) others = MultiVehicleFleet .Where(kv => kv.Key != CarNum && kv.Value.PosAvailable) .Select(kv => kv.Value).ToList(); if (others.Count == 0) return float.NaN; return others.Min(o => (float)Math.Sqrt((o.X - selfX) * (o.X - selfX) + (o.Y - selfY) * (o.Y - selfY))); } private bool TryInferFleetCenter(out float centerX, out float centerY, out float centerTh) { centerX = centerY = centerTh = 0; List positioned; lock (MultiVehicleFleet) positioned = MultiVehicleFleet.Values.Where(v => v.PosAvailable).ToList(); if (positioned.Count == 0) return false; var centers = positioned.Select(InferFleetCenterFromCar).ToList(); centerX = centers.Average(c => c.Item1); centerY = centers.Average(c => c.Item2); var avgSin = centers.Average(c => Math.Sin(c.Item3 * Math.PI / 180.0)); var avgCos = centers.Average(c => Math.Cos(c.Item3 * Math.PI / 180.0)); centerTh = (float)(Math.Atan2(avgSin, avgCos) * 180.0 / Math.PI); return true; } private static (float, float, float) InferFleetCenterFromCar(VehicleSyncInfo car) { // 由 carWorld = Transform2D(center, layout) 反推 center = carWorld ∘ layout⁻¹。 // 注意 layout⁻¹ 是真正的 SE(2) 逆,而非逐分量取负 (-layout): // 当 layoutTh≠0(如从车 180°)时,逐分量取负会把平移方向算错,导致车队中心偏移 → 位姿补偿跑飞。 var layout = Tuple.Create((double)car.LayoutX, (double)car.LayoutY, (double)car.LayoutTh); var layoutInv = LessMath.SolveTransform2D(layout, Tuple.Create(0.0, 0.0, 0.0)); var center = LessMath.Transform2D( Tuple.Create((double)car.X, (double)car.Y, (double)car.Th), layoutInv); return ((float)center.Item1, (float)center.Item2, (float)center.Item3); } private (float, float, float) GetLayoutPose(float syncTh, float syncDistance) { // 双车编队布局由 TestCarSyncDistance(=syncDistance) 与 TestCarSyncTh(=syncTh) 唯一确定: // 车体相对车队中心沿编队方向 ±syncDistance/2 对称分布,从车额外朝向翻转 180°。 var rad = syncTh / 180f * Math.PI; var sign = CarNum == 1 ? 1f : -1f; var xx = (float)(Math.Cos(rad) * syncDistance / 2 * sign); var yy = (float)(Math.Sin(rad) * syncDistance / 2 * sign); return (xx, yy, syncTh + (CarNum == 1 ? 0 : 180)); } private VehicleSyncInfo BuildSelfInfo(bool master, bool posAvailable, float x, float y, float th, float layoutX, float layoutY, float layoutTh, bool aligned, bool detectOk = true) { return new VehicleSyncInfo { Master = master, Ip = Conf.SimpleIp, Port = WebAPI.port, PosAvailable = posAvailable, X = x, Y = y, Th = th, LayoutX = layoutX, LayoutY = layoutY, LayoutTh = layoutTh, Aligned = aligned, DetectOk = detectOk }; } private void VisualizeFleet(List contour, float egoLayoutX, float egoLayoutY, float egoLayoutTh) { // 画在本车车体系:global=false,渲染时由 ClumsyLite 叠加本车真实位姿。 // 每个成员按“相对本车 layout 的位姿”绘制(memberInEgo = egoLayout⁻¹ ∘ memberLayout), // 这样手动模式无 SLAM 定位也能正确显示编队相对关系;避免之前用车队系 layout 坐标叠加本车位姿造成的整体平移。 var fleetPainter = UI.GetPainter("MultiVehicleFleet-vis", false); Dictionary fleet; lock (MultiVehicleFleet) fleet = new Dictionary(MultiVehicleFleet); var egoLayout = Tuple.Create((double)egoLayoutX, (double)egoLayoutY, (double)egoLayoutTh); Tuple MemberInEgo(float mx, float my, float mth) => LessMath.SolveTransform2D(egoLayout, Tuple.Create((double)mx, (double)my, (double)mth)); void DrawContour(Color color, Tuple rel) { var transformed = contour .Select(pp => LessMath.Transform2D((float)rel.Item1, (float)rel.Item2, (float)rel.Item3, pp)) .ToList(); for (var i = 0; i < 4; ++i) fleetPainter.DrawLine(color, transformed[i], transformed[(i + 1) % 4]); // 对角线便于辨认朝向 fleetPainter.DrawLine(color, transformed[0], transformed[2]); fleetPainter.DrawLine(color, transformed[1], transformed[3]); } fleetPainter.Clear(); foreach (var kvp in fleet) { var color = kvp.Value.Master ? Color.Red : Color.Orange; var rel = MemberInEgo(kvp.Value.LayoutX, kvp.Value.LayoutY, kvp.Value.LayoutTh); DrawContour(color, rel); fleetPainter.DrawText(color, $"{kvp.Key}", new Vector((float)rel.Item1, (float)rel.Item2)); } } private void FireAndForgetRegister(HttpClient hc, VehicleSyncInfo info) { ParseMasterEndpoint(out var masterIp, out var masterPort); var infoJson = JsonConvert.SerializeObject(info); var url = $"http://{masterIp}:{masterPort}/multi-vehicle-register?CarNum={CarNum}&Info={Uri.EscapeDataString(infoJson)}"; _ = hc.GetStringAsync(url).ContinueWith(t => { if (t.IsFaulted) DLog.Log($"register failed: {t.Exception?.GetBaseException().Message}", "MultiVehicle"); }, TaskScheduler.Default); } private static void FireAndForgetNotify(HttpClient hc, string ip, int port, VehicleSyncNotification notification) { var notifyJson = JsonConvert.SerializeObject(notification); var url = $"http://{ip}:{port}/multi-vehicle-notify?Notification={Uri.EscapeDataString(notifyJson)}"; _ = hc.GetStringAsync(url).ContinueWith(t => { if (t.IsFaulted) DLog.Log($"notify {ip}:{port} failed: {t.Exception?.GetBaseException().Message}", "MultiVehicle"); }, TaskScheduler.Default); } private void ParseMasterEndpoint(out string ip, out int port) { ip = "127.0.0.1"; port = 8008; var endpoint = Conf.MultiVehicleMasterEndpoint ?? "/"; if (endpoint == "/") return; var parts = endpoint.Split(':'); if (parts.Length >= 1 && !string.IsNullOrWhiteSpace(parts[0])) ip = parts[0]; if (parts.Length >= 2 && int.TryParse(parts[1], out var p)) port = p; } private static float ClampBias(float value, float threshold) => Math.Sign(value) * Math.Min(Math.Abs(value), threshold); /// /// 原地旋转纠偏单轴 PI 控制器。err 为车体系偏差(mm 或 deg),输出为修正速度(mm/s 或 deg/s), /// 带死区、积分抗饱和(限制积分贡献在 ±max 内)与总输出限幅。死区内冻结积分(保留稳态修正以抵消恒定扰动)。 /// private static float RotatePiTerm(float err, ref float integ, float deadband, float pFac, float iFac, float max, float dt) { // 死区内:不再累加误差,仅输出已积累的积分项(维持对恒定扰动的稳态补偿)。 if (Math.Abs(err) < deadband) return ClampBias(integ * iFac, max); // 条件积分(抗 windup):仅当总输出未在同向饱和时才累加误差。 // 起步阶段大误差会让 P 项接近/超过 max,此时继续积分会顶满积分器, // 误差反向后需很久才能泄放,造成纠偏"过冲再回拉"。此处饱和即停积分。 var unclamped = err * pFac + integ * iFac; var saturatedSameSign = Math.Abs(unclamped) >= max && Math.Sign(unclamped) == Math.Sign(err); if (!saturatedSameSign) integ += err * dt; var iTerm = integ * iFac; // 抗积分饱和:把积分贡献限制在 ±max,并反算回写积分器,避免 windup。 if (iFac > 1e-9f) { var iLimit = max / iFac; if (integ > iLimit) integ = iLimit; else if (integ < -iLimit) integ = -iLimit; iTerm = integ * iFac; } return ClampBias(err * pFac + iTerm, max); } /// /// 主车专用:通过 Playground WebAPI 读取本车与邻车的真实 sim 位姿,落 RotatePoseDbg 日志。 /// 量化"开环横向滑移"来源: /// - w1/w2/dw:两车实际偏航角速率(deg/s)。dw 持续非零 ⇒ 主从转速不一致(相对旋转)。 /// - rel_in1(x,y,dyaw):邻车在本车体系下的真实相对位姿;dyaw=实际相对朝向−180°。 /// dyaw≈0 但 y 持续漂 ⇒ 纯横向平移滑移(非角度滞后,印证 B 无用)。 /// - midDrift:车队几何中心(世界系)相对起转瞬间的位移 ⇒ 整体平移漂移量。 /// 节流 ~200ms;WebAPI 异常时静默跳过,不影响控制环。 /// private void LogRotatePoseSample() { var now = DateTime.Now; if ((now - _rotPoseLastLog).TotalMilliseconds < 200) return; _rotPoseLastLog = now; var neighbor = Conf.PlaygroundNeighborRobotName; if (string.IsNullOrWhiteSpace(neighbor) || neighbor == Conf.PlaygroundRobotName) return; try { var p1 = PlaygroundWebApi.GetPose(Conf.PlaygroundWebApiUrl, Conf.PlaygroundRobotName); var p2 = PlaygroundWebApi.GetPose(Conf.PlaygroundWebApiUrl, neighbor); // 邻车在本车(car1)体系下的相对位姿:rel = R(-yaw1)·(p2-p1) var ddx = p2.X - p1.X; var ddy = p2.Y - p1.Y; var th1 = p1.YawDeg * (float)Math.PI / 180f; var c = (float)Math.Cos(th1); var s = (float)Math.Sin(th1); var relX = ddx * c + ddy * s; var relY = -ddx * s + ddy * c; var relYaw = (float)LessMath.ThDiff(p2.YawDeg - p1.YawDeg, 180f); // 实际相对朝向偏离 180° 的量 var dist = (float)Math.Sqrt(ddx * ddx + ddy * ddy); var midX = (p1.X + p2.X) / 2f; var midY = (p1.Y + p2.Y) / 2f; if (!_rotPoseEpisode) { _rotPoseEpisode = true; _rotPoseMid0X = midX; _rotPoseMid0Y = midY; } var midDx = midX - _rotPoseMid0X; var midDy = midY - _rotPoseMid0Y; // 实际偏航角速率(数值微分) float w1 = 0, w2 = 0, dw = 0; if (_rotPosePrevTime != DateTime.MinValue) { var dt = (float)(now - _rotPosePrevTime).TotalSeconds; if (dt > 1e-3) { w1 = (float)LessMath.ThDiff(p1.YawDeg, _rotPosePrevYaw1) / dt; w2 = (float)LessMath.ThDiff(p2.YawDeg, _rotPosePrevYaw2) / dt; dw = w1 - w2; } } _rotPosePrevTime = now; _rotPosePrevYaw1 = p1.YawDeg; _rotPosePrevYaw2 = p2.YawDeg; DLog.Log( $"car1({p1.X:F0},{p1.Y:F0},{p1.YawDeg:F2}) car2({p2.X:F0},{p2.Y:F0},{p2.YawDeg:F2}) " + $"| rel_in1(x:{relX:F0} y:{relY:F0} dyaw:{relYaw:F2}) dist:{dist:F0} " + $"| mid({midX:F0},{midY:F0}) midDrift(dx:{midDx:F0} dy:{midDy:F0} |d|:{Math.Sqrt(midDx * midDx + midDy * midDy):F0}) " + $"| w1:{w1:F2} w2:{w2:F2} dw:{dw:F2}", "RotatePoseDbg"); } catch (Exception e) { // 诊断用途,WebAPI 不通时静默(仅偶发提示),不打断控制环。 Hedingben.ToastText($"RotatePose WebAPI err: {e.Message}", $"MultiVehicle{CarNum}-rotpose"); } } #endregion }