From dcfd4df45f6501ab2d015b1f3bf2051846ca11ce Mon Sep 17 00:00:00 2001 From: Suciu Mircea Adrian Date: Thu, 10 Sep 2026 09:44:50 +0300 Subject: [PATCH] Master378 backport ctt changes (#4326) # Description Backport CTT related changes from master into master378. --- .../Quickstarts.ReferenceServer.Config.xml | 4 + .../Properties/AssemblyInfo.cs | 2 + .../ReferenceServer/ReferenceNodeManager.cs | 495 +++++++++- .../ReferenceServer/ReferenceServer.cs | 32 + .../TestData/HistoryArchive.cs | 129 ++- .../TestData/HistoryDataReader.cs | 138 ++- .../TestData/HistoryFile.cs | 2 +- .../TestData/ProcessedHistoryAdapter.cs | 424 ++++++++ .../TestData/TestDataNodeManager.cs | 100 +- .../TestData/TestDataSystem.cs | 10 + Applications/UAReferenceServer.ctt.xml | 48 +- Docs/HistorianAggregateBackport.md | 74 ++ Docs/Profiles.md | 9 +- .../Aggregates/AggregateCalculator.cs | 38 +- .../Aggregates/AggregateManager.cs | 8 +- .../Aggregates/CountAggregateCalculator.cs | 33 +- .../Aggregates/MinMaxAggregateCalculator.cs | 4 +- .../Aggregates/StatusAggregateCalculator.cs | 48 +- .../Aggregates/StdDevAggregateCalculator.cs | 38 +- .../NodeManager/CustomNodeManager.cs | 13 +- .../NodeManager/IConformanceContributor.cs | 50 + .../Server/ConformanceUnitsManager.cs | 205 ++++ .../Opc.Ua.Server/Server/StandardServer.cs | 43 + .../Opc.Ua.Core/Stack/Tcp/TcpServerChannel.cs | 3 +- Stack/Opc.Ua.Types/Utils/TypeInfo.cs | 8 +- .../Stack/State/ConditionStateTests.cs | 62 ++ .../AggregateCalculatorCoverageTests.cs | 367 +++++++ .../AggregateCalculatorTests.cs | 54 +- .../AggregateCttRegressionTests.cs | 571 +++++++++++ .../AggregateManagerTests.cs | 228 +++++ .../AggregateRun14ResidualOracleTests.cs | 381 ++++++++ Tests/Opc.Ua.Server.Tests/AggregatorsTests.cs | 836 ++++++++++++++++ .../AverageAggregateCalculatorTests.cs | 365 +++++++ .../ConformanceUnitsManagerTests.cs | 128 +++ .../CountAggregateCalculatorTests.cs | 477 +++++++++ ...ateSessionApplicationUriValidationTests.cs | 121 ++- .../HistorianBackportTestCompatibility.cs | 61 ++ .../HistoryArchiveCompatibilityTests.cs | 356 +++++++ ...LegacyProcessedHistoryTestCompatibility.cs | 133 +++ .../MinMaxAggregateCalculatorTests.cs | 925 ++++++++++++++++++ .../ReferenceServerTest.cs | 654 ++++++++++++- .../StartEndAggregateCalculatorEdgeTests.cs | 412 ++++++++ .../StartEndAggregateCalculatorTests.cs | 370 +++++++ .../StatusAggregateCalculatorTests.cs | 471 +++++++++ ...tdDevAggregateCalculatorRegressionTests.cs | 213 ++++ .../StdDevAggregateCalculatorTests.cs | 399 ++++++++ .../BuiltIn/TypeInfoTests.cs | 50 + 47 files changed, 9391 insertions(+), 201 deletions(-) create mode 100644 Applications/Quickstarts.Servers/TestData/ProcessedHistoryAdapter.cs create mode 100644 Docs/HistorianAggregateBackport.md create mode 100644 Libraries/Opc.Ua.Server/NodeManager/IConformanceContributor.cs create mode 100644 Libraries/Opc.Ua.Server/Server/ConformanceUnitsManager.cs create mode 100644 Tests/Opc.Ua.Server.Tests/AggregateCalculatorCoverageTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/AggregateCttRegressionTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/AggregateManagerTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/AggregateRun14ResidualOracleTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/AggregatorsTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/AverageAggregateCalculatorTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/ConformanceUnitsManagerTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/CountAggregateCalculatorTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/HistorianBackportTestCompatibility.cs create mode 100644 Tests/Opc.Ua.Server.Tests/HistoryArchiveCompatibilityTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/LegacyProcessedHistoryTestCompatibility.cs create mode 100644 Tests/Opc.Ua.Server.Tests/MinMaxAggregateCalculatorTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/StartEndAggregateCalculatorEdgeTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/StartEndAggregateCalculatorTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/StatusAggregateCalculatorTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/StdDevAggregateCalculatorRegressionTests.cs create mode 100644 Tests/Opc.Ua.Server.Tests/StdDevAggregateCalculatorTests.cs 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(); + while (calculator.TryGetProcessedValue(true, out DataValue value)) + { + results.Add(value); + } + + return results; + } + + private List RunAllStandard( + NodeId aggregateId, List values, DateTime startTime, DateTime endTime, double interval) + { + IAggregateCalculator calculator = Aggregators.CreateStandardCalculator( + aggregateId, startTime, endTime, interval, false, m_configuration, m_telemetry); + return RunAll(calculator, values); + } + + [Test] + public void StartAggregateReturnsFirstGoodValue() + { + 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(10.0, StatusCodes.Good, t0), + Value(20.0, StatusCodes.Good, t0.AddMilliseconds(2000)), + Value(30.0, StatusCodes.Good, t0.AddMilliseconds(4000)) + }; + + DataValue result = ComputeStandard( + ObjectIds.AggregateFunction_Start, values, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(10.0).Within(0.0001)); + } + + [Test] + public void EndAggregateReturnsLastGoodValue() + { + 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(10.0, StatusCodes.Good, t0), + Value(20.0, StatusCodes.Good, t0.AddMilliseconds(2000)), + Value(30.0, StatusCodes.Good, t0.AddMilliseconds(4000)) + }; + + DataValue result = ComputeStandard( + ObjectIds.AggregateFunction_End, values, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(30.0).Within(0.0001)); + } + + [Test] + public void DeltaAggregateComputesDifferenceForGoodData() + { + 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(5.0, StatusCodes.Good, t0), + Value(15.0, StatusCodes.Good, t0.AddMilliseconds(2000)), + Value(35.0, StatusCodes.Good, t0.AddMilliseconds(4000)) + }; + + DataValue result = ComputeStandard( + ObjectIds.AggregateFunction_Delta, values, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(30.0).Within(0.0001)); + } + + [Test] + public void StartAndEndAggregatesReturnNoDataForEmptySlices() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + + // Values only in the first slice; trailing slices are empty and must return NoData. + var values = new List + { + Value(10.0, StatusCodes.Good, startTime.AddMilliseconds(200)), + Value(20.0, StatusCodes.Good, startTime.AddMilliseconds(400)) + }; + + List startResults = RunAllStandard( + ObjectIds.AggregateFunction_Start, values, startTime, endTime, 2000); + List deltaResults = RunAllStandard( + ObjectIds.AggregateFunction_Delta, values, startTime, endTime, 2000); + + Assert.That(startResults.Exists(r => StatusCode.IsBad(r.StatusCode)), Is.True); + Assert.That(deltaResults.Exists(r => StatusCode.IsBad(r.StatusCode)), Is.True); + } + + [Test] + public void DeltaWithNonNumericGoodValueReturnsNoData() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime t0 = startTime.AddMilliseconds(500); + DateTime endTime = startTime.AddMilliseconds(10000); + + var values = new List + { + new(new Variant("not-a-number"), StatusCodes.Good, t0, t0), + new( + new Variant("also-bad"), + StatusCodes.Good, + t0.AddMilliseconds(2000), + t0.AddMilliseconds(2000)) + }; + + DataValue result = ComputeStandard( + ObjectIds.AggregateFunction_Delta, values, startTime, endTime, 10000); + + Assert.That(StatusCode.IsBad(result.StatusCode), Is.True); + } + + [Test] + public void StartBoundAggregateReturnsStartValue() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + + var values = new List + { + Value(10.0, StatusCodes.Good, startTime), + Value(20.0, StatusCodes.Good, startTime.AddMilliseconds(5000)), + Value(30.0, StatusCodes.Good, endTime) + }; + + DataValue result = ComputeStandard( + ObjectIds.AggregateFunction_StartBound, values, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + } + + [Test] + public void EndBoundAggregateReturnsCalculatedEndValue() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + + var values = new List + { + Value(10.0, StatusCodes.Good, startTime), + Value(20.0, StatusCodes.Good, startTime.AddMilliseconds(5000)), + Value(30.0, StatusCodes.Good, endTime) + }; + + DataValue result = ComputeStandard( + ObjectIds.AggregateFunction_EndBound, values, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True); + } + + [Test] + public void DeltaBoundsAggregateComputesDifference() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + + var values = new List + { + Value(5.0, StatusCodes.Good, startTime), + Value(15.0, StatusCodes.Good, startTime.AddMilliseconds(5000)), + Value(25.0, StatusCodes.Good, endTime) + }; + + DataValue result = ComputeStandard( + ObjectIds.AggregateFunction_DeltaBounds, values, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.EqualTo(20.0).Within(0.0001)); + } + + [Test] + public void BoundAggregatesReturnNoDataForEmptySlices() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(4000); + + // No raw values at all: every bound slice must return NoData. + var values = new List(); + + List startBound = RunAllStandard( + ObjectIds.AggregateFunction_StartBound, values, startTime, endTime, 2000); + List deltaBounds = RunAllStandard( + ObjectIds.AggregateFunction_DeltaBounds, values, startTime, endTime, 2000); + + Assert.That(startBound.TrueForAll(r => StatusCode.IsNotGood(r.StatusCode)), Is.True); + Assert.That(deltaBounds.TrueForAll(r => StatusCode.IsNotGood(r.StatusCode)), Is.True); + } + } +} diff --git a/Tests/Opc.Ua.Server.Tests/StartEndAggregateCalculatorTests.cs b/Tests/Opc.Ua.Server.Tests/StartEndAggregateCalculatorTests.cs new file mode 100644 index 0000000000..f7a48d11d9 --- /dev/null +++ b/Tests/Opc.Ua.Server.Tests/StartEndAggregateCalculatorTests.cs @@ -0,0 +1,370 @@ +/* ======================================================================== + * 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 StartEndAggregateCalculatorTests + { + 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 StartReturnsFirstValue() + { + 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_Start, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double startVal = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(startVal, Is.EqualTo(10.0).Within(0.0001)); + } + + [Test] + public void EndReturnsLastValue() + { + 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_End, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + } + + [Test] + public void DeltaReturnsEndMinusStart() + { + 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_Delta, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double delta = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(delta, Is.GreaterThan(0)); + } + + [Test] + public void DeltaWithIdenticalValuesReturnsZero() + { + 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_Delta, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double delta = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(delta, Is.Zero.Within(0.0001)); + } + + [Test] + public void DeltaWithDecreasingValuesReturnsNegative() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime firstValueTime = startTime.AddMilliseconds(500); + double[] values = [50, 40, 30, 20, 10, 10]; + List dataValues = CreateDataValues(firstValueTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(12000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_Delta, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double delta = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(delta, Is.LessThan(0)); + } + + [Test] + public void DeltaResultHasCalculatedAggregateBits() + { + 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_Delta, + dataValues, startTime, endTime, 8000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True); + } + + [Test] + public void StartBoundReturnsResult() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + double[] values = [10, 20, 30, 40, 50, 60]; + List dataValues = CreateDataValues(startTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(12000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_StartBound, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + } + + [Test] + public void EndBoundReturnsResult() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + double[] values = [10, 20, 30, 40, 50, 60]; + List dataValues = CreateDataValues(startTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(12000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_EndBound, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + } + + [Test] + public void DeltaBoundsReturnsResult() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + double[] values = [10, 20, 30, 40, 50, 60]; + List dataValues = CreateDataValues(startTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(12000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_DeltaBounds, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + } + + [Test] + public void DeltaBoundsWithIdenticalValuesReturnsZero() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + double[] values = [42, 42, 42, 42, 42, 42]; + List dataValues = CreateDataValues(startTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(12000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_DeltaBounds, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + } + + [Test] + public void StartWithTwoValuesReturnsFirst() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime firstValueTime = startTime.AddMilliseconds(500); + double[] values = [100, 200]; + List dataValues = CreateDataValues(firstValueTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(4000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_Start, + dataValues, startTime, endTime, 4000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double startVal = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(startVal, Is.EqualTo(100.0).Within(0.0001)); + } + + [Test] + public void EndBoundHasCalculatedAggregateBits() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + double[] values = [10, 20, 30, 40]; + List dataValues = CreateDataValues(startTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(8000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_EndBound, + dataValues, startTime, endTime, 8000); + + Assert.That(result, Is.Not.Null); + } + + [Test] + public void DeltaBoundsResultHasCalculatedAggregateBits() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + double[] values = [10, 20, 30, 40]; + List dataValues = CreateDataValues(startTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(8000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_DeltaBounds, + dataValues, startTime, endTime, 8000); + + Assert.That(result, Is.Not.Null); + } + + [Test] + public void DeltaWithLargeChangeReturnsCorrectDifference() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime firstValueTime = startTime.AddMilliseconds(500); + double[] values = [0, 100, 200, 300, 400, 400]; + List dataValues = CreateDataValues(firstValueTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(12000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_Delta, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double delta = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(delta, Is.GreaterThan(0)); + } + + [Test] + public void StartAndEndWithSameValuesReturnSame() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime firstValueTime = startTime.AddMilliseconds(500); + double[] values = [77, 77, 77, 77, 77, 77]; + List dataValues = CreateDataValues(firstValueTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(12000); + + DataValue startResult = ComputeAggregate( + ObjectIds.AggregateFunction_Start, + dataValues, startTime, endTime, 12000); + + DataValue endResult = ComputeAggregate( + ObjectIds.AggregateFunction_End, + dataValues, startTime, endTime, 12000); + + Assert.That(startResult, Is.Not.Null); + Assert.That(endResult, Is.Not.Null); + double startVal = (double)startResult.WrappedValue.ConvertToDouble(); + double endVal = (double)endResult.WrappedValue.ConvertToDouble(); + Assert.That(startVal, Is.EqualTo(endVal).Within(0.0001)); + } + } +} diff --git a/Tests/Opc.Ua.Server.Tests/StatusAggregateCalculatorTests.cs b/Tests/Opc.Ua.Server.Tests/StatusAggregateCalculatorTests.cs new file mode 100644 index 0000000000..ff4f3a238b --- /dev/null +++ b/Tests/Opc.Ua.Server.Tests/StatusAggregateCalculatorTests.cs @@ -0,0 +1,471 @@ +/* ======================================================================== + * 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 +{ + /// + /// Value-validated tests for the status-based aggregates defined in OPC UA Part 13 v1.05.07 + /// §5.4.3.31-.36 (DurationGood, DurationBad, PercentGood, PercentBad, WorstQuality, + /// WorstQuality2). + /// + [TestFixture] + [Category("Aggregators")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Parallelizable] + public class StatusAggregateCalculatorTests + { + 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 Value, StatusCode Status)[] items, double intervalMs = 1000) + { + var dataValues = new List(); + for (int i = 0; i < items.Length; i++) + { + DateTime t = startTime.AddMilliseconds(i * intervalMs); + dataValues.Add(new DataValue(new Variant(items[i].Value), items[i].Status, t, t)); + } + 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); + + if (endTime < startTime) + { + for (int ii = values.Count - 1; ii >= 0; ii--) + { + Assert.That(calculator.QueueRawValue(values[ii]), Is.True); + } + } + else + { + foreach (DataValue value in values) + { + Assert.That(calculator.QueueRawValue(value), Is.True); + } + } + + var results = new List(); + while (calculator.TryGetProcessedValue(true, out DataValue result)) + { + results.Add(result); + } + + return results.Count > 0 ? results[0] : default; + } + + [Test] + public void WorstQualityAllGoodReturnsGood() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + List values = CreateDataValues( + startTime, + [ + (1, StatusCodes.Good), + (2, StatusCodes.Good), + (3, StatusCodes.Good), + (4, StatusCodes.Good), + (5, StatusCodes.Good) + ], + 2000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_WorstQuality, values, startTime, endTime, 10000); + + Assert.That(result.WrappedValue.TryGetValue(out StatusCode worst), Is.True); + Assert.That(StatusCode.IsGood(worst), Is.True); + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(result.StatusCode.AggregateBits & AggregateBits.Calculated, + Is.EqualTo(AggregateBits.Calculated)); + } + + [Test] + public void WorstQualityWithBadValuesReturnsBadAndMultipleValuesBit() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + + // Bad values placed in the interior so the result is independent of how boundary + // values are handled. + List values = CreateDataValues( + startTime, + [ + (1, StatusCodes.Good), + (2, StatusCodes.Bad), + (3, StatusCodes.Bad), + (4, StatusCodes.Good), + (5, StatusCodes.Good) + ], + 1000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_WorstQuality, values, startTime, endTime, 10000); + + Assert.That(result.WrappedValue.TryGetValue(out StatusCode worst), Is.True); + Assert.That(StatusCode.IsBad(worst), Is.True); + Assert.That(result.StatusCode.AggregateBits & AggregateBits.MultipleValues, + Is.EqualTo(AggregateBits.MultipleValues)); + } + + [Test] + public void WorstQualityWithUncertainValuesReturnsUncertain() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + + List values = CreateDataValues( + startTime, + [ + (1, StatusCodes.Good), + (2, StatusCodes.Uncertain), + (3, StatusCodes.Uncertain), + (4, StatusCodes.Good), + (5, StatusCodes.Good) + ], + 1000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_WorstQuality, values, startTime, endTime, 10000); + + Assert.That(result.WrappedValue.TryGetValue(out StatusCode worst), Is.True); + Assert.That(StatusCode.IsUncertain(worst), Is.True); + } + + [Test] + public void WorstQualityPreservesFirstGoodStatusCode() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(2000); + List values = CreateDataValues( + startTime, + [ + (1, StatusCodes.GoodClamped), + (2, StatusCodes.Good), + (3, StatusCodes.Good) + ]); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_WorstQuality, values, startTime, endTime, 2000); + + Assert.That(result.WrappedValue.TryGetValue(out StatusCode worst), Is.True); + Assert.That(worst, Is.EqualTo(StatusCodes.GoodClamped)); + Assert.That( + result.StatusCode.AggregateBits, + Is.EqualTo(AggregateBits.Calculated | AggregateBits.MultipleValues)); + } + + [Test] + public void WorstQualityPreservesFirstUncertainStatusCodeAfterGood() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(4000); + List values = CreateDataValues( + startTime, + [ + (1, StatusCodes.GoodClamped), + (2, StatusCodes.Good), + (3, StatusCodes.UncertainLastUsableValue), + (4, StatusCodes.UncertainDataSubNormal), + (5, StatusCodes.Good) + ]); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_WorstQuality, values, startTime, endTime, 4000); + + Assert.That(result.WrappedValue.TryGetValue(out StatusCode worst), Is.True); + Assert.That(worst, Is.EqualTo(StatusCodes.UncertainLastUsableValue)); + Assert.That( + result.StatusCode.AggregateBits, + Is.EqualTo(AggregateBits.Calculated | AggregateBits.MultipleValues)); + } + + [Test] + public void WorstQualityPreservesFirstBadStatusCodeAfterUncertain() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(6000); + List values = CreateDataValues( + startTime, + [ + (1, StatusCodes.GoodClamped), + (2, StatusCodes.Good), + (3, StatusCodes.UncertainLastUsableValue), + (4, StatusCodes.UncertainDataSubNormal), + (5, StatusCodes.BadOutOfRange), + (6, StatusCodes.BadSensorFailure), + (7, StatusCodes.Good) + ]); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_WorstQuality, values, startTime, endTime, 6000); + + Assert.That(result.WrappedValue.TryGetValue(out StatusCode worst), Is.True); + Assert.That(worst, Is.EqualTo(StatusCodes.BadOutOfRange)); + Assert.That( + result.StatusCode.AggregateBits, + Is.EqualTo(AggregateBits.Calculated | AggregateBits.MultipleValues)); + } + + [TestCase(false)] + [TestCase(true)] + public void WorstQuality2IncludesRequestStartAndExcludesRequestEnd(bool reverse) + { + var baseTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime startTime = baseTime.AddMilliseconds(reverse ? 8000 : 2000); + DateTime endTime = baseTime.AddMilliseconds(reverse ? 2000 : 8000); + List values = reverse + ? + [ + new DataValue( + new Variant(1), + StatusCodes.BadOutOfRange, + baseTime.AddMilliseconds(2000)), + new DataValue( + new Variant(2), + StatusCodes.Good, + baseTime.AddMilliseconds(4000)), + new DataValue( + new Variant(3), + StatusCodes.UncertainLastUsableValue, + baseTime.AddMilliseconds(10000)) + ] + : + [ + new DataValue( + new Variant(1), + StatusCodes.UncertainLastUsableValue, + baseTime), + new DataValue( + new Variant(2), + StatusCodes.Good, + baseTime.AddMilliseconds(4000)), + new DataValue( + new Variant(3), + StatusCodes.BadSensorFailure, + baseTime.AddMilliseconds(8000)) + ]; + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_WorstQuality2, values, startTime, endTime, 6000); + + Assert.That(result.WrappedValue.TryGetValue(out StatusCode worst), Is.True); + Assert.That(worst, Is.EqualTo(StatusCodes.UncertainDataSubNormal)); + Assert.That(result.SourceTimestamp, Is.EqualTo(startTime)); + Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.Good)); + Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated)); + } + + [TestCase(false)] + [TestCase(true)] + public void WorstQuality2SetsMultipleValuesForRequestStartAndInteriorWorstQuality(bool reverse) + { + var earlyTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime lateTime = earlyTime.AddMilliseconds(10000); + StatusCode earlyStatus = reverse ? StatusCodes.Good : StatusCodes.BadOutOfRange; + StatusCode lateStatus = reverse ? StatusCodes.BadSensorFailure : StatusCodes.Good; + List values = CreateDataValues( + earlyTime, + [ + (1, earlyStatus), + (2, StatusCodes.BadOutOfRange), + (3, lateStatus) + ], + 5000); + DateTime startTime = reverse ? lateTime : earlyTime; + DateTime endTime = reverse ? earlyTime : lateTime; + StatusCode expected = reverse ? StatusCodes.BadSensorFailure : StatusCodes.BadOutOfRange; + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_WorstQuality2, values, startTime, endTime, 10000); + + Assert.That(result.WrappedValue.TryGetValue(out StatusCode worst), Is.True); + Assert.That(worst, Is.EqualTo(expected)); + Assert.That( + result.StatusCode.AggregateBits, + Is.EqualTo(AggregateBits.Calculated | AggregateBits.MultipleValues)); + } + + [Test] + public void PercentGoodAllGoodReturns100() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + + // First value at the interval start so the start bound is a Good raw value. + List values = CreateDataValues( + startTime, + [ + (1, StatusCodes.Good), + (2, StatusCodes.Good), + (3, StatusCodes.Good), + (4, StatusCodes.Good), + (5, StatusCodes.Good) + ], + 2000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_PercentGood, values, startTime, endTime, 10000); + + Assert.That(result.WrappedValue.TryGetValue(out double percent), Is.True); + Assert.That(percent, Is.EqualTo(100.0).Within(0.0001)); + } + + [Test] + public void PercentBadAllGoodReturnsZero() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + + List values = CreateDataValues( + startTime, + [ + (1, StatusCodes.Good), + (2, StatusCodes.Good), + (3, StatusCodes.Good), + (4, StatusCodes.Good), + (5, StatusCodes.Good) + ], + 2000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_PercentBad, values, startTime, endTime, 10000); + + Assert.That(result.WrappedValue.TryGetValue(out double percent), Is.True); + Assert.That(percent, Is.Zero); + } + + [Test] + public void DurationGoodResultIsGoodCalculated() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + + List values = CreateDataValues( + startTime, + [ + (1, StatusCodes.Good), + (2, StatusCodes.Good), + (3, StatusCodes.Good), + (4, StatusCodes.Good), + (5, StatusCodes.Good) + ], + 2000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_DurationGood, values, startTime, endTime, 10000); + + // Part 13 §5.4.3.31: StatusCode is always Good, Calculated. + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(result.StatusCode.AggregateBits & AggregateBits.Calculated, + Is.EqualTo(AggregateBits.Calculated)); + Assert.That(result.WrappedValue.TryGetValue(out double duration), Is.True); + Assert.That(duration, Is.GreaterThanOrEqualTo(0.0)); + } + + [Test] + public void WorstQuality2WithBadValuesReturnsBad() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + + List values = CreateDataValues( + startTime, + [ + (1, StatusCodes.Good), + (2, StatusCodes.Bad), + (3, StatusCodes.Bad), + (4, StatusCodes.Good), + (5, StatusCodes.Good) + ], + 1000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_WorstQuality2, values, startTime, endTime, 10000); + + Assert.That(result.WrappedValue.TryGetValue(out StatusCode worst), Is.True); + Assert.That(StatusCode.IsBad(worst), Is.True); + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + } + + [Test] + public void DurationBadAllGoodReturnsZero() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + + List values = CreateDataValues( + startTime, + [ + (1, StatusCodes.Good), + (2, StatusCodes.Good), + (3, StatusCodes.Good), + (4, StatusCodes.Good), + (5, StatusCodes.Good) + ], + 2000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_DurationBad, values, startTime, endTime, 10000); + + // Part 13 §5.4.3.32: StatusCode is always Good, Calculated; no Bad data → duration 0. + Assert.That(StatusCode.IsGood(result.StatusCode), Is.True); + Assert.That(result.WrappedValue.TryGetValue(out double duration), Is.True); + Assert.That(duration, Is.Zero); + } + } +} diff --git a/Tests/Opc.Ua.Server.Tests/StdDevAggregateCalculatorRegressionTests.cs b/Tests/Opc.Ua.Server.Tests/StdDevAggregateCalculatorRegressionTests.cs new file mode 100644 index 0000000000..ce2bd83055 --- /dev/null +++ b/Tests/Opc.Ua.Server.Tests/StdDevAggregateCalculatorRegressionTests.cs @@ -0,0 +1,213 @@ +/* ======================================================================== + * 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 regression (RegSlope/RegConst/RegStdDev) calculation exposed by + /// . The protected method is + /// exercised through a derived calculator that routes ComputeValue to it. + /// + [TestFixture] + [Category("Aggregators")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Parallelizable] + public class StdDevAggregateCalculatorRegressionTests + { + 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 sealed class RegressionCalculator : StdDevAggregateCalculator + { + private readonly int m_valueType; + + public RegressionCalculator( + int valueType, + NodeId aggregateId, + DateTime startTime, + DateTime endTime, + double processingInterval, + bool stepped, + AggregateConfiguration configuration, + ITelemetryContext telemetry) + : base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry) + { + m_valueType = valueType; + } + + protected override DataValue ComputeValue(TimeSlice slice) + { + return ComputeRegression(slice, m_valueType); + } + } + + private DataValue Compute(int valueType, List values, DateTime startTime, DateTime endTime) + { + var calculator = new RegressionCalculator( + valueType, + ObjectIds.AggregateFunction_StandardDeviationPopulation, + startTime, + endTime, + (endTime - startTime).TotalMilliseconds, + false, + m_configuration, + m_telemetry); + + 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 static List GoodSeries(DateTime first, double[] values, double intervalMs) + { + var list = new List(); + for (int i = 0; i < values.Length; i++) + { + DateTime ts = first.AddMilliseconds(i * intervalMs); + list.Add(new DataValue(new Variant(values[i]), StatusCodes.Good, ts, ts)); + } + return list; + } + + [Test] + public void RegSlopeReturnsCalculatedResult() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(12000); + List values = GoodSeries(startTime.AddMilliseconds(500), [10, 20, 30, 40, 50], 2000); + + DataValue result = Compute(1, values, startTime, endTime); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True); + } + + [Test] + public void RegConstReturnsCalculatedResult() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(12000); + List values = GoodSeries(startTime.AddMilliseconds(500), [10, 20, 30, 40, 50], 2000); + + DataValue result = Compute(2, values, startTime, endTime); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True); + } + + [Test] + public void RegStdDevReturnsCalculatedResult() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(12000); + List values = GoodSeries(startTime.AddMilliseconds(500), [10, 25, 33, 44, 51], 2000); + + DataValue result = Compute(3, values, startTime, endTime); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + Assert.That(result.WrappedValue.ConvertToDouble().GetDouble(), Is.GreaterThanOrEqualTo(0)); + } + + [Test] + public void RegSlopeWithNonGoodDataMarksSubNormal() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime t0 = startTime.AddMilliseconds(500); + DateTime endTime = startTime.AddMilliseconds(12000); + + var values = new List + { + new(new Variant(10.0), StatusCodes.Good, t0, t0), + new(new Variant(20.0), StatusCodes.Good, t0.AddMilliseconds(2000), t0.AddMilliseconds(2000)), + new(new Variant(0.0), StatusCodes.Bad, t0.AddMilliseconds(4000), t0.AddMilliseconds(4000)), + new(new Variant(40.0), StatusCodes.Good, t0.AddMilliseconds(6000), t0.AddMilliseconds(6000)), + new(new Variant(50.0), StatusCodes.Good, t0.AddMilliseconds(8000), t0.AddMilliseconds(8000)) + }; + + DataValue result = Compute(1, values, startTime, endTime); + + Assert.That(result, Is.Not.Null); + Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.UncertainDataSubNormal)); + } + + [Test] + public void RegSlopeWithAllBadDataReturnsNoData() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime t0 = startTime.AddMilliseconds(500); + DateTime endTime = startTime.AddMilliseconds(6000); + + var values = new List + { + new(new Variant(1.0), StatusCodes.Bad, t0, t0), + new(new Variant(2.0), StatusCodes.Bad, t0.AddMilliseconds(2000), t0.AddMilliseconds(2000)) + }; + + DataValue result = Compute(1, values, startTime, endTime); + + Assert.That(StatusCode.IsBad(result.StatusCode), Is.True); + } + } +} diff --git a/Tests/Opc.Ua.Server.Tests/StdDevAggregateCalculatorTests.cs b/Tests/Opc.Ua.Server.Tests/StdDevAggregateCalculatorTests.cs new file mode 100644 index 0000000000..be8b2a41b9 --- /dev/null +++ b/Tests/Opc.Ua.Server.Tests/StdDevAggregateCalculatorTests.cs @@ -0,0 +1,399 @@ +/* ======================================================================== + * 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 StdDevAggregateCalculatorTests + { + 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 StdDevPopulationWithLargeSpreadReturnsHighValue() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime firstValueTime = startTime.AddMilliseconds(500); + double[] values = [1, 100, 1, 100, 1, 100]; + List dataValues = CreateDataValues(firstValueTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(12000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_StandardDeviationPopulation, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double stdDev = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(stdDev, Is.GreaterThan(10)); + } + + [Test] + public void StdDevSampleWithLargeSpreadReturnsHighValue() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + double[] values = [1, 100, 1, 100, 1]; + List dataValues = CreateDataValues(startTime, values, 2000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_StandardDeviationSample, + dataValues, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double stdDev = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(stdDev, Is.GreaterThan(10)); + } + + [Test] + public void VariancePopulationIsNonNegative() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime firstValueTime = startTime.AddMilliseconds(500); + double[] values = [-50, 0, 50, -50, 0, 50]; + List dataValues = CreateDataValues(firstValueTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(12000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_VariancePopulation, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double variance = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(variance, Is.GreaterThanOrEqualTo(0)); + } + + [Test] + public void VarianceSampleIsNonNegative() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + double[] values = [-50, 0, 50, -50, 0]; + List dataValues = CreateDataValues(startTime, values, 2000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_VarianceSample, + dataValues, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double variance = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(variance, Is.GreaterThanOrEqualTo(0)); + } + + [Test] + public void StdDevPopulationResultHasCalculatedAggregateBits() + { + 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_StandardDeviationPopulation, + dataValues, startTime, endTime, 8000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True); + } + + [Test] + public void StdDevSampleResultHasCalculatedAggregateBits() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + double[] values = [10, 20, 30, 40, 50]; + List dataValues = CreateDataValues(startTime, values, 2000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_StandardDeviationSample, + dataValues, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True); + } + + [Test] + public void VariancePopulationResultHasCalculatedAggregateBits() + { + 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_VariancePopulation, + dataValues, startTime, endTime, 8000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True); + } + + [Test] + public void VarianceSampleResultHasCalculatedAggregateBits() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + double[] values = [10, 20, 30, 40, 50]; + List dataValues = CreateDataValues(startTime, values, 2000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_VarianceSample, + dataValues, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.StatusCode.AggregateBits.HasFlag(AggregateBits.Calculated), Is.True); + } + + [Test] + public void StdDevPopulationWithNegativeValues() + { + 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_StandardDeviationPopulation, + dataValues, startTime, endTime, 12000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double stdDev = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(stdDev, Is.GreaterThanOrEqualTo(0)); + } + + [Test] + public void StdDevSampleNegativeValues() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + double[] values = [-10, -20, -30, -40, -50]; + List dataValues = CreateDataValues(startTime, values, 2000); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_StandardDeviationSample, + dataValues, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double stdDev = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(stdDev, Is.GreaterThanOrEqualTo(0)); + } + + [Test] + public void StdDevPopulationIsLessThanOrEqualToStdDevSample() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + double[] values = [10, 20, 30, 40, 50]; + List dataValues = CreateDataValues(startTime, values, 2000); + + DataValue popResult = ComputeAggregate( + ObjectIds.AggregateFunction_StandardDeviationPopulation, + dataValues, startTime, endTime, 10000); + + DataValue sampleResult = ComputeAggregate( + ObjectIds.AggregateFunction_StandardDeviationSample, + dataValues, startTime, endTime, 10000); + + Assert.That(popResult, Is.Not.Null); + Assert.That(sampleResult, Is.Not.Null); + double popStdDev = (double)popResult.WrappedValue.ConvertToDouble(); + double sampleStdDev = (double)sampleResult.WrappedValue.ConvertToDouble(); + Assert.That(popStdDev, Is.LessThanOrEqualTo(sampleStdDev + 0.0001)); + } + + [Test] + public void VariancePopulationWithKnownValues() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime firstValueTime = startTime.AddMilliseconds(500); + double[] values = [2, 4, 4, 4, 5, 5, 7, 9, 9]; + List dataValues = CreateDataValues(firstValueTime, values, 1000); + DateTime endTime = startTime.AddMilliseconds(9500); + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_VariancePopulation, + dataValues, startTime, endTime, 9500); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + double variance = (double)result.WrappedValue.ConvertToDouble(); + Assert.That(variance, Is.GreaterThan(0)); + } + + [Test] + public void StdDevPopulationIsSquareRootOfVariancePopulation() + { + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime firstValueTime = startTime.AddMilliseconds(500); + double[] values = [3, 7, 11, 15, 19, 19]; + List dataValues = CreateDataValues(firstValueTime, values, 2000); + DateTime endTime = startTime.AddMilliseconds(12000); + + DataValue stdDevResult = ComputeAggregate( + ObjectIds.AggregateFunction_StandardDeviationPopulation, + dataValues, startTime, endTime, 12000); + + DataValue varianceResult = ComputeAggregate( + ObjectIds.AggregateFunction_VariancePopulation, + dataValues, startTime, endTime, 12000); + + Assert.That(stdDevResult, Is.Not.Null); + Assert.That(varianceResult, Is.Not.Null); + double stdDev = (double)stdDevResult.WrappedValue.ConvertToDouble(); + double variance = (double)varianceResult.WrappedValue.ConvertToDouble(); + Assert.That(stdDev, Is.EqualTo(Math.Sqrt(variance)).Within(0.001)); + } + + [Test] + public void StdDevWithBadStatusValueExistingMarksSubNormal() + { + // Verifies the nonGoodDataExists branch in ComputeStdDev → Uncertain_DataSubNormal status. + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(10000); + DateTime t0 = startTime.AddMilliseconds(500); + + var values = new List + { + new(new Variant(10.0), StatusCodes.Good, t0, t0), + new(new Variant(0.0), StatusCodes.Bad, t0.AddMilliseconds(2000), t0.AddMilliseconds(2000)), + new(new Variant(20.0), StatusCodes.Good, t0.AddMilliseconds(4000), t0.AddMilliseconds(4000)), + new(new Variant(30.0), StatusCodes.Good, t0.AddMilliseconds(6000), t0.AddMilliseconds(6000)), + new(new Variant(40.0), StatusCodes.Good, t0.AddMilliseconds(8000), t0.AddMilliseconds(8000)) + }; + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_StandardDeviationPopulation, + values, startTime, endTime, 10000); + + Assert.That(result, Is.Not.Null); + Assert.That(result.WrappedValue.IsNull, Is.False); + Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.UncertainDataSubNormal)); + } + + [Test] + public void StdDevAllBadValuesReturnsNoData() + { + // Verifies the "xData.Count == 0" branch in ComputeStdDev → GetNoDataValue. + var startTime = new DateTime(2024, 1, 1, 0, 0, 0); + DateTime endTime = startTime.AddMilliseconds(6000); + DateTime t0 = startTime.AddMilliseconds(500); + + var values = new List + { + new(new Variant(1.0), StatusCodes.Bad, t0, t0), + new(new Variant(2.0), StatusCodes.Bad, t0.AddMilliseconds(2000), t0.AddMilliseconds(2000)), + new(new Variant(3.0), StatusCodes.Bad, t0.AddMilliseconds(4000), t0.AddMilliseconds(4000)) + }; + + DataValue result = ComputeAggregate( + ObjectIds.AggregateFunction_VariancePopulation, + values, startTime, endTime, 6000); + + Assert.That(StatusCode.IsBad(result.StatusCode), Is.True); + } + } +} diff --git a/Tests/Opc.Ua.Types.Tests/BuiltIn/TypeInfoTests.cs b/Tests/Opc.Ua.Types.Tests/BuiltIn/TypeInfoTests.cs index de74645c57..c49108e4d7 100644 --- a/Tests/Opc.Ua.Types.Tests/BuiltIn/TypeInfoTests.cs +++ b/Tests/Opc.Ua.Types.Tests/BuiltIn/TypeInfoTests.cs @@ -29,5 +29,55 @@ namespace Opc.Ua.Types.Tests.BuiltIn Assert.That(typeInfo.BuiltInType, Is.EqualTo(BuiltInType.Null)); Assert.That(typeInfo.ValueRank, Is.EqualTo(ValueRanks.Any)); } + + [Test] + public void IsInstanceOfBaseDataTypeAcceptsTypedArray() + { + var namespaceUris = new NamespaceTable(); + TypeInfo typeInfo = TypeInfo.IsInstanceOfDataType( + new[] { 1, 2, 3 }, + DataTypeIds.BaseDataType, + ValueRanks.OneDimension, + namespaceUris, + new TypeTable(namespaceUris)); + + Assert.That(typeInfo, Is.Not.Null); + Assert.That(typeInfo.BuiltInType, Is.EqualTo(BuiltInType.Int32)); + } + + [Test] + public void IsInstanceOfBaseDataTypeAcceptsVariantArrayContainingArray() + { + Variant[] value = + [ + new Variant(1), + new Variant(new StatusCode[] { StatusCodes.Good, StatusCodes.Bad }) + ]; + var namespaceUris = new NamespaceTable(); + + TypeInfo typeInfo = TypeInfo.IsInstanceOfDataType( + value, + DataTypeIds.BaseDataType, + ValueRanks.OneDimension, + namespaceUris, + new TypeTable(namespaceUris)); + + Assert.That(typeInfo, Is.Not.Null); + Assert.That(typeInfo.BuiltInType, Is.EqualTo(BuiltInType.Variant)); + } + + [Test] + public void IsInstanceOfSpecificDataTypeStillRejectsMismatch() + { + var namespaceUris = new NamespaceTable(); + TypeInfo typeInfo = TypeInfo.IsInstanceOfDataType( + new[] { 1, 2, 3 }, + DataTypeIds.String, + ValueRanks.OneDimension, + namespaceUris, + new TypeTable(namespaceUris)); + + Assert.That(typeInfo, Is.Null); + } } }