From ab8e4010f8d42dd8ddcd6066b82eaba7c163fc28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E8=96=84=E4=BA=91?= Date: Thu, 6 Aug 2026 09:27:58 +0800 Subject: [PATCH] feat: add bounded visualization snapshots --- .../Runtime/BoundedCycleHistory.cs | 55 ++++++++++ .../Runtime/LatestVisualizationFrameStore.cs | 46 ++++++++ .../Runtime/PlanningVisualizationOptions.cs | 45 ++++++++ .../Runtime/VisualizationJson.cs | 21 ++++ .../Program.cs | 1 + .../RuntimeChecks.cs | 101 ++++++++++++++++++ 6 files changed, 269 insertions(+) create mode 100644 ClumsyPilot/TrajectoryPlanningVisualization/Runtime/BoundedCycleHistory.cs create mode 100644 ClumsyPilot/TrajectoryPlanningVisualization/Runtime/LatestVisualizationFrameStore.cs create mode 100644 ClumsyPilot/TrajectoryPlanningVisualization/Runtime/PlanningVisualizationOptions.cs create mode 100644 ClumsyPilot/TrajectoryPlanningVisualization/Runtime/VisualizationJson.cs create mode 100644 ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/RuntimeChecks.cs diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/BoundedCycleHistory.cs b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/BoundedCycleHistory.cs new file mode 100644 index 0000000..3745c11 --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/BoundedCycleHistory.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace TrajectoryPlanningVisualization; + +public sealed class BoundedCycleHistory +{ + private readonly object sync = new object(); + private readonly int capacity; + private readonly Queue entries = new Queue(); + private readonly HashSet versions = new HashSet(); + + public BoundedCycleHistory(int capacity) + { + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + this.capacity = capacity; + } + + public void Add(VisualizationCycleSummary summary) + { + if (summary == null) + { + throw new ArgumentNullException(nameof(summary)); + } + + lock (sync) + { + if (!versions.Add(summary.CycleVersion)) + { + return; + } + + entries.Enqueue(summary); + while (entries.Count > capacity) + { + VisualizationCycleSummary removed = entries.Dequeue(); + versions.Remove(removed.CycleVersion); + } + } + } + + public IReadOnlyList Snapshot() + { + lock (sync) + { + return new ReadOnlyCollection( + new List(entries)); + } + } +} diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/LatestVisualizationFrameStore.cs b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/LatestVisualizationFrameStore.cs new file mode 100644 index 0000000..3e2c2b2 --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/LatestVisualizationFrameStore.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; + +namespace TrajectoryPlanningVisualization; + +public sealed class VisualizationFrame +{ + public VisualizationFrame(long version, PlanningVisualizationDynamicSnapshot snapshot) + { + Version = version; + Snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot)); + } + + public long Version { get; } + public PlanningVisualizationDynamicSnapshot Snapshot { get; } +} + +public sealed class LatestVisualizationFrameStore +{ + private VisualizationFrame latest; + private long nextVersion; + + public void Publish(PlanningVisualizationDynamicSnapshot snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + long version = Interlocked.Increment(ref nextVersion); + Interlocked.Exchange(ref latest, new VisualizationFrame(version, snapshot)); + } + + public bool TryReadAfter(long version, out VisualizationFrame frame) + { + VisualizationFrame current = Volatile.Read(ref latest); + if (current == null || current.Version <= version) + { + frame = null; + return false; + } + + frame = current; + return true; + } +} diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/PlanningVisualizationOptions.cs b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/PlanningVisualizationOptions.cs new file mode 100644 index 0000000..72d15cd --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/PlanningVisualizationOptions.cs @@ -0,0 +1,45 @@ +using System; + +namespace TrajectoryPlanningVisualization; + +public sealed class PlanningVisualizationOptions +{ + public int Port { get; set; } = 0; + public int RefreshRateHz { get; set; } = 10; + public int HistoryCycleLimit { get; set; } = 60; + + public PlanningVisualizationOptionsSnapshot CreateValidatedSnapshot() + { + if (Port < 0 || Port > 65535) + { + throw new ArgumentOutOfRangeException(nameof(Port), "Port must be in the range 0 to 65535."); + } + + if (RefreshRateHz <= 0) + { + throw new ArgumentOutOfRangeException(nameof(RefreshRateHz), "Refresh rate must be positive."); + } + + if (HistoryCycleLimit <= 0) + { + throw new ArgumentOutOfRangeException(nameof(HistoryCycleLimit), "History cycle limit must be positive."); + } + + return new PlanningVisualizationOptionsSnapshot(Port, RefreshRateHz, HistoryCycleLimit); + } +} + +public sealed class PlanningVisualizationOptionsSnapshot +{ + internal PlanningVisualizationOptionsSnapshot(int port, int refreshRateHz, int historyCycleLimit) + { + Port = port; + RefreshRateHz = refreshRateHz; + HistoryCycleLimit = historyCycleLimit; + } + + public int Port { get; } + public int RefreshRateHz { get; } + public int HistoryCycleLimit { get; } + public int MaximumClients => 2; +} diff --git a/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/VisualizationJson.cs b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/VisualizationJson.cs new file mode 100644 index 0000000..a2f7167 --- /dev/null +++ b/ClumsyPilot/TrajectoryPlanningVisualization/Runtime/VisualizationJson.cs @@ -0,0 +1,21 @@ +using System.Globalization; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace TrajectoryPlanningVisualization; + +public static class VisualizationJson +{ + private static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Culture = CultureInfo.InvariantCulture, + DateFormatHandling = DateFormatHandling.IsoDateFormat, + Formatting = Formatting.None + }; + + public static string Serialize(object value) + { + return JsonConvert.SerializeObject(value, Settings); + } +} diff --git a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Program.cs b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Program.cs index a4149e3..cb81ab3 100644 --- a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Program.cs +++ b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/Program.cs @@ -9,6 +9,7 @@ internal static class Program try { ContractChecks.Run(); + RuntimeChecks.Run(); Console.WriteLine("PASS trajectory-planning-visualization"); return 0; } diff --git a/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/RuntimeChecks.cs b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/RuntimeChecks.cs new file mode 100644 index 0000000..038014b --- /dev/null +++ b/ClumsyPilot/tests/TrajectoryPlanningVisualizationVerificationHost/RuntimeChecks.cs @@ -0,0 +1,101 @@ +using System; +using System.Linq; +using TrajectoryPlanningVisualization; + +namespace TrajectoryPlanningVisualizationVerificationHost; + +internal static class RuntimeChecks +{ + public static void Run() + { + KeepsOnlyTheLatestFrame(); + KeepsBoundedDeduplicatedCycleHistory(); + SerializesStableCamelCaseJson(); + RejectsInvalidOptions(); + } + + private static void KeepsOnlyTheLatestFrame() + { + var store = new LatestVisualizationFrameStore(); + store.Publish(Snap(1)); + store.Publish(Snap(2)); + + Verification.True(store.TryReadAfter(0, out VisualizationFrame frame), "latest frame exists"); + Verification.Equal(2L, frame.Snapshot.Sequence, "latest frame replaces old frame"); + Verification.True(!store.TryReadAfter(frame.Version, out _), "same frame is not replayed"); + } + + private static void KeepsBoundedDeduplicatedCycleHistory() + { + var history = new BoundedCycleHistory(2); + history.Add(Cycle(1)); + history.Add(Cycle(2)); + history.Add(Cycle(3)); + history.Add(Cycle(3)); + + Verification.Equal("2|3", string.Join("|", history.Snapshot().Select(x => x.CycleVersion)), + "history is bounded and deduplicated"); + } + + private static void SerializesStableCamelCaseJson() + { + string json = VisualizationJson.Serialize(Snap(2)); + Verification.True(json.Contains("\"sessionStateChinese\"") && json.Contains("\"observedAtUtc\""), + "JSON uses stable camel case names"); + } + + private static void RejectsInvalidOptions() + { + Verification.Throws( + () => new PlanningVisualizationOptions { Port = -1 }.CreateValidatedSnapshot(), + "options reject a negative port"); + Verification.Throws( + () => new PlanningVisualizationOptions { RefreshRateHz = 0 }.CreateValidatedSnapshot(), + "options reject a zero refresh rate"); + Verification.Throws( + () => new PlanningVisualizationOptions { HistoryCycleLimit = 0 }.CreateValidatedSnapshot(), + "options reject a zero history limit"); + Verification.Throws( + () => new PlanningVisualizationOptions { Port = 65536 }.CreateValidatedSnapshot(), + "options reject a port above the TCP range"); + + var snapshot = new PlanningVisualizationOptions().CreateValidatedSnapshot(); + Verification.Equal(0, snapshot.Port, "default port is automatic"); + Verification.Equal(10, snapshot.RefreshRateHz, "default refresh rate is 10 Hz"); + Verification.Equal(60, snapshot.HistoryCycleLimit, "default history is bounded to 60 cycles"); + Verification.Equal(2, snapshot.MaximumClients, "maximum clients is fixed at two"); + } + + private static PlanningVisualizationDynamicSnapshot Snap(long sequence) + { + return new PlanningVisualizationDynamicSnapshot( + sequence, + DateTimeOffset.Parse("2026-08-06T00:00:00+00:00").AddSeconds(sequence), + "运行中", + 0, + "forward", + new VisualizationPose(1d, 2d, 0d), + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + Cycle(sequence)); + } + + private static VisualizationCycleSummary Cycle(long cycleVersion) + { + return new VisualizationCycleSummary( + cycleVersion, + DateTimeOffset.Parse("2026-08-06T00:00:00+00:00").AddSeconds(cycleVersion), + "成功", + true, + 2.5d, + 0, + "forward", + "rolling", + "none", + null, + null, + ""); + } +}