feat: coordinate rolling EM replans

This commit is contained in:
梁薄云
2026-08-04 12:47:39 +08:00
parent 49109ec835
commit d75380cf8f
7 changed files with 497 additions and 1 deletions
@@ -0,0 +1,124 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>
/// Latest-wins rolling coordinator around the pure one-shot planning service.
/// It owns no localization, hardware, UI, or wall-clock source; callers supply each cycle's state and time.
/// </summary>
public sealed class EmPlanningCoordinator
{
private readonly IEmPlanningService planningService;
private readonly IEmPlanningCycleSink sink;
private readonly object publicationGate = new object();
private long latestCycleVersion;
private PlanningCycleIdentity latestIdentity;
private CancellationTokenSource latestCancellation;
private EmTrajectory publishedTrajectory;
private DateTimeOffset? lastCycleStartedAtUtc;
private double replanPeriodSeconds = 0.20d;
public EmPlanningCoordinator(IEmPlanningService planningService, IEmPlanningCycleSink sink = null)
{
this.planningService = planningService ?? throw new ArgumentNullException(nameof(planningService));
this.sink = sink;
}
public EmTrajectory PublishedTrajectory
{
get
{
lock (publicationGate)
return publishedTrajectory;
}
}
/// <summary>Returns whether a caller-supplied time is due for another rolling cycle.</summary>
public bool ShouldStartCycle(DateTimeOffset now)
{
lock (publicationGate)
{
return !lastCycleStartedAtUtc.HasValue ||
now - lastCycleStartedAtUtc.Value >= TimeSpan.FromSeconds(replanPeriodSeconds);
}
}
public Task<PlanningCycleResult> PlanLatestAsync(PlanningCycleInput input, CancellationToken cancellationToken)
{
if (input == null)
throw new ArgumentNullException(nameof(input));
long version;
CancellationTokenSource cycleCancellation;
lock (publicationGate)
{
latestCancellation?.Cancel();
latestCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cycleCancellation = latestCancellation;
version = ++latestCycleVersion;
latestIdentity = input.Identity;
lastCycleStartedAtUtc = input.Now;
replanPeriodSeconds = input.ReplanPeriodSeconds;
}
return Task.Run(() => CompleteCycle(version, input, cycleCancellation));
}
private PlanningCycleResult CompleteCycle(long version, PlanningCycleInput input,
CancellationTokenSource cycleCancellation)
{
EmPlanningResult planned;
try
{
planned = planningService.Plan(input.Request, cycleCancellation.Token);
if (planned == null)
planned = new EmPlanningResult(EmPlanningStatus.Failed, null, "planning service returned no result");
}
catch (OperationCanceledException)
{
planned = new EmPlanningResult(EmPlanningStatus.Cancelled, null, "planning cycle cancelled");
}
catch (Exception exception)
{
planned = new EmPlanningResult(EmPlanningStatus.Failed, null, "planning service exception: " + exception.Message);
}
PlanningCycleResult result;
lock (publicationGate)
{
bool current = version == latestCycleVersion && input.Identity.Equals(latestIdentity);
if (!current)
{
var superseded = new EmPlanningResult(EmPlanningStatus.Superseded, null,
"rolling cycle superseded before publication");
result = new PlanningCycleResult(version, input.Identity, superseded, false, superseded.FailureReason);
}
else
{
bool publishable = (planned.Status == EmPlanningStatus.Success || planned.Status == EmPlanningStatus.SuccessWithFallback) &&
planned.Trajectory != null;
if (publishable)
publishedTrajectory = planned.Trajectory;
result = new PlanningCycleResult(version, input.Identity, planned, publishable, planned.FailureReason);
}
}
if (sink == null)
return result;
try
{
sink.OnCycleCompleted(result);
return result;
}
catch (Exception exception)
{
string diagnostic = string.IsNullOrEmpty(result.Diagnostic)
? "cycle sink exception: " + exception.Message
: result.Diagnostic + "; cycle sink exception: " + exception.Message;
return result.WithDiagnostic(diagnostic);
}
}
}
@@ -0,0 +1,7 @@
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Optional observer for completed rolling planning cycles.</summary>
public interface IEmPlanningCycleSink
{
void OnCycleCompleted(PlanningCycleResult result);
}
@@ -0,0 +1,75 @@
using System;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Immutable identity bound to one rolling planning cycle.</summary>
public sealed class PlanningCycleIdentity : IEquatable<PlanningCycleIdentity>
{
public PlanningCycleIdentity(long mapSnapshotId, string referencePathId, long vehicleStateSequenceId,
string previousTrajectoryId, int segmentIndex)
{
if (mapSnapshotId < 0)
throw new ArgumentOutOfRangeException(nameof(mapSnapshotId));
if (string.IsNullOrWhiteSpace(referencePathId))
throw new ArgumentException("A reference path ID is required.", nameof(referencePathId));
if (vehicleStateSequenceId < 0)
throw new ArgumentOutOfRangeException(nameof(vehicleStateSequenceId));
if (segmentIndex < 0)
throw new ArgumentOutOfRangeException(nameof(segmentIndex));
MapSnapshotId = mapSnapshotId;
ReferencePathId = referencePathId;
VehicleStateSequenceId = vehicleStateSequenceId;
PreviousTrajectoryId = previousTrajectoryId ?? string.Empty;
SegmentIndex = segmentIndex;
}
public long MapSnapshotId { get; }
public string ReferencePathId { get; }
public long VehicleStateSequenceId { get; }
public string PreviousTrajectoryId { get; }
public int SegmentIndex { get; }
public static PlanningCycleIdentity FromRequest(EmPlanningRequest request)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.Map == null)
throw new ArgumentException("A planning map is required for a rolling cycle.", nameof(request));
if (request.VehicleState == null)
throw new ArgumentException("A vehicle state is required for a rolling cycle.", nameof(request));
return new PlanningCycleIdentity(request.Map.SnapshotId, request.ReferencePathId,
request.VehicleState.SequenceId, request.PreviousTrajectoryId, request.SegmentIndex);
}
public bool Equals(PlanningCycleIdentity other)
{
return other != null && MapSnapshotId == other.MapSnapshotId &&
string.Equals(ReferencePathId, other.ReferencePathId, StringComparison.Ordinal) &&
VehicleStateSequenceId == other.VehicleStateSequenceId &&
string.Equals(PreviousTrajectoryId, other.PreviousTrajectoryId, StringComparison.Ordinal) &&
SegmentIndex == other.SegmentIndex;
}
public override bool Equals(object obj)
{
return Equals(obj as PlanningCycleIdentity);
}
public override int GetHashCode()
{
unchecked
{
int hash = MapSnapshotId.GetHashCode();
hash = (hash * 397) ^ StringComparer.Ordinal.GetHashCode(ReferencePathId);
hash = (hash * 397) ^ VehicleStateSequenceId.GetHashCode();
hash = (hash * 397) ^ StringComparer.Ordinal.GetHashCode(PreviousTrajectoryId);
return (hash * 397) ^ SegmentIndex;
}
}
}
@@ -0,0 +1,29 @@
using System;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Caller-captured request and clock value for one rolling cycle.</summary>
public sealed class PlanningCycleInput
{
public PlanningCycleInput(EmPlanningRequest request, DateTimeOffset now)
{
Request = request ?? throw new ArgumentNullException(nameof(request));
Identity = PlanningCycleIdentity.FromRequest(request);
Now = now;
ReplanPeriodSeconds = ReadReplanPeriod(request.Configuration);
}
public EmPlanningRequest Request { get; }
public PlanningCycleIdentity Identity { get; }
public DateTimeOffset Now { get; }
public double ReplanPeriodSeconds { get; }
private static double ReadReplanPeriod(EmPlannerConfiguration configuration)
{
double configured = configuration?.Scheduling?.ReplanPeriodSeconds ?? 0.20d;
return !double.IsNaN(configured) && !double.IsInfinity(configured) && configured > 0d ? configured : 0.20d;
}
}
@@ -0,0 +1,34 @@
using System;
namespace MultiWheelC.TrajectoryPlanning.EMPlanner;
/// <summary>Immutable completion record for one rolling planning cycle.</summary>
public sealed class PlanningCycleResult
{
public PlanningCycleResult(long version, PlanningCycleIdentity identity, EmPlanningResult result, bool published,
string diagnostic)
{
if (version <= 0)
throw new ArgumentOutOfRangeException(nameof(version));
Version = version;
Identity = identity ?? throw new ArgumentNullException(nameof(identity));
Result = result ?? throw new ArgumentNullException(nameof(result));
Published = published;
Diagnostic = diagnostic ?? string.Empty;
}
public long Version { get; }
public PlanningCycleIdentity Identity { get; }
public EmPlanningResult Result { get; }
public bool Published { get; }
public string Diagnostic { get; }
internal PlanningCycleResult WithDiagnostic(string diagnostic)
{
return new PlanningCycleResult(Version, Identity, Result, Published, diagnostic);
}
}
@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.EMPlanner;
using MultiWheelC.TrajectoryPlanning.Mapping;
namespace EMPlannerVerificationHost;
internal static class CoordinatorChecks
{
public static void Run()
{
VerifiesCallerSuppliedSchedulingDecision();
VerifiesCompletedCycleDoesNotPoisonNextCancellationSource();
VerifiesLatestCycleWinsAndEveryIdentityFieldSuppressesStaleResults();
VerifiesSinkExceptionsAreIsolatedIntoCycleDiagnostics();
}
private static void VerifiesCallerSuppliedSchedulingDecision()
{
var service = new ControlledPlanningService();
var coordinator = new EmPlanningCoordinator(service);
DateTimeOffset start = DateTimeOffset.UnixEpoch.AddSeconds(100d);
PlanningCycleInput input = CreateInput(CreateMap(1), "schedule-reference", 7L, "prior", 0, "schedule", start);
Verification.True(coordinator.ShouldStartCycle(start), "first cycle is due");
Task<PlanningCycleResult> cycle = coordinator.PlanLatestAsync(input, CancellationToken.None);
service.WaitUntilStarted("schedule");
service.Complete("schedule");
Verification.Equal(EmPlanningStatus.Success, cycle.GetAwaiter().GetResult().Result.Status, "schedule setup result");
Verification.True(!coordinator.ShouldStartCycle(start.AddSeconds(0.199d)), "cycle remains deferred before period");
Verification.True(coordinator.ShouldStartCycle(start.AddSeconds(0.20d)), "cycle becomes due at exact period");
}
private static void VerifiesLatestCycleWinsAndEveryIdentityFieldSuppressesStaleResults()
{
PlanningGridMap firstMap = CreateMap(10);
PlanningGridMap secondMap = CreateMap(11);
DateTimeOffset now = DateTimeOffset.UnixEpoch.AddSeconds(200d);
VerifySuperseded("later-version", CreateInput(firstMap, "reference", 7L, "prior", 0, "later-version-A", now),
CreateInput(firstMap, "reference", 7L, "prior", 0, "later-version-B", now.AddSeconds(0.20d)));
VerifySuperseded("map", CreateInput(firstMap, "reference", 7L, "prior", 0, "map-A", now),
CreateInput(secondMap, "reference", 7L, "prior", 0, "map-B", now.AddSeconds(0.20d)));
VerifySuperseded("reference", CreateInput(firstMap, "reference-A", 7L, "prior", 0, "reference-A", now),
CreateInput(firstMap, "reference-B", 7L, "prior", 0, "reference-B", now.AddSeconds(0.20d)));
VerifySuperseded("state", CreateInput(firstMap, "reference", 7L, "prior", 0, "state-A", now),
CreateInput(firstMap, "reference", 8L, "prior", 0, "state-B", now.AddSeconds(0.20d)));
VerifySuperseded("segment", CreateInput(firstMap, "reference", 7L, "prior", 0, "segment-A", now),
CreateInput(firstMap, "reference", 7L, "prior", 1, "segment-B", now.AddSeconds(0.20d)));
VerifySuperseded("previous", CreateInput(firstMap, "reference", 7L, "prior-A", 0, "previous-A", now),
CreateInput(firstMap, "reference", 7L, "prior-B", 0, "previous-B", now.AddSeconds(0.20d)));
}
private static void VerifiesCompletedCycleDoesNotPoisonNextCancellationSource()
{
var service = new ControlledPlanningService();
var coordinator = new EmPlanningCoordinator(service);
DateTimeOffset now = DateTimeOffset.UnixEpoch.AddSeconds(150d);
PlanningCycleInput first = CreateInput(CreateMap(5), "completed-reference", 4L, "prior", 0,
"completed-first", now);
Task<PlanningCycleResult> firstCycle = coordinator.PlanLatestAsync(first, CancellationToken.None);
service.WaitUntilStarted(first.Request.OutputTrajectoryId);
service.Complete(first.Request.OutputTrajectoryId);
Verification.Equal(EmPlanningStatus.Success, firstCycle.GetAwaiter().GetResult().Result.Status,
"first completed cycle succeeds");
PlanningCycleInput second = CreateInput(CreateMap(6), "completed-reference", 5L, "completed-first", 0,
"completed-second", now.AddSeconds(0.20d));
Task<PlanningCycleResult> secondCycle = coordinator.PlanLatestAsync(second, CancellationToken.None);
service.WaitUntilStarted(second.Request.OutputTrajectoryId);
service.Complete(second.Request.OutputTrajectoryId);
PlanningCycleResult result = secondCycle.GetAwaiter().GetResult();
Verification.Equal(EmPlanningStatus.Success, result.Result.Status, "next cycle after completion succeeds");
Verification.True(result.Published, "next cycle after completion publishes");
}
private static void VerifiesSinkExceptionsAreIsolatedIntoCycleDiagnostics()
{
var service = new ControlledPlanningService();
var coordinator = new EmPlanningCoordinator(service, new ThrowingCycleSink());
PlanningCycleInput input = CreateInput(CreateMap(20), "sink-reference", 9L, "prior", 0, "sink", DateTimeOffset.UnixEpoch);
Task<PlanningCycleResult> cycle = coordinator.PlanLatestAsync(input, CancellationToken.None);
service.WaitUntilStarted("sink");
service.Complete("sink");
PlanningCycleResult result = cycle.GetAwaiter().GetResult();
Verification.Equal(EmPlanningStatus.Success, result.Result.Status, "sink failure preserves planning result");
Verification.True(result.Diagnostic.IndexOf("cycle sink", StringComparison.OrdinalIgnoreCase) >= 0,
"sink failure is reported in diagnostic");
}
private static void VerifySuperseded(string name, PlanningCycleInput older, PlanningCycleInput newer)
{
var service = new ControlledPlanningService();
var coordinator = new EmPlanningCoordinator(service);
Task<PlanningCycleResult> slow = coordinator.PlanLatestAsync(older, CancellationToken.None);
service.WaitUntilStarted(older.Request.OutputTrajectoryId);
Task<PlanningCycleResult> current = coordinator.PlanLatestAsync(newer, CancellationToken.None);
service.WaitUntilStarted(newer.Request.OutputTrajectoryId);
Verification.True(service.WasCancelled(older.Request.OutputTrajectoryId), name + " cancels older cycle token");
service.Complete(newer.Request.OutputTrajectoryId);
PlanningCycleResult currentResult = current.GetAwaiter().GetResult();
service.Complete(older.Request.OutputTrajectoryId);
PlanningCycleResult staleResult = slow.GetAwaiter().GetResult();
Verification.Equal(EmPlanningStatus.Success, currentResult.Result.Status, name + " current result succeeds");
Verification.True(currentResult.Published, name + " current result publishes");
Verification.Equal(EmPlanningStatus.Superseded, staleResult.Result.Status, name + " old result is superseded");
Verification.True(staleResult.Result.Trajectory == null, name + " stale result exposes no trajectory");
Verification.Equal(newer.Request.OutputTrajectoryId, coordinator.PublishedTrajectory.Metadata.TrajectoryId,
name + " only latest trajectory is published");
}
private static PlanningCycleInput CreateInput(PlanningGridMap map, string referencePathId, long stateSequenceId,
string previousTrajectoryId, int segmentIndex, string outputTrajectoryId, DateTimeOffset now)
{
EmPlannerConfiguration configuration = EmPlannerConfiguration.CreateDefault();
var state = new VehicleMotionState(new Pose2D(0d, 0d, 0d), 0d, 0d, now, stateSequenceId);
var request = new EmPlanningRequest(null, map, null, state, configuration, segmentIndex, null, now, now,
outputTrajectoryId, referencePathId, previousTrajectoryId, EmMotionModel.NonholonomicForwardReverse);
return new PlanningCycleInput(request, now);
}
private static PlanningGridMap CreateMap(int widthOffset)
{
PlanningMapBuildResult build = new PlanningMapFactory().Create(new PlanningMapRequest
{
Bounds = new MapBoundsMm(-1000f, 1000f + widthOffset * 20f, -1000f, 1000f),
ResolutionMm = 20f,
ObstacleSources = Array.Empty<IMapObstacleSource>(),
AllowExplicitEmptyMap = true,
});
Verification.True(build.Succeeded && build.Map != null && build.Map.PlanningReady, "coordinator map builds");
return build.Map!;
}
private sealed class ControlledPlanningService : IEmPlanningService
{
private readonly object gate = new object();
private readonly Dictionary<string, PendingCycle> pending = new Dictionary<string, PendingCycle>();
public EmPlanningResult Plan(EmPlanningRequest request, CancellationToken cancellationToken)
{
var cycle = new PendingCycle(request, cancellationToken);
lock (gate)
{
pending.Add(request.OutputTrajectoryId, cycle);
Monitor.PulseAll(gate);
}
return cycle.Completion.Task.GetAwaiter().GetResult();
}
public void WaitUntilStarted(string outputTrajectoryId)
{
DateTimeOffset timeout = DateTimeOffset.UtcNow.AddSeconds(5d);
lock (gate)
{
while (!pending.ContainsKey(outputTrajectoryId))
{
TimeSpan remaining = timeout - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero || !Monitor.Wait(gate, remaining))
throw new InvalidOperationException("Planner cycle did not start: " + outputTrajectoryId);
}
}
}
public bool WasCancelled(string outputTrajectoryId)
{
lock (gate)
return pending[outputTrajectoryId].CancellationToken.IsCancellationRequested;
}
public void Complete(string outputTrajectoryId)
{
PendingCycle cycle;
lock (gate)
cycle = pending[outputTrajectoryId];
cycle.Completion.TrySetResult(CreateSuccess(cycle.Request));
}
private static EmPlanningResult CreateSuccess(EmPlanningRequest request)
{
var metadata = new EmTrajectoryMetadata(request.OutputTrajectoryId, request.RequestedAtUtc, request.EffectiveAtUtc,
request.Map.SnapshotId, request.ReferencePathId, request.VehicleState.SequenceId, request.PreviousTrajectoryId,
request.SegmentIndex, TravelDirection.Forward, EmTerminalType.RollingSafetyStop);
var point = new EmTrajectoryPoint(0d, 0d, 0d, 0d, 0d, 0d, request.SegmentIndex, 0d, 0d,
TravelDirection.Forward, EmBoundaryType.RollingSafetyStop, 0d, 0d);
return new EmPlanningResult(EmPlanningStatus.Success, new EmTrajectory(metadata, new[] { point }), string.Empty);
}
private sealed class PendingCycle
{
public PendingCycle(EmPlanningRequest request, CancellationToken cancellationToken)
{
Request = request;
CancellationToken = cancellationToken;
Completion = new TaskCompletionSource<EmPlanningResult>(TaskCreationOptions.RunContinuationsAsynchronously);
}
public EmPlanningRequest Request { get; }
public CancellationToken CancellationToken { get; }
public TaskCompletionSource<EmPlanningResult> Completion { get; }
}
}
private sealed class ThrowingCycleSink : IEmPlanningCycleSink
{
public void OnCycleCompleted(PlanningCycleResult result)
{
throw new InvalidOperationException("cycle sink failure");
}
}
}
@@ -12,7 +12,7 @@ internal static class Program
args[0] != "lateral-real-osqp" && args[0] != "lateral-real-osqp-probe" && args[0] != "lateral-all" &&
args[0] != "longitudinal-model" && args[0] != "longitudinal-integration" &&
args[0] != "longitudinal-real-osqp-probe" && args[0] != "trajectory" &&
args[0] != "em-planning-service" && args[0] != "em-core-all"))
args[0] != "em-planning-service" && args[0] != "em-core-all" && args[0] != "coordinator"))
{
Console.Error.WriteLine("Usage: EMPlannerVerificationHost foundation|segmentation|frenet|corridor|optimization|osqp|osqp-loader|all-foundation|lateral-model|lateral-integration|lateral-real-osqp|lateral-all|longitudinal-model|longitudinal-integration");
return 2;
@@ -108,6 +108,11 @@ internal static class Program
MultiWheelC.TrajectoryPlanning.EMPlanner.EmPlanningServiceChecks.Run();
Console.WriteLine("PASS em-planning-service");
}
if (args[0] == "coordinator")
{
CoordinatorChecks.Run();
Console.WriteLine("PASS coordinator");
}
return 0;
}
catch (Exception exception)