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" + } +]