feat: add read-only EM observation movement test

This commit is contained in:
梁薄云
2026-08-04 16:43:29 +08:00
parent fded5181db
commit 6accfd9d6a
3 changed files with 518 additions and 0 deletions
@@ -0,0 +1,442 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using ClumsyCore;
using ClumsyCore.DTools;
using ClumsyCore.Interfaces;
using ClumsyCore.Pilot;
using CommonUsage.Chassis;
using FundamentalLib;
using MDCSToolBox.Clumsy.Pilot;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Facade;
using MultiWheelC.TrajectoryPlanning.EMPlanner;
using MultiWheelC.TrajectoryPlanning.TrajectoryObservation;
namespace MultiWheelC;
[MovementTest(name = "EM杞ㄨ抗瑙勫垝瑙傚療闂幆娴嬭瘯")]
public sealed class TrajectoryObservationMovementTest : MovementTest
{
private const int MaximumManualObstacleCount = 20;
private static long nextObstacleSnapshotVersion;
public double GoalXmm = double.NaN;
public double GoalYmm = double.NaN;
public double GoalYawDeg = 0d;
public double MapPaddingMeters = 2d;
public float MapResolutionMm = 50f;
public double ReplanPeriodSeconds = 0.20d;
public double ObserverPeriodSeconds = 0.05d;
public override void Test()
{
try
{
double goalXMillimeters = GoalXmm;
double goalYMillimeters = GoalYmm;
double goalYawDegrees = GoalYawDeg;
if (!IsFinite(goalXMillimeters) || !IsFinite(goalYMillimeters))
{
goalXMillimeters = ReadFiniteInput("EM观察终点 X(世界 mm");
goalYMillimeters = ReadFiniteInput("EM观察终点 Y(世界 mm");
goalYawDegrees = ReadFiniteInput("EM观察终点航向(世界 deg");
}
EnsureFinite(goalXMillimeters, nameof(GoalXmm));
EnsureFinite(goalYMillimeters, nameof(GoalYmm));
EnsureFinite(goalYawDegrees, nameof(GoalYawDeg));
var settings = new TrajectoryObservationSettings
{
MapPaddingMeters = MapPaddingMeters,
MapResolutionMillimeters = MapResolutionMm,
ReplanPeriodSeconds = ReplanPeriodSeconds,
ObserverPeriodSeconds = ObserverPeriodSeconds,
};
settings.Validate();
TimeSpan observerPeriod = TimeSpan.FromSeconds(settings.ObserverPeriodSeconds);
IReadOnlyList<TrajectoryObservationObstacle> obstacles = ReadManualObstacles();
long obstacleSnapshotVersion = obstacles.Count == 0
? 0L
: Interlocked.Increment(ref nextObstacleSnapshotVersion);
var goal = new Pose2D(goalXMillimeters / 1000d, goalYMillimeters / 1000d,
goalYawDegrees * Math.PI / 180d);
TrajectoryObservationMovementTestRunner.Start(
goal, settings, observerPeriod, obstacles, obstacleSnapshotVersion);
}
catch (Exception exception)
{
TrajectoryObservationMovementTestRunner.ShowInputFailure(exception);
}
}
public override void TestStop()
{
TrajectoryObservationMovementTestRunner.Stop();
}
private static IReadOnlyList<TrajectoryObservationObstacle> ReadManualObstacles()
{
int count = ReadBoundedIntegerInput("EM观察手动障碍物数量(0-20", 0, MaximumManualObstacleCount);
var obstacles = new List<TrajectoryObservationObstacle>(count);
for (int index = 0; index < count; index++)
{
string label = "障碍物 " + (index + 1).ToString(CultureInfo.InvariantCulture);
int kind = ReadBoundedIntegerInput(label + " 类型(1=圆形,2=轴对齐矩形)", 1, 2);
double centerXMillimeters = ReadFiniteInput(label + " 中心 X(世界 mm");
double centerYMillimeters = ReadFiniteInput(label + " 中心 Y(世界 mm");
if (kind == 1)
{
double radiusMillimeters = ReadPositiveFiniteInput(label + " 半径(mm");
obstacles.Add(TrajectoryObservationObstacle.Circle(
centerXMillimeters, centerYMillimeters, radiusMillimeters));
}
else
{
double lengthXMillimeters = ReadPositiveFiniteInput(label + " X 方向长度(mm");
double widthYMillimeters = ReadPositiveFiniteInput(label + " Y 方向宽度(mm");
obstacles.Add(TrajectoryObservationObstacle.Rectangle(
centerXMillimeters - lengthXMillimeters / 2d,
centerXMillimeters + lengthXMillimeters / 2d,
centerYMillimeters - widthYMillimeters / 2d,
centerYMillimeters + widthYMillimeters / 2d));
}
}
return obstacles;
}
private static int ReadBoundedIntegerInput(string prompt, int minimum, int maximum)
{
object raw = UI.GetInput(prompt);
string text = Convert.ToString(raw, CultureInfo.CurrentCulture);
int value;
if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.CurrentCulture, out value) &&
!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
throw new ArgumentException("输入必须是整数:" + prompt);
if (value < minimum || value > maximum)
throw new ArgumentOutOfRangeException(nameof(prompt), "输入超出允许范围:" + prompt);
return value;
}
private static double ReadPositiveFiniteInput(string prompt)
{
double value = ReadFiniteInput(prompt);
if (value <= 0d)
throw new ArgumentOutOfRangeException(nameof(prompt), "输入必须是正数:" + prompt);
return value;
}
private static double ReadFiniteInput(string prompt)
{
object raw = UI.GetInput(prompt);
string text = Convert.ToString(raw, CultureInfo.CurrentCulture);
double value;
if (!double.TryParse(text, NumberStyles.Float, CultureInfo.CurrentCulture, out value) &&
!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value))
throw new ArgumentException("输入必须是有限数字:" + prompt);
EnsureFinite(value, prompt);
return value;
}
private static void EnsureFinite(double value, string name)
{
if (!IsFinite(value)) throw new ArgumentException("输入必须是有限数字:" + name);
}
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
}
internal static class TrajectoryObservationMovementTestRunner
{
private const string StatusChannel = "TrajectoryObserver";
private const string ObserveOnlyNotice = "OBSERVE_ONLY: no chassis command is sent.";
private const string GearSwitchWaitingNotice =
"绛夊緟鐪熷疄妗d綅/鏂瑰悜纭锛涜瀵熸ā寮忎笉浼氭帹杩涗笅涓€鏂瑰悜娈?";
private static readonly TimeSpan StatusPeriod = TimeSpan.FromSeconds(1d);
private static readonly object SessionSync = new object();
private static readonly TrajectoryObservationPresentation Presentation =
new TrajectoryObservationPresentation();
private static CancellationTokenSource activeCancellation;
private static Task activeTask;
private static long nextSessionId;
private static long activeSessionId;
private static long stateSequence;
internal static void Start(Pose2D goal, TrajectoryObservationSettings settings, TimeSpan observerPeriod,
IReadOnlyList<TrajectoryObservationObstacle> obstacles, long obstacleSnapshotVersion)
{
if (goal == null) throw new ArgumentNullException(nameof(goal));
if (settings == null) throw new ArgumentNullException(nameof(settings));
if (obstacles == null) throw new ArgumentNullException(nameof(obstacles));
var cancellation = new CancellationTokenSource();
CancellationTokenSource previousCancellation;
long sessionId;
lock (SessionSync)
{
previousCancellation = activeCancellation;
activeCancellation = cancellation;
activeTask = null;
sessionId = ++nextSessionId;
activeSessionId = sessionId;
Presentation.ClearAll();
PrintStatus("Starting frozen observation session " +
sessionId.ToString(CultureInfo.InvariantCulture) + ".");
}
CancelWithoutWaiting(previousCancellation);
Task task = Task.Run(() => RunSessionAsync(sessionId, goal, settings, observerPeriod,
obstacles, obstacleSnapshotVersion, cancellation.Token), cancellation.Token);
lock (SessionSync)
{
if (activeSessionId == sessionId) activeTask = task;
}
_ = task.ContinueWith(completed => Finish(sessionId, cancellation, completed), TaskScheduler.Default);
}
internal static void Stop()
{
CancellationTokenSource cancellation;
lock (SessionSync)
{
cancellation = activeCancellation;
activeCancellation = null;
activeTask = null;
activeSessionId = 0L;
Presentation.ClearAll();
PrintStatus("Observation stop requested; all observer layers were cleared.");
}
CancelWithoutWaiting(cancellation);
}
internal static void ShowInputFailure(Exception exception)
{
string message = exception == null ? "Unknown input error." : exception.Message;
PrintStatus("Observation session did not start: " + message);
}
private static async Task RunSessionAsync(long sessionId, Pose2D goal,
TrajectoryObservationSettings settings, TimeSpan observerPeriod,
IReadOnlyList<TrajectoryObservationObstacle> obstacles, long obstacleSnapshotVersion,
CancellationToken token)
{
token.ThrowIfCancellationRequested();
VehicleMotionState initialState = ReadVehicleState();
CoarsePathPlanningJob job = TrajectoryObservationSetupFactory.CreateBootstrapJob(
initialState.Pose, goal, settings, obstacles, obstacleSnapshotVersion);
var bootstrapTimer = Stopwatch.StartNew();
TrajectoryObservationBootstrapResult bootstrap = new TrajectoryObservationBootstrapper()
.Bootstrap(job, token);
bootstrapTimer.Stop();
if (!bootstrap.Succeeded)
{
DrawIfCurrent(sessionId, bootstrap, null, null);
LogIfCurrent(sessionId, "Planning bootstrap failed after " +
bootstrapTimer.Elapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) +
" ms: " + bootstrap.FailureReason);
return;
}
LogIfCurrent(sessionId, CreateBootstrapStatus(sessionId, bootstrap, settings, obstacles.Count,
bootstrapTimer.Elapsed));
var controller = new TrajectoryObservationController(bootstrap, settings,
new EmPlanningService(new OsqpNativeSolver()),
"trajectory-observer-" + sessionId.ToString(CultureInfo.InvariantCulture));
DirectionSegmentView segment = bootstrap.Segments[0];
DateTimeOffset nextStatusAt = DateTimeOffset.MinValue;
PlanningCycleResult latestCycle = null;
TimeSpan latestPlanningElapsed = TimeSpan.Zero;
bool gearSwitchWaitingPrinted = false;
while (true)
{
await Task.Delay(observerPeriod, token).ConfigureAwait(false);
DateTimeOffset now = DateTimeOffset.UtcNow;
VehicleMotionState state = ReadVehicleState();
if (controller.ShouldStartCycle(now))
{
var planningTimer = Stopwatch.StartNew();
latestCycle = await controller.StartCycle(now, state, token).ConfigureAwait(false);
planningTimer.Stop();
latestPlanningElapsed = planningTimer.Elapsed;
}
TrajectoryObservationObservation observation = controller.Observe(now, state);
TrajectoryObservationCharts charts = observation.PublishedTrajectory == null
? null
: TrajectoryObservationCharts.Build(observation.PublishedTrajectory, segment,
settings.MapPaddingMeters);
DrawIfCurrent(sessionId, bootstrap, observation, charts);
bool waitingAtGearSwitch = HasReachedGearSwitchFinal(now, observation.PublishedTrajectory);
if (waitingAtGearSwitch && !gearSwitchWaitingPrinted)
{
LogIfCurrent(sessionId, GearSwitchWaitingNotice);
gearSwitchWaitingPrinted = true;
}
if (now >= nextStatusAt)
{
LogIfCurrent(sessionId, CreateTickStatus(sessionId, observation, charts,
latestCycle, latestPlanningElapsed, waitingAtGearSwitch));
nextStatusAt = now + StatusPeriod;
}
}
}
private static VehicleMotionState ReadVehicleState()
{
var location = DetourInterface.getCartLocation();
if (location == null) throw new InvalidOperationException("Live localization is unavailable.");
if (BasicPilotBase.Chassis == null) throw new InvalidOperationException("Live chassis read interface is unavailable.");
var speed = BasicPilotBase.Chassis.GetCarSpeed(true);
return new VehicleMotionState(
new Pose2D(location.x / 1000d, location.y / 1000d, location.th * Math.PI / 180d),
speed.Vx, null, DateTimeOffset.UtcNow, Interlocked.Increment(ref stateSequence));
}
private static bool HasReachedGearSwitchFinal(DateTimeOffset now, EmTrajectory trajectory)
{
if (trajectory == null || trajectory.Metadata.TerminalType != EmTerminalType.GearSwitch)
return false;
EmTrajectoryPoint finalPoint = trajectory.Points[trajectory.Points.Count - 1];
return (now - trajectory.Metadata.EffectiveAtUtc).TotalSeconds >= finalPoint.TimeFromStart;
}
private static string CreateBootstrapStatus(long sessionId, TrajectoryObservationBootstrapResult bootstrap,
TrajectoryObservationSettings settings, int obstacleCount, TimeSpan elapsed)
{
float widthMillimeters = bootstrap.Map.Bounds.XMax - bootstrap.Map.Bounds.XMin;
float heightMillimeters = bootstrap.Map.Bounds.YMax - bootstrap.Map.Bounds.YMin;
return "Session " + sessionId.ToString(CultureInfo.InvariantCulture) + " bootstrap succeeded in " +
elapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) + " ms.\n" +
"start=(" + Format(bootstrap.Job.Start.X) + ", " + Format(bootstrap.Job.Start.Y) + ", " +
Format(bootstrap.Job.Start.Heading) + ") m/rad; goal=(" + Format(bootstrap.Job.Goal.X) + ", " +
Format(bootstrap.Job.Goal.Y) + ", " + Format(bootstrap.Job.Goal.Heading) + ") m/rad.\n" +
"map=" + Format(widthMillimeters) + " x " + Format(heightMillimeters) + " mm, resolution=" +
Format(settings.MapResolutionMillimeters) + " mm, padding=" + Format(settings.MapPaddingMeters) +
" m, obstacles=" + obstacleCount.ToString(CultureInfo.InvariantCulture) + ".\n" +
"replan=" + Format(settings.ReplanPeriodSeconds) + " s, observer=" +
Format(settings.ObserverPeriodSeconds) + " s, segment index=0.";
}
private static string CreateTickStatus(long sessionId, TrajectoryObservationObservation observation,
TrajectoryObservationCharts charts, PlanningCycleResult latestCycle, TimeSpan latestPlanningElapsed,
bool waitingAtGearSwitch)
{
string planning = latestCycle == null
? "not started"
: latestCycle.Result.Status + ", published=" + latestCycle.Published +
", version=" + latestCycle.Version.ToString(CultureInfo.InvariantCulture);
string selected = observation.SelectedPoint == null
? "selected trajectory point unavailable"
: "selected t=" + Format(observation.SelectedPoint.TimeFromStart) +
" s, path-S=" + Format(observation.SelectedPoint.PathS) + " m";
string loggedCommand = observation.Command == null
? "logged command unavailable"
: "logged command only: signed speed=" + Format(observation.Command.SignedLongitudinalVelocity) +
" m/s, yaw rate=" + Format(observation.Command.YawRate) + " rad/s, direction=" +
observation.Command.Direction + ", request direction change=" +
observation.Command.RequestDirectionChange;
string terminal = observation.PublishedTrajectory == null
? "trajectory unavailable"
: "trajectory=" + observation.PublishedTrajectory.Metadata.TrajectoryId + ", terminal=" +
observation.PublishedTrajectory.Metadata.TerminalType + ", segment index=" +
observation.PublishedTrajectory.Metadata.SegmentIndex.ToString(CultureInfo.InvariantCulture);
string projectionFailures = charts == null
? "unavailable"
: charts.FailedProjectionCount.ToString(CultureInfo.InvariantCulture);
string waiting = waitingAtGearSwitch ? "\n" + GearSwitchWaitingNotice : string.Empty;
return "Session " + sessionId.ToString(CultureInfo.InvariantCulture) + ".\n" +
"pose=(" + Format(observation.VehicleState.Pose.X) + ", " +
Format(observation.VehicleState.Pose.Y) + ", " + Format(observation.VehicleState.Pose.Heading) +
") m/rad, actual longitudinal speed=" +
Format(observation.VehicleState.SignedLongitudinalSpeedMetersPerSecond) + " m/s.\n" +
"planning=" + planning + ", latest replan elapsed=" +
latestPlanningElapsed.TotalMilliseconds.ToString("F0", CultureInfo.InvariantCulture) + " ms.\n" +
terminal + ".\n" + selected + ".\n" + loggedCommand + ".\n" +
"LS projection failures=" + projectionFailures + "." + waiting;
}
private static void DrawIfCurrent(long sessionId, TrajectoryObservationBootstrapResult bootstrap,
TrajectoryObservationObservation observation, TrajectoryObservationCharts charts)
{
lock (SessionSync)
{
if (activeSessionId != sessionId) return;
Presentation.DrawWorld(bootstrap, observation);
Presentation.DrawLs(charts);
Presentation.DrawSt(charts);
}
}
private static void LogIfCurrent(long sessionId, string message)
{
lock (SessionSync)
{
if (activeSessionId != sessionId) return;
PrintStatus(message);
}
}
private static void PrintStatus(string message)
{
Hedingben.ToastText(ObserveOnlyNotice + "\n" + message, StatusChannel);
}
private static void Finish(long sessionId, CancellationTokenSource cancellation, Task completed)
{
try
{
completed.GetAwaiter().GetResult();
}
catch (OperationCanceledException)
{
}
catch (Exception exception)
{
LogIfCurrent(sessionId, "Observation session failed: " +
exception.GetType().Name + ": " + exception.Message);
}
finally
{
lock (SessionSync)
{
if (activeSessionId == sessionId)
{
activeTask = null;
activeCancellation = null;
}
}
cancellation.Dispose();
}
}
private static void CancelWithoutWaiting(CancellationTokenSource cancellation)
{
if (cancellation == null) return;
try
{
cancellation.Cancel();
}
catch (ObjectDisposedException)
{
}
}
private static string Format(double value)
{
return value.ToString("F3", CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,50 @@
# EM trajectory observation MovementTest
`TrajectoryObservationMovementTest` is an observe-only host for the real MDCS localization and chassis-speed read
interfaces. It bootstraps the coarse path and Local G2 reference once, repeatedly plans EM trajectories, samples the
latest published trajectory, and draws the world, L-S, and T-S/T-V layers. It does not drive, steer, brake, change gear,
or invoke a geometric vehicle controller.
Every runtime status contains `OBSERVE_ONLY: no chassis command is sent.` Treat the displayed control command as a
diagnostic prediction only. Goal and rolling-safety-stop commands are logged, never applied to hardware. At the end of a
gear-switch trajectory, the observer remains on direction segment `0` and waits for real direction confirmation; it does
not create or dispatch a direction-change action.
## Configuration
| Field | Unit | Default | Meaning |
| --- | --- | ---: | --- |
| `GoalXmm` | world mm | `NaN` | Goal X. If X or Y is not finite, the host prompts for X, Y, and yaw. |
| `GoalYmm` | world mm | `NaN` | Goal Y. |
| `GoalYawDeg` | world deg | `0` | Goal heading. |
| `MapPaddingMeters` | m | `2.0` | Padding added on all sides of the start/goal bounds. |
| `MapResolutionMm` | mm | `50` | Local occupancy-grid resolution. |
| `ReplanPeriodSeconds` | s | `0.20` | Minimum interval between EM planning cycles. |
| `ObserverPeriodSeconds` | s | `0.05` | Live-state sampling and redraw interval. |
The planning snapshot also uses the setup defaults: vehicle length `0.80 m`, vehicle width `0.60 m`, safety margin
`0.05 m`, and maximum curvature `1 / 1.20 m` (about `0.8333 1/m`). All fields and manual obstacles are read and frozen
before the background session starts. The live pose and actual longitudinal speed are then read once per observer tick.
## Manual obstacles
Enter a count from `0` through `20`, then select each obstacle type. Coordinates are global/world millimeters.
- Circle example: center `(2500, 1200) mm`, radius `300 mm`.
- Axis-aligned rectangle example: center `(4000, -500) mm`, X length `800 mm`, Y width `500 mm`.
The complete obstacle envelope must fit inside the start/goal bounds plus `MapPaddingMeters`; otherwise bootstrap is
rejected before EM planning.
## Reading the layers
- `TrajectoryObserver.World` shows map bounds and occupied cells, the frozen start and goal, Hybrid A* coarse path,
Local G2 smoothed path, current real pose, and latest published EM trajectory.
- `TrajectoryObserver.LS` plots reference path-S horizontally and lateral offset vertically. Projection failures indicate
trajectory points that could not be associated with the current direction segment.
- `TrajectoryObserver.ST` overlays time-to-path-S and time-to-signed-speed. Use it to check monotonic time/progress,
stop profiles, and the sign of forward/reverse velocity.
`EmTrajectoryPoint.VelocityX` and `VelocityY` are world-frame components. They are not chassis-frame velocity commands
and must never be forwarded directly to a vehicle motion interface. This MovementTest performs no coordinate conversion
for driving and has no driving capability; any future execution mode requires a separate safety-reviewed design.
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Facade;
@@ -14,6 +15,7 @@ internal static class TrajectoryObservationChecks
{
public static void Run()
{
VerifiesObservationSourceHasNoActuatorCalls();
VerifiesStartGoalBoundsUseOnlyConfiguredPadding();
RejectsObstacleOutsideConfiguredBounds();
VerifiesLsAndStUsePublishedTrajectoryData();
@@ -23,6 +25,30 @@ internal static class TrajectoryObservationChecks
FreezesBootstrapVehicleForRollingRequests();
}
private static void VerifiesObservationSourceHasNoActuatorCalls()
{
string sourceDirectory = Path.Combine(Directory.GetCurrentDirectory(), "ClumsyPilot",
"ParkrobTrajplanner", "tarjplanner_movementtest");
string movementTestSource = Path.Combine(sourceDirectory, "MovementTest.TrajectoryObservationTest.cs");
Verification.True(File.Exists(movementTestSource), "observation MovementTest source exists");
string[] forbiddenTokens =
{
".SendXYThSpeed(", ".SendMotion(", ".SendTh(", ".AccumulateSpeed(",
".ComputeWheelsGeometrically(", ".DriveStop(", ".PredefinedDriveStop("
};
string[] runtimeSources = Directory.GetFiles(sourceDirectory, "*.cs", SearchOption.TopDirectoryOnly);
for (int sourceIndex = 0; sourceIndex < runtimeSources.Length; sourceIndex++)
{
string source = File.ReadAllText(runtimeSources[sourceIndex]);
for (int tokenIndex = 0; tokenIndex < forbiddenTokens.Length; tokenIndex++)
{
Verification.True(source.IndexOf(forbiddenTokens[tokenIndex], StringComparison.Ordinal) < 0,
"observation runtime source excludes actuator token " + forbiddenTokens[tokenIndex]);
}
}
}
private static void VerifiesStartGoalBoundsUseOnlyConfiguredPadding()
{
var settings = new TrajectoryObservationSettings