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]); }