├── logo.png ├── ManagedCode.TimeSeries ├── MarkupDirection.cs ├── Abstractions │ ├── Strategy.cs │ ├── ITimeSeries.cs │ ├── ISummerItem.cs │ ├── BaseGroupNumberTimeSeriesSummer.cs │ ├── BaseTimeSeriesByValueAccumulator.cs │ ├── BaseGroupTimeSeriesAccumulator.cs │ ├── BaseTimeSeriesAccumulator.cs │ ├── BaseTimeSeriesSummer.cs │ ├── BaseNumberTimeSeriesSummer.cs │ └── BaseTimeSeries.cs ├── Accumulators │ ├── StringTimeSeriesAccumulator.cs │ ├── IntTimeSeriesAccumulator.cs │ ├── FloatTimeSeriesAccumulator.cs │ ├── DoubleTimeSeriesAccumulator.cs │ ├── IntGroupTimeSeriesAccumulator.cs │ ├── FloatGroupTimeSeriesAccumulator.cs │ └── DoubleGroupTimeSeriesAccumulator.cs ├── Summers │ ├── DoubleGroupTimeSeriesSummer.cs │ ├── IntGroupNumberTimeSeriesSummer.cs │ ├── FloatGroupNumberTimeSeriesSummer.cs │ ├── IntTimeSeriesSummer.cs │ ├── FloatTimeSeriesSummer.cs │ ├── DoubleTimeSeriesSummer.cs │ ├── NumberTimeSeriesSummer.cs │ └── NumberGroupTimeSeriesSummer.cs ├── Extensions │ └── RoundDateTimeAndTimeSpanExtensions.cs └── ManagedCode.TimeSeries.csproj ├── ManagedCode.TimeSeries.Benchmark ├── Program.cs ├── ManagedCode.TimeSeries.Benchmark.csproj └── Benchmarks │ ├── Bench1.cs │ └── CollectionBenchmark.cs ├── ManagedCode.TimeSeries.slnx ├── ManagedCode.TimeSeries.Orleans ├── ManagedCode.TimeSeries.Orleans.csproj ├── Accumulators │ ├── TimeSeriesAccumulatorsSurrogate.cs │ └── Converters │ │ ├── FloatTimeSeriesAccumulatorConverter.cs │ │ ├── DoubleTimeSeriesAccumulatorConverter.cs │ │ └── IntTimeSeriesAccumulatorConverter.cs └── Summers │ ├── Converters │ ├── FloatTimeSeriesSummerConverter.cs │ ├── DoubleTimeSeriesSummerConverter.cs │ └── IntTimeSeriesSummerConverter.cs │ └── TimeSeriesSummerSurrogate.cs ├── Directory.Packages.props ├── LICENSE ├── .github └── workflows │ ├── dotnet.yml │ ├── codeql-analysis.yml │ └── release.yml ├── ManagedCode.TimeSeries.Tests ├── Assertions │ └── ShouldlyExtensions.cs ├── ManagedCode.TimeSeries.Tests.csproj ├── TimeSeriesAdvancedTests.cs ├── AccumulatorsTests.cs ├── SummersTests.cs └── TimeSeriesBehaviorTests.cs ├── Directory.Build.props ├── AGENTS.md ├── README.md └── .gitignore /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managedcode/TimeSeries/HEAD/logo.png -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/MarkupDirection.cs: -------------------------------------------------------------------------------- 1 | namespace ManagedCode.TimeSeries; 2 | 3 | public enum MarkupDirection 4 | { 5 | Middle, 6 | Past, 7 | Feature 8 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Benchmark/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection.Emit; 2 | using BenchmarkDotNet.Running; 3 | 4 | var summary = BenchmarkRunner.Run(typeof(Program).Assembly); -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Abstractions/Strategy.cs: -------------------------------------------------------------------------------- 1 | namespace ManagedCode.TimeSeries.Abstractions; 2 | 3 | public enum Strategy 4 | { 5 | Sum, 6 | Min, 7 | Max, 8 | Replace, 9 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Accumulators/StringTimeSeriesAccumulator.cs: -------------------------------------------------------------------------------- 1 | // namespace ManagedCode.TimeSeries.Accumulators; 2 | // 3 | // public class StringTimeSeriesAccumulator : BaseTimeSeriesByValueAccumulator 4 | // { 5 | // public StringTimeSeriesAccumulator(TimeSpan sampleInterval, int samplesCount = 0) : base(sampleInterval, samplesCount) 6 | // { 7 | // } 8 | // } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.slnx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Accumulators/IntTimeSeriesAccumulator.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Accumulators; 4 | 5 | public class IntTimeSeriesAccumulator : BaseTimeSeriesAccumulator 6 | { 7 | public IntTimeSeriesAccumulator(TimeSpan sampleInterval, int maxSamplesCount = 0) : base(sampleInterval, maxSamplesCount) 8 | { 9 | } 10 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Accumulators/FloatTimeSeriesAccumulator.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Accumulators; 4 | 5 | public class FloatTimeSeriesAccumulator : BaseTimeSeriesAccumulator 6 | { 7 | public FloatTimeSeriesAccumulator(TimeSpan sampleInterval, int maxSamplesCount = 0) : base(sampleInterval, maxSamplesCount) 8 | { 9 | } 10 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Accumulators/DoubleTimeSeriesAccumulator.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Accumulators; 4 | 5 | public class DoubleTimeSeriesAccumulator : BaseTimeSeriesAccumulator 6 | { 7 | public DoubleTimeSeriesAccumulator(TimeSpan sampleInterval, int maxSamplesCount = 0) : base(sampleInterval, maxSamplesCount) 8 | { 9 | } 10 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Summers/DoubleGroupTimeSeriesSummer.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Summers; 4 | 5 | public class DoubleGroupTimeSeriesSummer : NumberGroupTimeSeriesSummer 6 | { 7 | public DoubleGroupTimeSeriesSummer(TimeSpan sampleInterval, int samplesCount, Strategy strategy, bool deleteOverdueSamples) 8 | : base(sampleInterval, samplesCount, strategy, deleteOverdueSamples) 9 | { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Summers/IntGroupNumberTimeSeriesSummer.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Summers; 4 | 5 | public class IntGroupNumberTimeSeriesSummer : NumberGroupTimeSeriesSummer 6 | { 7 | public IntGroupNumberTimeSeriesSummer(TimeSpan sampleInterval, int samplesCount, Strategy strategy, bool deleteOverdueSamples) 8 | : base(sampleInterval, samplesCount, strategy, deleteOverdueSamples) 9 | { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Summers/FloatGroupNumberTimeSeriesSummer.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Summers; 4 | 5 | public class FloatGroupNumberTimeSeriesSummer : NumberGroupTimeSeriesSummer 6 | { 7 | public FloatGroupNumberTimeSeriesSummer(TimeSpan sampleInterval, int samplesCount, Strategy strategy, bool deleteOverdueSamples) 8 | : base(sampleInterval, samplesCount, strategy, deleteOverdueSamples) 9 | { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Accumulators/IntGroupTimeSeriesAccumulator.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Accumulators; 4 | 5 | public sealed class IntGroupTimeSeriesAccumulator : BaseGroupTimeSeriesAccumulator 6 | { 7 | public IntGroupTimeSeriesAccumulator(TimeSpan sampleInterval, int maxSamplesCount = 0, bool deleteOverdueSamples = true) 8 | : base(sampleInterval, deleteOverdueSamples, () => new IntTimeSeriesAccumulator(sampleInterval, maxSamplesCount)) 9 | { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Accumulators/FloatGroupTimeSeriesAccumulator.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Accumulators; 4 | 5 | public sealed class FloatGroupTimeSeriesAccumulator : BaseGroupTimeSeriesAccumulator 6 | { 7 | public FloatGroupTimeSeriesAccumulator(TimeSpan sampleInterval, int maxSamplesCount = 0, bool deleteOverdueSamples = true) 8 | : base(sampleInterval, deleteOverdueSamples, () => new FloatTimeSeriesAccumulator(sampleInterval, maxSamplesCount)) 9 | { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Accumulators/DoubleGroupTimeSeriesAccumulator.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Accumulators; 4 | 5 | public sealed class DoubleGroupTimeSeriesAccumulator : BaseGroupTimeSeriesAccumulator 6 | { 7 | public DoubleGroupTimeSeriesAccumulator(TimeSpan sampleInterval, int maxSamplesCount = 0, bool deleteOverdueSamples = true) 8 | : base(sampleInterval, deleteOverdueSamples, () => new DoubleTimeSeriesAccumulator(sampleInterval, maxSamplesCount)) 9 | { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Benchmark/ManagedCode.TimeSeries.Benchmark.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net9.0 6 | 13 7 | enable 8 | enable 9 | false 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Summers/IntTimeSeriesSummer.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Summers; 4 | 5 | public class IntTimeSeriesSummer : BaseNumberTimeSeriesSummer 6 | { 7 | public IntTimeSeriesSummer(TimeSpan sampleInterval, int maxSamplesCount, Strategy strategy) : base(sampleInterval, maxSamplesCount, strategy) 8 | { 9 | } 10 | 11 | public IntTimeSeriesSummer(TimeSpan sampleInterval, int maxSamplesCount) : base(sampleInterval, maxSamplesCount, Strategy.Sum) 12 | { 13 | } 14 | 15 | public IntTimeSeriesSummer(TimeSpan sampleInterval) : base(sampleInterval, 0, Strategy.Sum) 16 | { 17 | } 18 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Summers/FloatTimeSeriesSummer.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Summers; 4 | 5 | public class FloatTimeSeriesSummer : BaseNumberTimeSeriesSummer 6 | { 7 | public FloatTimeSeriesSummer(TimeSpan sampleInterval, int maxSamplesCount, Strategy strategy) : base(sampleInterval, maxSamplesCount, strategy) 8 | { 9 | } 10 | 11 | public FloatTimeSeriesSummer(TimeSpan sampleInterval, int maxSamplesCount) : base(sampleInterval, maxSamplesCount, Strategy.Sum) 12 | { 13 | } 14 | 15 | public FloatTimeSeriesSummer(TimeSpan sampleInterval) : base(sampleInterval, 0, Strategy.Sum) 16 | { 17 | } 18 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Summers/DoubleTimeSeriesSummer.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Abstractions; 2 | 3 | namespace ManagedCode.TimeSeries.Summers; 4 | 5 | public class DoubleTimeSeriesSummer : BaseNumberTimeSeriesSummer 6 | { 7 | public DoubleTimeSeriesSummer(TimeSpan sampleInterval, int maxSamplesCount, Strategy strategy) : base(sampleInterval, maxSamplesCount, strategy) 8 | { 9 | } 10 | 11 | public DoubleTimeSeriesSummer(TimeSpan sampleInterval, int maxSamplesCount) : base(sampleInterval, maxSamplesCount, Strategy.Sum) 12 | { 13 | } 14 | 15 | public DoubleTimeSeriesSummer(TimeSpan sampleInterval) : base(sampleInterval, 0, Strategy.Sum) 16 | { 17 | } 18 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Summers/NumberTimeSeriesSummer.cs: -------------------------------------------------------------------------------- 1 | using System.Numerics; 2 | using ManagedCode.TimeSeries.Abstractions; 3 | 4 | namespace ManagedCode.TimeSeries.Summers; 5 | 6 | public sealed class NumberTimeSeriesSummer : BaseNumberTimeSeriesSummer> 7 | where TNumber : struct, INumber 8 | { 9 | public NumberTimeSeriesSummer(TimeSpan sampleInterval, int maxSamplesCount, Strategy strategy) 10 | : base(sampleInterval, maxSamplesCount, strategy) 11 | { 12 | } 13 | 14 | public NumberTimeSeriesSummer(TimeSpan sampleInterval, int maxSamplesCount) 15 | : this(sampleInterval, maxSamplesCount, Strategy.Sum) 16 | { 17 | } 18 | 19 | public NumberTimeSeriesSummer(TimeSpan sampleInterval) 20 | : this(sampleInterval, 0, Strategy.Sum) 21 | { 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Abstractions/ITimeSeries.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.Contracts; 2 | 3 | namespace ManagedCode.TimeSeries.Abstractions; 4 | 5 | public interface ITimeSeries where TSelf : ITimeSeries 6 | { 7 | void AddNewData(T data); 8 | void AddNewData(DateTimeOffset dateTimeOffset, T data); 9 | 10 | void DeleteOverdueSamples(); 11 | void MarkupAllSamples(MarkupDirection direction = MarkupDirection.Past); 12 | 13 | [Pure] 14 | TSelf Rebase(TSelf accumulator); 15 | 16 | [Pure] 17 | TSelf Rebase(IEnumerable accumulators); 18 | 19 | void Merge(TSelf accumulator); 20 | void Merge(IEnumerable accumulators); 21 | 22 | void Resample(TimeSpan sampleInterval, int samplesCount); 23 | 24 | [Pure] 25 | static abstract TSelf Empty(TimeSpan? sampleInterval = null, int maxSamplesCount = 0); 26 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Orleans/ManagedCode.TimeSeries.Orleans.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | 13 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | ManagedCode.TimeSeries.Orleans 13 | ManagedCode.TimeSeries.Orleans 14 | TimeSeries 15 | managedcode, TimeSeries, Orleans 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Benchmark/Benchmarks/Bench1.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using ManagedCode.TimeSeries.Accumulators; 3 | 4 | namespace ManagedCode.TimeSeries.Benchmark.Benchmarks; 5 | 6 | 7 | [SimpleJob] 8 | [MemoryDiagnoser] 9 | public class Bench1 10 | { 11 | [Benchmark] 12 | public async Task Int_1000() 13 | { 14 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(50)); 15 | for (var i = 0; i < 1000; i++) 16 | { 17 | series.AddNewData(i); 18 | } 19 | } 20 | 21 | [Benchmark] 22 | public async Task Int_100_000() 23 | { 24 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(50)); 25 | for (var i = 0; i < 100_000; i++) 26 | { 27 | series.AddNewData(i); 28 | } 29 | } 30 | 31 | [Benchmark] 32 | public async Task Int_10_000_000() 33 | { 34 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(50)); 35 | for (var i = 0; i < 10_000_000; i++) 36 | { 37 | series.AddNewData(i); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Extensions/RoundDateTimeAndTimeSpanExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace ManagedCode.TimeSeries.Extensions; 2 | 3 | public static class RoundDateTimeAndTimeSpanExtensions 4 | { 5 | public static TimeSpan Round(this TimeSpan time, TimeSpan roundingInterval, MidpointRounding roundingType) 6 | { 7 | return new TimeSpan(Convert.ToInt64(Math.Round(time.Ticks / (decimal)roundingInterval.Ticks, roundingType)) * roundingInterval.Ticks); 8 | } 9 | 10 | public static TimeSpan Round(this TimeSpan time, TimeSpan roundingInterval) 11 | { 12 | return Round(time, roundingInterval, MidpointRounding.ToEven); 13 | } 14 | 15 | public static DateTime Round(this DateTime datetime, TimeSpan roundingInterval) 16 | { 17 | return new DateTime((datetime - DateTime.MinValue).Round(roundingInterval).Ticks); 18 | } 19 | 20 | public static DateTimeOffset Round(this DateTimeOffset dateTimeOffset, TimeSpan roundingInterval) 21 | { 22 | var datetime = dateTimeOffset.UtcDateTime.Round(roundingInterval); 23 | 24 | return new DateTimeOffset(datetime, dateTimeOffset.Offset); 25 | } 26 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Managed-Code 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/ManagedCode.TimeSeries.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | 13 6 | enable 7 | enable 8 | true 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | true 17 | GeneratedCodeAttribute 18 | [*]*.Migrations.* 19 | **/MyFile.cs 20 | lcov 21 | 22 | 23 | 24 | 25 | ManagedCode.TimeSeries 26 | ManagedCode.TimeSeries 27 | TimeSeries 28 | managedcode, TimeSeries 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /.github/workflows/dotnet.yml: -------------------------------------------------------------------------------- 1 | name: .NET 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | jobs: 14 | 15 | build-and-test: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | 20 | - uses: actions/checkout@v5 21 | - name: Setup .NET 22 | uses: actions/setup-dotnet@v4 23 | with: 24 | dotnet-version: 9.0.x 25 | 26 | # run build and test 27 | - name: Restore dependencies 28 | run: dotnet restore ManagedCode.TimeSeries.slnx 29 | - name: Build 30 | run: dotnet build ManagedCode.TimeSeries.slnx --configuration Release --no-restore 31 | - name: Test and collect code coverage 32 | run: dotnet test ManagedCode.TimeSeries.Tests/ManagedCode.TimeSeries.Tests.csproj --configuration Release --no-build -p:CollectCoverage=true -p:CoverletOutputFormat=lcov -p:CoverletOutput=./ 33 | - name: coveralls 34 | uses: coverallsapp/github-action@master 35 | with: 36 | github-token: ${{secrets.GITHUB_TOKEN }} 37 | path-to-lcov: ManagedCode.TimeSeries.Tests/coverage.info 38 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Abstractions/ISummerItem.cs: -------------------------------------------------------------------------------- 1 | using System.Numerics; 2 | 3 | namespace ManagedCode.TimeSeries.Abstractions; 4 | 5 | public interface ISummerItem : 6 | IUnaryNegationOperators, 7 | IAdditionOperators 8 | where TSelf : ISummerItem 9 | { 10 | /// Gets the radix, or base, for the type. 11 | static abstract TSelf Zero { get; } 12 | 13 | /// Gets the value 1 for the type. 14 | static abstract TSelf One { get; } 15 | 16 | /// Compares two values to compute which is lesser. 17 | /// The value to compare with . 18 | /// The value to compare with . 19 | /// if it is less than ; otherwise, . 20 | static abstract TSelf Min(TSelf x, TSelf y); 21 | 22 | /// Compares two values to compute which is greater. 23 | /// The value to compare with . 24 | /// The value to compare with . 25 | /// if it is greater than ; otherwise, . 26 | static abstract TSelf Max(TSelf x, TSelf y); 27 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Orleans/Accumulators/TimeSeriesAccumulatorsSurrogate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Numerics; 4 | using System.Runtime.InteropServices.JavaScript; 5 | using ManagedCode.TimeSeries.Abstractions; 6 | using Orleans; 7 | 8 | namespace ManagedCode.TimeSeries.Orleans; 9 | 10 | // This is the surrogate which will act as a stand-in for the foreign type. 11 | // Surrogates should use plain fields instead of properties for better perfomance. 12 | [Immutable] 13 | [GenerateSerializer] 14 | public struct TimeSeriesAccumulatorsSurrogate 15 | { 16 | public TimeSeriesAccumulatorsSurrogate(Dictionary> samples, 17 | DateTimeOffset start, 18 | DateTimeOffset end, 19 | TimeSpan sampleInterval, 20 | int maxSamplesCount, 21 | DateTimeOffset lastDate, 22 | ulong dataCount) 23 | { 24 | Samples = samples; 25 | Start = start; 26 | End = end; 27 | SampleInterval = sampleInterval; 28 | MaxSamplesCount = maxSamplesCount; 29 | LastDate = lastDate; 30 | DataCount = dataCount; 31 | } 32 | 33 | [Id(0)] 34 | public Dictionary> Samples; 35 | [Id(1)] 36 | public DateTimeOffset Start; 37 | [Id(2)] 38 | public DateTimeOffset End; 39 | [Id(3)] 40 | public TimeSpan SampleInterval; 41 | [Id(4)] 42 | public int MaxSamplesCount; 43 | [Id(5)] 44 | public DateTimeOffset LastDate; 45 | [Id(6)] 46 | public ulong DataCount; 47 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Tests/Assertions/ShouldlyExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Shouldly; 5 | 6 | namespace ManagedCode.TimeSeries.Tests.Assertions; 7 | 8 | internal static class ShouldlyExtensions 9 | { 10 | public static void ShouldHaveCount(this IEnumerable source, int expected) 11 | { 12 | source.Count().ShouldBe(expected); 13 | } 14 | 15 | public static void ShouldHaveCount(this IReadOnlyDictionary source, int expected) 16 | { 17 | source.Count.ShouldBe(expected); 18 | } 19 | 20 | public static void ShouldBeInAscendingOrder(this IEnumerable source) where T : IComparable 21 | { 22 | var values = source as T[] ?? source.ToArray(); 23 | var ordered = values.OrderBy(static value => value).ToArray(); 24 | values.ShouldBe(ordered); 25 | } 26 | 27 | public static void ShouldContainKeys(this IReadOnlyDictionary source, params TKey[] keys) 28 | { 29 | foreach (var key in keys) 30 | { 31 | source.ContainsKey(key).ShouldBeTrue(); 32 | } 33 | } 34 | 35 | public static void ShouldBeCloseTo(this DateTimeOffset actual, DateTimeOffset expected, TimeSpan tolerance) 36 | { 37 | (actual - expected).Duration().ShouldBeLessThanOrEqualTo(tolerance); 38 | } 39 | 40 | public static void ShouldSequenceEqual(this IEnumerable actual, params T[] expected) 41 | { 42 | actual.ToArray().ShouldBe(expected); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Orleans/Summers/Converters/FloatTimeSeriesSummerConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using ManagedCode.TimeSeries.Summers; 4 | using Orleans; 5 | 6 | namespace ManagedCode.TimeSeries.Orleans; 7 | 8 | [RegisterConverter] 9 | public sealed class FloatTimeSeriesSummerConverter : IConverter> 10 | { 11 | public FloatTimeSeriesSummer ConvertFromSurrogate(in TimeSeriesSummerSurrogate surrogate) 12 | { 13 | var series = new FloatTimeSeriesSummer(surrogate.SampleInterval, surrogate.MaxSamplesCount, surrogate.Strategy); 14 | var converted = new Dictionary(surrogate.Samples.Count); 15 | 16 | foreach (var pair in surrogate.Samples) 17 | { 18 | converted[pair.Key] = pair.Value; 19 | } 20 | 21 | series.InitInternal(converted, surrogate.Start, surrogate.End, surrogate.LastDate, surrogate.DataCount); 22 | return series; 23 | } 24 | 25 | public TimeSeriesSummerSurrogate ConvertToSurrogate(in FloatTimeSeriesSummer value) 26 | { 27 | var samples = value.Samples; 28 | var converted = new Dictionary(samples.Count); 29 | 30 | foreach (var pair in samples) 31 | { 32 | converted[pair.Key] = pair.Value; 33 | } 34 | 35 | return new TimeSeriesSummerSurrogate(converted, value.Start, value.End, 36 | value.SampleInterval, value.MaxSamplesCount, value.LastDate, value.DataCount, value.Strategy); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Orleans/Summers/Converters/DoubleTimeSeriesSummerConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using ManagedCode.TimeSeries.Summers; 4 | using Orleans; 5 | 6 | namespace ManagedCode.TimeSeries.Orleans; 7 | 8 | [RegisterConverter] 9 | public sealed class DoubleTimeSeriesSummerConverter : IConverter> 10 | { 11 | public DoubleTimeSeriesSummer ConvertFromSurrogate(in TimeSeriesSummerSurrogate surrogate) 12 | { 13 | var series = new DoubleTimeSeriesSummer(surrogate.SampleInterval, surrogate.MaxSamplesCount, surrogate.Strategy); 14 | var converted = new Dictionary(surrogate.Samples.Count); 15 | 16 | foreach (var pair in surrogate.Samples) 17 | { 18 | converted[pair.Key] = pair.Value; 19 | } 20 | 21 | series.InitInternal(converted, surrogate.Start, surrogate.End, surrogate.LastDate, surrogate.DataCount); 22 | return series; 23 | } 24 | 25 | public TimeSeriesSummerSurrogate ConvertToSurrogate(in DoubleTimeSeriesSummer value) 26 | { 27 | var samples = value.Samples; 28 | var converted = new Dictionary(samples.Count); 29 | 30 | foreach (var pair in samples) 31 | { 32 | converted[pair.Key] = pair.Value; 33 | } 34 | 35 | return new TimeSeriesSummerSurrogate(converted, value.Start, value.End, 36 | value.SampleInterval, value.MaxSamplesCount, value.LastDate, value.DataCount, value.Strategy); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Orleans/Summers/TimeSeriesSummerSurrogate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Numerics; 4 | using System.Runtime.InteropServices.JavaScript; 5 | using ManagedCode.TimeSeries.Abstractions; 6 | using Orleans; 7 | 8 | namespace ManagedCode.TimeSeries.Orleans; 9 | 10 | // This is the surrogate which will act as a stand-in for the foreign type. 11 | // Surrogates should use plain fields instead of properties for better perfomance. 12 | [Immutable] 13 | [GenerateSerializer] 14 | public struct TimeSeriesSummerSurrogate 15 | { 16 | public TimeSeriesSummerSurrogate(Dictionary samples, 17 | DateTimeOffset start, 18 | DateTimeOffset end, 19 | TimeSpan sampleInterval, 20 | int maxSamplesCount, 21 | DateTimeOffset lastDate, 22 | ulong dataCount, 23 | Strategy strategy) 24 | { 25 | Samples = samples; 26 | Start = start; 27 | End = end; 28 | SampleInterval = sampleInterval; 29 | MaxSamplesCount = maxSamplesCount; 30 | LastDate = lastDate; 31 | DataCount = dataCount; 32 | Strategy = strategy; 33 | } 34 | 35 | [Id(0)] 36 | public Dictionary Samples; 37 | [Id(1)] 38 | public DateTimeOffset Start; 39 | [Id(2)] 40 | public DateTimeOffset End; 41 | [Id(3)] 42 | public TimeSpan SampleInterval; 43 | [Id(4)] 44 | public int MaxSamplesCount; 45 | [Id(5)] 46 | public DateTimeOffset LastDate; 47 | [Id(6)] 48 | public ulong DataCount; 49 | [Id(7)] 50 | public Strategy Strategy; 51 | 52 | } -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Orleans/Summers/Converters/IntTimeSeriesSummerConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using ManagedCode.TimeSeries.Summers; 4 | using Orleans; 5 | 6 | namespace ManagedCode.TimeSeries.Orleans; 7 | 8 | // This is a converter which converts between the surrogate and the foreign type. 9 | [RegisterConverter] 10 | public sealed class IntTimeSeriesSummerConverter : IConverter> 11 | { 12 | public IntTimeSeriesSummer ConvertFromSurrogate(in TimeSeriesSummerSurrogate surrogate) 13 | { 14 | var series = new IntTimeSeriesSummer(surrogate.SampleInterval, surrogate.MaxSamplesCount, surrogate.Strategy); 15 | var converted = new Dictionary(surrogate.Samples.Count); 16 | 17 | foreach (var pair in surrogate.Samples) 18 | { 19 | converted[pair.Key] = pair.Value; 20 | } 21 | 22 | series.InitInternal(converted, surrogate.Start, surrogate.End, surrogate.LastDate, surrogate.DataCount); 23 | return series; 24 | } 25 | 26 | public TimeSeriesSummerSurrogate ConvertToSurrogate(in IntTimeSeriesSummer value) 27 | { 28 | var samples = value.Samples; 29 | var converted = new Dictionary(samples.Count); 30 | 31 | foreach (var pair in samples) 32 | { 33 | converted[pair.Key] = pair.Value; 34 | } 35 | 36 | return new TimeSeriesSummerSurrogate(converted, value.Start, value.End, 37 | value.SampleInterval, value.MaxSamplesCount, value.LastDate, value.DataCount, value.Strategy); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Tests/ManagedCode.TimeSeries.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | 13 6 | enable 7 | enable 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | all 23 | 24 | 25 | runtime; build; native; contentfiles; analyzers; buildtransitive 26 | all 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Orleans/Accumulators/Converters/FloatTimeSeriesAccumulatorConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using ManagedCode.TimeSeries.Accumulators; 5 | using Orleans; 6 | 7 | namespace ManagedCode.TimeSeries.Orleans; 8 | 9 | [RegisterConverter] 10 | public sealed class FloatTimeSeriesAccumulatorConverter : IConverter> 11 | { 12 | public FloatTimeSeriesAccumulator ConvertFromSurrogate(in TimeSeriesAccumulatorsSurrogate surrogate) 13 | { 14 | var series = new FloatTimeSeriesAccumulator(surrogate.SampleInterval, surrogate.MaxSamplesCount); 15 | var converted = new Dictionary>(surrogate.Samples.Count); 16 | 17 | foreach (var pair in surrogate.Samples) 18 | { 19 | converted[pair.Key] = new ConcurrentQueue(pair.Value); 20 | } 21 | 22 | series.InitInternal(converted, surrogate.Start, surrogate.End, surrogate.LastDate, surrogate.DataCount); 23 | return series; 24 | } 25 | 26 | public TimeSeriesAccumulatorsSurrogate ConvertToSurrogate(in FloatTimeSeriesAccumulator value) 27 | { 28 | var samples = value.Samples; 29 | var converted = new Dictionary>(samples.Count); 30 | 31 | foreach (var pair in samples) 32 | { 33 | converted[pair.Key] = new Queue(pair.Value); 34 | } 35 | 36 | return new TimeSeriesAccumulatorsSurrogate(converted, value.Start, value.End, 37 | value.SampleInterval, value.MaxSamplesCount, value.LastDate, value.DataCount); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Orleans/Accumulators/Converters/DoubleTimeSeriesAccumulatorConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using ManagedCode.TimeSeries.Accumulators; 5 | using Orleans; 6 | 7 | namespace ManagedCode.TimeSeries.Orleans; 8 | 9 | [RegisterConverter] 10 | public sealed class DoubleTimeSeriesAccumulatorConverter : IConverter> 11 | { 12 | public DoubleTimeSeriesAccumulator ConvertFromSurrogate(in TimeSeriesAccumulatorsSurrogate surrogate) 13 | { 14 | var series = new DoubleTimeSeriesAccumulator(surrogate.SampleInterval, surrogate.MaxSamplesCount); 15 | var converted = new Dictionary>(surrogate.Samples.Count); 16 | 17 | foreach (var pair in surrogate.Samples) 18 | { 19 | converted[pair.Key] = new ConcurrentQueue(pair.Value); 20 | } 21 | 22 | series.InitInternal(converted, surrogate.Start, surrogate.End, surrogate.LastDate, surrogate.DataCount); 23 | return series; 24 | } 25 | 26 | public TimeSeriesAccumulatorsSurrogate ConvertToSurrogate(in DoubleTimeSeriesAccumulator value) 27 | { 28 | var samples = value.Samples; 29 | var converted = new Dictionary>(samples.Count); 30 | 31 | foreach (var pair in samples) 32 | { 33 | converted[pair.Key] = new Queue(pair.Value); 34 | } 35 | 36 | return new TimeSeriesAccumulatorsSurrogate(converted, value.Start, value.End, 37 | value.SampleInterval, value.MaxSamplesCount, value.LastDate, value.DataCount); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Orleans/Accumulators/Converters/IntTimeSeriesAccumulatorConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using ManagedCode.TimeSeries.Abstractions; 5 | using ManagedCode.TimeSeries.Accumulators; 6 | using ManagedCode.TimeSeries.Summers; 7 | using Orleans; 8 | 9 | namespace ManagedCode.TimeSeries.Orleans; 10 | 11 | // This is a converter which converts between the surrogate and the foreign type. 12 | [RegisterConverter] 13 | public sealed class IntTimeSeriesAccumulatorConverter : IConverter> 14 | { 15 | public IntTimeSeriesAccumulator ConvertFromSurrogate(in TimeSeriesAccumulatorsSurrogate surrogate) 16 | { 17 | var series = new IntTimeSeriesAccumulator(surrogate.SampleInterval, surrogate.MaxSamplesCount); 18 | var converted = new Dictionary>(surrogate.Samples.Count); 19 | 20 | foreach (var pair in surrogate.Samples) 21 | { 22 | converted[pair.Key] = new ConcurrentQueue(pair.Value); 23 | } 24 | 25 | series.InitInternal(converted, surrogate.Start, surrogate.End, surrogate.LastDate, surrogate.DataCount); 26 | return series; 27 | } 28 | 29 | public TimeSeriesAccumulatorsSurrogate ConvertToSurrogate(in IntTimeSeriesAccumulator value) 30 | { 31 | var samples = value.Samples; 32 | var converted = new Dictionary>(samples.Count); 33 | 34 | foreach (var pair in samples) 35 | { 36 | converted[pair.Key] = new Queue(pair.Value); 37 | } 38 | 39 | return new TimeSeriesAccumulatorsSurrogate(converted, value.Start, value.End, 40 | value.SampleInterval, value.MaxSamplesCount, value.LastDate, value.DataCount); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ManagedCode 5 | Copyright © 2021-$([System.DateTime]::Now.ToString(`yyyy`)) ManagedCode SAS 6 | true 7 | true 8 | true 9 | snupkg 10 | Github 11 | $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb 12 | logo.png 13 | MIT 14 | true 15 | README.md 16 | 17 | https://github.com/managedcode/TimeSeries 18 | https://github.com/managedcode/TimeSeries 19 | Managed Code - TimeSeries 20 | 0.0.20 21 | 0.0.20 22 | 23 | 24 | 25 | true 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | all 34 | runtime; build; native; contentfiles; analyzers; buildtransitive 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Abstractions/BaseGroupNumberTimeSeriesSummer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Collections.Generic; 3 | using System.Numerics; 4 | using System.Threading; 5 | 6 | namespace ManagedCode.TimeSeries.Abstractions; 7 | 8 | public abstract class BaseGroupNumberTimeSeriesSummer : IDisposable 9 | where TSummer : BaseNumberTimeSeriesSummer 10 | where TNumber : struct, INumber 11 | where TSelf : BaseGroupNumberTimeSeriesSummer 12 | { 13 | internal readonly Timer? _timer; 14 | public readonly ConcurrentDictionary TimeSeries = new(); 15 | 16 | protected BaseGroupNumberTimeSeriesSummer(TimeSpan sampleInterval, bool deleteOverdueSamples) 17 | { 18 | _timer = deleteOverdueSamples ? new Timer(Callback, null, sampleInterval, sampleInterval) : null; 19 | } 20 | 21 | private void Callback(object? state) 22 | { 23 | foreach (var (key, summer) in TimeSeries.ToArray()) 24 | { 25 | summer.DeleteOverdueSamples(); 26 | if (summer.IsEmpty) 27 | { 28 | TimeSeries.TryRemove(key, out _); 29 | } 30 | } 31 | } 32 | 33 | public virtual void AddNewData(string key, TNumber value) 34 | { 35 | var summer = TimeSeries.GetOrAdd(key, _ => CreateSummer()); 36 | summer.AddNewData(value); 37 | } 38 | 39 | public virtual void Increment(string key) 40 | { 41 | var summer = TimeSeries.GetOrAdd(key, _ => CreateSummer()); 42 | summer.Increment(); 43 | } 44 | 45 | public virtual void Decrement(string key) 46 | { 47 | var summer = TimeSeries.GetOrAdd(key, _ => CreateSummer()); 48 | summer.Decrement(); 49 | } 50 | 51 | public abstract TNumber Average(); 52 | 53 | public abstract TNumber Min(); 54 | 55 | public abstract TNumber Max(); 56 | 57 | public abstract TNumber Sum(); 58 | 59 | protected abstract TSummer CreateSummer(); 60 | 61 | public void Dispose() 62 | { 63 | _timer?.Dispose(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '35 11 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'csharp' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Abstractions/BaseTimeSeriesByValueAccumulator.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | 3 | namespace ManagedCode.TimeSeries.Abstractions; 4 | 5 | public abstract class BaseTimeSeriesByValueAccumulator : BaseTimeSeries, TSelf> 6 | where T : notnull 7 | where TSelf : BaseTimeSeries, TSelf> 8 | { 9 | protected BaseTimeSeriesByValueAccumulator(TimeSpan sampleInterval, int samplesCount) : base(sampleInterval, samplesCount) 10 | { 11 | } 12 | 13 | protected BaseTimeSeriesByValueAccumulator(TimeSpan sampleInterval, int samplesCount, DateTimeOffset start, DateTimeOffset end, 14 | DateTimeOffset lastDate) 15 | : base(sampleInterval, samplesCount, start, end, lastDate) 16 | { 17 | } 18 | 19 | protected override void AddData(DateTimeOffset date, T data) 20 | { 21 | var set = GetOrCreateSample(date, static () => new ConcurrentDictionary()); 22 | set.TryAdd(data, 0); 23 | } 24 | 25 | // public BaseTimeSeriesByValueAccumulator Trim() 26 | // { 27 | // TrimStart(); 28 | // TrimEnd(); 29 | // return this; 30 | // } 31 | // 32 | // public BaseTimeSeriesByValueAccumulator TrimStart() 33 | // { 34 | // foreach (var item in Samples.ToArray()) 35 | // { 36 | // if (item.Value.Count > 0) 37 | // { 38 | // break; 39 | // } 40 | // 41 | // Samples.Remove(item.Key); 42 | // } 43 | // 44 | // return this; 45 | // } 46 | // 47 | // public BaseTimeSeriesByValueAccumulator TrimEnd() 48 | // { 49 | // foreach (var item in Samples.Reverse().ToArray()) 50 | // { 51 | // if (item.Value.Count > 0) 52 | // { 53 | // break; 54 | // } 55 | // 56 | // Samples.Remove(item.Key); 57 | // } 58 | // 59 | // return this; 60 | // } 61 | 62 | public override void Merge(TSelf accumulator) 63 | { 64 | if (accumulator is null) 65 | { 66 | return; 67 | } 68 | 69 | AddToDataCount(accumulator.DataCount); 70 | if (accumulator.LastDate > LastDate) 71 | { 72 | LastDate = accumulator.LastDate; 73 | } 74 | 75 | foreach (var sample in accumulator.Samples) 76 | { 77 | var set = GetOrCreateSample(sample.Key, static () => new ConcurrentDictionary()); 78 | foreach (var key in sample.Value.Keys) 79 | { 80 | set.TryAdd(key, 0); 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Abstractions/BaseGroupTimeSeriesAccumulator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Threading; 5 | 6 | namespace ManagedCode.TimeSeries.Abstractions; 7 | 8 | public abstract class BaseGroupTimeSeriesAccumulator : IDisposable 9 | where TAccumulator : BaseTimeSeriesAccumulator 10 | { 11 | private readonly Func _factory; 12 | private readonly Timer? _timer; 13 | protected readonly ConcurrentDictionary Accumulators = new(); 14 | 15 | protected BaseGroupTimeSeriesAccumulator(TimeSpan sampleInterval, bool deleteOverdueSamples, Func factory) 16 | { 17 | _factory = factory ?? throw new ArgumentNullException(nameof(factory)); 18 | if (deleteOverdueSamples) 19 | { 20 | _timer = new Timer(Callback, null, sampleInterval, sampleInterval); 21 | } 22 | } 23 | 24 | private void Callback(object? state) 25 | { 26 | foreach (var (key, accumulator) in Accumulators.ToArray()) 27 | { 28 | accumulator.DeleteOverdueSamples(); 29 | if (accumulator.IsEmpty) 30 | { 31 | Accumulators.TryRemove(key, out _); 32 | } 33 | } 34 | } 35 | 36 | protected TAccumulator CreateAccumulator() 37 | { 38 | return _factory(); 39 | } 40 | 41 | public TAccumulator GetOrAdd(string key) 42 | { 43 | return Accumulators.GetOrAdd(key, _ => CreateAccumulator()); 44 | } 45 | 46 | public void AddNewData(string key, T data) 47 | { 48 | GetOrAdd(key).AddNewData(data); 49 | } 50 | 51 | public void AddNewData(string key, DateTimeOffset dateTimeOffset, T data) 52 | { 53 | GetOrAdd(key).AddNewData(dateTimeOffset, data); 54 | } 55 | 56 | public bool TryGet(string key, out TAccumulator accumulator) 57 | { 58 | return Accumulators.TryGetValue(key, out accumulator!); 59 | } 60 | 61 | public bool Remove(string key) 62 | { 63 | return Accumulators.TryRemove(key, out _); 64 | } 65 | 66 | public IReadOnlyList> Snapshot() 67 | { 68 | if (Accumulators.IsEmpty) 69 | { 70 | return Array.Empty>(); 71 | } 72 | 73 | var copy = new List>(Accumulators.Count); 74 | foreach (var pair in Accumulators) 75 | { 76 | copy.Add(new KeyValuePair(pair.Key, pair.Value)); 77 | } 78 | 79 | return copy; 80 | } 81 | 82 | public void Dispose() 83 | { 84 | _timer?.Dispose(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Benchmark/Benchmarks/CollectionBenchmark.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using ManagedCode.TimeSeries.Accumulators; 3 | 4 | namespace ManagedCode.TimeSeries.Benchmark.Benchmarks; 5 | 6 | 7 | [SimpleJob] 8 | [MemoryDiagnoser] 9 | public class CollectionBenchmark 10 | { 11 | [Benchmark] 12 | public void Queue_1000() 13 | { 14 | var series = new Queue(); 15 | for (var i = 0; i < 1000; i++) 16 | { 17 | series.Enqueue(i); 18 | if (series.Count > 100) 19 | series.Dequeue(); 20 | } 21 | } 22 | 23 | [Benchmark] 24 | public void Queue_100_000() 25 | { 26 | var series = new Queue(); 27 | for (var i = 0; i < 100_000; i++) 28 | { 29 | series.Enqueue(i); 30 | if (series.Count > 100) 31 | series.Dequeue(); 32 | } 33 | } 34 | 35 | [Benchmark] 36 | public void Queue_10_000_000() 37 | { 38 | var series = new Queue(); 39 | for (var i = 0; i < 10_000_000; i++) 40 | { 41 | series.Enqueue(i); 42 | if (series.Count > 100) 43 | series.Dequeue(); 44 | } 45 | } 46 | 47 | [Benchmark] 48 | public void LinkedList_1000() 49 | { 50 | var series = new LinkedList(); 51 | for (var i = 0; i < 1000; i++) 52 | { 53 | series.AddLast(i); 54 | if (series.Count > 100) 55 | series.RemoveFirst(); 56 | } 57 | } 58 | 59 | [Benchmark] 60 | public void LinkedList_100_000() 61 | { 62 | var series = new LinkedList(); 63 | for (var i = 0; i < 100_000; i++) 64 | { 65 | series.AddLast(i); 66 | if (series.Count > 100) 67 | series.RemoveFirst(); 68 | } 69 | } 70 | 71 | [Benchmark] 72 | public void LinkedList_10_000_000() 73 | { 74 | var series = new LinkedList(); 75 | for (var i = 0; i < 10_000_000; i++) 76 | { 77 | series.AddLast(i); 78 | if (series.Count > 100) 79 | series.RemoveFirst(); 80 | } 81 | } 82 | 83 | [Benchmark] 84 | public void List_1000() 85 | { 86 | var series = new List(); 87 | for (var i = 0; i < 1000; i++) 88 | { 89 | series.Add(i); 90 | if (series.Count > 100) 91 | series.RemoveAt(0); 92 | } 93 | } 94 | 95 | [Benchmark] 96 | public void List_100_000() 97 | { 98 | var series = new List(); 99 | for (var i = 0; i < 100_000; i++) 100 | { 101 | series.Add(i); 102 | if (series.Count > 100) 103 | series.RemoveAt(0); 104 | } 105 | } 106 | 107 | [Benchmark] 108 | public void List_10_000_000() 109 | { 110 | var series = new List(); 111 | for (var i = 0; i < 10_000_000; i++) 112 | { 113 | series.Add(i); 114 | if (series.Count > 100) 115 | series.RemoveAt(0); 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Conversations 2 | any resulting updates to agents.md should go under the section "## Rules to follow" 3 | When you see a convincing argument from me on how to solve or do something. add a summary for this in agents.md. so you learn what I want over time. 4 | If I say any of the following point, you do this: add the context to agents.md, and associate this with a specific type of task. 5 | if I say "never do x" in some way. 6 | if I say "always do x" in some way. 7 | if I say "the process is x" in some way. 8 | If I tell you to remember something, you do the same, update 9 | 10 | 11 | ## Rules to follow (SUPER IMPORTANT) 12 | For any code modification task: run dotnet build before applying changes to capture the current error count. After the changes, run dotnet build again and only commit if the error count stays the same or decreases; revert if it increases. 13 | 14 | 15 | # Repository Guidelines 16 | 17 | ## Project Structure & Module Organization 18 | - `ManagedCode.TimeSeries/` — core library with accumulators, summers, and shared abstractions. 19 | - `ManagedCode.TimeSeries.Tests/` — xUnit test suite referencing the core project; coverage instrumentation configured via coverlet. 20 | - `ManagedCode.TimeSeries.Orleans/` — Orleans-specific adapters built atop the core types. 21 | - `ManagedCode.TimeSeries.Benchmark/` — BenchmarkDotNet harness for performance tracking, entry point in `Program.cs`. 22 | - `Directory.Build.props` centralizes NuGet metadata, reproducible build settings, and solution-wide assets. 23 | 24 | ## Build, Test, and Development Commands 25 | - `dotnet restore` — fetch solution dependencies. 26 | - `dotnet build --configuration Release` — compile all projects and validate analyzers. 27 | - `dotnet test ManagedCode.TimeSeries.Tests/ManagedCode.TimeSeries.Tests.csproj -p:CollectCoverage=true` — run tests with coverlet lcov output (mirrors CI). 28 | - `dotnet run --project ManagedCode.TimeSeries.Benchmark/ManagedCode.TimeSeries.Benchmark.csproj -c Release` — execute benchmarks before publishing performance-sensitive changes. 29 | - `dotnet pack ManagedCode.TimeSeries/ManagedCode.TimeSeries.csproj -c Release` — produce the NuGet package using metadata from `Directory.Build.props`. 30 | 31 | ## Coding Style & Naming Conventions 32 | Target `net9.0` with C# 13, `Nullable` and `ImplicitUsings` enabled; favour modern language features when they improve clarity. Stick to four-space indentation, braces on new lines, PascalCase for types, methods, and public properties, camelCase for locals and parameters, and ALL_CAPS only for constants. Keep namespaces aligned with folder paths under `ManagedCode.TimeSeries`. 33 | 34 | ## Testing Guidelines 35 | Use xUnit `[Fact]` and `[Theory]` patterns with Shouldly for assertions; prefer descriptive test method names (`MethodUnderTest_State_Expectation`). Ensure new logic includes coverage and leaves no skipped tests behind; if a skip is unavoidable, document the unblocker in-code. Generate fresh coverage via the command above before opening a PR and verify lcov output lands under `ManagedCode.TimeSeries.Tests/`. 36 | 37 | ## Commit & Pull Request Guidelines 38 | Follow the repo’s concise commit style (`scope summary` or short imperative lines under ~60 characters, e.g., `bench improve queue path`). Each PR should describe the change, reference related issues, and call out API or serialization impacts. Include evidence of local `dotnet test` runs (and benchmarks when relevant) plus screenshots for user-facing changes. Highlight breaking changes or version bumps so release automation remains accurate. 39 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Summers/NumberGroupTimeSeriesSummer.cs: -------------------------------------------------------------------------------- 1 | using System.Numerics; 2 | using ManagedCode.TimeSeries.Abstractions; 3 | 4 | namespace ManagedCode.TimeSeries.Summers; 5 | 6 | public class NumberGroupTimeSeriesSummer : BaseGroupNumberTimeSeriesSummer, NumberGroupTimeSeriesSummer> 7 | where TNumber : struct, INumber 8 | { 9 | private readonly TimeSpan _sampleInterval; 10 | private readonly int _samplesCount; 11 | private readonly Strategy _strategy; 12 | 13 | public NumberGroupTimeSeriesSummer(TimeSpan sampleInterval, int samplesCount, Strategy strategy, bool deleteOverdueSamples) 14 | : base(sampleInterval, deleteOverdueSamples) 15 | { 16 | _sampleInterval = sampleInterval; 17 | _samplesCount = samplesCount; 18 | _strategy = strategy; 19 | } 20 | 21 | public NumberGroupTimeSeriesSummer(TimeSpan sampleInterval, int samplesCount, bool deleteOverdueSamples) 22 | : this(sampleInterval, samplesCount, Strategy.Sum, deleteOverdueSamples) 23 | { 24 | } 25 | 26 | public NumberGroupTimeSeriesSummer(TimeSpan sampleInterval, bool deleteOverdueSamples) 27 | : this(sampleInterval, 0, Strategy.Sum, deleteOverdueSamples) 28 | { 29 | } 30 | 31 | public override TNumber Average() 32 | { 33 | if (TimeSeries.IsEmpty) 34 | { 35 | return TNumber.Zero; 36 | } 37 | 38 | var total = TNumber.Zero; 39 | var sampleCount = 0; 40 | 41 | foreach (var summer in TimeSeries.Values) 42 | { 43 | total += summer.Sum(); 44 | sampleCount += summer.Samples.Count; 45 | } 46 | 47 | return sampleCount == 0 ? TNumber.Zero : total / TNumber.CreateChecked(sampleCount); 48 | } 49 | 50 | public override TNumber Min() 51 | { 52 | var hasValue = false; 53 | var min = TNumber.Zero; 54 | 55 | foreach (var summer in TimeSeries.Values) 56 | { 57 | if (summer.Min() is not TNumber minValue) 58 | { 59 | continue; 60 | } 61 | 62 | if (!hasValue) 63 | { 64 | min = minValue; 65 | hasValue = true; 66 | } 67 | else 68 | { 69 | min = TNumber.Min(min, minValue); 70 | } 71 | } 72 | 73 | return hasValue ? min : TNumber.Zero; 74 | } 75 | 76 | public override TNumber Max() 77 | { 78 | var hasValue = false; 79 | var max = TNumber.Zero; 80 | 81 | foreach (var summer in TimeSeries.Values) 82 | { 83 | if (summer.Max() is not TNumber maxValue) 84 | { 85 | continue; 86 | } 87 | 88 | if (!hasValue) 89 | { 90 | max = maxValue; 91 | hasValue = true; 92 | } 93 | else 94 | { 95 | max = TNumber.Max(max, maxValue); 96 | } 97 | } 98 | 99 | return hasValue ? max : TNumber.Zero; 100 | } 101 | 102 | public override TNumber Sum() 103 | { 104 | var total = TNumber.Zero; 105 | foreach (var summer in TimeSeries.Values) 106 | { 107 | total += summer.Sum(); 108 | } 109 | 110 | return total; 111 | } 112 | 113 | protected override NumberTimeSeriesSummer CreateSummer() 114 | { 115 | return new NumberTimeSeriesSummer(_sampleInterval, _samplesCount, _strategy); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Abstractions/BaseTimeSeriesAccumulator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Linq; 4 | using ManagedCode.TimeSeries.Extensions; 5 | 6 | namespace ManagedCode.TimeSeries.Abstractions; 7 | 8 | public abstract class BaseTimeSeriesAccumulator : BaseTimeSeries, TSelf> where TSelf : BaseTimeSeries, TSelf> 9 | { 10 | protected BaseTimeSeriesAccumulator(TimeSpan sampleInterval, int maxSamplesCount) : base(sampleInterval, maxSamplesCount) 11 | { 12 | } 13 | 14 | protected BaseTimeSeriesAccumulator(TimeSpan sampleInterval, int maxSamplesCount, DateTimeOffset start, DateTimeOffset end, 15 | DateTimeOffset lastDate) 16 | : base(sampleInterval, maxSamplesCount, start, end, lastDate) 17 | { 18 | } 19 | 20 | protected override void AddData(DateTimeOffset date, T data) 21 | { 22 | var queue = GetOrCreateSample(date, static () => new ConcurrentQueue()); 23 | queue.Enqueue(data); 24 | } 25 | 26 | public void Trim() 27 | { 28 | TrimStart(); 29 | TrimEnd(); 30 | } 31 | 32 | public void TrimStart() 33 | { 34 | while (TryGetBoundarySample(static (candidate, current) => candidate < current, out var key, out var queue)) 35 | { 36 | if (!queue.IsEmpty) 37 | { 38 | return; 39 | } 40 | 41 | if (Storage.TryRemove(key, out _)) 42 | { 43 | RecalculateRange(); 44 | continue; 45 | } 46 | 47 | // Removal failed due to contention, retry. 48 | } 49 | } 50 | 51 | public void TrimEnd() 52 | { 53 | while (TryGetBoundarySample(static (candidate, current) => candidate > current, out var key, out var queue)) 54 | { 55 | if (!queue.IsEmpty) 56 | { 57 | return; 58 | } 59 | 60 | if (Storage.TryRemove(key, out _)) 61 | { 62 | RecalculateRange(); 63 | continue; 64 | } 65 | 66 | // Contention, retry. 67 | } 68 | } 69 | 70 | public override void Merge(TSelf accumulator) 71 | { 72 | if (accumulator is null) 73 | { 74 | return; 75 | } 76 | 77 | AddToDataCount(accumulator.DataCount); 78 | if (accumulator.LastDate > LastDate) 79 | { 80 | LastDate = accumulator.LastDate; 81 | } 82 | 83 | foreach (var sample in accumulator.Samples) 84 | { 85 | var queue = GetOrCreateSample(sample.Key, static () => new ConcurrentQueue()); 86 | if (ReferenceEquals(queue, sample.Value)) 87 | { 88 | continue; 89 | } 90 | 91 | foreach (var item in sample.Value) 92 | { 93 | queue.Enqueue(item); 94 | } 95 | } 96 | } 97 | 98 | public override void Resample(TimeSpan sampleInterval, int samplesCount) 99 | { 100 | if (sampleInterval <= SampleInterval) 101 | { 102 | throw new InvalidOperationException(); 103 | } 104 | 105 | SampleInterval = sampleInterval; 106 | MaxSamplesCount = samplesCount; 107 | 108 | var previousCount = DataCount; 109 | var previousLastDate = LastDate; 110 | 111 | var snapshot = Storage.ToArray(); 112 | ResetSamplesStorage(); 113 | SetDataCount(0); 114 | 115 | DateTimeOffset? lastObservedSample = null; 116 | 117 | foreach (var (key, value) in snapshot) 118 | { 119 | var roundedKey = key.Round(SampleInterval); 120 | var queue = GetOrCreateSample(roundedKey, static () => new ConcurrentQueue()); 121 | 122 | foreach (var item in value) 123 | { 124 | queue.Enqueue(item); 125 | } 126 | 127 | if (!lastObservedSample.HasValue || roundedKey > lastObservedSample.Value) 128 | { 129 | lastObservedSample = roundedKey; 130 | } 131 | } 132 | 133 | SetDataCount(previousCount); 134 | LastDate = lastObservedSample ?? previousLastDate; 135 | } 136 | 137 | private bool TryGetBoundarySample(Func comparer, out DateTimeOffset key, out ConcurrentQueue queue) 138 | { 139 | key = default; 140 | queue = default!; 141 | var found = false; 142 | 143 | foreach (var sample in Samples) 144 | { 145 | if (!found || comparer(sample.Key, key)) 146 | { 147 | key = sample.Key; 148 | queue = sample.Value; 149 | found = true; 150 | } 151 | } 152 | 153 | return found; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Abstractions/BaseTimeSeriesSummer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Runtime.CompilerServices; 3 | using ManagedCode.TimeSeries.Extensions; 4 | 5 | namespace ManagedCode.TimeSeries.Abstractions; 6 | 7 | public abstract class BaseTimeSeriesSummer : BaseTimeSeries 8 | where TSummerItem : ISummerItem where TSelf : BaseTimeSeries 9 | { 10 | private readonly Strategy _strategy; 11 | 12 | protected BaseTimeSeriesSummer(TimeSpan sampleInterval, int maxSamplesCount, Strategy strategy) : base(sampleInterval, maxSamplesCount) 13 | { 14 | _strategy = strategy; 15 | } 16 | 17 | protected override void AddData(DateTimeOffset date, TSummerItem data) 18 | { 19 | AddOrUpdateSample(date, 20 | () => data, 21 | current => Update(current, data)); 22 | } 23 | 24 | public override void Merge(TSelf accumulator) 25 | { 26 | if (accumulator is null) 27 | { 28 | return; 29 | } 30 | 31 | AddToDataCount(accumulator.DataCount); 32 | if (accumulator.LastDate > LastDate) 33 | { 34 | LastDate = accumulator.LastDate; 35 | } 36 | 37 | foreach (var sample in accumulator.Samples) 38 | { 39 | AddOrUpdateSample(sample.Key, 40 | () => sample.Value, 41 | current => Update(current, sample.Value)); 42 | } 43 | } 44 | 45 | public override void Resample(TimeSpan sampleInterval, int samplesCount) 46 | { 47 | if (sampleInterval <= SampleInterval) 48 | { 49 | throw new InvalidOperationException(); 50 | } 51 | 52 | SampleInterval = sampleInterval; 53 | MaxSamplesCount = samplesCount; 54 | 55 | var previousCount = DataCount; 56 | var previousLastDate = LastDate; 57 | var snapshot = Storage.ToArray(); 58 | ResetSamplesStorage(); 59 | SetDataCount(0); 60 | 61 | DateTimeOffset? lastObservedSample = null; 62 | 63 | foreach (var (key, value) in snapshot) 64 | { 65 | var roundedKey = key.Round(SampleInterval); 66 | AddOrUpdateSample(roundedKey, 67 | () => value, 68 | current => Update(current, value)); 69 | 70 | if (!lastObservedSample.HasValue || roundedKey > lastObservedSample.Value) 71 | { 72 | lastObservedSample = roundedKey; 73 | } 74 | } 75 | 76 | SetDataCount(previousCount); 77 | LastDate = lastObservedSample ?? previousLastDate; 78 | } 79 | 80 | private TSummerItem Update(TSummerItem left, TSummerItem right) 81 | { 82 | return _strategy switch 83 | { 84 | Strategy.Sum => left + right, 85 | Strategy.Min => TSummerItem.Min(left, right), 86 | Strategy.Max => TSummerItem.Max(left, right), 87 | Strategy.Replace => right, 88 | _ => throw new ArgumentOutOfRangeException() 89 | }; 90 | } 91 | 92 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 93 | public virtual void Increment() 94 | { 95 | AddNewData(TSummerItem.One); 96 | } 97 | 98 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 99 | public virtual void Decrement() 100 | { 101 | AddNewData(-TSummerItem.One); 102 | } 103 | 104 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 105 | public virtual TSummerItem? Min() 106 | { 107 | var hasValue = false; 108 | var min = TSummerItem.Zero; 109 | 110 | foreach (var value in Samples.Values) 111 | { 112 | if (!hasValue) 113 | { 114 | min = value; 115 | hasValue = true; 116 | } 117 | else 118 | { 119 | min = TSummerItem.Min(min, value); 120 | } 121 | } 122 | 123 | return hasValue ? min : default; 124 | } 125 | 126 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 127 | public virtual TSummerItem? Max() 128 | { 129 | var hasValue = false; 130 | var max = TSummerItem.Zero; 131 | 132 | foreach (var value in Samples.Values) 133 | { 134 | if (!hasValue) 135 | { 136 | max = value; 137 | hasValue = true; 138 | } 139 | else 140 | { 141 | max = TSummerItem.Max(max, value); 142 | } 143 | } 144 | 145 | return hasValue ? max : default; 146 | } 147 | 148 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 149 | public virtual TSummerItem Sum() 150 | { 151 | var total = TSummerItem.Zero; 152 | foreach (var value in Samples.Values) 153 | { 154 | total += value; 155 | } 156 | 157 | return total; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Abstractions/BaseNumberTimeSeriesSummer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Numerics; 4 | using System.Runtime.CompilerServices; 5 | using ManagedCode.TimeSeries.Extensions; 6 | 7 | namespace ManagedCode.TimeSeries.Abstractions; 8 | 9 | public abstract class BaseNumberTimeSeriesSummer : BaseTimeSeries 10 | where TNumber : struct, INumber where TSelf : BaseTimeSeries 11 | { 12 | protected BaseNumberTimeSeriesSummer(TimeSpan sampleInterval, int maxSamplesCount, Strategy strategy) : base(sampleInterval, maxSamplesCount) 13 | { 14 | Strategy = strategy; 15 | } 16 | 17 | public Strategy Strategy { get; protected set; } 18 | 19 | protected override void AddData(DateTimeOffset date, TNumber data) 20 | { 21 | AddOrUpdateSample(date, 22 | () => data, 23 | current => Update(current, data)); 24 | } 25 | 26 | public override void Merge(TSelf accumulator) 27 | { 28 | if (accumulator is null) 29 | { 30 | return; 31 | } 32 | 33 | AddToDataCount(accumulator.DataCount); 34 | if (accumulator.LastDate > LastDate) 35 | { 36 | LastDate = accumulator.LastDate; 37 | } 38 | 39 | foreach (var sample in accumulator.Samples) 40 | { 41 | AddOrUpdateSample(sample.Key, 42 | () => sample.Value, 43 | current => Update(current, sample.Value)); 44 | } 45 | } 46 | 47 | public override void Resample(TimeSpan sampleInterval, int samplesCount) 48 | { 49 | if (sampleInterval <= SampleInterval) 50 | { 51 | throw new InvalidOperationException(); 52 | } 53 | 54 | SampleInterval = sampleInterval; 55 | MaxSamplesCount = samplesCount; 56 | 57 | var previousCount = DataCount; 58 | var previousLastDate = LastDate; 59 | 60 | var snapshot = Storage.ToArray(); 61 | ResetSamplesStorage(); 62 | SetDataCount(0); 63 | 64 | DateTimeOffset? lastObservedSample = null; 65 | 66 | foreach (var (key, value) in snapshot) 67 | { 68 | var roundedKey = key.Round(SampleInterval); 69 | AddOrUpdateSample(roundedKey, 70 | () => value, 71 | current => Update(current, value)); 72 | 73 | if (!lastObservedSample.HasValue || roundedKey > lastObservedSample.Value) 74 | { 75 | lastObservedSample = roundedKey; 76 | } 77 | } 78 | 79 | SetDataCount(previousCount); 80 | LastDate = lastObservedSample ?? previousLastDate; 81 | } 82 | 83 | private TNumber Update(TNumber left, TNumber right) 84 | { 85 | return Strategy switch 86 | { 87 | Strategy.Sum => left + right, 88 | Strategy.Min => TNumber.Min(left, right), 89 | Strategy.Max => TNumber.Max(left, right), 90 | Strategy.Replace => right, 91 | _ => throw new ArgumentOutOfRangeException() 92 | }; 93 | } 94 | 95 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 96 | public virtual void Increment() 97 | { 98 | AddNewData(TNumber.One); 99 | } 100 | 101 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 102 | public virtual void Decrement() 103 | { 104 | AddNewData(-TNumber.One); 105 | } 106 | 107 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 108 | public virtual TNumber Average() 109 | { 110 | var total = TNumber.Zero; 111 | var count = 0; 112 | 113 | foreach (var value in Samples.Values) 114 | { 115 | total += value; 116 | count++; 117 | } 118 | 119 | return count == 0 ? TNumber.Zero : total / TNumber.CreateChecked(count); 120 | } 121 | 122 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 123 | public virtual TNumber? Min() 124 | { 125 | var hasValue = false; 126 | var min = TNumber.Zero; 127 | 128 | foreach (var value in Samples.Values) 129 | { 130 | if (!hasValue) 131 | { 132 | min = value; 133 | hasValue = true; 134 | } 135 | else 136 | { 137 | min = TNumber.Min(min, value); 138 | } 139 | } 140 | 141 | return hasValue ? min : null; 142 | } 143 | 144 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 145 | public virtual TNumber? Max() 146 | { 147 | var hasValue = false; 148 | var max = TNumber.Zero; 149 | 150 | foreach (var value in Samples.Values) 151 | { 152 | if (!hasValue) 153 | { 154 | max = value; 155 | hasValue = true; 156 | } 157 | else 158 | { 159 | max = TNumber.Max(max, value); 160 | } 161 | } 162 | 163 | return hasValue ? max : null; 164 | } 165 | 166 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 167 | public virtual TNumber Sum() 168 | { 169 | var total = TNumber.Zero; 170 | foreach (var sample in Samples.Values) 171 | { 172 | total += sample; 173 | } 174 | 175 | return total; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![ManagedCode TimeSeries](https://raw.githubusercontent.com/managedcode/TimeSeries/main/logo.png) 2 | 3 | # ManagedCode.TimeSeries 4 | 5 | [![.NET](https://github.com/managedcode/TimeSeries/actions/workflows/dotnet.yml/badge.svg)](https://github.com/managedcode/TimeSeries/actions/workflows/dotnet.yml) 6 | [![Coverage Status](https://coveralls.io/repos/github/managedcode/TimeSeries/badge.svg?branch=main&service=github)](https://coveralls.io/github/managedcode/TimeSeries?branch=main) 7 | [![Release](https://github.com/managedcode/TimeSeries/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/managedcode/TimeSeries/actions/workflows/release.yml) 8 | [![CodeQL](https://github.com/managedcode/TimeSeries/actions/workflows/codeql-analysis.yml/badge.svg?branch=main)](https://github.com/managedcode/TimeSeries/actions/workflows/codeql-analysis.yml) 9 | 10 | > Lock-free, allocation-conscious time-series primitives written in modern C# for building fast counters, aggregations, and rolling analytics. 11 | 12 | | Package | NuGet | 13 | | --- | --- | 14 | | Core library | [![NuGet Package](https://img.shields.io/nuget/v/ManagedCode.TimeSeries.svg)](https://www.nuget.org/packages/ManagedCode.TimeSeries) | 15 | 16 | --- 17 | 18 | ## Why TimeSeries? 19 | 20 | - **Data pipelines demand concurrency.** We built the core on lock-free `ConcurrentDictionary`/`ConcurrentQueue` structures and custom atomic helpers, so write-heavy workloads scale across cores without blocking. 21 | - **Numeric algorithms shouldn’t duplicate code.** Everything is generic over `INumber`, so `int`, `decimal`, or your own numeric type can use the same summer/accumulator implementations. 22 | - **Hundreds of signals, one API.** Grouped accumulators and summers make it trivial to manage keyed windows (think “per customer”, “per endpoint”, “per shard”) with automatic clean-up. 23 | - **Production-ready plumbing.** Orleans converters, Release automation, Coveralls reporting, and central package management are all wired up out of the box. 24 | 25 | ## Table of Contents 26 | 27 | 1. [Feature Highlights](#feature-highlights) 28 | 2. [Quickstart](#quickstart) 29 | 3. [Architecture Notes](#architecture-notes) 30 | 4. [Development Workflow](#development-workflow) 31 | 5. [Release Automation](#release-automation) 32 | 6. [Extensibility](#extensibility) 33 | 7. [Contributing](#contributing) 34 | 8. [License](#license) 35 | 36 | ## Feature Highlights 37 | 38 | - **Lock-free core** – writes hit `ConcurrentDictionary` + `ConcurrentQueue`, range metadata updated via custom atomics. 39 | - **Generic summers** – `NumberTimeSeriesSummer` & friends operate on any `INumber` implementation. 40 | - **Mass fan-in ready** – grouped accumulators/summers handle hundreds of keys without `lock`. 41 | - **Orleans-native** – converters bridge to Orleans surrogates so grains can persist accumulators out of the box. 42 | - **Delivery pipeline** – GitHub Actions release workflow bundles builds, tests, packs, tagging, and publishing. 43 | - **Central package versions** – single source of NuGet truth via `Directory.Packages.props`. 44 | 45 | ## Quickstart 46 | 47 | ### Install 48 | 49 | ```bash 50 | dotnet add package ManagedCode.TimeSeries 51 | ``` 52 | 53 | ### Create a rolling accumulator 54 | 55 | ```csharp 56 | using ManagedCode.TimeSeries.Accumulators; 57 | 58 | var requests = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(5), maxSamplesCount: 60); 59 | 60 | Parallel.For(0, 10_000, i => 61 | { 62 | requests.AddNewData(i); 63 | }); 64 | 65 | Console.WriteLine($"Samples stored: {requests.Samples.Count}"); 66 | Console.WriteLine($"Events processed: {requests.DataCount}"); 67 | ``` 68 | 69 | ### Summaries with any numeric type 70 | 71 | ```csharp 72 | using ManagedCode.TimeSeries.Summers; 73 | 74 | var latency = new NumberTimeSeriesSummer(TimeSpan.FromMilliseconds(500)); 75 | 76 | latency.AddNewData(DateTimeOffset.UtcNow, 12.4m); 77 | latency.AddNewData(DateTimeOffset.UtcNow.AddMilliseconds(250), 9.6m); 78 | 79 | Console.WriteLine($"AVG: {latency.Average():F2} ms"); 80 | Console.WriteLine($"P50/P100: {latency.Min()} / {latency.Max()}"); 81 | ``` 82 | 83 | ### Track many signals at once 84 | 85 | ```csharp 86 | using ManagedCode.TimeSeries.Accumulators; 87 | 88 | var perEndpoint = new IntGroupTimeSeriesAccumulator( 89 | sampleInterval: TimeSpan.FromSeconds(1), 90 | maxSamplesCount: 300, 91 | deleteOverdueSamples: true); 92 | 93 | Parallel.ForEach(requests, req => 94 | { 95 | perEndpoint.AddNewData(req.Path, req.Timestamp, 1); 96 | }); 97 | 98 | foreach (var (endpoint, accumulator) in perEndpoint.Snapshot()) 99 | { 100 | Console.WriteLine($"{endpoint}: {accumulator.DataCount} hits"); 101 | } 102 | ``` 103 | 104 | ### Orleans-friendly serialization 105 | 106 | ```csharp 107 | // In your Orleans silo: 108 | builder.Services.AddSerializer(builder => 109 | { 110 | builder.AddConverter>(); 111 | builder.AddConverter>(); 112 | // …add others as needed 113 | }); 114 | ``` 115 | 116 | ## Architecture Notes 117 | 118 | - **Lock-free core:** `BaseTimeSeries` stores samples in a `ConcurrentDictionary` and updates range metadata through `AtomicDateTimeOffset`. Per-key data is a `ConcurrentQueue` (accumulators) or direct `INumber` (summers). 119 | - **Deterministic reads:** consumers get an ordered read-only projection of the concurrent map, so existing iteration/test semantics stay intact while writers remain lock-free. 120 | - **Group managers:** `BaseGroupTimeSeriesAccumulator` and `BaseGroupNumberTimeSeriesSummer` use `ConcurrentDictionary` plus lightweight background timers for overdue clean-up—no `lock` statements anywhere on the hot path. 121 | - **Orleans bridge:** converters project between the concurrent structures and Orleans’ plain dictionaries/queues, keeping serialized payloads simple while the live types stay lock-free. 122 | 123 | ## Extensibility 124 | 125 | | Scenario | Hook | 126 | | --- | --- | 127 | | Custom numeric type | Implement `INumber` and plug into `NumberTimeSeriesSummer` | 128 | | Alternative aggregation strategy | Extend `Strategy` enum & override `Update` in a derived summer | 129 | | Domain-specific accumulator | Derive from `TimeSeriesAccumulator` (future rename of `BaseTimeSeriesAccumulator`) and expose tailored helpers | 130 | | Serialization | Add dedicated Orleans converters / System.Text.Json converters using the pattern in `ManagedCode.TimeSeries.Orleans` | 131 | 132 | > **Heads up:** the `Base*` prefixes hang around for historical reasons. We plan to rename the concrete-ready generics to `TimeSeriesAccumulator` / `TimeSeriesSummer` in a future release with deprecation shims. 133 | 134 | ## Development Workflow 135 | 136 | - Solution: `ManagedCode.TimeSeries.slnx` 137 | ```bash 138 | dotnet restore ManagedCode.TimeSeries.slnx 139 | dotnet build ManagedCode.TimeSeries.slnx --configuration Release 140 | dotnet test ManagedCode.TimeSeries.Tests/ManagedCode.TimeSeries.Tests.csproj --configuration Release 141 | ``` 142 | - Packages: update versions only in `Directory.Packages.props`. 143 | - Coverage: `dotnet test ... -p:CollectCoverage=true -p:CoverletOutputFormat=lcov`. 144 | - Benchmarks: `dotnet run --project ManagedCode.TimeSeries.Benchmark --configuration Release`. 145 | 146 | ## Release Automation 147 | 148 | - Workflow: `.github/workflows/release.yml` 149 | - Trigger: push to `main` or manual `workflow_dispatch`. 150 | - Steps: restore → build → test → pack → `dotnet nuget push` (skip duplicates) → create/tag release. 151 | - Configure secrets: 152 | - `NUGET_API_KEY`: NuGet publish token. 153 | - Default `${{ secrets.GITHUB_TOKEN }}` is used for tagging and releases. 154 | 155 | ## Contributing 156 | 157 | 1. Restore/build/test using the commands above. 158 | 2. Keep new APIs covered with tests (see existing samples in `ManagedCode.TimeSeries.Tests`). 159 | 3. Align with the lock-free architecture—avoid introducing `lock` on hot paths. 160 | 4. Document new features in this README. 161 | 162 | ## License 163 | 164 | MIT © ManagedCode SAS. 165 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Tests/TimeSeriesAdvancedTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using ManagedCode.TimeSeries.Abstractions; 6 | using ManagedCode.TimeSeries.Accumulators; 7 | using ManagedCode.TimeSeries.Summers; 8 | using ManagedCode.TimeSeries.Orleans; 9 | using ManagedCode.TimeSeries.Tests.Assertions; 10 | using Shouldly; 11 | using Xunit; 12 | 13 | namespace ManagedCode.TimeSeries.Tests; 14 | 15 | public class TimeSeriesAdvancedTests 16 | { 17 | [Fact] 18 | public void IntAccumulator_ParallelAdds_PreserveCountAndOrdering() 19 | { 20 | var interval = TimeSpan.FromMilliseconds(1); 21 | var accumulator = new IntTimeSeriesAccumulator(interval, maxSamplesCount: 10_000); 22 | var baseline = DateTimeOffset.UnixEpoch; 23 | const int operations = 20_000; 24 | 25 | Parallel.For(0, operations, i => 26 | { 27 | var timestamp = baseline.AddMilliseconds(i % 32); 28 | accumulator.AddNewData(timestamp, i); 29 | }); 30 | 31 | accumulator.DataCount.ShouldBe((ulong)operations); 32 | var totalItems = accumulator.Samples.Values.Sum(queue => queue.Count); 33 | totalItems.ShouldBe(operations); 34 | accumulator.Samples.Keys.ShouldBeInAscendingOrder(); 35 | } 36 | 37 | [Fact] 38 | public void IntAccumulator_TrimRemovesEmptyEdges() 39 | { 40 | var accumulator = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(1), maxSamplesCount: 10); 41 | 42 | accumulator.MarkupAllSamples(); 43 | accumulator.AddNewData(DateTimeOffset.UtcNow, 42); 44 | 45 | var countBefore = accumulator.Samples.Count; 46 | accumulator.TrimStart(); 47 | accumulator.TrimEnd(); 48 | accumulator.Samples.Count.ShouldBeLessThan(countBefore); 49 | accumulator.Samples.Values.All(queue => queue.Count > 0).ShouldBeTrue(); 50 | } 51 | 52 | [Fact] 53 | public void NumberSummer_ConcurrentUsage_IsConsistent() 54 | { 55 | var summer = new NumberTimeSeriesSummer(TimeSpan.FromMilliseconds(2)); 56 | var start = DateTimeOffset.UnixEpoch; 57 | const int operations = 15_000; 58 | 59 | Parallel.For(0, operations, i => 60 | { 61 | var timestamp = start.AddMilliseconds(i % 8); 62 | summer.AddNewData(timestamp, 1); 63 | }); 64 | 65 | summer.Sum().ShouldBe(operations); 66 | 67 | var min = summer.Min(); 68 | min.ShouldNotBeNull(); 69 | min.Value.ShouldBeGreaterThan(0); 70 | 71 | var max = summer.Max(); 72 | max.ShouldNotBeNull(); 73 | max.Value.ShouldBeGreaterThan(0); 74 | 75 | summer.Average().ShouldBeGreaterThan(0); 76 | } 77 | 78 | [Fact] 79 | public void NumberSummer_MergeRetainsTotals() 80 | { 81 | var left = new NumberTimeSeriesSummer(TimeSpan.FromMilliseconds(5)); 82 | var right = new NumberTimeSeriesSummer(TimeSpan.FromMilliseconds(5)); 83 | var now = DateTimeOffset.UtcNow; 84 | 85 | for (var i = 0; i < 10; i++) 86 | { 87 | left.AddNewData(now.AddMilliseconds(i), 2); 88 | right.AddNewData(now.AddMilliseconds(i), 3); 89 | } 90 | 91 | left.Merge(right); 92 | left.Sum().ShouldBe(50); 93 | left.DataCount.ShouldBeGreaterThan(0ul); 94 | left.Sum().ShouldBe(left.Samples.Values.Sum(value => value)); 95 | } 96 | 97 | [Fact] 98 | public void NumberSummer_ResampleAggregatesValues() 99 | { 100 | var start = DateTimeOffset.UnixEpoch; 101 | var summer = new NumberTimeSeriesSummer(TimeSpan.FromMilliseconds(1)); 102 | 103 | for (var i = 0; i < 8; i++) 104 | { 105 | summer.AddNewData(start.AddMilliseconds(i), i + 1); 106 | } 107 | 108 | summer.Resample(TimeSpan.FromMilliseconds(4), samplesCount: 4); 109 | 110 | summer.SampleInterval.ShouldBe(TimeSpan.FromMilliseconds(4)); 111 | summer.Samples.Count.ShouldBeLessThanOrEqualTo(4); 112 | summer.Sum().ShouldBe(36); 113 | } 114 | 115 | [Fact] 116 | public void NumberGroupSummer_ParallelUpdates_AreAggregated() 117 | { 118 | var group = new NumberGroupTimeSeriesSummer( 119 | sampleInterval: TimeSpan.FromMilliseconds(5), 120 | samplesCount: 128, 121 | strategy: Strategy.Sum, 122 | deleteOverdueSamples: false); 123 | 124 | var keys = Enumerable.Range(0, 16).Select(i => $"key-{i}").ToArray(); 125 | 126 | Parallel.ForEach(keys, key => 127 | { 128 | for (var i = 0; i < 1_000; i++) 129 | { 130 | group.AddNewData(key, 1); 131 | } 132 | }); 133 | 134 | group.Sum().ShouldBe(16_000); 135 | group.Average().ShouldBeGreaterThan(0); 136 | group.Min().ShouldBeGreaterThan(0); 137 | group.Max().ShouldBeGreaterThanOrEqualTo(group.Min()); 138 | } 139 | 140 | [Fact] 141 | public void IntGroupAccumulator_ConcurrentAdds_AreSnapshotSafe() 142 | { 143 | var group = new IntGroupTimeSeriesAccumulator( 144 | sampleInterval: TimeSpan.FromMilliseconds(5), 145 | maxSamplesCount: 256, 146 | deleteOverdueSamples: false); 147 | 148 | var keys = Enumerable.Range(0, 8).Select(i => $"group-{i}").ToArray(); 149 | 150 | Parallel.ForEach(keys, key => 151 | { 152 | for (var i = 0; i < 500; i++) 153 | { 154 | group.AddNewData(key, DateTimeOffset.UnixEpoch.AddMilliseconds(i), i); 155 | } 156 | }); 157 | 158 | var snapshot = group.Snapshot(); 159 | snapshot.ShouldHaveCount(8); 160 | snapshot.Aggregate(0UL, (total, pair) => total + pair.Value.DataCount).ShouldBe(8 * 500ul); 161 | } 162 | 163 | [Fact] 164 | public void Accumulator_DeleteOverdueSamples_RemovesOldEntries() 165 | { 166 | var accumulator = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(10), maxSamplesCount: 5); 167 | var baseTime = DateTimeOffset.UnixEpoch; 168 | 169 | for (var i = 0; i < 10; i++) 170 | { 171 | accumulator.AddNewData(baseTime.AddMilliseconds(i * 10), i); 172 | } 173 | 174 | accumulator.Samples.Count.ShouldBe(5); 175 | 176 | accumulator.DeleteOverdueSamples(); 177 | accumulator.Samples.Count.ShouldBeLessThanOrEqualTo(5); 178 | accumulator.Samples.Keys.ShouldBeInAscendingOrder(); 179 | } 180 | 181 | [Fact] 182 | public void OrleansAccumulatorConverter_RoundTrips() 183 | { 184 | var accumulator = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(1), maxSamplesCount: 16); 185 | for (var i = 0; i < 8; i++) 186 | { 187 | accumulator.AddNewData(DateTimeOffset.UnixEpoch.AddSeconds(i), i); 188 | } 189 | 190 | var converter = new IntTimeSeriesAccumulatorConverter(); 191 | var surrogate = converter.ConvertToSurrogate(accumulator); 192 | var restored = converter.ConvertFromSurrogate(surrogate); 193 | 194 | restored.DataCount.ShouldBe(accumulator.DataCount); 195 | restored.Samples.Count.ShouldBe(accumulator.Samples.Count); 196 | restored.Samples.Keys.ShouldSequenceEqual(accumulator.Samples.Keys.ToArray()); 197 | } 198 | 199 | [Fact] 200 | public void OrleansSummerConverter_RoundTrips() 201 | { 202 | var summer = new IntTimeSeriesSummer(TimeSpan.FromMilliseconds(5), maxSamplesCount: 64, strategy: Strategy.Sum); 203 | for (var i = 0; i < 10; i++) 204 | { 205 | summer.AddNewData(DateTimeOffset.UnixEpoch.AddMilliseconds(i), i); 206 | } 207 | 208 | var converter = new IntTimeSeriesSummerConverter(); 209 | var surrogate = converter.ConvertToSurrogate(summer); 210 | var restored = converter.ConvertFromSurrogate(surrogate); 211 | 212 | restored.Sum().ShouldBe(summer.Sum()); 213 | restored.Samples.Count.ShouldBe(summer.Samples.Count); 214 | restored.Samples.Keys.ShouldSequenceEqual(summer.Samples.Keys.ToArray()); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: 7 | 8 | env: 9 | DOTNET_VERSION: '9.0.x' 10 | 11 | jobs: 12 | build: 13 | name: Build and Test 14 | runs-on: ubuntu-latest 15 | 16 | outputs: 17 | version: ${{ steps.version.outputs.version }} 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v5 22 | 23 | - name: Setup .NET 24 | uses: actions/setup-dotnet@v4 25 | with: 26 | dotnet-version: ${{ env.DOTNET_VERSION }} 27 | 28 | - name: Extract version from Directory.Build.props 29 | id: version 30 | run: | 31 | VERSION=$(grep -oPm1 "(?<=)[^<]+" Directory.Build.props) 32 | echo "version=$VERSION" >> $GITHUB_OUTPUT 33 | echo "Version from Directory.Build.props: $VERSION" 34 | 35 | - name: Restore dependencies 36 | run: dotnet restore ManagedCode.TimeSeries.slnx 37 | 38 | - name: Build 39 | run: dotnet build ManagedCode.TimeSeries.slnx --configuration Release --no-restore 40 | 41 | - name: Test 42 | run: dotnet test ManagedCode.TimeSeries.Tests/ManagedCode.TimeSeries.Tests.csproj --configuration Release --no-build --verbosity normal 43 | 44 | - name: Pack NuGet packages 45 | run: dotnet pack ManagedCode.TimeSeries.slnx --configuration Release --no-build -p:IncludeSymbols=false -p:SymbolPackageFormat=snupkg --output ./artifacts 46 | 47 | - name: Upload artifacts 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: nuget-packages 51 | path: ./artifacts/*.nupkg 52 | retention-days: 5 53 | 54 | publish-nuget: 55 | name: Publish to NuGet 56 | needs: build 57 | runs-on: ubuntu-latest 58 | if: github.ref == 'refs/heads/main' 59 | 60 | outputs: 61 | published: ${{ steps.publish.outputs.published }} 62 | version: ${{ needs.build.outputs.version }} 63 | 64 | steps: 65 | - name: Checkout 66 | uses: actions/checkout@v5 67 | 68 | - name: Download artifacts 69 | uses: actions/download-artifact@v5 70 | with: 71 | name: nuget-packages 72 | path: ./artifacts 73 | 74 | - name: Setup .NET 75 | uses: actions/setup-dotnet@v4 76 | with: 77 | dotnet-version: ${{ env.DOTNET_VERSION }} 78 | 79 | - name: Publish to NuGet 80 | id: publish 81 | continue-on-error: true 82 | run: | 83 | set +e 84 | OUTPUT="" 85 | PUBLISHED=false 86 | 87 | for package in ./artifacts/*.nupkg; do 88 | echo "Publishing $package..." 89 | RESULT=$(dotnet nuget push "$package" \ 90 | --api-key ${{ secrets.NUGET_API_KEY }} \ 91 | --source https://api.nuget.org/v3/index.json \ 92 | --skip-duplicate 2>&1) 93 | EXIT_CODE=$? 94 | echo "$RESULT" 95 | OUTPUT="$OUTPUT$RESULT" 96 | 97 | if [ $EXIT_CODE -eq 0 ]; then 98 | echo "Successfully published $package" 99 | PUBLISHED=true 100 | elif echo "$RESULT" | grep -q "already exists"; then 101 | echo "Package already exists, skipping..." 102 | else 103 | echo "Failed to publish $package" 104 | exit 1 105 | fi 106 | done 107 | 108 | if [ "$PUBLISHED" = true ] || echo "$OUTPUT" | grep -q "Your package was pushed"; then 109 | echo "published=true" >> $GITHUB_OUTPUT 110 | echo "At least one package was successfully published" 111 | else 112 | echo "published=false" >> $GITHUB_OUTPUT 113 | echo "No new packages were published (all already exist)" 114 | fi 115 | 116 | create-release: 117 | name: Create GitHub Release and Tag 118 | needs: publish-nuget 119 | runs-on: ubuntu-latest 120 | if: needs.publish-nuget.outputs.published == 'true' 121 | 122 | steps: 123 | - name: Checkout 124 | uses: actions/checkout@v5 125 | with: 126 | fetch-depth: 0 127 | token: ${{ secrets.GITHUB_TOKEN }} 128 | 129 | - name: Download artifacts 130 | uses: actions/download-artifact@v5 131 | with: 132 | name: nuget-packages 133 | path: ./artifacts 134 | 135 | - name: Create and push tag 136 | id: create_tag 137 | run: | 138 | VERSION="${{ needs.publish-nuget.outputs.version }}" 139 | TAG="v$VERSION" 140 | 141 | git config user.name "github-actions[bot]" 142 | git config user.email "github-actions[bot]@users.noreply.github.com" 143 | 144 | if git rev-parse "$TAG" >/dev/null 2>&1; then 145 | echo "Tag $TAG already exists" 146 | echo "tag_exists=true" >> $GITHUB_OUTPUT 147 | else 148 | echo "Creating tag $TAG" 149 | git tag -a "$TAG" -m "Release $VERSION" 150 | git push origin "$TAG" 151 | echo "tag_exists=false" >> $GITHUB_OUTPUT 152 | fi 153 | 154 | - name: Get previous tag 155 | id: prev_tag 156 | run: | 157 | CURRENT_TAG="v${{ needs.publish-nuget.outputs.version }}" 158 | PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -A1 "^$CURRENT_TAG$" | tail -n1 || echo "") 159 | if [ "$PREVIOUS_TAG" = "$CURRENT_TAG" ] || [ -z "$PREVIOUS_TAG" ]; then 160 | PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "^$CURRENT_TAG$" | head -n1 || echo "") 161 | fi 162 | echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT 163 | echo "Current tag: $CURRENT_TAG" 164 | echo "Previous tag: $PREVIOUS_TAG" 165 | 166 | - name: Generate release notes 167 | id: release_notes 168 | run: | 169 | VERSION="${{ needs.publish-nuget.outputs.version }}" 170 | CURRENT_TAG="v$VERSION" 171 | PREVIOUS_TAG="${{ steps.prev_tag.outputs.previous_tag }}" 172 | 173 | echo "# Release $VERSION" > release_notes.md 174 | echo "" >> release_notes.md 175 | echo "Released on $(date +'%Y-%m-%d')" >> release_notes.md 176 | echo "" >> release_notes.md 177 | 178 | if [ -n "$PREVIOUS_TAG" ]; then 179 | echo "## 📋 Changes since $PREVIOUS_TAG" >> release_notes.md 180 | echo "" >> release_notes.md 181 | 182 | echo "### ✨ Features" >> release_notes.md 183 | git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^feat" --grep="^feature" >> release_notes.md || true 184 | echo "" >> release_notes.md 185 | 186 | echo "### 🐛 Bug Fixes" >> release_notes.md 187 | git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^fix" --grep="^bugfix" >> release_notes.md || true 188 | echo "" >> release_notes.md 189 | 190 | echo "### 📚 Documentation" >> release_notes.md 191 | git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --grep="^docs" --grep="^doc" >> release_notes.md || true 192 | echo "" >> release_notes.md 193 | 194 | echo "### 🔧 Other Changes" >> release_notes.md 195 | git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD --invert-grep --grep="^feat" --grep="^feature" --grep="^fix" --grep="^bugfix" --grep="^docs" --grep="^doc" >> release_notes.md || true 196 | echo "" >> release_notes.md 197 | else 198 | echo "## 🎉 Initial Release" >> release_notes.md 199 | echo "" >> release_notes.md 200 | echo "### Recent Changes" >> release_notes.md 201 | git log --pretty=format:"- %s (%h)" --max-count=20 >> release_notes.md 202 | echo "" >> release_notes.md 203 | fi 204 | 205 | echo "" >> release_notes.md 206 | echo "## 📦 NuGet Packages" >> release_notes.md 207 | echo "" >> release_notes.md 208 | for package in ./artifacts/*.nupkg; do 209 | PACKAGE_NAME=$(basename "$package" .nupkg) 210 | BASE_NAME=$(echo "$PACKAGE_NAME" | sed "s/\.$VERSION//") 211 | echo "- [$BASE_NAME v$VERSION](https://www.nuget.org/packages/$BASE_NAME/$VERSION)" >> release_notes.md 212 | done 213 | 214 | echo "" >> release_notes.md 215 | echo "---" >> release_notes.md 216 | echo "*This release was automatically created by GitHub Actions*" >> release_notes.md 217 | 218 | - name: Create GitHub Release 219 | uses: softprops/action-gh-release@v2 220 | with: 221 | tag_name: v${{ needs.publish-nuget.outputs.version }} 222 | name: v${{ needs.publish-nuget.outputs.version }} 223 | body_path: release_notes.md 224 | draft: false 225 | prerelease: false 226 | files: ./artifacts/*.nupkg 227 | token: ${{ secrets.GITHUB_TOKEN }} 228 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Tests/AccumulatorsTests.cs: -------------------------------------------------------------------------------- 1 | using ManagedCode.TimeSeries.Accumulators; 2 | using ManagedCode.TimeSeries.Extensions; 3 | using ManagedCode.TimeSeries.Tests.Assertions; 4 | using Shouldly; 5 | using Xunit; 6 | 7 | namespace ManagedCode.TimeSeries.Tests; 8 | 9 | public class AccumulatorsTests 10 | { 11 | 12 | [Fact] 13 | public async Task IntTimeSeriesAccumulator() 14 | { 15 | int count = 1050; 16 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1)); 17 | for (int i = 0; i < count; i++) 18 | { 19 | await Task.Delay(new Random().Next(1, 5)); 20 | series.AddNewData(i); 21 | } 22 | 23 | series.DataCount.ShouldBe(Convert.ToUInt64(count)); 24 | 25 | var step = 0; 26 | foreach (var queue in series.Samples) 27 | { 28 | foreach (var item in queue.Value) 29 | { 30 | item.ShouldBe(step); 31 | step++; 32 | } 33 | } 34 | } 35 | 36 | [Fact(Skip = "Need fix")] 37 | public async Task IntTimeSeriesAccumulatorMaxSamplesCount() 38 | { 39 | int samplesCount = 105; 40 | int count = 1050; 41 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(0.1), samplesCount); 42 | for (int i = 0; i < count; i++) 43 | { 44 | await Task.Delay(new Random().Next(1, 5)); 45 | series.AddNewData(i); 46 | } 47 | 48 | series.DataCount.ShouldBe(Convert.ToUInt64(count)); //because it's total; number of samples 49 | series.Samples.Count.ShouldBe(samplesCount); //because it's total; number of samples 50 | 51 | var step = count - samplesCount - 1; 52 | foreach (var queue in series.Samples) 53 | { 54 | foreach (var item in queue.Value) 55 | { 56 | item.ShouldBe(step); 57 | step++; 58 | } 59 | } 60 | } 61 | 62 | 63 | 64 | 65 | 66 | 67 | [Fact] 68 | public void GroupAccumulatorSupportsMultipleKeys() 69 | { 70 | var group = new IntGroupTimeSeriesAccumulator(TimeSpan.FromMilliseconds(50), maxSamplesCount: 10, deleteOverdueSamples: false); 71 | var origin = DateTimeOffset.UtcNow; 72 | 73 | group.AddNewData("alpha", origin, 1); 74 | group.AddNewData("alpha", origin.AddMilliseconds(60), 2); 75 | group.AddNewData("beta", origin, 5); 76 | 77 | group.TryGet("alpha", out var alphaAccumulator).ShouldBeTrue(); 78 | alphaAccumulator.ShouldNotBeNull(); 79 | alphaAccumulator!.DataCount.ShouldBe(2ul); 80 | alphaAccumulator.Samples.ShouldHaveCount(2); 81 | 82 | var snapshot = group.Snapshot(); 83 | snapshot.Count.ShouldBe(2); 84 | 85 | group.Remove("beta").ShouldBeTrue(); 86 | group.TryGet("beta", out _).ShouldBeFalse(); 87 | } 88 | 89 | [Fact] 90 | public void DoubleAccumulatorStoresValues() 91 | { 92 | var interval = TimeSpan.FromMilliseconds(25); 93 | var accumulator = new DoubleTimeSeriesAccumulator(interval, maxSamplesCount: 4); 94 | var now = DateTimeOffset.UtcNow; 95 | 96 | accumulator.AddNewData(now, 1.5); 97 | accumulator.AddNewData(now.AddMilliseconds(25), 2.5); 98 | 99 | accumulator.Samples.ShouldHaveCount(2); 100 | accumulator.Samples.TryGetValue(now.Round(interval), out var firstQueue).ShouldBeTrue(); 101 | firstQueue.ShouldNotBeNull(); 102 | firstQueue!.ShouldContain(1.5); 103 | } 104 | 105 | [Fact] 106 | public void DoubleGroupAccumulatorHandlesMultipleStreams() 107 | { 108 | var group = new DoubleGroupTimeSeriesAccumulator(TimeSpan.FromMilliseconds(50), maxSamplesCount: 2, deleteOverdueSamples: false); 109 | var origin = DateTimeOffset.UtcNow; 110 | 111 | group.AddNewData("alpha", origin, 1.0); 112 | group.AddNewData("alpha", origin.AddMilliseconds(50), 2.0); 113 | group.AddNewData("beta", origin, 3.0); 114 | 115 | group.TryGet("alpha", out var alpha).ShouldBeTrue(); 116 | alpha.ShouldNotBeNull(); 117 | alpha!.Samples.ShouldHaveCount(2); 118 | alpha.DataCount.ShouldBe(2ul); 119 | 120 | group.TryGet("beta", out var beta).ShouldBeTrue(); 121 | beta.ShouldNotBeNull(); 122 | beta!.Samples.ShouldHaveCount(1); 123 | } 124 | 125 | [Fact] 126 | public void FloatGroupAccumulatorTracksSamples() 127 | { 128 | var group = new FloatGroupTimeSeriesAccumulator(TimeSpan.FromMilliseconds(40), maxSamplesCount: 2, deleteOverdueSamples: false); 129 | var origin = DateTimeOffset.UtcNow; 130 | 131 | group.AddNewData("alpha", origin, 1.0f); 132 | group.AddNewData("alpha", origin.AddMilliseconds(40), 2.0f); 133 | 134 | group.TryGet("alpha", out var alpha).ShouldBeTrue(); 135 | alpha.ShouldNotBeNull(); 136 | alpha!.Samples.ShouldHaveCount(2); 137 | } 138 | 139 | [Fact] 140 | public void TrimRemovesEmptyBoundarySamples() 141 | { 142 | var interval = TimeSpan.FromSeconds(1); 143 | var series = new IntTimeSeriesAccumulator(interval, maxSamplesCount: 3); 144 | 145 | series.MarkupAllSamples(); 146 | series.AddNewData(series.Start, 7); 147 | 148 | series.Trim(); 149 | 150 | series.Samples.ShouldHaveCount(1); 151 | var queue = series.Samples.Single().Value; 152 | queue.Count.ShouldBe(1); 153 | queue.ShouldContain(7); 154 | } 155 | 156 | 157 | 158 | 159 | 160 | 161 | [Fact] 162 | public async Task Accumulator() 163 | { 164 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1)); 165 | for (var i = 0; i < 1000; i++) 166 | { 167 | await Task.Delay(new Random().Next(1, 5)); 168 | series.AddNewData(i); 169 | } 170 | 171 | series.DataCount.ShouldBe(1000ul); 172 | 173 | var step = 0; 174 | foreach (var queue in series.Samples) 175 | { 176 | foreach (var item in queue.Value) 177 | { 178 | item.ShouldBe(step); 179 | step++; 180 | } 181 | } 182 | } 183 | 184 | // [Fact] 185 | // public async Task AccumulatorByString() 186 | // { 187 | // var rnd = new Random(); 188 | // var series = new StringTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1)); 189 | // 190 | // var dt = DateTimeOffset.Now; 191 | // series.AddNewData(dt, "1"); 192 | // series.AddNewData(dt, "1"); 193 | // series.AddNewData(dt, "2"); 194 | // series.AddNewData(dt, "3"); 195 | // series.AddNewData(dt, "3"); 196 | // series.AddNewData(dt, "2"); 197 | // 198 | // 199 | // dt = dt.AddHours(5); 200 | // series.AddNewData(dt, "1"); 201 | // series.AddNewData(dt, "1"); 202 | // series.AddNewData(dt, "2"); 203 | // series.AddNewData(dt, "3"); 204 | // series.AddNewData(dt, "3"); 205 | // series.AddNewData(dt, "2"); 206 | // 207 | // series.DataCount.ShouldBe(12); 208 | // series.Samples.First().Value.Count.ShouldBe(3); 209 | // series.Samples.Last().Value.Count.ShouldBe(3); 210 | // } 211 | 212 | [Fact] 213 | public async Task AccumulatorLimit() 214 | { 215 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1), 10); 216 | 217 | for (var i = 0; i < 1000; i++) 218 | { 219 | await Task.Delay(new Random().Next(1, 5)); 220 | series.AddNewData(i); 221 | } 222 | 223 | series.Samples.Count.ShouldBe(10); 224 | } 225 | 226 | [Fact] 227 | public async Task IsFull() 228 | { 229 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1), 10); 230 | 231 | for (var i = 0; i < 1000; i++) 232 | { 233 | await Task.Delay(new Random().Next(1, 5)); 234 | 235 | if (series.IsFull) 236 | { 237 | break; 238 | } 239 | 240 | series.AddNewData(i); 241 | } 242 | 243 | series.IsFull.ShouldBeTrue(); 244 | } 245 | 246 | [Fact] 247 | public void IsEmpty() 248 | { 249 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1), 10); 250 | series.IsEmpty.ShouldBeTrue(); 251 | } 252 | 253 | [Fact] 254 | public async Task AccumulatorMerge() 255 | { 256 | Func> FillFunc = async () => 257 | { 258 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1), 10); 259 | for (var i = 0; i < 1000; i++) 260 | { 261 | await Task.Delay(new Random().Next(1, 5)); 262 | series.AddNewData(i); 263 | } 264 | 265 | return series; 266 | }; 267 | 268 | var seriesA = FillFunc(); 269 | var seriesB = FillFunc(); 270 | 271 | await Task.WhenAll(seriesA, seriesB); 272 | 273 | seriesA.Result.Samples.Count.ShouldBe(10); 274 | seriesB.Result.Samples.Count.ShouldBe(10); 275 | 276 | seriesA.Result.Merge(seriesB.Result); 277 | 278 | seriesA.Result.Samples.Count.ShouldBe(10); 279 | 280 | var seriesList = new List(); 281 | seriesList.Add(await FillFunc()); 282 | seriesList.Add(await FillFunc()); 283 | seriesList.Add(await FillFunc()); 284 | seriesList.Add(new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1), 10)); 285 | 286 | IntTimeSeriesAccumulator onlineExpertsPerHourTimeSeries = null; 287 | foreach (var item in seriesList.ToArray()) 288 | { 289 | if (onlineExpertsPerHourTimeSeries == null) 290 | { 291 | onlineExpertsPerHourTimeSeries = item; 292 | } 293 | else 294 | { 295 | onlineExpertsPerHourTimeSeries.Merge(item); 296 | } 297 | } 298 | 299 | onlineExpertsPerHourTimeSeries.Samples.Count.ShouldBe(10); 300 | } 301 | 302 | 303 | [Fact] 304 | public async Task Resample() 305 | { 306 | var seriesFeature = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(2), 100); 307 | 308 | for (var i = 0; i < 100; i++) 309 | { 310 | seriesFeature.AddNewData(i); 311 | 312 | await Task.Delay(1); 313 | } 314 | 315 | seriesFeature.Resample(TimeSpan.FromMilliseconds(4), 100); 316 | var sad = seriesFeature; 317 | } 318 | 319 | 320 | 321 | [Fact] 322 | public void MarkupAllSamples() 323 | { 324 | var seriesFeature = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(10), 100); 325 | seriesFeature.MarkupAllSamples(MarkupDirection.Feature); 326 | seriesFeature.AddNewData(1); 327 | (seriesFeature.Samples.Keys.Max() - seriesFeature.Samples.Keys.Min()).TotalMilliseconds.ShouldBeGreaterThanOrEqualTo(990); 328 | (seriesFeature.Samples.Keys.Max() - seriesFeature.Samples.Keys.Min()).TotalMilliseconds.ShouldBeLessThanOrEqualTo(1000); 329 | var seriesFeatureOrdered = seriesFeature.Samples.OrderBy(o => o.Key).Take(10); 330 | seriesFeatureOrdered.Any(a => a.Value.Count == 1).ShouldBeTrue(); 331 | 332 | var seriesPast = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(10)); 333 | seriesPast.MarkupAllSamples(); 334 | seriesPast.AddNewData(1); 335 | (seriesPast.Samples.Keys.Max() - seriesPast.Samples.Keys.Min()).TotalMilliseconds.ShouldBeGreaterThanOrEqualTo(990); 336 | (seriesPast.Samples.Keys.Max() - seriesPast.Samples.Keys.Min()).TotalMilliseconds.ShouldBeLessThanOrEqualTo(1000); 337 | var seriesPastOrdered = seriesPast.Samples.OrderBy(o => o.Key).TakeLast(10); 338 | seriesPastOrdered.Any(a => a.Value.Count == 1).ShouldBeTrue(); 339 | 340 | var seriesMiddle = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(10), 100); 341 | seriesMiddle.MarkupAllSamples(MarkupDirection.Middle); 342 | seriesMiddle.AddNewData(1); 343 | (seriesMiddle.Samples.Keys.Max() - seriesMiddle.Samples.Keys.Min()).TotalMilliseconds.ShouldBeGreaterThanOrEqualTo(990); 344 | (seriesMiddle.Samples.Keys.Max() - seriesMiddle.Samples.Keys.Min()).TotalMilliseconds.ShouldBeLessThanOrEqualTo(1000); 345 | var seriesMiddleOrdered = seriesMiddle.Samples.OrderBy(o => o.Key).Skip(45).Take(10); 346 | seriesMiddleOrdered.Any(a => a.Value.Count == 1).ShouldBeTrue(); 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/intellij,intellij+all,macos,linux,windows,visualstudio,visualstudiocode,rider 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=intellij,intellij+all,macos,linux,windows,visualstudio,visualstudiocode,rider 4 | 5 | ### Intellij ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/artifacts 37 | # .idea/compiler.xml 38 | # .idea/jarRepositories.xml 39 | # .idea/modules.xml 40 | # .idea/*.iml 41 | # .idea/modules 42 | # *.iml 43 | # *.ipr 44 | 45 | # CMake 46 | cmake-build-*/ 47 | 48 | # Mongo Explorer plugin 49 | .idea/**/mongoSettings.xml 50 | 51 | # File-based project format 52 | *.iws 53 | 54 | # IntelliJ 55 | out/ 56 | 57 | # mpeltonen/sbt-idea plugin 58 | .idea_modules/ 59 | 60 | # JIRA plugin 61 | atlassian-ide-plugin.xml 62 | 63 | # Cursive Clojure plugin 64 | .idea/replstate.xml 65 | 66 | # Crashlytics plugin (for Android Studio and IntelliJ) 67 | com_crashlytics_export_strings.xml 68 | crashlytics.properties 69 | crashlytics-build.properties 70 | fabric.properties 71 | 72 | # Editor-based Rest Client 73 | .idea/httpRequests 74 | 75 | # Android studio 3.1+ serialized cache file 76 | .idea/caches/build_file_checksums.ser 77 | 78 | ### Intellij Patch ### 79 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 80 | 81 | # *.iml 82 | # modules.xml 83 | # .idea/misc.xml 84 | # *.ipr 85 | 86 | # Sonarlint plugin 87 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 88 | .idea/**/sonarlint/ 89 | 90 | # SonarQube Plugin 91 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 92 | .idea/**/sonarIssues.xml 93 | 94 | # Markdown Navigator plugin 95 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 96 | .idea/**/markdown-navigator.xml 97 | .idea/**/markdown-navigator-enh.xml 98 | .idea/**/markdown-navigator/ 99 | 100 | # Cache file creation bug 101 | # See https://youtrack.jetbrains.com/issue/JBR-2257 102 | .idea/$CACHE_FILE$ 103 | 104 | # CodeStream plugin 105 | # https://plugins.jetbrains.com/plugin/12206-codestream 106 | .idea/codestream.xml 107 | 108 | ### Intellij+all ### 109 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 110 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 111 | 112 | # User-specific stuff 113 | 114 | # Generated files 115 | 116 | # Sensitive or high-churn files 117 | 118 | # Gradle 119 | 120 | # Gradle and Maven with auto-import 121 | # When using Gradle or Maven with auto-import, you should exclude module files, 122 | # since they will be recreated, and may cause churn. Uncomment if using 123 | # auto-import. 124 | # .idea/artifacts 125 | # .idea/compiler.xml 126 | # .idea/jarRepositories.xml 127 | # .idea/modules.xml 128 | # .idea/*.iml 129 | # .idea/modules 130 | # *.iml 131 | # *.ipr 132 | 133 | # CMake 134 | 135 | # Mongo Explorer plugin 136 | 137 | # File-based project format 138 | 139 | # IntelliJ 140 | 141 | # mpeltonen/sbt-idea plugin 142 | 143 | # JIRA plugin 144 | 145 | # Cursive Clojure plugin 146 | 147 | # Crashlytics plugin (for Android Studio and IntelliJ) 148 | 149 | # Editor-based Rest Client 150 | 151 | # Android studio 3.1+ serialized cache file 152 | 153 | ### Intellij+all Patch ### 154 | # Ignores the whole .idea folder and all .iml files 155 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 156 | 157 | .idea/ 158 | 159 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 160 | 161 | *.iml 162 | modules.xml 163 | .idea/misc.xml 164 | *.ipr 165 | 166 | # Sonarlint plugin 167 | .idea/sonarlint 168 | 169 | ### Linux ### 170 | *~ 171 | 172 | # temporary files which can be created if a process still has a handle open of a deleted file 173 | .fuse_hidden* 174 | 175 | # KDE directory preferences 176 | .directory 177 | 178 | # Linux trash folder which might appear on any partition or disk 179 | .Trash-* 180 | 181 | # .nfs files are created when an open file is removed but is still being accessed 182 | .nfs* 183 | 184 | ### macOS ### 185 | # General 186 | .DS_Store 187 | .AppleDouble 188 | .LSOverride 189 | 190 | # Icon must end with two \r 191 | Icon 192 | 193 | 194 | # Thumbnails 195 | ._* 196 | 197 | # Files that might appear in the root of a volume 198 | .DocumentRevisions-V100 199 | .fseventsd 200 | .Spotlight-V100 201 | .TemporaryItems 202 | .Trashes 203 | .VolumeIcon.icns 204 | .com.apple.timemachine.donotpresent 205 | 206 | # Directories potentially created on remote AFP share 207 | .AppleDB 208 | .AppleDesktop 209 | Network Trash Folder 210 | Temporary Items 211 | .apdisk 212 | 213 | ### Rider ### 214 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 215 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 216 | 217 | # User-specific stuff 218 | 219 | # Generated files 220 | 221 | # Sensitive or high-churn files 222 | 223 | # Gradle 224 | 225 | # Gradle and Maven with auto-import 226 | # When using Gradle or Maven with auto-import, you should exclude module files, 227 | # since they will be recreated, and may cause churn. Uncomment if using 228 | # auto-import. 229 | # .idea/artifacts 230 | # .idea/compiler.xml 231 | # .idea/jarRepositories.xml 232 | # .idea/modules.xml 233 | # .idea/*.iml 234 | # .idea/modules 235 | # *.iml 236 | # *.ipr 237 | 238 | # CMake 239 | 240 | # Mongo Explorer plugin 241 | 242 | # File-based project format 243 | 244 | # IntelliJ 245 | 246 | # mpeltonen/sbt-idea plugin 247 | 248 | # JIRA plugin 249 | 250 | # Cursive Clojure plugin 251 | 252 | # Crashlytics plugin (for Android Studio and IntelliJ) 253 | 254 | # Editor-based Rest Client 255 | 256 | # Android studio 3.1+ serialized cache file 257 | 258 | ### VisualStudioCode ### 259 | .vscode/* 260 | !.vscode/tasks.json 261 | !.vscode/launch.json 262 | *.code-workspace 263 | 264 | ### VisualStudioCode Patch ### 265 | # Ignore all local history of files 266 | .history 267 | .ionide 268 | 269 | ### Windows ### 270 | # Windows thumbnail cache files 271 | Thumbs.db 272 | Thumbs.db:encryptable 273 | ehthumbs.db 274 | ehthumbs_vista.db 275 | 276 | # Dump file 277 | *.stackdump 278 | 279 | # Folder config file 280 | [Dd]esktop.ini 281 | 282 | # Recycle Bin used on file shares 283 | $RECYCLE.BIN/ 284 | 285 | # Windows Installer files 286 | *.cab 287 | *.msi 288 | *.msix 289 | *.msm 290 | *.msp 291 | 292 | # Windows shortcuts 293 | *.lnk 294 | 295 | ### VisualStudio ### 296 | ## Ignore Visual Studio temporary files, build results, and 297 | ## files generated by popular Visual Studio add-ons. 298 | ## 299 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 300 | 301 | # User-specific files 302 | *.rsuser 303 | *.suo 304 | *.user 305 | *.userosscache 306 | *.sln.docstates 307 | 308 | # User-specific files (MonoDevelop/Xamarin Studio) 309 | *.userprefs 310 | 311 | # Mono auto generated files 312 | mono_crash.* 313 | 314 | # Build results 315 | [Dd]ebug/ 316 | [Dd]ebugPublic/ 317 | [Rr]elease/ 318 | [Rr]eleases/ 319 | x64/ 320 | x86/ 321 | [Aa][Rr][Mm]/ 322 | [Aa][Rr][Mm]64/ 323 | bld/ 324 | [Bb]in/ 325 | [Oo]bj/ 326 | [Ll]og/ 327 | [Ll]ogs/ 328 | 329 | # Visual Studio 2015/2017 cache/options directory 330 | .vs/ 331 | # Uncomment if you have tasks that create the project's static files in wwwroot 332 | #wwwroot/ 333 | 334 | # Visual Studio 2017 auto generated files 335 | Generated\ Files/ 336 | 337 | # MSTest test Results 338 | [Tt]est[Rr]esult*/ 339 | [Bb]uild[Ll]og.* 340 | 341 | # NUnit 342 | *.VisualState.xml 343 | TestResult.xml 344 | nunit-*.xml 345 | 346 | # Build Results of an ATL Project 347 | [Dd]ebugPS/ 348 | [Rr]eleasePS/ 349 | dlldata.c 350 | 351 | # Benchmark Results 352 | BenchmarkDotNet.Artifacts/ 353 | 354 | # .NET Core 355 | project.lock.json 356 | project.fragment.lock.json 357 | artifacts/ 358 | 359 | # StyleCop 360 | StyleCopReport.xml 361 | 362 | # Files built by Visual Studio 363 | *_i.c 364 | *_p.c 365 | *_h.h 366 | *.ilk 367 | *.meta 368 | *.obj 369 | *.iobj 370 | *.pch 371 | *.pdb 372 | *.ipdb 373 | *.pgc 374 | *.pgd 375 | *.rsp 376 | *.sbr 377 | *.tlb 378 | *.tli 379 | *.tlh 380 | *.tmp 381 | *.tmp_proj 382 | *_wpftmp.csproj 383 | *.log 384 | *.vspscc 385 | *.vssscc 386 | .builds 387 | *.pidb 388 | *.svclog 389 | *.scc 390 | 391 | # Chutzpah Test files 392 | _Chutzpah* 393 | 394 | # Visual C++ cache files 395 | ipch/ 396 | *.aps 397 | *.ncb 398 | *.opendb 399 | *.opensdf 400 | *.sdf 401 | *.cachefile 402 | *.VC.db 403 | *.VC.VC.opendb 404 | 405 | # Visual Studio profiler 406 | *.psess 407 | *.vsp 408 | *.vspx 409 | *.sap 410 | 411 | # Visual Studio Trace Files 412 | *.e2e 413 | 414 | # TFS 2012 Local Workspace 415 | $tf/ 416 | 417 | # Guidance Automation Toolkit 418 | *.gpState 419 | 420 | # ReSharper is a .NET coding add-in 421 | _ReSharper*/ 422 | *.[Rr]e[Ss]harper 423 | *.DotSettings.user 424 | 425 | # TeamCity is a build add-in 426 | _TeamCity* 427 | 428 | # DotCover is a Code Coverage Tool 429 | *.dotCover 430 | 431 | # AxoCover is a Code Coverage Tool 432 | .axoCover/* 433 | !.axoCover/settings.json 434 | 435 | # Coverlet is a free, cross platform Code Coverage Tool 436 | coverage*[.json, .xml, .info] 437 | 438 | # Visual Studio code coverage results 439 | *.coverage 440 | *.coveragexml 441 | 442 | # NCrunch 443 | _NCrunch_* 444 | .*crunch*.local.xml 445 | nCrunchTemp_* 446 | 447 | # MightyMoose 448 | *.mm.* 449 | AutoTest.Net/ 450 | 451 | # Web workbench (sass) 452 | .sass-cache/ 453 | 454 | # Installshield output folder 455 | [Ee]xpress/ 456 | 457 | # DocProject is a documentation generator add-in 458 | DocProject/buildhelp/ 459 | DocProject/Help/*.HxT 460 | DocProject/Help/*.HxC 461 | DocProject/Help/*.hhc 462 | DocProject/Help/*.hhk 463 | DocProject/Help/*.hhp 464 | DocProject/Help/Html2 465 | DocProject/Help/html 466 | 467 | # Click-Once directory 468 | publish/ 469 | 470 | # Publish Web Output 471 | *.[Pp]ublish.xml 472 | *.azurePubxml 473 | # Note: Comment the next line if you want to checkin your web deploy settings, 474 | # but database connection strings (with potential passwords) will be unencrypted 475 | *.pubxml 476 | *.publishproj 477 | 478 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 479 | # checkin your Azure Web App publish settings, but sensitive information contained 480 | # in these scripts will be unencrypted 481 | PublishScripts/ 482 | 483 | # NuGet Packages 484 | *.nupkg 485 | # NuGet Symbol Packages 486 | *.snupkg 487 | # The packages folder can be ignored because of Package Restore 488 | **/[Pp]ackages/* 489 | # except build/, which is used as an MSBuild target. 490 | !**/[Pp]ackages/build/ 491 | # Uncomment if necessary however generally it will be regenerated when needed 492 | #!**/[Pp]ackages/repositories.config 493 | # NuGet v3's project.json files produces more ignorable files 494 | *.nuget.props 495 | *.nuget.targets 496 | 497 | # Microsoft Azure Build Output 498 | csx/ 499 | *.build.csdef 500 | 501 | # Microsoft Azure Emulator 502 | ecf/ 503 | rcf/ 504 | 505 | # Windows Store app package directories and files 506 | AppPackages/ 507 | BundleArtifacts/ 508 | Package.StoreAssociation.xml 509 | _pkginfo.txt 510 | *.appx 511 | *.appxbundle 512 | *.appxupload 513 | 514 | # Visual Studio cache files 515 | # files ending in .cache can be ignored 516 | *.[Cc]ache 517 | # but keep track of directories ending in .cache 518 | !?*.[Cc]ache/ 519 | 520 | # Others 521 | ClientBin/ 522 | ~$* 523 | *.dbmdl 524 | *.dbproj.schemaview 525 | *.jfm 526 | *.pfx 527 | *.publishsettings 528 | orleans.codegen.cs 529 | 530 | # Including strong name files can present a security risk 531 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 532 | #*.snk 533 | 534 | # Since there are multiple workflows, uncomment next line to ignore bower_components 535 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 536 | #bower_components/ 537 | 538 | # RIA/Silverlight projects 539 | Generated_Code/ 540 | 541 | # Backup & report files from converting an old project file 542 | # to a newer Visual Studio version. Backup files are not needed, 543 | # because we have git ;-) 544 | _UpgradeReport_Files/ 545 | Backup*/ 546 | UpgradeLog*.XML 547 | UpgradeLog*.htm 548 | ServiceFabricBackup/ 549 | *.rptproj.bak 550 | 551 | # SQL Server files 552 | *.mdf 553 | *.ldf 554 | *.ndf 555 | 556 | # Business Intelligence projects 557 | *.rdl.data 558 | *.bim.layout 559 | *.bim_*.settings 560 | *.rptproj.rsuser 561 | *- [Bb]ackup.rdl 562 | *- [Bb]ackup ([0-9]).rdl 563 | *- [Bb]ackup ([0-9][0-9]).rdl 564 | 565 | # Microsoft Fakes 566 | FakesAssemblies/ 567 | 568 | # GhostDoc plugin setting file 569 | *.GhostDoc.xml 570 | 571 | # Node.js Tools for Visual Studio 572 | .ntvs_analysis.dat 573 | node_modules/ 574 | 575 | # Visual Studio 6 build log 576 | *.plg 577 | 578 | # Visual Studio 6 workspace options file 579 | *.opt 580 | 581 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 582 | *.vbw 583 | 584 | # Visual Studio LightSwitch build output 585 | **/*.HTMLClient/GeneratedArtifacts 586 | **/*.DesktopClient/GeneratedArtifacts 587 | **/*.DesktopClient/ModelManifest.xml 588 | **/*.Server/GeneratedArtifacts 589 | **/*.Server/ModelManifest.xml 590 | _Pvt_Extensions 591 | 592 | # Paket dependency manager 593 | .paket/paket.exe 594 | paket-files/ 595 | 596 | # FAKE - F# Make 597 | .fake/ 598 | 599 | # CodeRush personal settings 600 | .cr/personal 601 | 602 | # Python Tools for Visual Studio (PTVS) 603 | __pycache__/ 604 | *.pyc 605 | 606 | # Cake - Uncomment if you are using it 607 | # tools/** 608 | # !tools/packages.config 609 | 610 | # Tabs Studio 611 | *.tss 612 | 613 | # Telerik's JustMock configuration file 614 | *.jmconfig 615 | 616 | # BizTalk build output 617 | *.btp.cs 618 | *.btm.cs 619 | *.odx.cs 620 | *.xsd.cs 621 | 622 | # OpenCover UI analysis results 623 | OpenCover/ 624 | 625 | # Azure Stream Analytics local run output 626 | ASALocalRun/ 627 | 628 | # MSBuild Binary and Structured Log 629 | *.binlog 630 | 631 | # NVidia Nsight GPU debugger configuration file 632 | *.nvuser 633 | 634 | # MFractors (Xamarin productivity tool) working folder 635 | .mfractor/ 636 | 637 | # Local History for Visual Studio 638 | .localhistory/ 639 | 640 | # BeatPulse healthcheck temp database 641 | healthchecksdb 642 | 643 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 644 | MigrationBackup/ 645 | 646 | # Ionide (cross platform F# VS Code tools) working folder 647 | .ionide/ 648 | 649 | # End of https://www.toptal.com/developers/gitignore/api/intellij,intellij+all,macos,linux,windows,visualstudio,visualstudiocode,rider -------------------------------------------------------------------------------- /ManagedCode.TimeSeries/Abstractions/BaseTimeSeries.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Concurrent; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Runtime.CompilerServices; 7 | using System.Runtime.InteropServices; 8 | using System.Threading; 9 | using ManagedCode.TimeSeries.Extensions; 10 | 11 | namespace ManagedCode.TimeSeries.Abstractions; 12 | 13 | public abstract class BaseTimeSeries : 14 | ITimeSeries where TSelf : BaseTimeSeries 15 | { 16 | private const int DefaultSampleCount = 100; 17 | 18 | private readonly ConcurrentDictionary _samples = new(); 19 | private AtomicDateTimeOffset _start; 20 | private AtomicDateTimeOffset _end; 21 | private AtomicDateTimeOffset _lastDate; 22 | private long _dataCount; 23 | private int _maxSamplesCount; 24 | 25 | protected BaseTimeSeries(TimeSpan sampleInterval, int maxSamplesCount) 26 | { 27 | SampleInterval = sampleInterval; 28 | MaxSamplesCount = maxSamplesCount; 29 | 30 | var now = DateTimeOffset.UtcNow.Round(SampleInterval); 31 | _start.Write(now); 32 | _end.Write(now); 33 | _lastDate.Write(now); 34 | } 35 | 36 | protected BaseTimeSeries(TimeSpan sampleInterval, int maxSamplesCount, DateTimeOffset start, DateTimeOffset end, DateTimeOffset lastDate) 37 | { 38 | SampleInterval = sampleInterval; 39 | MaxSamplesCount = maxSamplesCount; 40 | 41 | _start.Write(start.Round(SampleInterval)); 42 | _end.Write(end.Round(SampleInterval)); 43 | _lastDate.Write(lastDate); 44 | 45 | foreach (var key in _samples.Keys) 46 | { 47 | _samples.TryRemove(key, out _); 48 | } 49 | } 50 | 51 | protected ConcurrentDictionary Storage => _samples; 52 | 53 | public IReadOnlyDictionary Samples => new OrderedSampleView(_samples); 54 | 55 | public DateTimeOffset Start 56 | { 57 | get => _start.Read(); 58 | internal set => _start.Write(value); 59 | } 60 | 61 | public DateTimeOffset End 62 | { 63 | get => _end.Read(); 64 | protected set => _end.Write(value); 65 | } 66 | 67 | public DateTimeOffset LastDate 68 | { 69 | get => _lastDate.Read(); 70 | protected set => _lastDate.Write(value); 71 | } 72 | 73 | public TimeSpan SampleInterval { get; protected set; } 74 | 75 | public int MaxSamplesCount 76 | { 77 | get => Volatile.Read(ref _maxSamplesCount); 78 | protected set => Volatile.Write(ref _maxSamplesCount, value); 79 | } 80 | 81 | public ulong DataCount => unchecked((ulong)Volatile.Read(ref _dataCount)); 82 | 83 | internal void InitInternal(Dictionary samples, 84 | DateTimeOffset start, DateTimeOffset end, 85 | DateTimeOffset lastDate, 86 | ulong dataCount) 87 | { 88 | _samples.Clear(); 89 | foreach (var kvp in samples) 90 | { 91 | _samples.TryAdd(kvp.Key, kvp.Value); 92 | } 93 | 94 | _start.Write(start); 95 | _end.Write(end); 96 | _lastDate.Write(lastDate); 97 | Volatile.Write(ref _dataCount, unchecked((long)dataCount)); 98 | } 99 | 100 | public bool IsFull => _samples.Count >= MaxSamplesCount; 101 | public bool IsEmpty => _samples.IsEmpty; 102 | public bool IsOverflow => MaxSamplesCount > 0 && _samples.Count > MaxSamplesCount; 103 | 104 | public abstract void Resample(TimeSpan sampleInterval, int samplesCount); 105 | 106 | public static TSelf Empty(TimeSpan? sampleInterval = null, int maxSamplesCount = 0) 107 | { 108 | if (sampleInterval is null || sampleInterval.Value <= TimeSpan.Zero) 109 | { 110 | throw new ArgumentOutOfRangeException(nameof(sampleInterval), "Sample interval must be a positive value."); 111 | } 112 | 113 | return (TSelf)Activator.CreateInstance(typeof(TSelf), sampleInterval.Value, maxSamplesCount)!; 114 | } 115 | 116 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 117 | public abstract void Merge(TSelf accumulator); 118 | 119 | public void AddNewData(T data) 120 | { 121 | var now = DateTimeOffset.UtcNow; 122 | var rounded = now.Round(SampleInterval); 123 | 124 | Interlocked.Increment(ref _dataCount); 125 | AddData(rounded, data); 126 | 127 | UpdateEnd(rounded); 128 | _lastDate.Write(now); 129 | } 130 | 131 | public void AddNewData(DateTimeOffset dateTimeOffset, T data) 132 | { 133 | var rounded = dateTimeOffset.Round(SampleInterval); 134 | 135 | Interlocked.Increment(ref _dataCount); 136 | AddData(rounded, data); 137 | 138 | UpdateEnd(rounded); 139 | _lastDate.Write(rounded); 140 | } 141 | 142 | public void MarkupAllSamples(MarkupDirection direction = MarkupDirection.Past) 143 | { 144 | var samples = MaxSamplesCount > 0 ? MaxSamplesCount : DefaultSampleCount; 145 | 146 | if (direction is MarkupDirection.Past or MarkupDirection.Feature) 147 | { 148 | var cursor = Start; 149 | for (var i = 0; i < samples; i++) 150 | { 151 | cursor = cursor.Round(SampleInterval); 152 | _ = GetOrCreateSample(cursor, static () => Activator.CreateInstance()!); 153 | cursor = direction is MarkupDirection.Feature ? cursor.Add(SampleInterval) : cursor.Subtract(SampleInterval); 154 | } 155 | } 156 | else 157 | { 158 | var forward = Start; 159 | var backward = Start; 160 | 161 | for (var i = 0; i < samples / 2 + 1; i++) 162 | { 163 | forward = forward.Round(SampleInterval); 164 | backward = backward.Round(SampleInterval); 165 | 166 | _ = GetOrCreateSample(forward, static () => Activator.CreateInstance()!); 167 | _ = GetOrCreateSample(backward, static () => Activator.CreateInstance()!); 168 | 169 | forward = forward.Add(SampleInterval); 170 | backward = backward.Subtract(SampleInterval); 171 | } 172 | } 173 | } 174 | 175 | public void DeleteOverdueSamples() 176 | { 177 | if (MaxSamplesCount <= 0 || _samples.IsEmpty) 178 | { 179 | return; 180 | } 181 | 182 | var threshold = DateTimeOffset.UtcNow.Round(SampleInterval); 183 | for (var i = 0; i < MaxSamplesCount; i++) 184 | { 185 | threshold = threshold.Subtract(SampleInterval); 186 | } 187 | 188 | foreach (var key in _samples.Keys) 189 | { 190 | if (key < threshold) 191 | { 192 | _samples.TryRemove(key, out _); 193 | } 194 | } 195 | 196 | RecalculateRange(); 197 | } 198 | 199 | public TSelf Rebase(IEnumerable accumulators) 200 | { 201 | var empty = (TSelf)Activator.CreateInstance(typeof(TSelf), SampleInterval, MaxSamplesCount)!; 202 | 203 | foreach (var accumulator in accumulators) 204 | { 205 | empty.Merge(accumulator); 206 | } 207 | 208 | return empty; 209 | } 210 | 211 | public void Merge(IEnumerable accumulators) 212 | { 213 | foreach (var accumulator in accumulators) 214 | { 215 | Merge(accumulator); 216 | } 217 | } 218 | 219 | public TSelf Rebase(TSelf accumulator) 220 | { 221 | var empty = (TSelf)Activator.CreateInstance(typeof(TSelf), SampleInterval, MaxSamplesCount)!; 222 | empty.Merge(accumulator); 223 | 224 | return empty; 225 | } 226 | 227 | public static TSelf operator +(BaseTimeSeries left, TSelf right) 228 | { 229 | return left.Rebase(right); 230 | } 231 | 232 | public static TSelf operator checked +(BaseTimeSeries left, TSelf right) 233 | { 234 | return left.Rebase(right); 235 | } 236 | 237 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 238 | protected abstract void AddData(DateTimeOffset now, T data); 239 | 240 | protected void AddToDataCount(ulong value) 241 | { 242 | if (value == 0) 243 | { 244 | return; 245 | } 246 | 247 | Interlocked.Add(ref _dataCount, unchecked((long)value)); 248 | } 249 | 250 | protected void SetDataCount(ulong value) 251 | { 252 | Volatile.Write(ref _dataCount, unchecked((long)value)); 253 | } 254 | 255 | protected TSample GetOrCreateSample(DateTimeOffset key, Func factory) 256 | { 257 | while (true) 258 | { 259 | if (_samples.TryGetValue(key, out var existing)) 260 | { 261 | UpdateRange(key); 262 | return existing; 263 | } 264 | 265 | var created = factory(); 266 | if (_samples.TryAdd(key, created)) 267 | { 268 | UpdateOnAdd(key); 269 | return created; 270 | } 271 | } 272 | } 273 | 274 | protected TSample AddOrUpdateSample(DateTimeOffset key, Func addFactory, Func updateFactory) 275 | { 276 | while (true) 277 | { 278 | if (_samples.TryGetValue(key, out var existing)) 279 | { 280 | var updated = updateFactory(existing); 281 | if (_samples.TryUpdate(key, updated, existing)) 282 | { 283 | UpdateRange(key); 284 | return updated; 285 | } 286 | 287 | continue; 288 | } 289 | 290 | var created = addFactory(); 291 | if (_samples.TryAdd(key, created)) 292 | { 293 | UpdateOnAdd(key); 294 | return created; 295 | } 296 | } 297 | } 298 | 299 | protected void ResetSamplesStorage() 300 | { 301 | _samples.Clear(); 302 | var now = DateTimeOffset.UtcNow.Round(SampleInterval); 303 | _start.Write(now); 304 | _end.Write(now); 305 | } 306 | 307 | private void UpdateOnAdd(DateTimeOffset key) 308 | { 309 | UpdateRange(key); 310 | EnsureCapacity(); 311 | } 312 | 313 | private void UpdateEnd(DateTimeOffset key) 314 | { 315 | _end.TrySetLater(key); 316 | _start.TrySetEarlier(key); 317 | } 318 | 319 | private void UpdateRange(DateTimeOffset key) 320 | { 321 | _start.TrySetEarlier(key); 322 | _end.TrySetLater(key); 323 | } 324 | 325 | private void EnsureCapacity() 326 | { 327 | if (MaxSamplesCount <= 0) 328 | { 329 | return; 330 | } 331 | 332 | while (_samples.Count > MaxSamplesCount) 333 | { 334 | if (!TryRemoveOldest()) 335 | { 336 | break; 337 | } 338 | } 339 | } 340 | 341 | private bool TryRemoveOldest() 342 | { 343 | DateTimeOffset oldest = DateTimeOffset.MaxValue; 344 | foreach (var key in _samples.Keys) 345 | { 346 | if (key < oldest) 347 | { 348 | oldest = key; 349 | } 350 | } 351 | 352 | if (oldest == DateTimeOffset.MaxValue) 353 | { 354 | return false; 355 | } 356 | 357 | var removed = _samples.TryRemove(oldest, out _); 358 | if (removed) 359 | { 360 | RecalculateRange(); 361 | } 362 | 363 | return removed; 364 | } 365 | 366 | protected void RecalculateRange() 367 | { 368 | DateTimeOffset min = DateTimeOffset.MaxValue; 369 | DateTimeOffset max = DateTimeOffset.MinValue; 370 | 371 | foreach (var key in _samples.Keys) 372 | { 373 | if (key < min) 374 | { 375 | min = key; 376 | } 377 | 378 | if (key > max) 379 | { 380 | max = key; 381 | } 382 | } 383 | 384 | if (min == DateTimeOffset.MaxValue) 385 | { 386 | var now = DateTimeOffset.UtcNow.Round(SampleInterval); 387 | _start.Write(now); 388 | _end.Write(now); 389 | return; 390 | } 391 | 392 | _start.Write(min); 393 | _end.Write(max); 394 | } 395 | 396 | private sealed class OrderedSampleView : IReadOnlyDictionary 397 | { 398 | private readonly ConcurrentDictionary _source; 399 | 400 | public OrderedSampleView(ConcurrentDictionary source) 401 | { 402 | _source = source; 403 | } 404 | 405 | public IEnumerable Keys => _source.Keys.OrderBy(static key => key); 406 | 407 | public IEnumerable Values => _source.OrderBy(static pair => pair.Key).Select(static pair => pair.Value); 408 | 409 | public int Count => _source.Count; 410 | 411 | public TSample this[DateTimeOffset key] => _source[key]; 412 | 413 | public bool ContainsKey(DateTimeOffset key) => _source.ContainsKey(key); 414 | 415 | public bool TryGetValue(DateTimeOffset key, out TSample value) => _source.TryGetValue(key, out value); 416 | 417 | public IEnumerator> GetEnumerator() => _source.OrderBy(static pair => pair.Key).GetEnumerator(); 418 | 419 | IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); 420 | } 421 | } 422 | 423 | internal struct AtomicDateTimeOffset 424 | { 425 | private long _utcTicks; 426 | private int _offsetMinutes; 427 | 428 | public DateTimeOffset Read() 429 | { 430 | var ticks = Volatile.Read(ref _utcTicks); 431 | var offsetMinutes = Volatile.Read(ref _offsetMinutes); 432 | 433 | if (ticks == 0 && offsetMinutes == 0) 434 | { 435 | return default; 436 | } 437 | 438 | var utc = new DateTime(ticks == 0 ? 0 : ticks, DateTimeKind.Utc); 439 | var offset = TimeSpan.FromMinutes(offsetMinutes); 440 | return new DateTimeOffset(utc, TimeSpan.Zero).ToOffset(offset); 441 | } 442 | 443 | public void Write(DateTimeOffset value) 444 | { 445 | Volatile.Write(ref _utcTicks, value.UtcTicks); 446 | Volatile.Write(ref _offsetMinutes, (int)value.Offset.TotalMinutes); 447 | } 448 | 449 | public void TrySetEarlier(DateTimeOffset candidate) 450 | { 451 | while (true) 452 | { 453 | var currentTicks = Volatile.Read(ref _utcTicks); 454 | if (currentTicks != 0 && candidate.UtcTicks >= currentTicks) 455 | { 456 | return; 457 | } 458 | 459 | if (Interlocked.CompareExchange(ref _utcTicks, candidate.UtcTicks, currentTicks) == currentTicks) 460 | { 461 | Volatile.Write(ref _offsetMinutes, (int)candidate.Offset.TotalMinutes); 462 | return; 463 | } 464 | } 465 | } 466 | 467 | public void TrySetLater(DateTimeOffset candidate) 468 | { 469 | while (true) 470 | { 471 | var currentTicks = Volatile.Read(ref _utcTicks); 472 | if (currentTicks != 0 && candidate.UtcTicks <= currentTicks) 473 | { 474 | return; 475 | } 476 | 477 | if (Interlocked.CompareExchange(ref _utcTicks, candidate.UtcTicks, currentTicks) == currentTicks) 478 | { 479 | Volatile.Write(ref _offsetMinutes, (int)candidate.Offset.TotalMinutes); 480 | return; 481 | } 482 | } 483 | } 484 | } 485 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Tests/SummersTests.cs: -------------------------------------------------------------------------------- 1 | using Shouldly; 2 | using ManagedCode.TimeSeries.Abstractions; 3 | using ManagedCode.TimeSeries.Accumulators; 4 | using ManagedCode.TimeSeries.Extensions; 5 | using ManagedCode.TimeSeries.Summers; 6 | using ManagedCode.TimeSeries.Tests.Assertions; 7 | using Xunit; 8 | 9 | namespace ManagedCode.TimeSeries.Tests; 10 | 11 | public class SummersTests 12 | { 13 | [Fact] 14 | public async Task Accumulator() 15 | { 16 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1)); 17 | for (var i = 0; i < 1000; i++) 18 | { 19 | await Task.Delay(new Random().Next(1, 5)); 20 | series.AddNewData(i); 21 | } 22 | 23 | series.DataCount.ShouldBe(1000ul); 24 | 25 | var step = 0; 26 | foreach (var queue in series.Samples) 27 | { 28 | foreach (var item in queue.Value) 29 | { 30 | item.ShouldBe(step); 31 | step++; 32 | } 33 | } 34 | } 35 | 36 | // [Fact] 37 | // public async Task AccumulatorByString() 38 | // { 39 | // var rnd = new Random(); 40 | // var series = new StringTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1)); 41 | // 42 | // var dt = DateTimeOffset.Now; 43 | // series.AddNewData(dt, "1"); 44 | // series.AddNewData(dt, "1"); 45 | // series.AddNewData(dt, "2"); 46 | // series.AddNewData(dt, "3"); 47 | // series.AddNewData(dt, "3"); 48 | // series.AddNewData(dt, "2"); 49 | // 50 | // 51 | // dt = dt.AddHours(5); 52 | // series.AddNewData(dt, "1"); 53 | // series.AddNewData(dt, "1"); 54 | // series.AddNewData(dt, "2"); 55 | // series.AddNewData(dt, "3"); 56 | // series.AddNewData(dt, "3"); 57 | // series.AddNewData(dt, "2"); 58 | // 59 | // series.DataCount.ShouldBe(12); 60 | // series.Samples.First().Value.Count.ShouldBe(3); 61 | // series.Samples.Last().Value.Count.ShouldBe(3); 62 | // } 63 | 64 | [Fact] 65 | public async Task AccumulatorLimit() 66 | { 67 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1), 10); 68 | 69 | for (var i = 0; i < 1000; i++) 70 | { 71 | await Task.Delay(new Random().Next(1, 5)); 72 | series.AddNewData(i); 73 | } 74 | 75 | series.Samples.Count.ShouldBe(10); 76 | } 77 | 78 | [Fact] 79 | public async Task IsFull() 80 | { 81 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1), 10); 82 | 83 | for (var i = 0; i < 1000; i++) 84 | { 85 | await Task.Delay(new Random().Next(1, 5)); 86 | 87 | if (series.IsFull) 88 | { 89 | break; 90 | } 91 | 92 | series.AddNewData(i); 93 | } 94 | 95 | series.IsFull.ShouldBeTrue(); 96 | } 97 | 98 | [Fact] 99 | public void IsEmpty() 100 | { 101 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1), 10); 102 | series.IsEmpty.ShouldBeTrue(); 103 | } 104 | 105 | [Fact] 106 | public async Task AccumulatorMerge() 107 | { 108 | Func> FillFunc = async () => 109 | { 110 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1), 10); 111 | for (var i = 0; i < 1000; i++) 112 | { 113 | await Task.Delay(new Random().Next(1, 5)); 114 | series.AddNewData(i); 115 | } 116 | 117 | return series; 118 | }; 119 | 120 | var seriesA = FillFunc(); 121 | var seriesB = FillFunc(); 122 | 123 | await Task.WhenAll(seriesA, seriesB); 124 | 125 | seriesA.Result.Samples.Count.ShouldBe(10); 126 | seriesB.Result.Samples.Count.ShouldBe(10); 127 | 128 | seriesA.Result.Merge(seriesB.Result); 129 | 130 | seriesA.Result.Samples.Count.ShouldBe(10); 131 | 132 | var seriesList = new List(); 133 | seriesList.Add(await FillFunc()); 134 | seriesList.Add(await FillFunc()); 135 | seriesList.Add(await FillFunc()); 136 | seriesList.Add(new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(0.1), 10)); 137 | 138 | IntTimeSeriesAccumulator onlineExpertsPerHourTimeSeries = null; 139 | foreach (var item in seriesList.ToArray()) 140 | { 141 | if (onlineExpertsPerHourTimeSeries == null) 142 | { 143 | onlineExpertsPerHourTimeSeries = item; 144 | } 145 | else 146 | { 147 | onlineExpertsPerHourTimeSeries.Merge(item); 148 | } 149 | } 150 | 151 | onlineExpertsPerHourTimeSeries.Samples.Count.ShouldBe(10); 152 | } 153 | 154 | [Fact] 155 | public void IntTimeSeriesSummerIncrementDecrement() 156 | { 157 | var series = new IntTimeSeriesSummer(TimeSpan.FromMinutes(1), 10); 158 | for (var i = 0; i < 100; i++) 159 | { 160 | series.Increment(); 161 | } 162 | 163 | for (var i = 0; i < 50; i++) 164 | { 165 | series.Decrement(); 166 | } 167 | 168 | series.DataCount.ShouldBe(150ul); 169 | series.Samples.First().Value.ShouldBe(50); 170 | } 171 | 172 | [Fact] 173 | public async Task Summer() 174 | { 175 | var series = new IntTimeSeriesSummer(TimeSpan.FromSeconds(0.1)); 176 | var count = 0; 177 | for (var i = 0; i < 100; i++) 178 | { 179 | await Task.Delay(new Random().Next(10, 50)); 180 | series.AddNewData(i); 181 | count++; 182 | } 183 | 184 | series.DataCount.ShouldBe((ulong) count); 185 | } 186 | 187 | [Fact] 188 | public void NumberTimeSeriesSummerSupportsLong() 189 | { 190 | var baseTime = DateTimeOffset.UtcNow; 191 | var series = new NumberTimeSeriesSummer(TimeSpan.FromSeconds(1)); 192 | 193 | series.AddNewData(baseTime, 10); 194 | series.AddNewData(baseTime.AddSeconds(1), 15); 195 | 196 | series.Sum().ShouldBe(25); 197 | series.Min().ShouldBe(10); 198 | series.Max().ShouldBe(15); 199 | series.Average().ShouldBe(12); 200 | } 201 | 202 | [Fact] 203 | public void NumberGroupTimeSeriesSummerAggregatesDecimal() 204 | { 205 | var group = new NumberGroupTimeSeriesSummer(TimeSpan.FromSeconds(1), samplesCount: 32, strategy: Strategy.Sum, deleteOverdueSamples: false); 206 | 207 | group.AddNewData("a", 1.5m); 208 | group.AddNewData("a", 2.0m); 209 | group.AddNewData("b", 3.0m); 210 | 211 | group.Sum().ShouldBe(6.5m); 212 | group.Min().ShouldBe(3.0m); 213 | group.Max().ShouldBe(3.5m); 214 | group.Average().ShouldBe(3.25m); 215 | } 216 | 217 | [Fact] 218 | public void NumberGroupTimeSeriesSummerHandlesEmptyState() 219 | { 220 | var group = new NumberGroupTimeSeriesSummer(TimeSpan.FromSeconds(1), deleteOverdueSamples: false); 221 | 222 | group.Sum().ShouldBe(0); 223 | group.Average().ShouldBe(0); 224 | group.Min().ShouldBe(0); 225 | group.Max().ShouldBe(0); 226 | } 227 | 228 | [Fact] 229 | public void IntGroupNumberTimeSeriesSummerAggregatesValues() 230 | { 231 | var group = new IntGroupNumberTimeSeriesSummer(TimeSpan.FromMilliseconds(5), samplesCount: 8, strategy: Strategy.Sum, deleteOverdueSamples: false); 232 | 233 | group.AddNewData("alpha", 1); 234 | group.AddNewData("alpha", 2); 235 | group.AddNewData("beta", 3); 236 | 237 | group.Sum().ShouldBe(6); 238 | group.Max().ShouldBe(3); 239 | group.Min().ShouldBe(3); 240 | } 241 | 242 | [Fact] 243 | public void DoubleGroupTimeSeriesSummerAggregatesValues() 244 | { 245 | var group = new DoubleGroupTimeSeriesSummer(TimeSpan.FromSeconds(1), samplesCount: 8, strategy: Strategy.Sum, deleteOverdueSamples: false); 246 | 247 | group.AddNewData("sensor", 1.5); 248 | group.AddNewData("sensor", 2.5); 249 | group.AddNewData("backup", 2.0); 250 | 251 | group.Sum().ShouldBe(6.0); 252 | group.Max().ShouldBe(4.0); 253 | group.Min().ShouldBe(2.0); 254 | } 255 | 256 | [Fact] 257 | public void FloatGroupNumberTimeSeriesSummerAggregatesValues() 258 | { 259 | var group = new FloatGroupNumberTimeSeriesSummer(TimeSpan.FromMilliseconds(100), samplesCount: 4, strategy: Strategy.Sum, deleteOverdueSamples: false); 260 | 261 | group.AddNewData("alpha", 1.0f); 262 | group.AddNewData("alpha", 2.0f); 263 | group.AddNewData("beta", 3.5f); 264 | 265 | group.Sum().ShouldBe(6.5f); 266 | group.Max().ShouldBe(3.5f); 267 | group.Min().ShouldBe(3.0f); 268 | } 269 | 270 | [Fact] 271 | public void NumberTimeSeriesSummerResampleAggregatesBuckets() 272 | { 273 | var start = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); 274 | var summer = new NumberTimeSeriesSummer(TimeSpan.FromSeconds(1), maxSamplesCount: 10); 275 | 276 | summer.AddNewData(start, 1); 277 | summer.AddNewData(start.AddSeconds(1), 2); 278 | summer.AddNewData(start.AddSeconds(2), 3); 279 | 280 | summer.Resample(TimeSpan.FromSeconds(2), 10); 281 | 282 | summer.SampleInterval.ShouldBe(TimeSpan.FromSeconds(2)); 283 | summer.Samples.ShouldHaveCount(2); 284 | summer.Samples[start].ShouldBe(3); 285 | summer.Samples[start.AddSeconds(2)].ShouldBe(3); 286 | } 287 | 288 | [Fact] 289 | public void NumberTimeSeriesSummerResamplePreservesTotals() 290 | { 291 | var interval = TimeSpan.FromMilliseconds(20); 292 | var summer = new NumberTimeSeriesSummer(interval, maxSamplesCount: 8); 293 | var start = DateTimeOffset.UtcNow; 294 | 295 | for (var i = 0; i < 6; i++) 296 | { 297 | summer.AddNewData(start.AddMilliseconds(i * interval.TotalMilliseconds), 2); 298 | } 299 | 300 | var countBefore = summer.DataCount; 301 | var lastBefore = summer.LastDate; 302 | 303 | summer.Resample(TimeSpan.FromMilliseconds(40), samplesCount: 4); 304 | 305 | summer.DataCount.ShouldBe(countBefore); 306 | summer.LastDate.ShouldBe(lastBefore.Round(TimeSpan.FromMilliseconds(40))); 307 | summer.Sum().ShouldBe(12); 308 | } 309 | 310 | [Fact] 311 | public void DoubleTimeSeriesSummerConstructorsRespectStrategy() 312 | { 313 | var explicitStrategy = new DoubleTimeSeriesSummer(TimeSpan.FromMilliseconds(5), maxSamplesCount: 3, strategy: Strategy.Max); 314 | explicitStrategy.Strategy.ShouldBe(Strategy.Max); 315 | 316 | var withCount = new DoubleTimeSeriesSummer(TimeSpan.FromMilliseconds(5), maxSamplesCount: 2); 317 | withCount.Strategy.ShouldBe(Strategy.Sum); 318 | 319 | var defaultCount = new DoubleTimeSeriesSummer(TimeSpan.FromMilliseconds(5)); 320 | defaultCount.Strategy.ShouldBe(Strategy.Sum); 321 | } 322 | 323 | [Fact] 324 | public void FloatTimeSeriesSummerAggregatesValues() 325 | { 326 | var summer = new FloatTimeSeriesSummer(TimeSpan.FromMilliseconds(30), maxSamplesCount: 4); 327 | var now = DateTimeOffset.UtcNow; 328 | 329 | summer.AddNewData(now, 1.5f); 330 | summer.AddNewData(now.AddMilliseconds(30), 2.5f); 331 | 332 | summer.Sum().ShouldBe(4.0f); 333 | summer.Min().ShouldNotBeNull(); 334 | summer.Max().ShouldNotBeNull(); 335 | } 336 | 337 | // [Fact] 338 | // public async Task SummerGroup() 339 | // { 340 | // var interval = TimeSpan.FromSeconds(0.1); 341 | // var series = new IntGroupTimeSeriesSummer(interval, 100, Strategy.Sum, true); 342 | // var count = 0; 343 | // for (var i = 0; i < 100; i++) 344 | // { 345 | // await Task.Delay(new Random().Next(10, 50)); 346 | // series.AddNewData((i % 10).ToString(), i); 347 | // count++; 348 | // } 349 | // 350 | // series.TimeSeries.Count.ShouldBeGreaterThan(0); 351 | // await Task.Delay(interval * 102); 352 | // 353 | // series.TimeSeries.Count.ShouldBe(0); 354 | // } 355 | // 356 | // [Fact] 357 | // public async Task SummerGroupMax() 358 | // { 359 | // var interval = TimeSpan.FromSeconds(5); 360 | // var series = new IntGroupTimeSeriesSummer(interval, 100, Strategy.Max, true); 361 | // var count = 0; 362 | // for (var i = 0; i < 100; i++) 363 | // { 364 | // series.AddNewData("host", i); 365 | // count++; 366 | // } 367 | // 368 | // series.TimeSeries.Count.ShouldBe(1); 369 | // series.TimeSeries.Values.SingleOrDefault().Samples.SingleOrDefault().Value.ShouldBe(99); 370 | // } 371 | 372 | [Fact] 373 | public async Task SummerMerge() 374 | { 375 | Func> FillFunc = async () => 376 | { 377 | var series = new IntTimeSeriesSummer(TimeSpan.FromSeconds(0.1)); 378 | 379 | for (var i = 0; i < 100; i++) 380 | { 381 | await Task.Delay(new Random().Next(10, 50)); 382 | series.AddNewData(1); 383 | } 384 | 385 | return series; 386 | }; 387 | 388 | var seriesA = FillFunc(); 389 | var seriesB = FillFunc(); 390 | 391 | await Task.WhenAll(seriesA, seriesB); 392 | 393 | seriesA.Result.DataCount.ShouldBe(100ul); 394 | seriesB.Result.DataCount.ShouldBe(100ul); 395 | 396 | seriesA.Result.Merge(seriesB.Result); 397 | 398 | seriesA.Result.DataCount.ShouldBe(200ul); 399 | 400 | seriesA.Result.Samples.Select(s => s.Value).Sum().ShouldBe(200); 401 | } 402 | 403 | [Fact] 404 | public async Task Resample() 405 | { 406 | var seriesFeature = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(2), 100); 407 | 408 | for (var i = 0; i < 100; i++) 409 | { 410 | seriesFeature.AddNewData(i); 411 | 412 | await Task.Delay(1); 413 | } 414 | 415 | seriesFeature.Resample(TimeSpan.FromMilliseconds(4), 100); 416 | var sad = seriesFeature; 417 | } 418 | 419 | [Fact] 420 | public async Task ResampleSummer() 421 | { 422 | var seriesFeature = new IntTimeSeriesSummer(TimeSpan.FromMilliseconds(2), 100); 423 | 424 | for (var i = 0; i < 100; i++) 425 | { 426 | seriesFeature.AddNewData(i); 427 | 428 | await Task.Delay(1); 429 | } 430 | 431 | seriesFeature.Resample(TimeSpan.FromMilliseconds(4), 100); 432 | } 433 | 434 | 435 | [Fact] 436 | public void MarkupAllSamples() 437 | { 438 | var seriesFeature = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(10), 100); 439 | seriesFeature.MarkupAllSamples(MarkupDirection.Feature); 440 | seriesFeature.AddNewData(1); 441 | (seriesFeature.Samples.Keys.Max() - seriesFeature.Samples.Keys.Min()).TotalMilliseconds.ShouldBeGreaterThanOrEqualTo(990); 442 | (seriesFeature.Samples.Keys.Max() - seriesFeature.Samples.Keys.Min()).TotalMilliseconds.ShouldBeLessThanOrEqualTo(1000); 443 | var seriesFeatureOrdered = seriesFeature.Samples.OrderBy(o => o.Key).Take(10); 444 | seriesFeatureOrdered.Any(a => a.Value.Count == 1).ShouldBeTrue(); 445 | 446 | var seriesPast = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(10)); 447 | seriesPast.MarkupAllSamples(); 448 | seriesPast.AddNewData(1); 449 | (seriesPast.Samples.Keys.Max() - seriesPast.Samples.Keys.Min()).TotalMilliseconds.ShouldBeGreaterThanOrEqualTo(990); 450 | (seriesPast.Samples.Keys.Max() - seriesPast.Samples.Keys.Min()).TotalMilliseconds.ShouldBeLessThanOrEqualTo(1000); 451 | var seriesPastOrdered = seriesPast.Samples.OrderBy(o => o.Key).TakeLast(10); 452 | seriesPastOrdered.Any(a => a.Value.Count == 1).ShouldBeTrue(); 453 | 454 | var seriesMiddle = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(10), 100); 455 | seriesMiddle.MarkupAllSamples(MarkupDirection.Middle); 456 | seriesMiddle.AddNewData(1); 457 | (seriesMiddle.Samples.Keys.Max() - seriesMiddle.Samples.Keys.Min()).TotalMilliseconds.ShouldBeGreaterThanOrEqualTo(990); 458 | (seriesMiddle.Samples.Keys.Max() - seriesMiddle.Samples.Keys.Min()).TotalMilliseconds.ShouldBeLessThanOrEqualTo(1000); 459 | var seriesMiddleOrdered = seriesMiddle.Samples.OrderBy(o => o.Key).Skip(45).Take(10); 460 | seriesMiddleOrdered.Any(a => a.Value.Count == 1).ShouldBeTrue(); 461 | } 462 | } 463 | -------------------------------------------------------------------------------- /ManagedCode.TimeSeries.Tests/TimeSeriesBehaviorTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Concurrent; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reflection; 7 | using ManagedCode.TimeSeries.Accumulators; 8 | using ManagedCode.TimeSeries.Summers; 9 | using ManagedCode.TimeSeries.Abstractions; 10 | using ManagedCode.TimeSeries.Extensions; 11 | using ManagedCode.TimeSeries.Orleans; 12 | using ManagedCode.TimeSeries.Tests.Assertions; 13 | using Shouldly; 14 | using Xunit; 15 | 16 | namespace ManagedCode.TimeSeries.Tests; 17 | 18 | public class TimeSeriesBehaviorTests 19 | { 20 | private sealed class TestAccumulator : BaseTimeSeriesAccumulator 21 | { 22 | public TestAccumulator(TimeSpan sampleInterval, int maxSamplesCount) 23 | : base(sampleInterval, maxSamplesCount) 24 | { 25 | } 26 | 27 | public TestAccumulator(TimeSpan sampleInterval, int maxSamplesCount, DateTimeOffset start, DateTimeOffset end, DateTimeOffset last) 28 | : base(sampleInterval, maxSamplesCount, start, end, last) 29 | { 30 | } 31 | 32 | public IReadOnlyDictionary> InternalStorage => Storage; 33 | 34 | public void ForceReset() => ResetSamplesStorage(); 35 | 36 | public void ForceSetMaxSamples(int value) => MaxSamplesCount = value; 37 | } 38 | 39 | private sealed class StringSetAccumulator : BaseTimeSeriesByValueAccumulator 40 | { 41 | public StringSetAccumulator(TimeSpan sampleInterval, int samplesCount = 0) 42 | : base(sampleInterval, samplesCount) 43 | { 44 | } 45 | 46 | public override void Resample(TimeSpan sampleInterval, int samplesCount) 47 | { 48 | if (sampleInterval <= SampleInterval) 49 | { 50 | throw new InvalidOperationException(); 51 | } 52 | 53 | SampleInterval = sampleInterval; 54 | MaxSamplesCount = samplesCount; 55 | 56 | var snapshot = Storage.ToArray(); 57 | ResetSamplesStorage(); 58 | foreach (var (key, set) in snapshot) 59 | { 60 | foreach (var value in set.Keys) 61 | { 62 | AddNewData(key, value); 63 | } 64 | } 65 | } 66 | } 67 | 68 | [Fact] 69 | public void Accumulator_DropsOldestEntriesWhenCapacityExceeded() 70 | { 71 | var accumulator = new TestAccumulator(TimeSpan.FromMilliseconds(1), maxSamplesCount: 3); 72 | var start = DateTimeOffset.UnixEpoch; 73 | 74 | for (var i = 0; i < 6; i++) 75 | { 76 | accumulator.AddNewData(start.AddMilliseconds(i), i); 77 | } 78 | 79 | accumulator.Samples.Count.ShouldBe(3); 80 | accumulator.Samples.Keys.ShouldSequenceEqual( 81 | start.AddMilliseconds(3), 82 | start.AddMilliseconds(4), 83 | start.AddMilliseconds(5)); 84 | } 85 | 86 | [Fact] 87 | public void Accumulator_DeleteOverdueSamples_RemovesObsoleteWindows() 88 | { 89 | var accumulator = new TestAccumulator(TimeSpan.FromMilliseconds(100), maxSamplesCount: 4); 90 | var baseTime = DateTimeOffset.UtcNow.AddSeconds(-10); 91 | 92 | for (var i = 0; i < 8; i++) 93 | { 94 | accumulator.AddNewData(baseTime.AddMilliseconds(i * 100), i); 95 | } 96 | 97 | accumulator.Samples.Count.ShouldBe(4); 98 | accumulator.DeleteOverdueSamples(); 99 | accumulator.Samples.Count.ShouldBeLessThanOrEqualTo(4); 100 | accumulator.Samples.Keys.ShouldBeInAscendingOrder(); 101 | } 102 | 103 | [Fact] 104 | public void Accumulator_ResetSamples_ClearsState() 105 | { 106 | var accumulator = new TestAccumulator(TimeSpan.FromMilliseconds(1), maxSamplesCount: 4); 107 | var now = DateTimeOffset.UtcNow; 108 | accumulator.AddNewData(now, 1); 109 | accumulator.InternalStorage.ShouldNotBeEmpty(); 110 | 111 | accumulator.ForceReset(); 112 | 113 | accumulator.Samples.ShouldBeEmpty(); 114 | accumulator.Start.ShouldBeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1)); 115 | accumulator.End.ShouldBeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1)); 116 | } 117 | 118 | [Fact] 119 | public void Accumulator_IsOverflowPropertyAccessible() 120 | { 121 | var accumulator = new TestAccumulator(TimeSpan.FromMilliseconds(1), maxSamplesCount: 1); 122 | accumulator.AddNewData(DateTimeOffset.UtcNow, 42); 123 | accumulator.AddNewData(DateTimeOffset.UtcNow.AddMilliseconds(1), 43); 124 | accumulator.ForceSetMaxSamples(-1); 125 | var overflow = accumulator.IsOverflow; 126 | overflow.ShouldBeFalse(); 127 | } 128 | 129 | [Fact] 130 | public void Accumulator_RebaseCombinesMultipleSources() 131 | { 132 | var source1 = new TestAccumulator(TimeSpan.FromMilliseconds(10), maxSamplesCount: 16); 133 | var source2 = new TestAccumulator(TimeSpan.FromMilliseconds(10), maxSamplesCount: 16); 134 | var baseline = DateTimeOffset.UnixEpoch; 135 | 136 | source1.AddNewData(baseline, 1); 137 | source1.AddNewData(baseline.AddMilliseconds(10), 2); 138 | 139 | source2.AddNewData(baseline.AddMilliseconds(20), 3); 140 | source2.AddNewData(baseline.AddMilliseconds(30), 4); 141 | 142 | var seed = new TestAccumulator(TimeSpan.FromMilliseconds(10), maxSamplesCount: 32); 143 | var rebase = seed.Rebase(new[] { source1, source2 }); 144 | 145 | rebase.Samples.Count.ShouldBe(4); 146 | rebase.DataCount.ShouldBe(4ul); 147 | rebase.Samples.Keys.ShouldSequenceEqual( 148 | baseline, 149 | baseline.AddMilliseconds(10), 150 | baseline.AddMilliseconds(20), 151 | baseline.AddMilliseconds(30)); 152 | } 153 | 154 | [Fact] 155 | public void Accumulator_MergeEnumerableAggregatesSources() 156 | { 157 | var target = new TestAccumulator(TimeSpan.FromMilliseconds(10), maxSamplesCount: 16); 158 | var others = Enumerable.Range(0, 3).Select(index => 159 | { 160 | var acc = new TestAccumulator(TimeSpan.FromMilliseconds(10), maxSamplesCount: 16); 161 | acc.AddNewData(DateTimeOffset.UnixEpoch.AddMilliseconds(index * 10), index); 162 | return acc; 163 | }).ToArray(); 164 | 165 | target.Merge(others); 166 | 167 | target.DataCount.ShouldBe(3ul); 168 | target.Samples.Count.ShouldBe(3); 169 | } 170 | 171 | [Fact] 172 | public void Accumulator_OperatorPlus_ProducesMergedSnapshot() 173 | { 174 | var left = new TestAccumulator(TimeSpan.FromMilliseconds(5), maxSamplesCount: 8); 175 | var right = new TestAccumulator(TimeSpan.FromMilliseconds(5), maxSamplesCount: 8); 176 | 177 | left.AddNewData(DateTimeOffset.UnixEpoch, 1); 178 | right.AddNewData(DateTimeOffset.UnixEpoch.AddMilliseconds(5), 2); 179 | 180 | var merged = left + right; 181 | merged.Samples.Count.ShouldBe(right.Samples.Count); 182 | merged.DataCount.ShouldBe(right.DataCount); 183 | } 184 | 185 | [Fact] 186 | public void Accumulator_CheckedAddition_ProducesMergedSnapshot() 187 | { 188 | var left = new TestAccumulator(TimeSpan.FromMilliseconds(5), maxSamplesCount: 8); 189 | var right = new TestAccumulator(TimeSpan.FromMilliseconds(5), maxSamplesCount: 8); 190 | 191 | left.AddNewData(DateTimeOffset.UnixEpoch, 1); 192 | right.AddNewData(DateTimeOffset.UnixEpoch.AddMilliseconds(5), 2); 193 | 194 | var merged = checked(left + right); 195 | merged.Samples.Count.ShouldBe(right.Samples.Count); 196 | merged.DataCount.ShouldBe(right.DataCount); 197 | } 198 | 199 | [Fact] 200 | public void StringAccumulator_Merge_UnionsValues() 201 | { 202 | var left = new StringSetAccumulator(TimeSpan.FromSeconds(1)); 203 | var right = new StringSetAccumulator(TimeSpan.FromSeconds(1)); 204 | var ts = DateTimeOffset.UtcNow; 205 | 206 | left.AddNewData(ts, "alpha"); 207 | left.AddNewData(ts, "beta"); 208 | right.AddNewData(ts, "beta"); 209 | right.AddNewData(ts, "gamma"); 210 | 211 | left.Merge(right); 212 | 213 | left.Samples.Count.ShouldBe(1); 214 | var values = left.Samples.First().Value; 215 | values.ShouldContainKeys("alpha", "beta", "gamma"); 216 | } 217 | 218 | [Fact] 219 | public void StringAccumulator_ResampleSmallerIntervalThrows() 220 | { 221 | var accumulator = new StringSetAccumulator(TimeSpan.FromSeconds(1)); 222 | accumulator.AddNewData(DateTimeOffset.UtcNow, "value"); 223 | 224 | Action act = () => accumulator.Resample(TimeSpan.FromMilliseconds(100), 4); 225 | Should.Throw(act); 226 | } 227 | 228 | [Fact] 229 | public void GroupAccumulator_TryGetAndRemove_BehavesAsExpected() 230 | { 231 | var group = new IntGroupTimeSeriesAccumulator( 232 | sampleInterval: TimeSpan.FromMilliseconds(5), 233 | maxSamplesCount: 32, 234 | deleteOverdueSamples: false); 235 | 236 | group.AddNewData("alpha", DateTimeOffset.UtcNow, 1); 237 | 238 | group.TryGet("alpha", out var first).ShouldBeTrue(); 239 | first.ShouldNotBeNull(); 240 | group.Remove("alpha").ShouldBeTrue(); 241 | group.TryGet("alpha", out _).ShouldBeFalse(); 242 | } 243 | 244 | [Fact] 245 | public void NumberGroupSummer_NoData_ReturnsZeros() 246 | { 247 | var group = new NumberGroupTimeSeriesSummer( 248 | sampleInterval: TimeSpan.FromSeconds(1), 249 | samplesCount: 16, 250 | strategy: Strategy.Sum, 251 | deleteOverdueSamples: false); 252 | 253 | group.Sum().ShouldBe(0); 254 | group.Average().ShouldBe(0); 255 | group.Min().ShouldBe(0); 256 | group.Max().ShouldBe(0); 257 | } 258 | 259 | [Fact] 260 | public void NumberGroupSummer_IncrementAndDecrement_Work() 261 | { 262 | var group = new NumberGroupTimeSeriesSummer( 263 | sampleInterval: TimeSpan.FromSeconds(1), 264 | samplesCount: 16, 265 | strategy: Strategy.Sum, 266 | deleteOverdueSamples: false); 267 | 268 | group.Increment("key"); 269 | group.Decrement("key"); 270 | 271 | group.Sum().ShouldBe(0); 272 | group.Max().ShouldBe(0); 273 | group.Min().ShouldBe(0); 274 | } 275 | 276 | [Fact] 277 | public void NumberSummer_DecrementProducesNegativeValues() 278 | { 279 | var summer = new NumberTimeSeriesSummer(TimeSpan.FromMilliseconds(10)); 280 | summer.Decrement(); 281 | summer.Sum().ShouldBe(-1); 282 | summer.Min().ShouldBe(-1); 283 | summer.Max().ShouldBe(-1); 284 | } 285 | 286 | [Fact] 287 | public void NumberSummer_EmptyFactoryCreatesInstance() 288 | { 289 | var empty = NumberTimeSeriesSummer.Empty(TimeSpan.FromSeconds(2), maxSamplesCount: 5); 290 | empty.ShouldNotBeNull(); 291 | empty.Samples.ShouldBeEmpty(); 292 | } 293 | 294 | [Fact] 295 | public void OrleansAccumulatorConverter_RoundTripsEmptySeries() 296 | { 297 | var converter = new IntTimeSeriesAccumulatorConverter(); 298 | var source = new IntTimeSeriesAccumulator(TimeSpan.FromSeconds(1), maxSamplesCount: 4); 299 | 300 | var surrogate = converter.ConvertToSurrogate(source); 301 | surrogate.Samples.ShouldBeEmpty(); 302 | 303 | var restored = converter.ConvertFromSurrogate(in surrogate); 304 | restored.Samples.ShouldBeEmpty(); 305 | restored.DataCount.ShouldBe(0ul); 306 | } 307 | 308 | [Fact] 309 | public void OrleansConverters_HandleFloatAndDouble() 310 | { 311 | var floatAcc = new FloatTimeSeriesAccumulator(TimeSpan.FromMilliseconds(10), maxSamplesCount: 8); 312 | floatAcc.AddNewData(DateTimeOffset.UnixEpoch, 1.23f); 313 | var floatConverter = new FloatTimeSeriesAccumulatorConverter(); 314 | var floatSurrogate = floatConverter.ConvertToSurrogate(floatAcc); 315 | var floatRestored = floatConverter.ConvertFromSurrogate(floatSurrogate); 316 | var floatOriginal = floatAcc.Samples.Sum(pair => pair.Value.Sum()); 317 | var floatRoundTripped = floatRestored.Samples.Sum(pair => pair.Value.Sum()); 318 | floatRoundTripped.ShouldBe(floatOriginal); 319 | 320 | var doubleAcc = new DoubleTimeSeriesAccumulator(TimeSpan.FromMilliseconds(10), maxSamplesCount: 6); 321 | doubleAcc.AddNewData(DateTimeOffset.UnixEpoch, 4.2); 322 | var doubleAccConverter = new DoubleTimeSeriesAccumulatorConverter(); 323 | var doubleAccSurrogate = doubleAccConverter.ConvertToSurrogate(doubleAcc); 324 | var doubleAccRestored = doubleAccConverter.ConvertFromSurrogate(doubleAccSurrogate); 325 | doubleAccRestored.DataCount.ShouldBe(doubleAcc.DataCount); 326 | doubleAccRestored.Samples.Count.ShouldBe(doubleAcc.Samples.Count); 327 | 328 | var doubleSummer = new DoubleTimeSeriesSummer(TimeSpan.FromMilliseconds(10), maxSamplesCount: 8, Strategy.Sum); 329 | doubleSummer.AddNewData(DateTimeOffset.UnixEpoch, 3.14); 330 | var doubleConverter = new DoubleTimeSeriesSummerConverter(); 331 | var doubleSurrogate = doubleConverter.ConvertToSurrogate(doubleSummer); 332 | var doubleRestored = doubleConverter.ConvertFromSurrogate(doubleSurrogate); 333 | doubleRestored.Sum().ShouldBe(doubleSummer.Sum()); 334 | 335 | var floatSummer = new FloatTimeSeriesSummer(TimeSpan.FromMilliseconds(10), maxSamplesCount: 6, Strategy.Max); 336 | floatSummer.AddNewData(DateTimeOffset.UnixEpoch, 1.0f); 337 | floatSummer.AddNewData(DateTimeOffset.UnixEpoch.AddMilliseconds(10), 2.0f); 338 | var floatSummerConverter = new FloatTimeSeriesSummerConverter(); 339 | var floatSummerSurrogate = floatSummerConverter.ConvertToSurrogate(floatSummer); 340 | var floatSummerRestored = floatSummerConverter.ConvertFromSurrogate(floatSummerSurrogate); 341 | floatSummerRestored.Max().ShouldBe(floatSummer.Max()); 342 | } 343 | 344 | [Fact] 345 | public void OrleansSummerConverter_RoundTripsEmptySeries() 346 | { 347 | var converter = new IntTimeSeriesSummerConverter(); 348 | var summer = new IntTimeSeriesSummer(TimeSpan.FromMilliseconds(5), maxSamplesCount: 4, strategy: Strategy.Sum); 349 | 350 | var surrogate = converter.ConvertToSurrogate(summer); 351 | surrogate.Samples.ShouldBeEmpty(); 352 | 353 | var restored = converter.ConvertFromSurrogate(in surrogate); 354 | restored.Samples.ShouldBeEmpty(); 355 | restored.Sum().ShouldBe(0); 356 | } 357 | 358 | [Fact] 359 | public void Accumulator_StartEndMutatorsInvokableViaReflection() 360 | { 361 | var accumulator = new TestAccumulator(TimeSpan.FromMilliseconds(5), maxSamplesCount: 8); 362 | var baseType = typeof(BaseTimeSeries, TestAccumulator>); 363 | var startSetter = baseType.GetMethod("set_Start", BindingFlags.Instance | BindingFlags.NonPublic)!; 364 | var endSetter = baseType.GetMethod("set_End", BindingFlags.Instance | BindingFlags.NonPublic)!; 365 | 366 | var newStart = DateTimeOffset.UnixEpoch.AddMinutes(1); 367 | var newEnd = DateTimeOffset.UnixEpoch.AddMinutes(2); 368 | 369 | startSetter.Invoke(accumulator, new object[] { newStart }); 370 | endSetter.Invoke(accumulator, new object[] { newEnd }); 371 | 372 | accumulator.Start.ShouldBe(newStart); 373 | accumulator.End.ShouldBe(newEnd); 374 | } 375 | 376 | [Fact] 377 | public void Accumulator_SamplesExposeDictionarySemantics() 378 | { 379 | var accumulator = new TestAccumulator(TimeSpan.FromMilliseconds(5), maxSamplesCount: 8); 380 | var timestamp = DateTimeOffset.UnixEpoch; 381 | var secondTimestamp = timestamp.AddMilliseconds(10); 382 | 383 | accumulator.AddNewData(timestamp, 99); 384 | accumulator.AddNewData(secondTimestamp, 100); 385 | 386 | var samples = accumulator.Samples; 387 | samples.ShouldHaveCount(2); 388 | 389 | var roundedFirst = timestamp.Round(TimeSpan.FromMilliseconds(5)); 390 | var roundedSecond = secondTimestamp.Round(TimeSpan.FromMilliseconds(5)); 391 | 392 | samples.ContainsKey(roundedFirst).ShouldBeTrue(); 393 | samples.TryGetValue(roundedFirst, out var firstQueue).ShouldBeTrue(); 394 | firstQueue.ShouldNotBeNull(); 395 | firstQueue!.ShouldContain(99); 396 | 397 | samples.TryGetValue(roundedSecond, out var secondQueue).ShouldBeTrue(); 398 | secondQueue.ShouldNotBeNull(); 399 | secondQueue!.ShouldContain(100); 400 | 401 | samples.TryGetValue(secondTimestamp.AddMilliseconds(1), out _).ShouldBeFalse(); 402 | samples.Keys.ShouldBeInAscendingOrder(); 403 | 404 | var enumerator = ((IEnumerable)samples).GetEnumerator(); 405 | enumerator.MoveNext().ShouldBeTrue(); 406 | enumerator.MoveNext().ShouldBeTrue(); 407 | } 408 | 409 | [Fact] 410 | public void Accumulator_CanBeConstructedWithExplicitRange() 411 | { 412 | var start = DateTimeOffset.UnixEpoch.AddMinutes(1); 413 | var end = start.AddMinutes(5); 414 | var last = end.AddSeconds(30); 415 | 416 | var accumulator = new TestAccumulator(TimeSpan.FromSeconds(1), 16, start, end, last); 417 | accumulator.Start.ShouldBe(start.Round(TimeSpan.FromSeconds(1))); 418 | accumulator.End.ShouldBe(end.Round(TimeSpan.FromSeconds(1))); 419 | accumulator.LastDate.ShouldBe(last); 420 | } 421 | 422 | [Fact] 423 | public void AtomicDateTimeOffset_DefaultReadReturnsDefault() 424 | { 425 | var assembly = typeof(BaseTimeSeries<,,>).Assembly; 426 | var atomicType = assembly.GetType("ManagedCode.TimeSeries.Abstractions.AtomicDateTimeOffset", throwOnError: true)!; 427 | var atomic = Activator.CreateInstance(atomicType); 428 | var readMethod = atomicType.GetMethod("Read", BindingFlags.Public | BindingFlags.Instance)!; 429 | var result = (DateTimeOffset)readMethod.Invoke(atomic, Array.Empty())!; 430 | result.ShouldBe(default); 431 | } 432 | 433 | [Fact] 434 | public void TimeSeriesEmpty_WithoutInterval_Throws() 435 | { 436 | Should.Throw(() => IntTimeSeriesAccumulator.Empty()); 437 | Should.Throw(() => IntTimeSeriesAccumulator.Empty(TimeSpan.Zero)); 438 | 439 | var empty = IntTimeSeriesAccumulator.Empty(TimeSpan.FromMilliseconds(10)); 440 | empty.Samples.ShouldBeEmpty(); 441 | empty.SampleInterval.ShouldBe(TimeSpan.FromMilliseconds(10)); 442 | } 443 | 444 | [Fact] 445 | public void AccumulatorResample_PreservesDataCountAndLastDate() 446 | { 447 | var series = new IntTimeSeriesAccumulator(TimeSpan.FromMilliseconds(25), maxSamplesCount: 8); 448 | var start = DateTimeOffset.UtcNow; 449 | 450 | for (var i = 0; i < 6; i++) 451 | { 452 | series.AddNewData(start.AddMilliseconds(i * 25), i); 453 | } 454 | 455 | var countBefore = series.DataCount; 456 | var lastBefore = series.LastDate; 457 | 458 | series.Resample(TimeSpan.FromMilliseconds(50), samplesCount: 4); 459 | 460 | series.DataCount.ShouldBe(countBefore); 461 | series.LastDate.ShouldBe(lastBefore.Round(TimeSpan.FromMilliseconds(50))); 462 | series.Samples.Count.ShouldBeLessThanOrEqualTo(4); 463 | series.Samples.Values.Sum(queue => queue.Count).ShouldBe((int)countBefore); 464 | } 465 | 466 | [Fact] 467 | public void GroupAccumulatorCallback_RemovesExpiredEntries() 468 | { 469 | var interval = TimeSpan.FromMilliseconds(10); 470 | var group = new IntGroupTimeSeriesAccumulator(interval, maxSamplesCount: 1, deleteOverdueSamples: true); 471 | 472 | var staleTimestamp = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromSeconds(1)); 473 | group.AddNewData("expired", staleTimestamp, 42); 474 | 475 | var callback = typeof(BaseGroupTimeSeriesAccumulator) 476 | .GetMethod("Callback", BindingFlags.Instance | BindingFlags.NonPublic); 477 | callback.ShouldNotBeNull(); 478 | callback!.Invoke(group, new object?[] { null }); 479 | 480 | group.TryGet("expired", out _).ShouldBeFalse(); 481 | group.Dispose(); 482 | } 483 | 484 | [Fact] 485 | public void NumberGroupSummerCallback_RemovesExpiredEntries() 486 | { 487 | var interval = TimeSpan.FromMilliseconds(10); 488 | var group = new NumberGroupTimeSeriesSummer(interval, samplesCount: 1, strategy: Strategy.Sum, deleteOverdueSamples: true); 489 | 490 | var summer = new NumberTimeSeriesSummer(interval, maxSamplesCount: 1); 491 | var staleTimestamp = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromSeconds(1)); 492 | summer.AddNewData(staleTimestamp, 5); 493 | group.TimeSeries["expired"] = summer; 494 | 495 | var callback = typeof(BaseGroupNumberTimeSeriesSummer, NumberGroupTimeSeriesSummer>) 496 | .GetMethod("Callback", BindingFlags.Instance | BindingFlags.NonPublic); 497 | callback.ShouldNotBeNull(); 498 | callback!.Invoke(group, new object?[] { null }); 499 | 500 | group.TimeSeries.ContainsKey("expired").ShouldBeFalse(); 501 | group.Dispose(); 502 | } 503 | 504 | [Fact] 505 | public void BaseTimeSeriesSummer_StrategiesOperateCorrectly() 506 | { 507 | var interval = TimeSpan.FromMilliseconds(5); 508 | var now = DateTimeOffset.UtcNow; 509 | 510 | var sumSummer = new DummySummer(interval, maxSamplesCount: 8, Strategy.Sum); 511 | sumSummer.AddNewData(now, new DummySummerItem(1)); 512 | sumSummer.AddNewData(now, new DummySummerItem(2)); 513 | sumSummer.Sum().Value.ShouldBe(3); 514 | sumSummer.Increment(); 515 | sumSummer.Decrement(); 516 | 517 | var minSummer = new DummySummer(interval, maxSamplesCount: 8, Strategy.Min); 518 | minSummer.AddNewData(now, new DummySummerItem(5)); 519 | minSummer.AddNewData(now, new DummySummerItem(2)); 520 | minSummer.Sum().Value.ShouldBe(2); 521 | minSummer.Min()!.Value.ShouldBe(2); 522 | 523 | var maxSummer = new DummySummer(interval, maxSamplesCount: 8, Strategy.Max); 524 | maxSummer.AddNewData(now, new DummySummerItem(5)); 525 | maxSummer.AddNewData(now, new DummySummerItem(7)); 526 | maxSummer.Sum().Value.ShouldBe(7); 527 | maxSummer.Max()!.Value.ShouldBe(7); 528 | 529 | var replaceSummer = new DummySummer(interval, maxSamplesCount: 8, Strategy.Replace); 530 | replaceSummer.AddNewData(now, new DummySummerItem(1)); 531 | replaceSummer.AddNewData(now, new DummySummerItem(9)); 532 | replaceSummer.Sum().Value.ShouldBe(9); 533 | 534 | sumSummer.Merge(replaceSummer); 535 | sumSummer.DataCount.ShouldBeGreaterThan(0ul); 536 | 537 | var countBefore = sumSummer.DataCount; 538 | var lastBefore = sumSummer.LastDate; 539 | sumSummer.Resample(TimeSpan.FromMilliseconds(10), samplesCount: 4); 540 | sumSummer.DataCount.ShouldBe(countBefore); 541 | sumSummer.LastDate.ShouldBe(lastBefore.Round(TimeSpan.FromMilliseconds(10))); 542 | } 543 | 544 | private readonly struct DummySummerItem : ISummerItem 545 | { 546 | public DummySummerItem(int value) 547 | { 548 | Value = value; 549 | } 550 | 551 | public int Value { get; } 552 | 553 | public static DummySummerItem Zero => new(0); 554 | public static DummySummerItem One => new(1); 555 | 556 | public static DummySummerItem Min(DummySummerItem x, DummySummerItem y) => x.Value <= y.Value ? x : y; 557 | public static DummySummerItem Max(DummySummerItem x, DummySummerItem y) => x.Value >= y.Value ? x : y; 558 | 559 | public static DummySummerItem operator -(DummySummerItem value) => new(-value.Value); 560 | public static DummySummerItem operator +(DummySummerItem left, DummySummerItem right) => new(left.Value + right.Value); 561 | } 562 | 563 | private sealed class DummySummer : BaseTimeSeriesSummer 564 | { 565 | public DummySummer(TimeSpan sampleInterval, int maxSamplesCount, Strategy strategy) 566 | : base(sampleInterval, maxSamplesCount, strategy) 567 | { 568 | } 569 | } 570 | } 571 | --------------------------------------------------------------------------------