diff --git a/Raccoon.Ninja.AzFn.ScheduledTasks/HbA1cCalcFunc.cs b/Raccoon.Ninja.AzFn.ScheduledTasks/HbA1cCalcFunc.cs index 2d394f6..dd5c8c5 100644 --- a/Raccoon.Ninja.AzFn.ScheduledTasks/HbA1cCalcFunc.cs +++ b/Raccoon.Ninja.AzFn.ScheduledTasks/HbA1cCalcFunc.cs @@ -3,6 +3,8 @@ using System.Linq; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; +using Raccoon.Ninja.Domain.Core.Calculators; +using Raccoon.Ninja.Domain.Core.Calculators.Abstractions; using Raccoon.Ninja.Domain.Core.Entities; using Raccoon.Ninja.Domain.Core.ExtensionMethods; @@ -50,12 +52,18 @@ public HbA1CCalculation Run( { var previousCalculation = previousCalculations.FirstOrDefault(); - var hbA1C = readings.CalculateHbA1C(referenceDate); + var sortedGlucoseValues = readings.ToSortedValueArray(); - if (previousCalculation is not null) - hbA1C = hbA1C with { Delta = hbA1C.Value - previousCalculation.Value }; + var chain = BaseCalculatorHandler.BuildChain(); - return hbA1C; + var calculatedData = chain.Handle(new CalculationData + { + GlucoseValues = sortedGlucoseValues, + PreviousHbA1C = previousCalculation + }); + + // TODO: Convert the calculated data into the appropriate CosmosDb documents. + return null; } catch (Exception e) { diff --git a/Raccoon.Ninja.Domain.Core.Tests/Calculators/Abstractions/BaseCalculatorHandlerTests.cs b/Raccoon.Ninja.Domain.Core.Tests/Calculators/Abstractions/BaseCalculatorHandlerTests.cs new file mode 100644 index 0000000..5e5d588 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core.Tests/Calculators/Abstractions/BaseCalculatorHandlerTests.cs @@ -0,0 +1,19 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Abstractions; +using Raccoon.Ninja.Domain.Core.Calculators.Handlers; + +namespace Raccoon.Ninja.Domain.Core.Tests.Calculators.Abstractions; + +public class BaseCalculatorHandlerTests +{ + [Fact] + public void BuildChain_ShouldReturnChain() + { + // Arrange + // Act + var result = BaseCalculatorHandler.BuildChain(); + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core.Tests/Calculators/Handlers/HbA1CCalculatorTests.cs b/Raccoon.Ninja.Domain.Core.Tests/Calculators/Handlers/HbA1CCalculatorTests.cs new file mode 100644 index 0000000..86ce96e --- /dev/null +++ b/Raccoon.Ninja.Domain.Core.Tests/Calculators/Handlers/HbA1CCalculatorTests.cs @@ -0,0 +1,80 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Handlers; +using Raccoon.Ninja.Domain.Core.Constants; +using Raccoon.Ninja.Domain.Core.Enums; +using Raccoon.Ninja.TestHelpers; + +namespace Raccoon.Ninja.Domain.Core.Tests.Calculators.Handlers; + +public class HbA1CCalculatorTests +{ + [Theory] + [MemberData(nameof(TheoryGenerator.InvalidFloatListsWithNull), MemberType = typeof(TheoryGenerator))] + public void Handle_WhenListIsInvalid_ShouldReturnError(IList glucoseValues) + { + // Arrange + var calculator = new HbA1CCalculator(null); + + // Act + var result = calculator.Handle(Generators.CalculationDataMockSingle(glucoseValues)); + + // Assert + var status = result.Status; + status.Success.Should().BeFalse(); + status.FirstFailedStep.Should().Be(nameof(HbA1CCalculator)); + status.Message.Should().Be("This calculation requires a valid average glucose value."); + } + + [Fact] + public void Handle_WhenListHasMoreThanExpected_ShouldReturnError() + { + // Arrange + var calculator = new HbA1CCalculator(null); + + const int actualReadingCount = HbA1CConstants.ReadingsIn115Days + 1; + + var glucoseValues = Generators.ListWithNumbers(actualReadingCount, 100f).ToList(); + + // Act + var result = calculator.Handle(Generators.CalculationDataMockSingle(glucoseValues)); + + // Assert + var status = result.Status; + status.Success.Should().BeFalse(); + status.FirstFailedStep.Should().Be(nameof(HbA1CCalculator)); + status.Message.Should().Be($"Too many readings to calculate HbA1c reliably. Expected (max) 33120 but got {actualReadingCount}"); + } + + [Theory] + [MemberData(nameof(TheoryGenerator.PartiallyValidHb1AcDataSets), MemberType = typeof(TheoryGenerator))] + public void Handle_WhenListHasLessThanExpectedReadings_ShouldReturnPartialSuccess(IList glucoseValues, float expectedResult) + { + // Arrange + var calculator = new HbA1CCalculator(null); + + // Act + var result = calculator.Handle(Generators.CalculationDataMockSingle(glucoseValues)); + + // Assert + result.Status.Success.Should().BeTrue(); + result.CurrentHbA1C.Should().NotBeNull(); + result.CurrentHbA1C.Value.Should().Be(expectedResult); + result.CurrentHbA1C.Status.Should().Be(HbA1CCalculationStatus.SuccessPartial); + } + + [Theory] + [MemberData(nameof(TheoryGenerator.ValidHb1AcDataSets), MemberType = typeof(TheoryGenerator))] + public void CalculateHbA1c_WhenListHasExactNumberOfReadings_ShouldReturnSuccess(IList glucoseValues, float expectedResult) + { + // Arrange + var calculator = new HbA1CCalculator(null); + + // Act + var result = calculator.Handle(Generators.CalculationDataMockSingle(glucoseValues)); + + // Assert + result.Status.Success.Should().BeTrue(); + result.CurrentHbA1C.Should().NotBeNull(); + result.CurrentHbA1C.Value.Should().Be(expectedResult); + result.CurrentHbA1C.Status.Should().Be(HbA1CCalculationStatus.Success); + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core.Tests/ExtensionMethods/ListExtensionsTests.cs b/Raccoon.Ninja.Domain.Core.Tests/ExtensionMethods/ListExtensionsTests.cs index 42d0544..dbebc21 100644 --- a/Raccoon.Ninja.Domain.Core.Tests/ExtensionMethods/ListExtensionsTests.cs +++ b/Raccoon.Ninja.Domain.Core.Tests/ExtensionMethods/ListExtensionsTests.cs @@ -8,8 +8,6 @@ namespace Raccoon.Ninja.Domain.Core.Tests.ExtensionMethods; public class ListExtensionsTests { - private static readonly DateOnly ReferenceDate = DateOnly.FromDateTime(DateTime.UtcNow); - [Fact] public void HasElements_Should_Return_True_When_List_Has_Elements() { @@ -48,71 +46,4 @@ public void HasElements_Should_Return_False_When_List_Is_Empty() //Assert result.Should().BeFalse(); } - - [Fact] - public void CalculateHbA1c_WhenListIsNull_ShouldReturnError() - { - // Arrange - IEnumerable list = null; - - // Act - var result = list.CalculateHbA1C(ReferenceDate); - - // Assert - result.Status.Should().Be(HbA1CCalculationStatus.Error); - result.Error.Should().Be("No readings to calculate HbA1c"); - } - - [Fact] - public void CalculateHbA1c_WhenListIsEmpty_ShouldReturnError() - { - // Arrange - var list = new List(); - - // Act - var result = list.CalculateHbA1C(ReferenceDate); - - // Assert - result.Status.Should().Be(HbA1CCalculationStatus.Error); - result.Error.Should().Be("No readings returned from Db to calculate HbA1c"); - } - - [Fact] - public void CalculateHbA1c_WhenListHasMoreThanExpected_ShouldReturnError() - { - // Arrange - var actualReadingCount = Constants.ReadingsIn115Days + 1; - var readings = Generators.GlucoseReadingMockList(actualReadingCount, 100); - - // Act - var result = readings.CalculateHbA1C(ReferenceDate); - - // Assert - result.Status.Should().Be(HbA1CCalculationStatus.Error); - result.Error.Should().Be($"Too many readings to calculate HbA1c reliably. Expected (max) 33120 but got {actualReadingCount}"); - } - - [Theory] - [MemberData(nameof(TheoryGenerator.PartiallyValidHb1AcDataSets), MemberType = typeof(TheoryGenerator))] - public void CalculateHbA1c_WhenListHasOneReading_ShouldReturnPartialSuccess(IList readings, float expectedResult) - { - // Arrange & Act - var result = readings.CalculateHbA1C(ReferenceDate); - - // Assert - result.Status.Should().Be(HbA1CCalculationStatus.SuccessPartial); - result.Value.Should().Be(expectedResult); - } - - [Theory] - [MemberData(nameof(TheoryGenerator.ValidHb1AcDataSets), MemberType = typeof(TheoryGenerator))] - public void CalculateHbA1c_WhenListHasExactNumberOfReadings_ShouldReturnSuccess(IList readings, float expectedResult) - { - // Arrange & Act - var result = readings.CalculateHbA1C(ReferenceDate); - - // Assert - result.Status.Should().Be(HbA1CCalculationStatus.Success); - result.Value.Should().Be(expectedResult); - } } \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Calculators/Abstractions/BaseCalculatorHandler.cs b/Raccoon.Ninja.Domain.Core/Calculators/Abstractions/BaseCalculatorHandler.cs new file mode 100644 index 0000000..e5ffa44 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Calculators/Abstractions/BaseCalculatorHandler.cs @@ -0,0 +1,72 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Handlers; + +namespace Raccoon.Ninja.Domain.Core.Calculators.Abstractions; + +/// +/// Base class to handle the calculation of a specific metric. +/// The overall result will be converted do a CosmosDb document +/// and stored in the aggregation collection. +/// +/// +/// Why not use a simple function that calculates everything at once? +/// - The idea is to have a pipeline of handlers, each one responsible for a specific calculation. +/// - This way, we can easily add new calculations without changing the main class. +/// - Also, we can easily test each calculation separately. +/// +public abstract class BaseCalculatorHandler +{ + private readonly BaseCalculatorHandler _nextHandler; + + protected BaseCalculatorHandler(BaseCalculatorHandler nextHandler) + { + _nextHandler = nextHandler; + } + + protected virtual bool CanHandle(CalculationData data) + { + return data.GlucoseValues is not null && data.GlucoseValues.Count > 0; + } + + protected virtual CalculationData HandleError(CalculationData data) + { + return data with + { + Status = new CalculationDataStatus + { + Message = "No glucose values were provided.", + Success = false, + FirstFailedStep = GetType().Name + } + }; + } + + protected CalculationData HandleNext(CalculationData data) + { + return _nextHandler is null + ? data + : _nextHandler.Handle(data); + } + + public abstract CalculationData Handle(CalculationData data); + + /// + /// Build the default chain of calculations. + /// + /// First link in the chain. + public static BaseCalculatorHandler BuildChain() + { + // Last step of the chain + var mageCalculator = new MageCalculator(null); + var tirCalculator = new TimeInRangeCalculator(mageCalculator); + var hbA1CCalculator = new HbA1CCalculator(tirCalculator); + var glucoseVariabilityCalculator = new RangeCalculator(hbA1CCalculator); + var medianCalculator = new MedianCalculator(glucoseVariabilityCalculator); + var percentileCalculator = new PercentileCalculator(medianCalculator); + var sdCalculator = new StandardDeviationCalculator(percentileCalculator); + var cvCalculator = new CoefficientOfVariationCalculator(sdCalculator); + var avgCalculator = new AverageCalculator(cvCalculator); + //First step of the chain + + return avgCalculator; + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Calculators/CalculationData.cs b/Raccoon.Ninja.Domain.Core/Calculators/CalculationData.cs new file mode 100644 index 0000000..4366c0f --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Calculators/CalculationData.cs @@ -0,0 +1,46 @@ +using Raccoon.Ninja.Domain.Core.Entities; + +namespace Raccoon.Ninja.Domain.Core.Calculators; + +public record CalculationData +{ + public IList GlucoseValues { get; init; } + public float Average { get; init; } + public int Count => GlucoseValues?.Count ?? 0; + public float Median { get; init; } + public float Min { get; init; } + public float Max { get; init; } + public float Mage { get; init; } + public float StandardDeviation { get; init; } + public float CoefficientOfVariation { get; init; } + public HbA1CCalculation CurrentHbA1C { get; init; } + public HbA1CCalculation PreviousHbA1C { get; init; } + + public CalculationDataTimeInRange TimeInRange { get; init; } = new (); + public CalculationDataPercentile Percentile { get; init; } = new (); + public CalculationDataStatus Status { get; init; } = new (); +} + +public record CalculationDataTimeInRange +{ + public float Low { get; init; } + public float Normal { get; init; } + public float High { get; init; } + public float VeryHigh { get; init; } +} + +public record CalculationDataPercentile +{ + public float P10 { get; init; } + public float P25 { get; init; } + public float P75 { get; init; } + public float P90 { get; init; } + public float Iqr { get; init; } +} + +public record CalculationDataStatus +{ + public bool Success { get; init; } = true; + public string FirstFailedStep { get; set; } + public string Message { get; init; } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Calculators/Handlers/AverageCalculator.cs b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/AverageCalculator.cs new file mode 100644 index 0000000..a2103c5 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/AverageCalculator.cs @@ -0,0 +1,29 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Abstractions; + +namespace Raccoon.Ninja.Domain.Core.Calculators.Handlers; + +/// +/// Average Glucose: The average glucose level is a measure of central tendency that can be used to assess +/// overall glucose control. +/// +public class AverageCalculator: BaseCalculatorHandler +{ + public AverageCalculator(BaseCalculatorHandler nextHandler) : base(nextHandler) + { + } + + public override CalculationData Handle(CalculationData data) + { + if (!CanHandle(data)) + { + return HandleError(data); + } + + var average = data.GlucoseValues.Average(); + + return HandleNext(data with + { + Average = average, + }); + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Calculators/Handlers/CoefficientOfVariationCalculator.cs b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/CoefficientOfVariationCalculator.cs new file mode 100644 index 0000000..df2dbc4 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/CoefficientOfVariationCalculator.cs @@ -0,0 +1,46 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Abstractions; + +namespace Raccoon.Ninja.Domain.Core.Calculators.Handlers; + +/// +/// Coefficient of Variation (CV) is a standardized measure of dispersion of a probability +/// distribution or frequency distribution. It's particularly useful because it allows for +/// comparison of variability across different mean glucose levels. +/// +public class CoefficientOfVariationCalculator: BaseCalculatorHandler +{ + public CoefficientOfVariationCalculator(BaseCalculatorHandler nextHandler) : base(nextHandler) + { + } + + protected override bool CanHandle(CalculationData data) + { + return data.StandardDeviation > 0 && data.Average > 0; + } + + protected override CalculationData HandleError(CalculationData data) + { + return data with + { + Status = new CalculationDataStatus + { + Success = false, + Message = "Cannot calculate Coefficient of Variation without Standard Deviation and Average.", + FirstFailedStep = nameof(CoefficientOfVariationCalculator) + } + }; + } + + public override CalculationData Handle(CalculationData data) + { + if (!CanHandle(data)) + { + return HandleError(data); + } + + return HandleNext(data with + { + CoefficientOfVariation = data.StandardDeviation / data.Average + }); + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Calculators/Handlers/HbA1CCalculator.cs b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/HbA1CCalculator.cs new file mode 100644 index 0000000..59351f4 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/HbA1CCalculator.cs @@ -0,0 +1,70 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Abstractions; +using Raccoon.Ninja.Domain.Core.Constants; +using Raccoon.Ninja.Domain.Core.Entities; +using Raccoon.Ninja.Domain.Core.Enums; + +namespace Raccoon.Ninja.Domain.Core.Calculators.Handlers; + +/// +/// HbA1C: The HbA1C test measures the average blood glucose level over the past 2-3 months. +/// It is used to monitor the effectiveness of diabetes treatment. +/// +public class HbA1CCalculator: BaseCalculatorHandler +{ + private const float GlucoseConversionFactor = 46.7f; + private const float HbA1CDivisor = 28.7f; + + public HbA1CCalculator(BaseCalculatorHandler nextHandler) : base(nextHandler) + { + } + + protected override bool CanHandle(CalculationData data) + { + return data.Average > 0 && data.Count is > 0 and <= HbA1CConstants.ReadingsIn115Days; + } + + protected override CalculationData HandleError(CalculationData data) + { + return data with + { + Status = new CalculationDataStatus + { + Success = false, + FirstFailedStep = nameof(HbA1CCalculator), + Message = data.Count <= HbA1CConstants.ReadingsIn115Days + ? "This calculation requires a valid average glucose value." + : $"Too many readings to calculate HbA1c reliably. Expected (max) {HbA1CConstants.ReadingsIn115Days} but got {data.Count}", + } + }; + } + + public override CalculationData Handle(CalculationData data) + { + if (!CanHandle(data)) + { + return HandleError(data); + } + + var average = data.Average; + + var hba1C = (average + GlucoseConversionFactor) / HbA1CDivisor; + + return HandleNext(data with + { + CurrentHbA1C = new HbA1CCalculation + { + Value = hba1C, + ReferenceDate = DateOnly.FromDateTime(DateTime.UtcNow), + Delta = hba1C - data.PreviousHbA1C?.Value, // Null if data.PreviousHbA1C is null + Status = GetStatusByReadingCount(data.Count) + } + }); + } + + private static HbA1CCalculationStatus GetStatusByReadingCount(int count) + { + return count == HbA1CConstants.ReadingsIn115Days + ? HbA1CCalculationStatus.Success + : HbA1CCalculationStatus.SuccessPartial; + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Calculators/Handlers/MageCalculator.cs b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/MageCalculator.cs new file mode 100644 index 0000000..9a89093 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/MageCalculator.cs @@ -0,0 +1,81 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Abstractions; + +namespace Raccoon.Ninja.Domain.Core.Calculators.Handlers; + +/// +/// MAGE (Mean Amplitude of Glycemic Excursions): MAGE is a measure of glycemic variability. +/// Used to quantify the major swings in glucose levels, particularly the peaks and troughs, over a period of time. +/// It's primarily used in diabetes management to assess the volatility or variability of blood glucose levels, +/// which is an important aspect beyond just the average blood glucose levels. High variability can be indicative +/// of greater risk of hypoglycemia, even if average glucose levels are within a target range. Understanding +/// and managing glucose variability can help in minimizing the risk of complications associated with diabetes. +/// +/// Lower MAGE values = lower glycemic variability. +/// Higher MAGE values = higher glycemic variability. +/// +/// Lower is better. +/// +public class MageCalculator: BaseCalculatorHandler +{ + public MageCalculator(BaseCalculatorHandler nextHandler) : base(nextHandler) + { + } + + protected override bool CanHandle(CalculationData data) + { + return base.CanHandle(data) && data.StandardDeviation > 0; + } + + protected override CalculationData HandleError(CalculationData data) + { + return data with + { + Status = new CalculationDataStatus + { + Success = false, + FirstFailedStep = nameof(MageCalculator), + Message = + "This calculation requires the list of glucose readings, and standard deviation glucose values." + } + }; + } + + public override CalculationData Handle(CalculationData data) + { + if (!CanHandle(data)) + { + return HandleError(data); + } + + var standardDeviation = data.StandardDeviation; + var glucoseReadings = data.GlucoseValues; + + // Identify peaks and troughs + var excursions = new List(); + for (var i = 1; i < glucoseReadings.Count - 1; i++) + { + if ((glucoseReadings[i] > glucoseReadings[i - 1] && glucoseReadings[i] > glucoseReadings[i + 1]) || + (glucoseReadings[i] < glucoseReadings[i - 1] && glucoseReadings[i] < glucoseReadings[i + 1])) + { + excursions.Add(glucoseReadings[i]); + } + } + + // Calculate amplitudes and filter by significant excursions + var significantExcursions = new List(); + for (var i = 0; i < excursions.Count - 1; i++) + { + var amplitude = Math.Abs(excursions[i + 1] - excursions[i]); + if (amplitude > standardDeviation) + { + significantExcursions.Add(amplitude); + } + } + + var mage = significantExcursions.Count > 0 + ? significantExcursions.Average() + : 0; + + return HandleNext(data with { Mage = mage }); + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Calculators/Handlers/MedianCalculator.cs b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/MedianCalculator.cs new file mode 100644 index 0000000..45fd691 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/MedianCalculator.cs @@ -0,0 +1,35 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Abstractions; + +namespace Raccoon.Ninja.Domain.Core.Calculators.Handlers; + +/// +/// Median: The median is the middle value of a set of values. If the set has an even number of values, +/// the median is the average of the two middle values. +/// For control of blood glucose, the median is a measure of central tendency that can be used to. +/// +public class MedianCalculator: BaseCalculatorHandler +{ + public MedianCalculator(BaseCalculatorHandler nextHandler) : base(nextHandler) + { + } + + public override CalculationData Handle(CalculationData data) + { + if (!CanHandle(data)) + { + return HandleError(data); + } + + var glucoseValues = data.GlucoseValues; + var count = glucoseValues.Count; + var isEven = count % 2 == 0; + var middle = count / 2; + + return HandleNext(data with + { + Median = isEven + ? (glucoseValues[(count - 1) / 2] + glucoseValues[count / 2]) / 2.0f + : glucoseValues[middle], + }); + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Calculators/Handlers/PercentileCalculator.cs b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/PercentileCalculator.cs new file mode 100644 index 0000000..697b61d --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/PercentileCalculator.cs @@ -0,0 +1,62 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Abstractions; + +namespace Raccoon.Ninja.Domain.Core.Calculators.Handlers; + +/// +/// Percentiles (e.g., 10th, 25th, 75th, 90th) provide another way to understand the distribution of +/// glucose values beyond the median and mean, showing how glucose levels distribute across different +/// points of the data set. +/// +/// 10th Percentile: Lower Bound of Glucose Variability: Helps identify hypoglycemia risk by showing +/// the glucose level below which only 10% of readings fall. +/// +/// 25th Percentile (Q1): Marks the bottom end of the middle 50% of readings, providing insight into +/// lower glucose levels but above the very low extremes. +/// +/// 75th Percentile (Q3): Upper Quartile represents the top end of the middle 50% of readings, +/// giving an indication of higher glucose levels but excluding the highest extremes. +/// +/// 90th Percentile: Upper Bound of Glucose Variability: Helps identify hyperglycemia risk by showing +/// the glucose level above which only 10% of readings fall. +/// +/// Interquartile Range (IQR): IQR measures the spread of the middle 50% of values. It's useful for +/// understanding the variability of glucose levels while being less sensitive to outliers than the +/// range. +/// +public class PercentileCalculator: BaseCalculatorHandler +{ + public PercentileCalculator(BaseCalculatorHandler nextHandler) : base(nextHandler) + { + } + + public override CalculationData Handle(CalculationData data) + { + if (!CanHandle(data)) + { + return HandleError(data); + } + + var p10 = CalculatePercentile(data.GlucoseValues, 10); + var p25 = CalculatePercentile(data.GlucoseValues, 25); + var p75 = CalculatePercentile(data.GlucoseValues, 75); + var p90 = CalculatePercentile(data.GlucoseValues, 90); + + return HandleNext(data with + { + Percentile = new CalculationDataPercentile + { + P10 = p10, + P25 = p25, + P75 = p75, + P90 = p90, + Iqr = p75 - p25 + } + }); + } + + private static float CalculatePercentile(IList values, float percentile) + { + var n = (int)Math.Ceiling((percentile / 100.0) * values.Count) - 1; + return values[n]; + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Calculators/Handlers/RangeCalculator.cs b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/RangeCalculator.cs new file mode 100644 index 0000000..1065b70 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/RangeCalculator.cs @@ -0,0 +1,28 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Abstractions; + +namespace Raccoon.Ninja.Domain.Core.Calculators.Handlers; + +/// +/// Range of glucose levels (difference between the maximum and minimum values) can provide insights into the +/// variability of glucose levels over a period. +/// +public class RangeCalculator: BaseCalculatorHandler +{ + public RangeCalculator(BaseCalculatorHandler nextHandler) : base(nextHandler) + { + } + + public override CalculationData Handle(CalculationData data) + { + if (!CanHandle(data)) + { + return HandleError(data); + } + + return HandleNext(data with + { + Min = data.GlucoseValues.Min(), + Max = data.GlucoseValues.Max(), + }); + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Calculators/Handlers/StandardDeviationCalculator.cs b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/StandardDeviationCalculator.cs new file mode 100644 index 0000000..fdb6742 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/StandardDeviationCalculator.cs @@ -0,0 +1,34 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Abstractions; + +namespace Raccoon.Ninja.Domain.Core.Calculators.Handlers; + +/// +/// Standard Deviation and Variance offer a measure of the variability or spread of glucose levels +/// around the mean. A higher standard deviation indicates greater variability, which could be +/// significant for managing diabetes. +/// +public class StandardDeviationCalculator: BaseCalculatorHandler +{ + public StandardDeviationCalculator(BaseCalculatorHandler nextHandler) : base(nextHandler) + { + } + + public override CalculationData Handle(CalculationData data) + { + if (!CanHandle(data)) + { + return HandleError(data); + } + + var glucoseValues = data.GlucoseValues; + + var average = data.Average; + + var standardDeviation = (float)Math.Sqrt(glucoseValues.Sum(r => Math.Pow(r - average, 2)) / glucoseValues.Count); + + return HandleNext(data with + { + StandardDeviation = standardDeviation + }); + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Calculators/Handlers/TimeInRangeCalculator.cs b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/TimeInRangeCalculator.cs new file mode 100644 index 0000000..5a38343 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Calculators/Handlers/TimeInRangeCalculator.cs @@ -0,0 +1,45 @@ +using Raccoon.Ninja.Domain.Core.Calculators.Abstractions; +using Raccoon.Ninja.Domain.Core.Constants; + +namespace Raccoon.Ninja.Domain.Core.Calculators.Handlers; + +/// +/// Time in Range (TIR): TIR refers to the percentage of time glucose levels are within a target range. +/// This measure is increasingly used in diabetes management to assess how well blood glucose levels +/// are controlled. +/// +public class TimeInRangeCalculator: BaseCalculatorHandler +{ + public TimeInRangeCalculator(BaseCalculatorHandler nextHandler) : base(nextHandler) + { + } + + public override CalculationData Handle(CalculationData data) + { + if (!CanHandle(data)) + { + return HandleError(data); + } + + var low = data.GlucoseValues.Count(v => v < GlucoseConstants.LowGlucoseThreshold); + var normal = data.GlucoseValues.Count(v => v >= GlucoseConstants.LowGlucoseThreshold && v <= GlucoseConstants.HighGlucoseThreshold); + var high = data.GlucoseValues.Count(v => v >= GlucoseConstants.HighGlucoseThreshold && v <= GlucoseConstants.VeryHighGlucoseThreshold); + var veryHigh = data.GlucoseValues.Count(v => v > GlucoseConstants.VeryHighGlucoseThreshold); + + return HandleNext(data with + { + TimeInRange = new CalculationDataTimeInRange + { + Low = ToPercents(low, data.Count), + Normal = ToPercents(normal, data.Count), + High = ToPercents(high, data.Count), + VeryHigh = ToPercents(veryHigh, data.Count) + } + }); + } + + private static float ToPercents(float value, float total) + { + return value / total * 100; + } +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Constants/GlucoseConstants.cs b/Raccoon.Ninja.Domain.Core/Constants/GlucoseConstants.cs new file mode 100644 index 0000000..f6f3030 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Constants/GlucoseConstants.cs @@ -0,0 +1,8 @@ +namespace Raccoon.Ninja.Domain.Core.Constants; + +public static class GlucoseConstants +{ + public const float LowGlucoseThreshold = 85; + public const float HighGlucoseThreshold = 180; + public const float VeryHighGlucoseThreshold = 250; +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Constants/HbA1CConstants.cs b/Raccoon.Ninja.Domain.Core/Constants/HbA1CConstants.cs new file mode 100644 index 0000000..e34d0c4 --- /dev/null +++ b/Raccoon.Ninja.Domain.Core/Constants/HbA1CConstants.cs @@ -0,0 +1,7 @@ +namespace Raccoon.Ninja.Domain.Core.Constants; + +public static class HbA1CConstants +{ + public const int ReadingsIn115Days = 33120; + public const int ReadingsIn1Day = 288; +} \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/Entities/BaseEntity.cs b/Raccoon.Ninja.Domain.Core/Entities/BaseEntity.cs index cfd7858..4fe3d87 100644 --- a/Raccoon.Ninja.Domain.Core/Entities/BaseEntity.cs +++ b/Raccoon.Ninja.Domain.Core/Entities/BaseEntity.cs @@ -10,6 +10,9 @@ public record BaseEntity [JsonPropertyName("value")] public float Value { get; init; } + /// + /// Difference between the current and the previous value, if available. + /// [JsonPropertyName("delta")] public float? Delta { get; init; } }; \ No newline at end of file diff --git a/Raccoon.Ninja.Domain.Core/ExtensionMethods/ListExtensions.cs b/Raccoon.Ninja.Domain.Core/ExtensionMethods/ListExtensions.cs index 6641912..a8ad972 100644 --- a/Raccoon.Ninja.Domain.Core/ExtensionMethods/ListExtensions.cs +++ b/Raccoon.Ninja.Domain.Core/ExtensionMethods/ListExtensions.cs @@ -1,12 +1,9 @@ using Raccoon.Ninja.Domain.Core.Entities; -using Raccoon.Ninja.Domain.Core.Enums; namespace Raccoon.Ninja.Domain.Core.ExtensionMethods; public static class ListExtensions { - private const int ReadingsIn115Days = 33120; - /// /// A more performative way to check if a list has elements. /// @@ -17,53 +14,14 @@ public static bool HasElements(this ICollection list) { return list is not null && list.Count > 0; } - - public static HbA1CCalculation CalculateHbA1C(this IEnumerable list, DateOnly referenceDate) - { - if (list is null) - return HbA1CCalculation.FromError("No readings to calculate HbA1c", referenceDate); - - var count = 0; - var sum = 0f; - foreach (var glucoseReading in list) - { - count++; - sum += glucoseReading.Value; - } - if (count == 0) - { - return HbA1CCalculation.FromError("No readings returned from Db to calculate HbA1c", referenceDate); - } - - var avg = sum / count; - var hbA1C = (avg + 46.7f) / 28.7f; - - if (HasNumberOfReadingsExceededMax(count)) - { - return HbA1CCalculation.FromError( - $"Too many readings to calculate HbA1c reliably. Expected (max) {ReadingsIn115Days} but got {count}", - referenceDate); - } - - return new HbA1CCalculation - { - Value = hbA1C, - ReferenceDate = referenceDate, - Status = GetStatusByReadingCount(count) - }; - } - - private static bool HasNumberOfReadingsExceededMax(int count) - { - return count > ReadingsIn115Days; - - } - - private static HbA1CCalculationStatus GetStatusByReadingCount(int count) + /// + /// Extracts the glucose values from the readings and returns them as a sorted (ascending) array. + /// + /// List of GlucoseReadings to be used + /// Array of values + public static IList ToSortedValueArray(this IEnumerable readings) { - return count == ReadingsIn115Days - ? HbA1CCalculationStatus.Success - : HbA1CCalculationStatus.SuccessPartial; + return readings.Select(r => r.Value).OrderBy(v => v).ToArray(); } } \ No newline at end of file diff --git a/Raccoon.Ninja.TestHelpers/Generators.cs b/Raccoon.Ninja.TestHelpers/Generators.cs index 43f5e7d..fd8dc09 100644 --- a/Raccoon.Ninja.TestHelpers/Generators.cs +++ b/Raccoon.Ninja.TestHelpers/Generators.cs @@ -1,5 +1,7 @@ -using Bogus; +using System.Runtime.InteropServices.JavaScript; +using Bogus; using MongoDB.Bson; +using Raccoon.Ninja.Domain.Core.Calculators; using Raccoon.Ninja.Domain.Core.Entities; using Raccoon.Ninja.Domain.Core.Enums; using Raccoon.Ninja.Domain.Core.Models; @@ -9,6 +11,33 @@ namespace Raccoon.Ninja.TestHelpers; public static class Generators { + public static IEnumerable ListWithNumbers(int qty, float? exactValue = null, float? minValue = null, float? maxValue = null) + { + var faker = new Faker(); + + for (var i = 0; i < qty; i++) + { + yield return exactValue ?? faker.Random.Float(minValue ?? 0f, maxValue ?? 100f); + } + } + + public static IEnumerable ListWithNumbers(int qty, int? exactValue = null, int? minValue = null, int? maxValue = null) + { + foreach (var number in ListWithNumbers(qty, (float?)exactValue, (float?)minValue, (float?)maxValue)) + { + yield return (int)number; + } + } + + public static CalculationData CalculationDataMockSingle(IList glucoseValues) + { + return new CalculationData + { + GlucoseValues = glucoseValues, + Average = glucoseValues is null || glucoseValues.Count == 0? 0f : glucoseValues.Average() + }; + } + public static IList NightScoutMongoDocumentMockList(int qty, int? value = null) { var faker = new Faker() diff --git a/Raccoon.Ninja.TestHelpers/TheoryGenerator.cs b/Raccoon.Ninja.TestHelpers/TheoryGenerator.cs index b88466a..4645897 100644 --- a/Raccoon.Ninja.TestHelpers/TheoryGenerator.cs +++ b/Raccoon.Ninja.TestHelpers/TheoryGenerator.cs @@ -1,4 +1,5 @@ -using Raccoon.Ninja.Domain.Core.Entities; +using Raccoon.Ninja.Domain.Core.Constants; +using Raccoon.Ninja.Domain.Core.Entities; using Raccoon.Ninja.Domain.Core.Enums; using Xunit; @@ -6,6 +7,15 @@ namespace Raccoon.Ninja.TestHelpers; public static class TheoryGenerator { + public static TheoryData> InvalidFloatListsWithNull() + { + return new TheoryData> + { + null, + new List() + }; + } + public static TheoryData AllTrendsWithExpectedStrings() { return new TheoryData @@ -60,33 +70,33 @@ public static TheoryData TimeStampAndCorrespondingDateTimes() return data; } - public static TheoryData, float> ValidHb1AcDataSets() + public static TheoryData, float> ValidHb1AcDataSets() { - var data = new TheoryData, float> + var data = new TheoryData, float> { - { Generators.GlucoseReadingMockList(Constants.ReadingsIn115Days, 80), 4.4146338f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn115Days, 100), 5.111498f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn115Days, 150), 6.853658f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn115Days, 170), 7.5505223f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn115Days, 210), 8.944251f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn115Days, 300), 12.080139f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn115Days, 400), 15.56446f } + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn115Days, 80f).ToList(), 4.4146338f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn115Days, 100f).ToList(), 5.111498f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn115Days, 150f).ToList(), 6.853658f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn115Days, 170f).ToList(), 7.5505223f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn115Days, 210f).ToList(), 8.944251f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn115Days, 300f).ToList(), 12.080139f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn115Days, 400f).ToList(), 15.56446f } }; return data; } - public static TheoryData, float> PartiallyValidHb1AcDataSets() + public static TheoryData, float> PartiallyValidHb1AcDataSets() { - return new TheoryData, float> + return new TheoryData, float> { - { Generators.GlucoseReadingMockList(Constants.ReadingsIn1Day, 80), 4.4146338f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn1Day, 100), 5.111498f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn1Day, 150), 6.853658f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn1Day, 170), 7.5505223f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn1Day, 210), 8.944251f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn1Day, 300), 12.080139f }, - { Generators.GlucoseReadingMockList(Constants.ReadingsIn1Day, 400), 15.56446f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn1Day, 80f).ToList(), 4.4146338f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn1Day, 100f).ToList(), 5.111498f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn1Day, 150f).ToList(), 6.853658f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn1Day, 170f).ToList(), 7.5505223f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn1Day, 210f).ToList(), 8.944251f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn1Day, 300f).ToList(), 12.080139f }, + { Generators.ListWithNumbers(HbA1CConstants.ReadingsIn1Day, 400f).ToList(), 15.56446f }, }; } } \ No newline at end of file