diff --git a/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml b/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml
index a6f2503138..13ec4e2b44 100644
--- a/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml
+++ b/Applications/ConsoleReferenceServer/Quickstarts.ReferenceServer.Config.xml
@@ -272,10 +272,14 @@
http://opcfoundation.org/UA-Profile/Server/Methods
http://opcfoundation.org/UA-Profile/Server/ReverseConnect
http://opcfoundation.org/UA-Profile/Server/ClientRedundancy
+ http://opcfoundation.org/UA-Profile/Server/HistoricalRawData2022
+ http://opcfoundation.org/UA-Profile/Server/AggregateHistorical2022
5
DA
+ HD
+ AC
PFX
diff --git a/Applications/Quickstarts.Servers/Properties/AssemblyInfo.cs b/Applications/Quickstarts.Servers/Properties/AssemblyInfo.cs
index 2b9848014c..1b4d7133ee 100644
--- a/Applications/Quickstarts.Servers/Properties/AssemblyInfo.cs
+++ b/Applications/Quickstarts.Servers/Properties/AssemblyInfo.cs
@@ -28,5 +28,7 @@
* ======================================================================*/
using System;
+using System.Runtime.CompilerServices;
[assembly: CLSCompliant(false)]
+[assembly: InternalsVisibleTo("Opc.Ua.Server.Tests")]
diff --git a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs
index b1664a4147..7a0973c52e 100644
--- a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs
+++ b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs
@@ -38,6 +38,11 @@ using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
using Opc.Ua.Test;
+using HistoryArchive = TestData.HistoryArchive;
+using HistoryDataReader = TestData.HistoryDataReader;
+using HistoryFile = TestData.HistoryFile;
+using ProcessedHistoryAdapter = TestData.ProcessedHistoryAdapter;
+using ProcessedHistoryContinuationState = TestData.ProcessedHistoryContinuationState;
using Range = Opc.Ua.Range;
namespace Quickstarts.ReferenceServer
@@ -45,7 +50,7 @@ namespace Quickstarts.ReferenceServer
///
/// A node manager for a server that exposes several variables.
///
- public class ReferenceNodeManager : CustomNodeManager2
+ public class ReferenceNodeManager : CustomNodeManager2, IConformanceContributor
{
///
/// Initializes the node manager.
@@ -62,10 +67,17 @@ namespace Quickstarts.ReferenceServer
Namespaces.ReferenceServer)
{
SystemContext.NodeIdFactory = this;
+ m_historyArchive = new HistoryArchive(server.Telemetry);
// use suitable defaults if no configuration exists.
}
+ ///
+ public IReadOnlyList ConformanceUnits => s_conformanceUnits;
+
+ ///
+ public IReadOnlyList ServerProfiles => s_serverProfiles;
+
///
/// An overrideable version of the Dispose.
///
@@ -77,6 +89,7 @@ namespace Quickstarts.ReferenceServer
Utils.SilentDispose(m_simulationTimer);
m_simulationTimer = null;
+ Utils.SilentDispose(m_historyArchive);
}
base.Dispose(disposing);
}
@@ -266,13 +279,45 @@ namespace Quickstarts.ReferenceServer
"Int16",
DataTypeIds.Int16,
ValueRanks.Scalar));
- variables.Add(
- CreateVariable(
- staticFolder,
- scalarStatic + "Int32",
- "Int32",
- DataTypeIds.Int32,
- ValueRanks.Scalar));
+ BaseDataVariableState int32Static = CreateVariable(
+ staticFolder,
+ scalarStatic + "Int32",
+ "Int32",
+ DataTypeIds.Int32,
+ ValueRanks.Scalar);
+ const uint cttTestNodePermissions =
+ (uint)PermissionType.Browse |
+ (uint)PermissionType.Read |
+ (uint)PermissionType.Write |
+ (uint)PermissionType.ReadHistory |
+ (uint)PermissionType.ReadRolePermissions;
+ var anonymousPermissions = new RolePermissionType
+ {
+ RoleId = ObjectIds.WellKnownRole_Anonymous,
+ Permissions = cttTestNodePermissions
+ };
+ var authenticatedPermissions = new RolePermissionType
+ {
+ RoleId = ObjectIds.WellKnownRole_AuthenticatedUser,
+ Permissions = cttTestNodePermissions
+ };
+ var securityAdminPermissions = new RolePermissionType
+ {
+ RoleId = ObjectIds.WellKnownRole_SecurityAdmin,
+ Permissions = 0xFFFF
+ };
+ int32Static.RolePermissions =
+ [
+ anonymousPermissions,
+ authenticatedPermissions,
+ securityAdminPermissions
+ ];
+ int32Static.UserRolePermissions =
+ [
+ anonymousPermissions,
+ authenticatedPermissions
+ ];
+ variables.Add(int32Static);
variables.Add(
CreateVariable(
staticFolder,
@@ -3713,12 +3758,47 @@ namespace Quickstarts.ReferenceServer
myCompanyInstructions.Value
= "A place for the vendor to describe their address-space.";
variables.Add(myCompanyInstructions);
+
+ // Deterministic paired nodes used by the CTT aggregate tests.
+ FolderState aggregatesFolder = CreateFolder(root, "Aggregates", "Aggregates");
+ const string aggregates = "Aggregates_";
+ variables.Add(CreateVariable(
+ aggregatesFolder,
+ aggregates + "Boolean",
+ "Boolean",
+ DataTypeIds.Boolean,
+ ValueRanks.Scalar));
+ variables.Add(CreateVariable(
+ aggregatesFolder,
+ aggregates + "Int32",
+ "Int32",
+ DataTypeIds.Int32,
+ ValueRanks.Scalar));
+ variables.Add(CreateVariable(
+ aggregatesFolder,
+ aggregates + "Float",
+ "Float",
+ DataTypeIds.Float,
+ ValueRanks.Scalar));
+ variables.Add(CreateVariable(
+ aggregatesFolder,
+ aggregates + "Double",
+ "Double",
+ DataTypeIds.Double,
+ ValueRanks.Scalar));
+ variables.Add(CreateVariable(
+ aggregatesFolder,
+ aggregates + "String",
+ "String",
+ DataTypeIds.String,
+ ValueRanks.Scalar));
}
catch (Exception e)
{
m_logger.LogError(e, "Error creating the ReferenceNodeManager address space.");
}
+ EnableHistoryArchiving(root);
AddPredefinedNode(SystemContext, root);
if (m_simulationEnabled)
@@ -3732,6 +3812,100 @@ namespace Quickstarts.ReferenceServer
}
}
+ ///
+ /// Enables legacy-history storage for deterministic CTT scalar nodes.
+ /// The archive remains the existing HistoryRecord/HistoryEntry layout.
+ ///
+ private void EnableHistoryArchiving(FolderState root)
+ {
+ var nodes = new List();
+ root.GetChildren(SystemContext, nodes);
+ EnableHistoryArchiving(nodes);
+ }
+
+ private void EnableHistoryArchiving(List nodes)
+ {
+ for (int ii = 0; ii < nodes.Count; ii++)
+ {
+ BaseInstanceState node = nodes[ii];
+ if (node is BaseVariableState variable &&
+ IsHistoricalNode(variable.NodeId))
+ {
+ variable.Historizing = true;
+ variable.AccessLevel = (byte)(
+ variable.AccessLevel | AccessLevels.HistoryRead);
+ variable.UserAccessLevel = (byte)(
+ variable.UserAccessLevel | AccessLevels.HistoryRead);
+
+ BuiltInType dataType = TypeInfo.GetBuiltInType(variable.DataType);
+ m_historyArchive.CreateRecord(variable.NodeId, dataType, true);
+ InstallHistoricalConfiguration(variable);
+ }
+
+ var children = new List();
+ node.GetChildren(SystemContext, children);
+ EnableHistoryArchiving(children);
+ }
+ }
+
+ private bool IsHistoricalNode(NodeId nodeId)
+ {
+ if (nodeId?.Identifier is not string name)
+ {
+ return false;
+ }
+
+ for (int ii = 0; ii < s_historicalNodeNames.Length; ii++)
+ {
+ if (name == s_historicalNodeNames[ii])
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void InstallHistoricalConfiguration(BaseVariableState variable)
+ {
+ string identifier = (string)variable.NodeId.Identifier;
+ var configuration = new HistoricalDataConfigurationState(variable)
+ {
+ ReferenceTypeId = ReferenceTypes.HasHistoricalConfiguration
+ };
+ configuration.Create(
+ SystemContext,
+ new NodeId(identifier + "_HAConfiguration", NamespaceIndex),
+ new QualifiedName("HA Configuration", 0),
+ new LocalizedText("en", "HA Configuration"),
+ true);
+
+ configuration.Stepped.Value = false;
+ configuration.Definition.Value = string.Empty;
+ configuration.MaxTimeInterval.Value = 0;
+ configuration.MinTimeInterval.Value = 0;
+ configuration.ExceptionDeviation.Value = 0;
+ configuration.ExceptionDeviationFormat.Value = 0;
+ DateTime start = DateTime.UtcNow.AddSeconds(-10000);
+ configuration.StartOfArchive.Value = start;
+ configuration.StartOfOnlineArchive.Value = start;
+ configuration.ServerTimestampSupported.Value = true;
+ configuration.AggregateConfiguration.TreatUncertainAsBad.Value = true;
+ configuration.AggregateConfiguration.PercentDataBad.Value = 100;
+ configuration.AggregateConfiguration.PercentDataGood.Value = 100;
+ configuration.AggregateConfiguration.UseSlopedExtrapolation.Value = false;
+
+ variable.AddChild(configuration);
+ variable.AddReference(
+ ReferenceTypes.HasHistoricalConfiguration,
+ false,
+ configuration.NodeId);
+ configuration.AddReference(
+ ReferenceTypes.HasHistoricalConfiguration,
+ true,
+ variable.NodeId);
+ }
+
private ServiceResult OnWriteInterval(
ISystemContext context,
NodeState node,
@@ -4427,9 +4601,10 @@ namespace Quickstarts.ReferenceServer
{
variable.ArrayDimensions = new ReadOnlyList([0]);
}
- else if (valueRank == ValueRanks.TwoDimensions)
+ else if (valueRank >= ValueRanks.TwoDimensions)
{
- variable.ArrayDimensions = new ReadOnlyList([0, 0]);
+ variable.ArrayDimensions = new ReadOnlyList(
+ CreateFixedArrayDimensions(valueRank));
}
parent?.AddChild(variable);
@@ -4813,13 +4988,17 @@ namespace Quickstarts.ReferenceServer
{
Debug.Assert(m_generator != null, "Need a random generator!");
+ IList dimensions = variable.ValueRank >= ValueRanks.TwoDimensions
+ ? CreateFixedArrayDimensions(variable.ValueRank)
+ : [DefaultArrayLength];
+
object value = null;
for (int retryCount = 0; value == null && retryCount < 10; retryCount++)
{
value = m_generator.GetRandom(
variable.DataType,
variable.ValueRank,
- [10],
+ dimensions,
Server.TypeTree);
// skip Variant Null
if (value is Variant variant && variant.Value == null)
@@ -4831,6 +5010,16 @@ namespace Quickstarts.ReferenceServer
return value;
}
+ private static uint[] CreateFixedArrayDimensions(int valueRank)
+ {
+ var dimensions = new uint[valueRank];
+ for (int ii = 0; ii < valueRank; ii++)
+ {
+ dimensions[ii] = MultiDimensionalArrayLength;
+ }
+ return dimensions;
+ }
+
private void DoSimulation(object state)
{
if (!m_simulationEnabled)
@@ -4870,6 +5059,187 @@ namespace Quickstarts.ReferenceServer
}
}
+ ///
+ protected override void HistoryReadRawModified(
+ ServerSystemContext context,
+ ReadRawModifiedDetails details,
+ TimestampsToReturn timestampsToReturn,
+ IList nodesToRead,
+ IList results,
+ IList errors,
+ List nodesToProcess,
+ IDictionary cache)
+ {
+ for (int ii = 0; ii < nodesToProcess.Count; ii++)
+ {
+ NodeHandle handle = nodesToProcess[ii];
+ NodeState source = ValidateNode(context, handle, cache);
+ if (source is not BaseVariableState variable)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ continue;
+ }
+
+ errors[handle.Index] = ReadRaw(
+ context,
+ variable,
+ details,
+ timestampsToReturn,
+ nodesToRead[handle.Index],
+ results[handle.Index]);
+ }
+ }
+
+ private ServiceResult ReadRaw(
+ ServerSystemContext context,
+ BaseVariableState variable,
+ ReadRawModifiedDetails details,
+ TimestampsToReturn timestampsToReturn,
+ HistoryReadValueId nodeToRead,
+ HistoryReadResult result)
+ {
+ if (variable.ValueRank == ValueRanks.Scalar &&
+ nodeToRead.ParsedIndexRange != NumericRange.Empty)
+ {
+ result.StatusCode = StatusCodes.BadIndexRangeNoData;
+ result.ContinuationPoint = null;
+ return ServiceResult.Good;
+ }
+
+ var data = new HistoryData();
+ HistoryDataReader reader;
+
+ if (nodeToRead.ContinuationPoint != null &&
+ nodeToRead.ContinuationPoint.Length > 0)
+ {
+ reader = context.OperationContext?.Session?
+ .RestoreHistoryContinuationPoint(nodeToRead.ContinuationPoint)
+ as HistoryDataReader;
+ if (reader == null || reader.VariableId != nodeToRead.NodeId)
+ {
+ Utils.SilentDispose(reader);
+ return StatusCodes.BadContinuationPointInvalid;
+ }
+ }
+ else
+ {
+ HistoryFile source = m_historyArchive.GetHistoryFile(variable.NodeId);
+ if (source == null)
+ {
+ return StatusCodes.BadNotReadable;
+ }
+
+ reader = new HistoryDataReader(variable.NodeId, source);
+ reader.BeginReadRaw(
+ context,
+ details,
+ timestampsToReturn,
+ nodeToRead.ParsedIndexRange,
+ nodeToRead.DataEncoding,
+ data.DataValues);
+ }
+
+ bool complete = reader.NextReadRaw(
+ context,
+ timestampsToReturn,
+ nodeToRead.ParsedIndexRange,
+ nodeToRead.DataEncoding,
+ data.DataValues);
+ if (!complete)
+ {
+ context.OperationContext?.Session?
+ .SaveHistoryContinuationPoint(reader.Id, reader);
+ result.ContinuationPoint = reader.Id.ToByteArray();
+ }
+ else
+ {
+ Utils.SilentDispose(reader);
+ result.ContinuationPoint = null;
+ }
+
+ result.StatusCode = complete && data.DataValues.Count == 0 && !details.ReturnBounds
+ ? StatusCodes.GoodNoData
+ : StatusCodes.Good;
+ result.HistoryData = new ExtensionObject(data);
+ return ServiceResult.Good;
+ }
+
+ ///
+ protected override void HistoryReadProcessed(
+ ServerSystemContext context,
+ ReadProcessedDetails details,
+ TimestampsToReturn timestampsToReturn,
+ IList nodesToRead,
+ IList results,
+ IList errors,
+ List nodesToProcess,
+ IDictionary cache)
+ {
+ for (int ii = 0; ii < nodesToProcess.Count; ii++)
+ {
+ NodeHandle handle = nodesToProcess[ii];
+ NodeState source = ValidateNode(context, handle, cache);
+ if (source is not BaseVariableState variable)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ continue;
+ }
+
+ HistoryFile history = m_historyArchive.GetHistoryFile(variable.NodeId);
+ if (history == null)
+ {
+ errors[handle.Index] = StatusCodes.BadNotReadable;
+ continue;
+ }
+
+ errors[handle.Index] = ProcessedHistoryAdapter.Read(
+ context,
+ Server,
+ variable.NodeId,
+ history,
+ details,
+ details.AggregateType[handle.Index],
+ m_historyArchive.GetAnnotationTimestamps(variable.NodeId),
+ timestampsToReturn,
+ nodesToRead[handle.Index],
+ results[handle.Index]);
+ }
+ }
+
+ ///
+ protected override void HistoryReleaseContinuationPoints(
+ ServerSystemContext context,
+ IList nodesToRead,
+ IList errors,
+ List nodesToProcess,
+ IDictionary cache)
+ {
+ for (int ii = 0; ii < nodesToProcess.Count; ii++)
+ {
+ NodeHandle handle = nodesToProcess[ii];
+ NodeState source = ValidateNode(context, handle, cache);
+ if (source is not BaseVariableState)
+ {
+ errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
+ continue;
+ }
+
+ HistoryReadValueId nodeToRead = nodesToRead[handle.Index];
+ object continuation = context.OperationContext?.Session?
+ .RestoreHistoryContinuationPoint(nodeToRead.ContinuationPoint);
+
+ bool valid = continuation is HistoryDataReader raw &&
+ raw.VariableId == nodeToRead.NodeId;
+ valid |= continuation is ProcessedHistoryContinuationState processed &&
+ processed.VariableId == nodeToRead.NodeId;
+
+ Utils.SilentDispose(continuation);
+ errors[handle.Index] = valid
+ ? ServiceResult.Good
+ : StatusCodes.BadContinuationPointInvalid;
+ }
+ }
+
///
/// Frees any resources allocated for the address space.
///
@@ -4940,6 +5310,109 @@ namespace Quickstarts.ReferenceServer
private bool m_simulationEnabled = true;
private int m_simulationsRunning;
private readonly List m_dynamicNodes = [];
+ private readonly HistoryArchive m_historyArchive;
+
+ private const uint DefaultArrayLength = 10;
+ private const uint MultiDimensionalArrayLength = 3;
+
+ private static readonly QualifiedName[] s_conformanceUnits =
+ [
+ new("Address Space Atomicity"),
+ new("Address Space Base"),
+ new("Address Space Full Array Only"),
+ new("Address Space Method"),
+ new("Attribute Read"),
+ new("Base Info Base Types"),
+ new("Base Info Core Structure 2"),
+ new("Base Info Core Types Folders"),
+ new("Base Info Date DataTypes"),
+ new("Base Info Decimal DataType"),
+ new("Base Info GetMonitoredItems Method"),
+ new("Base Info Method Argument DataType"),
+ new("Base Info Method Capabilities"),
+ new("Base Info ResendData Method"),
+ new("Base Info SemanticChange Bit"),
+ new("Base Info Server Capabilities 2"),
+ new("Base Info Server Capabilities MaxMonitoredItemsQueueSize"),
+ new("Base Info Server Capabilities Subscriptions"),
+ new("Base Info ServerType"),
+ new("Base Info Type Information"),
+ new("Data Access DataItems"),
+ new("Discovery Find Servers Self"),
+ new("Discovery Get Endpoints"),
+ new("Discovery Register"),
+ new("Discovery Register2"),
+ new("Documentation - Core Capacities"),
+ new("Method Call"),
+ new("Monitor Basic"),
+ new("Monitor Items 2"),
+ new("Monitor Queueing"),
+ new("Monitor Triggering"),
+ new("Monitor Value Change V2"),
+ new("Monitored Items Deadband Filter"),
+ new("Protocol Reverse Connect Server"),
+ new("Protocol UA TCP"),
+ new("Push Model for Global Certificate and TrustList Management"),
+ new("Security Default ApplicationInstance Certificate"),
+ new("Security ECC Policy"),
+ new("Security Invalid user token"),
+ new("Security Policy Required"),
+ new("Security User Name Password 2"),
+ new("Security User X509"),
+ new("SecurityPolicy Support"),
+ new("Session Base"),
+ new("Session Cancel"),
+ new("Session General Service Behaviour"),
+ new("Session Multiple"),
+ new("Subscription Basic"),
+ new("Subscription Multiple"),
+ new("Subscription Publish Basic"),
+ new("Subscription PublishRequest Queue Overflow"),
+ new("Subscription Retransmission Queue"),
+ new("Subscription Transfer"),
+ new("Time Sync - Support"),
+ new("UA Binary Encoding"),
+ new("UA Secure Conversation"),
+ new("View Basic 2"),
+ new("View RegisterNodes"),
+ new("View TranslateBrowsePath"),
+ new("Aggregate Master Configuration"),
+ new("Attribute Historical Read"),
+ new("Base Info History Read Capabilities"),
+ new("Base Info History ReadData Capabilities"),
+ new("Historical Access Aggregates"),
+ new("Historical Access Read Raw")
+ ];
+
+ private static readonly string[] s_serverProfiles =
+ [
+ "http://opcfoundation.org/UA-Profile/Server/HistoricalRawData2022",
+ "http://opcfoundation.org/UA-Profile/Server/AggregateHistorical2022"
+ ];
+
+ private static readonly string[] s_historicalNodeNames =
+ [
+ "Scalar_Static_Boolean",
+ "Scalar_Static_SByte",
+ "Scalar_Static_Byte",
+ "Scalar_Static_Int16",
+ "Scalar_Static_UInt16",
+ "Scalar_Static_Int32",
+ "Scalar_Static_UInt32",
+ "Scalar_Static_Int64",
+ "Scalar_Static_UInt64",
+ "Scalar_Static_Float",
+ "Scalar_Static_Double",
+ "Scalar_Static_String",
+ "Scalar_Static_DateTime",
+ "Scalar_Static_Guid",
+ "Scalar_Static_ByteString",
+ "Aggregates_Boolean",
+ "Aggregates_Int32",
+ "Aggregates_Float",
+ "Aggregates_Double",
+ "Aggregates_String"
+ ];
private static readonly bool[] s_booleanArray
= [true, false, true, false, true, false, true, false, true];
diff --git a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs
index f764e476c8..33c5a067b8 100644
--- a/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs
+++ b/Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs
@@ -118,6 +118,38 @@ namespace Quickstarts.ReferenceServer
return new MasterNodeManager(server, configuration, null, asyncNodeManagers, nodeManagers);
}
+ ///
+ protected override void OnNodeManagerStarted(IServerInternal server)
+ {
+ base.OnNodeManagerStarted(server);
+
+ bool historicalAccessEnabled = server.NodeManager.NodeManagers
+ .Any(nodeManager => nodeManager is ReferenceNodeManager);
+ if (!historicalAccessEnabled)
+ {
+ return;
+ }
+
+ HistoryServerCapabilitiesState capabilities = server.DiagnosticsNodeManager
+ .GetDefaultHistoryCapabilities();
+
+ capabilities.AccessHistoryDataCapability.Value = true;
+ capabilities.AccessHistoryEventsCapability.Value = false;
+ capabilities.InsertDataCapability.Value = false;
+ capabilities.ReplaceDataCapability.Value = false;
+ capabilities.UpdateDataCapability.Value = false;
+ capabilities.DeleteRawCapability.Value = false;
+ capabilities.DeleteAtTimeCapability.Value = false;
+ capabilities.InsertEventCapability.Value = false;
+ capabilities.ReplaceEventCapability.Value = false;
+ capabilities.UpdateEventCapability.Value = false;
+ capabilities.InsertAnnotationCapability.Value = false;
+ capabilities.ServerTimestampSupported.Value = true;
+ capabilities.ClearChangeMasks(server.DefaultSystemContext, false);
+
+ server.DiagnosticsNodeManager.UpdateServerEventNotifier();
+ }
+
protected override IMonitoredItemQueueFactory CreateMonitoredItemQueueFactory(
IServerInternal server,
ApplicationConfiguration configuration)
diff --git a/Applications/Quickstarts.Servers/TestData/HistoryArchive.cs b/Applications/Quickstarts.Servers/TestData/HistoryArchive.cs
index a01133f54f..41d03d4073 100644
--- a/Applications/Quickstarts.Servers/TestData/HistoryArchive.cs
+++ b/Applications/Quickstarts.Servers/TestData/HistoryArchive.cs
@@ -29,6 +29,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Threading;
using Microsoft.Extensions.Logging;
using Opc.Ua;
@@ -75,18 +76,38 @@ namespace TestData
}
}
+ ///
+ /// Returns a stable request-local snapshot of annotation timestamps.
+ /// Annotations are deliberately kept outside RawData.
+ ///
+ public IReadOnlyList GetAnnotationTimestamps(NodeId nodeId)
+ {
+ lock (m_lock)
+ {
+ if (m_annotations == null ||
+ !m_annotations.TryGetValue(nodeId, out List annotations))
+ {
+ return Array.Empty();
+ }
+
+ return annotations.ToArray();
+ }
+ }
+
///
/// Creates a new record in the archive.
///
- public void CreateRecord(NodeId nodeId, BuiltInType dataType)
+ public void CreateRecord(
+ NodeId nodeId,
+ BuiltInType dataType,
+ bool useDeterministicCttPattern = false)
{
lock (m_lock)
{
var record = new HistoryRecord
{
RawData = [],
- Historizing = true,
- DataType = dataType
+ Historizing = true
};
DateTime now = DateTime.UtcNow;
@@ -100,11 +121,22 @@ namespace TestData
entry.Value.SourceTimestamp = entry.Value.ServerTimestamp.AddMilliseconds(1234);
entry.IsModified = false;
- switch (dataType)
+ if (useDeterministicCttPattern)
{
- case BuiltInType.Int32:
- entry.Value.Value = ii;
- break;
+ int sampleIndex = 1000 - ii;
+ entry.Value.Value = CreateScalarValue(dataType, sampleIndex, now);
+ entry.Value.StatusCode = GetSeededStatusCode(sampleIndex);
+ }
+ else
+ {
+ // Preserve the original TestData Int32 fixture byte-for-byte
+ // in meaning: oldest value 1000 through newest value 0.
+ switch (dataType)
+ {
+ case BuiltInType.Int32:
+ entry.Value.Value = ii;
+ break;
+ }
}
record.RawData.Add(entry);
@@ -114,6 +146,17 @@ namespace TestData
m_records[nodeId] = record;
+ // Deterministic in-memory annotation sidecar. Two annotations
+ // intentionally share a timestamp to exercise multiplicity.
+ m_annotations ??= [];
+ m_annotations[nodeId] =
+ [
+ now.AddSeconds(-7500),
+ now.AddSeconds(-5000),
+ now.AddSeconds(-5000),
+ now.AddSeconds(-2500)
+ ];
+
m_updateTimer ??= new Timer(OnUpdate, null, 10000, 10000);
}
}
@@ -144,13 +187,7 @@ namespace TestData
.AddMilliseconds(-4567);
entry.IsModified = false;
- switch (record.DataType)
- {
- case BuiltInType.Int32:
- int lastValue = (int)record.RawData[^1].Value.Value;
- entry.Value.Value = lastValue + 1;
- break;
- }
+ entry.Value.Value = CreateNextScalarValue(record);
record.RawData.Add(entry);
}
@@ -162,9 +199,72 @@ namespace TestData
}
}
+ private static object CreateNextScalarValue(HistoryRecord record)
+ {
+ object lastValue = record.RawData[^1].Value.Value;
+ BuiltInType dataType = TypeInfo.Construct(lastValue).BuiltInType;
+ return dataType switch
+ {
+ BuiltInType.Boolean => !(bool)lastValue,
+ BuiltInType.SByte => (sbyte)((sbyte)lastValue + 1),
+ BuiltInType.Byte => (byte)((byte)lastValue + 1),
+ BuiltInType.Int16 => (short)((short)lastValue + 1),
+ BuiltInType.UInt16 => (ushort)((ushort)lastValue + 1),
+ BuiltInType.Int32 => (int)lastValue + 1,
+ BuiltInType.UInt32 => (uint)lastValue + 1,
+ BuiltInType.Int64 => (long)lastValue + 1,
+ BuiltInType.UInt64 => (ulong)lastValue + 1,
+ BuiltInType.Float => (float)lastValue + 1,
+ BuiltInType.Double => (double)lastValue + 1,
+ BuiltInType.String => ((int)record.RawData.Count).ToString(
+ CultureInfo.InvariantCulture),
+ BuiltInType.DateTime => ((DateTime)lastValue).AddSeconds(1),
+ BuiltInType.Guid => Guid.NewGuid(),
+ BuiltInType.ByteString => BitConverter.GetBytes(record.RawData.Count),
+ _ => lastValue
+ };
+ }
+
+ private static object CreateScalarValue(
+ BuiltInType dataType,
+ int value,
+ DateTime now)
+ {
+ return dataType switch
+ {
+ BuiltInType.Boolean => (value & 1) == 0,
+ BuiltInType.SByte => (sbyte)(value % 100),
+ BuiltInType.Byte => (byte)(value % 200),
+ BuiltInType.Int16 => (short)value,
+ BuiltInType.UInt16 => (ushort)value,
+ BuiltInType.Int32 => value,
+ BuiltInType.UInt32 => (uint)value,
+ BuiltInType.Int64 => (long)value,
+ BuiltInType.UInt64 => (ulong)value,
+ BuiltInType.Float => (float)value,
+ BuiltInType.Double => (double)value,
+ BuiltInType.String => value.ToString(CultureInfo.InvariantCulture),
+ BuiltInType.DateTime => now.AddSeconds(value),
+ BuiltInType.Guid => new Guid(value, 0, 0, new byte[8]),
+ BuiltInType.ByteString => BitConverter.GetBytes(value),
+ _ => value
+ };
+ }
+
+ private static StatusCode GetSeededStatusCode(int sampleIndex)
+ {
+ return (sampleIndex % 10) switch
+ {
+ 7 => StatusCodes.BadDataUnavailable,
+ 9 => StatusCodes.UncertainSubstituteValue,
+ _ => StatusCodes.Good
+ };
+ }
+
private readonly Lock m_lock = new();
private Timer m_updateTimer;
private Dictionary m_records;
+ private Dictionary> m_annotations;
private readonly ILogger m_logger;
}
@@ -184,6 +284,5 @@ namespace TestData
{
public List RawData;
public bool Historizing;
- public BuiltInType DataType;
}
}
diff --git a/Applications/Quickstarts.Servers/TestData/HistoryDataReader.cs b/Applications/Quickstarts.Servers/TestData/HistoryDataReader.cs
index adcecc0565..972ee4fe83 100644
--- a/Applications/Quickstarts.Servers/TestData/HistoryDataReader.cs
+++ b/Applications/Quickstarts.Servers/TestData/HistoryDataReader.cs
@@ -95,32 +95,65 @@ namespace TestData
{
m_request = request;
- // initialize start and end.
- m_startTime = m_request.StartTime;
- m_endTime = m_request.EndTime;
+ bool startSpecified = m_request.StartTime != DateTime.MinValue;
+ bool endSpecified = m_request.EndTime != DateTime.MinValue;
- if (m_endTime == DateTime.MinValue)
+ // Part 11: a request with only StartTime is forward and a request
+ // with only EndTime is reverse. DateTime.MinValue means that the
+ // corresponding time was not specified.
+ m_isForward = startSpecified &&
+ (!endSpecified || m_request.StartTime <= m_request.EndTime);
+ m_isOneSided = startSpecified != endSpecified;
+
+ if (!startSpecified)
{
+ m_startTime = m_request.EndTime;
+ m_endTime = DateTime.MinValue;
+ }
+ else if (!endSpecified)
+ {
+ m_startTime = m_request.StartTime;
m_endTime = DateTime.MaxValue;
}
-
- // check the direction.
- m_isForward = m_startTime < m_endTime;
- m_position = -1;
-
- // get first bound.
- if (m_request.ReturnBounds)
+ else
{
- DataValue value = m_source.FirstRaw(
+ m_startTime = m_request.StartTime;
+ m_endTime = m_request.EndTime;
+ }
+
+ m_position = -1;
+ m_complete = false;
+ m_pendingValue = null;
+
+ // Position the cursor at the requested boundary. When bounds are
+ // requested, first look on the opposite side of the boundary.
+ DataValue value = m_source.FirstRaw(
+ m_startTime,
+ m_request.ReturnBounds ? !m_isForward : m_isForward,
+ m_request.IsReadModified,
+ out m_position);
+
+ // A missing leading bound must not prevent values inside the
+ // requested domain from being returned.
+ if (value == null && m_request.ReturnBounds)
+ {
+ value = m_source.FirstRaw(
m_startTime,
- !m_isForward,
+ m_isForward,
m_request.IsReadModified,
out m_position);
+ }
- if (value != null)
- {
- AddValue(timestampsToReturn, indexRange, dataEncoding, values, value);
- }
+ if (value == null)
+ {
+ m_complete = true;
+ return;
+ }
+
+ m_complete = IsAtOrPastEnd(value.ServerTimestamp);
+ if (!m_complete || m_request.ReturnBounds)
+ {
+ AddValue(timestampsToReturn, indexRange, dataEncoding, values, value);
}
}
@@ -140,35 +173,81 @@ namespace TestData
QualifiedName dataEncoding,
DataValueCollection values)
{
+ if (m_complete)
+ {
+ return true;
+ }
+
while (true)
{
// check for limit.
if (m_request.NumValuesPerNode > 0 && values.Count >= m_request.NumValuesPerNode)
{
+ // For a one-sided request the count defines the end of the
+ // time domain, so no continuation point is required.
+ if (m_isOneSided)
+ {
+ return true;
+ }
+
+ // Look ahead before returning a continuation point. Without
+ // this check an exact page boundary produces a spurious empty
+ // final page. The source position is advanced by NextRaw, so
+ // retain a qualifying value for the next page.
+ DataValue nextValue = m_source.NextRaw(
+ m_lastTime,
+ m_isForward,
+ m_request.IsReadModified,
+ ref m_position);
+
+ if (nextValue == null)
+ {
+ m_complete = true;
+ return true;
+ }
+
+ if (IsAtOrPastEnd(nextValue.ServerTimestamp) && !m_request.ReturnBounds)
+ {
+ m_complete = true;
+ return true;
+ }
+
+ m_pendingValue = nextValue;
return false;
}
- DataValue value = m_source.NextRaw(
- m_lastTime,
- m_isForward,
- m_request.IsReadModified,
- ref m_position);
+ DataValue value;
+
+ if (m_pendingValue != null)
+ {
+ value = m_pendingValue;
+ m_pendingValue = null;
+ }
+ else
+ {
+ value = m_source.NextRaw(
+ m_lastTime,
+ m_isForward,
+ m_request.IsReadModified,
+ ref m_position);
+ }
// no more data.
if (value == null)
{
+ m_complete = true;
return true;
}
// check for bound.
- if ((m_isForward && value.ServerTimestamp >= m_endTime) ||
- (!m_isForward && value.ServerTimestamp <= m_endTime))
+ if (IsAtOrPastEnd(value.ServerTimestamp))
{
if (m_request.ReturnBounds)
{
AddValue(timestampsToReturn, indexRange, dataEncoding, values, value);
- return true;
}
+ m_complete = true;
+ return true;
}
// add value.
@@ -176,6 +255,12 @@ namespace TestData
}
}
+ private bool IsAtOrPastEnd(DateTime timestamp)
+ {
+ return (m_isForward && timestamp >= m_endTime) ||
+ (!m_isForward && timestamp <= m_endTime);
+ }
+
///
/// Adds a DataValue to a list of values to return.
///
@@ -244,7 +329,10 @@ namespace TestData
private DateTime m_startTime;
private DateTime m_endTime;
private bool m_isForward;
+ private bool m_isOneSided;
+ private bool m_complete;
private int m_position;
private DateTime m_lastTime;
+ private DataValue m_pendingValue;
}
}
diff --git a/Applications/Quickstarts.Servers/TestData/HistoryFile.cs b/Applications/Quickstarts.Servers/TestData/HistoryFile.cs
index 17f88b5a22..fb9b9b53b5 100644
--- a/Applications/Quickstarts.Servers/TestData/HistoryFile.cs
+++ b/Applications/Quickstarts.Servers/TestData/HistoryFile.cs
@@ -120,7 +120,7 @@ namespace TestData
bool isReadModified,
ref int position)
{
- position++;
+ position += isForward ? 1 : -1;
lock (m_lock)
{
diff --git a/Applications/Quickstarts.Servers/TestData/ProcessedHistoryAdapter.cs b/Applications/Quickstarts.Servers/TestData/ProcessedHistoryAdapter.cs
new file mode 100644
index 0000000000..8733ed5440
--- /dev/null
+++ b/Applications/Quickstarts.Servers/TestData/ProcessedHistoryAdapter.cs
@@ -0,0 +1,424 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using Opc.Ua;
+using Opc.Ua.Server;
+
+namespace TestData
+{
+ ///
+ /// Bridges the legacy history source to the existing aggregate calculators
+ /// without changing the HistoryFile storage contract.
+ ///
+ internal static class ProcessedHistoryAdapter
+ {
+ internal const int PageSize = 1000;
+ internal const int MaxBufferedOutputs = 100000;
+
+ public static ServiceResult Read(
+ ServerSystemContext context,
+ IServerInternal server,
+ NodeId variableId,
+ IHistoryDataSource source,
+ ReadProcessedDetails details,
+ NodeId aggregateId,
+ IReadOnlyList annotationTimes,
+ TimestampsToReturn timestampsToReturn,
+ HistoryReadValueId nodeToRead,
+ HistoryReadResult result)
+ {
+ if (details.StartTime == details.EndTime)
+ {
+ result.StatusCode = StatusCodes.BadInvalidArgument;
+ return StatusCodes.BadInvalidArgument;
+ }
+
+ ProcessedHistoryContinuationState state = null;
+ if (nodeToRead.ContinuationPoint != null &&
+ nodeToRead.ContinuationPoint.Length > 0)
+ {
+ state = Restore(context, nodeToRead.ContinuationPoint);
+ if (state == null ||
+ state.VariableId != variableId ||
+ state.AggregateId != aggregateId)
+ {
+ state?.Dispose();
+ result.StatusCode = StatusCodes.BadContinuationPointInvalid;
+ result.ContinuationPoint = null;
+ return StatusCodes.BadContinuationPointInvalid;
+ }
+ }
+
+ if (state == null)
+ {
+ ServiceResult error = Compute(
+ server,
+ variableId,
+ source,
+ details,
+ aggregateId,
+ annotationTimes,
+ out List outputs);
+ if (ServiceResult.IsBad(error))
+ {
+ result.StatusCode = error.StatusCode;
+ return error;
+ }
+
+ state = new ProcessedHistoryContinuationState(
+ variableId,
+ aggregateId,
+ outputs);
+ }
+
+ EmitPage(context, state, timestampsToReturn, nodeToRead, result);
+ return ServiceResult.Good;
+ }
+
+ public static ProcessedHistoryContinuationState Restore(
+ ServerSystemContext context,
+ byte[] continuationPoint)
+ {
+ if (context?.OperationContext?.Session == null)
+ {
+ return null;
+ }
+
+ return context.OperationContext.Session.RestoreHistoryContinuationPoint(
+ continuationPoint) as ProcessedHistoryContinuationState;
+ }
+
+ private static ServiceResult Compute(
+ IServerInternal server,
+ NodeId variableId,
+ IHistoryDataSource source,
+ ReadProcessedDetails details,
+ NodeId aggregateId,
+ IReadOnlyList annotationTimes,
+ out List outputs)
+ {
+ outputs = [];
+
+ if (source == null)
+ {
+ return StatusCodes.BadNotReadable;
+ }
+
+ AggregateConfiguration configuration = ResolveConfiguration(
+ server.AggregateManager,
+ variableId,
+ details.AggregateConfiguration);
+
+ if (configuration.PercentDataGood > 100 ||
+ configuration.PercentDataBad > 100 ||
+ configuration.PercentDataGood < 100 - configuration.PercentDataBad)
+ {
+ return StatusCodes.BadAggregateInvalidInputs;
+ }
+
+ double processingInterval = details.ProcessingInterval;
+ if (processingInterval > 0 &&
+ Math.Abs((details.EndTime - details.StartTime).TotalMilliseconds) /
+ processingInterval >
+ MaxBufferedOutputs)
+ {
+ return StatusCodes.BadTooManyOperations;
+ }
+
+ if (aggregateId == Opc.Ua.ObjectIds.AggregateFunction_AnnotationCount)
+ {
+ return ComputeAnnotationCount(
+ details,
+ annotationTimes ?? Array.Empty(),
+ outputs);
+ }
+
+ IAggregateCalculator calculator = server.AggregateManager.CreateCalculator(
+ aggregateId,
+ details.StartTime,
+ details.EndTime,
+ processingInterval,
+ false,
+ configuration);
+ if (calculator == null)
+ {
+ return StatusCodes.BadAggregateNotSupported;
+ }
+
+ bool isForward = details.StartTime < details.EndTime;
+ DataValue value = source.FirstRaw(
+ details.StartTime,
+ !isForward,
+ false,
+ out int position);
+
+ // If the leading bound does not exist, begin with the first value
+ // in the requested direction.
+ if (value == null)
+ {
+ value = source.FirstRaw(
+ details.StartTime,
+ isForward,
+ false,
+ out position);
+ }
+
+ while (value != null)
+ {
+ if (!calculator.QueueRawValue(value))
+ {
+ Flush(calculator, outputs, false);
+ }
+
+ if (outputs.Count > MaxBufferedOutputs)
+ {
+ return StatusCodes.BadTooManyOperations;
+ }
+
+ bool passedEnd = isForward
+ ? value.ServerTimestamp > details.EndTime
+ : value.ServerTimestamp < details.EndTime;
+ if (passedEnd)
+ {
+ break;
+ }
+
+ value = source.NextRaw(
+ value.ServerTimestamp,
+ isForward,
+ false,
+ ref position);
+ }
+
+ Flush(calculator, outputs, true);
+ return outputs.Count > MaxBufferedOutputs
+ ? StatusCodes.BadTooManyOperations
+ : ServiceResult.Good;
+ }
+
+ private static ServiceResult ComputeAnnotationCount(
+ ReadProcessedDetails details,
+ IReadOnlyList annotationTimes,
+ List outputs)
+ {
+ bool isForward = details.StartTime < details.EndTime;
+ if (details.ProcessingInterval <= 0)
+ {
+ DateTime low = isForward ? details.StartTime : details.EndTime;
+ DateTime high = isForward ? details.EndTime : details.StartTime;
+ outputs.Add(CreateAnnotationCountValue(
+ CountAnnotations(annotationTimes, low, high),
+ details.StartTime));
+ return ServiceResult.Good;
+ }
+
+ TimeSpan interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
+ if (isForward)
+ {
+ for (DateTime start = details.StartTime;
+ start < details.EndTime;
+ start = start.Add(interval))
+ {
+ DateTime end = start.Add(interval);
+ if (end > details.EndTime)
+ {
+ end = details.EndTime;
+ }
+
+ outputs.Add(CreateAnnotationCountValue(
+ CountAnnotations(annotationTimes, start, end),
+ start));
+ }
+ }
+ else
+ {
+ for (DateTime start = details.StartTime;
+ start > details.EndTime;
+ start = start.Subtract(interval))
+ {
+ DateTime end = start.Subtract(interval);
+ if (end < details.EndTime)
+ {
+ end = details.EndTime;
+ }
+
+ outputs.Add(CreateAnnotationCountValue(
+ CountAnnotations(annotationTimes, end, start),
+ start));
+ }
+ }
+
+ return ServiceResult.Good;
+ }
+
+ private static int CountAnnotations(
+ IReadOnlyList annotationTimes,
+ DateTime lowInclusive,
+ DateTime highExclusive)
+ {
+ int count = 0;
+ for (int ii = 0; ii < annotationTimes.Count; ii++)
+ {
+ DateTime timestamp = annotationTimes[ii];
+ if (timestamp >= lowInclusive && timestamp < highExclusive)
+ {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+ private static DataValue CreateAnnotationCountValue(int count, DateTime timestamp)
+ {
+ var value = new DataValue(
+ new Variant(count),
+ StatusCodes.Good,
+ timestamp,
+ timestamp);
+ value.StatusCode = value.StatusCode.SetAggregateBits(AggregateBits.Calculated);
+ return value;
+ }
+
+ private static AggregateConfiguration ResolveConfiguration(
+ AggregateManager manager,
+ NodeId variableId,
+ AggregateConfiguration requested)
+ {
+ bool implicitDefault = requested != null &&
+ !requested.UseServerCapabilitiesDefaults &&
+ requested.PercentDataBad == 0 &&
+ requested.PercentDataGood == 0 &&
+ !requested.TreatUncertainAsBad &&
+ !requested.UseSlopedExtrapolation;
+
+ if (requested == null ||
+ requested.UseServerCapabilitiesDefaults ||
+ implicitDefault)
+ {
+ return manager.GetDefaultConfiguration(variableId);
+ }
+
+ return requested;
+ }
+
+ private static void Flush(
+ IAggregateCalculator calculator,
+ List outputs,
+ bool returnPartial)
+ {
+ DataValue value;
+ while ((value = calculator.GetProcessedValue(returnPartial)) != null)
+ {
+ outputs.Add(value);
+ }
+ }
+
+ private static void EmitPage(
+ ServerSystemContext context,
+ ProcessedHistoryContinuationState state,
+ TimestampsToReturn timestampsToReturn,
+ HistoryReadValueId nodeToRead,
+ HistoryReadResult result)
+ {
+ int count = Math.Min(PageSize, state.Outputs.Count - state.Offset);
+ var data = new HistoryData();
+
+ for (int ii = 0; ii < count; ii++)
+ {
+ DataValue value = (DataValue)state.Outputs[state.Offset + ii].Clone();
+ ApplyResultOptions(value, timestampsToReturn, nodeToRead);
+ data.DataValues.Add(value);
+ }
+
+ state.Offset += count;
+ result.HistoryData = new ExtensionObject(data);
+ result.StatusCode = StatusCodes.Good;
+
+ if (state.Offset >= state.Outputs.Count)
+ {
+ result.ContinuationPoint = null;
+ state.Dispose();
+ return;
+ }
+
+ state.Id = Guid.NewGuid();
+ context.OperationContext?.Session?.SaveHistoryContinuationPoint(state.Id, state);
+ result.ContinuationPoint = state.Id.ToByteArray();
+ }
+
+ private static void ApplyResultOptions(
+ DataValue value,
+ TimestampsToReturn timestampsToReturn,
+ HistoryReadValueId nodeToRead)
+ {
+ if (StatusCode.IsGood(value.StatusCode))
+ {
+ object valueToReturn = value.Value;
+ if (nodeToRead.ParsedIndexRange != NumericRange.Empty)
+ {
+ StatusCode error = nodeToRead.ParsedIndexRange.ApplyRange(ref valueToReturn);
+ if (StatusCode.IsBad(error))
+ {
+ value.Value = null;
+ value.StatusCode = error;
+ }
+ else
+ {
+ value.Value = valueToReturn;
+ }
+ }
+
+ if (!QualifiedName.IsNull(nodeToRead.DataEncoding))
+ {
+ value.Value = null;
+ value.StatusCode = StatusCodes.BadDataEncodingUnsupported;
+ }
+ }
+
+ if (timestampsToReturn is TimestampsToReturn.Neither or TimestampsToReturn.Server)
+ {
+ value.SourceTimestamp = DateTime.MinValue;
+ }
+
+ if (timestampsToReturn is TimestampsToReturn.Neither or TimestampsToReturn.Source)
+ {
+ value.ServerTimestamp = DateTime.MinValue;
+ }
+ }
+ }
+
+ internal sealed class ProcessedHistoryContinuationState : IDisposable
+ {
+ public ProcessedHistoryContinuationState(
+ NodeId variableId,
+ NodeId aggregateId,
+ List outputs)
+ {
+ Id = Guid.NewGuid();
+ VariableId = variableId;
+ AggregateId = aggregateId;
+ Outputs = outputs;
+ }
+
+ public Guid Id { get; set; }
+
+ public NodeId VariableId { get; }
+
+ public NodeId AggregateId { get; }
+
+ public List Outputs { get; }
+
+ public int Offset { get; set; }
+
+ public void Dispose()
+ {
+ // The state owns only managed, immutable-for-the-read data.
+ }
+ }
+}
diff --git a/Applications/Quickstarts.Servers/TestData/TestDataNodeManager.cs b/Applications/Quickstarts.Servers/TestData/TestDataNodeManager.cs
index bb32cda31b..8705ba1201 100644
--- a/Applications/Quickstarts.Servers/TestData/TestDataNodeManager.cs
+++ b/Applications/Quickstarts.Servers/TestData/TestDataNodeManager.cs
@@ -554,6 +554,14 @@ namespace TestData
{
var serverContext = context as ServerSystemContext;
+ if (source.ValueRank == ValueRanks.Scalar &&
+ nodeToRead.ParsedIndexRange != NumericRange.Empty)
+ {
+ result.StatusCode = StatusCodes.BadIndexRangeNoData;
+ result.ContinuationPoint = null;
+ return ServiceResult.Good;
+ }
+
var data = new HistoryData();
HistoryDataReader reader;
@@ -619,7 +627,16 @@ namespace TestData
if (!complete)
{
SaveDataReader(serverContext, reader);
- result.StatusCode = StatusCodes.GoodMoreData;
+ result.ContinuationPoint = reader.Id.ToByteArray();
+ result.StatusCode = StatusCodes.Good;
+ }
+ else
+ {
+ Utils.SilentDispose(reader);
+ result.ContinuationPoint = null;
+ result.StatusCode = data.DataValues.Count == 0 && !details.ReturnBounds
+ ? StatusCodes.GoodNoData
+ : StatusCodes.Good;
}
// return the dat.
@@ -672,6 +689,55 @@ namespace TestData
}
}
+ ///
+ /// Reads processed history through the existing aggregate calculator
+ /// implementations while retaining the legacy HistoryFile layout.
+ ///
+ protected override void HistoryReadProcessed(
+ ServerSystemContext context,
+ ReadProcessedDetails details,
+ TimestampsToReturn timestampsToReturn,
+ IList nodesToRead,
+ IList results,
+ IList errors,
+ List nodesToProcess,
+ IDictionary cache)
+ {
+ for (int ii = 0; ii < nodesToProcess.Count; ii++)
+ {
+ NodeHandle handle = nodesToProcess[ii];
+ NodeState source = ValidateNode(context, handle, cache);
+
+ if (source is not BaseVariableState variable)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ continue;
+ }
+
+ ServiceResult error = GetHistoryDataSource(
+ context,
+ variable,
+ out IHistoryDataSource datasource);
+ if (ServiceResult.IsBad(error))
+ {
+ errors[handle.Index] = error;
+ continue;
+ }
+
+ errors[handle.Index] = ProcessedHistoryAdapter.Read(
+ context,
+ Server,
+ variable.NodeId,
+ datasource,
+ details,
+ details.AggregateType[handle.Index],
+ m_system.GetAnnotationTimestamps(variable),
+ timestampsToReturn,
+ nodesToRead[handle.Index],
+ results[handle.Index]);
+ }
+ }
+
///
/// Releases the continuation points for history read operations.
///
@@ -695,21 +761,33 @@ namespace TestData
}
// only variables can have history.
- if (source is not BaseVariableState variable)
+ if (source is not BaseVariableState)
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
continue;
}
- // release the continuation point.
- errors[handle.Index] = HistoryReadRaw(
- context,
- variable,
- null,
- TimestampsToReturn.Neither,
- true,
- nodesToRead[handle.Index],
- new HistoryReadResult());
+ HistoryReadValueId nodeToRead = nodesToRead[handle.Index];
+ object continuation = context.OperationContext?.Session?
+ .RestoreHistoryContinuationPoint(nodeToRead.ContinuationPoint);
+
+ if (continuation is HistoryDataReader rawReader &&
+ rawReader.VariableId == nodeToRead.NodeId)
+ {
+ Utils.SilentDispose(rawReader);
+ errors[handle.Index] = ServiceResult.Good;
+ }
+ else if (continuation is ProcessedHistoryContinuationState processed &&
+ processed.VariableId == nodeToRead.NodeId)
+ {
+ Utils.SilentDispose(processed);
+ errors[handle.Index] = ServiceResult.Good;
+ }
+ else
+ {
+ Utils.SilentDispose(continuation);
+ errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
+ }
}
}
diff --git a/Applications/Quickstarts.Servers/TestData/TestDataSystem.cs b/Applications/Quickstarts.Servers/TestData/TestDataSystem.cs
index 211b2f98fa..d64c0fc11a 100644
--- a/Applications/Quickstarts.Servers/TestData/TestDataSystem.cs
+++ b/Applications/Quickstarts.Servers/TestData/TestDataSystem.cs
@@ -145,6 +145,16 @@ namespace TestData
return m_historyArchive.GetHistoryFile(variable.NodeId);
}
+ ///
+ /// Returns a request-local snapshot of annotations for a variable.
+ ///
+ internal IReadOnlyList GetAnnotationTimestamps(BaseVariableState variable)
+ {
+ return variable == null
+ ? Array.Empty()
+ : m_historyArchive.GetAnnotationTimestamps(variable.NodeId);
+ }
+
///
/// Returns a new value for the variable.
///
diff --git a/Applications/UAReferenceServer.ctt.xml b/Applications/UAReferenceServer.ctt.xml
index aa2e9dd7d8..f43c08f59d 100644
--- a/Applications/UAReferenceServer.ctt.xml
+++ b/Applications/UAReferenceServer.ctt.xml
@@ -6571,7 +6571,7 @@
-
+
@@ -6609,7 +6609,7 @@
-
+
@@ -6647,7 +6647,7 @@
-
+
@@ -6685,7 +6685,7 @@
-
+
@@ -6723,7 +6723,7 @@
-
+
@@ -6761,7 +6761,7 @@
-
+
@@ -6799,7 +6799,7 @@
-
+
@@ -6837,7 +6837,7 @@
-
+
@@ -6875,7 +6875,7 @@
-
+
@@ -6913,7 +6913,7 @@
-
+
@@ -6951,7 +6951,7 @@
-
+
@@ -6989,7 +6989,7 @@
-
+
@@ -7027,7 +7027,7 @@
-
+
@@ -7065,7 +7065,7 @@
-
+
@@ -8816,7 +8816,7 @@
-
+
@@ -8892,7 +8892,7 @@
-
+
@@ -8968,7 +8968,7 @@
-
+
@@ -9044,7 +9044,7 @@
-
+
@@ -9120,7 +9120,7 @@
-
+
@@ -9196,7 +9196,7 @@
-
+
@@ -9272,7 +9272,7 @@
-
+
@@ -9348,7 +9348,7 @@
-
+
@@ -9424,7 +9424,7 @@
-
+
@@ -9500,7 +9500,7 @@
-
+
diff --git a/Docs/HistorianAggregateBackport.md b/Docs/HistorianAggregateBackport.md
new file mode 100644
index 0000000000..0954525d91
--- /dev/null
+++ b/Docs/HistorianAggregateBackport.md
@@ -0,0 +1,74 @@
+# Additive Historian Aggregate Backport
+
+This backport brings the aggregate corrections and aggregate-focused tests from
+`master` into `master378`. It deliberately keeps the
+`master378` historian architecture, public API, and wire behavior.
+
+## Compatibility boundary
+
+- `HistoryFile` remains the raw-history implementation of
+ `IHistoryDataSource`.
+- `IHistoryDataSource` still exposes only `FirstRaw` and `NextRaw`.
+- `HistoryRecord` still contains only `RawData` and `Historizing`.
+- `HistoryEntry` still contains only `Value` and `IsModified`.
+- Existing Int32 archive generation remains oldest value `1000` through newest
+ value `0`.
+- No async historian provider, registry, client facade, or new public historian
+ contract from `master` is introduced.
+- Processed reads are added behind the existing synchronous NodeManager
+ overrides. Existing clients continue to send the standard OPC UA
+ `HistoryRead` request and do not need adapting.
+
+The new `ProcessedHistoryAdapter` reads the existing `HistoryFile` through
+`IHistoryDataSource`, feeds those values into the existing aggregate
+calculators, and returns standard `HistoryData`. It does not rewrite or append
+raw history. A compatibility test fingerprints the record fields and compares
+all raw values, status codes, and timestamps before and after a processed read.
+
+Annotations are stored in a separate in-memory timestamp dictionary. They are
+never inserted into `HistoryRecord.RawData`, so the raw-history storage layout
+and raw-read ordering remain unchanged.
+
+## Processed-read behavior
+
+- Forward and reverse ranges are supported.
+- Leading and trailing bounds are supplied from the existing raw source.
+- Aggregate configuration uses an explicit request when supplied; an implicit
+ or requested server default is resolved through `AggregateManager`.
+- Results are buffered as a stable request snapshot, limited to 100,000
+ aggregate outputs, and returned in pages of 1,000.
+- A non-empty continuation point is the only “more data” signal; the per-node
+ result remains `Good`.
+- Continuation points are tied to the session, node, and aggregate, and can be
+ released through the existing HistoryRead release operation.
+- Index range, data encoding, and `TimestampsToReturn` are applied to every
+ result page.
+- `AnnotationCount` reads only the annotation sidecar and preserves duplicate
+ annotation timestamps.
+
+## Seeded Reference Server data
+
+The Reference Server adds deterministic historizing scalar nodes for Boolean,
+Int32, Float, Double, and String. Selected pre-existing static scalar nodes are
+also made historizing. Each record has 1,001 samples at ten-second intervals
+with deterministic Good, Bad, and Uncertain status codes. Historical
+configuration metadata is linked with `HasHistoricalConfiguration`.
+
+## Source commit ledger in plain language
+
+Times are the commits' recorded ISO timestamps.
+
+| Source commit | Time | What it means for this backport |
+| --- | --- | --- |
+| `64ca85fce304` | 2026-04-14 13:32:10 +02:00 | Added more tests around existing behavior. Relevant aggregate tests were inventoried. |
+| `14e995c1d061` | 2026-05-24 06:23:16 +02:00 | Added aggregate history integration tests while reorganizing the test projects. The tests are mapped to the older project layout. |
+| `c89bc92e7cdd` | 2026-05-26 10:45:32 +02:00 | Added processed-history and annotation scenarios as part of a new historian framework. The framework is not copied; the observable behavior is adapted to the old historian. |
+| `b2b4dbb13f4f` | 2026-06-10 08:34:09 +02:00 | Added the main Part 13 aggregate correctness fixes and broad tests. This is the central calculator backport. |
+| `e1c734438458` | 2026-06-28 12:29:11 +02:00 | Changed general StatusCode equality rules. The broad API change is not copied; aggregate tests compare the intended code and aggregate bits explicitly. |
+| `ed090304986f` | 2026-07-06 08:48:56 +02:00 | Added calculator, manager, edge-case, and regression tests that exposed additional aggregate defects. |
+| `b26be47a487a` | 2026-07-08 16:24:19 +02:00 | Added tests for processed-historian branches and error paths. |
+| `d7cb170b86e4` | 2026-07-09 09:08:27 +02:00 | Added CTT facets, deterministic history nodes, configuration metadata, and continuation fixes. These are implemented through the legacy NodeManager path. |
+| `7f409657ade9` | 2026-07-11 04:47:25 +02:00 | Fixed which timestamp Min/Max aggregates return when the chosen value comes from a raw sample. |
+| `70f2e0557a88` | 2026-07-11 20:43:06 +02:00 | Added processed-read configuration coverage, including server-default resolution. |
+| `0da85b9d8988` | 2026-07-13 19:14:19 +02:00 | Fixed reverse reads, bounds, quality handling, status results, and aggregate configuration behavior. |
+| `84fa8e8906c7` | 2026-07-18 05:01:03 +02:00 | Fixed bound markers, transition counting, and aggregate behavior for nonnumeric values. |
diff --git a/Docs/Profiles.md b/Docs/Profiles.md
index 5186510493..d5e6fadedf 100644
--- a/Docs/Profiles.md
+++ b/Docs/Profiles.md
@@ -21,7 +21,7 @@ The Reference Server implementation supports the following OPC UA Server profile
- Subscription management
- MonitoredItem services
- View services (Browse, BrowseNext, TranslateBrowsePathsToNodeIds)
- - Attribute services (Read, Write, HistoryRead, HistoryUpdate)
+ - Attribute services (Read, Write, HistoryRead)
- Query services
### Functional Facets
@@ -37,6 +37,10 @@ The Reference Server implementation supports the following OPC UA Server profile
- Session management for redundant connections
- See [Transfer Subscriptions documentation](TransferSubscription.md)
+- **[Historical Raw Data Server Facet (2022)](http://opcfoundation.org/UA-Profile/Server/HistoricalRawData2022)** - Raw historical reads for the Reference Server's historized static scalar nodes. Modified, at-time, annotation, and HistoryUpdate operations are not advertised.
+
+- **[Historical Aggregate Server Facet (2022)](http://opcfoundation.org/UA-Profile/Server/AggregateHistorical2022)** - Processed historical reads computed from the legacy raw archive through the existing aggregate calculators.
+
### Additional Features
The server implementation also provides support for:
@@ -51,9 +55,8 @@ The server implementation also provides support for:
The following server profiles/facets are **not yet fully supported**:
- **Alarms & Conditions** - Only a limited set of alarms is currently implemented (`ExclusiveLevel`, `NonExclusiveLevel`, `OffNormal`)
-- **Historical Access** - Limited support for historical data access
+- **Historical Access updates** - Modified, at-time, annotation, insert, replace, update, and delete operations are not supported by the Reference Server's legacy history archive
- **Events** - Limited event support
-- **Aggregates Server Facet** - Historical data aggregation
- **Query Server Facet** - Advanced query capabilities
## Client Profiles
diff --git a/Libraries/Opc.Ua.Server/Aggregates/AggregateCalculator.cs b/Libraries/Opc.Ua.Server/Aggregates/AggregateCalculator.cs
index c3a68b8483..391894d2d1 100644
--- a/Libraries/Opc.Ua.Server/Aggregates/AggregateCalculator.cs
+++ b/Libraries/Opc.Ua.Server/Aggregates/AggregateCalculator.cs
@@ -115,7 +115,8 @@ namespace Opc.Ua.Server
}
// ignore placeholders in the stream.
- if (value.StatusCode.CodeBits == StatusCodes.BadNoData)
+ if (value.StatusCode.CodeBits == StatusCodes.BadNoData ||
+ value.StatusCode.CodeBits == StatusCodes.BadBoundNotFound)
{
return true;
}
@@ -521,6 +522,11 @@ namespace Opc.Ua.Server
///
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).
///
@@ -638,6 +644,11 @@ namespace Opc.Ua.Server
// 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;
@@ -674,6 +685,11 @@ namespace Opc.Ua.Server
// 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;
@@ -799,6 +815,26 @@ namespace Opc.Ua.Server
/// 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);
diff --git a/Libraries/Opc.Ua.Server/Aggregates/AggregateManager.cs b/Libraries/Opc.Ua.Server/Aggregates/AggregateManager.cs
index 3e236ca823..a42d8fd6d9 100644
--- a/Libraries/Opc.Ua.Server/Aggregates/AggregateManager.cs
+++ b/Libraries/Opc.Ua.Server/Aggregates/AggregateManager.cs
@@ -119,7 +119,8 @@ namespace Opc.Ua.Server
{
PercentDataBad = 100,
PercentDataGood = 100,
- TreatUncertainAsBad = false,
+ // OPC UA Part 13 specifies that uncertain values are treated as bad by default.
+ TreatUncertainAsBad = true,
UseSlopedExtrapolation = false,
UseServerCapabilitiesDefaults = false
};
@@ -204,7 +205,10 @@ namespace Opc.Ua.Server
m_factories[aggregateId] = factory;
}
- m_server?.DiagnosticsNodeManager.AddAggregateFunction(aggregateId, aggregateName, true);
+ m_server?.DiagnosticsNodeManager?.AddAggregateFunction(
+ aggregateId,
+ aggregateName,
+ true);
}
///
diff --git a/Libraries/Opc.Ua.Server/Aggregates/CountAggregateCalculator.cs b/Libraries/Opc.Ua.Server/Aggregates/CountAggregateCalculator.cs
index 587ccfe0d2..7f8a67ce85 100644
--- a/Libraries/Opc.Ua.Server/Aggregates/CountAggregateCalculator.cs
+++ b/Libraries/Opc.Ua.Server/Aggregates/CountAggregateCalculator.cs
@@ -233,46 +233,29 @@ namespace Opc.Ua.Server
return GetNoDataValue(slice);
}
- // determine whether a transition occurs at the StartTime
- double lastValue = double.NaN;
-
- if (slice.EarlyBound != null && StatusCode.IsGood(slice.EarlyBound.Value.StatusCode))
- {
- try
- {
- lastValue = CastToDouble(slice.EarlyBound.Value);
- }
- catch (Exception)
- {
- lastValue = double.NaN;
- }
- }
+ // 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 (!IsGood(values[ii]))
+ if (StatusCode.IsBad(values[ii].StatusCode))
{
continue;
}
- double nextValue;
- try
- {
- nextValue = CastToDouble(values[ii]);
- }
- catch (Exception)
- {
- continue;
- }
+ object nextValue = values[ii].Value;
- if (!double.IsNaN(lastValue) && lastValue != nextValue)
+ if (!hasLastValue || !Utils.IsEqual(lastValue, nextValue))
{
count++;
}
+ hasLastValue = true;
lastValue = nextValue;
}
diff --git a/Libraries/Opc.Ua.Server/Aggregates/MinMaxAggregateCalculator.cs b/Libraries/Opc.Ua.Server/Aggregates/MinMaxAggregateCalculator.cs
index 6b8a764382..740b0d499b 100644
--- a/Libraries/Opc.Ua.Server/Aggregates/MinMaxAggregateCalculator.cs
+++ b/Libraries/Opc.Ua.Server/Aggregates/MinMaxAggregateCalculator.cs
@@ -245,7 +245,7 @@ namespace Opc.Ua.Server
}
// set calculated if not returning actual time and value is not at the start time.
- if (!returnActualTime && processedTimestamp != slice.StartTime)
+ if (!returnActualTime && processedTimestamp != GetTimestamp(slice))
{
statusCode = statusCode.SetAggregateBits(AggregateBits.Calculated);
}
@@ -429,7 +429,7 @@ namespace Opc.Ua.Server
// set calculated if not returning actual time and value is not at the start time.
if (!returnActualTime &&
- processedTimestamp != slice.StartTime &&
+ processedTimestamp != GetTimestamp(slice) &&
(statusCode.AggregateBits & AggregateBits.Interpolated) == 0)
{
statusCode = statusCode.SetAggregateBits(
diff --git a/Libraries/Opc.Ua.Server/Aggregates/StatusAggregateCalculator.cs b/Libraries/Opc.Ua.Server/Aggregates/StatusAggregateCalculator.cs
index 89cdb51c8b..12ac3a3fdf 100644
--- a/Libraries/Opc.Ua.Server/Aggregates/StatusAggregateCalculator.cs
+++ b/Libraries/Opc.Ua.Server/Aggregates/StatusAggregateCalculator.cs
@@ -116,12 +116,16 @@ namespace Opc.Ua.Server
if (isBad)
{
- if (StatusCode.IsBad(regions[ii].StatusCode))
+ if (StatusCode.IsBad(regions[ii].StatusCode) ||
+ (Configuration.TreatUncertainAsBad &&
+ StatusCode.IsUncertain(regions[ii].StatusCode)))
{
duration += regions[ii].Duration;
}
}
- else if (StatusCode.IsGood(regions[ii].StatusCode))
+ else if (StatusCode.IsGood(regions[ii].StatusCode) ||
+ (!Configuration.TreatUncertainAsBad &&
+ StatusCode.IsUncertain(regions[ii].StatusCode)))
{
duration += regions[ii].Duration;
}
@@ -151,14 +155,26 @@ namespace Opc.Ua.Server
protected DataValue ComputeWorstQuality(TimeSlice slice, bool includeBounds)
{
// get the values in the slice.
- List values;
- if (!includeBounds)
+ List values = GetValues(slice);
+
+ if (includeBounds && values != null)
{
- values = GetValues(slice);
- }
- else
- {
- values = GetValuesWithSimpleBounds(slice);
+ 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.
@@ -170,12 +186,15 @@ namespace Opc.Ua.Server
// get the regions.
_ = GetRegionsInValueSet(values, false, true);
- StatusCode worstQuality = StatusCodes.Good;
+ int firstIndex = TimeFlowsBackward ? values.Count - 1 : 0;
+ StatusCode worstQuality = values[firstIndex].StatusCode.CodeBits;
int badQualityCount = 0;
int uncertainQualityCount = 0;
+ int goodQualityCount = 0;
- for (int ii = 0; ii < values.Count; ii++)
+ 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))
@@ -199,6 +218,10 @@ namespace Opc.Ua.Server
worstQuality = quality.CodeBits;
}
}
+ else
+ {
+ goodQualityCount++;
+ }
}
// set the timestamp and status.
@@ -211,7 +234,8 @@ namespace Opc.Ua.Server
value.StatusCode = value.StatusCode.SetAggregateBits(AggregateBits.Calculated);
if ((StatusCode.IsBad(worstQuality) && badQualityCount > 1) ||
- (StatusCode.IsUncertain(worstQuality) && uncertainQualityCount > 1))
+ (StatusCode.IsUncertain(worstQuality) && uncertainQualityCount > 1) ||
+ (StatusCode.IsGood(worstQuality) && goodQualityCount > 1))
{
value.StatusCode = value.StatusCode.SetAggregateBits(
value.StatusCode.AggregateBits | AggregateBits.MultipleValues);
diff --git a/Libraries/Opc.Ua.Server/Aggregates/StdDevAggregateCalculator.cs b/Libraries/Opc.Ua.Server/Aggregates/StdDevAggregateCalculator.cs
index bc9eef0bf3..33ba2c398b 100644
--- a/Libraries/Opc.Ua.Server/Aggregates/StdDevAggregateCalculator.cs
+++ b/Libraries/Opc.Ua.Server/Aggregates/StdDevAggregateCalculator.cs
@@ -229,20 +229,12 @@ namespace Opc.Ua.Server
}
///
- /// Calculates the StdDev, Variance, StdDev2 and Variance2 aggregates for the timeslice.
+ /// Calculates sample or population standard deviation/variance for the timeslice.
///
protected DataValue ComputeStdDev(TimeSlice slice, bool includeBounds, int valueType)
{
- // get the values in the slice.
- List values;
- if (includeBounds)
- {
- values = GetValuesWithSimpleBounds(slice);
- }
- else
- {
- values = GetValues(slice);
- }
+ // 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)
@@ -250,19 +242,24 @@ namespace Opc.Ua.Server
return GetNoDataValue(slice);
}
- // get the regions.
- List regions = GetRegionsInValueSet(values, false, true);
-
var xData = new List();
- double average = 0;
+ double sum = 0;
bool nonGoodDataExists = false;
- for (int ii = 0; ii < regions.Count; ii++)
+ for (int ii = 0; ii < values.Count; ii++)
{
- if (StatusCode.IsGood(regions[ii].StatusCode))
+ if (IsGood(values[ii]))
{
- xData.Add(regions[ii].StartValue);
- average += regions[ii].StartValue;
+ try
+ {
+ double sample = CastToDouble(values[ii]);
+ xData.Add(sample);
+ sum += sample;
+ }
+ catch (Exception)
+ {
+ nonGoodDataExists = true;
+ }
}
else
{
@@ -276,7 +273,7 @@ namespace Opc.Ua.Server
return GetNoDataValue(slice);
}
- average /= xData.Count;
+ double average = sum / xData.Count;
// calculate variance.
double variance = 0;
@@ -287,7 +284,6 @@ namespace Opc.Ua.Server
variance += error * error;
}
- // use the sample variance if bounds are included.
if (includeBounds)
{
// Spec part 13 v105 section 5.4.3.37 and subsequent
diff --git a/Libraries/Opc.Ua.Server/NodeManager/CustomNodeManager.cs b/Libraries/Opc.Ua.Server/NodeManager/CustomNodeManager.cs
index 0dd18bd972..ace5dd0cdb 100644
--- a/Libraries/Opc.Ua.Server/NodeManager/CustomNodeManager.cs
+++ b/Libraries/Opc.Ua.Server/NodeManager/CustomNodeManager.cs
@@ -1670,6 +1670,9 @@ namespace Opc.Ua.Server
continue;
}
+ // Capture the time immediately before reading the attribute. Values
+ // produced by the read use their source timestamp as the server
+ // timestamp; cached/static values receive a fresh server timestamp.
DateTime readTime = DateTime.UtcNow;
// read the attribute value.
@@ -1692,7 +1695,9 @@ namespace Opc.Ua.Server
{
value.SourceTimestamp = readTime;
}
- value.ServerTimestamp = value.SourceTimestamp >= readTime ? value.SourceTimestamp : readTime;
+ value.ServerTimestamp = value.SourceTimestamp >= readTime
+ ? value.SourceTimestamp
+ : readTime;
}
else if (value.ServerTimestamp == DateTime.MinValue)
{
@@ -2514,7 +2519,7 @@ namespace Opc.Ua.Server
}
// check timestamps to return.
- if (timestampsToReturn is < TimestampsToReturn.Source or > TimestampsToReturn.Neither)
+ if (timestampsToReturn is < TimestampsToReturn.Source or >= TimestampsToReturn.Neither)
{
throw new ServiceResultException(StatusCodes.BadTimestampsToReturnInvalid);
}
@@ -2527,7 +2532,7 @@ namespace Opc.Ua.Server
if (readRawModifiedDetails.StartTime == DateTime.MinValue &&
readRawModifiedDetails.EndTime == DateTime.MinValue)
{
- throw new ServiceResultException(StatusCodes.BadInvalidTimestampArgument);
+ throw new ServiceResultException(StatusCodes.BadHistoryOperationInvalid);
}
// if one is null the num values must be provided.
@@ -2536,7 +2541,7 @@ namespace Opc.Ua.Server
{
if (readRawModifiedDetails.NumValuesPerNode == 0)
{
- throw new ServiceResultException(StatusCodes.BadInvalidTimestampArgument);
+ throw new ServiceResultException(StatusCodes.BadHistoryOperationInvalid);
}
}
diff --git a/Libraries/Opc.Ua.Server/NodeManager/IConformanceContributor.cs b/Libraries/Opc.Ua.Server/NodeManager/IConformanceContributor.cs
new file mode 100644
index 0000000000..a07c05d3bc
--- /dev/null
+++ b/Libraries/Opc.Ua.Server/NodeManager/IConformanceContributor.cs
@@ -0,0 +1,50 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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 System.Collections.Generic;
+
+namespace Opc.Ua.Server
+{
+ ///
+ /// Implemented by a node manager or server feature which contributes
+ /// conformance units and server profiles.
+ ///
+ public interface IConformanceContributor
+ {
+ ///
+ /// Gets the supported conformance units.
+ ///
+ IReadOnlyList ConformanceUnits { get; }
+
+ ///
+ /// Gets the supported server profile URIs.
+ ///
+ IReadOnlyList ServerProfiles { get; }
+ }
+}
diff --git a/Libraries/Opc.Ua.Server/Server/ConformanceUnitsManager.cs b/Libraries/Opc.Ua.Server/Server/ConformanceUnitsManager.cs
new file mode 100644
index 0000000000..c6e6dab3de
--- /dev/null
+++ b/Libraries/Opc.Ua.Server/Server/ConformanceUnitsManager.cs
@@ -0,0 +1,205 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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 System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Opc.Ua.Server
+{
+ ///
+ /// Aggregates and publishes conformance units and server profiles supplied
+ /// by the features installed in a server.
+ ///
+ public class ConformanceUnitsManager : IDisposable
+ {
+ ///
+ /// Initializes a new instance of the manager.
+ ///
+ public ConformanceUnitsManager(IServerInternal server)
+ {
+ m_server = server ?? throw new ArgumentNullException(nameof(server));
+ m_conformanceUnits = [];
+ m_serverProfiles = new HashSet(StringComparer.Ordinal);
+ m_serverProfilesOrdered = [];
+ }
+
+ ///
+ /// Returns whether a conformance unit has been registered.
+ ///
+ public bool IsSupported(QualifiedName conformanceUnit)
+ {
+ if (QualifiedName.IsNull(conformanceUnit))
+ {
+ return false;
+ }
+
+ lock (m_lock)
+ {
+ return m_conformanceUnits.Contains(conformanceUnit);
+ }
+ }
+
+ ///
+ /// Registers the units and profiles supplied by a contributor.
+ ///
+ public void Register(IConformanceContributor contributor)
+ {
+ if (contributor == null)
+ {
+ throw new ArgumentNullException(nameof(contributor));
+ }
+
+ lock (m_lock)
+ {
+ IReadOnlyList conformanceUnits = contributor.ConformanceUnits;
+ if (conformanceUnits != null)
+ {
+ foreach (QualifiedName conformanceUnit in conformanceUnits)
+ {
+ if (!QualifiedName.IsNull(conformanceUnit))
+ {
+ m_conformanceUnits.Add(conformanceUnit);
+ }
+ }
+ }
+
+ IReadOnlyList serverProfiles = contributor.ServerProfiles;
+ if (serverProfiles != null)
+ {
+ foreach (string serverProfile in serverProfiles)
+ {
+ if (!string.IsNullOrEmpty(serverProfile))
+ {
+ if (m_serverProfiles.Add(serverProfile))
+ {
+ m_serverProfilesOrdered.Add(serverProfile);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Publishes the registered values in the ServerCapabilities object.
+ ///
+ public ValueTask PublishAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ QualifiedName[] conformanceUnits;
+ string[] serverProfiles;
+
+ lock (m_lock)
+ {
+ var orderedUnits = new List(m_conformanceUnits);
+ orderedUnits.Sort(
+ (left, right) => string.CompareOrdinal(left.Name, right.Name));
+ conformanceUnits = [.. orderedUnits];
+ serverProfiles = [.. m_serverProfilesOrdered];
+ }
+
+ lock (m_server.DiagnosticsLock)
+ {
+ ServerCapabilitiesState capabilities = m_server.DiagnosticsNodeManager
+ .FindPredefinedNode(
+ ObjectIds.Server_ServerCapabilities);
+
+ if (capabilities == null)
+ {
+ return default;
+ }
+
+ if (capabilities.ConformanceUnits != null)
+ {
+ capabilities.ConformanceUnits.Value = conformanceUnits;
+ capabilities.ConformanceUnits.ClearChangeMasks(
+ m_server.DefaultSystemContext,
+ false);
+ }
+
+ if (capabilities.ServerProfileArray != null && serverProfiles.Length > 0)
+ {
+ var mergedProfiles = new List();
+ var knownProfiles = new HashSet(StringComparer.Ordinal);
+
+ string[] configuredProfiles = capabilities.ServerProfileArray.Value;
+ if (configuredProfiles != null)
+ {
+ foreach (string profile in configuredProfiles)
+ {
+ if (!string.IsNullOrEmpty(profile) && knownProfiles.Add(profile))
+ {
+ mergedProfiles.Add(profile);
+ }
+ }
+ }
+
+ foreach (string profile in serverProfiles)
+ {
+ if (knownProfiles.Add(profile))
+ {
+ mergedProfiles.Add(profile);
+ }
+ }
+
+ capabilities.ServerProfileArray.Value = [.. mergedProfiles];
+ capabilities.ServerProfileArray.ClearChangeMasks(
+ m_server.DefaultSystemContext,
+ false);
+ }
+ }
+
+ return default;
+ }
+
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Releases resources held by the manager.
+ ///
+ protected virtual void Dispose(bool disposing)
+ {
+ // The manager does not own unmanaged resources.
+ }
+
+ private readonly object m_lock = new object();
+ private readonly IServerInternal m_server;
+ private readonly HashSet m_conformanceUnits;
+ private readonly HashSet m_serverProfiles;
+ private readonly List m_serverProfilesOrdered;
+ }
+}
diff --git a/Libraries/Opc.Ua.Server/Server/StandardServer.cs b/Libraries/Opc.Ua.Server/Server/StandardServer.cs
index 28a16e7628..1154597c0c 100644
--- a/Libraries/Opc.Ua.Server/Server/StandardServer.cs
+++ b/Libraries/Opc.Ua.Server/Server/StandardServer.cs
@@ -54,6 +54,12 @@ namespace Opc.Ua.Server
{
if (disposing)
{
+ if (m_conformanceUnitsManager != null)
+ {
+ Utils.SilentDispose(m_conformanceUnitsManager);
+ m_conformanceUnitsManager = null;
+ }
+
// halt any outstanding timer.
if (m_registrationTimer != null)
{
@@ -3104,6 +3110,16 @@ namespace Opc.Ua.Server
m_serverInternal.SetModellingRulesManager(
CreateModellingRulesManager(m_serverInternal, configuration));
+ // create and publish the conformance units and server profiles
+ // contributed by the installed node managers.
+ m_logger.LogInformation(
+ Utils.TraceMasks.StartStop,
+ "Server - CreateConformanceUnitsManager.");
+ m_conformanceUnitsManager = await CreateConformanceUnitsManagerAsync(
+ m_serverInternal,
+ configuration,
+ cancellationToken).ConfigureAwait(false);
+
// start the session manager.
m_logger.LogInformation(Utils.TraceMasks.StartStop, "Server - CreateSessionManager.");
ISessionManager sessionManager = CreateSessionManager(
@@ -3628,6 +3644,32 @@ namespace Opc.Ua.Server
return manager;
}
+ ///
+ /// Creates and publishes the manager used to aggregate conformance units
+ /// and server profiles from the installed node managers.
+ ///
+ protected virtual async ValueTask CreateConformanceUnitsManagerAsync(
+ IServerInternal server,
+ ApplicationConfiguration configuration,
+ CancellationToken cancellationToken = default)
+ {
+ var manager = new ConformanceUnitsManager(server);
+
+ foreach (IAsyncNodeManager nodeManager in server.NodeManager.AsyncNodeManagers)
+ {
+ IConformanceContributor contributor = nodeManager as IConformanceContributor ??
+ nodeManager.SyncNodeManager as IConformanceContributor;
+
+ if (contributor != null)
+ {
+ manager.Register(contributor);
+ }
+ }
+
+ await manager.PublishAsync(cancellationToken).ConfigureAwait(false);
+ return manager;
+ }
+
///
/// Creates the resource manager for the server.
///
@@ -3836,6 +3878,7 @@ namespace Opc.Ua.Server
private bool m_registeredWithDiscoveryServer;
private int m_minNonceLength;
private bool m_useRegisterServer2;
+ private ConformanceUnitsManager m_conformanceUnitsManager;
private readonly List m_nodeManagerFactories = [];
private readonly List m_asyncNodeManagerFactories = [];
}
diff --git a/Stack/Opc.Ua.Core/Stack/Tcp/TcpServerChannel.cs b/Stack/Opc.Ua.Core/Stack/Tcp/TcpServerChannel.cs
index e5f2f3beb1..afa60d7be4 100644
--- a/Stack/Opc.Ua.Core/Stack/Tcp/TcpServerChannel.cs
+++ b/Stack/Opc.Ua.Core/Stack/Tcp/TcpServerChannel.cs
@@ -595,7 +595,8 @@ namespace Opc.Ua.Bindings
// If the certificate structure, signature and trust list checks pass,
// return the other specific validation errors instead of BadSecurityChecksFailed
- if (e.InnerException is ServiceResultException innerException)
+ if ((e as ServiceResultException ?? e.InnerException as ServiceResultException)
+ is ServiceResultException innerException)
{
if (innerException.StatusCode == StatusCodes.BadCertificateUntrusted ||
innerException.StatusCode == StatusCodes.BadCertificateChainIncomplete ||
diff --git a/Stack/Opc.Ua.Types/Utils/TypeInfo.cs b/Stack/Opc.Ua.Types/Utils/TypeInfo.cs
index 4d1acd9a85..3b3dfa7210 100644
--- a/Stack/Opc.Ua.Types/Utils/TypeInfo.cs
+++ b/Stack/Opc.Ua.Types/Utils/TypeInfo.cs
@@ -1059,11 +1059,9 @@ namespace Opc.Ua
return null;
case DataTypes.BaseDataType:
- if (typeInfo.BuiltInType != BuiltInType.Variant)
- {
- return typeInfo;
- }
- break;
+ // Every value is an instance of BaseDataType, including
+ // Variant collections which contain arrays or matrices.
+ return typeInfo;
}
}
diff --git a/Tests/Opc.Ua.Core.Tests/Stack/State/ConditionStateTests.cs b/Tests/Opc.Ua.Core.Tests/Stack/State/ConditionStateTests.cs
index 2a9da73f5c..ff6515216a 100644
--- a/Tests/Opc.Ua.Core.Tests/Stack/State/ConditionStateTests.cs
+++ b/Tests/Opc.Ua.Core.Tests/Stack/State/ConditionStateTests.cs
@@ -203,6 +203,43 @@ namespace Opc.Ua.Core.Tests.Stack.State
Assert.That(condition.ConfirmedState.ChangeMasks, Is.EqualTo(NodeStateChangeMasks.None));
}
+ [Test]
+ public void EmptyAcknowledgeAndConfirmCommentsPreserveExistingComment()
+ {
+ var condition = new TestableAcknowledgeableConditionState();
+ condition.Create(m_context, new NodeId(1), "AckCondition", null, true);
+ condition.BranchId.Value = NodeId.Null;
+ condition.SetEnableState(m_context, true);
+ var existingComment = new LocalizedText("en", "existing comment");
+ condition.SetComment(m_context, existingComment, "tester");
+ condition.SetAcknowledgedState(m_context, false);
+
+ ServiceResult acknowledge = condition.CallAcknowledge(
+ m_context,
+ new byte[] { 1 },
+ LocalizedText.Null);
+
+ Assert.That(ServiceResult.IsGood(acknowledge), Is.True);
+ Assert.That(condition.Comment.Value, Is.EqualTo(existingComment));
+
+ ServiceResult confirm = condition.CallConfirm(
+ m_context,
+ new byte[] { 2 },
+ new LocalizedText((string)null, (string)null));
+
+ Assert.That(ServiceResult.IsGood(confirm), Is.True);
+ Assert.That(condition.Comment.Value, Is.EqualTo(existingComment));
+
+ condition.SetAcknowledgedState(m_context, false);
+ var replacement = new LocalizedText("en", "replacement comment");
+ ServiceResult replacementResult = condition.CallAcknowledge(
+ m_context,
+ new byte[] { 3 },
+ replacement);
+ Assert.That(ServiceResult.IsGood(replacementResult), Is.True);
+ Assert.That(condition.Comment.Value, Is.EqualTo(replacement));
+ }
+
///
/// Test that SetShelvingState updates the timestamp and clears change masks.
///
@@ -254,6 +291,31 @@ namespace Opc.Ua.Core.Tests.Stack.State
Assert.That(alarm.ActiveState.ChangeMasks, Is.EqualTo(NodeStateChangeMasks.None));
}
+ private sealed class TestableAcknowledgeableConditionState
+ : AcknowledgeableConditionState
+ {
+ public TestableAcknowledgeableConditionState()
+ : base(null)
+ {
+ }
+
+ public ServiceResult CallAcknowledge(
+ ISystemContext context,
+ byte[] eventId,
+ LocalizedText comment)
+ {
+ return OnAcknowledgeCalled(context, null, NodeId, eventId, comment);
+ }
+
+ public ServiceResult CallConfirm(
+ ISystemContext context,
+ byte[] eventId,
+ LocalizedText comment)
+ {
+ return OnConfirmCalled(context, null, NodeId, eventId, comment);
+ }
+ }
+
///
/// Test that UpdateStateAfterEnable calls EvaluateRetainStateOnEnable
/// and that the default implementation sets Retain based on GetRetainState.
diff --git a/Tests/Opc.Ua.Server.Tests/AggregateCalculatorCoverageTests.cs b/Tests/Opc.Ua.Server.Tests/AggregateCalculatorCoverageTests.cs
new file mode 100644
index 0000000000..90495e68a9
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/AggregateCalculatorCoverageTests.cs
@@ -0,0 +1,367 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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 System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Server.Tests
+{
+ ///
+ /// Tests for the shared helpers on the base class,
+ /// in particular the public stepped/sloped interpolation routines and the reverse-time
+ /// and sloped-extrapolation processing branches.
+ ///
+ [TestFixture]
+ [Category("Aggregators")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ [Parallelizable]
+ public class AggregateCalculatorCoverageTests
+ {
+ private ITelemetryContext m_telemetry;
+
+ [SetUp]
+ public void SetUp()
+ {
+ m_telemetry = NUnitTelemetryContext.Create();
+ }
+
+ private static DataValue Good(double value, DateTime timestamp)
+ {
+ return new DataValue(new Variant(value), StatusCodes.Good, timestamp, timestamp);
+ }
+
+ [Test]
+ public void SteppedInterpolateWithGoodEarlyBoundReturnsInterpolatedGood()
+ {
+ var timestamp = new DateTime(2024, 1, 1, 0, 0, 5);
+ DataValue earlyBound = Good(42.0, new DateTime(2024, 1, 1, 0, 0, 0));
+
+ DataValue result = AggregateCalculator.SteppedInterpolate(timestamp, earlyBound);
+
+ Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(42.0));
+ Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Interpolated), Is.True);
+ }
+
+ [Test]
+ public void SteppedInterpolateWithBadEarlyBoundReturnsBadNoData()
+ {
+ var timestamp = new DateTime(2024, 1, 1, 0, 0, 5);
+ var earlyBound = new DataValue(
+ new Variant(1.0), StatusCodes.Bad, timestamp, timestamp);
+
+ DataValue result = AggregateCalculator.SteppedInterpolate(timestamp, earlyBound);
+
+ Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.BadNoData));
+ }
+
+ [Test]
+ public void SteppedInterpolateWithUncertainEarlyBoundReturnsUncertain()
+ {
+ var timestamp = new DateTime(2024, 1, 1, 0, 0, 5);
+ var earlyBound = new DataValue(
+ new Variant(1.0), StatusCodes.UncertainDataSubNormal, timestamp, timestamp);
+
+ DataValue result = AggregateCalculator.SteppedInterpolate(timestamp, earlyBound);
+
+ Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.UncertainDataSubNormal));
+ Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Interpolated), Is.True);
+ }
+
+ [Test]
+ public void SlopedInterpolateWithBadEarlyBoundReturnsBadNoData()
+ {
+ var timestamp = new DateTime(2024, 1, 1, 0, 0, 5);
+ var earlyBound = new DataValue(
+ new Variant(1.0), StatusCodes.Bad,
+ new DateTime(2024, 1, 1, 0, 0, 0), new DateTime(2024, 1, 1, 0, 0, 0));
+ DataValue lateBound = Good(10.0, new DateTime(2024, 1, 1, 0, 0, 10));
+
+ DataValue result = AggregateCalculator.SlopedInterpolate(timestamp, earlyBound, lateBound);
+
+ Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.BadNoData));
+ }
+
+ [Test]
+ public void SlopedInterpolateWithBadLateBoundFallsBackToStepped()
+ {
+ var timestamp = new DateTime(2024, 1, 1, 0, 0, 5);
+ DataValue earlyBound = Good(7.0, new DateTime(2024, 1, 1, 0, 0, 0));
+ var lateBound = new DataValue(
+ new Variant(10.0), StatusCodes.Bad,
+ new DateTime(2024, 1, 1, 0, 0, 10), new DateTime(2024, 1, 1, 0, 0, 10));
+
+ DataValue result = AggregateCalculator.SlopedInterpolate(timestamp, earlyBound, lateBound);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(7.0));
+ Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.UncertainDataSubNormal));
+ }
+
+ [Test]
+ public void SlopedInterpolateWithGoodBoundsInterpolatesLinearly()
+ {
+ var early = new DateTime(2024, 1, 1, 0, 0, 0);
+ var late = new DateTime(2024, 1, 1, 0, 0, 10);
+ var timestamp = new DateTime(2024, 1, 1, 0, 0, 5);
+ DataValue earlyBound = Good(0.0, early);
+ DataValue lateBound = Good(100.0, late);
+
+ DataValue result = AggregateCalculator.SlopedInterpolate(timestamp, earlyBound, lateBound);
+
+ Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(50.0).Within(0.0001));
+ Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Interpolated), Is.True);
+ }
+
+ [Test]
+ public void SlopedInterpolateWithUncertainBoundMarksUncertain()
+ {
+ var early = new DateTime(2024, 1, 1, 0, 0, 0);
+ var late = new DateTime(2024, 1, 1, 0, 0, 10);
+ var timestamp = new DateTime(2024, 1, 1, 0, 0, 5);
+ var earlyBound = new DataValue(
+ new Variant(0.0), StatusCodes.UncertainDataSubNormal, early, early);
+ DataValue lateBound = Good(100.0, late);
+
+ DataValue result = AggregateCalculator.SlopedInterpolate(timestamp, earlyBound, lateBound);
+
+ Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.UncertainDataSubNormal));
+ }
+
+ [Test]
+ public void SlopedInterpolateWithNonNumericValuesReturnsBadTypeMismatch()
+ {
+ var early = new DateTime(2024, 1, 1, 0, 0, 0);
+ var late = new DateTime(2024, 1, 1, 0, 0, 10);
+ var timestamp = new DateTime(2024, 1, 1, 0, 0, 5);
+ var earlyBound = new DataValue(new Variant("not-a-number"), StatusCodes.Good, early, early);
+ var lateBound = new DataValue(new Variant("also-not"), StatusCodes.Good, late, late);
+
+ DataValue result = AggregateCalculator.SlopedInterpolate(timestamp, earlyBound, lateBound);
+
+ Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.BadTypeMismatch));
+ }
+
+ [Test]
+ public void HasEndTimePassedReflectsProcessingWindow()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+ var configuration = new AggregateConfiguration
+ {
+ TreatUncertainAsBad = false,
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ UseSlopedExtrapolation = false
+ };
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Average, startTime, endTime, 10000, false, configuration, m_telemetry);
+
+ calculator.QueueRawValue(new DataValue(new Variant(1.0), StatusCodes.Good, startTime, startTime));
+ calculator.TryGetProcessedValue(false, out _);
+
+ Assert.That(calculator.HasEndTimePassed(startTime.AddMilliseconds(5000)), Is.False);
+ Assert.That(calculator.HasEndTimePassed(endTime.AddMilliseconds(5000)), Is.True);
+ }
+
+ [Test]
+ public void ReverseTimeAverageComputesOverInterval()
+ {
+ // endTime < startTime exercises the TimeFlowsBackward branches in the base class.
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 12);
+ var endTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ var configuration = new AggregateConfiguration
+ {
+ TreatUncertainAsBad = false,
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ UseSlopedExtrapolation = false
+ };
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Average, startTime, endTime, 12000, false, configuration, m_telemetry);
+
+ for (int i = 0; i < 6; i++)
+ {
+ DateTime ts = endTime.AddMilliseconds(500 + (i * 2000));
+ calculator.QueueRawValue(Good(10.0 + i, ts));
+ }
+
+ DataValue result = default;
+ bool any = false;
+ while (calculator.TryGetProcessedValue(true, out DataValue value))
+ {
+ if (!any)
+ {
+ result = value;
+ any = true;
+ }
+ }
+
+ Assert.That(any, Is.True);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void InterpolativeExactGoodBoundaryPreservesRawValue(bool reverse)
+ {
+ DataValue result = ComputeInterpolativeBoundary(StatusCodes.GoodClamped, reverse);
+ DateTime expectedTimestamp = new(2024, 1, 1, 0, 0, 10);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(123.0));
+ Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.GoodClamped));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Raw));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(expectedTimestamp));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(expectedTimestamp));
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void InterpolativeExactUncertainBoundaryPreservesUncertainRawValue(bool reverse)
+ {
+ DataValue result = ComputeInterpolativeBoundary(
+ StatusCodes.UncertainLastUsableValue,
+ reverse);
+ DateTime expectedTimestamp = new(2024, 1, 1, 0, 0, 10);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(123.0));
+ Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.UncertainLastUsableValue));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Raw));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(expectedTimestamp));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(expectedTimestamp));
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void InterpolativeExactBadBoundaryUsesSurroundingGoodValues(bool reverse)
+ {
+ DataValue result = ComputeInterpolativeBoundary(StatusCodes.BadSensorFailure, reverse);
+ DateTime expectedTimestamp = new(2024, 1, 1, 0, 0, 10);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(100.0));
+ Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.UncertainDataSubNormal));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Interpolated));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(expectedTimestamp));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(expectedTimestamp));
+ }
+
+ [Test]
+ public void SlopedExtrapolationConfigurationProducesInterpolatedResult()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(20000);
+ var configuration = new AggregateConfiguration
+ {
+ TreatUncertainAsBad = false,
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ UseSlopedExtrapolation = true
+ };
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Interpolative, startTime, endTime, 5000, false, configuration, m_telemetry);
+
+ var values = new List
+ {
+ Good(0.0, startTime.AddMilliseconds(1000)),
+ Good(40.0, startTime.AddMilliseconds(9000))
+ };
+ foreach (DataValue value in values)
+ {
+ calculator.QueueRawValue(value);
+ }
+
+ bool any = false;
+ while (calculator.TryGetProcessedValue(true, out DataValue _))
+ {
+ any = true;
+ }
+
+ Assert.That(any, Is.True);
+ }
+
+ private DataValue ComputeInterpolativeBoundary(
+ StatusCode exactStatus,
+ bool reverse)
+ {
+ var exactTime = new DateTime(2024, 1, 1, 0, 0, 10);
+ DateTime startTime = exactTime;
+ DateTime endTime = reverse
+ ? new DateTime(2024, 1, 1, 0, 0, 5)
+ : new DateTime(2024, 1, 1, 0, 0, 15);
+ var configuration = new AggregateConfiguration
+ {
+ TreatUncertainAsBad = true,
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ UseSlopedExtrapolation = false
+ };
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Interpolative,
+ startTime,
+ endTime,
+ 5000,
+ false,
+ configuration,
+ m_telemetry);
+
+ DataValue[] values =
+ [
+ Good(0.0, new DateTime(2024, 1, 1, 0, 0, 0)),
+ Good(50.0, new DateTime(2024, 1, 1, 0, 0, 5)),
+ new DataValue(new Variant(123.0), exactStatus, exactTime, exactTime),
+ Good(150.0, new DateTime(2024, 1, 1, 0, 0, 15)),
+ Good(200.0, new DateTime(2024, 1, 1, 0, 0, 20))
+ ];
+ if (reverse)
+ {
+ for (int index = values.Length - 1; index >= 0; index--)
+ {
+ Assert.That(calculator.QueueRawValue(values[index]), Is.True);
+ }
+ }
+ else
+ {
+ foreach (DataValue value in values)
+ {
+ Assert.That(calculator.QueueRawValue(value), Is.True);
+ }
+ }
+
+ Assert.That(calculator.TryGetProcessedValue(true, out DataValue result), Is.True);
+ Assert.That(calculator.TryGetProcessedValue(true, out _), Is.False);
+ return result;
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/AggregateCalculatorTests.cs b/Tests/Opc.Ua.Server.Tests/AggregateCalculatorTests.cs
index f1c9921abf..8e1c4c8ae5 100644
--- a/Tests/Opc.Ua.Server.Tests/AggregateCalculatorTests.cs
+++ b/Tests/Opc.Ua.Server.Tests/AggregateCalculatorTests.cs
@@ -147,11 +147,6 @@ namespace Opc.Ua.Server.Tests
/// Test StandardDeviationPopulation with example from OPC UA Part 13 v1.05 Section A.35
/// Example: Values [10, 20, 30, 40, 50] should give population std dev ≈ 14.142
///
- ///
- /// Implementation Note: Population aggregates exclude the last value when using GetValues().
- /// To test the spec example with 5 values, we provide 6 values where the last is a duplicate.
- /// The implementation will use the first 5 values for the calculation.
- ///
///
[Test]
public void StandardDeviationPopulation_SpecExample()
@@ -159,8 +154,7 @@ namespace Opc.Ua.Server.Tests
// Arrange
var startTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime firstValueTime = startTime.AddMilliseconds(500);
- // Providing 6 values where last is duplicate - implementation will use first 5
- double[] values = [10, 20, 30, 40, 50, 50];
+ double[] values = [10, 20, 30, 40, 50];
List dataValues = CreateDataValues(firstValueTime, values, 2000);
DateTime endTime = startTime.AddSeconds(12);
@@ -186,11 +180,6 @@ namespace Opc.Ua.Server.Tests
///
/// Test StandardDeviationPopulation with single value - should return 0
- ///
- /// Implementation Note: To test single value behavior with population aggregates,
- /// we provide 2 identical values since the implementation excludes the last value.
- /// This results in 1 value being used in the calculation, which correctly returns stddev=0.
- ///
///
[Test]
public void StandardDeviationPopulation_SingleValue_ReturnsZero()
@@ -198,8 +187,7 @@ namespace Opc.Ua.Server.Tests
// Arrange
var startTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime firstValueTime = startTime.AddMilliseconds(500);
- // Providing 2 identical values - implementation will use first 1
- double[] values = [42.5, 42.5];
+ double[] values = [42.5];
List dataValues = CreateDataValues(firstValueTime, values, 2000);
DateTime endTime = startTime.AddSeconds(5);
@@ -348,11 +336,6 @@ namespace Opc.Ua.Server.Tests
/// Test VariancePopulation with example from OPC UA Part 13 v1.05 Section A.37
/// Example: Values [10, 20, 30, 40, 50] should give population variance = 200
///
- ///
- /// Implementation Note: Population aggregates exclude the last value when using GetValues().
- /// To test the spec example with 5 values, we provide 6 values where the last is a duplicate.
- /// The implementation will use the first 5 values for the calculation.
- ///
///
[Test]
public void VariancePopulation_SpecExample()
@@ -360,8 +343,7 @@ namespace Opc.Ua.Server.Tests
// Arrange
var startTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime firstValueTime = startTime.AddMilliseconds(500);
- // Providing 6 values where last is duplicate - implementation will use first 5
- double[] values = [10, 20, 30, 40, 50, 50];
+ double[] values = [10, 20, 30, 40, 50];
List dataValues = CreateDataValues(firstValueTime, values, 2000);
DateTime endTime = startTime.AddSeconds(12);
@@ -387,11 +369,6 @@ namespace Opc.Ua.Server.Tests
///
/// Test VariancePopulation with single value - should return 0
- ///
- /// Implementation Note: To test single value behavior with population aggregates,
- /// we provide 2 identical values since the implementation excludes the last value.
- /// This results in 1 value being used in the calculation, which correctly returns variance=0.
- ///
///
[Test]
public void VariancePopulation_SingleValue_ReturnsZero()
@@ -399,8 +376,7 @@ namespace Opc.Ua.Server.Tests
// Arrange
var startTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime firstValueTime = startTime.AddMilliseconds(500);
- // Providing 2 identical values - implementation will use first 1
- double[] values = [42.5, 42.5];
+ double[] values = [42.5];
List dataValues = CreateDataValues(firstValueTime, values, 2000);
DateTime endTime = startTime.AddSeconds(5);
@@ -657,5 +633,27 @@ namespace Opc.Ua.Server.Tests
Assert.That(sampleVariance, Is.GreaterThanOrEqualTo(populationVariance),
"Sample variance should be greater than or equal to population variance");
}
+
+ [Test]
+ public void InterpolativeReturnsValueAtIntervalStart()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(6000);
+ double[] values = [10, 20, 30];
+ List dataValues = CreateDataValues(startTime, values, 2000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Interpolative,
+ dataValues,
+ startTime,
+ endTime,
+ 6000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That(
+ Convert.ToDouble(result.Value, CultureInfo.InvariantCulture),
+ Is.EqualTo(10.0).Within(0.0001));
+ }
}
}
diff --git a/Tests/Opc.Ua.Server.Tests/AggregateCttRegressionTests.cs b/Tests/Opc.Ua.Server.Tests/AggregateCttRegressionTests.cs
new file mode 100644
index 0000000000..20d543eba8
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/AggregateCttRegressionTests.cs
@@ -0,0 +1,571 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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 System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Server.Tests
+{
+ ///
+ /// Part 11 and Part 13 oracle tests for aggregate scenarios exercised by the CTT aggregate units.
+ ///
+ [TestFixture]
+ [Category("Aggregators")]
+ [Category("Historian")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ [Parallelizable]
+ public sealed class AggregateCttRegressionTests
+ {
+ [TestCase("Minimum", false)]
+ [TestCase("Maximum", false)]
+ [TestCase("Range", false)]
+ [TestCase("TimeAverage", false)]
+ [TestCase("Minimum", true)]
+ [TestCase("Maximum", true)]
+ [TestCase("Range", true)]
+ [TestCase("TimeAverage", true)]
+ public async Task DirectAndLiveTenIntervalResultsMatchPart13OracleAsync(
+ string aggregateName,
+ bool reverse)
+ {
+ NodeId aggregateId = GetAggregateId(aggregateName);
+ List rawValues = CreateSharedRawValues();
+ DateTime startTime = reverse ? AtSeconds(100) : s_baseTime;
+ DateTime endTime = reverse ? s_baseTime : AtSeconds(100);
+ AggregateConfiguration configuration = CreateConfiguration();
+
+ List direct = RunDirect(
+ aggregateId,
+ rawValues,
+ startTime,
+ endTime,
+ 10_000,
+ configuration);
+
+ using var harness = new AggregateHarness();
+ List live = await harness.ReadProcessedAsync(
+ aggregateId,
+ rawValues,
+ startTime,
+ endTime,
+ 10_000,
+ configuration).ConfigureAwait(false);
+
+ AssertSharedTenIntervalResults(direct, aggregateName, reverse);
+ AssertSharedTenIntervalResults(live, aggregateName, reverse);
+ }
+
+ [TestCase("Minimum", 0.0)]
+ [TestCase("Maximum", 19.0)]
+ [TestCase("Range", 19.0)]
+ [TestCase("TimeAverage", 9.75)]
+ public async Task DirectAndLiveIntervalEqualToRangeReturnsOneValueAsync(
+ string aggregateName,
+ double expected)
+ {
+ NodeId aggregateId = GetAggregateId(aggregateName);
+ List rawValues = CreateSharedRawValues();
+ AggregateConfiguration configuration = CreateConfiguration();
+ DateTime endTime = AtSeconds(100);
+
+ List direct = RunDirect(
+ aggregateId,
+ rawValues,
+ s_baseTime,
+ endTime,
+ 100_000,
+ configuration);
+
+ using var harness = new AggregateHarness();
+ List live = await harness.ReadProcessedAsync(
+ aggregateId,
+ rawValues,
+ s_baseTime,
+ endTime,
+ 100_000,
+ configuration).ConfigureAwait(false);
+
+ AssertSingleNumericResult(direct, expected, s_baseTime, AggregateBits.Calculated);
+ AssertSingleNumericResult(live, expected, s_baseTime, AggregateBits.Calculated);
+ }
+
+ [TestCase("Start", false, 0.0, 0, AggregateBits.Raw)]
+ [TestCase("End", false, 5.0, 5, AggregateBits.Raw)]
+ [TestCase("StartBound", false, 0.0, 0, AggregateBits.Raw)]
+ [TestCase("EndBound", false, 10.0, 0, AggregateBits.Calculated)]
+ [TestCase("Start", true, 5.0, 5, AggregateBits.Raw)]
+ [TestCase("End", true, 10.0, 10, AggregateBits.Raw)]
+ [TestCase("StartBound", true, 10.0, 10, AggregateBits.Raw)]
+ [TestCase("EndBound", true, 0.0, 10, AggregateBits.Calculated)]
+ public async Task DirectAndLiveStartEndFamiliesMatchPart13OracleAsync(
+ string aggregateName,
+ bool reverse,
+ double expected,
+ int expectedTimestampSeconds,
+ AggregateBits expectedBits)
+ {
+ NodeId aggregateId = GetAggregateId(aggregateName);
+ List rawValues =
+ [
+ CreateValue(0, StatusCodes.Good, 0),
+ CreateValue(5, StatusCodes.Good, 5),
+ CreateValue(10, StatusCodes.Good, 10)
+ ];
+ DateTime startTime = reverse ? AtSeconds(10) : s_baseTime;
+ DateTime endTime = reverse ? s_baseTime : AtSeconds(10);
+ AggregateConfiguration configuration = CreateConfiguration();
+
+ List direct = RunDirect(
+ aggregateId,
+ rawValues,
+ startTime,
+ endTime,
+ 10_000,
+ configuration);
+
+ using var harness = new AggregateHarness();
+ List live = await harness.ReadProcessedAsync(
+ aggregateId,
+ rawValues,
+ startTime,
+ endTime,
+ 10_000,
+ configuration).ConfigureAwait(false);
+
+ DateTime expectedTimestamp = AtSeconds(expectedTimestampSeconds);
+ AssertSingleNumericResult(direct, expected, expectedTimestamp, expectedBits);
+ AssertSingleNumericResult(live, expected, expectedTimestamp, expectedBits);
+ }
+
+ [TestCase("PercentGood", false, 50.0)]
+ [TestCase("PercentBad", false, 50.0)]
+ [TestCase("PercentGood", true, 25.0)]
+ [TestCase("PercentBad", true, 75.0)]
+ public async Task DirectAndLivePercentAggregatesHonorExplicitUncertainConfigurationAsync(
+ string aggregateName,
+ bool treatUncertainAsBad,
+ double expected)
+ {
+ NodeId aggregateId = GetAggregateId(aggregateName);
+ List rawValues =
+ [
+ CreateValue(1, StatusCodes.Good, 0),
+ CreateValue(1, StatusCodes.Uncertain, 5),
+ CreateValue(1, StatusCodes.Bad, 10),
+ CreateValue(1, StatusCodes.Bad, 15),
+ CreateValue(1, StatusCodes.Good, 20)
+ ];
+ AggregateConfiguration configuration = CreateConfiguration(treatUncertainAsBad);
+ DateTime endTime = AtSeconds(20);
+
+ List direct = RunDirect(
+ aggregateId,
+ rawValues,
+ s_baseTime,
+ endTime,
+ 20_000,
+ configuration);
+
+ using var harness = new AggregateHarness();
+ List live = await harness.ReadProcessedAsync(
+ aggregateId,
+ rawValues,
+ s_baseTime,
+ endTime,
+ 20_000,
+ configuration).ConfigureAwait(false);
+
+ AssertSingleNumericResult(direct, expected, s_baseTime, AggregateBits.Calculated);
+ AssertSingleNumericResult(live, expected, s_baseTime, AggregateBits.Calculated);
+ }
+
+ [TestCase("WorstQuality")]
+ [TestCase("WorstQuality2")]
+ public async Task DirectAndLiveWorstQualitySetMultipleValuesForRepeatedGoodQualityAsync(
+ string aggregateName)
+ {
+ NodeId aggregateId = GetAggregateId(aggregateName);
+ List rawValues =
+ [
+ CreateValue(1, StatusCodes.Good, 0),
+ CreateValue(2, StatusCodes.Good, 5),
+ CreateValue(3, StatusCodes.Good, 10)
+ ];
+ AggregateConfiguration configuration = CreateConfiguration();
+ DateTime endTime = AtSeconds(10);
+
+ List direct = RunDirect(
+ aggregateId,
+ rawValues,
+ s_baseTime,
+ endTime,
+ 10_000,
+ configuration);
+
+ using var harness = new AggregateHarness();
+ List live = await harness.ReadProcessedAsync(
+ aggregateId,
+ rawValues,
+ s_baseTime,
+ endTime,
+ 10_000,
+ configuration).ConfigureAwait(false);
+
+ AssertWorstQualityResult(direct);
+ AssertWorstQualityResult(live);
+ }
+
+ [TestCase("DurationInStateZero", false)]
+ [TestCase("DurationInStateNonZero", false)]
+ [TestCase("DurationInStateZero", true)]
+ [TestCase("DurationInStateNonZero", true)]
+ public async Task DirectAndLiveDurationInStateMatchPart13OracleAsync(
+ string aggregateName,
+ bool reverse)
+ {
+ NodeId aggregateId = GetAggregateId(aggregateName);
+ List rawValues =
+ [
+ CreateValue(0, StatusCodes.Good, 0),
+ CreateValue(1, StatusCodes.Good, 5),
+ CreateValue(0, StatusCodes.Good, 10),
+ CreateValue(2, StatusCodes.Good, 15),
+ CreateValue(0, StatusCodes.Good, 20)
+ ];
+ DateTime startTime = reverse ? AtSeconds(20) : s_baseTime;
+ DateTime endTime = reverse ? s_baseTime : AtSeconds(20);
+ AggregateConfiguration configuration = CreateConfiguration();
+
+ List direct = RunDirect(
+ aggregateId,
+ rawValues,
+ startTime,
+ endTime,
+ 20_000,
+ configuration);
+
+ using var harness = new AggregateHarness();
+ List live = await harness.ReadProcessedAsync(
+ aggregateId,
+ rawValues,
+ startTime,
+ endTime,
+ 20_000,
+ configuration).ConfigureAwait(false);
+
+ DateTime expectedTimestamp = reverse ? AtSeconds(20) : s_baseTime;
+ AssertSingleNumericResult(direct, 10_000, expectedTimestamp, AggregateBits.Calculated);
+ AssertSingleNumericResult(live, 10_000, expectedTimestamp, AggregateBits.Calculated);
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public async Task DirectAndLiveNumberOfTransitionsMatchesPart13BoundaryRulesAsync(bool reverse)
+ {
+ List rawValues =
+ [
+ CreateValue(5, StatusCodes.Good, 0),
+ CreateValue(5, StatusCodes.Good, 1),
+ CreateValue(6, StatusCodes.Good, 2),
+ CreateValue(7, StatusCodes.Good, 3)
+ ];
+ DateTime startTime = reverse ? AtSeconds(3) : s_baseTime;
+ DateTime endTime = reverse ? s_baseTime : AtSeconds(3);
+ AggregateConfiguration configuration = CreateConfiguration();
+
+ List direct = RunDirect(
+ ObjectIds.AggregateFunction_NumberOfTransitions,
+ rawValues,
+ startTime,
+ endTime,
+ 3_000,
+ configuration);
+
+ using var harness = new AggregateHarness();
+ List live = await harness.ReadProcessedAsync(
+ ObjectIds.AggregateFunction_NumberOfTransitions,
+ rawValues,
+ startTime,
+ endTime,
+ 3_000,
+ configuration).ConfigureAwait(false);
+
+ AssertSingleNumericResult(direct, 2, startTime, AggregateBits.Calculated);
+ AssertSingleNumericResult(live, 2, startTime, AggregateBits.Calculated);
+ }
+
+ [Test]
+ public async Task LiveProcessedReadWithEqualTimesReturnsBadInvalidArgumentAsync()
+ {
+ using var harness = new AggregateHarness();
+ var result = new HistoryReadResult();
+
+ ServiceResult error = await harness.DispatchProcessedAsync(
+ ObjectIds.AggregateFunction_Average,
+ [],
+ s_baseTime,
+ s_baseTime,
+ 1,
+ CreateConfiguration(),
+ result).ConfigureAwait(false);
+
+ Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
+ }
+
+ private static void AssertSharedTenIntervalResults(
+ List results,
+ string aggregateName,
+ bool reverse)
+ {
+ Assert.That(results, Has.Count.EqualTo(10));
+
+ for (int index = 0; index < results.Count; index++)
+ {
+ int interval = reverse ? 9 - index : index;
+ double expected = aggregateName switch
+ {
+ "Minimum" => interval,
+ "Maximum" => reverse ? interval + 11 : interval + 10,
+ "Range" => reverse ? 11 : 10,
+ "TimeAverage" => interval + 5.25,
+ _ => throw new ArgumentOutOfRangeException(nameof(aggregateName))
+ };
+ DateTime timestamp = reverse
+ ? AtSeconds(100 - (index * 10))
+ : AtSeconds(index * 10);
+ // Part 13 §5.4.3.11: Maximum reports Good, Raw when the maximum sample sits on
+ // the interval start. In this dataset the maximum is always at the interval
+ // start for both forward (earlier bound) and reverse (later bound) reads, so it
+ // is Raw in both directions; every other aggregate here is Calculated.
+ AggregateBits expectedBits =
+ aggregateName == "Maximum"
+ ? AggregateBits.Raw
+ : AggregateBits.Calculated;
+
+ AssertNumericResult(results[index], expected, timestamp, expectedBits, index);
+ }
+ }
+
+ private static void AssertSingleNumericResult(
+ List results,
+ double expected,
+ DateTime expectedTimestamp,
+ AggregateBits expectedBits)
+ {
+ Assert.That(results, Has.Count.EqualTo(1));
+ AssertNumericResult(results[0], expected, expectedTimestamp, expectedBits, 0);
+ }
+
+ private static void AssertNumericResult(
+ DataValue result,
+ double expected,
+ DateTime expectedTimestamp,
+ AggregateBits expectedBits,
+ int index)
+ {
+ Assert.That(
+ result.WrappedValue.ConvertToDouble().GetDouble(),
+ Is.EqualTo(expected).Within(0.000_001),
+ $"value at index {index}");
+ Assert.That(result.SourceTimestamp, Is.EqualTo(expectedTimestamp), $"timestamp at index {index}");
+ Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.Good), $"status at index {index}");
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(expectedBits), $"aggregate bits at index {index}");
+ }
+
+ private static void AssertWorstQualityResult(List results)
+ {
+ Assert.That(results, Has.Count.EqualTo(1));
+ DataValue result = results[0];
+ Assert.That(result.WrappedValue.TryGetValue(out StatusCode worstQuality), Is.True);
+ Assert.That(worstQuality, Is.EqualTo(StatusCodes.Good));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(s_baseTime));
+ Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.Good));
+ Assert.That(
+ result.StatusCode.AggregateBits,
+ Is.EqualTo(AggregateBits.Calculated | AggregateBits.MultipleValues));
+ }
+
+ private static List RunDirect(
+ NodeId aggregateId,
+ List rawValues,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ AggregateConfiguration configuration)
+ {
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ aggregateId,
+ startTime,
+ endTime,
+ processingInterval,
+ false,
+ configuration,
+ NUnitTelemetryContext.Create())!;
+
+ if (endTime < startTime)
+ {
+ for (int index = rawValues.Count - 1; index >= 0; index--)
+ {
+ Assert.That(calculator.QueueRawValue(rawValues[index]), Is.True, $"raw value {index}");
+ }
+ }
+ else
+ {
+ for (int index = 0; index < rawValues.Count; index++)
+ {
+ Assert.That(calculator.QueueRawValue(rawValues[index]), Is.True, $"raw value {index}");
+ }
+ }
+
+ var results = new List();
+ while (calculator.TryGetProcessedValue(true, out DataValue value))
+ {
+ results.Add(value);
+ }
+ return results;
+ }
+
+ private static List CreateSharedRawValues()
+ {
+ var values = new List(21);
+ for (int interval = 0; interval < 10; interval++)
+ {
+ values.Add(CreateValue(interval + 10, StatusCodes.Good, interval * 10));
+ values.Add(CreateValue(interval, StatusCodes.Good, (interval * 10) + 5));
+ }
+ values.Add(CreateValue(20, StatusCodes.Good, 100));
+ return values;
+ }
+
+ private static DataValue CreateValue(double value, StatusCode statusCode, int timestampSeconds)
+ {
+ DateTime timestamp = AtSeconds(timestampSeconds);
+ return new DataValue(new Variant(value), statusCode, timestamp, timestamp);
+ }
+
+ private static DateTime AtSeconds(int seconds)
+ {
+ return s_baseTime.AddMilliseconds(seconds * 1000);
+ }
+
+ private static AggregateConfiguration CreateConfiguration(bool treatUncertainAsBad = false)
+ {
+ return new AggregateConfiguration
+ {
+ UseServerCapabilitiesDefaults = false,
+ TreatUncertainAsBad = treatUncertainAsBad,
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ UseSlopedExtrapolation = false
+ };
+ }
+
+ private static NodeId GetAggregateId(string aggregateName)
+ {
+ return aggregateName switch
+ {
+ "Minimum" => ObjectIds.AggregateFunction_Minimum,
+ "Maximum" => ObjectIds.AggregateFunction_Maximum,
+ "Range" => ObjectIds.AggregateFunction_Range,
+ "TimeAverage" => ObjectIds.AggregateFunction_TimeAverage,
+ "Start" => ObjectIds.AggregateFunction_Start,
+ "End" => ObjectIds.AggregateFunction_End,
+ "StartBound" => ObjectIds.AggregateFunction_StartBound,
+ "EndBound" => ObjectIds.AggregateFunction_EndBound,
+ "PercentGood" => ObjectIds.AggregateFunction_PercentGood,
+ "PercentBad" => ObjectIds.AggregateFunction_PercentBad,
+ "WorstQuality" => ObjectIds.AggregateFunction_WorstQuality,
+ "WorstQuality2" => ObjectIds.AggregateFunction_WorstQuality2,
+ "DurationInStateZero" => ObjectIds.AggregateFunction_DurationInStateZero,
+ "DurationInStateNonZero" => ObjectIds.AggregateFunction_DurationInStateNonZero,
+ _ => throw new ArgumentOutOfRangeException(nameof(aggregateName))
+ };
+ }
+
+ private static readonly DateTime s_baseTime = new(2025, 1, 1, 0, 0, 0);
+
+ private sealed class AggregateHarness : IDisposable
+ {
+ public void Dispose()
+ {
+ }
+
+ public Task> ReadProcessedAsync(
+ NodeId aggregateId,
+ List rawValues,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ AggregateConfiguration configuration)
+ {
+ List values = HistorianBackportTestCompatibility.RunLegacyProcessed(
+ aggregateId,
+ rawValues,
+ startTime,
+ endTime,
+ processingInterval,
+ configuration,
+ out ServiceResult error,
+ out HistoryReadResult result);
+ Assert.That(ServiceResult.IsGood(error), Is.True, error.ToString());
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
+ return Task.FromResult(values);
+ }
+
+ public Task DispatchProcessedAsync(
+ NodeId aggregateId,
+ List rawValues,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ AggregateConfiguration configuration,
+ HistoryReadResult result)
+ {
+ _ = HistorianBackportTestCompatibility.RunLegacyProcessed(
+ aggregateId,
+ rawValues,
+ startTime,
+ endTime,
+ processingInterval,
+ configuration,
+ out ServiceResult error,
+ out HistoryReadResult computed);
+ result.StatusCode = computed.StatusCode;
+ result.ContinuationPoint = computed.ContinuationPoint;
+ result.HistoryData = computed.HistoryData;
+ return Task.FromResult(error);
+ }
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/AggregateManagerTests.cs b/Tests/Opc.Ua.Server.Tests/AggregateManagerTests.cs
new file mode 100644
index 0000000000..5e73749267
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/AggregateManagerTests.cs
@@ -0,0 +1,228 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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 System;
+using Moq;
+using NUnit.Framework;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Server.Tests
+{
+ ///
+ /// Tests for .
+ ///
+ [TestFixture]
+ [Category("Aggregators")]
+ [Parallelizable]
+ public class AggregateManagerTests
+ {
+ private ITelemetryContext m_telemetry;
+ private Mock m_server;
+ [SetUp]
+ public void SetUp()
+ {
+ m_telemetry = NUnitTelemetryContext.Create();
+ m_server = new Mock();
+ m_server.SetupGet(s => s.Telemetry).Returns(m_telemetry);
+ }
+
+ private AggregateManager CreateManager()
+ {
+ return new AggregateManager(m_server.Object);
+ }
+
+ [Test]
+ public void IsSupportedReturnsFalseForNullAggregateId()
+ {
+ using AggregateManager manager = CreateManager();
+
+ Assert.That(manager.IsSupported(NodeId.Null), Is.False);
+ }
+
+ [Test]
+ public void IsSupportedReturnsFalseForUnknownAggregateId()
+ {
+ using AggregateManager manager = CreateManager();
+
+ Assert.That(manager.IsSupported(new NodeId(9999)), Is.False);
+ }
+
+ [Test]
+ public void MinimumProcessingIntervalRoundTrips()
+ {
+ using AggregateManager manager = CreateManager();
+
+ Assert.That(manager.MinimumProcessingInterval, Is.EqualTo(1000));
+
+ manager.MinimumProcessingInterval = 250;
+
+ Assert.That(manager.MinimumProcessingInterval, Is.EqualTo(250));
+ }
+
+ [Test]
+ public void GetDefaultConfigurationReturnsPart13Defaults()
+ {
+ using AggregateManager manager = CreateManager();
+
+ AggregateConfiguration configuration = manager.GetDefaultConfiguration(NodeId.Null);
+
+ Assert.That(configuration.TreatUncertainAsBad, Is.True);
+ Assert.That(configuration.PercentDataBad, Is.EqualTo(100));
+ Assert.That(configuration.PercentDataGood, Is.EqualTo(100));
+ Assert.That(configuration.UseSlopedExtrapolation, Is.False);
+ }
+
+ [Test]
+ public void SetDefaultConfigurationIsReturnedByGetDefaultConfiguration()
+ {
+ using AggregateManager manager = CreateManager();
+ var custom = new AggregateConfiguration
+ {
+ PercentDataBad = 50,
+ PercentDataGood = 60,
+ TreatUncertainAsBad = false,
+ UseSlopedExtrapolation = true,
+ UseServerCapabilitiesDefaults = false
+ };
+
+ manager.SetDefaultConfiguration(custom);
+
+ Assert.That(manager.GetDefaultConfiguration(NodeId.Null), Is.SameAs(custom));
+ }
+
+ [Test]
+ public void CreateCalculatorReturnsNullForNullAggregateId()
+ {
+ using AggregateManager manager = CreateManager();
+
+ IAggregateCalculator calculator = manager.CreateCalculator(
+ NodeId.Null,
+ new DateTime(2024, 1, 1, 0, 0, 0),
+ new DateTime(2024, 1, 1, 0, 1, 0),
+ 1000,
+ false,
+ manager.GetDefaultConfiguration(NodeId.Null));
+
+ Assert.That(calculator, Is.Null);
+ }
+
+ [Test]
+ public void CreateCalculatorReturnsNullForUnknownAggregateId()
+ {
+ using AggregateManager manager = CreateManager();
+
+ IAggregateCalculator calculator = manager.CreateCalculator(
+ new NodeId(9999),
+ new DateTime(2024, 1, 1, 0, 0, 0),
+ new DateTime(2024, 1, 1, 0, 1, 0),
+ 1000,
+ false,
+ manager.GetDefaultConfiguration(NodeId.Null));
+
+ Assert.That(calculator, Is.Null);
+ }
+
+ [Test]
+ public void RegisterFactoryMakesAggregateSupportedAndCreatable()
+ {
+ using AggregateManager manager = CreateManager();
+ NodeId aggregateId = ObjectIds.AggregateFunction_Average;
+ var configuration = new AggregateConfiguration
+ {
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ TreatUncertainAsBad = false,
+ UseSlopedExtrapolation = false,
+ UseServerCapabilitiesDefaults = false
+ };
+
+ manager.RegisterFactory(
+ aggregateId,
+ "Average",
+ (a, s, e, p, stepped, c, t) =>
+ new AverageAggregateCalculator(a, s, e, p, stepped, c, t));
+
+ Assert.That(manager.IsSupported(aggregateId), Is.True);
+
+ IAggregateCalculator calculator = manager.CreateCalculator(
+ aggregateId,
+ new DateTime(2024, 1, 1, 0, 0, 0),
+ new DateTime(2024, 1, 1, 0, 1, 0),
+ 1000,
+ false,
+ configuration);
+
+ Assert.That(calculator, Is.Not.Null);
+ }
+
+ [Test]
+ public void CreateCalculatorUsesServerCapabilitiesDefaultsWhenRequested()
+ {
+ using AggregateManager manager = CreateManager();
+ NodeId aggregateId = ObjectIds.AggregateFunction_Average;
+ manager.RegisterFactory(
+ aggregateId,
+ "Average",
+ (a, s, e, p, stepped, c, t) =>
+ new AverageAggregateCalculator(a, s, e, p, stepped, c, t));
+
+ var configuration = new AggregateConfiguration
+ {
+ UseServerCapabilitiesDefaults = true
+ };
+
+ IAggregateCalculator calculator = manager.CreateCalculator(
+ aggregateId,
+ new DateTime(2024, 1, 1, 0, 0, 0),
+ new DateTime(2024, 1, 1, 0, 1, 0),
+ 1000,
+ false,
+ configuration);
+
+ Assert.That(calculator, Is.Not.Null);
+ }
+
+ [Test]
+ public void RegisterFactoryRemovesAggregateSupport()
+ {
+ using AggregateManager manager = CreateManager();
+ NodeId aggregateId = ObjectIds.AggregateFunction_Average;
+ manager.RegisterFactory(
+ aggregateId,
+ "Average",
+ (a, s, e, p, stepped, c, t) =>
+ new AverageAggregateCalculator(a, s, e, p, stepped, c, t));
+ Assert.That(manager.IsSupported(aggregateId), Is.True);
+
+ manager.RegisterFactory(aggregateId);
+
+ Assert.That(manager.IsSupported(aggregateId), Is.False);
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/AggregateRun14ResidualOracleTests.cs b/Tests/Opc.Ua.Server.Tests/AggregateRun14ResidualOracleTests.cs
new file mode 100644
index 0000000000..cf9677dd87
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/AggregateRun14ResidualOracleTests.cs
@@ -0,0 +1,381 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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 System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Server.Tests
+{
+ ///
+ /// Part 13 oracle tests that reproduce the all-Good linear ramp used by the reference server
+ /// during CTT run 14: 1000 samples spaced 10 s apart with value equal to the sample index.
+ /// The current reference server uses a mixed-quality seed, but these tests preserve the exact
+ /// run-14 scenarios and record where the divergence was on the CTT oracle side:
+ ///
+ /// - Sloped interpolation of Float/Double bounds is byte-exact (server matches the CTT
+ /// oracle); the CTT "not equal" comparisons only appear for integer-typed nodes.
+ /// - For integer-typed nodes the interpolated value is rounded to nearest (Convert.ToInt32,
+ /// identical in 1.5.378/master378 and HEAD); Part 13 §5.4.3.2.2 does not mandate truncation, so
+ /// the CTT oracle truncating toward the earlier integer is a CTT convention difference.
+ /// - Duration/Percent/WorstQuality2/DurationInState over all-Good data are full-Good; the CTT
+ /// expectations assume a configured Bad-data region that the all-Good seed does not contain
+ /// (the CTT "No start of bad data configured" warnings), so the mismatch is CTT configuration.
+ ///
+ ///
+ [TestFixture]
+ [Category("Aggregators")]
+ [Category("Historian")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ [Parallelizable]
+ public sealed class AggregateRun14ResidualOracleTests
+ {
+ private static readonly DateTime s_baseTime = new(2026, 1, 1, 0, 0, 0);
+
+ private const double c_interval = 23_800;
+
+ private static DateTime At(double seconds)
+ {
+ return s_baseTime.AddMilliseconds(seconds * 1000.0);
+ }
+
+ // Linear ramp: value v at t = v*10 s + 1.234 s, all Good (mirrors the run-14 seed).
+ private static List CreateRamp(BuiltInType type, int count = 40)
+ {
+ var raw = new List(count + 1);
+ for (int v = 0; v <= count; v++)
+ {
+ DateTime t = At((v * 10) + 1.234);
+ Variant value = type switch
+ {
+ BuiltInType.Int32 => new Variant(v),
+ BuiltInType.Float => new Variant((float)v),
+ _ => new Variant((double)v)
+ };
+ raw.Add(new DataValue(value, StatusCodes.Good, t, t));
+ }
+ return raw;
+ }
+
+ [TestCase("Interpolative", 50.0, 4.8766)]
+ [TestCase("StartBound", 50.0, 4.8766)]
+ [TestCase("EndBound", 50.0, 7.2566)]
+ [TestCase("Interpolative", 57.234, 5.6)]
+ public async Task FloatSlopedInterpolationIsExactPart13OracleAsync(
+ string aggregateName,
+ double startSeconds,
+ double expected)
+ {
+ // For a Double/Float variable the SlopedInterpolation bounding value is the exact linear
+ // value on the line value(t) = (t - 1.234)/10. The server reproduces this to full
+ // precision, which is why the CTT run-14 comparisons never fail on Float/Double nodes.
+ NodeId aggregateId = GetAggregateId(aggregateName);
+ List raw = CreateRamp(BuiltInType.Double);
+ DateTime start = At(startSeconds);
+ DateTime end = At(startSeconds + (c_interval / 1000.0));
+ AggregateConfiguration configuration = CreateConfiguration();
+
+ List direct = RunDirect(aggregateId, raw, start, end, c_interval, configuration);
+ using var harness = new Harness();
+ List live = await harness
+ .ReadProcessedAsync(aggregateId, raw, start, end, c_interval, configuration)
+ .ConfigureAwait(false);
+
+ Assert.That(direct, Has.Count.EqualTo(1));
+ Assert.That(live, Has.Count.EqualTo(1));
+ Assert.That(
+ direct[0].WrappedValue.ConvertToDouble().GetDouble(),
+ Is.EqualTo(expected).Within(1e-6),
+ "direct value");
+ Assert.That(
+ live[0].WrappedValue.ConvertToDouble().GetDouble(),
+ Is.EqualTo(expected).Within(1e-6),
+ "live value");
+ }
+
+ [TestCase("Interpolative", 50.0, 5)]
+ [TestCase("StartBound", 50.0, 5)]
+ [TestCase("Interpolative", 57.234, 6)]
+ public void IntegerInterpolationRoundsToNearest(
+ string aggregateName,
+ double startSeconds,
+ int expected)
+ {
+ // Integer nodes are the only aggregate comparisons that fail in CTT run 14 for the
+ // interpolation/bound families. The exact linear value (e.g. 4.8766 or 5.6) is converted
+ // to the source Int32 type via Convert.ToInt32, which rounds to nearest (5 and 6 here).
+ // Part 13 §5.4.3.2.2 does not specify integer rounding; the CTT oracle truncates toward
+ // the earlier integer (4 and 5). The behaviour is unchanged from 1.5.378/master378 and is
+ // therefore a CTT convention difference, not a server regression.
+ NodeId aggregateId = GetAggregateId(aggregateName);
+ List raw = CreateRamp(BuiltInType.Int32);
+ DateTime start = At(startSeconds);
+ DateTime end = At(startSeconds + (c_interval / 1000.0));
+
+ List direct = RunDirect(
+ aggregateId, raw, start, end, c_interval, CreateConfiguration());
+
+ Assert.That(direct, Has.Count.EqualTo(1));
+ Assert.That(direct[0].WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(expected));
+ Assert.That(direct[0].StatusCode.CodeBits, Is.EqualTo(StatusCodes.Good));
+ }
+
+ [Test]
+ public async Task AllGoodRampStatusAggregatesAreFullyGoodAsync()
+ {
+ // The reference server seeds only Good samples. Over three full intervals every
+ // status-based aggregate reflects "all Good": DurationGood is the full interval,
+ // DurationBad is zero, PercentGood is 100, PercentBad is zero and WorstQuality2 is Good.
+ // The CTT run-14 mismatches for these families require a configured Bad-data region that
+ // the all-Good seed does not provide, so they are CTT configuration issues, not defects.
+ List raw = CreateRamp(BuiltInType.Int32);
+ DateTime start = At(50);
+ DateTime end = At(50 + (3 * (c_interval / 1000.0)));
+ AggregateConfiguration configuration = CreateConfiguration();
+
+ await AssertConstantAsync("DurationGood", raw, start, end, c_interval, 23_800.0)
+ .ConfigureAwait(false);
+ await AssertConstantAsync("DurationBad", raw, start, end, c_interval, 0.0)
+ .ConfigureAwait(false);
+ await AssertConstantAsync("PercentGood", raw, start, end, c_interval, 100.0)
+ .ConfigureAwait(false);
+ await AssertConstantAsync("PercentBad", raw, start, end, c_interval, 0.0)
+ .ConfigureAwait(false);
+ await AssertConstantAsync("DurationInStateNonZero", raw, start, end, c_interval, 23_800.0)
+ .ConfigureAwait(false);
+ await AssertConstantAsync("DurationInStateZero", raw, start, end, c_interval, 0.0)
+ .ConfigureAwait(false);
+
+ List worst = RunDirect(
+ GetAggregateId("WorstQuality2"), raw, start, end, c_interval, configuration);
+ Assert.That(worst, Has.Count.EqualTo(3));
+ foreach (DataValue value in worst)
+ {
+ Assert.That(value.WrappedValue.TryGetValue(out StatusCode quality), Is.True);
+ Assert.That(quality, Is.EqualTo(StatusCodes.Good));
+ Assert.That(value.StatusCode.CodeBits, Is.EqualTo(StatusCodes.Good));
+ }
+ }
+
+ [TestCase("DurationGood")]
+ [TestCase("PercentGood")]
+ [TestCase("WorstQuality2")]
+ [TestCase("StartBound")]
+ public async Task BadBoundNotFoundPlaceholderIsNotAggregateInputAsync(string aggregateName)
+ {
+ List raw = CreateRamp(BuiltInType.Int32, 10);
+ DateTime start = At(-10);
+ DateTime end = At(50);
+ AggregateConfiguration configuration = CreateConfiguration();
+ NodeId aggregateId = GetAggregateId(aggregateName);
+
+ List expected = RunDirect(
+ aggregateId,
+ raw,
+ start,
+ end,
+ c_interval,
+ configuration);
+
+ var withMissingBound = new List(raw.Count + 1)
+ {
+ new(
+ Variant.Null,
+ StatusCodes.BadBoundNotFound,
+ sourceTimestamp: start,
+ serverTimestamp: DateTime.MinValue)
+ };
+ withMissingBound.AddRange(raw);
+
+ List direct = RunDirect(
+ aggregateId,
+ withMissingBound,
+ start,
+ end,
+ c_interval,
+ configuration);
+
+ using var harness = new Harness();
+ List live = await harness
+ .ReadProcessedAsync(aggregateId, raw, start, end, c_interval, configuration)
+ .ConfigureAwait(false);
+
+ Assert.That(direct, Is.EqualTo(expected), "direct calculator");
+ Assert.That(live, Has.Count.EqualTo(expected.Count), "live historian count");
+ for (int index = 0; index < expected.Count; index++)
+ {
+ Assert.That(
+ live[index].WrappedValue,
+ Is.EqualTo(expected[index].WrappedValue),
+ $"live historian value[{index}]");
+ Assert.That(
+ live[index].StatusCode,
+ Is.EqualTo(expected[index].StatusCode),
+ $"live historian status[{index}]");
+ Assert.That(
+ live[index].SourceTimestamp,
+ Is.EqualTo(expected[index].SourceTimestamp),
+ $"live historian timestamp[{index}]");
+ }
+ Assert.That(
+ live,
+ Has.None.Matches(value =>
+ value.StatusCode == StatusCodes.BadBoundNotFound),
+ "A synthetic missing-bound marker must never appear in processed results.");
+ }
+
+ private static async Task AssertConstantAsync(
+ string aggregateName,
+ List raw,
+ DateTime start,
+ DateTime end,
+ double interval,
+ double expected)
+ {
+ NodeId aggregateId = GetAggregateId(aggregateName);
+ AggregateConfiguration configuration = CreateConfiguration();
+
+ List direct = RunDirect(aggregateId, raw, start, end, interval, configuration);
+ using var harness = new Harness();
+ List live = await harness
+ .ReadProcessedAsync(aggregateId, raw, start, end, interval, configuration)
+ .ConfigureAwait(false);
+
+ Assert.That(direct, Has.Count.EqualTo(3), aggregateName);
+ Assert.That(live, Has.Count.EqualTo(3), aggregateName);
+ for (int index = 0; index < direct.Count; index++)
+ {
+ Assert.That(
+ direct[index].WrappedValue.ConvertToDouble().GetDouble(),
+ Is.EqualTo(expected).Within(1e-6),
+ $"{aggregateName} direct[{index}]");
+ Assert.That(
+ live[index].WrappedValue.ConvertToDouble().GetDouble(),
+ Is.EqualTo(expected).Within(1e-6),
+ $"{aggregateName} live[{index}]");
+ Assert.That(
+ direct[index].StatusCode.CodeBits,
+ Is.EqualTo(StatusCodes.Good),
+ $"{aggregateName} status[{index}]");
+ }
+ }
+
+ private static List RunDirect(
+ NodeId aggregateId,
+ List rawValues,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ AggregateConfiguration configuration)
+ {
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ aggregateId,
+ startTime,
+ endTime,
+ processingInterval,
+ false,
+ configuration,
+ NUnitTelemetryContext.Create())!;
+
+ for (int index = 0; index < rawValues.Count; index++)
+ {
+ Assert.That(calculator.QueueRawValue(rawValues[index]), Is.True, $"raw value {index}");
+ }
+
+ var results = new List();
+ while (calculator.TryGetProcessedValue(true, out DataValue value))
+ {
+ results.Add(value);
+ }
+ return results;
+ }
+
+ private static AggregateConfiguration CreateConfiguration()
+ {
+ return new AggregateConfiguration
+ {
+ UseServerCapabilitiesDefaults = false,
+ TreatUncertainAsBad = false,
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ UseSlopedExtrapolation = false
+ };
+ }
+
+ private static NodeId GetAggregateId(string aggregateName)
+ {
+ return aggregateName switch
+ {
+ "Interpolative" => ObjectIds.AggregateFunction_Interpolative,
+ "StartBound" => ObjectIds.AggregateFunction_StartBound,
+ "EndBound" => ObjectIds.AggregateFunction_EndBound,
+ "DurationGood" => ObjectIds.AggregateFunction_DurationGood,
+ "DurationBad" => ObjectIds.AggregateFunction_DurationBad,
+ "PercentGood" => ObjectIds.AggregateFunction_PercentGood,
+ "PercentBad" => ObjectIds.AggregateFunction_PercentBad,
+ "WorstQuality2" => ObjectIds.AggregateFunction_WorstQuality2,
+ "DurationInStateZero" => ObjectIds.AggregateFunction_DurationInStateZero,
+ "DurationInStateNonZero" => ObjectIds.AggregateFunction_DurationInStateNonZero,
+ _ => throw new ArgumentOutOfRangeException(nameof(aggregateName))
+ };
+ }
+
+ private sealed class Harness : IDisposable
+ {
+ public void Dispose()
+ {
+ }
+
+ public Task> ReadProcessedAsync(
+ NodeId aggregateId,
+ List rawValues,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ AggregateConfiguration configuration)
+ {
+ List values = HistorianBackportTestCompatibility.RunLegacyProcessed(
+ aggregateId,
+ rawValues,
+ startTime,
+ endTime,
+ processingInterval,
+ configuration,
+ out ServiceResult error,
+ out HistoryReadResult result);
+ Assert.That(ServiceResult.IsGood(error), Is.True, error.ToString());
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
+ return Task.FromResult(values);
+ }
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/AggregatorsTests.cs b/Tests/Opc.Ua.Server.Tests/AggregatorsTests.cs
new file mode 100644
index 0000000000..515574d982
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/AggregatorsTests.cs
@@ -0,0 +1,836 @@
+/* ========================================================================
+ * 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 System;
+using Moq;
+using NUnit.Framework;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Server.Tests
+{
+ [TestFixture]
+ [Category("Aggregators")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ [Parallelizable]
+ public class AggregatorsTests
+ {
+ private ITelemetryContext m_telemetry;
+ private AggregateConfiguration m_configuration;
+
+ [SetUp]
+ public void SetUp()
+ {
+ m_telemetry = NUnitTelemetryContext.Create();
+ m_configuration = new AggregateConfiguration
+ {
+ TreatUncertainAsBad = false,
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ UseSlopedExtrapolation = false
+ };
+ }
+
+ [Test]
+ public void GetDefaultConfigurationTreatsUncertainAsBadByDefault()
+ {
+ var server = new Mock();
+ using var manager = new AggregateManager(server.Object);
+
+ AggregateConfiguration config = manager.GetDefaultConfiguration(default);
+
+ // Part 13 v1.05.07 §4.2.1.2: the TreatUncertainAsBad default is True.
+ Assert.That(config.TreatUncertainAsBad, Is.True);
+ Assert.That(config.PercentDataBad, Is.EqualTo(100));
+ Assert.That(config.PercentDataGood, Is.EqualTo(100));
+ Assert.That(config.UseSlopedExtrapolation, Is.False);
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForInterpolative()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_Interpolative);
+ Assert.That(name, Is.EqualTo(new QualifiedName(BrowseNames.AggregateFunction_Interpolative)));
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForAverage()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_Average);
+ Assert.That(name, Is.EqualTo(new QualifiedName(BrowseNames.AggregateFunction_Average)));
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForMinimum()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_Minimum);
+ Assert.That(name, Is.EqualTo(new QualifiedName(BrowseNames.AggregateFunction_Minimum)));
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForMaximum()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_Maximum);
+ Assert.That(name, Is.EqualTo(new QualifiedName(BrowseNames.AggregateFunction_Maximum)));
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForCount()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_Count);
+ Assert.That(name, Is.EqualTo(new QualifiedName(BrowseNames.AggregateFunction_Count)));
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForStart()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_Start);
+ Assert.That(name, Is.EqualTo(new QualifiedName(BrowseNames.AggregateFunction_Start)));
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForStdDevPopulation()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_StandardDeviationPopulation);
+ Assert.That(name, Is.EqualTo(
+ new QualifiedName(BrowseNames.AggregateFunction_StandardDeviationPopulation)));
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsDefaultForUnknownId()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(new NodeId(999999));
+ Assert.That(name, Is.Default);
+ }
+
+ [Test]
+ public void GetIdForStandardAggregateReturnsIdForAverage()
+ {
+ NodeId id = Aggregators.GetIdForStandardAggregate(
+ new QualifiedName(BrowseNames.AggregateFunction_Average));
+ Assert.That(id, Is.EqualTo(ObjectIds.AggregateFunction_Average));
+ }
+
+ [Test]
+ public void GetIdForStandardAggregateReturnsIdForMinimum()
+ {
+ NodeId id = Aggregators.GetIdForStandardAggregate(
+ new QualifiedName(BrowseNames.AggregateFunction_Minimum));
+ Assert.That(id, Is.EqualTo(ObjectIds.AggregateFunction_Minimum));
+ }
+
+ [Test]
+ public void GetIdForStandardAggregateReturnsIdForCount()
+ {
+ NodeId id = Aggregators.GetIdForStandardAggregate(
+ new QualifiedName(BrowseNames.AggregateFunction_Count));
+ Assert.That(id, Is.EqualTo(ObjectIds.AggregateFunction_Count));
+ }
+
+ [Test]
+ public void GetIdForStandardAggregateReturnsIdForStart()
+ {
+ NodeId id = Aggregators.GetIdForStandardAggregate(
+ new QualifiedName(BrowseNames.AggregateFunction_Start));
+ Assert.That(id, Is.EqualTo(ObjectIds.AggregateFunction_Start));
+ }
+
+ [Test]
+ public void GetIdForStandardAggregateReturnsIdForStdDevPopulation()
+ {
+ NodeId id = Aggregators.GetIdForStandardAggregate(
+ new QualifiedName(BrowseNames.AggregateFunction_StandardDeviationPopulation));
+ Assert.That(id, Is.EqualTo(ObjectIds.AggregateFunction_StandardDeviationPopulation));
+ }
+
+ [Test]
+ public void GetIdForStandardAggregateReturnsDefaultForUnknownName()
+ {
+ NodeId id = Aggregators.GetIdForStandardAggregate(new QualifiedName("NonExistentAggregate"));
+ Assert.That(id, Is.Default);
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsCalculatorForInterpolative()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Interpolative,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsAverageCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Average,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsTimeAverageCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_TimeAverage,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsTimeAverage2Calculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_TimeAverage2,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsTotalCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Total,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsTotal2Calculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Total2,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsMinMaxCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Minimum,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsCountCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Count,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsStartEndCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Start,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsStdDevCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_StandardDeviationPopulation,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsStatusCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_DurationGood,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsNullForUnknownId()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ new NodeId(999999),
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Null);
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsAnnotationCountCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_AnnotationCount,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsDurationInStateZeroCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_DurationInStateZero,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsNumberOfTransitionsCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_NumberOfTransitions,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsDeltaCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Delta,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsDeltaBoundsCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_DeltaBounds,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsVariancePopulationCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_VariancePopulation,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsVarianceSampleCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_VarianceSample,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsStdDevSampleCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_StandardDeviationSample,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsWorstQualityCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_WorstQuality,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsWorstQuality2Calculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_WorstQuality2,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsPercentGoodCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_PercentGood,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsPercentBadCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_PercentBad,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsDurationBadCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_DurationBad,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForEnd()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_End);
+ Assert.That(name, Is.EqualTo(new QualifiedName(BrowseNames.AggregateFunction_End)));
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForDelta()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_Delta);
+ Assert.That(name, Is.EqualTo(new QualifiedName(BrowseNames.AggregateFunction_Delta)));
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForRange()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_Range);
+ Assert.That(name, Is.EqualTo(new QualifiedName(BrowseNames.AggregateFunction_Range)));
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForTotal()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_Total);
+ Assert.That(name, Is.EqualTo(new QualifiedName(BrowseNames.AggregateFunction_Total)));
+ }
+
+ [Test]
+ public void GetNameForStandardAggregateReturnsNameForTimeAverage()
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_TimeAverage);
+ Assert.That(name, Is.EqualTo(new QualifiedName(BrowseNames.AggregateFunction_TimeAverage)));
+ }
+
+ [Test]
+ public void GetIdForStandardAggregateReturnsIdForEnd()
+ {
+ NodeId id = Aggregators.GetIdForStandardAggregate(
+ new QualifiedName(BrowseNames.AggregateFunction_End));
+ Assert.That(id, Is.EqualTo(ObjectIds.AggregateFunction_End));
+ }
+
+ [Test]
+ public void GetIdForStandardAggregateReturnsIdForDelta()
+ {
+ NodeId id = Aggregators.GetIdForStandardAggregate(
+ new QualifiedName(BrowseNames.AggregateFunction_Delta));
+ Assert.That(id, Is.EqualTo(ObjectIds.AggregateFunction_Delta));
+ }
+
+ [Test]
+ public void GetIdForStandardAggregateReturnsIdForRange()
+ {
+ NodeId id = Aggregators.GetIdForStandardAggregate(
+ new QualifiedName(BrowseNames.AggregateFunction_Range));
+ Assert.That(id, Is.EqualTo(ObjectIds.AggregateFunction_Range));
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsEndBoundCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_EndBound,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsStartBoundCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_StartBound,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsDurationInStateNonZeroCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_DurationInStateNonZero,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsMinimum2Calculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Minimum2,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsMaximum2Calculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Maximum2,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsRange2Calculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Range2,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsMinimumActualTime2Calculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_MinimumActualTime2,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsMaximumActualTime2Calculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_MaximumActualTime2,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void GetNameAndIdAreConsistentForAllMappings()
+ {
+ NodeId[] aggregateIds =
+ [
+ ObjectIds.AggregateFunction_Interpolative,
+ ObjectIds.AggregateFunction_Average,
+ ObjectIds.AggregateFunction_TimeAverage,
+ ObjectIds.AggregateFunction_TimeAverage2,
+ ObjectIds.AggregateFunction_Total,
+ ObjectIds.AggregateFunction_Total2,
+ ObjectIds.AggregateFunction_Minimum,
+ ObjectIds.AggregateFunction_Maximum,
+ ObjectIds.AggregateFunction_Count,
+ ObjectIds.AggregateFunction_Start,
+ ObjectIds.AggregateFunction_End,
+ ObjectIds.AggregateFunction_Delta,
+ ObjectIds.AggregateFunction_StandardDeviationPopulation,
+ ObjectIds.AggregateFunction_VariancePopulation,
+ ObjectIds.AggregateFunction_StandardDeviationSample,
+ ObjectIds.AggregateFunction_VarianceSample,
+ ObjectIds.AggregateFunction_DurationGood,
+ ObjectIds.AggregateFunction_DurationBad,
+ ObjectIds.AggregateFunction_PercentGood,
+ ObjectIds.AggregateFunction_PercentBad,
+ ObjectIds.AggregateFunction_WorstQuality,
+ ObjectIds.AggregateFunction_WorstQuality2
+ ];
+
+ foreach (NodeId aggregateId in aggregateIds)
+ {
+ QualifiedName name = Aggregators.GetNameForStandardAggregate(aggregateId);
+ Assert.That(name, Is.Not.Default,
+ $"Name should be found for aggregate {aggregateId}");
+
+ NodeId roundTrippedId = Aggregators.GetIdForStandardAggregate(name);
+ Assert.That(roundTrippedId, Is.EqualTo(aggregateId),
+ $"Round-tripped id should match for aggregate {aggregateId}");
+ }
+ }
+
+ [Test]
+ public void CreateStandardCalculatorWithSteppedFlagReturnsCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Average,
+ startTime, endTime, 10000, true, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsEndCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_End,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsDurationGoodCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_DurationGood,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsMaximumActualTimeCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_MaximumActualTime,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsMinimumActualTimeCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_MinimumActualTime,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+
+ [Test]
+ public void CreateStandardCalculatorReturnsMaximumCalculator()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ ObjectIds.AggregateFunction_Maximum,
+ startTime, endTime, 10000, false, m_configuration, m_telemetry);
+
+ Assert.That(calculator, Is.Not.Null);
+ Assert.That(calculator, Is.InstanceOf());
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/AverageAggregateCalculatorTests.cs b/Tests/Opc.Ua.Server.Tests/AverageAggregateCalculatorTests.cs
new file mode 100644
index 0000000000..51de4bff40
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/AverageAggregateCalculatorTests.cs
@@ -0,0 +1,365 @@
+/* ========================================================================
+ * 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 System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Server.Tests
+{
+ [TestFixture]
+ [Category("AverageAggregateCalculator")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ [Parallelizable]
+ public class AverageAggregateCalculatorTests
+ {
+ private ITelemetryContext m_telemetry;
+ private AggregateConfiguration m_configuration;
+
+ [SetUp]
+ public void SetUp()
+ {
+ m_telemetry = NUnitTelemetryContext.Create();
+ m_configuration = new AggregateConfiguration
+ {
+ TreatUncertainAsBad = false,
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ UseSlopedExtrapolation = false
+ };
+ }
+
+ private static List CreateDataValues(
+ DateTime startTime, double[] values, double intervalMs = 1000)
+ {
+ var dataValues = new List();
+ for (int i = 0; i < values.Length; i++)
+ {
+ dataValues.Add(new DataValue(
+ new Variant(values[i]),
+ StatusCodes.Good,
+ startTime.AddMilliseconds(i * intervalMs),
+ startTime.AddMilliseconds(i * intervalMs)));
+ }
+ return dataValues;
+ }
+
+ private DataValue ComputeAggregate(
+ NodeId aggregateId,
+ List values,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval)
+ {
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ aggregateId, startTime, endTime, processingInterval, false, m_configuration, m_telemetry);
+
+ foreach (DataValue value in values)
+ {
+ calculator.QueueRawValue(value);
+ }
+
+ var results = new List();
+ bool hasData = true;
+ while (hasData)
+ {
+ bool _hasresult = calculator.TryGetProcessedValue(true, out DataValue result);
+ if (_hasresult)
+ {
+ results.Add(result);
+ }
+ else
+ {
+ hasData = false;
+ }
+ }
+
+ return results.Count > 0 ? results[0] : default;
+ }
+
+ [Test]
+ public void AverageReturnsCorrectMean()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 40, 50, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Average,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double avg = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(avg, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void AverageIdenticalValuesReturnsThatValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [25, 25, 25, 25, 25, 25];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Average,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double avg = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(avg, Is.EqualTo(25.0).Within(0.0001));
+ }
+
+ [Test]
+ public void AverageNegativeValues()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [-10, -20, -30, -40, -50, -50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Average,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double avg = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(avg, Is.LessThan(0));
+ }
+
+ [Test]
+ public void AverageSingleValueReturnsThatValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [42.5, 42.5];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(4000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Average,
+ dataValues, startTime, endTime, 4000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ }
+
+ [Test]
+ public void AverageResultHasCalculatedAggregateBits()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 30];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(8000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Average,
+ dataValues, startTime, endTime, 8000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True);
+ }
+
+ [Test]
+ public void TimeAverageReturnsResult()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 40, 50, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_TimeAverage,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double timeAvg = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(timeAvg, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void TimeAverage2ReturnsResult()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 40, 50, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_TimeAverage2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double timeAvg2 = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(timeAvg2, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void TotalReturnsResult()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 40, 50, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Total,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double total = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(total, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void Total2ReturnsResult()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 40, 50, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Total2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double total2 = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(total2, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void TimeAverageWithConstantValueReturnsConstant()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [42, 42, 42, 42, 42, 42];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_TimeAverage,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double timeAvg = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(timeAvg, Is.EqualTo(42.0).Within(0.5));
+ }
+
+ [Test]
+ public void TotalResultHasCalculatedAggregateBits()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 30];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(8000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Total,
+ dataValues, startTime, endTime, 8000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True);
+ }
+
+ [Test]
+ public void AverageOfSymmetricValuesReturnsMiddle()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 20, 10, 10];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Average,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ double avg = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(avg, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void TimeAverage2WithConstantValueReturnsConstant()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [100, 100, 100, 100, 100, 100];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_TimeAverage2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double timeAvg2 = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(timeAvg2, Is.EqualTo(100.0).Within(0.5));
+ }
+
+ [Test]
+ public void TimeAverageResultHasCalculatedAggregateBits()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 30];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(8000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_TimeAverage,
+ dataValues, startTime, endTime, 8000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True);
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/ConformanceUnitsManagerTests.cs b/Tests/Opc.Ua.Server.Tests/ConformanceUnitsManagerTests.cs
new file mode 100644
index 0000000000..a7a17cf2de
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/ConformanceUnitsManagerTests.cs
@@ -0,0 +1,128 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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 System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Moq;
+using NUnit.Framework;
+
+namespace Opc.Ua.Server.Tests
+{
+ [TestFixture]
+ [Category("Server")]
+ [Parallelizable]
+ public class ConformanceUnitsManagerTests
+ {
+ [Test]
+ public async Task PublishDeduplicatesSortsAndPreservesConfiguredProfilesAsync()
+ {
+ var fixture = new ServerFixture();
+ StandardServer standardServer = await fixture.StartAsync().ConfigureAwait(false);
+ try
+ {
+ ServerCapabilitiesState capabilities = standardServer.CurrentInstance
+ .DiagnosticsNodeManager.FindPredefinedNode(
+ ObjectIds.Server_ServerCapabilities);
+ capabilities.ServerProfileArray.Value = ["urn:configured", "urn:profile:b"];
+ using var manager = new ConformanceUnitsManager(standardServer.CurrentInstance);
+
+ manager.Register(new FakeContributor(
+ [new QualifiedName("Zeta Unit"), new QualifiedName("Alpha Unit")],
+ ["urn:profile:b"]));
+ manager.Register(new FakeContributor(
+ [new QualifiedName("Alpha Unit"), new QualifiedName("Mid Unit")],
+ ["urn:profile:a", string.Empty]));
+
+ await manager.PublishAsync(CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(
+ capabilities.ConformanceUnits.Value,
+ Is.EqualTo(new[]
+ {
+ new QualifiedName("Alpha Unit"),
+ new QualifiedName("Mid Unit"),
+ new QualifiedName("Zeta Unit")
+ }));
+ Assert.That(
+ capabilities.ServerProfileArray.Value,
+ Is.EqualTo(new[] { "urn:configured", "urn:profile:b", "urn:profile:a" }));
+ }
+ finally
+ {
+ await fixture.StopAsync().ConfigureAwait(false);
+ }
+ }
+
+ [Test]
+ public void IsSupportedReflectsRegisteredUnitsAndIgnoresNulls()
+ {
+ IServerInternal server = new Mock().Object;
+ using var manager = new ConformanceUnitsManager(server);
+
+ manager.Register(new FakeContributor(
+ [QualifiedName.Null, new QualifiedName("Address Space Base")],
+ [string.Empty]));
+
+ Assert.That(manager.IsSupported(new QualifiedName("Address Space Base")), Is.True);
+ Assert.That(manager.IsSupported(QualifiedName.Null), Is.False);
+ Assert.That(manager.IsSupported(new QualifiedName("Not Registered")), Is.False);
+ }
+
+ [Test]
+ public void NullArgumentsAreRejectedAndDisposeIsIdempotent()
+ {
+ Assert.That(() => new ConformanceUnitsManager(null), Throws.ArgumentNullException);
+
+ IServerInternal server = new Mock().Object;
+ var manager = new ConformanceUnitsManager(server);
+ Assert.That(() => manager.Register(null), Throws.ArgumentNullException);
+ Assert.That(() =>
+ {
+ manager.Dispose();
+ manager.Dispose();
+ }, Throws.Nothing);
+ }
+
+ private sealed class FakeContributor : IConformanceContributor
+ {
+ public FakeContributor(
+ IReadOnlyList conformanceUnits,
+ IReadOnlyList serverProfiles)
+ {
+ ConformanceUnits = conformanceUnits;
+ ServerProfiles = serverProfiles;
+ }
+
+ public IReadOnlyList ConformanceUnits { get; }
+
+ public IReadOnlyList ServerProfiles { get; }
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/CountAggregateCalculatorTests.cs b/Tests/Opc.Ua.Server.Tests/CountAggregateCalculatorTests.cs
new file mode 100644
index 0000000000..bac79b7059
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/CountAggregateCalculatorTests.cs
@@ -0,0 +1,477 @@
+/* ========================================================================
+ * 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 System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Server.Tests
+{
+ [TestFixture]
+ [Category("Aggregators")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ [Parallelizable]
+ public class CountAggregateCalculatorTests
+ {
+ private ITelemetryContext m_telemetry;
+ private AggregateConfiguration m_configuration;
+
+ [SetUp]
+ public void SetUp()
+ {
+ m_telemetry = NUnitTelemetryContext.Create();
+ m_configuration = new AggregateConfiguration
+ {
+ TreatUncertainAsBad = false,
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ UseSlopedExtrapolation = false
+ };
+ }
+
+ private static List CreateDataValues(
+ DateTime startTime, double[] values, double intervalMs = 1000)
+ {
+ var dataValues = new List();
+ for (int i = 0; i < values.Length; i++)
+ {
+ dataValues.Add(new DataValue(
+ new Variant(values[i]),
+ StatusCodes.Good,
+ startTime.AddMilliseconds(i * intervalMs),
+ startTime.AddMilliseconds(i * intervalMs)));
+ }
+ return dataValues;
+ }
+
+ private static List CreateMixedStatusDataValues(
+ DateTime startTime, double[] values, StatusCode[] statusCodes, double intervalMs = 1000)
+ {
+ var dataValues = new List();
+ for (int i = 0; i < values.Length; i++)
+ {
+ dataValues.Add(new DataValue(
+ new Variant(values[i]),
+ statusCodes[i],
+ startTime.AddMilliseconds(i * intervalMs),
+ startTime.AddMilliseconds(i * intervalMs)));
+ }
+ return dataValues;
+ }
+
+ private DataValue ComputeAggregate(
+ NodeId aggregateId,
+ List values,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval)
+ {
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ aggregateId, startTime, endTime, processingInterval, false, m_configuration, m_telemetry);
+
+ foreach (DataValue value in values)
+ {
+ calculator.QueueRawValue(value);
+ }
+
+ var results = new List();
+ bool hasData = true;
+ while (hasData)
+ {
+ bool _hasresult = calculator.TryGetProcessedValue(true, out DataValue result);
+ if (_hasresult)
+ {
+ results.Add(result);
+ }
+ else
+ {
+ hasData = false;
+ }
+ }
+
+ return results.Count > 0 ? results[0] : default;
+ }
+
+ [Test]
+ public void CountReturnsNumberOfGoodValues()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 40, 50, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Count,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ int count = (int)(double)result.WrappedValue.ConvertToDouble();
+ Assert.That(count, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void CountWithMixedStatusCountsOnlyGoodValues()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 40, 50, 50];
+ StatusCode[] statuses =
+ [
+ StatusCodes.Good,
+ StatusCodes.Bad,
+ StatusCodes.Good,
+ StatusCodes.Bad,
+ StatusCodes.Good,
+ StatusCodes.Good
+ ];
+ List dataValues = CreateMixedStatusDataValues(
+ firstValueTime, values, statuses, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Count,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ int count = (int)(double)result.WrappedValue.ConvertToDouble();
+ Assert.That(count, Is.LessThan(6));
+ }
+
+ [Test]
+ public void CountSingleValueReturnsOne()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [42, 42];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(4000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Count,
+ dataValues, startTime, endTime, 4000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ }
+
+ [Test]
+ public void AnnotationCountReturnsCountOfAllValues()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 40, 50, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_AnnotationCount,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ int count = (int)(double)result.WrappedValue.ConvertToDouble();
+ Assert.That(count, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void AnnotationCountIncludesBadValues()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 40, 50, 50];
+ StatusCode[] statuses =
+ [
+ StatusCodes.Good,
+ StatusCodes.Bad,
+ StatusCodes.Good,
+ StatusCodes.Bad,
+ StatusCodes.Good,
+ StatusCodes.Good
+ ];
+ List dataValues = CreateMixedStatusDataValues(
+ firstValueTime, values, statuses, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue annotationResult = ComputeAggregate(
+ ObjectIds.AggregateFunction_AnnotationCount,
+ dataValues, startTime, endTime, 12000);
+
+ DataValue countResult = ComputeAggregate(
+ ObjectIds.AggregateFunction_Count,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(annotationResult, Is.Not.Null);
+ Assert.That(countResult, Is.Not.Null);
+ int annotationCount = (int)(double)annotationResult.WrappedValue.ConvertToDouble();
+ int goodCount = (int)(double)countResult.WrappedValue.ConvertToDouble();
+ Assert.That(annotationCount, Is.GreaterThanOrEqualTo(goodCount));
+ }
+
+ [Test]
+ public void DurationInStateZeroReturnsDuration()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [0, 0, 1, 0, 0, 0];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_DurationInStateZero,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double duration = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(duration, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void DurationInStateNonZeroReturnsDuration()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [1, 1, 0, 1, 1, 1];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_DurationInStateNonZero,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double duration = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(duration, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void DurationInStateZeroAllNonZeroReturnsZero()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [1, 2, 3, 4, 5, 5];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_DurationInStateZero,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double duration = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(duration, Is.Zero.Within(0.001));
+ }
+
+ [Test]
+ public void DurationInStateNonZeroAllZeroReturnsZero()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [0, 0, 0, 0, 0, 0];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_DurationInStateNonZero,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ double duration = (double)result.WrappedValue.ConvertToDouble();
+ Assert.That(duration, Is.Zero.Within(0.001));
+ }
+
+ [Test]
+ public void NumberOfTransitionsCountsFirstValueAndChangesWhenNoPreviousValueExists()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [0, 1, 0, 1, 0, 0];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_NumberOfTransitions,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ int count = (int)(double)result.WrappedValue.ConvertToDouble();
+ Assert.That(count, Is.EqualTo(5));
+ }
+
+ [Test]
+ public void NumberOfTransitionsCountsFirstValueWhenNoPreviousValueExists()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [5, 5, 5, 5, 5, 5];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_NumberOfTransitions,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ int count = (int)(double)result.WrappedValue.ConvertToDouble();
+ Assert.That(count, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void NumberOfTransitionsCountsFirstValueAndOneChange()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [0, 0, 0, 1, 1, 1];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_NumberOfTransitions,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ int count = (int)(double)result.WrappedValue.ConvertToDouble();
+ Assert.That(count, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void NumberOfTransitionsDoesNotCountMatchingPreviousValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ List dataValues = CreateDataValues(
+ startTime.AddMilliseconds(-500),
+ [5, 5, 5, 5],
+ 1000);
+ DateTime endTime = startTime.AddMilliseconds(2500);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_NumberOfTransitions,
+ dataValues,
+ startTime,
+ endTime,
+ 2500);
+
+ Assert.That(result.WrappedValue.TryGetValue(out int count), Is.True);
+ Assert.That(count, Is.Zero);
+ }
+
+ [Test]
+ public void NumberOfTransitionsUsesPreviousUncertainValueWhenUncertainIsConfiguredAsBad()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ List dataValues = CreateMixedStatusDataValues(
+ startTime.AddMilliseconds(-500),
+ [5, 5, 6, 7],
+ [StatusCodes.Uncertain, StatusCodes.Good, StatusCodes.Good, StatusCodes.Good],
+ 1000);
+ DateTime endTime = startTime.AddMilliseconds(2500);
+ m_configuration.TreatUncertainAsBad = true;
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_NumberOfTransitions,
+ dataValues,
+ startTime,
+ endTime,
+ 2500);
+
+ Assert.That(result.WrappedValue.TryGetValue(out int count), Is.True);
+ Assert.That(count, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void DurationInStateZeroAndNonZeroArComplementary()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [0, 1, 0, 1, 0, 1];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue zeroResult = ComputeAggregate(
+ ObjectIds.AggregateFunction_DurationInStateZero,
+ dataValues, startTime, endTime, 12000);
+
+ DataValue nonZeroResult = ComputeAggregate(
+ ObjectIds.AggregateFunction_DurationInStateNonZero,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(zeroResult, Is.Not.Null);
+ Assert.That(nonZeroResult, Is.Not.Null);
+ double zeroDuration = (double)zeroResult.WrappedValue.ConvertToDouble();
+ double nonZeroDuration = (double)nonZeroResult.WrappedValue.ConvertToDouble();
+ Assert.That(zeroDuration, Is.GreaterThanOrEqualTo(0));
+ Assert.That(nonZeroDuration, Is.GreaterThanOrEqualTo(0));
+ }
+
+ [Test]
+ public void CountResultHasCalculatedAggregateBits()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 40, 50, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Count,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True);
+ }
+
+ [Test]
+ public void AnnotationCountResultHasCalculatedAggregateBits()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 20, 30, 30];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(8000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_AnnotationCount,
+ dataValues, startTime, endTime, 8000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True);
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/CreateSessionApplicationUriValidationTests.cs b/Tests/Opc.Ua.Server.Tests/CreateSessionApplicationUriValidationTests.cs
index 214c0536b5..47b582d355 100644
--- a/Tests/Opc.Ua.Server.Tests/CreateSessionApplicationUriValidationTests.cs
+++ b/Tests/Opc.Ua.Server.Tests/CreateSessionApplicationUriValidationTests.cs
@@ -253,6 +253,103 @@ namespace Opc.Ua.Server.Tests
Assert.AreEqual((StatusCode)StatusCodes.BadCertificateUriInvalid, (StatusCode)ex.StatusCode);
}
+ [TestCase(true)]
+ [TestCase(false)]
+ public void OpenSecureChannelPreservesCertificateTimeInvalid(bool expired)
+ {
+ DateTime notBefore = expired
+ ? DateTime.UtcNow.AddYears(-2)
+ : DateTime.UtcNow.AddDays(1);
+ DateTime notAfter = expired
+ ? DateTime.UtcNow.AddYears(-1)
+ : DateTime.UtcNow.AddYears(1);
+ using X509Certificate2 clientCert = CreateCertificateWithMultipleUris(
+ [kClientApplicationUri],
+ kClientSubjectName,
+ [Utils.GetHostName()],
+ ObjectTypeIds.RsaMinApplicationCertificateType,
+ notBefore,
+ notAfter);
+
+ ServiceResultException exception = NUnit.Framework.Assert
+ .ThrowsAsync(async () =>
+ await CreateSessionWithCustomCertificateAsync(
+ clientCert,
+ kClientApplicationUri).ConfigureAwait(false));
+
+ Assert.That(
+ exception.StatusCode,
+ Is.AnyOf(
+ StatusCodes.BadCertificateTimeInvalid,
+ StatusCodes.BadCertificateIssuerTimeInvalid));
+ }
+
+ [Test]
+ public void OpenSecureChannelPreservesCertificateUseNotAllowed()
+ {
+ using X509Certificate2 clientCert = CreateCertificateWithMultipleUris(
+ [kClientApplicationUri],
+ kClientSubjectName,
+ [Utils.GetHostName()],
+ ObjectTypeIds.RsaMinApplicationCertificateType,
+ keyUsage: X509KeyUsageFlags.DigitalSignature);
+
+ ServiceResultException exception = NUnit.Framework.Assert
+ .ThrowsAsync(async () =>
+ await CreateSessionWithCustomCertificateAsync(
+ clientCert,
+ kClientApplicationUri).ConfigureAwait(false));
+
+ Assert.AreEqual(
+ (StatusCode)StatusCodes.BadCertificateUseNotAllowed,
+ exception.StatusCode);
+ }
+
+ [Test]
+ [NonParallelizable]
+ public async Task OpenSecureChannelMasksUntrustedCertificateAsync()
+ {
+ string serverPkiRoot = Path.GetTempPath() +
+ Path.GetRandomFileName() +
+ Path.DirectorySeparatorChar;
+ var fixture = new ServerFixture
+ {
+ AutoAccept = false,
+ AllNodeManagers = true,
+ SecurityNone = false
+ };
+
+ try
+ {
+ await fixture.LoadConfigurationAsync(serverPkiRoot).ConfigureAwait(false);
+ await fixture.StartAsync().ConfigureAwait(false);
+ using X509Certificate2 clientCert = CreateCertificateWithMultipleUris(
+ [kClientApplicationUri],
+ kClientSubjectName,
+ [Utils.GetHostName()],
+ ObjectTypeIds.RsaMinApplicationCertificateType);
+
+ ServiceResultException exception = NUnit.Framework.Assert
+ .ThrowsAsync(async () =>
+ await CreateSessionWithCustomCertificateAsync(
+ clientCert,
+ kClientApplicationUri,
+ fixture).ConfigureAwait(false));
+
+ Assert.AreEqual(
+ (StatusCode)StatusCodes.BadSecurityChecksFailed,
+ exception.StatusCode);
+ }
+ finally
+ {
+ await fixture.StopAsync().ConfigureAwait(false);
+ if (Directory.Exists(serverPkiRoot))
+ {
+ Directory.Delete(serverPkiRoot, true);
+ }
+ }
+ }
+
#region Helper Methods
///
@@ -260,7 +357,8 @@ namespace Opc.Ua.Server.Tests
///
private async Task CreateSessionWithCustomCertificateAsync(
X509Certificate2 clientCertificate,
- string clientApplicationUri)
+ string clientApplicationUri,
+ ServerFixture serverFixture = null)
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
ILogger logger = telemetry.CreateLogger();
@@ -309,7 +407,7 @@ namespace Opc.Ua.Server.Tests
.ConfigureAwait(false);
// Get server endpoint with RSA-compatible security policy
- EndpointDescription endpoint = m_serverFixture.Server.GetEndpoints()
+ EndpointDescription endpoint = (serverFixture ?? m_serverFixture).Server.GetEndpoints()
.FirstOrDefault(e => e.SecurityMode == MessageSecurityMode.SignAndEncrypt &&
e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256);
@@ -383,10 +481,13 @@ namespace Opc.Ua.Server.Tests
IList applicationUris,
string subjectName,
IList domainNames,
- NodeId certificateType = default)
+ NodeId certificateType = default,
+ DateTime? notBefore = null,
+ DateTime? notAfter = null,
+ X509KeyUsageFlags? keyUsage = null)
{
- DateTime notBefore = DateTime.Today.AddDays(-1);
- DateTime notAfter = DateTime.Today.AddYears(1);
+ DateTime effectiveNotBefore = notBefore ?? DateTime.Today.AddDays(-1);
+ DateTime effectiveNotAfter = notAfter ?? DateTime.Today.AddYears(1);
// Default to RSA if not specified
certificateType ??= ObjectTypeIds.RsaSha256ApplicationCertificateType;
@@ -397,10 +498,16 @@ namespace Opc.Ua.Server.Tests
// Build the certificate with the custom SAN extension
ICertificateBuilder builder = CertificateBuilder
.Create(subjectName)
- .SetNotBefore(notBefore)
- .SetNotAfter(notAfter)
+ .SetNotBefore(effectiveNotBefore)
+ .SetNotAfter(effectiveNotAfter)
.AddExtension(subjectAltName);
+ if (keyUsage.HasValue)
+ {
+ builder = builder.AddExtension(
+ new X509KeyUsageExtension(keyUsage.Value, true));
+ }
+
// Create certificate based on type
if (certificateType == ObjectTypeIds.RsaSha256ApplicationCertificateType ||
certificateType == ObjectTypeIds.RsaMinApplicationCertificateType)
diff --git a/Tests/Opc.Ua.Server.Tests/HistorianBackportTestCompatibility.cs b/Tests/Opc.Ua.Server.Tests/HistorianBackportTestCompatibility.cs
new file mode 100644
index 0000000000..9039d7c465
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/HistorianBackportTestCompatibility.cs
@@ -0,0 +1,61 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ * ======================================================================*/
+
+using System;
+using System.Globalization;
+
+namespace Opc.Ua.Server.Tests
+{
+ ///
+ /// Adapts tests imported from the newer API to the unchanged master378
+ /// public aggregate contracts.
+ ///
+ internal static partial class HistorianBackportTestCompatibility
+ {
+ public static bool TryGetProcessedValue(
+ this IAggregateCalculator calculator,
+ bool returnPartial,
+ out DataValue value)
+ {
+ value = calculator.GetProcessedValue(returnPartial);
+ return value != null;
+ }
+
+ public static double ConvertToDouble(this Variant value)
+ {
+ return Convert.ToDouble(value.Value, CultureInfo.InvariantCulture);
+ }
+
+ public static double GetDouble(this double value)
+ {
+ return value;
+ }
+
+ public static bool TryGetValue(this Variant value, out T result)
+ {
+ if (value.Value is T typed)
+ {
+ result = typed;
+ return true;
+ }
+
+ try
+ {
+ result = (T)Convert.ChangeType(
+ value.Value,
+ typeof(T),
+ CultureInfo.InvariantCulture);
+ return true;
+ }
+ catch (Exception)
+ {
+ result = default;
+ return false;
+ }
+ }
+
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/HistoryArchiveCompatibilityTests.cs b/Tests/Opc.Ua.Server.Tests/HistoryArchiveCompatibilityTests.cs
new file mode 100644
index 0000000000..376ee10e4f
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/HistoryArchiveCompatibilityTests.cs
@@ -0,0 +1,356 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using Moq;
+using NUnit.Framework;
+using Opc.Ua.Tests;
+using TestData;
+
+namespace Opc.Ua.Server.Tests
+{
+ [TestFixture]
+ [Category("Aggregators")]
+ [Parallelizable]
+ public class HistoryArchiveCompatibilityTests
+ {
+ [Test]
+ public void LegacyHistoryRecordStorageLayoutIsUnchanged()
+ {
+ string[] fields = typeof(HistoryRecord)
+ .GetFields(
+ BindingFlags.Instance |
+ BindingFlags.Public |
+ BindingFlags.NonPublic |
+ BindingFlags.DeclaredOnly)
+ .Select(field => $"{field.FieldType.Name} {field.Name}")
+ .OrderBy(value => value, StringComparer.Ordinal)
+ .ToArray();
+
+ Assert.That(
+ fields,
+ Is.EqualTo(s_historyRecordFields));
+ }
+
+ [Test]
+ public void ProcessedReadDoesNotModifyLegacyRawHistory()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+ var nodeId = new NodeId("history-layout-test", 1);
+ var archive = new HistoryArchive(telemetry);
+ archive.CreateRecord(nodeId, BuiltInType.Int32);
+ archive.Dispose();
+
+ HistoryFile history = archive.GetHistoryFile(nodeId);
+ List before = ReadAll(history);
+ Assert.That(before, Has.Count.EqualTo(1001));
+ Assert.That(before[0].Value, Is.EqualTo(1000));
+ Assert.That(before[^1].Value, Is.EqualTo(0));
+
+ var server = new Mock();
+ server.SetupGet(value => value.Telemetry).Returns(telemetry);
+ using var manager = new AggregateManager(server.Object);
+ server.SetupGet(value => value.AggregateManager).Returns(manager);
+ manager.RegisterFactory(
+ ObjectIds.AggregateFunction_Average,
+ Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_Average).Name,
+ Aggregators.CreateStandardCalculator);
+
+ var details = new ReadProcessedDetails
+ {
+ StartTime = before[0].ServerTimestamp,
+ EndTime = before[^1].ServerTimestamp,
+ ProcessingInterval = 60000,
+ AggregateConfiguration = new AggregateConfiguration
+ {
+ UseServerCapabilitiesDefaults = true
+ }
+ };
+ var nodeToRead = new HistoryReadValueId
+ {
+ NodeId = nodeId,
+ ParsedIndexRange = NumericRange.Empty
+ };
+ var result = new HistoryReadResult();
+
+ ServiceResult error = ProcessedHistoryAdapter.Read(
+ new ServerSystemContext(server.Object),
+ server.Object,
+ nodeId,
+ history,
+ details,
+ ObjectIds.AggregateFunction_Average,
+ archive.GetAnnotationTimestamps(nodeId),
+ TimestampsToReturn.Both,
+ nodeToRead,
+ result);
+
+ Assert.That(ServiceResult.IsGood(error), Is.True, error?.ToString());
+ Assert.That(result.HistoryData?.Body, Is.TypeOf());
+
+ List after = ReadAll(history);
+ Assert.That(after, Has.Count.EqualTo(before.Count));
+ for (int ii = 0; ii < before.Count; ii++)
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(after[ii].Value, Is.EqualTo(before[ii].Value));
+ Assert.That(after[ii].StatusCode, Is.EqualTo(before[ii].StatusCode));
+ Assert.That(
+ after[ii].SourceTimestamp,
+ Is.EqualTo(before[ii].SourceTimestamp));
+ Assert.That(
+ after[ii].ServerTimestamp,
+ Is.EqualTo(before[ii].ServerTimestamp));
+ });
+ }
+ }
+
+ [Test]
+ public async Task ParallelProcessedReadsUseIndependentSnapshots()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+ var nodeId = new NodeId("parallel-history-test", 1);
+ var archive = new HistoryArchive(telemetry);
+ archive.CreateRecord(nodeId, BuiltInType.Double, true);
+ archive.Dispose();
+
+ HistoryFile history = archive.GetHistoryFile(nodeId);
+ List before = ReadAll(history);
+
+ var server = new Mock();
+ server.SetupGet(value => value.Telemetry).Returns(telemetry);
+ using var manager = new AggregateManager(server.Object);
+ server.SetupGet(value => value.AggregateManager).Returns(manager);
+ manager.RegisterFactory(
+ ObjectIds.AggregateFunction_Average,
+ Aggregators.GetNameForStandardAggregate(
+ ObjectIds.AggregateFunction_Average).Name,
+ Aggregators.CreateStandardCalculator);
+
+ Task<(ServiceResult Error, int Count)>[] reads = Enumerable.Range(0, 8)
+ .Select(_ => Task.Run(() =>
+ {
+ var details = new ReadProcessedDetails
+ {
+ StartTime = before[0].ServerTimestamp,
+ EndTime = before[^1].ServerTimestamp,
+ ProcessingInterval = 120000,
+ AggregateConfiguration = new AggregateConfiguration
+ {
+ UseServerCapabilitiesDefaults = true
+ }
+ };
+ var result = new HistoryReadResult();
+ ServiceResult error = ProcessedHistoryAdapter.Read(
+ new ServerSystemContext(server.Object),
+ server.Object,
+ nodeId,
+ history,
+ details,
+ ObjectIds.AggregateFunction_Average,
+ archive.GetAnnotationTimestamps(nodeId),
+ TimestampsToReturn.Source,
+ new HistoryReadValueId
+ {
+ NodeId = nodeId,
+ ParsedIndexRange = NumericRange.Empty
+ },
+ result);
+ int count = result.HistoryData?.Body is HistoryData data
+ ? data.DataValues.Count
+ : 0;
+ return (error, count);
+ }))
+ .ToArray();
+
+ (ServiceResult Error, int Count)[] results = await Task.WhenAll(reads)
+ .ConfigureAwait(false);
+ Assert.That(results.All(value => ServiceResult.IsGood(value.Error)), Is.True);
+ Assert.That(
+ results.Select(value => value.Count).Distinct().Count(),
+ Is.EqualTo(1));
+ Assert.That(results[0].Count, Is.GreaterThan(0));
+
+ List after = ReadAll(history);
+ Assert.That(after, Has.Count.EqualTo(before.Count));
+ Assert.That(
+ after.Select(value => value.Value),
+ Is.EqualTo(before.Select(value => value.Value)));
+ }
+
+ [TestCase(true)]
+ [TestCase(false)]
+ public void RawReaderDoesNotCreateEmptyFinalPage(bool forward)
+ {
+ DateTime origin = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var source = new ListHistoryDataSource(
+ [
+ CreateValue(1, origin.AddSeconds(1)),
+ CreateValue(2, origin.AddSeconds(2)),
+ CreateValue(3, origin.AddSeconds(3))
+ ]);
+ var details = new ReadRawModifiedDetails
+ {
+ StartTime = forward ? origin : origin.AddSeconds(4),
+ EndTime = forward ? origin.AddSeconds(4) : origin,
+ NumValuesPerNode = 1,
+ ReturnBounds = false
+ };
+ using var reader = new HistoryDataReader(
+ new NodeId("paged-history", 1),
+ source);
+
+ var pages = new List();
+ var hasContinuationPoint = new List();
+ bool firstPage = true;
+ bool complete;
+ do
+ {
+ var values = new DataValueCollection();
+ if (firstPage)
+ {
+ reader.BeginReadRaw(
+ null,
+ details,
+ TimestampsToReturn.Both,
+ NumericRange.Empty,
+ QualifiedName.Null,
+ values);
+ firstPage = false;
+ }
+
+ complete = reader.NextReadRaw(
+ null,
+ TimestampsToReturn.Both,
+ NumericRange.Empty,
+ QualifiedName.Null,
+ values);
+ pages.Add(values);
+ hasContinuationPoint.Add(!complete);
+ } while (!complete);
+
+ Assert.That(pages, Has.Count.EqualTo(3));
+ Assert.That(pages.All(page => page.Count == 1), Is.True);
+ Assert.That(hasContinuationPoint, Is.EqualTo(new[] { true, true, false }));
+ Assert.That(
+ pages.Select(page => (int)page[0].Value),
+ Is.EqualTo(forward ? new[] { 1, 2, 3 } : new[] { 3, 2, 1 }));
+ }
+
+ private static DataValue CreateValue(int value, DateTime timestamp)
+ {
+ return new DataValue
+ {
+ Value = value,
+ StatusCode = StatusCodes.Good,
+ SourceTimestamp = timestamp,
+ ServerTimestamp = timestamp
+ };
+ }
+
+ private static List ReadAll(HistoryFile source)
+ {
+ var values = new List();
+ DataValue value = source.FirstRaw(
+ DateTime.MinValue,
+ true,
+ false,
+ out int position);
+ while (value != null)
+ {
+ values.Add(value);
+ value = source.NextRaw(
+ value.ServerTimestamp,
+ true,
+ false,
+ ref position);
+ }
+
+ return values;
+ }
+
+ private static readonly string[] s_historyRecordFields =
+ [
+ "Boolean Historizing",
+ "List`1 RawData"
+ ];
+
+ private sealed class ListHistoryDataSource : IHistoryDataSource
+ {
+ public ListHistoryDataSource(IReadOnlyList values)
+ {
+ m_values = values;
+ }
+
+ public DataValue FirstRaw(
+ DateTime startTime,
+ bool isForward,
+ bool isReadModified,
+ out int position)
+ {
+ position = -1;
+ if (isForward)
+ {
+ for (int ii = 0; ii < m_values.Count; ii++)
+ {
+ if (m_values[ii].ServerTimestamp >= startTime)
+ {
+ position = ii;
+ break;
+ }
+ }
+ }
+ else
+ {
+ for (int ii = m_values.Count - 1; ii >= 0; ii--)
+ {
+ if (m_values[ii].ServerTimestamp <= startTime)
+ {
+ position = ii;
+ break;
+ }
+ }
+ }
+ return GetValue(position);
+ }
+
+ public DataValue NextRaw(
+ DateTime lastTime,
+ bool isForward,
+ bool isReadModified,
+ ref int position)
+ {
+ position += isForward ? 1 : -1;
+ return GetValue(position);
+ }
+
+ private DataValue GetValue(int position)
+ {
+ if (position < 0 || position >= m_values.Count)
+ {
+ return null;
+ }
+
+ DataValue value = m_values[position];
+ return new DataValue
+ {
+ WrappedValue = value.WrappedValue,
+ StatusCode = value.StatusCode,
+ SourceTimestamp = value.SourceTimestamp,
+ ServerTimestamp = value.ServerTimestamp
+ };
+ }
+
+ private readonly IReadOnlyList m_values;
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/LegacyProcessedHistoryTestCompatibility.cs b/Tests/Opc.Ua.Server.Tests/LegacyProcessedHistoryTestCompatibility.cs
new file mode 100644
index 0000000000..c86f2b60a8
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/LegacyProcessedHistoryTestCompatibility.cs
@@ -0,0 +1,133 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using Moq;
+using Opc.Ua.Tests;
+using TestData;
+
+namespace Opc.Ua.Server.Tests
+{
+ internal static partial class HistorianBackportTestCompatibility
+ {
+ public static List RunLegacyProcessed(
+ NodeId aggregateId,
+ List rawValues,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval,
+ AggregateConfiguration configuration,
+ out ServiceResult error,
+ out HistoryReadResult result)
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+ var server = new Mock();
+ server.SetupGet(s => s.Telemetry).Returns(telemetry);
+
+ using var manager = new AggregateManager(server.Object);
+ server.SetupGet(s => s.AggregateManager).Returns(manager);
+ QualifiedName aggregateName = Aggregators.GetNameForStandardAggregate(aggregateId);
+ manager.RegisterFactory(
+ aggregateId,
+ aggregateName.Name,
+ Aggregators.CreateStandardCalculator);
+
+ var source = new MemoryHistorySource(rawValues);
+ var nodeId = new NodeId("legacy-processed-test", 1);
+ var details = new ReadProcessedDetails
+ {
+ StartTime = startTime,
+ EndTime = endTime,
+ ProcessingInterval = processingInterval,
+ AggregateConfiguration = configuration
+ };
+ var nodeToRead = new HistoryReadValueId
+ {
+ NodeId = nodeId,
+ ParsedIndexRange = NumericRange.Empty
+ };
+ result = new HistoryReadResult();
+ var context = new ServerSystemContext(server.Object);
+
+ error = ProcessedHistoryAdapter.Read(
+ context,
+ server.Object,
+ nodeId,
+ source,
+ details,
+ aggregateId,
+ Array.Empty(),
+ TimestampsToReturn.Source,
+ nodeToRead,
+ result);
+
+ return result.HistoryData?.Body is HistoryData historyData
+ ? [.. historyData.DataValues]
+ : [];
+ }
+
+ private sealed class MemoryHistorySource : IHistoryDataSource
+ {
+ public MemoryHistorySource(List values)
+ {
+ m_values = new List(values);
+ m_values.Sort((left, right) =>
+ left.ServerTimestamp.CompareTo(right.ServerTimestamp));
+ }
+
+ public DataValue FirstRaw(
+ DateTime startTime,
+ bool isForward,
+ bool isReadModified,
+ out int position)
+ {
+ _ = isReadModified;
+ position = -1;
+ if (isForward)
+ {
+ for (int ii = 0; ii < m_values.Count; ii++)
+ {
+ if (m_values[ii].ServerTimestamp >= startTime)
+ {
+ position = ii;
+ break;
+ }
+ }
+ }
+ else
+ {
+ for (int ii = m_values.Count - 1; ii >= 0; ii--)
+ {
+ if (m_values[ii].ServerTimestamp <= startTime)
+ {
+ position = ii;
+ break;
+ }
+ }
+ }
+
+ return position < 0 ? null : (DataValue)m_values[position].Clone();
+ }
+
+ public DataValue NextRaw(
+ DateTime lastTime,
+ bool isForward,
+ bool isReadModified,
+ ref int position)
+ {
+ _ = lastTime;
+ _ = isReadModified;
+ position += isForward ? 1 : -1;
+ return position < 0 || position >= m_values.Count
+ ? null
+ : (DataValue)m_values[position].Clone();
+ }
+
+ private readonly List m_values;
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/MinMaxAggregateCalculatorTests.cs b/Tests/Opc.Ua.Server.Tests/MinMaxAggregateCalculatorTests.cs
new file mode 100644
index 0000000000..133dd3d2c1
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/MinMaxAggregateCalculatorTests.cs
@@ -0,0 +1,925 @@
+/* ========================================================================
+ * 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 System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Server.Tests
+{
+ [TestFixture]
+ [Category("Aggregators")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ [Parallelizable]
+ public class MinMaxAggregateCalculatorTests
+ {
+ private static readonly DateTime s_baseTime = new(2024, 1, 1, 0, 0, 0);
+ private ITelemetryContext m_telemetry;
+ private AggregateConfiguration m_configuration;
+
+ [SetUp]
+ public void SetUp()
+ {
+ m_telemetry = NUnitTelemetryContext.Create();
+ m_configuration = new AggregateConfiguration
+ {
+ TreatUncertainAsBad = false,
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ UseSlopedExtrapolation = false
+ };
+ }
+
+ private static List CreateDataValues(
+ DateTime startTime, double[] values, double intervalMs = 1000)
+ {
+ var dataValues = new List();
+ for (int i = 0; i < values.Length; i++)
+ {
+ dataValues.Add(new DataValue(
+ new Variant(values[i]),
+ StatusCodes.Good,
+ startTime.AddMilliseconds(i * intervalMs),
+ startTime.AddMilliseconds(i * intervalMs)));
+ }
+ return dataValues;
+ }
+
+ private DataValue ComputeAggregate(
+ NodeId aggregateId,
+ List values,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval)
+ {
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ aggregateId, startTime, endTime, processingInterval, false, m_configuration, m_telemetry);
+
+ foreach (DataValue value in values)
+ {
+ calculator.QueueRawValue(value);
+ }
+
+ var results = new List();
+ bool hasData = true;
+ while (hasData)
+ {
+ bool _hasresult = calculator.TryGetProcessedValue(true, out DataValue result);
+ if (_hasresult)
+ {
+ results.Add(result);
+ }
+ else
+ {
+ hasData = false;
+ }
+ }
+
+ return results.Count > 0 ? results[0] : default;
+ }
+
+ private static DataValue RawValue(DateTime timestamp, double value)
+ {
+ return new DataValue(new Variant(value), StatusCodes.Good, timestamp, timestamp);
+ }
+
+ private DataValue ComputeSingleInterval(
+ NodeId aggregateId,
+ List ascendingValues,
+ DateTime startTime,
+ DateTime endTime,
+ double processingInterval)
+ {
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ aggregateId, startTime, endTime, processingInterval, false, m_configuration, m_telemetry);
+
+ // Reverse reads (endTime earlier than startTime) require the raw stream to be
+ // queued latest-first; forward reads require earliest-first.
+ if (endTime < startTime)
+ {
+ for (int index = ascendingValues.Count - 1; index >= 0; index--)
+ {
+ calculator.QueueRawValue(ascendingValues[index]);
+ }
+ }
+ else
+ {
+ foreach (DataValue value in ascendingValues)
+ {
+ calculator.QueueRawValue(value);
+ }
+ }
+
+ var results = new List();
+ while (calculator.TryGetProcessedValue(true, out DataValue result))
+ {
+ results.Add(result);
+ }
+
+ Assert.That(results, Has.Count.EqualTo(1));
+ return results[0];
+ }
+
+ [Test]
+ public void Minimum_ReturnsSmallestValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 5, 20, 3, 15, 3];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Minimum,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(3.0).Within(0.0001));
+ }
+
+ [Test]
+ public void Maximum_ReturnsLargestValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 50, 20, 30, 15, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Maximum,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(50.0).Within(0.0001));
+ }
+
+ [Test]
+ public void Range_ReturnsMaxMinusMIn()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 50, 20, 30, 15, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Range,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(40.0).Within(0.0001));
+ }
+
+ [Test]
+ public void MinimumActualTime_ReturnsTimestampOfMin()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 5, 20, 3, 15, 3];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_MinimumActualTime,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ }
+
+ [Test]
+ public void MaximumActualTime_ReturnsTimestampOfMax()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 50, 20, 30, 15, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_MaximumActualTime,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ }
+
+ [Test]
+ public void Minimum_SingleValue_ReturnsThatValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [42, 42];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(4000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Minimum,
+ dataValues, startTime, endTime, 4000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(42.0).Within(0.0001));
+ }
+
+ [Test]
+ public void Maximum_NegativeValues_ReturnsLargest()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [-10, -5, -20, -3, -15, -3];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Maximum,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(-3.0).Within(0.0001));
+ }
+
+ [Test]
+ public void Range_AllSameValue_ReturnsZero()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [7, 7, 7, 7];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(8000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Range,
+ dataValues, startTime, endTime, 8000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.Zero.Within(0.0001));
+ }
+
+ [Test]
+ public void Minimum2_ReturnsSmallestValueIncludingSimpleBounds()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [50, 3, 20, 40, 15, 15];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Minimum2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(3.0).Within(0.0001));
+ }
+
+ [Test]
+ public void Maximum2_ReturnsLargestValueIncludingSimpleBounds()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 50, 20, 30, 15, 15];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Maximum2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(50.0).Within(0.0001));
+ }
+
+ [Test]
+ public void Range2_ReturnsMaximum2MinusMinimum2()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 50, 20, 3, 15, 15];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Range2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(47.0).Within(0.0001));
+ }
+
+ [Test]
+ public void MinimumActualTime2_ReturnsMinimumValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [50, 3, 20, 40, 15, 15];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_MinimumActualTime2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(3.0).Within(0.0001));
+ // Part 13 §5.4.3.17: timestamp is the actual time of the minimum value (within the range).
+ Assert.That(result.SourceTimestamp, Is.GreaterThanOrEqualTo(startTime));
+ Assert.That(result.SourceTimestamp, Is.LessThanOrEqualTo(endTime));
+ }
+
+ [Test]
+ public void MaximumActualTime2_ReturnsMaximumValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 50, 20, 30, 15, 15];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_MaximumActualTime2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.WrappedValue.IsNull, Is.False);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(50.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.GreaterThanOrEqualTo(startTime));
+ Assert.That(result.SourceTimestamp, Is.LessThanOrEqualTo(endTime));
+ }
+
+ // Regression tests for the timestamp provenance of ComputeMinMax and ComputeMinMax2.
+ // Commit a711c4b28 removed the `else` clauses that returned the region timestamp,
+ // relying only on the `if (returnActualTime)` branch returning early. The following
+ // tests pin down both sides of that control flow so a future control-flow cleanup
+ // cannot silently swap which timestamp is reported: the *ActualTime variants must
+ // keep reporting the timestamp of the raw sample that was selected as the minimum or
+ // maximum, while the plain variants must keep reporting the processing region's own
+ // timestamp, never the raw sample's timestamp.
+
+ [Test]
+ public void MinimumActualTimeSourceTimestampMatchesSelectedMinimumValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 5, 20, 3, 15, 3];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+ DateTime expectedMinimumTimestamp = firstValueTime.AddMilliseconds(3 * 2000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_MinimumActualTime,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(3.0).Within(0.0001));
+ // The timestamp must come from the raw sample that produced the minimum,
+ // not from the processing region's own start/end timestamp.
+ Assert.That(result.SourceTimestamp, Is.EqualTo(expectedMinimumTimestamp));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(expectedMinimumTimestamp));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(startTime));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(endTime));
+ }
+
+ [Test]
+ public void MaximumActualTimeSourceTimestampMatchesSelectedMaximumValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 50, 20, 30, 15, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+ DateTime expectedMaximumTimestamp = firstValueTime.AddMilliseconds(1 * 2000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_MaximumActualTime,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(50.0).Within(0.0001));
+ // The timestamp must come from the raw sample that produced the maximum,
+ // not from the processing region's own start/end timestamp.
+ Assert.That(result.SourceTimestamp, Is.EqualTo(expectedMaximumTimestamp));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(expectedMaximumTimestamp));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(startTime));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(endTime));
+ }
+
+ [Test]
+ public void MinimumSourceTimestampMatchesSliceStartNotSelectedValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 5, 20, 3, 15, 3];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+ DateTime rawMinimumTimestamp = firstValueTime.AddMilliseconds(3 * 2000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Minimum,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(3.0).Within(0.0001));
+ // The plain Minimum aggregate reports the processing region's own timestamp
+ // (the slice start), never the timestamp of the raw sample that was selected.
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(rawMinimumTimestamp));
+ }
+
+ [Test]
+ public void MaximumSourceTimestampMatchesSliceStartNotSelectedValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 50, 20, 30, 15, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+ DateTime rawMaximumTimestamp = firstValueTime.AddMilliseconds(1 * 2000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Maximum,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(50.0).Within(0.0001));
+ // The plain Maximum aggregate reports the processing region's own timestamp
+ // (the slice start), never the timestamp of the raw sample that was selected.
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(rawMaximumTimestamp));
+ }
+
+ [Test]
+ public void MinimumActualTime2SourceTimestampMatchesSelectedMinimumValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 5, 20, 3, 15, 3];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+ DateTime expectedMinimumTimestamp = firstValueTime.AddMilliseconds(3 * 2000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_MinimumActualTime2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(3.0).Within(0.0001));
+ // Same provenance guarantee as MinimumActualTime, but exercised through
+ // ComputeMinMax2's simple-bounds code path.
+ Assert.That(result.SourceTimestamp, Is.EqualTo(expectedMinimumTimestamp));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(expectedMinimumTimestamp));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(startTime));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(endTime));
+ }
+
+ [Test]
+ public void MaximumActualTime2SourceTimestampMatchesSelectedMaximumValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 50, 20, 30, 15, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+ DateTime expectedMaximumTimestamp = firstValueTime.AddMilliseconds(1 * 2000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_MaximumActualTime2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(50.0).Within(0.0001));
+ // Same provenance guarantee as MaximumActualTime, but exercised through
+ // ComputeMinMax2's simple-bounds code path.
+ Assert.That(result.SourceTimestamp, Is.EqualTo(expectedMaximumTimestamp));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(expectedMaximumTimestamp));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(startTime));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(endTime));
+ }
+
+ [Test]
+ public void Minimum2SourceTimestampMatchesSliceStartNotSelectedValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 5, 20, 3, 15, 3];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+ DateTime rawMinimumTimestamp = firstValueTime.AddMilliseconds(3 * 2000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Minimum2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(3.0).Within(0.0001));
+ // The plain Minimum2 aggregate reports the processing region's own timestamp
+ // (the slice start), never the timestamp of the raw sample that was selected.
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(rawMinimumTimestamp));
+ }
+
+ [Test]
+ public void Maximum2SourceTimestampMatchesSliceStartNotSelectedValue()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime firstValueTime = startTime.AddMilliseconds(500);
+ double[] values = [10, 50, 20, 30, 15, 50];
+ List dataValues = CreateDataValues(firstValueTime, values, 2000);
+ DateTime endTime = startTime.AddMilliseconds(12000);
+ DateTime rawMaximumTimestamp = firstValueTime.AddMilliseconds(1 * 2000);
+
+ DataValue result = ComputeAggregate(
+ ObjectIds.AggregateFunction_Maximum2,
+ dataValues, startTime, endTime, 12000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That((double)result.WrappedValue.ConvertToDouble(), Is.EqualTo(50.0).Within(0.0001));
+ // The plain Maximum2 aggregate reports the processing region's own timestamp
+ // (the slice start), never the timestamp of the raw sample that was selected.
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(rawMaximumTimestamp));
+ }
+
+ // Raw-versus-Calculated provenance tests for the Minimum/Maximum family.
+ // Part 13 §5.4.3.10/§5.4.3.11 return the min/max sample stamped at the start of the
+ // interval and mark it Good, Raw when the selected sample sits on that start timestamp,
+ // otherwise Good, Calculated. Part 11/Part 13 §5.4.2.2 define the interval start for a
+ // reverse read (startTime later than endTime) as the later timestamp, so the request
+ // direction - not the chronological lower bound - decides Raw versus Calculated.
+ // Each dataset spans the full interval so the Partial bit is never set, letting the
+ // tests assert the exact aggregate info bits.
+
+ [Test]
+ public void MaximumForwardWithMaximumAtIntervalStartIsRaw()
+ {
+ DateTime endTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 50),
+ RawValue(s_baseTime.AddMilliseconds(3000), 20),
+ RawValue(s_baseTime.AddMilliseconds(6000), 10),
+ RawValue(s_baseTime.AddMilliseconds(9000), 30),
+ RawValue(endTime, 40)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Maximum, values, s_baseTime, endTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(50.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(s_baseTime));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(s_baseTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Raw));
+ }
+
+ [Test]
+ public void MaximumForwardWithMaximumNotAtIntervalStartIsCalculated()
+ {
+ DateTime endTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 10),
+ RawValue(s_baseTime.AddMilliseconds(3000), 20),
+ RawValue(s_baseTime.AddMilliseconds(6000), 50),
+ RawValue(s_baseTime.AddMilliseconds(9000), 30),
+ RawValue(endTime, 40)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Maximum, values, s_baseTime, endTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(50.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(s_baseTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated));
+ }
+
+ [Test]
+ public void MaximumReverseWithMaximumAtRequestIntervalStartIsRaw()
+ {
+ DateTime startTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 10),
+ RawValue(s_baseTime.AddMilliseconds(3000), 20),
+ RawValue(s_baseTime.AddMilliseconds(6000), 30),
+ RawValue(s_baseTime.AddMilliseconds(9000), 40),
+ RawValue(startTime, 50)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Maximum, values, startTime, s_baseTime, 12000);
+
+ // The maximum sits on the later timestamp, which is the reverse interval start,
+ // so it must be reported Good, Raw (not Calculated) and stamped at that timestamp.
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(50.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Raw));
+ }
+
+ [Test]
+ public void MaximumReverseWithMaximumNotAtRequestIntervalStartIsCalculated()
+ {
+ DateTime startTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 10),
+ RawValue(s_baseTime.AddMilliseconds(3000), 50),
+ RawValue(s_baseTime.AddMilliseconds(6000), 30),
+ RawValue(s_baseTime.AddMilliseconds(9000), 20),
+ RawValue(startTime, 40)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Maximum, values, startTime, s_baseTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(50.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated));
+ }
+
+ [Test]
+ public void MinimumForwardWithMinimumAtIntervalStartIsRaw()
+ {
+ DateTime endTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 3),
+ RawValue(s_baseTime.AddMilliseconds(3000), 20),
+ RawValue(s_baseTime.AddMilliseconds(6000), 10),
+ RawValue(s_baseTime.AddMilliseconds(9000), 30),
+ RawValue(endTime, 40)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Minimum, values, s_baseTime, endTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(3.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(s_baseTime));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(s_baseTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Raw));
+ }
+
+ [Test]
+ public void MinimumForwardWithMinimumNotAtIntervalStartIsCalculated()
+ {
+ DateTime endTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 10),
+ RawValue(s_baseTime.AddMilliseconds(3000), 20),
+ RawValue(s_baseTime.AddMilliseconds(6000), 3),
+ RawValue(s_baseTime.AddMilliseconds(9000), 30),
+ RawValue(endTime, 40)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Minimum, values, s_baseTime, endTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(3.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(s_baseTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated));
+ }
+
+ [Test]
+ public void MinimumReverseWithMinimumAtRequestIntervalStartIsRaw()
+ {
+ DateTime startTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 10),
+ RawValue(s_baseTime.AddMilliseconds(3000), 40),
+ RawValue(s_baseTime.AddMilliseconds(6000), 30),
+ RawValue(s_baseTime.AddMilliseconds(9000), 20),
+ RawValue(startTime, 3)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Minimum, values, startTime, s_baseTime, 12000);
+
+ // The minimum sits on the later timestamp (the reverse interval start), so it must
+ // be reported Good, Raw and stamped at that timestamp.
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(3.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Raw));
+ }
+
+ [Test]
+ public void MinimumReverseWithMinimumNotAtRequestIntervalStartIsCalculated()
+ {
+ DateTime startTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 10),
+ RawValue(s_baseTime.AddMilliseconds(3000), 3),
+ RawValue(s_baseTime.AddMilliseconds(6000), 20),
+ RawValue(s_baseTime.AddMilliseconds(9000), 30),
+ RawValue(startTime, 40)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Minimum, values, startTime, s_baseTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(3.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated));
+ }
+
+ [Test]
+ public void MaximumActualTimeReverseReportsActualSampleTimeAsRaw()
+ {
+ DateTime startTime = s_baseTime.AddMilliseconds(12000);
+ DateTime expectedMaximumTimestamp = s_baseTime.AddMilliseconds(3000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 10),
+ RawValue(expectedMaximumTimestamp, 50),
+ RawValue(s_baseTime.AddMilliseconds(6000), 30),
+ RawValue(s_baseTime.AddMilliseconds(9000), 20),
+ RawValue(startTime, 40)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_MaximumActualTime, values, startTime, s_baseTime, 12000);
+
+ // The ActualTime variant reports the timestamp of the raw sample, not the interval
+ // timestamp, and the raw sample is Good, Raw regardless of read direction.
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(50.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(expectedMaximumTimestamp));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(expectedMaximumTimestamp));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(startTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Raw));
+ }
+
+ [Test]
+ public void MinimumActualTimeReverseReportsActualSampleTimeAsRaw()
+ {
+ DateTime startTime = s_baseTime.AddMilliseconds(12000);
+ DateTime expectedMinimumTimestamp = s_baseTime.AddMilliseconds(3000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 10),
+ RawValue(expectedMinimumTimestamp, 3),
+ RawValue(s_baseTime.AddMilliseconds(6000), 20),
+ RawValue(s_baseTime.AddMilliseconds(9000), 30),
+ RawValue(startTime, 40)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_MinimumActualTime, values, startTime, s_baseTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(3.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(expectedMinimumTimestamp));
+ Assert.That(result.ServerTimestamp, Is.EqualTo(expectedMinimumTimestamp));
+ Assert.That(result.SourceTimestamp, Is.Not.EqualTo(startTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Raw));
+ }
+
+ [Test]
+ public void Maximum2ForwardWithMaximumAtIntervalStartIsRaw()
+ {
+ DateTime endTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 50),
+ RawValue(s_baseTime.AddMilliseconds(3000), 20),
+ RawValue(s_baseTime.AddMilliseconds(6000), 30),
+ RawValue(s_baseTime.AddMilliseconds(9000), 40),
+ RawValue(endTime, 45)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Maximum2, values, s_baseTime, endTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(50.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(s_baseTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Raw));
+ }
+
+ [Test]
+ public void Maximum2ReverseWithMaximumAtRequestIntervalStartIsRaw()
+ {
+ DateTime startTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 10),
+ RawValue(s_baseTime.AddMilliseconds(3000), 20),
+ RawValue(s_baseTime.AddMilliseconds(6000), 30),
+ RawValue(s_baseTime.AddMilliseconds(9000), 40),
+ RawValue(startTime, 50)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Maximum2, values, startTime, s_baseTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(50.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Raw));
+ }
+
+ [Test]
+ public void Maximum2ReverseWithMaximumNotAtRequestIntervalStartIsCalculated()
+ {
+ DateTime startTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 10),
+ RawValue(s_baseTime.AddMilliseconds(3000), 50),
+ RawValue(s_baseTime.AddMilliseconds(6000), 30),
+ RawValue(s_baseTime.AddMilliseconds(9000), 20),
+ RawValue(startTime, 40)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Maximum2, values, startTime, s_baseTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(50.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated));
+ }
+
+ [Test]
+ public void Minimum2ReverseWithMinimumAtRequestIntervalStartIsRaw()
+ {
+ DateTime startTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 40),
+ RawValue(s_baseTime.AddMilliseconds(3000), 30),
+ RawValue(s_baseTime.AddMilliseconds(6000), 20),
+ RawValue(s_baseTime.AddMilliseconds(9000), 15),
+ RawValue(startTime, 3)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Minimum2, values, startTime, s_baseTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(3.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Raw));
+ }
+
+ [Test]
+ public void Minimum2ReverseWithMinimumNotAtRequestIntervalStartIsCalculated()
+ {
+ DateTime startTime = s_baseTime.AddMilliseconds(12000);
+ var values = new List
+ {
+ RawValue(s_baseTime, 40),
+ RawValue(s_baseTime.AddMilliseconds(3000), 3),
+ RawValue(s_baseTime.AddMilliseconds(6000), 20),
+ RawValue(s_baseTime.AddMilliseconds(9000), 30),
+ RawValue(startTime, 35)
+ };
+
+ DataValue result = ComputeSingleInterval(
+ ObjectIds.AggregateFunction_Minimum2, values, startTime, s_baseTime, 12000);
+
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(3.0).Within(0.0001));
+ Assert.That(result.SourceTimestamp, Is.EqualTo(startTime));
+ Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated));
+ }
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs b/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs
index 3ce3447ffa..c930270649 100644
--- a/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs
+++ b/Tests/Opc.Ua.Server.Tests/ReferenceServerTest.cs
@@ -1190,6 +1190,320 @@ namespace Opc.Ua.Server.Tests
logger);
}
+ [Test]
+ public void ReferenceServerPublishesHistoricalConformanceMetadata()
+ {
+ ServerCapabilitiesState capabilities = m_server.CurrentInstance.DiagnosticsNodeManager
+ .FindPredefinedNode(
+ ObjectIds.Server_ServerCapabilities);
+ string[] profiles = capabilities.ServerProfileArray.Value;
+ QualifiedName[] units = capabilities.ConformanceUnits.Value;
+
+ Assert.AreEqual(
+ 1,
+ profiles.Count(profile =>
+ profile == "http://opcfoundation.org/UA-Profile/Server/HistoricalRawData2022"));
+ Assert.AreEqual(
+ 1,
+ profiles.Count(profile =>
+ profile == "http://opcfoundation.org/UA-Profile/Server/AggregateHistorical2022"));
+ Assert.IsTrue(units.Any(unit => unit.Name == "Historical Access Read Raw"));
+ Assert.IsTrue(units.Any(unit => unit.Name == "Historical Access Aggregates"));
+ Assert.IsTrue(units.Any(unit => unit.Name == "Address Space Base"));
+
+ HistoryServerCapabilitiesState historyCapabilities = m_server.CurrentInstance
+ .DiagnosticsNodeManager.GetDefaultHistoryCapabilities();
+ Assert.IsTrue(historyCapabilities.AccessHistoryDataCapability.Value);
+ Assert.IsTrue(historyCapabilities.ServerTimestampSupported.Value);
+ Assert.IsFalse(historyCapabilities.AccessHistoryEventsCapability.Value);
+ Assert.IsFalse(historyCapabilities.InsertDataCapability.Value);
+ Assert.IsFalse(historyCapabilities.ReplaceDataCapability.Value);
+ Assert.IsFalse(historyCapabilities.UpdateDataCapability.Value);
+
+ ServerObjectState serverObject = m_server.CurrentInstance.DiagnosticsNodeManager
+ .FindPredefinedNode(ObjectIds.Server);
+ Assert.IsTrue((serverObject.EventNotifier & EventNotifiers.HistoryRead) != 0);
+ Assert.IsFalse((serverObject.EventNotifier & EventNotifiers.HistoryWrite) != 0);
+
+ ReferenceNodeManager nodeManager = m_server.CurrentInstance.NodeManager.NodeManagers
+ .OfType()
+ .Single();
+ ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
+ Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
+ BaseVariableState int32Static = nodeManager.FindPredefinedNode(
+ new NodeId("Scalar_Static_Int32", namespaceIndex));
+ uint expectedPermissions =
+ (uint)PermissionType.Browse |
+ (uint)PermissionType.Read |
+ (uint)PermissionType.Write |
+ (uint)PermissionType.ReadHistory |
+ (uint)PermissionType.ReadRolePermissions;
+ Assert.IsTrue(int32Static.RolePermissions.Any(permission =>
+ permission.RoleId == ObjectIds.WellKnownRole_Anonymous &&
+ permission.Permissions == expectedPermissions));
+ Assert.IsTrue(int32Static.RolePermissions.Any(permission =>
+ permission.RoleId == ObjectIds.WellKnownRole_AuthenticatedUser &&
+ permission.Permissions == expectedPermissions));
+ Assert.IsTrue(int32Static.RolePermissions.Any(permission =>
+ permission.RoleId == ObjectIds.WellKnownRole_SecurityAdmin &&
+ permission.Permissions == 0xFFFF));
+ Assert.IsTrue(int32Static.UserRolePermissions.Any(permission =>
+ permission.RoleId == ObjectIds.WellKnownRole_Anonymous &&
+ permission.Permissions == expectedPermissions));
+ Assert.IsTrue(int32Static.UserRolePermissions.Any(permission =>
+ permission.RoleId == ObjectIds.WellKnownRole_AuthenticatedUser &&
+ permission.Permissions == expectedPermissions));
+ }
+
+ [Test]
+ public void ReferenceHistoricalConfigurationsHaveUniqueInstanceNodeIds()
+ {
+ ReferenceNodeManager nodeManager = m_server.CurrentInstance.NodeManager.NodeManagers
+ .OfType()
+ .Single();
+ ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
+ Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
+ NodeId[] variableIds =
+ [
+ new NodeId("Scalar_Static_Int32", namespaceIndex),
+ new NodeId("Aggregates_Int32", namespaceIndex)
+ ];
+ var allConfigurationNodeIds = new HashSet();
+
+ foreach (NodeId variableId in variableIds)
+ {
+ BaseVariableState variable = nodeManager.FindPredefinedNode(
+ variableId);
+ var children = new List();
+ variable.GetChildren(nodeManager.SystemContext, children);
+ HistoricalDataConfigurationState configuration = children
+ .OfType()
+ .Single();
+
+ Assert.AreNotEqual(variable.NodeId, configuration.NodeId);
+ Assert.IsFalse(configuration.Stepped.Value);
+ Assert.IsTrue(configuration.ServerTimestampSupported.Value);
+ Assert.IsNotNull(configuration.AggregateConfiguration);
+ Assert.AreNotEqual(DateTime.MinValue, configuration.StartOfArchive.Value);
+ Assert.AreEqual(
+ configuration.StartOfArchive.Value,
+ configuration.StartOfOnlineArchive.Value);
+ Assert.IsTrue(
+ configuration.AggregateConfiguration.TreatUncertainAsBad.Value);
+ Assert.AreEqual(
+ 100,
+ configuration.AggregateConfiguration.PercentDataBad.Value);
+ Assert.AreEqual(
+ 100,
+ configuration.AggregateConfiguration.PercentDataGood.Value);
+ Assert.IsFalse(
+ configuration.AggregateConfiguration.UseSlopedExtrapolation.Value);
+
+ var references = new List();
+ variable.GetReferences(nodeManager.SystemContext, references);
+ Assert.IsTrue(references.Any(reference =>
+ !reference.IsInverse &&
+ reference.ReferenceTypeId == ReferenceTypeIds.HasHistoricalConfiguration &&
+ ExpandedNodeId.ToNodeId(
+ reference.TargetId,
+ m_server.CurrentInstance.NamespaceUris) == configuration.NodeId));
+
+ references.Clear();
+ configuration.GetReferences(nodeManager.SystemContext, references);
+ Assert.IsTrue(references.Any(reference =>
+ reference.IsInverse &&
+ reference.ReferenceTypeId == ReferenceTypeIds.HasHistoricalConfiguration &&
+ ExpandedNodeId.ToNodeId(
+ reference.TargetId,
+ m_server.CurrentInstance.NamespaceUris) == variable.NodeId));
+
+ AddInstanceNodeIds(
+ nodeManager.SystemContext,
+ configuration,
+ allConfigurationNodeIds);
+ }
+ }
+
+ [Test]
+ public void ReferenceTwoDimensionalValueMatchesArrayDimensions()
+ {
+ ReferenceNodeManager nodeManager = m_server.CurrentInstance.NodeManager.NodeManagers
+ .OfType()
+ .Single();
+ ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
+ Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
+ BaseVariableState variable = nodeManager.FindPredefinedNode(
+ new NodeId("Scalar_Static_Arrays2D_Int32", namespaceIndex));
+
+ Assert.AreEqual(ValueRanks.TwoDimensions, variable.ValueRank);
+ Assert.That(variable.ArrayDimensions, Is.EqualTo(new uint[] { 3, 3 }));
+ Assert.That(variable.Value, Is.AssignableTo());
+ var value = (Array)variable.Value;
+ Assert.AreEqual(2, value.Rank);
+ Assert.AreEqual(3, value.GetLength(0));
+ Assert.AreEqual(3, value.GetLength(1));
+ }
+
+ [Test]
+ public void HistoryReadRejectsNeitherAndInsufficientRawDetails()
+ {
+ ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
+ Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
+ var nodes = new HistoryReadValueIdCollection
+ {
+ new HistoryReadValueId
+ {
+ NodeId = new NodeId("Scalar_Static_Int32", namespaceIndex)
+ }
+ };
+ var validDetails = new ReadRawModifiedDetails
+ {
+ StartTime = DateTime.UtcNow.AddMinutes(-1),
+ EndTime = DateTime.UtcNow
+ };
+
+ ServiceResultException timestampError = NUnit.Framework.Assert
+ .ThrowsAsync(async () =>
+ await m_server.HistoryReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ new ExtensionObject(validDetails),
+ TimestampsToReturn.Neither,
+ false,
+ nodes,
+ CancellationToken.None).ConfigureAwait(false));
+ Assert.AreEqual(
+ (StatusCode)StatusCodes.BadTimestampsToReturnInvalid,
+ timestampError.StatusCode);
+
+ var invalidDetails = new ReadRawModifiedDetails
+ {
+ StartTime = DateTime.MinValue,
+ EndTime = DateTime.MinValue,
+ NumValuesPerNode = 0
+ };
+ ServiceResultException detailsError = NUnit.Framework.Assert
+ .ThrowsAsync(async () =>
+ await m_server.HistoryReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ new ExtensionObject(invalidDetails),
+ TimestampsToReturn.Both,
+ false,
+ nodes,
+ CancellationToken.None).ConfigureAwait(false));
+ Assert.AreEqual(
+ (StatusCode)StatusCodes.BadHistoryOperationInvalid,
+ detailsError.StatusCode);
+ }
+
+ [Test]
+ public async Task HistoryReadReturnsNoDataAndRejectsScalarIndexRangeAsync()
+ {
+ ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
+ Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
+ var nodeId = new NodeId("Scalar_Static_Int32", namespaceIndex);
+ var details = new ReadRawModifiedDetails
+ {
+ StartTime = DateTime.UtcNow.AddDays(1),
+ EndTime = DateTime.UtcNow.AddDays(2),
+ ReturnBounds = false
+ };
+ var nodes = new HistoryReadValueIdCollection
+ {
+ new HistoryReadValueId { NodeId = nodeId }
+ };
+
+ HistoryReadResponse noData = await m_server.HistoryReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ new ExtensionObject(details),
+ TimestampsToReturn.Both,
+ false,
+ nodes,
+ CancellationToken.None).ConfigureAwait(false);
+ Assert.AreEqual((StatusCode)StatusCodes.GoodNoData, noData.Results[0].StatusCode);
+ Assert.IsTrue(noData.Results[0].ContinuationPoint == null ||
+ noData.Results[0].ContinuationPoint.Length == 0);
+ Assert.AreEqual(0, ((HistoryData)noData.Results[0].HistoryData.Body).DataValues.Count);
+
+ nodes[0].IndexRange = "0";
+ HistoryReadResponse range = await m_server.HistoryReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ new ExtensionObject(details),
+ TimestampsToReturn.Both,
+ false,
+ nodes,
+ CancellationToken.None).ConfigureAwait(false);
+ Assert.AreEqual(
+ (StatusCode)StatusCodes.BadIndexRangeNoData,
+ range.Results[0].StatusCode);
+ }
+
+ [Test]
+ public async Task RawHistoryRejectsUnknownContinuationPointAsync()
+ {
+ ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
+ Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
+ var details = new ReadRawModifiedDetails
+ {
+ StartTime = DateTime.UtcNow.AddMinutes(-10),
+ EndTime = DateTime.UtcNow
+ };
+ var nodes = new HistoryReadValueIdCollection
+ {
+ new HistoryReadValueId
+ {
+ NodeId = new NodeId("Scalar_Static_Int32", namespaceIndex),
+ ContinuationPoint = Guid.NewGuid().ToByteArray()
+ }
+ };
+
+ HistoryReadResponse response = await m_server.HistoryReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ new ExtensionObject(details),
+ TimestampsToReturn.Both,
+ false,
+ nodes,
+ CancellationToken.None).ConfigureAwait(false);
+
+ Assert.AreEqual(
+ (StatusCode)StatusCodes.BadContinuationPointInvalid,
+ response.Results[0].StatusCode);
+ }
+
+ [Test]
+ public async Task StaticValueReadReceivesFreshServerTimestampAsync()
+ {
+ ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
+ Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
+ DateTime readTime = DateTime.UtcNow;
+ var nodes = new ReadValueIdCollection
+ {
+ new ReadValueId
+ {
+ NodeId = new NodeId("Scalar_Static_Int32", namespaceIndex),
+ AttributeId = Attributes.Value
+ }
+ };
+
+ ReadResponse response = await m_server.ReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ 0,
+ TimestampsToReturn.Both,
+ nodes,
+ CancellationToken.None).ConfigureAwait(false);
+
+ Assert.IsTrue(StatusCode.IsGood(response.Results[0].StatusCode));
+ Assert.GreaterOrEqual(response.Results[0].ServerTimestamp, readTime);
+ Assert.GreaterOrEqual(
+ response.Results[0].ServerTimestamp,
+ response.Results[0].SourceTimestamp);
+ }
+
///
/// Test that Server object EventNotifier has HistoryRead bit set when history capabilities are enabled.
///
@@ -1394,9 +1708,12 @@ namespace Opc.Ua.Server.Tests
logger.LogInformation("History read StatusCode: {StatusCode}", result.StatusCode);
- // The result should be Good or GoodMoreData (if there are more values)
- Assert.IsTrue(StatusCode.IsGood(result.StatusCode),
- $"History read should succeed, but got: {result.StatusCode}");
+ Assert.AreEqual(
+ (StatusCode)StatusCodes.Good,
+ result.StatusCode,
+ "A continuation point, not GoodMoreData, indicates that more history is available.");
+ Assert.IsNotNull(result.ContinuationPoint);
+ Assert.AreEqual(16, result.ContinuationPoint.Length);
Assert.IsNotNull(result.HistoryData, "HistoryData should not be null");
// Verify we got HistoryData back
@@ -1420,6 +1737,311 @@ namespace Opc.Ua.Server.Tests
}
}
+ ///
+ /// Verifies that one-sided raw history reads use the specified time as
+ /// the starting boundary and return values in the required direction.
+ ///
+ [TestCase(true)]
+ [TestCase(false)]
+ public async Task HistoryReadOneSidedTimeReturnsValuesInRequestedOrderAsync(bool startOnly)
+ {
+ var int32ValueNodeId = new NodeId(
+ TestData.Variables.Data_Dynamic_Scalar_Int32Value,
+ (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(TestData.Namespaces.TestData));
+
+ DateTime boundary = startOnly ? DateTime.UtcNow.AddHours(-1) : DateTime.UtcNow;
+ var historyReadDetails = new ReadRawModifiedDetails
+ {
+ StartTime = startOnly ? boundary : DateTime.MinValue,
+ EndTime = startOnly ? DateTime.MinValue : boundary,
+ NumValuesPerNode = 10,
+ IsReadModified = false,
+ ReturnBounds = false
+ };
+
+ var nodesToRead = new HistoryReadValueIdCollection {
+ new HistoryReadValueId {
+ NodeId = int32ValueNodeId
+ }
+ };
+
+ m_requestHeader.Timestamp = DateTime.UtcNow;
+ HistoryReadResponse response = await m_server.HistoryReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ new ExtensionObject(historyReadDetails),
+ TimestampsToReturn.Both,
+ false,
+ nodesToRead,
+ CancellationToken.None).ConfigureAwait(false);
+
+ ServerFixtureUtils.ValidateResponse(response.ResponseHeader, response.Results, nodesToRead);
+ Assert.AreEqual(1, response.Results.Count);
+
+ HistoryReadResult result = response.Results[0];
+ Assert.IsTrue(StatusCode.IsGood(result.StatusCode),
+ $"History read should succeed, but got: {result.StatusCode}");
+ Assert.IsTrue(result.ContinuationPoint == null || result.ContinuationPoint.Length == 0,
+ "A one-sided read limited by NumValuesPerNode should not return a continuation point.");
+ Assert.IsTrue(result.HistoryData?.Body is HistoryData,
+ "HistoryData body should be of type HistoryData.");
+
+ var historyData = (HistoryData)result.HistoryData.Body;
+ Assert.AreEqual(10, historyData.DataValues.Count);
+
+ for (int ii = 1; ii < historyData.DataValues.Count; ii++)
+ {
+ DateTime previous = historyData.DataValues[ii - 1].ServerTimestamp;
+ DateTime current = historyData.DataValues[ii].ServerTimestamp;
+ Assert.IsTrue(startOnly ? current > previous : current < previous,
+ $"History values should be returned in {(startOnly ? "ascending" : "descending")} timestamp order.");
+ }
+ }
+
+ [Test]
+ public async Task ReferenceStaticInt32SupportsProcessedHistoryAndContinuationAsync()
+ {
+ ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
+ Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
+ var nodeId = new NodeId("Scalar_Static_Int32", namespaceIndex);
+
+ var attributes = new ReadValueIdCollection
+ {
+ new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Historizing },
+ new ReadValueId { NodeId = nodeId, AttributeId = Attributes.AccessLevel }
+ };
+ ReadResponse attributeResponse = await m_server.ReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ kMaxAge,
+ TimestampsToReturn.Neither,
+ attributes,
+ CancellationToken.None).ConfigureAwait(false);
+ Assert.IsTrue((bool)attributeResponse.Results[0].Value);
+ Assert.IsTrue(
+ ((byte)attributeResponse.Results[1].Value & AccessLevels.HistoryRead) != 0);
+
+ DateTime end = DateTime.UtcNow;
+ DateTime start = end.AddSeconds(-10000);
+ var details = new ReadProcessedDetails
+ {
+ StartTime = start,
+ EndTime = end,
+ ProcessingInterval = 5000,
+ AggregateType = [ObjectIds.AggregateFunction_Average],
+ AggregateConfiguration = new AggregateConfiguration
+ {
+ UseServerCapabilitiesDefaults = true
+ }
+ };
+ var nodes = new HistoryReadValueIdCollection
+ {
+ new HistoryReadValueId { NodeId = nodeId }
+ };
+
+ HistoryReadResponse first = await m_server.HistoryReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ new ExtensionObject(details),
+ TimestampsToReturn.Both,
+ false,
+ nodes,
+ CancellationToken.None).ConfigureAwait(false);
+
+ Assert.IsTrue(StatusCode.IsGood(first.Results[0].StatusCode));
+ Assert.IsNotNull(first.Results[0].ContinuationPoint);
+ Assert.AreEqual(16, first.Results[0].ContinuationPoint.Length);
+ var firstPage = (HistoryData)first.Results[0].HistoryData.Body;
+ Assert.AreEqual(1000, firstPage.DataValues.Count);
+
+ nodes[0].ContinuationPoint = first.Results[0].ContinuationPoint;
+ HistoryReadResponse second = await m_server.HistoryReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ new ExtensionObject(details),
+ TimestampsToReturn.Both,
+ false,
+ nodes,
+ CancellationToken.None).ConfigureAwait(false);
+
+ Assert.IsTrue(StatusCode.IsGood(second.Results[0].StatusCode));
+ var secondPage = (HistoryData)second.Results[0].HistoryData.Body;
+ Assert.Greater(secondPage.DataValues.Count, 0);
+ Assert.LessOrEqual(secondPage.DataValues.Count, 1000);
+ Assert.Greater(
+ secondPage.DataValues[0].SourceTimestamp,
+ firstPage.DataValues[^1].SourceTimestamp);
+ Assert.IsTrue(
+ second.Results[0].ContinuationPoint == null ||
+ second.Results[0].ContinuationPoint.Length == 0);
+ }
+
+ [Test]
+ public async Task ReferenceProcessedContinuationIsNodeBoundAndReleasableAsync()
+ {
+ ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
+ Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
+ var originalNodeId = new NodeId("Scalar_Static_Int32", namespaceIndex);
+ var otherNodeId = new NodeId("Aggregates_Int32", namespaceIndex);
+ DateTime end = DateTime.UtcNow;
+ var details = new ReadProcessedDetails
+ {
+ StartTime = end.AddSeconds(-10000),
+ EndTime = end,
+ ProcessingInterval = 5000,
+ AggregateType = [ObjectIds.AggregateFunction_Average],
+ AggregateConfiguration = new AggregateConfiguration
+ {
+ UseServerCapabilitiesDefaults = true
+ }
+ };
+
+ Task ReadAsync(
+ NodeId nodeId,
+ byte[] continuationPoint,
+ bool release)
+ {
+ var nodes = new HistoryReadValueIdCollection
+ {
+ new HistoryReadValueId
+ {
+ NodeId = nodeId,
+ ContinuationPoint = continuationPoint
+ }
+ };
+ m_requestHeader.Timestamp = DateTime.UtcNow;
+ return m_server.HistoryReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ new ExtensionObject(details),
+ TimestampsToReturn.Both,
+ release,
+ nodes,
+ CancellationToken.None);
+ }
+
+ HistoryReadResponse first = await ReadAsync(
+ originalNodeId,
+ null,
+ false).ConfigureAwait(false);
+ byte[] wrongNodeContinuationPoint = first.Results[0].ContinuationPoint;
+ Assert.That(wrongNodeContinuationPoint, Has.Length.EqualTo(16));
+
+ HistoryReadResponse wrongNode = await ReadAsync(
+ otherNodeId,
+ wrongNodeContinuationPoint,
+ false).ConfigureAwait(false);
+ Assert.AreEqual(
+ (StatusCode)StatusCodes.BadContinuationPointInvalid,
+ wrongNode.Results[0].StatusCode);
+
+ HistoryReadResponse second = await ReadAsync(
+ originalNodeId,
+ null,
+ false).ConfigureAwait(false);
+ byte[] releasedContinuationPoint = second.Results[0].ContinuationPoint;
+ Assert.That(releasedContinuationPoint, Has.Length.EqualTo(16));
+
+ HistoryReadResponse released = await ReadAsync(
+ originalNodeId,
+ releasedContinuationPoint,
+ true).ConfigureAwait(false);
+ Assert.IsTrue(StatusCode.IsGood(released.Results[0].StatusCode));
+
+ HistoryReadResponse reused = await ReadAsync(
+ originalNodeId,
+ releasedContinuationPoint,
+ false).ConfigureAwait(false);
+ Assert.AreEqual(
+ (StatusCode)StatusCodes.BadContinuationPointInvalid,
+ reused.Results[0].StatusCode);
+ }
+
+ [Test]
+ public async Task ReferenceStaticInt32ProcessedHistorySupportsReverseOrderAsync()
+ {
+ ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
+ Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
+ var nodeId = new NodeId("Scalar_Static_Int32", namespaceIndex);
+ DateTime end = DateTime.UtcNow;
+ DateTime start = end.AddSeconds(-3600);
+ var details = new ReadProcessedDetails
+ {
+ StartTime = end,
+ EndTime = start,
+ ProcessingInterval = 60000,
+ AggregateType = [ObjectIds.AggregateFunction_Minimum],
+ AggregateConfiguration = new AggregateConfiguration
+ {
+ UseServerCapabilitiesDefaults = true
+ }
+ };
+ var nodes = new HistoryReadValueIdCollection
+ {
+ new HistoryReadValueId { NodeId = nodeId }
+ };
+
+ HistoryReadResponse response = await m_server.HistoryReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ new ExtensionObject(details),
+ TimestampsToReturn.Both,
+ false,
+ nodes,
+ CancellationToken.None).ConfigureAwait(false);
+
+ Assert.IsTrue(StatusCode.IsGood(response.Results[0].StatusCode));
+ var data = (HistoryData)response.Results[0].HistoryData.Body;
+ Assert.Greater(data.DataValues.Count, 1);
+ for (int ii = 1; ii < data.DataValues.Count; ii++)
+ {
+ Assert.Less(
+ data.DataValues[ii].SourceTimestamp,
+ data.DataValues[ii - 1].SourceTimestamp);
+ }
+ }
+
+ [Test]
+ public async Task ReferenceStaticInt32AnnotationCountUsesSidecarAsync()
+ {
+ ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
+ Quickstarts.ReferenceServer.Namespaces.ReferenceServer);
+ var nodeId = new NodeId("Scalar_Static_Int32", namespaceIndex);
+ DateTime end = DateTime.UtcNow;
+ var details = new ReadProcessedDetails
+ {
+ StartTime = end.AddSeconds(-10000),
+ EndTime = end,
+ ProcessingInterval = 0,
+ AggregateType = [ObjectIds.AggregateFunction_AnnotationCount],
+ AggregateConfiguration = new AggregateConfiguration
+ {
+ UseServerCapabilitiesDefaults = true
+ }
+ };
+ var nodes = new HistoryReadValueIdCollection
+ {
+ new HistoryReadValueId { NodeId = nodeId }
+ };
+
+ HistoryReadResponse response = await m_server.HistoryReadAsync(
+ m_secureChannelContext,
+ m_requestHeader,
+ new ExtensionObject(details),
+ TimestampsToReturn.Both,
+ false,
+ nodes,
+ CancellationToken.None).ConfigureAwait(false);
+
+ Assert.IsTrue(StatusCode.IsGood(response.Results[0].StatusCode));
+ var data = (HistoryData)response.Results[0].HistoryData.Body;
+ Assert.AreEqual(1, data.DataValues.Count);
+ Assert.AreEqual(4, data.DataValues[0].Value);
+ Assert.AreEqual(
+ AggregateBits.Calculated,
+ data.DataValues[0].StatusCode.AggregateBits);
+ }
+
///
/// Test provisioning mode - server should start with limited namespace.
///
@@ -1463,8 +2085,34 @@ namespace Opc.Ua.Server.Tests
Assert.IsTrue(hasEndpointWithoutAnonymous,
"At least one endpoint should not allow anonymous authentication in provisioning mode");
+ string[] profiles = server.CurrentInstance.ServerObject.ServerCapabilities
+ .ServerProfileArray.Value;
+ Assert.IsFalse(profiles.Contains(
+ "http://opcfoundation.org/UA-Profile/Server/HistoricalRawData2022"));
+ Assert.IsFalse(profiles.Contains(
+ "http://opcfoundation.org/UA-Profile/Server/AggregateHistorical2022"));
+ HistoryServerCapabilitiesState historyCapabilities = server.CurrentInstance
+ .DiagnosticsNodeManager.GetDefaultHistoryCapabilities();
+ Assert.IsFalse(historyCapabilities.AccessHistoryDataCapability.Value);
+
// Clean up
await fixture.StopAsync().ConfigureAwait(false);
}
+
+ private static void AddInstanceNodeIds(
+ ISystemContext context,
+ BaseInstanceState node,
+ ISet nodeIds)
+ {
+ Assert.IsNotNull(node.NodeId);
+ Assert.IsTrue(nodeIds.Add(node.NodeId), $"Duplicate instance NodeId: {node.NodeId}");
+
+ var children = new List();
+ node.GetChildren(context, children);
+ foreach (BaseInstanceState child in children)
+ {
+ AddInstanceNodeIds(context, child, nodeIds);
+ }
+ }
}
}
diff --git a/Tests/Opc.Ua.Server.Tests/StartEndAggregateCalculatorEdgeTests.cs b/Tests/Opc.Ua.Server.Tests/StartEndAggregateCalculatorEdgeTests.cs
new file mode 100644
index 0000000000..9b036d2076
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/StartEndAggregateCalculatorEdgeTests.cs
@@ -0,0 +1,412 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 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 System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Server.Tests
+{
+ ///
+ /// Edge-case tests for covering bad-data handling,
+ /// empty slices, non-castable values and the base-class fall-through paths.
+ ///
+ [TestFixture]
+ [Category("Aggregators")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ [Parallelizable]
+ public class StartEndAggregateCalculatorEdgeTests
+ {
+ private ITelemetryContext m_telemetry;
+ private AggregateConfiguration m_configuration;
+
+ [SetUp]
+ public void SetUp()
+ {
+ m_telemetry = NUnitTelemetryContext.Create();
+ m_configuration = new AggregateConfiguration
+ {
+ TreatUncertainAsBad = false,
+ PercentDataBad = 100,
+ PercentDataGood = 100,
+ UseSlopedExtrapolation = false
+ };
+ }
+
+ private static DataValue Value(double value, StatusCode status, DateTime timestamp)
+ {
+ return new DataValue(new Variant(value), status, timestamp, timestamp);
+ }
+
+ private DataValue RunFirst(IAggregateCalculator calculator, IEnumerable values)
+ {
+ foreach (DataValue value in values)
+ {
+ calculator.QueueRawValue(value);
+ }
+
+ DataValue result = default;
+ bool any = false;
+ while (calculator.TryGetProcessedValue(true, out DataValue value))
+ {
+ if (!any)
+ {
+ result = value;
+ any = true;
+ }
+ }
+
+ return result;
+ }
+
+ private DataValue ComputeStandard(
+ NodeId aggregateId, List values, DateTime startTime, DateTime endTime, double interval)
+ {
+ IAggregateCalculator calculator = Aggregators.CreateStandardCalculator(
+ aggregateId, startTime, endTime, interval, false, m_configuration, m_telemetry);
+ return RunFirst(calculator, values);
+ }
+
+ [Test]
+ public void DeltaWithLeadingBadDataMarksUncertain()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime t0 = startTime.AddMilliseconds(500);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ var values = new List
+ {
+ Value(1.0, StatusCodes.Bad, t0),
+ Value(10.0, StatusCodes.Good, t0.AddMilliseconds(2000)),
+ Value(20.0, StatusCodes.Good, t0.AddMilliseconds(4000)),
+ Value(30.0, StatusCodes.Good, t0.AddMilliseconds(6000))
+ };
+
+ DataValue result = ComputeStandard(
+ ObjectIds.AggregateFunction_Delta, values, startTime, endTime, 10000);
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.UncertainDataSubNormal));
+ Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(20.0).Within(0.0001));
+ }
+
+ [Test]
+ public void DeltaWithAllBadDataReturnsNoData()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime t0 = startTime.AddMilliseconds(500);
+ DateTime endTime = startTime.AddMilliseconds(6000);
+
+ var values = new List
+ {
+ Value(1.0, StatusCodes.Bad, t0),
+ Value(2.0, StatusCodes.Bad, t0.AddMilliseconds(2000)),
+ Value(3.0, StatusCodes.Bad, t0.AddMilliseconds(4000))
+ };
+
+ DataValue result = ComputeStandard(
+ ObjectIds.AggregateFunction_Delta, values, startTime, endTime, 6000);
+
+ Assert.That(StatusCode.IsBad(result.StatusCode), Is.True);
+ }
+
+ [Test]
+ public void DeltaBoundsWithBadBoundReturnsNoData()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime t0 = startTime.AddMilliseconds(500);
+ DateTime endTime = startTime.AddMilliseconds(6000);
+
+ var values = new List
+ {
+ Value(10.0, StatusCodes.Bad, t0),
+ Value(20.0, StatusCodes.Bad, t0.AddMilliseconds(2000))
+ };
+
+ DataValue result = ComputeStandard(
+ ObjectIds.AggregateFunction_DeltaBounds, values, startTime, endTime, 6000);
+
+ Assert.That(StatusCode.IsBad(result.StatusCode), Is.True);
+ }
+
+ [Test]
+ public void StartEndCalculatorWithUnhandledNumericIdFallsBackToBaseInterpolation()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ // Count is not a Start/End aggregate; the switch default falls back to base.ComputeValue.
+ var calculator = new StartEndAggregateCalculator(
+ ObjectIds.AggregateFunction_Count, startTime, endTime, 5000, false, m_configuration, m_telemetry);
+
+ var values = new List
+ {
+ Value(10.0, StatusCodes.Good, startTime.AddMilliseconds(1000)),
+ Value(20.0, StatusCodes.Good, startTime.AddMilliseconds(6000))
+ };
+
+ DataValue result = RunFirst(calculator, values);
+
+ Assert.That(result, Is.Not.Null);
+ }
+
+ [Test]
+ public void StartEndCalculatorWithNonNumericIdFallsBackToBaseInterpolation()
+ {
+ var startTime = new DateTime(2024, 1, 1, 0, 0, 0);
+ DateTime endTime = startTime.AddMilliseconds(10000);
+
+ // A non-numeric aggregate id causes AggregateId.TryGetValue(out uint) to fail.
+ var calculator = new StartEndAggregateCalculator(
+ new NodeId("CustomAggregate", 1), startTime, endTime, 5000, false, m_configuration, m_telemetry);
+
+ var values = new List
+ {
+ Value(10.0, StatusCodes.Good, startTime.AddMilliseconds(1000)),
+ Value(20.0, StatusCodes.Good, startTime.AddMilliseconds(6000))
+ };
+
+ DataValue fallbackResult = RunFirst(calculator, values);
+
+ Assert.That(fallbackResult, Is.Not.Null);
+ }
+
+ private List RunAll(IAggregateCalculator calculator, IEnumerable values)
+ {
+ foreach (DataValue value in values)
+ {
+ calculator.QueueRawValue(value);
+ }
+
+ var results = new List