diff --git a/ClumsyPilot/ClumsyPilot.csproj b/ClumsyPilot/ClumsyPilot.csproj index fbd1869..9e3bc8f 100644 --- a/ClumsyPilot/ClumsyPilot.csproj +++ b/ClumsyPilot/ClumsyPilot.csproj @@ -12,11 +12,17 @@ + + + + + + ref\CommonUsage.dll diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/PlanningVisualizationSnapshots.cs b/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/PlanningVisualizationSnapshots.cs new file mode 100644 index 0000000..337286f --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/PlanningVisualizationSnapshots.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; + +namespace TrajectoryPlanningVisualization; + +public sealed class PlanningVisualizationStaticSnapshot +{ + public PlanningVisualizationStaticSnapshot(string sessionNameChinese, VisualizationBounds worldBounds, + VisualizationOccupancyGrid occupancyGrid, IReadOnlyList staticPolylines, + IReadOnlyList staticMarkers, + IReadOnlyList directionSegments, + IReadOnlyList configurationGroups) + { + SessionNameChinese = VisualizationContract.RequireText(sessionNameChinese, nameof(sessionNameChinese)); + WorldBounds = worldBounds ?? throw new ArgumentNullException(nameof(worldBounds)); + OccupancyGrid = occupancyGrid; + StaticPolylines = VisualizationContract.Copy(staticPolylines, nameof(staticPolylines)); + StaticMarkers = VisualizationContract.Copy(staticMarkers, nameof(staticMarkers)); + DirectionSegments = VisualizationContract.Copy(directionSegments, nameof(directionSegments)); + ConfigurationGroups = VisualizationContract.Copy(configurationGroups, nameof(configurationGroups)); + } + + public string SessionNameChinese { get; } + public VisualizationBounds WorldBounds { get; } + public VisualizationOccupancyGrid OccupancyGrid { get; } + public IReadOnlyList StaticPolylines { get; } + public IReadOnlyList StaticMarkers { get; } + public IReadOnlyList DirectionSegments { get; } + public IReadOnlyList ConfigurationGroups { get; } +} + +public sealed class PlanningVisualizationDynamicSnapshot +{ + public PlanningVisualizationDynamicSnapshot(long sequence, DateTimeOffset observedAtUtc, + string sessionStateChinese, int activeSegmentIndex, string activeDirection, + VisualizationPose vehiclePose, IReadOnlyList dynamicPolylines, + IReadOnlyList dynamicMarkers, IReadOnlyList charts, + IReadOnlyList statusValues, VisualizationCycleSummary cycleSummary) + { + Sequence = sequence; + ObservedAtUtc = observedAtUtc; + SessionStateChinese = VisualizationContract.RequireText(sessionStateChinese, nameof(sessionStateChinese)); + ActiveSegmentIndex = activeSegmentIndex; + ActiveDirection = VisualizationContract.RequireText(activeDirection, nameof(activeDirection)); + VehiclePose = vehiclePose ?? throw new ArgumentNullException(nameof(vehiclePose)); + DynamicPolylines = VisualizationContract.Copy(dynamicPolylines, nameof(dynamicPolylines)); + DynamicMarkers = VisualizationContract.Copy(dynamicMarkers, nameof(dynamicMarkers)); + Charts = VisualizationContract.Copy(charts, nameof(charts)); + StatusValues = VisualizationContract.Copy(statusValues, nameof(statusValues)); + CycleSummary = cycleSummary ?? throw new ArgumentNullException(nameof(cycleSummary)); + } + + public long Sequence { get; } + public DateTimeOffset ObservedAtUtc { get; } + public string SessionStateChinese { get; } + public int ActiveSegmentIndex { get; } + public string ActiveDirection { get; } + public VisualizationPose VehiclePose { get; } + public IReadOnlyList DynamicPolylines { get; } + public IReadOnlyList DynamicMarkers { get; } + public IReadOnlyList Charts { get; } + public IReadOnlyList StatusValues { get; } + public VisualizationCycleSummary CycleSummary { get; } +} + +public sealed class VisualizationCycleSummary +{ + public VisualizationCycleSummary(long cycleVersion, DateTimeOffset occurredAtUtc, string status, bool published, + double planningElapsedMilliseconds, int segmentIndex, string direction, string longitudinalMode, + string terminalType, double? terminalVelocity, double? terminalAcceleration, string failureReason) + { + VisualizationContract.RequireFinite(planningElapsedMilliseconds, nameof(planningElapsedMilliseconds)); + VisualizationContract.RequireFinite(terminalVelocity, nameof(terminalVelocity)); + VisualizationContract.RequireFinite(terminalAcceleration, nameof(terminalAcceleration)); + CycleVersion = cycleVersion; + OccurredAtUtc = occurredAtUtc; + Status = VisualizationContract.RequireText(status, nameof(status)); + Published = published; + PlanningElapsedMilliseconds = planningElapsedMilliseconds; + SegmentIndex = segmentIndex; + Direction = VisualizationContract.RequireText(direction, nameof(direction)); + LongitudinalMode = VisualizationContract.RequireText(longitudinalMode, nameof(longitudinalMode)); + TerminalType = VisualizationContract.RequireText(terminalType, nameof(terminalType)); + TerminalVelocity = terminalVelocity; + TerminalAcceleration = terminalAcceleration; + FailureReason = VisualizationContract.RequireText(failureReason, nameof(failureReason)); + } + + public long CycleVersion { get; } + public DateTimeOffset OccurredAtUtc { get; } + public string Status { get; } + public bool Published { get; } + public double PlanningElapsedMilliseconds { get; } + public int SegmentIndex { get; } + public string Direction { get; } + public string LongitudinalMode { get; } + public string TerminalType { get; } + public double? TerminalVelocity { get; } + public double? TerminalAcceleration { get; } + public string FailureReason { get; } +} diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/VisualizationCharts.cs b/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/VisualizationCharts.cs new file mode 100644 index 0000000..c829f98 --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/VisualizationCharts.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; + +namespace TrajectoryPlanningVisualization; + +public enum VisualizationLineStyle +{ + Solid, + Dashed, + Limit +} + +public sealed class VisualizationSeries +{ + public VisualizationSeries(string id, string legend, VisualizationLineStyle lineStyle, + IReadOnlyList points) + { + Id = VisualizationContract.RequireText(id, nameof(id)); + Legend = VisualizationContract.RequireText(legend, nameof(legend)); + LineStyle = lineStyle; + Points = VisualizationContract.Copy(points, nameof(points)); + } + + public string Id { get; } + public string Legend { get; } + public VisualizationLineStyle LineStyle { get; } + public IReadOnlyList Points { get; } +} + +public sealed class VisualizationChart +{ + public VisualizationChart(string id, string chineseTitle, string xAxisLabel, string yAxisLabel, + IReadOnlyList series, string noteChinese = "") + { + Id = VisualizationContract.RequireText(id, nameof(id)); + ChineseTitle = VisualizationContract.RequireText(chineseTitle, nameof(chineseTitle)); + XAxisLabel = VisualizationContract.RequireText(xAxisLabel, nameof(xAxisLabel)); + YAxisLabel = VisualizationContract.RequireText(yAxisLabel, nameof(yAxisLabel)); + Series = VisualizationContract.Copy(series, nameof(series)); + NoteChinese = VisualizationContract.RequireText(noteChinese, nameof(noteChinese)); + } + + public string Id { get; } + public string ChineseTitle { get; } + public string XAxisLabel { get; } + public string YAxisLabel { get; } + public IReadOnlyList Series { get; } + public string NoteChinese { get; } +} diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/VisualizationGeometry.cs b/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/VisualizationGeometry.cs new file mode 100644 index 0000000..a3b44a8 --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/VisualizationGeometry.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; + +namespace TrajectoryPlanningVisualization; + +public sealed class VisualizationPolyline +{ + public VisualizationPolyline(string id, string legendChinese, string kind, VisualizationLineStyle lineStyle, + IReadOnlyList points) + { + Id = VisualizationContract.RequireText(id, nameof(id)); + LegendChinese = VisualizationContract.RequireText(legendChinese, nameof(legendChinese)); + Kind = VisualizationContract.RequireText(kind, nameof(kind)); + LineStyle = lineStyle; + Points = VisualizationContract.Copy(points, nameof(points)); + } + + public string Id { get; } + public string LegendChinese { get; } + public string Kind { get; } + public VisualizationLineStyle LineStyle { get; } + public IReadOnlyList Points { get; } +} + +public sealed class VisualizationMarker +{ + public VisualizationMarker(string id, string kind, string labelChinese, VisualizationPoint position) + { + Id = VisualizationContract.RequireText(id, nameof(id)); + Kind = VisualizationContract.RequireText(kind, nameof(kind)); + LabelChinese = VisualizationContract.RequireText(labelChinese, nameof(labelChinese)); + Position = position ?? throw new ArgumentNullException(nameof(position)); + } + + public string Id { get; } + public string Kind { get; } + public string LabelChinese { get; } + public VisualizationPoint Position { get; } +} + +public sealed class VisualizationDirectionSegment +{ + public VisualizationDirectionSegment(int segmentIndex, string direction, bool startsAtGearSwitch, + bool endsAtGearSwitch, IReadOnlyList points) + { + SegmentIndex = segmentIndex; + Direction = VisualizationContract.RequireText(direction, nameof(direction)); + StartsAtGearSwitch = startsAtGearSwitch; + EndsAtGearSwitch = endsAtGearSwitch; + Points = VisualizationContract.Copy(points, nameof(points)); + } + + public int SegmentIndex { get; } + public string Direction { get; } + public bool StartsAtGearSwitch { get; } + public bool EndsAtGearSwitch { get; } + public IReadOnlyList Points { get; } +} + +public sealed class VisualizationOccupancyGrid +{ + public VisualizationOccupancyGrid(VisualizationBounds bounds, double resolutionMeters, int rows, int columns, + byte[] occupancyBits) + { + Bounds = bounds ?? throw new ArgumentNullException(nameof(bounds)); + VisualizationContract.RequireFinite(resolutionMeters, nameof(resolutionMeters)); + if (resolutionMeters <= 0d) + { + throw new ArgumentOutOfRangeException(nameof(resolutionMeters)); + } + + if (rows <= 0) + { + throw new ArgumentOutOfRangeException(nameof(rows)); + } + + if (columns <= 0) + { + throw new ArgumentOutOfRangeException(nameof(columns)); + } + + if (occupancyBits == null) + { + throw new ArgumentNullException(nameof(occupancyBits)); + } + + long cellCount = (long)rows * columns; + long requiredByteCount = (cellCount + 7L) / 8L; + if (occupancyBits.Length != requiredByteCount) + { + throw new ArgumentException("Occupancy bit count does not match the grid dimensions.", nameof(occupancyBits)); + } + + var bitsCopy = new byte[occupancyBits.Length]; + Buffer.BlockCopy(occupancyBits, 0, bitsCopy, 0, bitsCopy.Length); + ResolutionMeters = resolutionMeters; + Rows = rows; + Columns = columns; + OccupancyBitsBase64 = Convert.ToBase64String(bitsCopy); + } + + public VisualizationBounds Bounds { get; } + public double ResolutionMeters { get; } + public int Rows { get; } + public int Columns { get; } + public string OccupancyBitsBase64 { get; } +} diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/VisualizationPrimitives.cs b/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/VisualizationPrimitives.cs new file mode 100644 index 0000000..e8dfca6 --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Contracts/VisualizationPrimitives.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace TrajectoryPlanningVisualization; + +public sealed class VisualizationPoint +{ + public VisualizationPoint(double x, double y) + { + VisualizationContract.RequireFinite(x, nameof(x)); + VisualizationContract.RequireFinite(y, nameof(y)); + X = x; + Y = y; + } + + public double X { get; } + public double Y { get; } +} + +public sealed class VisualizationPose +{ + public VisualizationPose(double x, double y, double headingRadians) + { + VisualizationContract.RequireFinite(x, nameof(x)); + VisualizationContract.RequireFinite(y, nameof(y)); + VisualizationContract.RequireFinite(headingRadians, nameof(headingRadians)); + X = x; + Y = y; + HeadingRadians = headingRadians; + } + + public double X { get; } + public double Y { get; } + public double HeadingRadians { get; } +} + +public sealed class VisualizationBounds +{ + public VisualizationBounds(double xMin, double xMax, double yMin, double yMax) + { + VisualizationContract.RequireFinite(xMin, nameof(xMin)); + VisualizationContract.RequireFinite(xMax, nameof(xMax)); + VisualizationContract.RequireFinite(yMin, nameof(yMin)); + VisualizationContract.RequireFinite(yMax, nameof(yMax)); + if (xMax < xMin || yMax < yMin) + { + throw new ArgumentOutOfRangeException(nameof(xMax), "Bounds maxima must not precede minima."); + } + + XMin = xMin; + XMax = xMax; + YMin = yMin; + YMax = yMax; + } + + public double XMin { get; } + public double XMax { get; } + public double YMin { get; } + public double YMax { get; } +} + +public sealed class VisualizationValue +{ + public VisualizationValue(string chineseName, string rawName, string value, string unit, string severity) + { + ChineseName = VisualizationContract.RequireText(chineseName, nameof(chineseName)); + RawName = VisualizationContract.RequireText(rawName, nameof(rawName)); + Value = VisualizationContract.RequireText(value, nameof(value)); + Unit = VisualizationContract.RequireText(unit, nameof(unit)); + if (severity != "normal" && severity != "notice" && severity != "failure") + { + throw new ArgumentOutOfRangeException(nameof(severity), "Severity must be normal, notice, or failure."); + } + + Severity = severity; + } + + public string ChineseName { get; } + public string RawName { get; } + public string Value { get; } + public string Unit { get; } + public string Severity { get; } +} + +public sealed class VisualizationConfigurationGroup +{ + public VisualizationConfigurationGroup(string chineseTitle, IReadOnlyList entries) + { + ChineseTitle = VisualizationContract.RequireText(chineseTitle, nameof(chineseTitle)); + Entries = VisualizationContract.Copy(entries, nameof(entries)); + } + + public string ChineseTitle { get; } + public IReadOnlyList Entries { get; } +} + +internal static class VisualizationContract +{ + public static string RequireText(string value, string parameterName) + { + if (value == null) + { + throw new ArgumentNullException(parameterName); + } + + return value; + } + + public static void RequireFinite(double value, string parameterName) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + throw new ArgumentOutOfRangeException(parameterName, "Value must be finite."); + } + } + + public static void RequireFinite(double? value, string parameterName) + { + if (value.HasValue) + { + RequireFinite(value.Value, parameterName); + } + } + + public static IReadOnlyList Copy(IReadOnlyList values, string parameterName) + { + if (values == null) + { + throw new ArgumentNullException(parameterName); + } + + var copy = new List(values.Count); + for (var index = 0; index < values.Count; index++) + { + T value = values[index]; + if (value is null) + { + throw new ArgumentException("Collections cannot contain null elements.", parameterName); + } + + copy.Add(value); + } + + return new ReadOnlyCollection(copy); + } +} diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/TrajectoryPlanningVisualization.csproj b/ClumsyPilot/TrajectoryPlanningVisualization/TrajectoryPlanningVisualization.csproj new file mode 100644 index 0000000..942c267 --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/TrajectoryPlanningVisualization.csproj @@ -0,0 +1,9 @@ + + + netstandard2.0 + 10 + + + + + diff --git a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/ContractChecks.cs b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/ContractChecks.cs new file mode 100644 index 0000000..f969d55 --- /dev/null +++ b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/ContractChecks.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using TrajectoryPlanningVisualization; + +namespace TrajectoryPlanningVisualizationVerificationHost; + +internal static class ContractChecks +{ + public static void Run() + { + CopiesCollectionsAndRejectsNonFiniteNumbers(); + StoresOccupancyAsDefensiveCompactBits(); + ExposesImmutableSnapshotContracts(); + } + + private static void CopiesCollectionsAndRejectsNonFiniteNumbers() + { + var points = new List { new VisualizationPoint(1d, 2d) }; + var series = new VisualizationSeries("current", "当前轨迹", VisualizationLineStyle.Solid, points); + points[0] = new VisualizationPoint(9d, 9d); + + Verification.NearlyEqual(1d, series.Points[0].X, "series copies points"); + Verification.Throws( + () => new VisualizationPoint(double.NaN, 0d), "point rejects NaN"); + Verification.Throws( + () => new VisualizationPose(0d, 0d, double.PositiveInfinity), "pose rejects infinity"); + Verification.Throws( + () => new VisualizationBounds(0d, 1d, 0d, double.NaN), "bounds rejects NaN"); + Verification.Throws( + () => new VisualizationSeries("invalid", "无效", VisualizationLineStyle.Solid, + new VisualizationPoint[] { null! }), "series rejects null point"); + } + + private static void StoresOccupancyAsDefensiveCompactBits() + { + byte[] bits = { 0x01 }; + var grid = new VisualizationOccupancyGrid(new VisualizationBounds(0d, 1d, 0d, 1d), + 0.5d, rows: 2, columns: 2, bits); + bits[0] = 0x00; + + Verification.Equal("AQ==", grid.OccupancyBitsBase64, "occupancy grid copies compact bits"); + Verification.Throws( + () => new VisualizationOccupancyGrid(new VisualizationBounds(0d, 1d, 0d, 1d), + 0.5d, rows: 2, columns: 2, new byte[2]), "occupancy grid validates bit length"); + } + + private static void ExposesImmutableSnapshotContracts() + { + var staticSnapshot = new PlanningVisualizationStaticSnapshot( + "会话", + new VisualizationBounds(0d, 1d, 0d, 1d), + null, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty()); + var dynamicSnapshot = new PlanningVisualizationDynamicSnapshot( + 1L, + DateTimeOffset.UtcNow, + "运行中", + 0, + "forward", + new VisualizationPose(0d, 0d, 0d), + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + new VisualizationCycleSummary(1L, DateTimeOffset.UtcNow, "成功", true, 1d, 0, + "forward", "rolling", "none", null, null, "")); + + Verification.True(staticSnapshot.StaticPolylines.GetType().Name.Contains("ReadOnlyCollection"), + "static snapshot uses read-only collection"); + Verification.True(dynamicSnapshot.Charts.GetType().Name.Contains("ReadOnlyCollection"), + "dynamic snapshot uses read-only collection"); + AssertNoSetters(typeof(PlanningVisualizationStaticSnapshot)); + AssertNoSetters(typeof(PlanningVisualizationDynamicSnapshot)); + } + + private static void AssertNoSetters(Type type) + { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + Verification.True(property.SetMethod == null, type.Name + " exposes no property setters"); + } + } +} diff --git a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Program.cs b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Program.cs new file mode 100644 index 0000000..a4149e3 --- /dev/null +++ b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Program.cs @@ -0,0 +1,21 @@ +using System; + +namespace TrajectoryPlanningVisualizationVerificationHost; + +internal static class Program +{ + private static int Main() + { + try + { + ContractChecks.Run(); + Console.WriteLine("PASS trajectory-planning-visualization"); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + return 1; + } + } +} diff --git a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj new file mode 100644 index 0000000..fc78432 --- /dev/null +++ b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/TrajectoryPlanningVisualizationVerificationHost.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0-windows + 10 + + + + + diff --git a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Verification.cs b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Verification.cs new file mode 100644 index 0000000..5c0e1ad --- /dev/null +++ b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Verification.cs @@ -0,0 +1,45 @@ +using System; + +namespace TrajectoryPlanningVisualizationVerificationHost; + +internal static class Verification +{ + public static void Equal(T expected, T actual, string message) + { + if (!Equals(expected, actual)) + { + throw new InvalidOperationException(message + ": expected " + expected + ", actual " + actual); + } + } + + public static void NearlyEqual(double expected, double actual, string message) + { + if (Math.Abs(expected - actual) > 0.0000001d) + { + throw new InvalidOperationException(message + ": expected " + expected + ", actual " + actual); + } + } + + public static void True(bool value, string message) + { + if (!value) + { + throw new InvalidOperationException(message); + } + } + + public static void Throws(Action action, string message) + where TException : Exception + { + try + { + action(); + } + catch (TException) + { + return; + } + + throw new InvalidOperationException(message); + } +}