580 lines
20 KiB
C#
580 lines
20 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Collections.ObjectModel;
|
||
using MyParking.Shared;
|
||
|
||
namespace MultiWheelC.Fleet
|
||
{
|
||
// 一辆成员车在主车统一时间轴上的状态样本。
|
||
public readonly struct FleetMemberStateSample
|
||
{
|
||
public FleetMemberStateSample(
|
||
int vehicleId,
|
||
double sampleTimestampSeconds,
|
||
Pose2D poseInWorld,
|
||
Twist2D twistAtVehicleOriginInWorld,
|
||
bool isStateAvailable,
|
||
bool hasValidVelocityEstimate)
|
||
{
|
||
if (vehicleId <= 0)
|
||
{
|
||
throw new ArgumentOutOfRangeException(
|
||
nameof(vehicleId),
|
||
"编队成员车号必须大于零。");
|
||
}
|
||
|
||
NumericGuard.EnsureFiniteNonNegative(
|
||
sampleTimestampSeconds,
|
||
nameof(sampleTimestampSeconds));
|
||
NumericGuard.EnsureFinite(
|
||
poseInWorld,
|
||
nameof(poseInWorld));
|
||
NumericGuard.EnsureFinite(
|
||
twistAtVehicleOriginInWorld,
|
||
nameof(twistAtVehicleOriginInWorld));
|
||
|
||
VehicleId = vehicleId;
|
||
SampleTimestampSeconds = sampleTimestampSeconds;
|
||
PoseInWorld = new Pose2D(
|
||
poseInWorld.XMeters,
|
||
poseInWorld.YMeters,
|
||
AngleMath.NormalizeRadians(
|
||
poseInWorld.YawRadians));
|
||
TwistAtVehicleOriginInWorld =
|
||
twistAtVehicleOriginInWorld;
|
||
IsStateAvailable = isStateAvailable;
|
||
HasValidVelocityEstimate =
|
||
hasValidVelocityEstimate;
|
||
}
|
||
|
||
public int VehicleId { get; }
|
||
|
||
// 该时间戳必须已经换算到主车/协调器的单调时间轴。
|
||
public double SampleTimestampSeconds { get; }
|
||
|
||
public Pose2D PoseInWorld { get; }
|
||
|
||
// 成员车体中心处的实际速度,在世界坐标系中表达。
|
||
public Twist2D TwistAtVehicleOriginInWorld { get; }
|
||
|
||
public bool IsStateAvailable { get; }
|
||
|
||
public bool HasValidVelocityEstimate { get; }
|
||
}
|
||
|
||
// 成员实际位姿相对固定布局目标位姿的误差。
|
||
public readonly struct FleetMemberLayoutError
|
||
{
|
||
public FleetMemberLayoutError(
|
||
int vehicleId,
|
||
Pose2D actualPoseInExpectedVehicleFrame)
|
||
{
|
||
if (vehicleId <= 0)
|
||
{
|
||
throw new ArgumentOutOfRangeException(
|
||
nameof(vehicleId),
|
||
"编队成员车号必须大于零。");
|
||
}
|
||
|
||
NumericGuard.EnsureFinite(
|
||
actualPoseInExpectedVehicleFrame,
|
||
nameof(actualPoseInExpectedVehicleFrame));
|
||
|
||
VehicleId = vehicleId;
|
||
ActualPoseInExpectedVehicleFrame =
|
||
new Pose2D(
|
||
actualPoseInExpectedVehicleFrame.XMeters,
|
||
actualPoseInExpectedVehicleFrame.YMeters,
|
||
AngleMath.NormalizeRadians(
|
||
actualPoseInExpectedVehicleFrame
|
||
.YawRadians));
|
||
}
|
||
|
||
public int VehicleId { get; }
|
||
|
||
// 期望成员车体系中表达的实际成员位姿;理想刚体布局时为Identity。
|
||
public Pose2D ActualPoseInExpectedVehicleFrame { get; }
|
||
}
|
||
|
||
// 一次车队状态估计的结果;不可用时不提供FleetState。
|
||
public sealed class FleetStateEstimateResult
|
||
{
|
||
private static readonly IReadOnlyList<FleetMemberLayoutError>
|
||
EmptyMemberErrors = Array.AsReadOnly(
|
||
Array.Empty<FleetMemberLayoutError>());
|
||
|
||
private FleetStateEstimateResult(
|
||
bool isAvailable,
|
||
FleetState? state,
|
||
IReadOnlyList<FleetMemberLayoutError> memberErrors,
|
||
string unavailableReason)
|
||
{
|
||
IsAvailable = isAvailable;
|
||
State = state;
|
||
MemberErrors = memberErrors;
|
||
UnavailableReason = unavailableReason;
|
||
}
|
||
|
||
public bool IsAvailable { get; }
|
||
|
||
public FleetState? State { get; }
|
||
|
||
public IReadOnlyList<FleetMemberLayoutError> MemberErrors { get; }
|
||
|
||
public string UnavailableReason { get; }
|
||
|
||
internal static FleetStateEstimateResult Available(
|
||
FleetState state,
|
||
FleetMemberLayoutError[] memberErrors)
|
||
{
|
||
return new FleetStateEstimateResult(
|
||
true,
|
||
state,
|
||
Array.AsReadOnly(memberErrors),
|
||
string.Empty);
|
||
}
|
||
|
||
internal static FleetStateEstimateResult Unavailable(
|
||
string reason)
|
||
{
|
||
return new FleetStateEstimateResult(
|
||
false,
|
||
null,
|
||
EmptyMemberErrors,
|
||
reason ?? string.Empty);
|
||
}
|
||
}
|
||
|
||
// 从各成员状态反算并融合车队虚拟中心状态。
|
||
public sealed class FleetStateEstimator
|
||
{
|
||
private const double TimestampToleranceSeconds = 1e-9;
|
||
private const double MinimumCircularMeanMagnitude = 1e-12;
|
||
|
||
private readonly double _maximumMemberStateAgeSeconds;
|
||
private readonly double _maximumPositionDisagreementMeters;
|
||
private readonly double _maximumYawDisagreementRadians;
|
||
|
||
public FleetStateEstimator(
|
||
double maximumMemberStateAgeSeconds,
|
||
double maximumPositionDisagreementMeters,
|
||
double maximumYawDisagreementRadians)
|
||
{
|
||
NumericGuard.EnsureFinitePositive(
|
||
maximumMemberStateAgeSeconds,
|
||
nameof(maximumMemberStateAgeSeconds));
|
||
NumericGuard.EnsureFinitePositive(
|
||
maximumPositionDisagreementMeters,
|
||
nameof(maximumPositionDisagreementMeters));
|
||
NumericGuard.EnsureFinitePositive(
|
||
maximumYawDisagreementRadians,
|
||
nameof(maximumYawDisagreementRadians));
|
||
|
||
if (maximumYawDisagreementRadians > Math.PI)
|
||
{
|
||
throw new ArgumentOutOfRangeException(
|
||
nameof(maximumYawDisagreementRadians),
|
||
"车队候选航向差阈值不能大于π。");
|
||
}
|
||
|
||
_maximumMemberStateAgeSeconds =
|
||
maximumMemberStateAgeSeconds;
|
||
_maximumPositionDisagreementMeters =
|
||
maximumPositionDisagreementMeters;
|
||
_maximumYawDisagreementRadians =
|
||
maximumYawDisagreementRadians;
|
||
}
|
||
|
||
public FleetStateEstimateResult Estimate(
|
||
FleetLayout layout,
|
||
IReadOnlyList<FleetMemberStateSample> memberStates,
|
||
double targetTimestampSeconds)
|
||
{
|
||
if (layout == null)
|
||
{
|
||
throw new ArgumentNullException(nameof(layout));
|
||
}
|
||
|
||
if (memberStates == null)
|
||
{
|
||
throw new ArgumentNullException(nameof(memberStates));
|
||
}
|
||
|
||
NumericGuard.EnsureFiniteNonNegative(
|
||
targetTimestampSeconds,
|
||
nameof(targetTimestampSeconds));
|
||
|
||
if (memberStates.Count != layout.VehicleCount)
|
||
{
|
||
return FleetStateEstimateResult.Unavailable(
|
||
$"成员状态数量{memberStates.Count}与布局数量" +
|
||
$"{layout.VehicleCount}不一致。");
|
||
}
|
||
|
||
var statesByVehicleId =
|
||
new Dictionary<int, FleetMemberStateSample>(
|
||
memberStates.Count);
|
||
|
||
for (var index = 0;
|
||
index < memberStates.Count;
|
||
index++)
|
||
{
|
||
var memberState = memberStates[index];
|
||
if (memberState.VehicleId <= 0)
|
||
{
|
||
return FleetStateEstimateResult.Unavailable(
|
||
$"第{index}个成员状态的车号无效。");
|
||
}
|
||
|
||
if (!layout.TryGetVehicle(
|
||
memberState.VehicleId,
|
||
out _))
|
||
{
|
||
return FleetStateEstimateResult.Unavailable(
|
||
$"成员状态包含布局外车辆" +
|
||
$"{memberState.VehicleId}。");
|
||
}
|
||
|
||
if (statesByVehicleId.ContainsKey(
|
||
memberState.VehicleId))
|
||
{
|
||
return FleetStateEstimateResult.Unavailable(
|
||
$"成员状态包含重复车号" +
|
||
$"{memberState.VehicleId}。");
|
||
}
|
||
|
||
statesByVehicleId.Add(
|
||
memberState.VehicleId,
|
||
memberState);
|
||
}
|
||
|
||
var alignedMembers =
|
||
new AlignedMemberState[layout.VehicleCount];
|
||
|
||
for (var index = 0;
|
||
index < layout.Vehicles.Count;
|
||
index++)
|
||
{
|
||
var vehicleLayout = layout.Vehicles[index];
|
||
if (!statesByVehicleId.TryGetValue(
|
||
vehicleLayout.VehicleId,
|
||
out var memberState))
|
||
{
|
||
return FleetStateEstimateResult.Unavailable(
|
||
$"缺少车辆{vehicleLayout.VehicleId}的状态。");
|
||
}
|
||
|
||
var alignmentResult = AlignMemberState(
|
||
memberState,
|
||
targetTimestampSeconds,
|
||
out var alignedPoseInWorld);
|
||
if (alignmentResult != null)
|
||
{
|
||
return FleetStateEstimateResult.Unavailable(
|
||
alignmentResult);
|
||
}
|
||
|
||
var candidateFleetPoseInWorld =
|
||
FrameTransform2D.Compose(
|
||
alignedPoseInWorld,
|
||
FrameTransform2D.Inverse(
|
||
vehicleLayout.PoseInFleet));
|
||
|
||
alignedMembers[index] =
|
||
new AlignedMemberState(
|
||
vehicleLayout,
|
||
memberState,
|
||
alignedPoseInWorld,
|
||
candidateFleetPoseInWorld);
|
||
}
|
||
|
||
var disagreementReason =
|
||
FindCandidateDisagreement(alignedMembers);
|
||
if (disagreementReason != null)
|
||
{
|
||
return FleetStateEstimateResult.Unavailable(
|
||
disagreementReason);
|
||
}
|
||
|
||
if (!TryAverageCandidateFleetPose(
|
||
alignedMembers,
|
||
out var fleetPoseInWorld))
|
||
{
|
||
return FleetStateEstimateResult.Unavailable(
|
||
"成员候选航向无法形成唯一的车队平均航向。");
|
||
}
|
||
|
||
var hasValidVelocityEstimate =
|
||
TryAverageFleetOriginTwist(
|
||
alignedMembers,
|
||
fleetPoseInWorld,
|
||
out var twistAtFleetOriginInWorld);
|
||
var fleetState = new FleetState(
|
||
targetTimestampSeconds,
|
||
fleetPoseInWorld,
|
||
twistAtFleetOriginInWorld,
|
||
hasValidVelocityEstimate);
|
||
var memberErrors = CalculateMemberErrors(
|
||
alignedMembers,
|
||
fleetPoseInWorld);
|
||
|
||
return FleetStateEstimateResult.Available(
|
||
fleetState,
|
||
memberErrors);
|
||
}
|
||
|
||
private string AlignMemberState(
|
||
FleetMemberStateSample memberState,
|
||
double targetTimestampSeconds,
|
||
out Pose2D alignedPoseInWorld)
|
||
{
|
||
alignedPoseInWorld = memberState.PoseInWorld;
|
||
|
||
if (!memberState.IsStateAvailable)
|
||
{
|
||
return $"车辆{memberState.VehicleId}状态不可用。";
|
||
}
|
||
|
||
var ageSeconds =
|
||
targetTimestampSeconds -
|
||
memberState.SampleTimestampSeconds;
|
||
|
||
if (ageSeconds < -TimestampToleranceSeconds)
|
||
{
|
||
return $"车辆{memberState.VehicleId}的状态时间晚于" +
|
||
"本次估计目标时间。";
|
||
}
|
||
|
||
if (ageSeconds > _maximumMemberStateAgeSeconds)
|
||
{
|
||
return $"车辆{memberState.VehicleId}的状态已过期:" +
|
||
$"{ageSeconds:F3}s。";
|
||
}
|
||
|
||
if (ageSeconds <= TimestampToleranceSeconds)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (!memberState.HasValidVelocityEstimate)
|
||
{
|
||
return $"车辆{memberState.VehicleId}缺少时间对齐所需的" +
|
||
"有效速度。";
|
||
}
|
||
|
||
var twist = memberState.TwistAtVehicleOriginInWorld;
|
||
alignedPoseInWorld = new Pose2D(
|
||
memberState.PoseInWorld.XMeters +
|
||
twist.VxMetersPerSecond * ageSeconds,
|
||
memberState.PoseInWorld.YMeters +
|
||
twist.VyMetersPerSecond * ageSeconds,
|
||
AngleMath.NormalizeRadians(
|
||
memberState.PoseInWorld.YawRadians +
|
||
twist.OmegaRadiansPerSecond * ageSeconds));
|
||
return null;
|
||
}
|
||
|
||
private string FindCandidateDisagreement(
|
||
IReadOnlyList<AlignedMemberState> alignedMembers)
|
||
{
|
||
for (var firstIndex = 0;
|
||
firstIndex < alignedMembers.Count;
|
||
firstIndex++)
|
||
{
|
||
var first = alignedMembers[firstIndex];
|
||
for (var secondIndex = firstIndex + 1;
|
||
secondIndex < alignedMembers.Count;
|
||
secondIndex++)
|
||
{
|
||
var second = alignedMembers[secondIndex];
|
||
var dx =
|
||
first.CandidateFleetPoseInWorld.XMeters -
|
||
second.CandidateFleetPoseInWorld.XMeters;
|
||
var dy =
|
||
first.CandidateFleetPoseInWorld.YMeters -
|
||
second.CandidateFleetPoseInWorld.YMeters;
|
||
var positionDifferenceMeters =
|
||
Math.Sqrt(dx * dx + dy * dy);
|
||
var yawDifferenceRadians = Math.Abs(
|
||
AngleMath.ShortestDifferenceRadians(
|
||
first.CandidateFleetPoseInWorld
|
||
.YawRadians,
|
||
second.CandidateFleetPoseInWorld
|
||
.YawRadians));
|
||
|
||
if (positionDifferenceMeters >
|
||
_maximumPositionDisagreementMeters)
|
||
{
|
||
return $"车辆{first.VehicleLayout.VehicleId}与" +
|
||
$"车辆{second.VehicleLayout.VehicleId}反算的" +
|
||
$"车队中心相差{positionDifferenceMeters:F3}m," +
|
||
"超过允许值。";
|
||
}
|
||
|
||
if (yawDifferenceRadians >
|
||
_maximumYawDisagreementRadians)
|
||
{
|
||
return $"车辆{first.VehicleLayout.VehicleId}与" +
|
||
$"车辆{second.VehicleLayout.VehicleId}反算的" +
|
||
$"车队航向相差" +
|
||
$"{AngleMath.RadiansToDegrees(yawDifferenceRadians):F2}°," +
|
||
"超过允许值。";
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static bool TryAverageCandidateFleetPose(
|
||
IReadOnlyList<AlignedMemberState> alignedMembers,
|
||
out Pose2D fleetPoseInWorld)
|
||
{
|
||
var xMeters = 0.0;
|
||
var yMeters = 0.0;
|
||
var yawCosineSum = 0.0;
|
||
var yawSineSum = 0.0;
|
||
|
||
for (var index = 0;
|
||
index < alignedMembers.Count;
|
||
index++)
|
||
{
|
||
var candidate =
|
||
alignedMembers[index]
|
||
.CandidateFleetPoseInWorld;
|
||
xMeters += candidate.XMeters;
|
||
yMeters += candidate.YMeters;
|
||
yawCosineSum += Math.Cos(candidate.YawRadians);
|
||
yawSineSum += Math.Sin(candidate.YawRadians);
|
||
}
|
||
|
||
var count = alignedMembers.Count;
|
||
var circularMeanMagnitude = Math.Sqrt(
|
||
yawCosineSum * yawCosineSum +
|
||
yawSineSum * yawSineSum);
|
||
if (circularMeanMagnitude <
|
||
MinimumCircularMeanMagnitude)
|
||
{
|
||
fleetPoseInWorld = Pose2D.Identity;
|
||
return false;
|
||
}
|
||
|
||
fleetPoseInWorld = new Pose2D(
|
||
xMeters / count,
|
||
yMeters / count,
|
||
Math.Atan2(yawSineSum, yawCosineSum));
|
||
return true;
|
||
}
|
||
|
||
private static bool TryAverageFleetOriginTwist(
|
||
IReadOnlyList<AlignedMemberState> alignedMembers,
|
||
Pose2D fleetPoseInWorld,
|
||
out Twist2D twistAtFleetOriginInWorld)
|
||
{
|
||
for (var index = 0;
|
||
index < alignedMembers.Count;
|
||
index++)
|
||
{
|
||
if (!alignedMembers[index]
|
||
.MemberState
|
||
.HasValidVelocityEstimate)
|
||
{
|
||
twistAtFleetOriginInWorld = Twist2D.Zero;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
var vxMetersPerSecond = 0.0;
|
||
var vyMetersPerSecond = 0.0;
|
||
var omegaRadiansPerSecond = 0.0;
|
||
|
||
for (var index = 0;
|
||
index < alignedMembers.Count;
|
||
index++)
|
||
{
|
||
var member = alignedMembers[index];
|
||
var twist = member.MemberState
|
||
.TwistAtVehicleOriginInWorld;
|
||
var memberXFromFleetOrigin =
|
||
member.AlignedPoseInWorld.XMeters -
|
||
fleetPoseInWorld.XMeters;
|
||
var memberYFromFleetOrigin =
|
||
member.AlignedPoseInWorld.YMeters -
|
||
fleetPoseInWorld.YMeters;
|
||
|
||
vxMetersPerSecond +=
|
||
twist.VxMetersPerSecond +
|
||
twist.OmegaRadiansPerSecond *
|
||
memberYFromFleetOrigin;
|
||
vyMetersPerSecond +=
|
||
twist.VyMetersPerSecond -
|
||
twist.OmegaRadiansPerSecond *
|
||
memberXFromFleetOrigin;
|
||
omegaRadiansPerSecond +=
|
||
twist.OmegaRadiansPerSecond;
|
||
}
|
||
|
||
var count = alignedMembers.Count;
|
||
twistAtFleetOriginInWorld = new Twist2D(
|
||
vxMetersPerSecond / count,
|
||
vyMetersPerSecond / count,
|
||
omegaRadiansPerSecond / count);
|
||
return true;
|
||
}
|
||
|
||
private static FleetMemberLayoutError[] CalculateMemberErrors(
|
||
IReadOnlyList<AlignedMemberState> alignedMembers,
|
||
Pose2D fleetPoseInWorld)
|
||
{
|
||
var errors =
|
||
new FleetMemberLayoutError[alignedMembers.Count];
|
||
|
||
for (var index = 0;
|
||
index < alignedMembers.Count;
|
||
index++)
|
||
{
|
||
var member = alignedMembers[index];
|
||
var expectedPoseInWorld =
|
||
FrameTransform2D.Compose(
|
||
fleetPoseInWorld,
|
||
member.VehicleLayout.PoseInFleet);
|
||
var actualPoseInExpectedVehicleFrame =
|
||
FrameTransform2D.Compose(
|
||
FrameTransform2D.Inverse(
|
||
expectedPoseInWorld),
|
||
member.AlignedPoseInWorld);
|
||
|
||
errors[index] = new FleetMemberLayoutError(
|
||
member.VehicleLayout.VehicleId,
|
||
actualPoseInExpectedVehicleFrame);
|
||
}
|
||
|
||
return errors;
|
||
}
|
||
|
||
private readonly struct AlignedMemberState
|
||
{
|
||
public AlignedMemberState(
|
||
VehicleLayout vehicleLayout,
|
||
FleetMemberStateSample memberState,
|
||
Pose2D alignedPoseInWorld,
|
||
Pose2D candidateFleetPoseInWorld)
|
||
{
|
||
VehicleLayout = vehicleLayout;
|
||
MemberState = memberState;
|
||
AlignedPoseInWorld = alignedPoseInWorld;
|
||
CandidateFleetPoseInWorld =
|
||
candidateFleetPoseInWorld;
|
||
}
|
||
|
||
public VehicleLayout VehicleLayout { get; }
|
||
|
||
public FleetMemberStateSample MemberState { get; }
|
||
|
||
public Pose2D AlignedPoseInWorld { get; }
|
||
|
||
public Pose2D CandidateFleetPoseInWorld { get; }
|
||
}
|
||
}
|
||
}
|