diff --git a/scripts/validation/opcua-aggregate-baseline/Baseline.csproj b/scripts/validation/opcua-aggregate-baseline/Baseline.csproj
new file mode 100644
index 00000000..373953e8
--- /dev/null
+++ b/scripts/validation/opcua-aggregate-baseline/Baseline.csproj
@@ -0,0 +1,4 @@
+
+ Exe
+
+
diff --git a/scripts/validation/opcua-aggregate-baseline/Directory.Build.props b/scripts/validation/opcua-aggregate-baseline/Directory.Build.props
new file mode 100644
index 00000000..2cc48e04
--- /dev/null
+++ b/scripts/validation/opcua-aggregate-baseline/Directory.Build.props
@@ -0,0 +1,4 @@
+
+
+ net10.0enableenabletruefalse
+
diff --git a/scripts/validation/opcua-aggregate-baseline/Directory.Packages.props b/scripts/validation/opcua-aggregate-baseline/Directory.Packages.props
new file mode 100644
index 00000000..802827ef
--- /dev/null
+++ b/scripts/validation/opcua-aggregate-baseline/Directory.Packages.props
@@ -0,0 +1 @@
+false
diff --git a/scripts/validation/opcua-aggregate-baseline/Program.cs b/scripts/validation/opcua-aggregate-baseline/Program.cs
new file mode 100644
index 00000000..25eb9209
--- /dev/null
+++ b/scripts/validation/opcua-aggregate-baseline/Program.cs
@@ -0,0 +1,124 @@
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text.Json;
+using Opc.Ua;
+using Opc.Ua.Server;
+
+if (args.Length != 1) throw new ArgumentException("Pass a fresh output JSON path.");
+if (File.Exists(args[0])) throw new IOException("Refusing to overwrite existing baseline evidence.");
+var epoch = new DateTime(2026, 10, 1, 8, 0, 0, DateTimeKind.Utc).AddTicks(1234567);
+var telemetry = DefaultTelemetry.Create(_ => { });
+var offsets = new double[] { -10, 0, 5, 15, 25, 35, 50, 80 };
+var data = new Dictionary
+{
+ ["double"] = offsets.Select((s, i) => new Sample(s, "Double", (10 + i * 7 % 13).ToString(CultureInfo.InvariantCulture), StatusCodes.Good)).ToArray(),
+ ["uncertain"] = offsets.Select((s, i) => new Sample(s, i == 4 ? "Null" : "Double", i == 4 ? null : i == 2 ? "100" : (10 + i * 7 % 13).ToString(CultureInfo.InvariantCulture), i == 2 ? StatusCodes.UncertainDataSubNormal : i == 4 ? StatusCodes.BadNoCommunication : StatusCodes.Good)).ToArray(),
+ ["integer"] = offsets.Select((s, i) => new Sample(s, "Int64", (i * 7 - 10).ToString(CultureInfo.InvariantCulture), i == 3 ? StatusCodes.UncertainDataSubNormal : StatusCodes.Good)).ToArray(),
+ ["boolean"] = offsets.Select((s, i) => new Sample(s, "Boolean", (i % 2 == 0).ToString(), i == 2 ? StatusCodes.UncertainDataSubNormal : StatusCodes.Good)).ToArray(),
+ ["sparse"] = [new(-10, "Double", "10", StatusCodes.Good), new(7, "Double", "20", StatusCodes.Good), new(18, "Null", null, StatusCodes.BadNoCommunication), new(45, "Double", "30", StatusCodes.Good)],
+ ["bad"] = offsets.Select(s => new Sample(s, "Null", null, StatusCodes.BadNoCommunication)).ToArray(),
+ ["empty"] = [],
+};
+var names = new string[]
+{
+ "AggregateFunction_Interpolative",
+ "AggregateFunction_Average",
+ "AggregateFunction_TimeAverage",
+ "AggregateFunction_TimeAverage2",
+ "AggregateFunction_Total",
+ "AggregateFunction_Total2",
+ "AggregateFunction_Minimum",
+ "AggregateFunction_Maximum",
+ "AggregateFunction_MinimumActualTime",
+ "AggregateFunction_MaximumActualTime",
+ "AggregateFunction_Range",
+ "AggregateFunction_Minimum2",
+ "AggregateFunction_Maximum2",
+ "AggregateFunction_MinimumActualTime2",
+ "AggregateFunction_MaximumActualTime2",
+ "AggregateFunction_Range2",
+ "AggregateFunction_Count",
+ "AggregateFunction_AnnotationCount",
+ "AggregateFunction_DurationInStateZero",
+ "AggregateFunction_DurationInStateNonZero",
+ "AggregateFunction_NumberOfTransitions",
+ "AggregateFunction_Start",
+ "AggregateFunction_End",
+ "AggregateFunction_Delta",
+ "AggregateFunction_StartBound",
+ "AggregateFunction_EndBound",
+ "AggregateFunction_DeltaBounds",
+ "AggregateFunction_DurationGood",
+ "AggregateFunction_DurationBad",
+ "AggregateFunction_PercentGood",
+ "AggregateFunction_PercentBad",
+ "AggregateFunction_WorstQuality",
+ "AggregateFunction_WorstQuality2",
+ "AggregateFunction_StandardDeviationPopulation",
+ "AggregateFunction_VariancePopulation",
+ "AggregateFunction_StandardDeviationSample",
+ "AggregateFunction_VarianceSample",
+};
+var vectors = new List();
+foreach (var name in names)
+foreach (var dataset in data)
+foreach (var reverse in new[] { false, true })
+foreach (var stepped in new[] { false, true })
+foreach (var uncertainAsBad in new[] { false, true })
+foreach (var incremental in new[] { false, true })
+{
+ var id = (NodeId)typeof(ObjectIds).GetField(name)!.GetValue(null)!;
+ var start = epoch.AddSeconds(reverse ? 70 : 0);
+ var end = epoch.AddSeconds(reverse ? 0 : 70);
+ var config = new AggregateConfiguration { TreatUncertainAsBad = uncertainAsBad, PercentDataBad = 100, PercentDataGood = 100, UseSlopedExtrapolation = false, UseServerCapabilitiesDefaults = false };
+ var calculator = Aggregators.CreateStandardCalculator(id, start, end, 20_000, stepped, config, telemetry);
+ var output = new List();
+ string? error = null;
+ try
+ {
+ var samples = dataset.Value.OrderBy(x => x.Seconds);
+ foreach (var sample in reverse ? samples.Reverse() : samples)
+ {
+ var time = epoch.AddSeconds(sample.Seconds);
+ calculator.QueueRawValue(new DataValue(new Variant(Payload(sample)!), sample.Status, time, time));
+ if (incremental) Drain(false);
+ }
+ Drain(true);
+ }
+ catch (Exception ex) { error = ex.GetType().FullName; }
+ vectors.Add(new(name, dataset.Key, reverse, stepped, uncertainAsBad, incremental, output.ToArray(), error));
+ void Drain(bool partial)
+ {
+ for (var guard = 0; guard < 100; guard++)
+ {
+ var value = calculator.GetProcessedValue(partial);
+ if (value is null) return;
+ output.Add(new(partial, value.Value?.GetType().Name, Format(value.Value), value.StatusCode.Code, value.SourceTimestamp.Ticks, value.ServerTimestamp.Ticks, value.SourcePicoseconds, value.ServerPicoseconds));
+ }
+ throw new InvalidOperationException("Baseline calculator did not terminate.");
+ }
+}
+var assembly = typeof(Aggregators).Assembly.Location;
+var baseline = new { sdk = "1.5.378.182", assemblySha256 = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(assembly))), epochTicks = epoch.Ticks, intervalMilliseconds = 20_000, data, vectors };
+Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(args[0]))!);
+File.WriteAllText(args[0], JsonSerializer.Serialize(baseline, new JsonSerializerOptions { WriteIndented = true }) + "\n");
+Console.WriteLine($"Generated {vectors.Count} vectors for {names.Length} aggregate IDs; baseline assembly SHA256 {baseline.assemblySha256}.");
+
+static object? Payload(Sample sample) => sample.Type switch
+{
+ "Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "Boolean" => bool.Parse(sample.Payload!),
+ _ => null,
+};
+static string? Format(object? value) => value switch
+{
+ null => null,
+ double number => number.ToString("R", CultureInfo.InvariantCulture),
+ float number => number.ToString("R", CultureInfo.InvariantCulture),
+ IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
+ _ => value.ToString(),
+};
+record Sample(double Seconds, string Type, string? Payload, uint Status);
+record Value(bool PartialRequested, string? Type, string? Payload, uint Status, long SourceTicks, long ServerTicks, ushort SourcePicoseconds, ushort ServerPicoseconds);
+record Vector(string Aggregate, string DataSet, bool Reverse, bool Stepped, bool UncertainAsBad, bool Incremental, Value[] Output, string? Exception);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/AggregateCalculator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/AggregateCalculator.cs
new file mode 100644
index 00000000..4c49a289
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/AggregateCalculator.cs
@@ -0,0 +1,1579 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Opc.Ua;
+using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.Logging;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
+{
+ ///
+ /// Calculates the value of an aggregate.
+ ///
+ internal class AggregateCalculator
+ {
+ ///
+ /// Create calculator
+ ///
+ [Obsolete("Use constructor with ITelemetryContext")]
+ public AggregateCalculator(
+ NodeId aggregateId,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ bool stepped,
+ AggregateConfiguration configuration)
+ : this(aggregateId, startTime, endTime, processingInterval, stepped, configuration, null)
+ {
+ }
+
+ ///
+ /// Initializes the calculation stream.
+ ///
+ /// The aggregate function to apply.
+ /// The start time.
+ /// The end time.
+ /// The processing interval.
+ /// Whether to use stepped interpolation.
+ /// The aggregate configuration.
+ /// The telemetry context to use to create obvservability instruments
+ public AggregateCalculator(
+ NodeId aggregateId,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ bool stepped,
+ AggregateConfiguration configuration,
+ ITelemetryContext? telemetry)
+ {
+ m_logger = telemetry.CreateLogger();
+ AggregateId = aggregateId;
+ StartTime = startTime;
+ EndTime = endTime;
+ ProcessingInterval = processingInterval;
+ Stepped = stepped;
+ Configuration = configuration;
+ TimeFlowsBackward = endTime < startTime;
+
+ if (processingInterval == 0)
+ {
+ if (endTime == DateTime.MinValue || startTime == DateTime.MinValue)
+ {
+ throw new ArgumentException(
+ "Non-zero processingInterval required.",
+ nameof(processingInterval));
+ }
+
+ ProcessingInterval = Math.Abs((endTime - startTime).TotalMilliseconds);
+ }
+
+ m_values = new LinkedList();
+ }
+
+ ///
+ /// The aggregate function applied by the calculator.
+ ///
+ public NodeId AggregateId { get; }
+
+ ///
+ /// Queues a raw value for processing.
+ ///
+ /// The data value to process.
+ /// True if successful, false if the timestamp has been superseded by values already in the stream.
+ public bool QueueRawValue(DataValue value)
+ {
+ // ignore bad data.
+ if (value == null)
+ {
+ return false;
+ }
+
+ // ignore placeholders in the stream.
+ if (value.StatusCode.CodeBits == StatusCodes.BadNoData ||
+ value.StatusCode.CodeBits == StatusCodes.BadBoundNotFound)
+ {
+ return true;
+ }
+
+ // check for start of data.
+ if (m_startOfData == DateTime.MinValue)
+ {
+ m_startOfData = value.SourceTimestamp;
+ }
+
+ // update end of data.
+ m_endOfData = value.SourceTimestamp;
+
+ // ensure values are being queued in the right order.
+ if (TimeFlowsBackward)
+ {
+ if (m_values.First != null && CompareTimestamps(value, m_values.First) > 0)
+ {
+ return false;
+ }
+ }
+ else if (m_values.Last != null && CompareTimestamps(value, m_values.Last) < 0)
+ {
+ return false;
+ }
+
+ // ensure value list is always ordered from past to future.
+ if (TimeFlowsBackward)
+ {
+ m_values.AddFirst(value);
+ }
+ else
+ {
+ m_values.AddLast(value);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Returns the next processed value.
+ ///
+ /// If true a partial interval should be processed.
+ /// The processed value. Null if nothing available and returnPartial is false.
+ public DataValue? GetProcessedValue(bool returnPartial)
+ {
+ // check if all done.
+ if (Complete)
+ {
+ return null;
+ }
+
+ // update the slice.
+ if (CurrentSlice == null)
+ {
+ CurrentSlice = CreateSlice(null);
+ }
+ else
+ {
+ UpdateSlice(CurrentSlice);
+ }
+
+ // check if a value can be produced.
+ if (!CurrentSlice.Complete && !returnPartial)
+ {
+ return null;
+ }
+
+ // check if the slice extends beyond the range of available data.
+ DateTime earlyTime = CurrentSlice.StartTime;
+ DateTime lateTime = CurrentSlice.EndTime;
+
+ if (CompareTimestamps(lateTime, m_values.First) < 0 ||
+ CompareTimestamps(earlyTime, m_values.Last) > 0)
+ {
+ CurrentSlice.OutOfDataRange = true;
+ }
+
+ m_logger.LogTrace("Computing Aggregate {StartTime:HH:mm:ss.fff}", CurrentSlice.StartTime);
+
+ // compute the value.
+ DataValue value = ComputeValue(CurrentSlice);
+
+ // check if overlapping the start of data.
+ if (SetPartialBit)
+ {
+ if (m_startOfData > earlyTime && m_startOfData < lateTime)
+ {
+ value.StatusCode = value.StatusCode.WithAggregateBits(
+ value.StatusCode.AggregateBits | AggregateBits.Partial);
+ }
+
+ if (!UsingExtrapolation &&
+ !TimeFlowsBackward &&
+ m_endOfData >= earlyTime &&
+ m_endOfData < lateTime)
+ {
+ value.StatusCode = value.StatusCode.WithAggregateBits(
+ value.StatusCode.AggregateBits | AggregateBits.Partial);
+ }
+ }
+
+ // force value to null if status code is bad.
+ if (StatusCode.IsBad(value.StatusCode))
+ {
+ value.WrappedValue = Variant.Null;
+ }
+
+ // delete unneeded data.
+ if (TimeFlowsBackward)
+ {
+ if (CurrentSlice.LateBound != null)
+ {
+ LinkedListNode? ii = CurrentSlice.LateBound.Next;
+
+ while (ii != null)
+ {
+ LinkedListNode? next = ii.Next;
+ m_values.Remove(ii);
+ ii = next;
+ }
+ }
+ }
+ else if (CurrentSlice.EarlyBound != null)
+ {
+ LinkedListNode? ii = CurrentSlice.EarlyBound.Previous;
+
+ if (CurrentSlice.SecondEarlyBound != null)
+ {
+ ii = CurrentSlice.SecondEarlyBound.Previous;
+ }
+
+ while (ii != null)
+ {
+ LinkedListNode? next = ii.Previous;
+ m_values.Remove(ii);
+ ii = next;
+ }
+ }
+
+ // check if more to be done.
+ Complete =
+ (!TimeFlowsBackward && CurrentSlice.EndTime >= EndTime) ||
+ (TimeFlowsBackward && CurrentSlice.StartTime <= EndTime);
+
+ if (Complete)
+ {
+ // check if overlapping the end of data.
+ if (SetPartialBit &&
+ !UsingExtrapolation &&
+ !TimeFlowsBackward &&
+ m_endOfData >= earlyTime &&
+ m_endOfData < lateTime)
+ {
+ value.StatusCode = value.StatusCode.WithAggregateBits(
+ value.StatusCode.AggregateBits | AggregateBits.Partial);
+ }
+ }
+ else
+ {
+ CurrentSlice = CreateSlice(CurrentSlice);
+ }
+
+ // return the processed value.
+ return value;
+ }
+
+ ///
+ /// Returns true if the specified time is later than the end of the current interval.
+ ///
+ /// Return true if time flows forward and the time is later than the end time.
+ public bool HasEndTimePassed(DateTime currentTime)
+ {
+ if (CurrentSlice == null)
+ {
+ return false;
+ }
+
+ if (TimeFlowsBackward)
+ {
+ return CurrentSlice.EndTime >= currentTime;
+ }
+
+ return CurrentSlice.EndTime <= currentTime;
+ }
+
+ ///
+ /// The start time for the request.
+ ///
+ protected DateTime StartTime { get; }
+
+ ///
+ /// The end time for the request.
+ ///
+ protected DateTime EndTime { get; }
+
+ ///
+ /// The processing interval for the request.
+ ///
+ protected double ProcessingInterval { get; }
+
+ ///
+ /// True if the data series requires stepped interpolation.
+ ///
+ protected bool Stepped { get; }
+
+ ///
+ /// The configuration to use when processing.
+ ///
+ protected AggregateConfiguration Configuration { get; }
+
+ ///
+ /// Whether to use the server timestamp for all processing.
+ ///
+ protected bool UseServerTimestamp { get; }
+
+ ///
+ /// True if data is being processed in reverse order.
+ ///
+ protected bool TimeFlowsBackward { get; }
+
+ ///
+ /// Whether to use the server timestamp for all processing.
+ ///
+ protected TimeSlice? CurrentSlice { get; private set; }
+
+ ///
+ /// True if all values required for the request have been received and processed
+ ///
+ protected bool Complete { get; private set; }
+
+ ///
+ /// True if the GetProcessedValue method should set the Partial bit when appropriate.
+ ///
+ protected bool SetPartialBit { get; set; }
+
+ ///
+ /// True if data is extrapolated after the end of data.
+ ///
+ protected bool UsingExtrapolation { get; set; }
+
+ ///
+ /// Compares timestamps for two DataValues according to the current UseServerTimestamp setting.
+ ///
+ /// The first value to compare.
+ /// The second value to compare.
+ /// Less than 0 if value1 is earlier than value2; 0 if they are equal; Greater than zero otherwise.
+ protected int CompareTimestamps(DataValue value1, DataValue value2)
+ {
+ if (value1 == null)
+ {
+ return value2 == null ? 0 : -1;
+ }
+
+ if (value2 == null)
+ {
+ return +1;
+ }
+
+ if (UseServerTimestamp)
+ {
+ int result = value1.ServerTimestamp.CompareTo(value2.ServerTimestamp);
+
+ if (result == 0)
+ {
+ return value1.ServerPicoseconds.CompareTo(value2.ServerPicoseconds);
+ }
+
+ return result;
+ }
+ else
+ {
+ int result = value1.SourceTimestamp.CompareTo(value2.SourceTimestamp);
+
+ if (result == 0)
+ {
+ return value1.SourcePicoseconds.CompareTo(value2.SourcePicoseconds);
+ }
+
+ return result;
+ }
+ }
+
+ ///
+ /// Compares timestamps for two DataValues according to the current UseServerTimestamp setting.
+ ///
+ /// The first value to compare.
+ /// The second value to compare.
+ /// Less than 0 if value1 is earlier than value2; 0 if they are equal; Greater than zero otherwise.
+ protected int CompareTimestamps(DataValue value1, LinkedListNode? value2)
+ {
+ if (value2 == null)
+ {
+ return value1 == null ? 0 : +1;
+ }
+
+ return CompareTimestamps(value1, value2.Value);
+ }
+
+ ///
+ /// Compares timestamps for two DataValues according to the current UseServerTimestamp setting.
+ ///
+ /// The first value to compare.
+ /// The second value to compare.
+ /// Less than 0 if value1 is earlier than value2; 0 if they are equal; Greater than zero otherwise.
+ protected int CompareTimestamps(
+ LinkedListNode? value1,
+ LinkedListNode? value2)
+ {
+ if (value1 == null)
+ {
+ return value2 == null ? 0 : -1;
+ }
+
+ if (value2 == null)
+ {
+ return +1;
+ }
+
+ return CompareTimestamps(value1.Value, value2.Value);
+ }
+
+ ///
+ /// Compares timestamps for a timestamp to a DataValue according to the current UseServerTimestamp setting.
+ ///
+ /// The timestamp to compare.
+ /// The data value to compare.
+ /// Less than 0 if value1 is earlier than value2; 0 if they are equal; Greater than zero otherwise.
+ protected int CompareTimestamps(DateTime value1, LinkedListNode? value2)
+ {
+ if (value2 == null || value2.Value == null)
+ {
+ return +1;
+ }
+
+ if (UseServerTimestamp)
+ {
+ return value1.CompareTo(value2.Value.ServerTimestamp);
+ }
+
+ return value1.CompareTo(value2.Value.SourceTimestamp);
+ }
+
+ ///
+ /// Checks if the value is good according to the configuration rules.
+ ///
+ /// The value to test.
+ /// True if the value is good.
+ protected bool IsGood(DataValue value)
+ {
+ if (value == null)
+ {
+ return false;
+ }
+
+ if (Configuration.TreatUncertainAsBad)
+ {
+ if (StatusCode.IsNotGood(value.StatusCode))
+ {
+ return false;
+ }
+ }
+ else if (StatusCode.IsBad(value.StatusCode))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Stores information about a slice of data to be processed.
+ ///
+ protected class TimeSlice
+ {
+ ///
+ /// The start time for the slice.
+ ///
+ public DateTime StartTime { get; set; }
+
+ ///
+ /// The end time for the slice.
+ ///
+ public DateTime EndTime { get; set; }
+
+ ///
+ /// True if the slice is a partial interval.
+ ///
+ public bool Partial { get; set; }
+
+ ///
+ /// True if all of the data required to process the slice has been collected.
+ ///
+ public bool Complete { get; set; }
+
+ ///
+ /// True if the slice includes times that are outside of the available dataset.
+ ///
+ public bool OutOfDataRange { get; set; }
+
+ ///
+ /// The first early bound for the slice.
+ ///
+ public LinkedListNode? EarlyBound { get; set; }
+
+ ///
+ /// The latest non-bad value before the slice.
+ ///
+ internal LinkedListNode? NonBadEarlyBound { get; set; }
+
+ ///
+ /// The second early bound for the slice (always earlier than the first).
+ ///
+ public LinkedListNode? SecondEarlyBound { get; set; }
+
+ ///
+ /// The beginning of the slice.
+ ///
+ public LinkedListNode? Begin { get; set; }
+
+ ///
+ /// The end of the slice.
+ ///
+ public LinkedListNode? End { get; set; }
+
+ ///
+ /// The late bound for the slice.
+ ///
+ public LinkedListNode? LateBound { get; set; }
+
+ ///
+ /// The last value which was processed.
+ ///
+ public LinkedListNode? LastProcessedValue { get; set; }
+ }
+
+ ///
+ /// Creates a new time slice to process.
+ ///
+ /// The previous processed slice.
+ /// The new time slice.
+ protected TimeSlice CreateSlice(TimeSlice? previousSlice)
+ {
+ var slice = new TimeSlice();
+
+ // ensure slice is oriented from past to future even if request is going backwards.
+ if (TimeFlowsBackward)
+ {
+ if (previousSlice == null)
+ {
+ slice.EndTime = StartTime;
+ }
+ else
+ {
+ slice.EndTime = previousSlice.StartTime;
+ }
+
+ slice.StartTime = slice.EndTime.AddMilliseconds(-ProcessingInterval);
+
+ // check for end of request.
+ if (slice.StartTime < EndTime)
+ {
+ slice.StartTime = EndTime;
+ slice.Partial = true;
+ }
+ }
+ else
+ {
+ if (previousSlice == null)
+ {
+ slice.StartTime = StartTime;
+ }
+ else
+ {
+ slice.StartTime = previousSlice.EndTime;
+ }
+
+ slice.EndTime = slice.StartTime.AddMilliseconds(ProcessingInterval);
+
+ // check for end of request.
+ if (slice.EndTime > EndTime)
+ {
+ slice.EndTime = EndTime;
+ slice.Partial = true;
+ }
+ }
+
+ // update the slice with current data.
+ UpdateSlice(slice);
+ return slice;
+ }
+
+ ///
+ /// Creates a new time slice to process.
+ ///
+ /// The slice to update.
+ /// True if the slice is complete.
+ protected bool UpdateSlice(TimeSlice slice)
+ {
+ // check if nothing to do.
+ if (m_values.First == null)
+ {
+ return slice.Complete;
+ }
+
+ // restart processing from where it left off.
+ LinkedListNode? start = m_values.First;
+
+ if (!TimeFlowsBackward && slice.LastProcessedValue != null)
+ {
+ start = slice.LastProcessedValue.Next;
+ }
+
+ // reset the begin bound each time we go through the values.
+ if (TimeFlowsBackward)
+ {
+ slice.Begin = null;
+ }
+
+ // initialize slice from value list.
+ for (LinkedListNode? ii = start; ii != null; ii = ii.Next)
+ {
+ if (TimeFlowsBackward)
+ {
+ // check if before the beginning of the slice.
+ if (CompareTimestamps(slice.StartTime, ii) >= 0)
+ {
+ if (StatusCode.IsNotBad(ii.Value.StatusCode))
+ {
+ slice.NonBadEarlyBound = ii;
+ }
+
+ if (IsGood(ii.Value))
+ {
+ slice.SecondEarlyBound = slice.EarlyBound;
+ slice.EarlyBound = ii;
+ }
+
+ continue;
+ }
+
+ // check if after the end if the slice.
+ if (CompareTimestamps(slice.EndTime, ii) < 0)
+ {
+ if (IsGood(ii.Value))
+ {
+ slice.LateBound = ii;
+ break;
+ }
+
+ continue;
+ }
+
+ // save first value in the slice.
+ slice.End ??= ii;
+
+ // save end of slice.
+ if (slice.Begin == null)
+ {
+ slice.Begin = ii;
+ slice.LastProcessedValue = ii;
+ }
+ }
+ else
+ {
+ // check if before the beginning of the slice.
+ if (CompareTimestamps(slice.StartTime, ii) > 0)
+ {
+ if (StatusCode.IsNotBad(ii.Value.StatusCode))
+ {
+ slice.NonBadEarlyBound = ii;
+ }
+
+ if (IsGood(ii.Value))
+ {
+ slice.SecondEarlyBound = slice.EarlyBound;
+ slice.EarlyBound = ii;
+ slice.LastProcessedValue = ii;
+ }
+
+ continue;
+ }
+
+ // check if after the end if the slice.
+ if (CompareTimestamps(slice.EndTime, ii) < 0)
+ {
+ if (IsGood(ii.Value))
+ {
+ slice.LateBound = ii;
+ slice.LastProcessedValue = ii;
+ break;
+ }
+
+ continue;
+ }
+
+ // save first value in the slice.
+ slice.Begin ??= ii;
+
+ // save end of slice.
+ slice.End = ii;
+ slice.LastProcessedValue = ii;
+ }
+ }
+
+ // check if no more data needs to be collected.
+ LinkedListNode? requiredBound;
+ if (TimeFlowsBackward)
+ {
+ // only need second early bound if using sloped extrapolation and there is no late bound.
+ if (Configuration.UseSlopedExtrapolation && slice.LateBound == null)
+ {
+ requiredBound = slice.SecondEarlyBound;
+ }
+ else
+ {
+ requiredBound = slice.EarlyBound;
+ }
+ }
+ else
+ {
+ requiredBound = slice.LateBound;
+ }
+
+ // all done if required bound exists.
+ if (requiredBound != null)
+ {
+ slice.Complete = true;
+ }
+
+ return slice.Complete;
+ }
+
+ ///
+ /// Calculates the value for the timeslice.
+ ///
+ /// The slice to process.
+ /// The processed value.
+ protected virtual DataValue ComputeValue(TimeSlice slice)
+ {
+ return Interpolate(slice);
+ }
+
+ ///
+ /// Calculate the interpolate aggregate for the timeslice.
+ ///
+ protected DataValue Interpolate(TimeSlice slice)
+ {
+ if (TimeFlowsBackward)
+ {
+ return Interpolate(slice.EndTime, slice);
+ }
+
+ return Interpolate(slice.StartTime, slice);
+ }
+
+ ///
+ /// Return a value indicating there is no data in the time slice.
+ ///
+ protected DataValue GetNoDataValue(TimeSlice slice)
+ {
+ if (TimeFlowsBackward)
+ {
+ return GetNoDataValue(slice.EndTime);
+ }
+
+ return GetNoDataValue(slice.StartTime);
+ }
+
+ ///
+ /// Returns the timestamp to use for the slice value.
+ ///
+ protected DateTime GetTimestamp(TimeSlice slice)
+ {
+ if (TimeFlowsBackward)
+ {
+ return slice.EndTime;
+ }
+
+ return slice.StartTime;
+ }
+
+ ///
+ /// Return a value indicating there is no data in the time slice.
+ ///
+ protected DataValue GetNoDataValue(DateTime timestamp)
+ {
+ return new DataValue(Variant.Null, StatusCodes.BadNoData, timestamp, timestamp);
+ }
+
+ ///
+ /// Interpolates a value at the timestamp.
+ ///
+ /// The timestamp.
+ /// The timeslice that contains the timestamp.
+ /// The interpolated value.
+ protected DataValue Interpolate(DateTime timestamp, TimeSlice reference)
+ {
+ for (LinkedListNode? ii = m_values.First; ii != null; ii = ii.Next)
+ {
+ int comparison = CompareTimestamps(timestamp, ii);
+
+ if (comparison == 0)
+ {
+ if (StatusCode.IsNotBad(ii.Value.StatusCode))
+ {
+ return ii.Value;
+ }
+
+ break;
+ }
+
+ if (comparison < 0)
+ {
+ break;
+ }
+ }
+
+ var slice = new TimeSlice { StartTime = timestamp, EndTime = timestamp };
+ UpdateSlice(slice);
+
+ // check for value at the timestamp.
+ if (slice.Begin != null && IsGood(slice.Begin.Value))
+ {
+ return slice.Begin.Value;
+ }
+
+ bool stepped = Stepped;
+
+ DataValue dataValue;
+ // check if the required bounds are available.
+ if (!Stepped)
+ {
+ // check if sloped interpolation is possible.
+ if (slice.EarlyBound != null && slice.LateBound != null)
+ {
+ dataValue = SlopedInterpolate(
+ timestamp,
+ slice.EarlyBound.Value,
+ slice.LateBound.Value);
+
+ if (!ReferenceEquals(slice.EarlyBound.Next, slice.LateBound))
+ {
+ dataValue.StatusCode = dataValue.StatusCode
+ .WithCodeBits(StatusCodes.UncertainDataSubNormal);
+ }
+
+ return dataValue;
+ }
+
+ // check if extrapolation is possible.
+ if (slice.EarlyBound != null)
+ {
+ if (Configuration.UseSlopedExtrapolation &&
+ slice.SecondEarlyBound != null)
+ {
+ UsingExtrapolation = true;
+ dataValue = SlopedInterpolate(
+ timestamp,
+ slice.SecondEarlyBound.Value,
+ slice.EarlyBound.Value);
+ dataValue.StatusCode = dataValue.StatusCode
+ .WithCodeBits(StatusCodes.UncertainDataSubNormal);
+ return dataValue;
+ }
+
+ // do stepped extrapolation.
+ stepped = true;
+ }
+ }
+
+ // do stepped interpolation.
+ if (stepped && slice.EarlyBound != null)
+ {
+ dataValue = SteppedInterpolate(timestamp, slice.EarlyBound.Value);
+
+ if (slice.EarlyBound.Next == null ||
+ CompareTimestamps(timestamp, slice.EarlyBound.Next) > 0)
+ {
+ UsingExtrapolation = true;
+ dataValue.StatusCode = dataValue.StatusCode
+ .WithCodeBits(StatusCodes.UncertainDataSubNormal);
+ }
+
+ return dataValue;
+ }
+
+ // no data found.
+ return GetNoDataValue(timestamp);
+ }
+
+ ///
+ /// Calculate the value at the timestamp using slopped interpolation.
+ ///
+ public static DataValue SteppedInterpolate(DateTime timestamp, DataValue earlyBound)
+ {
+ // can't interpolate if no start bound.
+ if (StatusCode.IsBad(earlyBound.StatusCode))
+ {
+ return new DataValue(Variant.Null, StatusCodes.BadNoData, timestamp, timestamp);
+ }
+
+ var dataValue = new DataValue
+ {
+ WrappedValue = earlyBound.WrappedValue,
+ SourceTimestamp = timestamp,
+ ServerTimestamp = timestamp,
+ StatusCode = StatusCodes.Good
+ };
+
+ // update status code.
+ if (StatusCode.IsBad(earlyBound.StatusCode))
+ {
+ dataValue.StatusCode = StatusCodes.BadNoData;
+ }
+
+ // update status code.
+ if (StatusCode.IsNotGood(earlyBound.StatusCode))
+ {
+ dataValue.StatusCode = StatusCodes.UncertainDataSubNormal;
+ }
+
+ dataValue.StatusCode = dataValue.StatusCode
+ .WithAggregateBits(AggregateBits.Interpolated);
+ return dataValue;
+ }
+
+ ///
+ /// Calculate the value at the timestamp using slopped interpolation.
+ ///
+ public static DataValue SlopedInterpolate(
+ DateTime timestamp,
+ DataValue earlyBound,
+ DataValue lateBound)
+ {
+ try
+ {
+ // can't interpolate if no start bound.
+ if (StatusCode.IsBad(earlyBound.StatusCode))
+ {
+ return new DataValue(Variant.Null, StatusCodes.BadNoData, timestamp, timestamp);
+ }
+
+ // revert to stepped if no end bound.
+ if (StatusCode.IsBad(lateBound.StatusCode))
+ {
+ DataValue dataValue2 = SteppedInterpolate(timestamp, earlyBound);
+
+ if (StatusCode.IsNotBad(dataValue2.StatusCode))
+ {
+ dataValue2.StatusCode = dataValue2.StatusCode
+ .WithCodeBits(StatusCodes.UncertainDataSubNormal);
+ }
+
+ return dataValue2;
+ }
+
+ // convert to doubles.
+ double earlyValue = CastToDouble(earlyBound);
+ double lateValue = CastToDouble(lateBound);
+
+ // do interpolation.
+ double range = (lateBound.SourceTimestamp - earlyBound.SourceTimestamp)
+ .TotalMilliseconds;
+ double slope = (lateValue - earlyValue) / range;
+ double calculatedValue =
+ (slope * (timestamp - earlyBound.SourceTimestamp).TotalMilliseconds) +
+ earlyValue;
+
+ // convert back to original type.
+ var dataValue = new DataValue
+ {
+ WrappedValue = CastToOriginalType(calculatedValue, earlyBound),
+ SourceTimestamp = timestamp,
+ ServerTimestamp = timestamp,
+ StatusCode = StatusCodes.Good
+ };
+
+ // update status code.
+ if (StatusCode.IsNotGood(earlyBound.StatusCode) ||
+ StatusCode.IsNotGood(lateBound.StatusCode))
+ {
+ dataValue.StatusCode = StatusCodes.UncertainDataSubNormal;
+ }
+
+ dataValue.StatusCode = dataValue.StatusCode
+ .WithAggregateBits(AggregateBits.Interpolated);
+
+ return dataValue;
+ }
+ // exception occurs on data conversion errors.
+ catch (Exception)
+ {
+ return new DataValue(
+ Variant.Null,
+ StatusCodes.BadTypeMismatch,
+ timestamp,
+ timestamp);
+ }
+ }
+
+ ///
+ /// Converts the value to a double for use in calculations (throws exceptions if conversion fails).
+ ///
+ private protected static object LegacyCast(object? value, TypeInfo source, BuiltInType target)
+ {
+ // Representation conversion only; baseline interval/quality/state algorithms are retained below.
+ return SdkValueBoundary.ToClr(SdkValueBoundary.FromClr(value).ConvertTo(target))!;
+ }
+
+ protected static double CastToDouble(DataValue value)
+ {
+ return (double)LegacyCast(
+ value.Value,
+ value.WrappedValue.TypeInfo,
+ BuiltInType.Double);
+ }
+
+ ///
+ /// Converts the value back to its original type (throws exceptions if conversion fails).
+ ///
+ protected static Variant CastToOriginalType(double value, DataValue original)
+ {
+ object castValue = LegacyCast(
+ value,
+ TypeInfo.Scalars.Double,
+ original.WrappedValue.TypeInfo.BuiltInType);
+ return SdkValueBoundary.FromClr(castValue);
+ }
+
+ ///
+ /// Returns the simple bound for the timestamp.
+ ///
+ protected DataValue? GetSimpleBound(DateTime timestamp, TimeSlice slice)
+ {
+ // choose the start point
+ LinkedListNode? start = slice.EarlyBound ?? m_values.First;
+
+ // look for a raw value at or immediately before the timestamp.
+ LinkedListNode? startBound = start;
+
+ for (LinkedListNode? ii = start; ii != null; ii = ii.Next)
+ {
+ // check for an exact match.
+ if (CompareTimestamps(timestamp, ii) == 0)
+ {
+ return new DataValue(ii.Value);
+ }
+
+ // looking for an end bound.
+ if (CompareTimestamps(timestamp, ii) < 0)
+ {
+ // only can find an end bound.
+ if (ii.Previous == null)
+ {
+ return GetNoDataValue(timestamp);
+ }
+
+ startBound = ii.Previous;
+ break;
+ }
+
+ // update start bound.
+ startBound = ii;
+ }
+
+ // check if no data found or if start bound is bad..
+ if (startBound == null || !IsGood(startBound.Value))
+ {
+ return GetNoDataValue(timestamp);
+ }
+
+ // look for an end bound.
+ bool revertToStepped = false;
+ LinkedListNode? endBound = startBound.Next;
+
+ if (!Stepped)
+ {
+ if (endBound != null)
+ {
+ // do sloped interpolation if two good bounds exist.
+ if (IsGood(endBound.Value))
+ {
+ return SlopedInterpolate(timestamp, startBound.Value, endBound.Value);
+ }
+ }
+
+ // have to use stepped because end bound is not good.
+ revertToStepped = true;
+ }
+
+ // check if end of data.
+ if (startBound.Next == null)
+ {
+ return GetNoDataValue(timestamp);
+ }
+
+ // do stepped interpolation for all other cases.
+ DataValue value = SteppedInterpolate(timestamp, startBound.Value);
+
+ // need to make it uncertain if interpolation was required but not used.
+ if (StatusCode.IsGood(value.StatusCode) && revertToStepped)
+ {
+ value.StatusCode = StatusCodes.UncertainDataSubNormal;
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Interpolated);
+ }
+
+ return value;
+ }
+
+ ///
+ /// Returns the values in the list with simple bounds.
+ ///
+ protected List? GetValuesWithSimpleBounds(TimeSlice slice)
+ {
+ // check if slice is beyond end of available data.
+ if (CompareTimestamps(slice.StartTime, m_values.Last) > 0 ||
+ CompareTimestamps(slice.EndTime, m_values.First) < 0)
+ {
+ return null;
+ }
+
+ var values = new List();
+
+ // add the start point.
+ DataValue? startBound = GetSimpleBound(slice.StartTime, slice);
+
+ if (startBound != null)
+ {
+ values.Add(startBound);
+ }
+
+ // initialize slice from value list.
+ for (LinkedListNode? ii = slice.Begin; ii != null; ii = ii.Next)
+ {
+ if (CompareTimestamps(slice.EndTime, ii) <= 0)
+ {
+ break;
+ }
+
+ if (CompareTimestamps(slice.StartTime, ii) < 0)
+ {
+ values.Add(ii.Value);
+ }
+ }
+
+ // add the end point.
+ DataValue? endBound = GetSimpleBound(slice.EndTime, slice);
+
+ if (endBound != null)
+ {
+ values.Add(endBound);
+ }
+
+ return values;
+ }
+
+ ///
+ /// Returns the values between the start time and the end time for the slice.
+ ///
+ protected List? GetValues(TimeSlice slice)
+ {
+ // check if slice is beyond end of available data.
+ if (CompareTimestamps(slice.StartTime, m_values.Last) > 0 ||
+ CompareTimestamps(slice.EndTime, m_values.First) < 0)
+ {
+ return null;
+ }
+
+ var values = new List();
+
+ // initialize slice from value list.
+ for (LinkedListNode? ii = slice.Begin; ii != null; ii = ii.Next)
+ {
+ if (TimeFlowsBackward)
+ {
+ if (CompareTimestamps(slice.EndTime, ii) < 0)
+ {
+ break;
+ }
+
+ if (CompareTimestamps(slice.StartTime, ii) < 0)
+ {
+ values.Add(ii.Value);
+ }
+ }
+ else
+ {
+ if (CompareTimestamps(slice.EndTime, ii) <= 0)
+ {
+ break;
+ }
+
+ if (CompareTimestamps(slice.StartTime, ii) <= 0)
+ {
+ values.Add(ii.Value);
+ }
+ }
+ }
+
+ return values;
+ }
+
+ ///
+ /// Returns the values in the list with interpolated bounds.
+ ///
+ protected List? GetValuesWithInterpolatedBounds(TimeSlice slice)
+ {
+ // check if slice is before the available data.
+ if (CompareTimestamps(slice.EndTime, m_values.First) < 0)
+ {
+ return null;
+ }
+
+ var values = new List();
+
+ // add the start point.
+ DataValue startBound = Interpolate(slice.StartTime, slice);
+
+ if (startBound != null)
+ {
+ values.Add(startBound);
+ }
+
+ // initialize slice from value list.
+ for (LinkedListNode? ii = slice.Begin; ii != null; ii = ii.Next)
+ {
+ if (CompareTimestamps(slice.EndTime, ii) <= 0)
+ {
+ break;
+ }
+
+ if (CompareTimestamps(slice.StartTime, ii) <= 0)
+ {
+ values.Add(ii.Value);
+ }
+ }
+
+ // add the end point.
+ DataValue endBound = Interpolate(slice.EndTime, slice);
+
+ if (endBound != null)
+ {
+ values.Add(endBound);
+ }
+
+ return values;
+ }
+
+ ///
+ /// A subset of a slice bounded by two raw data points.
+ ///
+ protected class SubRegion
+ {
+ ///
+ /// The value at the start of the region.
+ ///
+ public double StartValue { get; set; }
+
+ ///
+ /// The value at the end of the region.
+ ///
+ public double EndValue { get; set; }
+
+ ///
+ /// The timestamp at the start of the region.
+ ///
+ public DateTime StartTime;
+
+ ///
+ /// The length of the region.
+ ///
+ public double Duration { get; set; }
+
+ ///
+ /// The status for the region.
+ ///
+ public StatusCode StatusCode;
+
+ ///
+ /// The data point at the start of the region.
+ ///
+ public required DataValue DataPoint;
+ }
+
+ ///
+ /// Returns the values in the list with simple bounds.
+ ///
+ [return: System.Diagnostics.CodeAnalysis.NotNullIfNotNull(nameof(values))]
+ protected List? GetRegionsInValueSet(
+ List? values,
+ bool ignoreBadData,
+ bool useSteppedCalculations)
+ {
+ // nothing to do if no data.
+ if (values == null)
+ {
+ return null;
+ }
+
+ SubRegion? currentRegion = null;
+ var regions = new List();
+
+ for (int ii = 0; ii < values.Count; ii++)
+ {
+ double currentValue = 0;
+ DateTime currentTime = values[ii].SourceTimestamp;
+ StatusCode currentStatus = values[ii].StatusCode;
+
+ // convert to doubles to facilitate numeric calculations.
+ if (StatusCode.IsNotBad(currentStatus))
+ {
+ try
+ {
+ currentValue = CastToDouble(values[ii]);
+ }
+ catch (Exception)
+ {
+ currentStatus = StatusCodes.BadTypeMismatch;
+ }
+ }
+ else
+ {
+ // use the previous value if end of region is bad.
+ if (currentRegion != null)
+ {
+ currentValue = currentRegion.StartValue;
+ }
+ }
+
+ // some aggregates ignore bad data so remove them from the set.
+ if (ignoreBadData)
+ {
+ // always keep the first region.
+ if (currentRegion != null)
+ {
+ if (!IsGood(values[ii]))
+ {
+ // set the status to sub normal if bad end data ignored.
+ if (StatusCode.IsNotBad(currentRegion.StatusCode))
+ {
+ currentRegion.StatusCode = StatusCodes.UncertainDataSubNormal;
+ }
+
+ // skip everything but the endpoint.
+ if (ii < values.Count - 1)
+ {
+ continue;
+ }
+ }
+ else if (!useSteppedCalculations &&
+ StatusCode.IsNotGood(values[ii].StatusCode))
+ {
+ currentRegion.StatusCode = StatusCodes.UncertainDataSubNormal;
+ }
+ }
+ }
+
+ if (currentRegion != null)
+ {
+ // if using stepped calculations the end value is not used.
+ if (useSteppedCalculations)
+ {
+ currentRegion.EndValue = currentRegion.StartValue;
+ }
+ // using interpolated calculations means the end affects the status of the current region.
+ else if (IsGood(values[ii]))
+ {
+ // handle case with uncertain end point.
+ if (StatusCode.IsNotGood(values[ii].StatusCode) &&
+ StatusCode.IsNotBad(currentRegion.StatusCode))
+ {
+ currentRegion.StatusCode = StatusCodes.UncertainDataSubNormal;
+ }
+
+ currentRegion.EndValue = currentValue;
+ }
+ else
+ {
+ if (StatusCode.IsNotBad(currentRegion.StatusCode))
+ {
+ currentRegion.StatusCode = StatusCodes.UncertainDataSubNormal;
+ }
+
+ if (ignoreBadData && StatusCode.IsNotBad(currentStatus))
+ {
+ currentRegion.EndValue = currentValue;
+ }
+ }
+
+ // if at end of data then duration is 1 tick.
+ // must be end of data if start of region is good yet end bound is bad.
+ if (!ignoreBadData &&
+ IsGood(currentRegion.DataPoint) &&
+ currentStatus == StatusCodes.BadNoData &&
+ ii == values.Count - 1)
+ {
+ currentRegion.Duration = 1;
+ }
+ // calculate region span.
+ else
+ {
+ // set uncertain status to bad if treat uncertain as bad is true.
+ if (StatusCode.IsUncertain(currentStatus) && !IsGood(values[ii]))
+ {
+ currentStatus = StatusCodes.BadNoData;
+ }
+
+ currentRegion.Duration = (currentTime - currentRegion.StartTime)
+ .TotalMilliseconds;
+ }
+
+ regions.Add(currentRegion);
+ }
+
+ // start a new region.
+ currentRegion = new SubRegion
+ {
+ StartValue = currentValue,
+ EndValue = currentValue,
+ StartTime = currentTime,
+ StatusCode = currentStatus,
+ DataPoint = values[ii]
+ };
+ }
+
+ return regions;
+ }
+
+ ///
+ /// Calculates the value based status code for the slice
+ ///
+ protected StatusCode GetValueBasedStatusCode(
+ TimeSlice slice,
+ List values,
+ StatusCode statusCode)
+ {
+ // compute the total good/bad/uncertain.
+ double badCount = 0;
+ double goodCount = 0;
+ double totalCount = 0;
+
+ for (int ii = 0; ii < values.Count; ii++)
+ {
+ totalCount++;
+
+ if (StatusCode.IsBad(values[ii].StatusCode))
+ {
+ badCount++;
+ continue;
+ }
+
+ if (StatusCode.IsGood(values[ii].StatusCode))
+ {
+ goodCount++;
+ }
+ }
+
+ if (totalCount == 0 || goodCount / totalCount * 100 >= Configuration.PercentDataGood)
+ {
+ // good if the good count is greater than or equal to the configured threshold.
+ statusCode = statusCode.WithCodeBits(StatusCodes.Good);
+ }
+ else if (badCount / totalCount * 100 >= Configuration.PercentDataBad)
+ {
+ // bad if the bad count is greater than or equal to the configured threshold.
+ statusCode = StatusCodes.Bad;
+ }
+ else
+ {
+ // uncertain if did not meet the Good or Bad requirements
+ statusCode = statusCode.WithCodeBits(StatusCodes.UncertainDataSubNormal);
+ }
+
+ return statusCode;
+ }
+
+ ///
+ /// Calculates the status code for the slice
+ ///
+ protected StatusCode GetTimeBasedStatusCode(
+ TimeSlice slice,
+ List values,
+ StatusCode defaultCode)
+ {
+ // get the regions in the slice.
+ List regions = GetRegionsInValueSet(values, false, Stepped);
+
+ if (regions == null || regions.Count == 0)
+ {
+ return StatusCodes.BadNoData;
+ }
+
+ return GetTimeBasedStatusCode(regions, defaultCode);
+ }
+
+ ///
+ /// Calculates the status code for the slice
+ ///
+ protected StatusCode GetTimeBasedStatusCode(List regions, StatusCode statusCode)
+ {
+ // check for empty set.
+ if (regions == null || regions.Count == 0)
+ {
+ return StatusCodes.BadNoData;
+ }
+
+ // compute the total good/bad/uncertain.
+ double badDuration = 0;
+ double goodDuration = 0;
+ double totalDuration = 0;
+
+ foreach (SubRegion region in regions)
+ {
+ totalDuration += region.Duration;
+
+ if (StatusCode.IsBad(region.StatusCode))
+ {
+ badDuration += region.Duration;
+ continue;
+ }
+
+ // Take into account the Uncertain status code
+ if (StatusCode.IsGood(region.StatusCode) ||
+ (!Configuration.TreatUncertainAsBad &&
+ StatusCode.IsUncertain(region.StatusCode)))
+ {
+ goodDuration += region.Duration;
+ }
+ }
+
+ if (totalDuration == 0 ||
+ goodDuration / totalDuration * 100 >= Configuration.PercentDataGood)
+ {
+ // good if the good duration is greater than or equal to the configured threshold.
+ statusCode = statusCode.WithCodeBits(StatusCodes.Good);
+ }
+ else if (badDuration / totalDuration * 100 >= Configuration.PercentDataBad)
+ {
+ // bad if the bad duration is greater than or equal to the configured threshold.
+ statusCode = StatusCodes.Bad;
+ }
+ else
+ {
+ // uncertain if did not meet the Good or Bad requirements
+ statusCode = statusCode.WithCodeBits(StatusCodes.UncertainDataSubNormal);
+ }
+
+ // always calculated.
+ return statusCode;
+ }
+
+ private readonly ILogger m_logger;
+ private readonly LinkedList m_values;
+ private DateTime m_startOfData;
+ private DateTime m_endOfData;
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/Aggregators.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/Aggregators.cs
new file mode 100644
index 00000000..04b13426
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/Aggregators.cs
@@ -0,0 +1,382 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Opc.Ua;
+using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
+using System;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
+{
+ ///
+ /// Creates a new instance of an aggregate factory.
+ ///
+ internal delegate AggregateCalculator AggregatorFactory(
+ NodeId aggregateId,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ bool stepped,
+ AggregateConfiguration configuration,
+ ITelemetryContext? telemetry);
+
+ ///
+ /// The set of built-in aggregate factories.
+ ///
+ internal static class Aggregators
+ {
+ ///
+ /// Stores the mapping for a aggregate id to the calculator.
+ ///
+ private class FactoryMapping
+ {
+ public NodeId AggregateId { get; set; }
+
+ public QualifiedName AggregateName { get; set; }
+
+ public required Type Calculator { get; set; }
+ }
+
+ ///
+ /// Mapping for all of the standard aggregates.
+ ///
+ private static readonly FactoryMapping[] s_mappings =
+ [
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Interpolative,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Interpolative),
+ Calculator = typeof(AggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Average,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Average),
+ Calculator = typeof(AverageAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_TimeAverage,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_TimeAverage),
+ Calculator = typeof(AverageAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_TimeAverage2,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_TimeAverage2),
+ Calculator = typeof(AverageAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Total,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Total),
+ Calculator = typeof(AverageAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Total2,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Total2),
+ Calculator = typeof(AverageAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Minimum,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Minimum),
+ Calculator = typeof(MinMaxAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Maximum,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Maximum),
+ Calculator = typeof(MinMaxAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_MinimumActualTime,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MinimumActualTime),
+ Calculator = typeof(MinMaxAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_MaximumActualTime,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MaximumActualTime),
+ Calculator = typeof(MinMaxAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Range,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Range),
+ Calculator = typeof(MinMaxAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Minimum2,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Minimum2),
+ Calculator = typeof(MinMaxAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Maximum2,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Maximum2),
+ Calculator = typeof(MinMaxAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_MinimumActualTime2,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MinimumActualTime2),
+ Calculator = typeof(MinMaxAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_MaximumActualTime2,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MaximumActualTime2),
+ Calculator = typeof(MinMaxAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Range2,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Range2),
+ Calculator = typeof(MinMaxAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Count,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Count),
+ Calculator = typeof(CountAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_AnnotationCount,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_AnnotationCount),
+ Calculator = typeof(CountAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_DurationInStateZero,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationInStateZero),
+ Calculator = typeof(CountAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_DurationInStateNonZero,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationInStateNonZero),
+ Calculator = typeof(CountAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_NumberOfTransitions,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_NumberOfTransitions),
+ Calculator = typeof(CountAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Start,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Start),
+ Calculator = typeof(StartEndAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_End,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_End),
+ Calculator = typeof(StartEndAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_Delta,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Delta),
+ Calculator = typeof(StartEndAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_StartBound,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_StartBound),
+ Calculator = typeof(StartEndAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_EndBound,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_EndBound),
+ Calculator = typeof(StartEndAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_DeltaBounds,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DeltaBounds),
+ Calculator = typeof(StartEndAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_DurationGood,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationGood),
+ Calculator = typeof(StatusAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_DurationBad,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationBad),
+ Calculator = typeof(StatusAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_PercentGood,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_PercentGood),
+ Calculator = typeof(StatusAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_PercentBad,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_PercentBad),
+ Calculator = typeof(StatusAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_WorstQuality,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_WorstQuality),
+ Calculator = typeof(StatusAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_WorstQuality2,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_WorstQuality2),
+ Calculator = typeof(StatusAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_StandardDeviationPopulation,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_StandardDeviationPopulation),
+ Calculator = typeof(StdDevAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_VariancePopulation,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_VariancePopulation),
+ Calculator = typeof(StdDevAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_StandardDeviationSample,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_StandardDeviationSample),
+ Calculator = typeof(StdDevAggregateCalculator)
+ },
+ new FactoryMapping
+ {
+ AggregateId = ObjectIds.AggregateFunction_VarianceSample,
+ AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_VarianceSample),
+ Calculator = typeof(StdDevAggregateCalculator)
+ }
+ ];
+
+ internal static IEnumerable SupportedIds => s_mappings.Select(x => x.AggregateId);
+
+ ///
+ /// Returns the name for a standard aggregates.
+ ///
+ public static QualifiedName GetNameForStandardAggregate(NodeId aggregateId)
+ {
+ for (int ii = 0; ii < s_mappings.Length; ii++)
+ {
+ if (s_mappings[ii].AggregateId == aggregateId)
+ {
+ return s_mappings[ii].AggregateName;
+ }
+ }
+
+ return default;
+ }
+
+ ///
+ /// Returns the id for a standard aggregates.
+ ///
+ public static NodeId GetIdForStandardAggregate(QualifiedName aggregateName)
+ {
+ for (int ii = 0; ii < s_mappings.Length; ii++)
+ {
+ if (s_mappings[ii].AggregateName == aggregateName)
+ {
+ return s_mappings[ii].AggregateId;
+ }
+ }
+
+ return default;
+ }
+
+ ///
+ /// Creates a calculator for one of the standard aggregates.
+ ///
+ [Obsolete("Use CreateStandardCalculator with telemetry context.")]
+ public static AggregateCalculator? CreateStandardCalculator(
+ NodeId aggregateId,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ bool stepped,
+ AggregateConfiguration configuration)
+ {
+ return CreateStandardCalculator(
+ aggregateId,
+ startTime,
+ endTime,
+ processingInterval,
+ stepped,
+ configuration,
+ null);
+ }
+
+ ///
+ /// Creates a calculator for one of the standard aggregates.
+ ///
+ public static AggregateCalculator? CreateStandardCalculator(
+ NodeId aggregateId,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ bool stepped,
+ AggregateConfiguration configuration,
+ ITelemetryContext? telemetry)
+ {
+ for (int ii = 0; ii < s_mappings.Length; ii++)
+ {
+ if (s_mappings[ii].AggregateId == aggregateId)
+ {
+ return (AggregateCalculator?)
+ Activator.CreateInstance(
+ s_mappings[ii].Calculator,
+ aggregateId,
+ startTime,
+ endTime,
+ processingInterval,
+ stepped,
+ configuration,
+ telemetry);
+ }
+ }
+
+ return default;
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/AverageAggregateCalculator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/AverageAggregateCalculator.cs
new file mode 100644
index 00000000..5a193c9a
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/AverageAggregateCalculator.cs
@@ -0,0 +1,247 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Opc.Ua;
+using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
+{
+ ///
+ /// Calculates the value of an aggregate.
+ ///
+ internal class AverageAggregateCalculator : AggregateCalculator
+ {
+ ///
+ /// Initializes the aggregate calculator.
+ ///
+ /// The aggregate function to apply.
+ /// The start time.
+ /// The end time.
+ /// The processing interval.
+ /// Whether to use stepped interpolation.
+ /// The aggregate configuration.
+ /// The telemetry context to use to create obvservability instruments
+ public AverageAggregateCalculator(
+ NodeId aggregateId,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ bool stepped,
+ AggregateConfiguration configuration,
+ ITelemetryContext? telemetry)
+ : base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
+ {
+ SetPartialBit = aggregateId != ObjectIds.AggregateFunction_Average;
+ }
+
+ ///
+ /// Computes the value for the timeslice.
+ ///
+ protected override DataValue ComputeValue(TimeSlice slice)
+ {
+ uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
+
+ if (id == null)
+ {
+ return base.ComputeValue(slice);
+ }
+ switch (id.Value)
+ {
+ case Objects.AggregateFunction_Average:
+ return ComputeAverage(slice);
+ case Objects.AggregateFunction_TimeAverage:
+ return ComputeTimeAverage(slice, false, 1);
+ case Objects.AggregateFunction_Total:
+ return ComputeTimeAverage(slice, false, 2);
+ case Objects.AggregateFunction_TimeAverage2:
+ return ComputeTimeAverage(slice, true, 1);
+ case Objects.AggregateFunction_Total2:
+ return ComputeTimeAverage(slice, true, 2);
+ default:
+ return base.ComputeValue(slice);
+ }
+ }
+
+ ///
+ /// Calculates the RegSlope, RegConst and RegStdDev aggregates for the timeslice.
+ ///
+ protected DataValue ComputeAverage(TimeSlice slice)
+ {
+ // get the values in the slice.
+ List? values = GetValues(slice);
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // calculate total and count.
+ int count = 0;
+ double total = 0;
+
+ for (int ii = 0; ii < values.Count; ii++)
+ {
+ if (StatusCode.IsGood(values[ii].StatusCode))
+ {
+ try
+ {
+ double sample = CastToDouble(values[ii]);
+ total += sample;
+ count++;
+ }
+ catch
+ {
+ // ignore conversion errors.
+ }
+ }
+ }
+
+ // check for empty slice.
+ if (count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // select the result.
+ double result = total / count;
+
+ // set the timestamp and status.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(result),
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+ value.StatusCode = GetValueBasedStatusCode(slice, values, value.StatusCode);
+
+ // return result.
+ return value;
+ }
+
+ ///
+ /// Calculates the StdDev, Variance, StdDev2 and Variance2 aggregates for the timeslice.
+ ///
+ protected DataValue ComputeTimeAverage(TimeSlice slice, bool useSimpleBounds, int valueType)
+ {
+ // get the values in the slice.
+ List? values;
+ if (useSimpleBounds)
+ {
+ values = GetValuesWithSimpleBounds(slice);
+ }
+ else
+ {
+ values = GetValuesWithInterpolatedBounds(slice);
+ }
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // get the regions.
+ List regions = GetRegionsInValueSet(values, !useSimpleBounds, Stepped);
+
+ double total = 0;
+ double totalDuration = 0;
+ bool nonGoodRegionsExists = false;
+
+ for (int ii = 0; ii < regions.Count; ii++)
+ {
+ double duration = regions[ii].Duration / 1000.0;
+
+ if (StatusCode.IsNotBad(regions[ii].StatusCode))
+ {
+ total += (regions[ii].StartValue + regions[ii].EndValue) * duration / 2;
+ totalDuration += duration;
+ }
+
+ if (StatusCode.IsNotGood(regions[ii].StatusCode))
+ {
+ nonGoodRegionsExists = true;
+ }
+ }
+
+ // check if no good data.
+ if (totalDuration == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // select the result.
+ double result = 0;
+
+ switch (valueType)
+ {
+ case 1:
+ result = total / totalDuration;
+ break;
+ case 2:
+ result = total;
+ break;
+ default:
+ Debug.Fail($"Unexpected value type {valueType}");
+ break;
+ }
+
+ // set the timestamp and status.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(result),
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+
+ if (useSimpleBounds)
+ {
+ value.StatusCode = GetTimeBasedStatusCode(regions, value.StatusCode);
+ }
+ else
+ {
+ value.StatusCode = StatusCodes.Good;
+
+ if (nonGoodRegionsExists)
+ {
+ value.StatusCode = StatusCodes.UncertainDataSubNormal;
+ }
+ }
+
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+
+ // return result.
+ return value;
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/CompatibilityAggregateFactory.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/CompatibilityAggregateFactory.cs
new file mode 100644
index 00000000..c4d1b6f9
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/CompatibilityAggregateFactory.cs
@@ -0,0 +1,33 @@
+using Opc.Ua;
+using Opc.Ua.Server;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
+
+/// Adapts the versioned baseline algorithms to the SDK 2 calculator protocol without a second SDK runtime.
+internal static class CompatibilityAggregateFactory
+{
+ internal static IAggregateCalculator Create(NodeId id, DateTimeUtc start, DateTimeUtc end, double interval,
+ bool stepped, AggregateConfiguration configuration, ITelemetryContext telemetry) =>
+ new Adapter(Aggregators.CreateStandardCalculator(id, (DateTime)start, (DateTime)end, interval, stepped, configuration, telemetry)
+ ?? throw new ArgumentOutOfRangeException(nameof(id), "Unsupported baseline aggregate."));
+
+ /// Replaces exactly the existing standard factories; no aggregate IDs are added or removed.
+ internal static async ValueTask RegisterAsync(AggregateManager manager, CancellationToken ct)
+ {
+ foreach (var id in Aggregators.SupportedIds)
+ await manager.RegisterFactoryAsync(id, Aggregators.GetNameForStandardAggregate(id).Name!, Create, ct).ConfigureAwait(false);
+ }
+
+ private sealed class Adapter(AggregateCalculator calculator) : IAggregateCalculator
+ {
+ public NodeId AggregateId => calculator.AggregateId;
+ public bool QueueRawValue(Opc.Ua.DataValue value) => calculator.QueueRawValue(LegacyDataValue.FromSdk(value));
+ public bool HasEndTimePassed(DateTimeUtc currentTime) => calculator.HasEndTimePassed((DateTime)currentTime);
+ public bool TryGetProcessedValue(bool returnPartial, out Opc.Ua.DataValue value)
+ {
+ var processed = calculator.GetProcessedValue(returnPartial);
+ value = processed is null ? default : processed.ToSdk();
+ return processed is not null;
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/CountAggregateCalculator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/CountAggregateCalculator.cs
new file mode 100644
index 00000000..6b0debd5
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/CountAggregateCalculator.cs
@@ -0,0 +1,278 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Opc.Ua;
+using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
+using System;
+using System.Collections.Generic;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
+{
+ ///
+ /// Calculates the value of an aggregate.
+ ///
+ internal class CountAggregateCalculator : AggregateCalculator
+ {
+ ///
+ /// Initializes the aggregate calculator.
+ ///
+ /// The aggregate function to apply.
+ /// The start time.
+ /// The end time.
+ /// The processing interval.
+ /// Whether to use stepped interpolation.
+ /// The aggregate configuration.
+ /// The telemetry context to use to create obvservability instruments
+ public CountAggregateCalculator(
+ NodeId aggregateId,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ bool stepped,
+ AggregateConfiguration configuration,
+ ITelemetryContext? telemetry)
+ : base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
+ {
+ SetPartialBit = true;
+ }
+
+ ///
+ /// Computes the value for the timeslice.
+ ///
+ protected override DataValue ComputeValue(TimeSlice slice)
+ {
+ uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
+
+ if (id == null)
+ {
+ return base.ComputeValue(slice);
+ }
+ switch (id.Value)
+ {
+ case Objects.AggregateFunction_Count:
+ return ComputeCount(slice);
+ case Objects.AggregateFunction_AnnotationCount:
+ return ComputeAnnotationCount(slice);
+ case Objects.AggregateFunction_DurationInStateZero:
+ return ComputeDurationInState(slice, false);
+ case Objects.AggregateFunction_DurationInStateNonZero:
+ return ComputeDurationInState(slice, true);
+ case Objects.AggregateFunction_NumberOfTransitions:
+ return ComputeNumberOfTransitions(slice);
+ default:
+ return base.ComputeValue(slice);
+ }
+ }
+
+ ///
+ /// Calculates the Count aggregate for the timeslice.
+ ///
+ protected DataValue ComputeCount(TimeSlice slice)
+ {
+ // get the values in the slice.
+ List? values = GetValues(slice);
+
+ // check for empty slice.
+ if (values == null)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // count the values.
+ int count = 0;
+
+ for (int ii = 0; ii < values.Count; ii++)
+ {
+ if (StatusCode.IsGood(values[ii].StatusCode))
+ {
+ count++;
+ }
+ }
+
+ // set the timestamp and status.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(count),
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+ value.StatusCode = GetValueBasedStatusCode(slice, values, value.StatusCode);
+
+ if (!StatusCode.IsBad(value.StatusCode))
+ {
+ // set aggregate bits fon non Bad values
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+ }
+ // return result.
+ return value;
+ }
+
+ ///
+ /// Calculates the AnnotationCount aggregate for the timeslice.
+ ///
+ protected DataValue ComputeAnnotationCount(TimeSlice slice)
+ {
+ // get the values in the slice.
+ List? values = GetValues(slice);
+
+ // check for empty slice.
+ if (values == null)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // count the values.
+ int count = 0;
+
+ for (int ii = 0; ii < values.Count; ii++)
+ {
+ count++;
+ }
+
+ // set the timestamp and status.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(count),
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+
+ // return result.
+ return value;
+ }
+
+ ///
+ /// Calculates the DurationInStateZero and DurationInStateNonZero aggregates for the timeslice.
+ ///
+ protected DataValue ComputeDurationInState(TimeSlice slice, bool isNonZero)
+ {
+ // get the values in the slice.
+ List? values = GetValuesWithSimpleBounds(slice);
+
+ // check for empty slice.
+ if (values == null)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // get the regions.
+ List regions = GetRegionsInValueSet(values, false, true);
+
+ double duration = 0;
+
+ for (int ii = 0; ii < regions.Count; ii++)
+ {
+ if (StatusCode.IsNotGood(regions[ii].StatusCode))
+ {
+ continue;
+ }
+
+ if (isNonZero)
+ {
+ if (regions[ii].StartValue != 0)
+ {
+ duration += regions[ii].Duration;
+ }
+ }
+ else if (regions[ii].StartValue == 0)
+ {
+ duration += regions[ii].Duration;
+ }
+ }
+
+ // set the timestamp and status.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(duration),
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+ value.StatusCode = GetTimeBasedStatusCode(regions, value.StatusCode);
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+
+ // return result.
+ return value;
+ }
+
+ ///
+ /// Calculates the Count aggregate for the timeslice.
+ ///
+ protected DataValue ComputeNumberOfTransitions(TimeSlice slice)
+ {
+ // get the values in the slice.
+ List? values = GetValues(slice);
+
+ // check for empty slice.
+ if (values == null)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // The first non-bad value is a transition when no previous non-bad value exists.
+ LinkedListNode? previousValue = slice.NonBadEarlyBound;
+ bool hasLastValue = previousValue != null;
+ object? lastValue = previousValue?.Value.Value;
+
+ // count the transitions.
+ int count = 0;
+
+ for (int ii = 0; ii < values.Count; ii++)
+ {
+ if (StatusCode.IsBad(values[ii].StatusCode))
+ {
+ continue;
+ }
+
+ object? nextValue = values[ii].Value;
+
+ if (!hasLastValue || !Utils.IsEqual(lastValue, nextValue))
+ {
+ count++;
+ }
+
+ hasLastValue = true;
+ lastValue = nextValue;
+ }
+
+ // set the timestamp and status.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(count),
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+ value.StatusCode = GetValueBasedStatusCode(slice, values, value.StatusCode);
+
+ // return result.
+ return value;
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/LegacyDataValue.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/LegacyDataValue.cs
new file mode 100644
index 00000000..c4228e90
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/LegacyDataValue.cs
@@ -0,0 +1,36 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
+
+/// Private mutable work value preserving the baseline calculator's ownership and timestamp semantics.
+internal sealed class LegacyDataValue
+{
+ internal LegacyDataValue() { }
+ internal LegacyDataValue(LegacyDataValue other)
+ {
+ WrappedValue = other.WrappedValue;
+ StatusCode = other.StatusCode;
+ SourceTimestamp = other.SourceTimestamp;
+ ServerTimestamp = other.ServerTimestamp;
+ SourcePicoseconds = other.SourcePicoseconds;
+ ServerPicoseconds = other.ServerPicoseconds;
+ }
+ internal LegacyDataValue(Variant value, StatusCode status, DateTime source, DateTime server)
+ {
+ WrappedValue = value; StatusCode = status; SourceTimestamp = source; ServerTimestamp = server;
+ }
+ internal Variant WrappedValue { get; set; }
+ internal object? Value { get => SdkValueBoundary.ToClr(WrappedValue); set => WrappedValue = SdkValueBoundary.FromClr(value); }
+ internal StatusCode StatusCode { get; set; } = StatusCodes.Good;
+ internal DateTime SourceTimestamp { get; set; }
+ internal DateTime ServerTimestamp { get; set; }
+ internal ushort SourcePicoseconds { get; set; }
+ internal ushort ServerPicoseconds { get; set; }
+ internal static LegacyDataValue FromSdk(Opc.Ua.DataValue value) => new()
+ {
+ WrappedValue = value.WrappedValue, StatusCode = value.StatusCode,
+ SourceTimestamp = (DateTime)value.SourceTimestamp, ServerTimestamp = (DateTime)value.ServerTimestamp,
+ SourcePicoseconds = value.SourcePicoseconds, ServerPicoseconds = value.ServerPicoseconds,
+ };
+ internal Opc.Ua.DataValue ToSdk() => new(WrappedValue, StatusCode, SourceTimestamp, ServerTimestamp, SourcePicoseconds, ServerPicoseconds);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/MinMaxAggregateCalculator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/MinMaxAggregateCalculator.cs
new file mode 100644
index 00000000..5996edf0
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/MinMaxAggregateCalculator.cs
@@ -0,0 +1,505 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Opc.Ua;
+using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
+using System;
+using System.Collections.Generic;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
+{
+ ///
+ /// Calculates the value of an aggregate.
+ ///
+ internal class MinMaxAggregateCalculator : AggregateCalculator
+ {
+ ///
+ /// Initializes the aggregate calculator.
+ ///
+ /// The aggregate function to apply.
+ /// The start time.
+ /// The end time.
+ /// The processing interval.
+ /// Whether to use stepped interpolation.
+ /// The aggregate configuration.
+ /// The telemetry context to use to create obvservability instruments
+ public MinMaxAggregateCalculator(
+ NodeId aggregateId,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ bool stepped,
+ AggregateConfiguration configuration,
+ ITelemetryContext? telemetry)
+ : base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
+ {
+ SetPartialBit = true;
+ }
+
+ ///
+ /// Computes the value for the timeslice.
+ ///
+ protected override DataValue ComputeValue(TimeSlice slice)
+ {
+ uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
+
+ if (id == null)
+ {
+ return base.ComputeValue(slice);
+ }
+ switch (id.Value)
+ {
+ case Objects.AggregateFunction_Minimum:
+ return ComputeMinMax(slice, 1, false);
+ case Objects.AggregateFunction_MinimumActualTime:
+ return ComputeMinMax(slice, 1, true);
+ case Objects.AggregateFunction_Maximum:
+ return ComputeMinMax(slice, 2, false);
+ case Objects.AggregateFunction_MaximumActualTime:
+ return ComputeMinMax(slice, 2, true);
+ case Objects.AggregateFunction_Range:
+ return ComputeMinMax(slice, 3, false);
+ case Objects.AggregateFunction_Minimum2:
+ return ComputeMinMax2(slice, 1, false);
+ case Objects.AggregateFunction_MinimumActualTime2:
+ return ComputeMinMax2(slice, 1, true);
+ case Objects.AggregateFunction_Maximum2:
+ return ComputeMinMax2(slice, 2, false);
+ case Objects.AggregateFunction_MaximumActualTime2:
+ return ComputeMinMax2(slice, 2, true);
+ case Objects.AggregateFunction_Range2:
+ return ComputeMinMax2(slice, 3, false);
+ default:
+ return base.ComputeValue(slice);
+ }
+ }
+
+ ///
+ /// Calculate the Minimum, Maximum, MinimumActualTime and MaximumActualTime aggregates for the timeslice.
+ ///
+ protected DataValue ComputeMinMax(TimeSlice slice, int valueType, bool returnActualTime)
+ {
+ // get the values in the slice.
+ List? values = GetValues(slice);
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ double minimumGoodValue = double.MaxValue;
+ double minimumUncertainValue = double.MaxValue;
+ double maximumGoodValue = double.MinValue;
+ double maximumUncertainValue = double.MinValue;
+
+ DateTime minimumGoodTimestamp = DateTime.MinValue;
+ DateTime maximumGoodTimestamp = DateTime.MinValue;
+
+ TypeInfo? minimumOriginalType = null;
+ TypeInfo? maximumOriginalType = null;
+
+ bool badValuesExist = false;
+ bool duplicatesMinimumsExist = false;
+ bool duplicatesMaximumsExist = false;
+ bool goodValueExists = false;
+
+ for (int ii = 0; ii < values.Count; ii++)
+ {
+ DateTime currentTime = values[ii].SourceTimestamp;
+ StatusCode currentStatus = values[ii].StatusCode;
+
+ // ignore bad values.
+ if (!IsGood(values[ii]))
+ {
+ badValuesExist = true;
+ continue;
+ }
+
+ double currentValue;
+ // convert to double.
+ try
+ {
+ currentValue = CastToDouble(values[ii]);
+ }
+ catch (Exception)
+ {
+ badValuesExist = true;
+ continue;
+ }
+
+ // check for uncertain.
+ if (StatusCode.IsUncertain(currentStatus))
+ {
+ if (minimumUncertainValue > currentValue)
+ {
+ minimumUncertainValue = currentValue;
+ }
+
+ if (maximumUncertainValue < currentValue)
+ {
+ maximumUncertainValue = currentValue;
+ }
+
+ continue;
+ }
+
+ // check for new minimum.
+ if (minimumGoodValue > currentValue)
+ {
+ minimumGoodValue = currentValue;
+ minimumGoodTimestamp = currentTime;
+ minimumOriginalType = values[ii].WrappedValue.TypeInfo;
+ duplicatesMinimumsExist = false;
+ goodValueExists = true;
+ }
+ // check for duplicate minimums.
+ else if (minimumGoodValue == currentValue)
+ {
+ duplicatesMinimumsExist = true;
+ }
+
+ // check for new maximum.
+ if (maximumGoodValue < currentValue)
+ {
+ maximumGoodValue = currentValue;
+ maximumGoodTimestamp = currentTime;
+ maximumOriginalType = values[ii].WrappedValue.TypeInfo;
+ duplicatesMaximumsExist = false;
+ goodValueExists = true;
+ }
+ // check for duplicate maximums.
+ else if (maximumGoodValue == currentValue)
+ {
+ duplicatesMaximumsExist = true;
+ }
+ }
+
+ // check if at least one good value exists.
+ if (!goodValueExists)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // set the status code.
+ StatusCode statusCode = StatusCodes.Good;
+
+ // uncertain if any bad values exist.
+ if (badValuesExist)
+ {
+ statusCode = StatusCodes.UncertainDataSubNormal;
+ }
+
+ // determine the calculated value to return.
+ object? processedValue = null;
+ TypeInfo? processedType = null;
+ DateTime processedTimestamp = DateTime.MinValue;
+ bool duplicatesExist = false;
+
+ if (valueType == 1)
+ {
+ processedValue = minimumGoodValue;
+ processedTimestamp = minimumGoodTimestamp;
+ processedType = minimumOriginalType;
+ _ = minimumGoodValue > minimumUncertainValue;
+ duplicatesExist = duplicatesMinimumsExist;
+ }
+ else if (valueType == 2)
+ {
+ processedValue = maximumGoodValue;
+ processedTimestamp = maximumGoodTimestamp;
+ processedType = maximumOriginalType;
+ _ = maximumGoodValue < maximumUncertainValue;
+ duplicatesExist = duplicatesMaximumsExist;
+ }
+ else if (valueType == 3)
+ {
+ processedValue = Math.Abs(maximumGoodValue - minimumGoodValue);
+ processedType = TypeInfo.Scalars.Double;
+ _ = maximumGoodValue < maximumUncertainValue ||
+ minimumGoodValue > minimumUncertainValue;
+ }
+
+ // set calculated if not returning actual time and value is not at the start time.
+ if (!returnActualTime && processedTimestamp != GetTimestamp(slice))
+ {
+ statusCode = statusCode.WithAggregateBits(AggregateBits.Calculated);
+ }
+
+ // set the multiple values flags.
+ if (duplicatesExist)
+ {
+ statusCode = statusCode.WithAggregateBits(
+ statusCode.AggregateBits | AggregateBits.MultipleValues);
+ }
+
+ // convert back to original datatype.
+ if (processedType != null && processedType.Value.BuiltInType != BuiltInType.Double)
+ {
+ processedValue = LegacyCast(
+ processedValue,
+ TypeInfo.Scalars.Double,
+ processedType.Value.BuiltInType);
+ }
+ else
+ {
+ processedType = TypeInfo.Scalars.Double;
+ }
+
+ // create processed value.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(processedValue),
+ StatusCode = statusCode
+ };
+
+ if (returnActualTime)
+ {
+ value.SourceTimestamp = processedTimestamp;
+ value.ServerTimestamp = processedTimestamp;
+ }
+ else
+ {
+ value.SourceTimestamp = GetTimestamp(slice);
+ value.ServerTimestamp = GetTimestamp(slice);
+ }
+
+ return value;
+ }
+
+ ///
+ /// Calculate the Minimum2, Maximum2, MinimumActualTime2, MaximumActualTime2 and Range2 aggregates for the timeslice.
+ ///
+ protected DataValue ComputeMinMax2(TimeSlice slice, int valueType, bool returnActualTime)
+ {
+ // get the values in the slice.
+ List? values = GetValuesWithSimpleBounds(slice);
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ double minimumGoodValue = double.MaxValue;
+ double maximumGoodValue = double.MinValue;
+
+ DateTime minimumGoodTimestamp = DateTime.MinValue;
+ DateTime maximumGoodTimestamp = DateTime.MinValue;
+
+ StatusCode minimumGoodStatusCode = StatusCodes.Good;
+ StatusCode maximumGoodStatusCode = StatusCodes.Good;
+
+ TypeInfo? minimumOriginalType = null;
+ TypeInfo? maximumOriginalType = null;
+
+ bool duplicatesMinimumsExist = false;
+ bool duplicatesMaximumsExist = false;
+ bool goodValueExists = false;
+
+ for (int ii = 0; ii < values.Count; ii++)
+ {
+ DateTime currentTime = values[ii].SourceTimestamp;
+ StatusCode currentStatus = values[ii].StatusCode;
+
+ // ignore bad values (as determined by the TreatUncertainAsBad parameter).
+ if (!IsGood(values[ii]))
+ {
+ continue;
+ }
+
+ double currentValue;
+ // convert to double.
+ try
+ {
+ currentValue = CastToDouble(values[ii]);
+ }
+ catch (Exception)
+ {
+ continue;
+ }
+
+ // skip endpoint if stepped.
+ if (currentTime == slice.EndTime && Stepped)
+ {
+ break;
+ }
+
+ // check for new minimum.
+ if (minimumGoodValue > currentValue)
+ {
+ minimumGoodValue = currentValue;
+ minimumGoodTimestamp = currentTime;
+ minimumGoodStatusCode = currentStatus;
+ minimumOriginalType = values[ii].WrappedValue.TypeInfo;
+ duplicatesMinimumsExist = false;
+ goodValueExists = true;
+ }
+ // check for duplicate minimums.
+ else if (minimumGoodValue == currentValue)
+ {
+ duplicatesMinimumsExist = true;
+ }
+
+ // check for new maximum.
+ if (maximumGoodValue < currentValue)
+ {
+ maximumGoodValue = currentValue;
+ maximumGoodTimestamp = currentTime;
+ maximumGoodStatusCode = currentStatus;
+ maximumOriginalType = values[ii].WrappedValue.TypeInfo;
+ duplicatesMaximumsExist = false;
+ goodValueExists = true;
+ }
+ // check for duplicate maximums.
+ else if (maximumGoodValue == currentValue)
+ {
+ duplicatesMaximumsExist = true;
+ }
+ }
+
+ // check if at least one good value exists.
+ if (!goodValueExists)
+ {
+ DataValue noDataValue = GetNoDataValue(slice);
+ // check if interval is partial and set the flag accordingly
+ if (slice.Partial)
+ {
+ noDataValue.StatusCode = noDataValue.StatusCode
+ .WithAggregateBits(AggregateBits.Partial);
+ }
+ return noDataValue;
+ }
+
+ // determine the calculated value to return.
+ object? processedValue = null;
+ TypeInfo? processedType = null;
+ DateTime processedTimestamp = DateTime.MinValue;
+ StatusCode processedStatusCode = StatusCodes.Good;
+ bool duplicatesExist = false;
+
+ if (valueType == 1)
+ {
+ processedValue = minimumGoodValue;
+ processedTimestamp = minimumGoodTimestamp;
+ processedStatusCode = minimumGoodStatusCode;
+ processedType = minimumOriginalType;
+ duplicatesExist = duplicatesMinimumsExist;
+ }
+ else if (valueType == 2)
+ {
+ processedValue = maximumGoodValue;
+ processedTimestamp = maximumGoodTimestamp;
+ processedStatusCode = maximumGoodStatusCode;
+ processedType = maximumOriginalType;
+ duplicatesExist = duplicatesMaximumsExist;
+ }
+ else if (valueType == 3)
+ {
+ processedValue = Math.Abs(maximumGoodValue - minimumGoodValue);
+ processedType = TypeInfo.Scalars.Double;
+ }
+
+ // set the status code.
+ StatusCode statusCode = processedStatusCode;
+
+ // set calculated if not returning actual time and value is not at the start time.
+ if (!returnActualTime &&
+ processedTimestamp != GetTimestamp(slice) &&
+ (statusCode.AggregateBits & AggregateBits.Interpolated) == 0)
+ {
+ statusCode = statusCode.WithAggregateBits(
+ statusCode.AggregateBits | AggregateBits.Calculated);
+ }
+
+ // set the multiple values flags.
+ if (duplicatesExist)
+ {
+ statusCode = statusCode.WithAggregateBits(
+ statusCode.AggregateBits | AggregateBits.MultipleValues);
+ }
+
+ // convert back to original datatype.
+ if (processedType != null && processedType.Value.BuiltInType != BuiltInType.Double)
+ {
+ processedValue = LegacyCast(
+ processedValue,
+ TypeInfo.Scalars.Double,
+ processedType.Value.BuiltInType);
+ }
+ else
+ {
+ processedType = TypeInfo.Scalars.Double;
+ }
+
+ // create processed value.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(processedValue),
+ StatusCode = GetTimeBasedStatusCode(slice, values, statusCode)
+ };
+
+ // zero value if status is bad.
+ if (StatusCode.IsBad(value.StatusCode))
+ {
+ value.WrappedValue = Variant.Null;
+ }
+
+ if (returnActualTime)
+ {
+ // calculate effective time if end bound is used.
+ if (TimeFlowsBackward)
+ {
+ if (processedTimestamp == slice.StartTime)
+ {
+ processedTimestamp = processedTimestamp.AddMilliseconds(+1);
+ value.StatusCode = value.StatusCode.WithAggregateBits(
+ value.StatusCode.AggregateBits | AggregateBits.Interpolated);
+ }
+ }
+ else if (processedTimestamp == slice.EndTime)
+ {
+ processedTimestamp = processedTimestamp.AddMilliseconds(-1);
+ value.StatusCode = value.StatusCode.WithAggregateBits(
+ value.StatusCode.AggregateBits | AggregateBits.Interpolated);
+ }
+
+ value.SourceTimestamp = processedTimestamp;
+ value.ServerTimestamp = processedTimestamp;
+ }
+ else
+ {
+ value.SourceTimestamp = GetTimestamp(slice);
+ value.ServerTimestamp = GetTimestamp(slice);
+ }
+
+ return value;
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/README.md b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/README.md
new file mode 100644
index 00000000..ffb5e45b
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/README.md
@@ -0,0 +1,19 @@
+# Preserved aggregate calculations
+
+The SDK 2.0 upgrade keeps the application's existing historian and live AggregateFilter results (owner decision, 2026-11-10, #782). SDK 2.0.1 changes more than the default configuration: uncertain-sample selection, value-based quality, reverse data edges, partial results and interpolation differ. Changing TreatUncertainAsBad alone does not preserve the contract.
+
+The eight upstream algorithm/factory files here originate at OPCFoundation/UA-.NETStandard tag `1.5.378.182`. Original MIT license headers remain. `baseline-sources.json` records each original URL and SHA-256. They are application-private source, not an old SDK runtime or migration shim.
+
+Representation adaptations:
+
+- Private `LegacyDataValue` retains mutable intermediate work values and CLR DateTime timestamps; the adapter accepts/emits immutable SDK 2 values, including status and picoseconds.
+- Nullable reference annotations describe the existing optional bounds/slices; no warning policy is disabled.
+- Immutable StatusCode methods replace the old setters. NodeId extraction and QualifiedName construction use supported typed APIs.
+- Removed TypeInfo.Cast/Variant(object, TypeInfo) operations use the SDK 2 typed conversion surface; baseline vectors verify value **types**, values, timestamps and all status bits.
+- Interval, quality, sample selection, queue trimming, interpolation and partial-result algorithms remain the baseline algorithms. Factory registration replaces the existing 37 IDs through the public SDK API; it must not add capabilities or duplicate advertised references.
+
+The isolated `scripts/validation/opcua-aggregate-baseline` process pins only SDK 1.5.378.182 and generates the checked-in golden fixture. It is deliberately outside the solution and does not inherit the candidate package graph. The golden file records the baseline assembly hash, shared inputs and 4,144 outputs/cases across all 37 aggregate IDs, seven datasets, forward/reverse, stepped/sloped interpolation, uncertain-as-bad flags and incremental/partial processing. No baseline binary is loaded by the candidate tests or production host.
+
+Run the generator with a **new** output path, then compare it with `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Fixtures/opcua-1.5.378.182-aggregates.json`. It refuses to overwrite evidence. Never regenerate expectations from the compatibility implementation. Existing historian parity tests retain their application-specific documented divergences from the baseline SDK, while new compatibility tests check the complete old-calculator output.
+
+This preserves older behavior deliberately; it does not claim that all older SDK algorithms implement the newest OPC UA interpretation. A future semantic migration needs its own consumer-impact decision and versioned tests.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/StartEndAggregateCalculator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/StartEndAggregateCalculator.cs
new file mode 100644
index 00000000..f70b0034
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/StartEndAggregateCalculator.cs
@@ -0,0 +1,355 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Opc.Ua;
+using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
+using System;
+using System.Collections.Generic;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
+{
+ ///
+ /// Calculates the value of an aggregate.
+ ///
+ internal class StartEndAggregateCalculator : AggregateCalculator
+ {
+ ///
+ /// Initializes the aggregate calculator.
+ ///
+ /// The aggregate function to apply.
+ /// The start time.
+ /// The end time.
+ /// The processing interval.
+ /// Whether to use stepped interpolation.
+ /// The aggregate configuration.
+ /// The telemetry context to use to create obvservability instruments
+ public StartEndAggregateCalculator(
+ NodeId aggregateId,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ bool stepped,
+ AggregateConfiguration configuration,
+ ITelemetryContext? telemetry)
+ : base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
+ {
+ SetPartialBit = true;
+ }
+
+ ///
+ /// Computes the value for the timeslice.
+ ///
+ protected override DataValue ComputeValue(TimeSlice slice)
+ {
+ uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
+
+ if (id == null)
+ {
+ return base.ComputeValue(slice);
+ }
+ switch (id.Value)
+ {
+ case Objects.AggregateFunction_Start:
+ return ComputeStartEnd(slice, false);
+ case Objects.AggregateFunction_End:
+ return ComputeStartEnd(slice, true);
+ case Objects.AggregateFunction_Delta:
+ return ComputeDelta(slice);
+ case Objects.AggregateFunction_StartBound:
+ return ComputeStartEnd2(slice, false);
+ case Objects.AggregateFunction_EndBound:
+ return ComputeStartEnd2(slice, true);
+ case Objects.AggregateFunction_DeltaBounds:
+ return ComputeDelta2(slice);
+ default:
+ return base.ComputeValue(slice);
+ }
+ }
+
+ ///
+ /// Calculate the Start and End aggregates for the timeslice.
+ ///
+ protected DataValue ComputeStartEnd(TimeSlice slice, bool returnEnd)
+ {
+ // get the values in the slice.
+ List? values = GetValues(slice);
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // return start value.
+ if (!returnEnd)
+ {
+ return values[0];
+ }
+
+ return values[^1];
+ }
+
+ ///
+ /// Calculates the Delta aggregate for the timeslice.
+ ///
+ protected DataValue ComputeDelta(TimeSlice slice)
+ {
+ // get the values in the slice.
+ List? values = GetValues(slice);
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ double startValue = double.NaN;
+ TypeInfo? originalType = null;
+ bool badDataSkipped = false;
+
+ for (int ii = 0; ii < values.Count; ii++)
+ {
+ // find start value.
+ DataValue start = values[ii];
+
+ if (IsGood(start))
+ {
+ try
+ {
+ startValue = CastToDouble(start);
+ originalType = start.WrappedValue.TypeInfo;
+ break;
+ }
+ catch (Exception)
+ {
+ startValue = double.NaN;
+ }
+ }
+
+ badDataSkipped = true;
+ }
+
+ double endValue = double.NaN;
+
+ for (int ii = values.Count - 1; ii >= 0; ii--)
+ {
+ // find end value.
+ DataValue end = values[ii];
+
+ if (IsGood(end))
+ {
+ try
+ {
+ endValue = CastToDouble(end);
+ break;
+ }
+ catch (Exception)
+ {
+ endValue = double.NaN;
+ }
+
+ break;
+ }
+
+ badDataSkipped = true;
+ }
+
+ // check if no good data.
+ if (double.IsNaN(startValue) || double.IsNaN(endValue))
+ {
+ return GetNoDataValue(slice);
+ }
+
+ var value = new DataValue
+ {
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+
+ // set status code.
+ if (badDataSkipped)
+ {
+ value.StatusCode = StatusCodes.UncertainDataSubNormal;
+ }
+
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+
+ // calculate delta.
+ double delta = endValue - startValue;
+
+ if (originalType != null && originalType.Value.BuiltInType != BuiltInType.Double)
+ {
+ object delta2 = LegacyCast(
+ delta,
+ TypeInfo.Scalars.Double,
+ originalType.Value.BuiltInType);
+ value.WrappedValue = SdkValueBoundary.FromClr(delta2);
+ }
+ else
+ {
+ value.WrappedValue = SdkValueBoundary.FromClr(delta);
+ }
+
+ // return result.
+ return value;
+ }
+
+ ///
+ /// Calculate the Start2 and End2 aggregates for the timeslice.
+ ///
+ protected DataValue ComputeStartEnd2(TimeSlice slice, bool returnEnd)
+ {
+ // get the values in the slice.
+ List? values = GetValuesWithSimpleBounds(slice);
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ DataValue value;
+
+ // return start bound.
+ if ((!returnEnd && !TimeFlowsBackward) || (returnEnd && TimeFlowsBackward))
+ {
+ value = values[0];
+ }
+ // return end bound.
+ else
+ {
+ value = values[^1];
+ }
+
+ if (!IsGood(value))
+ {
+ value.StatusCode = StatusCodes.BadNoData;
+ }
+
+ if (returnEnd)
+ {
+ value.SourceTimestamp = GetTimestamp(slice);
+ value.ServerTimestamp = GetTimestamp(slice);
+
+ if (StatusCode.IsNotBad(value.StatusCode))
+ {
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+ }
+ }
+
+ return value;
+ }
+
+ ///
+ /// Calculates the Delta2 aggregate for the timeslice.
+ ///
+ protected DataValue ComputeDelta2(TimeSlice slice)
+ {
+ // get the values in the slice.
+ List? values = GetValuesWithSimpleBounds(slice);
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ DataValue start = values[0];
+ DataValue end = values[^1];
+
+ // check for bad bounds.
+ if (!IsGood(start) || !IsGood(end))
+ {
+ return GetNoDataValue(slice);
+ }
+
+ TypeInfo? originalType = null;
+
+ // convert to doubles.
+ double startValue;
+ try
+ {
+ startValue = CastToDouble(start);
+ originalType = start.WrappedValue.TypeInfo;
+ }
+ catch (Exception)
+ {
+ startValue = double.NaN;
+ }
+
+ double endValue;
+ try
+ {
+ endValue = CastToDouble(end);
+ }
+ catch (Exception)
+ {
+ endValue = double.NaN;
+ }
+
+ // check for bad bounds.
+ if (double.IsNaN(startValue) || double.IsNaN(endValue))
+ {
+ return GetNoDataValue(slice);
+ }
+
+ var value = new DataValue
+ {
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+
+ if (!IsGood(start) || !IsGood(end))
+ {
+ value.StatusCode = StatusCodes.UncertainDataSubNormal;
+ }
+
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+
+ // calculate delta.
+ double delta = endValue - startValue;
+
+ if (originalType != null && originalType.Value.BuiltInType != BuiltInType.Double)
+ {
+ object delta2 = LegacyCast(
+ delta,
+ TypeInfo.Scalars.Double,
+ originalType.Value.BuiltInType);
+ value.WrappedValue = SdkValueBoundary.FromClr(delta2);
+ }
+ else
+ {
+ value.WrappedValue = SdkValueBoundary.FromClr(delta);
+ }
+
+ // return result.
+ return value;
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/StatusAggregateCalculator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/StatusAggregateCalculator.cs
new file mode 100644
index 00000000..fded9165
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/StatusAggregateCalculator.cs
@@ -0,0 +1,250 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Opc.Ua;
+using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
+using System;
+using System.Collections.Generic;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
+{
+ ///
+ /// Calculates the value of an aggregate.
+ ///
+ internal class StatusAggregateCalculator : AggregateCalculator
+ {
+ ///
+ /// Initializes the aggregate calculator.
+ ///
+ /// The aggregate function to apply.
+ /// The start time.
+ /// The end time.
+ /// The processing interval.
+ /// Whether to use stepped interpolation.
+ /// The aggregate configuration.
+ /// The telemetry context to use to create obvservability instruments
+ public StatusAggregateCalculator(
+ NodeId aggregateId,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ bool stepped,
+ AggregateConfiguration configuration,
+ ITelemetryContext? telemetry)
+ : base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
+ {
+ SetPartialBit = true;
+ }
+
+ ///
+ /// Computes the value for the timeslice.
+ ///
+ protected override DataValue ComputeValue(TimeSlice slice)
+ {
+ uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
+
+ if (id == null)
+ {
+ return base.ComputeValue(slice);
+ }
+ switch (id.Value)
+ {
+ case Objects.AggregateFunction_DurationGood:
+ return ComputeDurationGoodBad(slice, false, false);
+ case Objects.AggregateFunction_DurationBad:
+ return ComputeDurationGoodBad(slice, true, false);
+ case Objects.AggregateFunction_PercentGood:
+ return ComputeDurationGoodBad(slice, false, true);
+ case Objects.AggregateFunction_PercentBad:
+ return ComputeDurationGoodBad(slice, true, true);
+ case Objects.AggregateFunction_WorstQuality:
+ return ComputeWorstQuality(slice, false);
+ case Objects.AggregateFunction_WorstQuality2:
+ return ComputeWorstQuality(slice, true);
+ default:
+ return base.ComputeValue(slice);
+ }
+ }
+
+ ///
+ /// Calculates the DurationGood and DurationBad aggregates for the timeslice.
+ ///
+ protected DataValue ComputeDurationGoodBad(TimeSlice slice, bool isBad, bool usePercent)
+ {
+ // get the values in the slice.
+ List? values = GetValuesWithSimpleBounds(slice);
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // get the regions.
+ List regions = GetRegionsInValueSet(values, false, true);
+
+ double duration = 0;
+ double total = 0;
+
+ for (int ii = 0; ii < regions.Count; ii++)
+ {
+ total += regions[ii].Duration;
+
+ if (isBad)
+ {
+ if (StatusCode.IsBad(regions[ii].StatusCode) ||
+ (Configuration.TreatUncertainAsBad &&
+ StatusCode.IsUncertain(regions[ii].StatusCode)))
+ {
+ duration += regions[ii].Duration;
+ }
+ }
+ else if (StatusCode.IsGood(regions[ii].StatusCode) ||
+ (!Configuration.TreatUncertainAsBad &&
+ StatusCode.IsUncertain(regions[ii].StatusCode)))
+ {
+ duration += regions[ii].Duration;
+ }
+ }
+
+ if (usePercent)
+ {
+ duration = duration / total * 100;
+ }
+
+ // set the timestamp and status.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(duration),
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+
+ // return result.
+ return value;
+ }
+
+ ///
+ /// Calculates the DurationGood and DurationBad aggregates for the timeslice.
+ ///
+ protected DataValue ComputeWorstQuality(TimeSlice slice, bool includeBounds)
+ {
+ // get the values in the slice.
+ List? values = GetValues(slice);
+
+ if (includeBounds && values != null)
+ {
+ DateTime startTime = GetTimestamp(slice);
+ DataValue? startBound = GetSimpleBound(startTime, slice);
+ int startIndex = TimeFlowsBackward ? values.Count - 1 : 0;
+
+ if (startBound != null &&
+ (values.Count == 0 || values[startIndex].SourceTimestamp != startTime))
+ {
+ if (TimeFlowsBackward)
+ {
+ values.Add(startBound);
+ }
+ else
+ {
+ values.Insert(0, startBound);
+ }
+ }
+ }
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // get the regions.
+ _ = GetRegionsInValueSet(values, false, true);
+
+ int firstIndex = TimeFlowsBackward ? values.Count - 1 : 0;
+ StatusCode worstQuality = values[firstIndex].StatusCode.CodeBits;
+ int badQualityCount = 0;
+ int uncertainQualityCount = 0;
+ int goodQualityCount = 0;
+
+ for (int index = 0; index < values.Count; index++)
+ {
+ int ii = TimeFlowsBackward ? values.Count - 1 - index : index;
+ StatusCode quality = values[ii].StatusCode;
+
+ if (StatusCode.IsBad(quality))
+ {
+ badQualityCount++;
+
+ if (StatusCode.IsNotBad(worstQuality))
+ {
+ worstQuality = quality.CodeBits;
+ }
+
+ continue;
+ }
+
+ if (StatusCode.IsUncertain(quality))
+ {
+ uncertainQualityCount++;
+
+ if (StatusCode.IsGood(worstQuality))
+ {
+ worstQuality = quality.CodeBits;
+ }
+ }
+ else
+ {
+ goodQualityCount++;
+ }
+ }
+
+ // set the timestamp and status.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(worstQuality),
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+
+ if ((StatusCode.IsBad(worstQuality) && badQualityCount > 1) ||
+ (StatusCode.IsUncertain(worstQuality) && uncertainQualityCount > 1) ||
+ (StatusCode.IsGood(worstQuality) && goodQualityCount > 1))
+ {
+ value.StatusCode = value.StatusCode.WithAggregateBits(
+ value.StatusCode.AggregateBits | AggregateBits.MultipleValues);
+ }
+
+ // return result.
+ return value;
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/StdDevAggregateCalculator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/StdDevAggregateCalculator.cs
new file mode 100644
index 00000000..1f2c12be
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/StdDevAggregateCalculator.cs
@@ -0,0 +1,342 @@
+/* ========================================================================
+ * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Opc.Ua;
+using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
+{
+ ///
+ /// Calculates the value of an aggregate.
+ ///
+ internal class StdDevAggregateCalculator : AggregateCalculator
+ {
+ ///
+ /// Initializes the aggregate calculator.
+ ///
+ /// The aggregate function to apply.
+ /// The start time.
+ /// The end time.
+ /// The processing interval.
+ /// Whether to use stepped interpolation.
+ /// The aggregate configuration.
+ /// The telemetry context to use to create obvservability instruments
+ public StdDevAggregateCalculator(
+ NodeId aggregateId,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ bool stepped,
+ AggregateConfiguration configuration,
+ ITelemetryContext? telemetry)
+ : base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
+ {
+ SetPartialBit = true;
+ }
+
+ ///
+ /// Computes the value for the timeslice.
+ ///
+ protected override DataValue ComputeValue(TimeSlice slice)
+ {
+ uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
+
+ if (id == null)
+ {
+ return base.ComputeValue(slice);
+ }
+ switch (id.Value)
+ {
+ // valueType == 1: StandardDeviation, valueType == 2: Variance
+
+ // includeBounds == true: sample, includeBounds == false: population
+ // (this is a strange way to distinguish between sample and population)
+
+ case Objects.AggregateFunction_StandardDeviationPopulation:
+ return ComputeStdDev(slice, false, 1);
+ case Objects.AggregateFunction_StandardDeviationSample:
+ return ComputeStdDev(slice, true, 1);
+ case Objects.AggregateFunction_VariancePopulation:
+ return ComputeStdDev(slice, false, 2);
+ case Objects.AggregateFunction_VarianceSample:
+ return ComputeStdDev(slice, true, 2);
+ default:
+ return base.ComputeValue(slice);
+ }
+ }
+
+ ///
+ /// Calculates the RegSlope, RegConst and RegStdDev aggregates for the timeslice.
+ ///
+ protected DataValue ComputeRegression(TimeSlice slice, int valueType)
+ {
+ // get the values in the slice.
+ List? values = GetValuesWithSimpleBounds(slice);
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // get the regions.
+ List regions = GetRegionsInValueSet(values, false, true);
+
+ var xData = new List();
+ var yData = new List();
+
+ double duration = 0;
+ bool nonGoodDataExists = false;
+
+ for (int ii = 0; ii < regions.Count; ii++)
+ {
+ if (StatusCode.IsGood(regions[ii].StatusCode))
+ {
+ xData.Add(regions[ii].StartValue);
+ yData.Add(duration);
+ }
+ else
+ {
+ nonGoodDataExists = true;
+ }
+
+ // normalize to seconds.
+ duration += regions[ii].Duration / 1000.0;
+ }
+
+ // check if no good data.
+ if (xData.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ // compute the regression parameters.
+ double regSlope = 0;
+ double regConst = 0;
+ double regStdDev = 0;
+
+ if (xData.Count > 1)
+ {
+ double xAvg = 0;
+ double yAvg = 0;
+ double xxAgv = 0;
+ double xyAvg = 0;
+
+ for (int ii = 0; ii < xData.Count; ii++)
+ {
+ xAvg += xData[ii];
+ yAvg += yData[ii];
+ xxAgv += xData[ii] * xData[ii];
+ xyAvg += xData[ii] * yData[ii];
+ }
+
+ xAvg /= xData.Count;
+ yAvg /= xData.Count;
+ xxAgv /= xData.Count;
+ xyAvg /= xData.Count;
+
+ regSlope = (xyAvg - (xAvg * yAvg)) / (xxAgv - (xAvg * xAvg));
+ regConst = yAvg - (regSlope * xAvg);
+
+ var errors = new List();
+
+ double eAvg = 0;
+
+ for (int ii = 0; ii < xData.Count; ii++)
+ {
+ double error = yData[ii] - regConst - (regSlope * xData[ii]);
+ errors.Add(error);
+ eAvg += error;
+ }
+
+ eAvg /= errors.Count;
+
+ double variance = 0;
+
+ for (int ii = 0; ii < errors.Count; ii++)
+ {
+ double error = errors[ii] - eAvg;
+ variance += error * error;
+ }
+
+ variance /= errors.Count;
+ regStdDev = Math.Sqrt(variance);
+ }
+
+ // select the result.
+ double result = 0;
+
+ switch (valueType)
+ {
+ case 1:
+ result = regSlope;
+ break;
+ case 2:
+ result = regConst;
+ break;
+ case 3:
+ result = regStdDev;
+ break;
+ default:
+ Debug.Fail($"Unexpected value type {valueType}");
+ break;
+ }
+
+ // set the timestamp and status.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(result),
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+
+ if (nonGoodDataExists)
+ {
+ value.StatusCode = StatusCodes.UncertainDataSubNormal;
+ }
+
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+
+ // return result.
+ return value;
+ }
+
+ ///
+ /// Calculates sample or population standard deviation/variance for the timeslice.
+ ///
+ protected DataValue ComputeStdDev(TimeSlice slice, bool includeBounds, int valueType)
+ {
+ // Part 13 defines these over good raw values in the interval, without bounds.
+ List? values = GetValues(slice);
+
+ // check for empty slice.
+ if (values == null || values.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ var xData = new List();
+ double sum = 0;
+ bool nonGoodDataExists = false;
+
+ for (int ii = 0; ii < values.Count; ii++)
+ {
+ if (IsGood(values[ii]))
+ {
+ try
+ {
+ double sample = CastToDouble(values[ii]);
+ xData.Add(sample);
+ sum += sample;
+ }
+ catch (Exception)
+ {
+ nonGoodDataExists = true;
+ }
+ }
+ else
+ {
+ nonGoodDataExists = true;
+ }
+ }
+
+ // check if no good data.
+ if (xData.Count == 0)
+ {
+ return GetNoDataValue(slice);
+ }
+
+ double average = sum / xData.Count;
+
+ // calculate variance.
+ double variance = 0;
+
+ for (int ii = 0; ii < xData.Count; ii++)
+ {
+ double error = xData[ii] - average;
+ variance += error * error;
+ }
+
+ if (includeBounds)
+ {
+ // Spec part 13 v105 section 5.4.3.37 and subsequent
+ if (xData.Count <= 1)
+ {
+ variance = 0;
+ }
+ else
+ {
+ variance /= xData.Count - 1;
+ }
+ }
+ // use the population variance if bounds are not included.
+ else
+ {
+ variance /= xData.Count;
+ }
+
+ // select the result.
+ double result = 0;
+
+ switch (valueType)
+ {
+ case 1:
+ result = Math.Sqrt(variance);
+ break;
+ case 2:
+ result = variance;
+ break;
+ default:
+ Debug.Fail($"Unexpected value type {valueType}");
+ break;
+ }
+
+ // set the timestamp and status.
+ var value = new DataValue
+ {
+ WrappedValue = SdkValueBoundary.FromClr(result),
+ SourceTimestamp = GetTimestamp(slice),
+ ServerTimestamp = GetTimestamp(slice)
+ };
+
+ if (nonGoodDataExists)
+ {
+ value.StatusCode = StatusCodes.UncertainDataSubNormal;
+ }
+
+ value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
+
+ // return result.
+ return value;
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/baseline-sources.json b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/baseline-sources.json
new file mode 100644
index 00000000..b36ebf76
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Compatibility/Aggregates/baseline-sources.json
@@ -0,0 +1,42 @@
+[
+ {
+ "file": "AggregateCalculator.cs",
+ "source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/AggregateCalculator.cs",
+ "sha256": "913a98e96fe1daeb592d754f42729fe73e6ea696f8094b56089bad1ad4853905"
+ },
+ {
+ "file": "AverageAggregateCalculator.cs",
+ "source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/AverageAggregateCalculator.cs",
+ "sha256": "6292ca7d277de2588046a789d1cd74aea3cb8c4c8ca698ff2d6a4e32e2a58ab0"
+ },
+ {
+ "file": "MinMaxAggregateCalculator.cs",
+ "source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/MinMaxAggregateCalculator.cs",
+ "sha256": "3a9a07aa0e8eb37a905bb670e766531332abad393e3ad57b3985beee6b44549c"
+ },
+ {
+ "file": "CountAggregateCalculator.cs",
+ "source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/CountAggregateCalculator.cs",
+ "sha256": "1e98095aaa4fa30918df6e3d63abb261852587cdf2c079d842d8bf2acecb48b8"
+ },
+ {
+ "file": "StartEndAggregateCalculator.cs",
+ "source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/StartEndAggregateCalculator.cs",
+ "sha256": "db4d8a69b4d8d5f599f60ee85861252e55cc1779f6ba29a3146ddf4c2958543a"
+ },
+ {
+ "file": "StatusAggregateCalculator.cs",
+ "source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/StatusAggregateCalculator.cs",
+ "sha256": "82fa78d6cd3331103ce7f2c03f0545078e355062650ebf1ec07a96446aa5854f"
+ },
+ {
+ "file": "StdDevAggregateCalculator.cs",
+ "source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/StdDevAggregateCalculator.cs",
+ "sha256": "c202d66c2cad946554ffac3b26ecd22e59f458894637a8cd98bbc541519996a2"
+ },
+ {
+ "file": "Aggregators.cs",
+ "source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/Aggregators.cs",
+ "sha256": "1d47a0a3d8a6a4985cbafc54425fed26ab2a20e22ec964eb83553e6145b71fa0"
+ }
+]