fix: preserve baseline OPC UA aggregate algorithms under SDK 2.0

This commit is contained in:
Joseph Doherty
2026-11-10 10:24:19 -05:00
parent 96fd67695f
commit a5bed28765
16 changed files with 4201 additions and 0 deletions
@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType></PropertyGroup>
<ItemGroup><PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.182" /></ItemGroup>
</Project>
@@ -0,0 +1,4 @@
<Project>
<!-- Deliberately isolated baseline process: never inherit or load the candidate SDK. -->
<PropertyGroup><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><TreatWarningsAsErrors>true</TreatWarningsAsErrors><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup>
</Project>
@@ -0,0 +1 @@
<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>
@@ -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<string, Sample[]>
{
["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<Vector>();
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<Value>();
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);
@@ -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
{
/// <summary>
/// Creates a new instance of an aggregate factory.
/// </summary>
internal delegate AggregateCalculator AggregatorFactory(
NodeId aggregateId,
DateTime startTime,
DateTime endTime,
double processingInterval,
bool stepped,
AggregateConfiguration configuration,
ITelemetryContext? telemetry);
/// <summary>
/// The set of built-in aggregate factories.
/// </summary>
internal static class Aggregators
{
/// <summary>
/// Stores the mapping for a aggregate id to the calculator.
/// </summary>
private class FactoryMapping
{
public NodeId AggregateId { get; set; }
public QualifiedName AggregateName { get; set; }
public required Type Calculator { get; set; }
}
/// <summary>
/// Mapping for all of the standard aggregates.
/// </summary>
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<NodeId> SupportedIds => s_mappings.Select(x => x.AggregateId);
/// <summary>
/// Returns the name for a standard aggregates.
/// </summary>
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;
}
/// <summary>
/// Returns the id for a standard aggregates.
/// </summary>
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;
}
/// <summary>
/// Creates a calculator for one of the standard aggregates.
/// </summary>
[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);
}
/// <summary>
/// Creates a calculator for one of the standard aggregates.
/// </summary>
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;
}
}
}
@@ -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
{
/// <summary>
/// Calculates the value of an aggregate.
/// </summary>
internal class AverageAggregateCalculator : AggregateCalculator
{
/// <summary>
/// Initializes the aggregate calculator.
/// </summary>
/// <param name="aggregateId">The aggregate function to apply.</param>
/// <param name="startTime">The start time.</param>
/// <param name="endTime">The end time.</param>
/// <param name="processingInterval">The processing interval.</param>
/// <param name="stepped">Whether to use stepped interpolation.</param>
/// <param name="configuration">The aggregate configuration.</param>
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
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;
}
/// <summary>
/// Computes the value for the timeslice.
/// </summary>
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);
}
}
/// <summary>
/// Calculates the RegSlope, RegConst and RegStdDev aggregates for the timeslice.
/// </summary>
protected DataValue ComputeAverage(TimeSlice slice)
{
// get the values in the slice.
List<DataValue>? 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;
}
/// <summary>
/// Calculates the StdDev, Variance, StdDev2 and Variance2 aggregates for the timeslice.
/// </summary>
protected DataValue ComputeTimeAverage(TimeSlice slice, bool useSimpleBounds, int valueType)
{
// get the values in the slice.
List<DataValue>? 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<SubRegion> 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;
}
}
}
@@ -0,0 +1,33 @@
using Opc.Ua;
using Opc.Ua.Server;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
/// <summary>Adapts the versioned baseline algorithms to the SDK 2 calculator protocol without a second SDK runtime.</summary>
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."));
/// <summary>Replaces exactly the existing standard factories; no aggregate IDs are added or removed.</summary>
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;
}
}
}
@@ -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
{
/// <summary>
/// Calculates the value of an aggregate.
/// </summary>
internal class CountAggregateCalculator : AggregateCalculator
{
/// <summary>
/// Initializes the aggregate calculator.
/// </summary>
/// <param name="aggregateId">The aggregate function to apply.</param>
/// <param name="startTime">The start time.</param>
/// <param name="endTime">The end time.</param>
/// <param name="processingInterval">The processing interval.</param>
/// <param name="stepped">Whether to use stepped interpolation.</param>
/// <param name="configuration">The aggregate configuration.</param>
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
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;
}
/// <summary>
/// Computes the value for the timeslice.
/// </summary>
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);
}
}
/// <summary>
/// Calculates the Count aggregate for the timeslice.
/// </summary>
protected DataValue ComputeCount(TimeSlice slice)
{
// get the values in the slice.
List<DataValue>? 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;
}
/// <summary>
/// Calculates the AnnotationCount aggregate for the timeslice.
/// </summary>
protected DataValue ComputeAnnotationCount(TimeSlice slice)
{
// get the values in the slice.
List<DataValue>? 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;
}
/// <summary>
/// Calculates the DurationInStateZero and DurationInStateNonZero aggregates for the timeslice.
/// </summary>
protected DataValue ComputeDurationInState(TimeSlice slice, bool isNonZero)
{
// get the values in the slice.
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
// check for empty slice.
if (values == null)
{
return GetNoDataValue(slice);
}
// get the regions.
List<SubRegion> 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;
}
/// <summary>
/// Calculates the Count aggregate for the timeslice.
/// </summary>
protected DataValue ComputeNumberOfTransitions(TimeSlice slice)
{
// get the values in the slice.
List<DataValue>? 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<DataValue>? 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;
}
}
}
@@ -0,0 +1,36 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
/// <summary>Private mutable work value preserving the baseline calculator's ownership and timestamp semantics.</summary>
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);
}
@@ -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
{
/// <summary>
/// Calculates the value of an aggregate.
/// </summary>
internal class MinMaxAggregateCalculator : AggregateCalculator
{
/// <summary>
/// Initializes the aggregate calculator.
/// </summary>
/// <param name="aggregateId">The aggregate function to apply.</param>
/// <param name="startTime">The start time.</param>
/// <param name="endTime">The end time.</param>
/// <param name="processingInterval">The processing interval.</param>
/// <param name="stepped">Whether to use stepped interpolation.</param>
/// <param name="configuration">The aggregate configuration.</param>
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
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;
}
/// <summary>
/// Computes the value for the timeslice.
/// </summary>
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);
}
}
/// <summary>
/// Calculate the Minimum, Maximum, MinimumActualTime and MaximumActualTime aggregates for the timeslice.
/// </summary>
protected DataValue ComputeMinMax(TimeSlice slice, int valueType, bool returnActualTime)
{
// get the values in the slice.
List<DataValue>? 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;
}
/// <summary>
/// Calculate the Minimum2, Maximum2, MinimumActualTime2, MaximumActualTime2 and Range2 aggregates for the timeslice.
/// </summary>
protected DataValue ComputeMinMax2(TimeSlice slice, int valueType, bool returnActualTime)
{
// get the values in the slice.
List<DataValue>? 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;
}
}
}
@@ -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.
@@ -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
{
/// <summary>
/// Calculates the value of an aggregate.
/// </summary>
internal class StartEndAggregateCalculator : AggregateCalculator
{
/// <summary>
/// Initializes the aggregate calculator.
/// </summary>
/// <param name="aggregateId">The aggregate function to apply.</param>
/// <param name="startTime">The start time.</param>
/// <param name="endTime">The end time.</param>
/// <param name="processingInterval">The processing interval.</param>
/// <param name="stepped">Whether to use stepped interpolation.</param>
/// <param name="configuration">The aggregate configuration.</param>
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
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;
}
/// <summary>
/// Computes the value for the timeslice.
/// </summary>
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);
}
}
/// <summary>
/// Calculate the Start and End aggregates for the timeslice.
/// </summary>
protected DataValue ComputeStartEnd(TimeSlice slice, bool returnEnd)
{
// get the values in the slice.
List<DataValue>? 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];
}
/// <summary>
/// Calculates the Delta aggregate for the timeslice.
/// </summary>
protected DataValue ComputeDelta(TimeSlice slice)
{
// get the values in the slice.
List<DataValue>? 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;
}
/// <summary>
/// Calculate the Start2 and End2 aggregates for the timeslice.
/// </summary>
protected DataValue ComputeStartEnd2(TimeSlice slice, bool returnEnd)
{
// get the values in the slice.
List<DataValue>? 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;
}
/// <summary>
/// Calculates the Delta2 aggregate for the timeslice.
/// </summary>
protected DataValue ComputeDelta2(TimeSlice slice)
{
// get the values in the slice.
List<DataValue>? 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;
}
}
}
@@ -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
{
/// <summary>
/// Calculates the value of an aggregate.
/// </summary>
internal class StatusAggregateCalculator : AggregateCalculator
{
/// <summary>
/// Initializes the aggregate calculator.
/// </summary>
/// <param name="aggregateId">The aggregate function to apply.</param>
/// <param name="startTime">The start time.</param>
/// <param name="endTime">The end time.</param>
/// <param name="processingInterval">The processing interval.</param>
/// <param name="stepped">Whether to use stepped interpolation.</param>
/// <param name="configuration">The aggregate configuration.</param>
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
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;
}
/// <summary>
/// Computes the value for the timeslice.
/// </summary>
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);
}
}
/// <summary>
/// Calculates the DurationGood and DurationBad aggregates for the timeslice.
/// </summary>
protected DataValue ComputeDurationGoodBad(TimeSlice slice, bool isBad, bool usePercent)
{
// get the values in the slice.
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
// check for empty slice.
if (values == null || values.Count == 0)
{
return GetNoDataValue(slice);
}
// get the regions.
List<SubRegion> 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;
}
/// <summary>
/// Calculates the DurationGood and DurationBad aggregates for the timeslice.
/// </summary>
protected DataValue ComputeWorstQuality(TimeSlice slice, bool includeBounds)
{
// get the values in the slice.
List<DataValue>? 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;
}
}
}
@@ -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
{
/// <summary>
/// Calculates the value of an aggregate.
/// </summary>
internal class StdDevAggregateCalculator : AggregateCalculator
{
/// <summary>
/// Initializes the aggregate calculator.
/// </summary>
/// <param name="aggregateId">The aggregate function to apply.</param>
/// <param name="startTime">The start time.</param>
/// <param name="endTime">The end time.</param>
/// <param name="processingInterval">The processing interval.</param>
/// <param name="stepped">Whether to use stepped interpolation.</param>
/// <param name="configuration">The aggregate configuration.</param>
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
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;
}
/// <summary>
/// Computes the value for the timeslice.
/// </summary>
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);
}
}
/// <summary>
/// Calculates the RegSlope, RegConst and RegStdDev aggregates for the timeslice.
/// </summary>
protected DataValue ComputeRegression(TimeSlice slice, int valueType)
{
// get the values in the slice.
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
// check for empty slice.
if (values == null || values.Count == 0)
{
return GetNoDataValue(slice);
}
// get the regions.
List<SubRegion> regions = GetRegionsInValueSet(values, false, true);
var xData = new List<double>();
var yData = new List<double>();
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>();
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;
}
/// <summary>
/// Calculates sample or population standard deviation/variance for the timeslice.
/// </summary>
protected DataValue ComputeStdDev(TimeSlice slice, bool includeBounds, int valueType)
{
// Part 13 defines these over good raw values in the interval, without bounds.
List<DataValue>? values = GetValues(slice);
// check for empty slice.
if (values == null || values.Count == 0)
{
return GetNoDataValue(slice);
}
var xData = new List<double>();
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;
}
}
}
@@ -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"
}
]