From 4fc0c91af512d85d0857bf9d20fc21cc06ca9f2b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:33:57 +0000 Subject: [PATCH] fix: spread RingBuffer.Resample repeats evenly and document nearest-neighbour [patch] Resample mapped the ends onto each other and rounded with Math.Round's default banker's rounding, so ties all went to the even index and some samples appeared three times while their neighbours appeared once (5 to 9 gave [0,0,1,2,2,2,3,4,4]). Each slot now takes the element under its centre, floor((i + 0.5) * oldCount / length), computed in exact long arithmetic so there are no floating-point ties and no overflow for large buffers. The XML doc now describes the nearest-neighbour selection instead of promising linear interpolation, and the test that claimed 10, 15, 20, 25, 30 asserts the full output. Fixes ktsu-dev/Containers#88 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dzcato9evJfMk7iLBAHZpo --- Containers.Test/RingBufferTests.cs | 48 +++++++++++++++++++++++++++--- Containers/RingBuffer.cs | 24 ++++++++------- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/Containers.Test/RingBufferTests.cs b/Containers.Test/RingBufferTests.cs index dfdb138..dc9ab8f 100644 --- a/Containers.Test/RingBufferTests.cs +++ b/Containers.Test/RingBufferTests.cs @@ -228,10 +228,50 @@ public void ResampleWithSpecificValues_CorrectlyInterpolates() // Resample to a larger size buffer.Resample(5); - // Should interpolate to approximately: 10, 15, 20, 25, 30 - Assert.AreEqual(10, buffer.At(0)); - Assert.AreEqual(20, buffer.At(2)); - Assert.AreEqual(30, buffer.At(4)); + // Nearest-neighbour, not interpolation: five slots over three samples, so two of them + // appear twice and one once. + Assert.AreSequenceEqual([10, 10, 20, 30, 30], buffer); + } + + [TestMethod] + [DataRow(new[] { 0, 1, 2, 3, 4 }, 9, new[] { 0, 0, 1, 1, 2, 3, 3, 4, 4 }, DisplayName = "5 to 9")] + [DataRow(new[] { 0, 10 }, 3, new[] { 0, 10, 10 }, DisplayName = "2 to 3")] + [DataRow(new[] { 0, 1, 2 }, 12, new[] { 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2 }, DisplayName = "3 to 12")] + [DataRow(new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 }, 3, new[] { 1, 4, 7 }, DisplayName = "9 to 3")] + public void Resample_PicksTheNearestSampleToEachSlotsCentre(int[] source, int length, int[] expected) + { + ArgumentNullException.ThrowIfNull(source); + RingBuffer buffer = new(source, source.Length); + + buffer.Resample(length); + + Assert.AreSequenceEqual(expected, buffer); + } + + [TestMethod] + public void Resample_Upsampling_RepeatsEverySampleEvenly() + { + for (int oldCount = 1; oldCount <= 12; oldCount++) + { + for (int length = oldCount; length <= 40; length++) + { + RingBuffer buffer = new(Enumerable.Range(0, oldCount), oldCount); + + buffer.Resample(length); + + int fewest = length / oldCount; + int most = (length + oldCount - 1) / oldCount; + foreach (IGrouping repeats in buffer.GroupBy(value => value)) + { + Assert.IsTrue( + repeats.Count() >= fewest && repeats.Count() <= most, + $"{oldCount} to {length}: sample {repeats.Key} appears {repeats.Count()} times, expected {fewest} to {most}."); + } + + Assert.AreEqual(0, buffer.Front(), $"{oldCount} to {length}: the first sample must come first."); + Assert.AreEqual(oldCount - 1, buffer.Back(), $"{oldCount} to {length}: the last sample must come last."); + } + } } [TestMethod] diff --git a/Containers/RingBuffer.cs b/Containers/RingBuffer.cs index 112ee55..fd46020 100644 --- a/Containers/RingBuffer.cs +++ b/Containers/RingBuffer.cs @@ -242,12 +242,15 @@ private static int NextPower2(int v) public void Resize(int length) => AllocateBuffer(length); /// - /// Resamples the buffer to a new length, interpolating or decimating the contents as needed. + /// Resamples the buffer to a new length, repeating or dropping elements as needed. /// /// - /// The resampling process preserves the data pattern by applying simple linear interpolation. - /// If the new length is smaller than the old length, data is decimated (some values are dropped). - /// If the new length is larger, data is interpolated (new values are created between existing ones). + /// Each new slot takes the existing element nearest to its centre (nearest-neighbour), since a + /// generic cannot be interpolated. No new values are created. + /// If the new length is larger than the old length, every element is repeated as evenly as the + /// ratio allows: each appears either the rounded-down or the rounded-up number of times, and the + /// first and last elements stay first and last. + /// If the new length is smaller, elements are decimated: evenly spaced ones are kept and the rest dropped. /// /// This is useful when you need to change the buffer size while preserving the overall pattern of the data. /// For example, resampling time-series data when changing the sampling rate. @@ -280,13 +283,12 @@ public void Resample(int length) // Resample the data into the new buffer for (int i = 0; i < length; i++) { - // Map the new index to the old data range - // Multiply in double: the int product overflows once length * oldCount passes int.MaxValue - double oldIndex = (double)i * (oldCount - 1) / Math.Max(length - 1, 1); - int index = (int)Math.Round(oldIndex); - - // Ensure we don't go out of bounds - index = Math.Min(index, oldCount - 1); + // Take the old element under the centre of new slot i: floor((i + 0.5) * oldCount / length). + // Sampling at the centre spreads repeats evenly where mapping the ends onto each other + // and rounding leaves ties that all round the same way. Doubling both sides keeps the + // arithmetic integral, so there are no floating-point ties, and the long product cannot + // overflow: (2 * i + 1) * oldCount stays below 2^63 for any int i and oldCount. + int index = (int)(((2L * i) + 1) * oldCount / (2L * length)); PushBack(oldData[index]); }