diff --git a/plans/ctt-issues.md b/plans/ctt-issues.md index 71d0961aba..73f0187c1a 100644 --- a/plans/ctt-issues.md +++ b/plans/ctt-issues.md @@ -2,15 +2,15 @@ Test cases of CTT 1.05.06 (scripts 1.05.513) that fail, warn or skip against the OPC Foundation .NET reference server (`ConsoleReferenceServer --ctt`) because of a CTT defect, with a short abstract and the -Mantis issue that tracks it. The details are in Mantis. Defects that are not filed yet, open questions, -open server findings and CTT project configuration notes follow the tables. The procedure for running the -CTT is in [ctt-testing.md](ctt-testing.md). +Mantis issue that tracks it. The details are in Mantis. Defects that are not filed yet, open questions and +CTT project configuration notes follow the tables. The procedure for running the CTT is in +[ctt-testing.md](ctt-testing.md). - "Resolved / fixed" in Mantis means the fix is in the CTT script repository. It ships with a script build after 1.05.513, so an installed 1.05.513 still shows the failure. -- The ids (1–19, C1–C50, U1–U4) are stable references for notes and commit messages; missing ids were +- The ids (1–19, C1–C53, U1–U4) are stable references for notes and commit messages; missing ids were withdrawn or no longer fail against the reference server. -- Mantis states were last checked on 2026-09-24. +- Mantis states were last checked on 2026-09-25. ## Filed in Mantis @@ -28,6 +28,7 @@ CTT is in [ctt-testing.md](ctt-testing.md). | C6 | Aggregate – DurationGood, PercentGood | The oracle ignores the raw value before the interval for the status of the first region. | [11425](https://mantis.opcfoundation.org/view.php?id=11425) | | C48 | Aggregate – Minimum, Maximum, MinimumActualTime, MaximumActualTime `001-02.js`… | The oracle ignores Uncertain values beyond the Good extremum and expects Good instead of UncertainDataSubNormal. | [11426](https://mantis.opcfoundation.org/view.php?id=11426) | | C49 | Aggregate – Minimum, MinimumActualTime, MaximumActualTime | The oracle does not set the Calculated bit when non-Good values make the status Uncertain. | [11427](https://mantis.opcfoundation.org/view.php?id=11427) | +| U2 | Aggregate – Minimum2 (reverse reads) | The oracle clears the Calculated bit when the minimum is the End bound at the early end of a reverse interval (Part 11 §6.5.4.2). | [11461](https://mantis.opcfoundation.org/view.php?id=11461) | ### Historical Access @@ -96,6 +97,7 @@ CTT is in [ctt-testing.md](ctt-testing.md). | C29 | Application Directory `005.js` | The random ApplicationUri is cut at its first NUL, so both calls are identical. | [11406](https://mantis.opcfoundation.org/view.php?id=11406) | | C30 | Query Applications `036.js` | Expects `rcp+` DiscoveryUrls the test never registered. | [11412](https://mantis.opcfoundation.org/view.php?id=11412) | | C31 | Query Applications `038.js` | Treats applicationType 3 as invalid. | [11413](https://mantis.opcfoundation.org/view.php?id=11413) | +| C51 | Application Directory `066.js`, `079.js`; Query Applications `039.js` (pass only because the server returns NA records) | Register the reference Servers with ServerCapabilities `NA` and expect them in query results, which OPC 10000-12 §6.5.10/§6.5.11 excludes; with the exclusion 40 test cases fail. | [11458](https://mantis.opcfoundation.org/view.php?id=11458) | ### Alarms and Conditions @@ -110,6 +112,8 @@ CTT is in [ctt-testing.md](ctt-testing.md). | C41 | A & C Alarm `Test_002.js` (runs to the maximum test time) | AcknowledgeableConditionType never gets a result. | [11449](https://mantis.opcfoundation.org/view.php?id=11449) | | C42 | A & C Enable `Test_003.js` (runs to the maximum test time) | Depends on all alarm types going active within one tenth of the Alarm Cycle Time. | [11450](https://mantis.opcfoundation.org/view.php?id=11450) | | C44 | A & C CertificateExpiration (a `--close --hidden` run never exits) | `initialize.js` opens a modal dialog. | [11453](https://mantis.opcfoundation.org/view.php?id=11453) | +| C52 | A & C Refresh, Refresh2, Shelving (`BadSubscriptionIdInvalid` when the CPU is saturated) | Refresh `Err_004.js` and Refresh2 `Err_003.js` leave 10 subscriptions each on the shared alarm session, and `ShutdownItem()` leaves one per Refresh test case. | [11459](https://mantis.opcfoundation.org/view.php?id=11459) | +| C53 | A & C (alarm thread) | `StartThreadPublish.js` lines 28–29 default misspelled properties, so MaximumPublishCalls/MaximumOutstandingCalls are sent undefined. | [11460](https://mantis.opcfoundation.org/view.php?id=11460) | ## Not filed @@ -135,90 +139,44 @@ CTT is in [ctt-testing.md](ctt-testing.md). ## Needs clarification -### U1. NumberOfTransitions with TreatUncertainAsBad=true - -For 24 monotonic samples (two Bad, two Uncertain), the server counts 22 transitions and the -oracle 20 (Uncertain values excluded). §5.4.3.24 excludes Bad values. §4.2.1.2 says -TreatUncertainAsBad=True makes Uncertain *"equivalent to Bad"*, which supports the oracle. The -server currently ignores TreatUncertainAsBad for this aggregate. Needs a decision before either -side changes. - -### U2. Minimum2 Calculated bit on reverse reads - -When the minimum is the chronologically first raw value of a reverse interval, the server sets -Calculated and the oracle does not. §5.4.3.15 sets Calculated *"unless the StartBound is the -Minimum"*. Part 11 §6.5.4.2 makes the later timestamp the start of a reverse interval, which -supports the server. Part 13 §5.4.2.2 says a reverse calculation equals the forward one, which -supports the oracle. - -### U3. DurationInStateZero/NonZero with an Uncertain end bound - -In an interval whose raw data is all Good but whose simple end bound is Uncertain, the server -returns Good and the oracle Uncertain, with equal values. §5.4.3.22 does not say whether an end -bound colors the region before it. The oracle is inconsistent: TimeAverage2 and Total2 over the -same interval match the server. - -### U4. DeltaBounds: which bounds count as Bad and which as Uncertain +### U4. DeltaBounds: does TreatUncertainAsBad make an Uncertain bound Bad? §5.4.3.30: *"If one or both values are Bad the return status will be Bad_NoData. If one or both values are -Uncertain the status will be Uncertain_DataSubNormal."* #4503 changed `StartEndAggregateCalculator` to return -`BadNoData` only for Bad bounds (it used to do so for every non-Good bound), so the server now computes the -difference for Uncertain bounds. In the run of 2026-09-22 that raised the Aggregates group from 4,498 to 4,562 -error messages, all in Aggregate – DeltaBounds `003-01.js`…`008-01.js` on the Double and Float nodes. +Uncertain the status will be Uncertain_DataSubNormal."* A logged run (AGGDIAG project copy) of Aggregate – +DeltaBounds `003-01.js`…`008-01.js` on the Double and Float nodes differs in 200 readings; the timestamps always +agree: -A logged run (AGGDIAG project copy) classifies all 200 differing readings; the timestamps always agree: +| Readings | Server | Oracle | Cause | +| --- | --- | --- | --- | +| 80 | value 24, Good | value 23, Good | Int32 rounding (see *Aggregate oracle differences*) | +| 72 | `BadNoData` | value, `UncertainDataSubNormal` | TreatUncertainAsBad=false requested, but C1 makes the server use its default true; the Uncertain raw value before the bound then counts as Bad and no bound exists | +| 48 | value, `UncertainDataSubNormal` | `BadNoData` | TreatUncertainAsBad=true; the raw value after the bound is Bad, so §3.1.9 makes the bound Uncertain, and the oracle then treats the Uncertain bound as Bad | -| Readings | Server | Oracle | -| --- | --- | --- | -| 80 | value 24, Good | value 23, Good | -| 72 | `BadNoData` | value, `UncertainDataSubNormal` | -| 48 | value, `UncertainDataSubNormal` | `BadNoData` | - -The first group is the known Int32 rounding difference (see *Aggregate oracle differences* under *Not filed*). The other two -are the same question in both directions: the two sides disagree about whether a given interval bound is Bad or -Uncertain, so one computes a value while the other reports no data. Needs a decision on how a bound derived from -a Bad raw value is classified (§5.4.2.3 simple bounding) before either side changes. - -## Open server findings - -- **Idle SecureChannels are closed after `ChannelLifetime` although their token is still valid.** Security None - `007.js` and Security Basic 256 Sha256 `005.js` keep channels without a Session idle for about 51 s and expect - the newest one to close with Good; with the default `ChannelLifetime` of 30 s the server has already closed it - (`BadInvalidState`). Part 4 §5.6.2.1 lets a SecureChannel live until its last token expires. A server change was - withdrawn because it lets an unauthenticated client hold a channel for up to the `SecurityTokenLifetime`; - `Ctt.ReferenceServer.Config.xml` sets `ChannelLifetime` to 120000 instead. -- **AddNodes after a child rename returns `BadNodeIdExists`.** AddNodes without a RequestedNewNodeId derives the - NodeId from the parent and the BrowseName; after a rename the renamed node still owns that id - (`AsyncCustomNodeManagerNodeManagementTests.AddNodeAsync_DuplicateBrowseNameUnderParentWithManyChildren_ReturnsBadBrowseNameDuplicatedAsync`). - The server should allocate another NodeId. No CTT test case renames nodes. -- **A & C Refresh / Refresh2 / Shelving `BadSubscriptionIdInvalid`.** Seen once on 2026-09-14 (Refresh `Err_004.js`) - and in three runs on 2026-09-24 (Refresh2 `Err_003.js`/`Err_004.js`, Shelving `initialize.js`) while other builds - kept the machine at up to 99% CPU, with `ChannelLifetime` 30000 and 120000 alike: the alarm thread's subscriptions - were gone, which is what an expiry after stopped Publish requests looks like. Not reproduced on an idle machine. - If it recurs, start the server with `-c -l` and look for `Subscription ... EXPIRED`. -- **GDS.** QueryApplications/QueryServers *"shall not return records with a ServerCapabilities that includes NA"* - (OPC 10000-12 §6.5.10/§6.5.11), but the CTT registers its reference Servers with `NA` and expects them in the - results (Application Directory `066.js`, `079.js` step 1); the server is unchanged. The - `Bad_EncodingLimitsExceeded` ServiceFault for an oversized request carries RequestHandle 0 (Part 4 §7.33: the - handle *should* be echoed). -- **Aggregate calculators, not exercised by the CTT yet.** AnnotationCount never returns `BadNoData` or sets - `Partial` (`CountAggregateCalculator`, §5.4.3.20); the value-based status ignores TreatUncertainAsBad=false - (`AggregateCalculator.GetValueBasedStatusCode`). Both only show once the CTT sends explicit aggregate - configurations (C1). +§3.1.9 (Simple Bounding Values) and Table 78 never mention TreatUncertainAsBad, §4.2.1.2 applies it to every +aggregate calculation *"unless the Aggregate definition says otherwise"*, and its note (*"still treated as +Uncertain when the StatusCode for the result is calculated"*) contradicts §5.4.3.2.1. Whether TreatUncertainAsBad +applies to the raw values that form a bound (server), to the resulting bound (oracle) or not at all is open. A +spec clarification request is filed as [11462](https://mantis.opcfoundation.org/view.php?id=11462); neither side changes +before the answer. ## CTT project configuration notes Tests skipped because of reference server sample-data gaps or missing CTT project settings are tracked in [#4479](https://github.com/OPCFoundation/UA-.NETStandard/issues/4479). -- **Aggregate ProcessingInterval.** Set `/Server Test/NodeIds/Static/HA Profile/Aggregates/ProcessingInterval` - to a positive value (see issue 4). +- **Aggregate ProcessingInterval.** `samples/UAReferenceServer.ctt.xml` sets + `/Server Test/NodeIds/Static/HA Profile/Aggregates/ProcessingInterval` to 1; keep it positive (see issue 4). - **Bad data entries for aggregates.** `005-05.js`/`005-06.js` need an explicit Bad data entry. Without it, `HAAggregateHelper.GetRequestEntry` falls back to the start entry (*"Bad Data Entry no found, using start data"*) or throws (*"GetRequestEntry failed due to incorrect test configuration"*). The reference server seeds a deterministic pattern on every history node: index mod 10 = 7 is - `BadDataUnavailable`, index mod 10 = 9 is `UncertainSubstituteValue`, the rest Good. -- **Reference server settings for #4479.** `samples/UAReferenceServer.ctt.xml` sets: + `BadDataUnavailable`, index mod 10 = 9 is `UncertainSubstituteValue`, the rest Good. No project setting + can supply the entry: `GetStartBadDataTime` (`HAAggregateHelper.js` line 1023) reads + `.../Aggregates/StartOfBadData` as an absolute time, the server seeds its history relative to its + start time, and the pattern never has the two consecutive non-Good values the helper looks for. It needs a + history seed anchored to a fixed date with a longer Bad block, plus that date in the template. +- **Reference server settings for #4479.** `samples/UAReferenceServer.ctt.xml` sets the following; a project + created from an older template keeps the old values, so copy them over: - `/Server Test/NodeIds/Static/All Profiles/Scalar/Bool` = `ns=2;s=Scalar_Static_NonHistorizing_Boolean`. Historical Access Read Raw `Err-025.js` and Delete Value `dat-Err-001.js`/`Err-004.js` take the first Static Scalar node as the non-historizing node. The HA Profile and Aggregate Boolean settings stay on @@ -253,8 +211,6 @@ tracked in [#4479](https://github.com/OPCFoundation/UA-.NETStandard/issues/4479) by design. Deselect it. - **Discovery.** Find Servers Filter `002.js` needs at least two servers known to FindServers (an LDS); Find Servers Self `010.js` and Get Endpoints `009.js` need a multi-homed host or several hostnames. - Find Servers Filter `003.js`/`006.js` and Get Endpoints `002.js` warn that `de-DE` was requested - but `en-US` returned, because the server has no `de-DE` ApplicationName. - **Auditing.** Auditing Connections `002.js`, `003.js`, `008.js`, `010.js`, `014.js` skip when no other test case in the same run produces the audit event they look for. Run the Auditing group together with the service groups whose actions it audits. @@ -289,6 +245,12 @@ tracked in [#4479](https://github.com/OPCFoundation/UA-.NETStandard/issues/4479) Node Management Add Ref and Delete Ref. - **Security groups need the CTT PKI, not `-a`.** See [ctt-testing.md](ctt-testing.md#9-security-groups). With `-a` every negative certificate test fails spuriously. +- **SecureChannels without a Session.** Security None `007.js` and Security Basic 256 Sha256 `005.js` keep + channels without a Session idle for about 51 s and expect the newest one to close with Good. The server closes + such channels after `ChannelLifetime` (default 30 s), although Part 4 §5.6.2.1 lets a SecureChannel live until + its last token expires; keeping them open would let an unauthenticated client hold a channel for up to the + `SecurityTokenLifetime`. `Ctt.ReferenceServer.Config.xml` sets `ChannelLifetime` to 120000 instead, and both + test cases pass (`005.js` keeps the C50 warning). - **Security General coverage.** In scripts 1.05.513, 50 of the 53 CUs contain only *Not Implemented* test cases (Push/Pull Model, No Application Authentication, Security Administration, Certificate Administration, Default ApplicationInstance Certificate, all Role and User Management CUs, TLS, Time Sync, KeyCredential, broker authentication, @@ -299,7 +261,10 @@ tracked in [#4479](https://github.com/OPCFoundation/UA-.NETStandard/issues/4479) client certificate. `049.js`/`050.js` need a Basic128Rsa15 endpoint for their SHA-1 certificates; the reference server does not offer that deprecated policy (not applicable). - **Security User Token coverage.** Security User Anonymous `003.js` needs a secure endpoint without the - Anonymous token; the CTT configuration offers Anonymous on every endpoint. Security User Name Password 2 + Anonymous token; the CTT configuration offers Anonymous on every endpoint. UserTokenPolicies are server-wide + in the configuration, so this needs another SignAndEncrypt endpoint (for example `Aes128_Sha256_RsaOaep`) whose + policies `ReferenceServer.GetUserTokenPolicies` filters; not done, because every CU that iterates the endpoints + would then see it. Security User Name Password 2 `002.js` needs a UserName token policy with SecurityPolicy `#None` on an encrypted endpoint (password sent unencrypted inside the channel); the reference server always encrypts passwords (not applicable). `012.js` passes only because `/Server Test/Session/LoginNameAccessDenied` (`username`) is not a known user @@ -314,17 +279,14 @@ tracked in [#4479](https://github.com/OPCFoundation/UA-.NETStandard/issues/4479) `UseChatteringAlarms` sets IgnoreSkip on every type and `Test_003.js`–`Test_005.js`, `Test_007.js`–`Test_010.js` and `Err_001.js`–`Err_003.js` finish immediately without testing anything. The reference server has no alarm that stays active across transitions, so there is no - condition to configure there yet. + condition to configure there yet. It would need an alarm source in the sample `AlarmNodeManager` that oscillates + inside the alarm band (for example between 75 and 95 around a HighHigh limit of 90), with its ConditionId in that + setting; the other A & C CUs would then see a condition that never clears. - **A & C Alarm Cycle Time and session timeout.** `/Server Test/Alarms and Conditions/Alarm Cycle Time` sets the initial event capture (1 ×), the maximum time of every collector test case (3 ×) and the Enable `Test_003.js` refresh delay (1/10). Keep it below `/Server Test/Session/RequestedSessionTimeout` (ms) when a Limit/Level CU can be the first A&C CU of a - run (C40). -- **Subscription Publish Min 05 `003.js`** creates 5 subscriptions in each of half the - `/Server Test/Capabilities/Max Supported Sessions` sessions (75 → 38 sessions, 190 subscriptions). With - `/Server Test/Capabilities/Max Supported Subscriptions` = 100 (the server's `MaxSubscriptionCount` in - `Ctt.ReferenceServer.Config.xml`) it warns *"Not enough subscriptions for all sessions. Reducing session - amount to 20"* and still passes. The warning is informational; raising both limits to 200 removes it. + run (C40). The template sets 30 s against a 60 s session timeout. - **Session and Subscription coverage.** Manual (*Not Implemented*) test cases: Subscription Basic `072.js`, `073.js`; Subscription Multiple `001.js`–`003.js`; Subscription Publish Basic `005.js`–`007.js`, `Err-001.js`; Subscription PublishRequest Queue Overflow `001.js`, `002.js`; Subscription Durable `013.js`. Subscription diff --git a/plans/ctt-testing.md b/plans/ctt-testing.md index 83544e7e11..c433ec7c48 100644 --- a/plans/ctt-testing.md +++ b/plans/ctt-testing.md @@ -272,7 +272,8 @@ each CU on its own takes well over an hour. manual single-case CUs cost nothing. 2. **One CTT process, fresh server.** The CTT keeps one alarm thread for the whole group, so the initial event capture (one Alarm Cycle Time) is paid once, by the first A & C CU. -3. **Project copy with `/Server Test/Alarms and Conditions/Alarm Cycle Time` = 30** (default 60). The setting +3. **`/Server Test/Alarms and Conditions/Alarm Cycle Time` = 30** (CTT default 60; `samples/UAReferenceServer.ctt.xml` + sets 30). The setting is the length of the initial capture, one third of the maximum time of every collector test case, and ten times the Enable `Test_003.js` refresh delay. The reference server's alarm sources run a 40 s sawtooth and every alarm type reports an event at most 11 s apart, so 30 s still captures every type, and the longest diff --git a/samples/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs b/samples/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs index 0fad0feae0..94f9cb0ec4 100644 --- a/samples/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs +++ b/samples/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs @@ -458,6 +458,15 @@ namespace Quickstarts.ReferenceServer } } + // Offer the application name in the advertised locales, so FindServers and + // GetEndpoints return a de-DE ApplicationName when the client asks for German. + string applicationName = configuration.ApplicationName ?? string.Empty; + resourceManager.Add(ApplicationNameKey, "en-US", applicationName); + resourceManager.Add( + ApplicationNameKey, + "de-DE", + applicationName.Replace("Reference Server", "Referenzserver", StringComparison.Ordinal)); + return resourceManager; } @@ -488,6 +497,15 @@ namespace Quickstarts.ReferenceServer RegisterIdentityAuthenticators(server); + // Key the ApplicationName of the endpoints, so the resource manager translates it. + if (ServerDescription != null) + { + ServerDescription.ApplicationName = new LocalizedText( + ApplicationNameKey, + "en-US", + ServerDescription.ApplicationName.Text ?? string.Empty); + } + try { ServerInternal.UpdateServerStatus( @@ -866,6 +884,7 @@ namespace Quickstarts.ReferenceServer base.Dispose(disposing); } + private const string ApplicationNameKey = "ApplicationName"; private CertificateManager? m_userCertificateValidator; private string? m_rejectedUserStorePath; private readonly ITelemetryContext m_telemetry; diff --git a/samples/Reference/ConsoleReferenceServer/Ctt.ReferenceServer.Config.xml b/samples/Reference/ConsoleReferenceServer/Ctt.ReferenceServer.Config.xml index 67aa88b69d..2c9add1472 100644 --- a/samples/Reference/ConsoleReferenceServer/Ctt.ReferenceServer.Config.xml +++ b/samples/Reference/ConsoleReferenceServer/Ctt.ReferenceServer.Config.xml @@ -196,7 +196,7 @@ Ctt.ReferenceServer.nodes.xml 10000 20 - 100 + 200 10000 diff --git a/samples/UAReferenceServer.ctt.xml b/samples/UAReferenceServer.ctt.xml index 43c52320ac..64df31b95c 100644 --- a/samples/UAReferenceServer.ctt.xml +++ b/samples/UAReferenceServer.ctt.xml @@ -397,7 +397,7 @@ - + @@ -16578,7 +16578,7 @@ - + diff --git a/src/Opc.Ua.Redundancy.Server/Historian/SharedKeyValueHistorianProvider.cs b/src/Opc.Ua.Redundancy.Server/Historian/SharedKeyValueHistorianProvider.cs index 452f3ff725..823643a8e8 100644 --- a/src/Opc.Ua.Redundancy.Server/Historian/SharedKeyValueHistorianProvider.cs +++ b/src/Opc.Ua.Redundancy.Server/Historian/SharedKeyValueHistorianProvider.cs @@ -2404,6 +2404,23 @@ namespace Opc.Ua.Redundancy.Server ArrayOf timestamps = archive == null ? [] : archive.Annotations.Keys.ToArrayOf(); + + // Part 13 §5.4.3.20: intervals outside the raw data and annotations are + // Bad_NoData, intervals overlapping their edges are Partial. Both are sorted. + DateTimeUtc startOfData = DateTimeUtc.MaxValue; + DateTimeUtc endOfData = DateTimeUtc.MinValue; + if (archive != null && archive.Raw.Count > 0) + { + startOfData = archive.Raw.Keys.First().SourceTimestamp; + endOfData = archive.Raw.Keys.Last().SourceTimestamp; + } + if (timestamps.Count > 0) + { + startOfData = timestamps[0] < startOfData ? timestamps[0] : startOfData; + endOfData = timestamps[timestamps.Count - 1] > endOfData + ? timestamps[timestamps.Count - 1] + : endOfData; + } return [ .. CountAggregateCalculator.CalculateAnnotationCounts( @@ -2411,6 +2428,8 @@ namespace Opc.Ua.Redundancy.Server request.StartTime, request.EndTime, request.ProcessingInterval, + startOfData, + endOfData, kMaxProcessedValues, ct) ]; diff --git a/src/Opc.Ua.Server/Aggregates/AggregateCalculator.cs b/src/Opc.Ua.Server/Aggregates/AggregateCalculator.cs index 1db35b823a..73edd81588 100644 --- a/src/Opc.Ua.Server/Aggregates/AggregateCalculator.cs +++ b/src/Opc.Ua.Server/Aggregates/AggregateCalculator.cs @@ -680,11 +680,6 @@ namespace Opc.Ua.Server /// public LinkedListNode EarlyBound { get; set; } = null!; - /// - /// The latest non-Bad value before the slice. - /// - public LinkedListNode NonBadEarlyBound { get; set; } = null!; - /// /// The second early bound for the slice (always earlier than the first). /// @@ -802,11 +797,6 @@ 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; @@ -843,11 +833,6 @@ 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; diff --git a/src/Opc.Ua.Server/Aggregates/CountAggregateCalculator.cs b/src/Opc.Ua.Server/Aggregates/CountAggregateCalculator.cs index d8080b52dc..03541f1610 100644 --- a/src/Opc.Ua.Server/Aggregates/CountAggregateCalculator.cs +++ b/src/Opc.Ua.Server/Aggregates/CountAggregateCalculator.cs @@ -92,6 +92,61 @@ namespace Opc.Ua.Server double processingInterval, int outputCap, CancellationToken cancellationToken) + { + return CalculateAnnotationCounts( + annotationTimestamps, + startTime, + endTime, + processingInterval, + DateTimeUtc.MinValue, + DateTimeUtc.MaxValue, + outputCap, + cancellationToken); + } + + /// + /// Calculates AnnotationCount values for the requested time domain of a + /// history collection whose data spans the specified range. + /// + /// + /// Part 13 §5.4.3.20: an interval entirely before the start of data or + /// after the end of data is Bad_NoData, and an interval that + /// overlaps the start or the end of data has the Partial bit set + /// (§5.3.3.2). Pass a later than + /// when the collection has no data. + /// + /// + /// Annotation timestamps in any order. + /// + /// The start of the requested domain. + /// The end of the requested domain. + /// + /// The interval in milliseconds. Zero requests one result over the + /// complete domain. + /// + /// The timestamp of the first data point. + /// The timestamp of the last data point. + /// Maximum number of returned values. + /// Cancellation token. + /// The calculated AnnotationCount values. + /// + /// is null. + /// + /// + /// is not positive. + /// + /// + /// The interval is invalid or the output cap is exceeded. + /// + public static ArrayOf CalculateAnnotationCounts( + ArrayOf annotationTimestamps, + DateTimeUtc startTime, + DateTimeUtc endTime, + double processingInterval, + DateTimeUtc startOfData, + DateTimeUtc endOfData, + int outputCap, + CancellationToken cancellationToken) { if (annotationTimestamps.IsNull) { @@ -181,7 +236,33 @@ namespace Opc.Ua.Server cursor, next, cancellationToken); - values.Add(CreateAnnotationCountValue(count, cursor)); + + // chronological bounds; forward intervals exclude the late + // time and reverse intervals exclude the early time. + DateTimeUtc early = forward ? cursor : next; + DateTimeUtc late = forward ? next : cursor; + bool beforeStartOfData = forward + ? late <= startOfData + : late < startOfData; + bool afterEndOfData = forward + ? early > endOfData + : early >= endOfData; + if (beforeStartOfData || afterEndOfData) + { + values.Add(new DataValue( + Variant.Null, + StatusCodes.BadNoData, + cursor, + cursor)); + } + else + { + // same edge rules as AggregateCalculator.TryGetProcessedValue. + bool partial = + (startOfData > early && startOfData < late) || + (endOfData >= early && endOfData < late); + values.Add(CreateAnnotationCountValue(count, cursor, partial)); + } cursor = next; } return values.ToArrayOf(); @@ -340,7 +421,12 @@ namespace Opc.Ua.Server StatusCodes.Good, GetTimestamp(slice), GetTimestamp(slice)); - value = value.WithStatus(GetTimeBasedStatusCode(regions, value.StatusCode)); + + // The duration uses stepped regions because a state lasts until the next value, but + // the status regions follow the interpolation of the variable: with sloped + // interpolation a region ending in a Bad or Uncertain value (including the simple + // end bound) is Uncertain (Part 13 §5.4.3.2.2). + value = value.WithStatus(GetTimeBasedStatusCode(slice, values, value.StatusCode)); value = value.WithStatus(value.StatusCode.WithAggregateBits(AggregateBits.Calculated)); // return result. @@ -362,7 +448,9 @@ namespace Opc.Ua.Server } // The first non-Bad value is a transition when no previous non-Bad value exists. - LinkedListNode? previousValue = slice.NonBadEarlyBound; + // Part 13 §4.2.1.2: with TreatUncertainAsBad an Uncertain value is equivalent to Bad, + // so it is neither counted nor used as the previous value (IsGood applies the setting). + LinkedListNode? previousValue = slice.EarlyBound; bool hasLastValue = previousValue != null; Variant lastValue = previousValue != null ? previousValue.Value.WrappedValue @@ -373,7 +461,7 @@ namespace Opc.Ua.Server for (int ii = 0; ii < values.Count; ii++) { - if (StatusCode.IsBad(values[ii].StatusCode)) + if (!IsGood(values[ii])) { continue; } @@ -502,7 +590,8 @@ namespace Opc.Ua.Server private static DataValue CreateAnnotationCountValue( int count, - DateTimeUtc timestamp) + DateTimeUtc timestamp, + bool partial) { var value = new DataValue( Variant.From(count), @@ -510,7 +599,9 @@ namespace Opc.Ua.Server timestamp, timestamp); return value.WithStatus(value.StatusCode.WithAggregateBits( - AggregateBits.Calculated)); + partial + ? AggregateBits.Calculated | AggregateBits.Partial + : AggregateBits.Calculated)); } } } diff --git a/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs b/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs index 8ca38025b2..c6b9fc5ecd 100644 --- a/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs +++ b/src/Opc.Ua.Server/Historian/HistorianDispatcher.cs @@ -1118,6 +1118,53 @@ namespace Opc.Ua.Server.Historian } token = page.NextToken; } + + // Part 13 §5.4.3.20: intervals before the start or after the end of data are + // Bad_NoData and intervals overlapping either edge are Partial. The data is the + // raw history (read with bounds, as for the other aggregates) and the annotations. + DateTimeUtc startOfData = DateTimeUtc.MaxValue; + DateTimeUtc endOfData = DateTimeUtc.MinValue; + foreach (DateTimeUtc annotationTime in annotationTimes) + { + startOfData = annotationTime < startOfData ? annotationTime : startOfData; + endOfData = annotationTime > endOfData ? annotationTime : endOfData; + } + + // Annotations outside the window are data too: the nearest one before and after it. + DateTimeUtc? annotationBefore = await ReadEdgeAnnotationTimestampAsync( + annotationProvider, opContext, node.NodeId, DateTimeUtc.MinValue, windowStart, false, + cancellationToken).ConfigureAwait(false); + DateTimeUtc? annotationAfter = await ReadEdgeAnnotationTimestampAsync( + annotationProvider, opContext, node.NodeId, windowEnd, DateTimeUtc.MaxValue, true, + cancellationToken).ConfigureAwait(false); + if (annotationBefore is DateTimeUtc before && before < startOfData) + { + startOfData = before; + } + if (annotationAfter is DateTimeUtc after && after > endOfData) + { + endOfData = after; + } + if (provider is IHistorianDataProvider raw) + { + // Only the edges matter: the first raw value of a forward read with bounds is + // the earliest one, the first of a reverse read the latest one. + DateTimeUtc? firstRaw = await ReadEdgeRawTimestampAsync( + raw, opContext, node.NodeId, windowStart, windowEnd, true, cancellationToken) + .ConfigureAwait(false); + DateTimeUtc? lastRaw = await ReadEdgeRawTimestampAsync( + raw, opContext, node.NodeId, windowStart, windowEnd, false, cancellationToken) + .ConfigureAwait(false); + if (firstRaw is DateTimeUtc first && first < startOfData) + { + startOfData = first; + } + if (lastRaw is DateTimeUtc last && last > endOfData) + { + endOfData = last; + } + } + ArrayOf outputs; try { @@ -1126,6 +1173,8 @@ namespace Opc.Ua.Server.Historian startTime, endTime, details.ProcessingInterval, + startOfData, + endOfData, kMaxProcessedBufferedOutputs, cancellationToken); } @@ -3260,6 +3309,108 @@ namespace Opc.Ua.Server.Historian } } + /// + /// Returns the timestamp of the first stored raw value that a read with bounds over + /// the window returns in the given direction: the earliest value at or before the + /// window for a forward read, the latest at or after it for a reverse read, or + /// null when the node has no raw data. Reads at most one value and a bound. + /// + private static async ValueTask ReadEdgeRawTimestampAsync( + IHistorianDataProvider raw, + HistorianOperationContext context, + NodeId nodeId, + DateTimeUtc windowStart, + DateTimeUtc windowEnd, + bool isForward, + CancellationToken cancellationToken) + { + var request = new HistorianRawReadRequest + { + NodeId = nodeId, + StartTime = windowStart, + EndTime = windowEnd, + MaxValues = 2, + IsForward = isForward, + ReturnBounds = true + }; + HistorianResumeToken token = default; + while (true) + { + HistorianPage page = await raw.ReadRawAsync( + context, request, token, cancellationToken).ConfigureAwait(false); + foreach (HistoricalDataValue sample in page.Values) + { + // skip bound placeholders as AggregateCalculator.QueueRawValue does. + StatusCode status = sample.Value.StatusCode; + if (!sample.Value.IsNull && + status != StatusCodes.BadNoData && + status != StatusCodes.BadBoundNotFound) + { + return sample.Value.SourceTimestamp; + } + } + if (page.IsFinal) + { + return null; + } + token = page.NextToken; + } + } + + /// + /// Returns the timestamp of the first annotation in the range in the given direction, + /// or null when the range has none. Reads at most one annotation. + /// + private static async ValueTask ReadEdgeAnnotationTimestampAsync( + IHistorianAnnotationProvider provider, + HistorianOperationContext context, + NodeId nodeId, + DateTimeUtc startTime, + DateTimeUtc endTime, + bool isForward, + CancellationToken cancellationToken) + { + var request = new HistorianAnnotationReadRequest + { + NodeId = nodeId, + StartTime = startTime, + EndTime = endTime, + MaxValues = 1, + IsForward = isForward + }; + HistorianResumeToken token = default; + while (true) + { + if (provider is IHistorianTimestampedAnnotationProvider timestamped) + { + HistorianPage timestampedPage = + await timestamped.ReadAnnotationsWithTimestampsAsync( + context, request, token, cancellationToken).ConfigureAwait(false); + foreach (HistorianAnnotation annotation in timestampedPage.Values) + { + return annotation.SourceTimestamp; + } + if (timestampedPage.IsFinal) + { + return null; + } + token = timestampedPage.NextToken; + continue; + } + HistorianPage page = await provider.ReadAnnotationsAsync( + context, request, token, cancellationToken).ConfigureAwait(false); + foreach (Annotation annotation in page.Values) + { + return annotation.AnnotationTime; + } + if (page.IsFinal) + { + return null; + } + token = page.NextToken; + } + } + private static async ValueTask AddOuterNonBadValuesAsync( List collected, HistorianOperationContext context, diff --git a/tests/Opc.Ua.Core.Tests/Stack/Transport/TcpServerChannelBufferTests.cs b/tests/Opc.Ua.Core.Tests/Stack/Transport/TcpServerChannelBufferTests.cs index a3ce9271c2..d5c317a13b 100644 --- a/tests/Opc.Ua.Core.Tests/Stack/Transport/TcpServerChannelBufferTests.cs +++ b/tests/Opc.Ua.Core.Tests/Stack/Transport/TcpServerChannelBufferTests.cs @@ -1064,6 +1064,69 @@ namespace Opc.Ua.Core.Tests.Stack.Transport Is.GreaterThan(DateTime.UtcNow.AddMinutes(-1))); } + /// + /// The shape of CTT GDS Application Directory 005.js: a Call request whose + /// String input argument is MaxStringLength + 10 %, sent in two chunks. + /// The Bad_EncodingLimitsExceeded ServiceFault (OPC 10000-4 §5.3) echoes + /// the RequestHandle (OPC 10000-4 §7.34), read from the first chunk. + /// + [Test] + public async Task OversizedCallArgumentInChunkedRequestFaultEchoesRequestHandleAsync() + { + const int maxStringLength = 1000; + var pool = new TrackingArrayPool(); + using TestServerChannel channel = CreateOpenChannel(pool, maxStringLength: maxStringLength); + var transport = new GateByteTransport(expectedSendCount: 1, captureSentChunks: true); + channel.SetTransport(transport); + + var request = new CallRequest + { + RequestHeader = new RequestHeader + { + AuthenticationToken = new NodeId(Guid.NewGuid()), + Timestamp = DateTime.UtcNow, + RequestHandle = 18 + }, + MethodsToCall = + [ + new CallMethodRequest + { + ObjectId = new NodeId(141u, 2), + MethodId = new NodeId(143u, 2), + InputArguments = [new Variant(new string('u', maxStringLength + (maxStringLength / 10)))] + } + ] + }; + var encodeContext = ServiceMessageContext.Create(NUnitTelemetryContext.Create()); + encodeContext.MaxStringLength = 0; + byte[] body = BinaryEncoder.EncodeMessage(request, encodeContext); + int split = body.Length / 2; + + await channel.FeedReceivedChunkAsync( + channel.CreateRequestChunkForTest( + TcpMessageType.Message, + isFinal: false, + sequenceNumber: 1, + requestId: 7, + body: body.AsSpan(0, split).ToArray())) + .ConfigureAwait(false); + await channel.FeedReceivedChunkAsync( + channel.CreateRequestChunkForTest( + TcpMessageType.Message, + isFinal: true, + sequenceNumber: 2, + requestId: 7, + body: body.AsSpan(split).ToArray())) + .ConfigureAwait(false); + + ServiceFault fault = await ReadSentServiceFaultAsync(transport, expectedRequestId: 7) + .ConfigureAwait(false); + Assert.That( + fault.ResponseHeader.ServiceResult, + Is.EqualTo((StatusCode)StatusCodes.BadEncodingLimitsExceeded)); + Assert.That(fault.ResponseHeader.RequestHandle, Is.EqualTo(18u)); + } + /// /// A discovery-only channel rejects a non-discovery request from its /// encoding id before decoding it; the ServiceFault still echoes the diff --git a/tests/Opc.Ua.Redundancy.Server.Tests/Historian/SharedKeyValueHistorianProviderTests.cs b/tests/Opc.Ua.Redundancy.Server.Tests/Historian/SharedKeyValueHistorianProviderTests.cs index ac8ca5eff4..1954063955 100644 --- a/tests/Opc.Ua.Redundancy.Server.Tests/Historian/SharedKeyValueHistorianProviderTests.cs +++ b/tests/Opc.Ua.Redundancy.Server.Tests/Historian/SharedKeyValueHistorianProviderTests.cs @@ -1110,6 +1110,76 @@ namespace Opc.Ua.Redundancy.Server.Tests.Historian Is.EqualTo(AggregateBits.Calculated)); } + /// + /// Verifies that annotation counts are Bad_NoData outside the data and Partial at its edges + /// (Part 13 §5.4.3.20, §5.3.3.2), and Bad_NoData for a node without any data. + /// + [Test] + public async Task AnnotationCountReportsIntervalsOutsideDataAsync() + { + using var store = new StrongTestStore(); + using AesCbcHmacRecordProtector protector = CreateProtector(); + await using SharedKeyValueHistorianProvider provider = CreateProvider( + store, + protector, + new TestElection(true)); + HistorianOperationContext context = CreateOperationContext(); + var nodeId = new NodeId("annotation-count-edges", 2); + await provider.InsertAsync( + context, + nodeId, + [ValueAt(1, 2), ValueAt(2, 7)], + default).ConfigureAwait(false); + await provider.InsertAnnotationsAsync( + context, + nodeId, + [new Annotation { AnnotationTime = TimeAt(3) }], + default).ConfigureAwait(false); + + HistorianPage page = await provider.ReadProcessedAsync( + context, + new HistorianProcessedReadRequest + { + NodeId = nodeId, + AggregateId = ObjectIds.AggregateFunction_AnnotationCount, + StartTime = TimeAt(0), + EndTime = TimeAt(15), + ProcessingInterval = 5000, + Configuration = new AggregateConfiguration() + }, + default, + default).ConfigureAwait(false); + + Assert.That(page.Values, Has.Count.EqualTo(3)); + Assert.That(page.Values[0].WrappedValue.TryGetValue(out int first), Is.True); + Assert.That(first, Is.EqualTo(1)); + Assert.That( + page.Values[0].StatusCode.AggregateBits, + Is.EqualTo(AggregateBits.Calculated | AggregateBits.Partial)); + Assert.That( + page.Values[1].StatusCode.AggregateBits, + Is.EqualTo(AggregateBits.Calculated | AggregateBits.Partial)); + Assert.That(page.Values[2].StatusCode.Code, Is.EqualTo(StatusCodes.BadNoData)); + + HistorianPage empty = await provider.ReadProcessedAsync( + context, + new HistorianProcessedReadRequest + { + NodeId = new NodeId("annotation-count-empty", 2), + AggregateId = ObjectIds.AggregateFunction_AnnotationCount, + StartTime = TimeAt(0), + EndTime = TimeAt(10), + ProcessingInterval = 5000, + Configuration = new AggregateConfiguration() + }, + default, + default).ConfigureAwait(false); + + Assert.That(empty.Values, Has.Count.EqualTo(2)); + Assert.That(empty.Values[0].StatusCode.Code, Is.EqualTo(StatusCodes.BadNoData)); + Assert.That(empty.Values[1].StatusCode.Code, Is.EqualTo(StatusCodes.BadNoData)); + } + /// /// Verifies provider identity, supported historian interfaces, portable tokens, and default structured-data /// capability. diff --git a/tests/Opc.Ua.Server.Tests/AggregateCttRegressionTests.cs b/tests/Opc.Ua.Server.Tests/AggregateCttRegressionTests.cs index c467b5be33..9a71d7cf48 100644 --- a/tests/Opc.Ua.Server.Tests/AggregateCttRegressionTests.cs +++ b/tests/Opc.Ua.Server.Tests/AggregateCttRegressionTests.cs @@ -226,6 +226,57 @@ namespace Opc.Ua.Server.Tests AssertSingleNumericResult(live, expected, s_baseTime, AggregateBits.Calculated); } + /// + /// Verifies that the value-based status counts Uncertain values as Good when + /// TreatUncertainAsBad is false and as Bad otherwise (Part 13 §4.2.1.2, §5.4.3.2.1). + /// + [TestCase(false)] + [TestCase(true)] + public async Task DirectAndLiveCountValueBasedStatusHonorsTreatUncertainAsBadAsync( + bool treatUncertainAsBad) + { + // Two Good values and one Uncertain value: 100% Good without TreatUncertainAsBad, + // otherwise 67% Good and 33% Bad, which meets neither threshold. + StatusCode expectedCodeBits = treatUncertainAsBad + ? StatusCodes.UncertainDataSubNormal + : StatusCodes.Good; + List rawValues = + [ + CreateValue(1, StatusCodes.Good, 0), + CreateValue(2, StatusCodes.UncertainSubstituteValue, 5), + CreateValue(3, StatusCodes.Good, 10), + CreateValue(4, StatusCodes.Good, 20) + ]; + AggregateConfiguration configuration = CreateConfiguration(treatUncertainAsBad); + DateTimeUtc endTime = AtSeconds(15); + + List direct = RunDirect( + ObjectIds.AggregateFunction_Count, + rawValues, + s_baseTime, + endTime, + 15_000, + configuration); + + using var harness = new AggregateHarness(); + List live = await harness.ReadProcessedAsync( + ObjectIds.AggregateFunction_Count, + rawValues, + s_baseTime, + endTime, + 15_000, + configuration).ConfigureAwait(false); + + foreach (List results in new[] { direct, live }) + { + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].WrappedValue.TryGetValue(out int count), Is.True); + Assert.That(count, Is.EqualTo(2)); + Assert.That(results[0].StatusCode.CodeBits, Is.EqualTo(expectedCodeBits)); + Assert.That(results[0].StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated)); + } + } + /// /// Verifies that repeated Good quality sets the multiple-values flag for worst-quality aggregates. /// @@ -311,6 +362,69 @@ namespace Opc.Ua.Server.Tests AssertSingleNumericResult(live, 10_000, expectedTimestamp, AggregateBits.Calculated); } + /// + /// Verifies that the status of the duration-in-state aggregates takes the region ending at an + /// Uncertain simple end bound into account when sloped interpolation is used + /// (Part 13 §5.4.3.2.2), while the duration itself still includes that region because it + /// starts at a Good raw value (Part 13 §5.4.3.22-.23). + /// + [TestCase("DurationInStateZero", true, 15_000.0)] + [TestCase("DurationInStateNonZero", true, 5_000.0)] + [TestCase("DurationInStateZero", false, 15_000.0)] + [TestCase("DurationInStateNonZero", false, 5_000.0)] + public async Task DirectAndLiveDurationInStateUncertainEndBoundMakesLastRegionUncertainAsync( + string aggregateName, + bool treatUncertainAsBad, + double expected) + { + // Interval [5 s, 25 s): the raw data in the interval is Good, but the simple end bound + // at 25 s is Uncertain_DataSubNormal because the raw value after it (30 s) is Bad. + // The region 20-25 s therefore ends in an Uncertain value. With TreatUncertainAsBad it + // counts as Bad (25%, neither threshold is met); otherwise it counts as Good. + List rawValues = + [ + CreateValue(0, StatusCodes.Good, 0), + CreateValue(0, StatusCodes.Good, 10), + CreateValue(1, StatusCodes.Good, 20), + CreateValue(1, StatusCodes.BadDataUnavailable, 30), + CreateValue(1, StatusCodes.Good, 40) + ]; + NodeId aggregateId = GetAggregateId(aggregateName); + AggregateConfiguration configuration = CreateConfiguration(treatUncertainAsBad); + DateTimeUtc startTime = AtSeconds(5); + DateTimeUtc endTime = AtSeconds(25); + StatusCode expectedCodeBits = treatUncertainAsBad + ? StatusCodes.UncertainDataSubNormal + : StatusCodes.Good; + + 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); + + foreach (List results in new[] { direct, live }) + { + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That( + results[0].WrappedValue.ConvertToDouble().GetDouble(), + Is.EqualTo(expected).Within(0.000_001)); + Assert.That(results[0].StatusCode.CodeBits, Is.EqualTo(expectedCodeBits)); + Assert.That(results[0].StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated)); + } + } + /// /// Verifies that direct and live transition counts follow Part 13 boundary rules. /// @@ -351,10 +465,15 @@ namespace Opc.Ua.Server.Tests } /// - /// Verifies that direct and live transition counts include uncertain values. + /// Verifies that direct and live transition counts include uncertain values only when + /// TreatUncertainAsBad is false. With TreatUncertainAsBad the Uncertain values are equivalent + /// to Bad (Part 13 §4.2.1.2) and Bad values are not counted (Part 13 §5.4.3.24). /// - [Test] - public async Task DirectAndLiveNumberOfTransitionsCountUncertainValuesAsync() + [TestCase(false, 22)] + [TestCase(true, 20)] + public async Task DirectAndLiveNumberOfTransitionsCountUncertainValuesOnlyWhenNotTreatedAsBadAsync( + bool treatUncertainAsBad, + int expectedTransitions) { var rawValues = new List(25); for (int index = 0; index <= 24; index++) @@ -368,7 +487,7 @@ namespace Opc.Ua.Server.Tests rawValues.Add(CreateValue(index, status, index)); } DateTimeUtc endTime = AtSeconds(24); - AggregateConfiguration configuration = CreateConfiguration(treatUncertainAsBad: true); + AggregateConfiguration configuration = CreateConfiguration(treatUncertainAsBad); List direct = RunDirect( ObjectIds.AggregateFunction_NumberOfTransitions, @@ -387,8 +506,8 @@ namespace Opc.Ua.Server.Tests 24_000, configuration).ConfigureAwait(false); - AssertNumberOfTransitionsWithMixedQuality(direct); - AssertNumberOfTransitionsWithMixedQuality(live); + AssertNumberOfTransitionsWithMixedQuality(direct, expectedTransitions); + AssertNumberOfTransitionsWithMixedQuality(live, expectedTransitions); } /// @@ -816,12 +935,14 @@ namespace Opc.Ua.Server.Tests Is.EqualTo(AggregateBits.Calculated | AggregateBits.MultipleValues)); } - private static void AssertNumberOfTransitionsWithMixedQuality(List results) + private static void AssertNumberOfTransitionsWithMixedQuality( + List results, + int expectedTransitions) { Assert.That(results, Has.Count.EqualTo(1)); DataValue result = results[0]; Assert.That(result.WrappedValue.TryGetValue(out int transitions), Is.True); - Assert.That(transitions, Is.EqualTo(22)); + Assert.That(transitions, Is.EqualTo(expectedTransitions)); Assert.That(result.SourceTimestamp, Is.EqualTo(s_baseTime)); Assert.That(result.StatusCode.CodeBits, Is.EqualTo(StatusCodes.UncertainDataSubNormal)); Assert.That(result.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated)); diff --git a/tests/Opc.Ua.Server.Tests/CountAggregateCalculatorTests.cs b/tests/Opc.Ua.Server.Tests/CountAggregateCalculatorTests.cs index eb5dfaf29a..b2a575eeee 100644 --- a/tests/Opc.Ua.Server.Tests/CountAggregateCalculatorTests.cs +++ b/tests/Opc.Ua.Server.Tests/CountAggregateCalculatorTests.cs @@ -282,6 +282,50 @@ namespace Opc.Ua.Server.Tests AssertAnnotationCount(result[0], 2, startTime); } + /// + /// Verifies that annotation counting returns Bad_NoData outside the data and sets the + /// Partial bit on intervals overlapping the edges of the data in both time directions + /// (Part 13 §5.4.3.20, §5.3.3.2). + /// + [TestCase(false)] + [TestCase(true)] + public void CalculateAnnotationCountsReportsIntervalsOutsideData(bool reverse) + { + // Data from 15 s to 25 s, intervals of 10 s over [0 s, 40 s]. + ArrayOf result = + CountAggregateCalculator.CalculateAnnotationCounts( + [TimeAt(15), TimeAt(18), TimeAt(25)], + reverse ? TimeAt(40) : TimeAt(0), + reverse ? TimeAt(0) : TimeAt(40), + processingInterval: 10000, + startOfData: TimeAt(15), + endOfData: TimeAt(25), + outputCap: 10, + CancellationToken.None); + + Assert.That(result, Has.Count.EqualTo(4)); + for (int index = 0; index < result.Count; index++) + { + // chronological interval: 0 = [0,10), 1 = [10,20), 2 = [20,30), 3 = [30,40). + int interval = reverse ? 3 - index : index; + DateTimeUtc timestamp = reverse ? TimeAt(40 - (index * 10)) : TimeAt(index * 10); + Assert.That(result[index].SourceTimestamp, Is.EqualTo(timestamp)); + if (interval is 0 or 3) + { + Assert.That(result[index].StatusCode.Code, Is.EqualTo(StatusCodes.BadNoData)); + Assert.That(result[index].WrappedValue.IsNull, Is.True); + continue; + } + + Assert.That(result[index].WrappedValue.TryGetValue(out int count), Is.True); + Assert.That(count, Is.EqualTo(interval == 1 ? 2 : 1)); + Assert.That(result[index].StatusCode.CodeBits, Is.EqualTo(StatusCodes.Good)); + Assert.That( + result[index].StatusCode.AggregateBits, + Is.EqualTo(AggregateBits.Calculated | AggregateBits.Partial)); + } + } + /// /// Verifies that forward annotation counting uses half-open time intervals. /// @@ -652,11 +696,15 @@ namespace Opc.Ua.Server.Tests } /// - /// Verifies that transition counting uses a preceding uncertain value even when uncertain quality is treated as - /// bad. + /// Verifies that transition counting uses a preceding uncertain value only when uncertain quality is not + /// treated as bad (Part 13 §4.2.1.2, §5.4.3.24): with TreatUncertainAsBad no previous non-Bad value exists, + /// so the first value in the interval is a transition. /// - [Test] - public void NumberOfTransitionsUsesPreviousUncertainValueWhenUncertainIsConfiguredAsBad() + [TestCase(false, 1)] + [TestCase(true, 2)] + public void NumberOfTransitionsUsesPreviousUncertainValueOnlyWhenUncertainIsNotConfiguredAsBad( + bool treatUncertainAsBad, + int expected) { var startTime = new DateTimeUtc(2024, 1, 1, 0, 0, 0); List dataValues = CreateMixedStatusDataValues( @@ -665,7 +713,7 @@ namespace Opc.Ua.Server.Tests [StatusCodes.Uncertain, StatusCodes.Good, StatusCodes.Good, StatusCodes.Good], 1000); DateTimeUtc endTime = startTime.AddMilliseconds(2500); - m_configuration.TreatUncertainAsBad = true; + m_configuration.TreatUncertainAsBad = treatUncertainAsBad; DataValue result = ComputeAggregate( ObjectIds.AggregateFunction_NumberOfTransitions, @@ -675,7 +723,7 @@ namespace Opc.Ua.Server.Tests 2500); Assert.That(result.WrappedValue.TryGetValue(out int count), Is.True); - Assert.That(count, Is.EqualTo(1)); + Assert.That(count, Is.EqualTo(expected)); } /// diff --git a/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs b/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs index 533d05eaee..213da1e056 100644 --- a/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs +++ b/tests/Opc.Ua.Server.Tests/Historian/HistorianDispatcherBranchTests.cs @@ -706,7 +706,144 @@ namespace Opc.Ua.Server.Tests.Historian values[i].SourceTimestamp.ToDateTime(), Is.EqualTo(HarnessFixture.BaseTime.AddSeconds(i * 10))); Assert.That(StatusCode.IsGood(values[i].StatusCode), Is.True); - Assert.That(values[i].StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated)); + + // §5.3.3.2: the last interval overlaps the end of data (29 s), so it is Partial. + Assert.That( + values[i].StatusCode.AggregateBits, + Is.EqualTo(i == 2 + ? AggregateBits.Calculated | AggregateBits.Partial + : AggregateBits.Calculated)); + } + } + + /// + /// Verifies that AnnotationCount returns Bad_NoData for intervals entirely before the start or + /// after the end of data and sets the Partial bit on the interval that overlaps the end of + /// data (Part 13 §5.4.3.20, §5.3.3.2). + /// + [Test] + public async Task DispatchProcessedReadAnnotationCountReportsIntervalsOutsideDataAsync() + { + HarnessFixture h = CreateHarnessWithAggregateManager(); + await h.RegisterAggregateAsync( + ObjectIds.AggregateFunction_AnnotationCount).ConfigureAwait(false); + var nodeId = new NodeId($"anncount-edges-{Guid.NewGuid():N}", 1); + h.Provider.Register(nodeId); + + HistorianOperationContext context = HarnessFixture.CreateContext(h.SystemContext); + + // Raw data from 10 s to 29 s. + var samples = new List(); + for (int i = 10; i < 30; i++) + { + DateTime ts = HarnessFixture.BaseTime.AddSeconds(i); + samples.Add(new DataValue(new Variant((double)i), StatusCodes.Good, ts, ts)); + } + await h.Provider.InsertAsync(context, nodeId, samples, CancellationToken.None).ConfigureAwait(false); + + var annotations = new List + { + new() { Message = "a", UserName = "t", AnnotationTime = HarnessFixture.BaseTime.AddSeconds(12) }, + new() { Message = "b", UserName = "t", AnnotationTime = HarnessFixture.BaseTime.AddSeconds(15) }, + new() { Message = "c", UserName = "t", AnnotationTime = HarnessFixture.BaseTime.AddSeconds(25) } + }; + await h.Provider.InsertAnnotationsAsync(context, nodeId, annotations, CancellationToken.None).ConfigureAwait(false); + + var details = new ReadProcessedDetails + { + StartTime = HarnessFixture.BaseTime, + EndTime = HarnessFixture.BaseTime.AddSeconds(40), + ProcessingInterval = 10000 + }; + var nodeToRead = new HistoryReadValueId + { + NodeId = nodeId, + ContinuationPoint = ByteString.Empty + }; + + var result = new HistoryReadResult(); + ServiceResult error = await HistorianDispatcher.DispatchProcessedReadAsync( + h.SystemContext, h.Provider, CreateVariable(nodeId), nodeToRead, details, + ObjectIds.AggregateFunction_AnnotationCount, TimestampsToReturn.Source, + result, CancellationToken.None).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(error), Is.True); + Assert.That(result.HistoryData.TryGetValue(out HistoryData? hd), Is.True); + DataValue[] values = hd!.DataValues.ToArray()!; + Assert.That(values, Has.Length.EqualTo(4)); + + // [0,10) is before the start of data and [30,40) after the end of data. + Assert.That(values[0].StatusCode.Code, Is.EqualTo(StatusCodes.BadNoData)); + Assert.That(values[3].StatusCode.Code, Is.EqualTo(StatusCodes.BadNoData)); + + // [10,20) starts at the start of data; [20,30) overlaps the end of data (29 s). + Assert.That(values[1].WrappedValue.TryGetValue(out int first), Is.True); + Assert.That(first, Is.EqualTo(2)); + Assert.That(values[1].StatusCode.CodeBits, Is.EqualTo(StatusCodes.Good)); + Assert.That(values[1].StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated)); + Assert.That(values[2].WrappedValue.TryGetValue(out int second), Is.True); + Assert.That(second, Is.EqualTo(1)); + Assert.That(values[2].StatusCode.CodeBits, Is.EqualTo(StatusCodes.Good)); + Assert.That( + values[2].StatusCode.AggregateBits, + Is.EqualTo(AggregateBits.Calculated | AggregateBits.Partial)); + for (int i = 0; i < values.Length; i++) + { + Assert.That( + values[i].SourceTimestamp.ToDateTime(), + Is.EqualTo(HarnessFixture.BaseTime.AddSeconds(i * 10))); + } + } + + /// + /// Verifies that annotations outside the requested window count as data, so a window between + /// two annotations returns calculated zeros instead of Bad_NoData (Part 13 §5.4.3.20). + /// + [Test] + public async Task DispatchProcessedReadAnnotationCountBetweenAnnotationsReturnsZerosAsync() + { + HarnessFixture h = CreateHarnessWithAggregateManager(); + await h.RegisterAggregateAsync( + ObjectIds.AggregateFunction_AnnotationCount).ConfigureAwait(false); + var nodeId = new NodeId($"anncount-between-{Guid.NewGuid():N}", 1); + h.Provider.Register(nodeId); + + HistorianOperationContext context = HarnessFixture.CreateContext(h.SystemContext); + var annotations = new List + { + new() { Message = "a", UserName = "t", AnnotationTime = HarnessFixture.BaseTime }, + new() { Message = "b", UserName = "t", AnnotationTime = HarnessFixture.BaseTime.AddSeconds(100) } + }; + await h.Provider.InsertAnnotationsAsync(context, nodeId, annotations, CancellationToken.None).ConfigureAwait(false); + + var details = new ReadProcessedDetails + { + StartTime = HarnessFixture.BaseTime.AddSeconds(40), + EndTime = HarnessFixture.BaseTime.AddSeconds(60), + ProcessingInterval = 10000 + }; + var nodeToRead = new HistoryReadValueId + { + NodeId = nodeId, + ContinuationPoint = ByteString.Empty + }; + + var result = new HistoryReadResult(); + ServiceResult error = await HistorianDispatcher.DispatchProcessedReadAsync( + h.SystemContext, h.Provider, CreateVariable(nodeId), nodeToRead, details, + ObjectIds.AggregateFunction_AnnotationCount, TimestampsToReturn.Source, + result, CancellationToken.None).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(error), Is.True); + Assert.That(result.HistoryData.TryGetValue(out HistoryData? hd), Is.True); + DataValue[] values = hd!.DataValues.ToArray()!; + Assert.That(values, Has.Length.EqualTo(2)); + foreach (DataValue value in values) + { + Assert.That(value.StatusCode.CodeBits, Is.EqualTo(StatusCodes.Good)); + Assert.That(value.StatusCode.AggregateBits, Is.EqualTo(AggregateBits.Calculated)); + Assert.That(value.WrappedValue.TryGetValue(out int count), Is.True); + Assert.That(count, Is.Zero); } } @@ -797,9 +934,12 @@ namespace Opc.Ua.Server.Tests.Historian Assert.That(values, Is.Not.Null.And.Length.EqualTo(1)); Assert.That(values![0].WrappedValue.TryGetValue(out int count), Is.True); Assert.That(count, Is.EqualTo(3)); + + // §5.3.3.2: the only data (the annotations at 1-3 s) starts and ends inside the + // interval, so it is Partial. Assert.That( values[0].StatusCode.AggregateBits, - Is.EqualTo(AggregateBits.Calculated)); + Is.EqualTo(AggregateBits.Calculated | AggregateBits.Partial)); } /// @@ -993,10 +1133,11 @@ namespace Opc.Ua.Server.Tests.Historian } /// - /// Verifies that AnnotationCount returns zero-valued buckets when no annotations exist. + /// Verifies that AnnotationCount returns Bad_NoData buckets when the node has neither raw + /// data nor annotations, because every interval is outside the (empty) data. /// [Test] - public async Task DispatchProcessedReadAnnotationCountWithNoAnnotationsReturnsZerosAsync() + public async Task DispatchProcessedReadAnnotationCountWithoutDataReturnsBadNoDataAsync() { HarnessFixture h = CreateHarnessWithAggregateManager(); await h.RegisterAggregateAsync( @@ -1023,19 +1164,15 @@ namespace Opc.Ua.Server.Tests.Historian ObjectIds.AggregateFunction_AnnotationCount, TimestampsToReturn.Source, result, CancellationToken.None).ConfigureAwait(false); - // §5.4.3.20: empty interval count is 0 with Good/Calculated status (never Bad_NoData). + // §5.4.3.20: Bad_NoData before the start of data and after the end of data. Assert.That(ServiceResult.IsGood(error), Is.True); Assert.That(result.HistoryData.TryGetValue(out HistoryData? hd), Is.True); DataValue[]? values = hd!.DataValues.ToArray(); Assert.That(values, Is.Not.Null.And.Length.EqualTo(2)); foreach (DataValue v in values!) { - Assert.That(v.WrappedValue.TryGetValue(out int count), Is.True); - Assert.That(count, Is.Zero); - Assert.That(StatusCode.IsGood(v.StatusCode), Is.True); - Assert.That( - v.StatusCode.AggregateBits, - Is.EqualTo(AggregateBits.Calculated)); + Assert.That(v.WrappedValue.IsNull, Is.True); + Assert.That(v.StatusCode.Code, Is.EqualTo(StatusCodes.BadNoData)); } } diff --git a/tests/Opc.Ua.Server.Tests/NodeManager/AsyncCustomNodeManagerNodeManagementTests.cs b/tests/Opc.Ua.Server.Tests/NodeManager/AsyncCustomNodeManagerNodeManagementTests.cs index 445d26ee5b..87742c2494 100644 --- a/tests/Opc.Ua.Server.Tests/NodeManager/AsyncCustomNodeManagerNodeManagementTests.cs +++ b/tests/Opc.Ua.Server.Tests/NodeManager/AsyncCustomNodeManagerNodeManagementTests.cs @@ -242,13 +242,14 @@ namespace Opc.Ua.Server.Tests.NodeManager ParentNodeId = parentId, ReferenceTypeId = ReferenceTypeIds.Organizes, BrowseName = new QualifiedName("SameName", ns), - NodeClass = NodeClass.Object + NodeClass = NodeClass.Object, + TypeDefinition = ObjectTypeIds.BaseObjectType }; (ServiceResult firstResult, NodeId firstId) = await h.Manager .AddNodeAsync(h.OperationContext, first).ConfigureAwait(false); - Assume.That(ServiceResult.IsGood(firstResult), Is.True); - Assume.That(firstId.IsNull, Is.False); + Assert.That(ServiceResult.IsGood(firstResult), Is.True, $"expected Good result; got {firstResult}"); + Assert.That(firstId.IsNull, Is.False); // Second child under same parent with same browse name fails. var second = new AddNodesItem @@ -256,7 +257,8 @@ namespace Opc.Ua.Server.Tests.NodeManager ParentNodeId = parentId, ReferenceTypeId = ReferenceTypeIds.Organizes, BrowseName = new QualifiedName("SameName", ns), - NodeClass = NodeClass.Object + NodeClass = NodeClass.Object, + TypeDefinition = ObjectTypeIds.BaseObjectType }; (ServiceResult secondResult, NodeId secondId) = await h.Manager diff --git a/tests/Opc.Ua.Sessions.Tests/ClientTest.cs b/tests/Opc.Ua.Sessions.Tests/ClientTest.cs index 6c7bd9556c..3d3dc3e8f4 100644 --- a/tests/Opc.Ua.Sessions.Tests/ClientTest.cs +++ b/tests/Opc.Ua.Sessions.Tests/ClientTest.cs @@ -302,6 +302,52 @@ namespace Opc.Ua.Sessions.Tests } } + /// + /// FindServers and GetEndpoints return the ApplicationName in the requested + /// locale when the server advertises it (CTT Discovery Find Servers Filter + /// 003.js/006.js, Get Endpoints 002.js request de-DE). + /// + [Test] + [Order(101)] + [TestCase("de-DE")] + [TestCase("en-US")] + public async Task DiscoveryReturnsApplicationNameInRequestedLocaleAsync(string locale) + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + var endpointConfiguration = EndpointConfiguration.Create(); + endpointConfiguration.OperationTimeout = 10000; + + using DiscoveryClient client = await DiscoveryClient.CreateAsync( + ServerUrl, + endpointConfiguration, + telemetry).ConfigureAwait(false); + ArrayOf localeIds = [locale]; + FindServersResponse servers = await client.FindServersAsync( + null, + client.Endpoint.EndpointUrl, + localeIds, + default, + CancellationToken.None).ConfigureAwait(false); + GetEndpointsResponse endpoints = await client.GetEndpointsAsync( + null, + client.Endpoint.EndpointUrl, + localeIds, + default, + CancellationToken.None).ConfigureAwait(false); + await client.CloseAsync(CancellationToken.None).ConfigureAwait(false); + + ApplicationDescription self = servers.Servers.ToArray()!.Single(s => + s.ApplicationUri == ServerFixture.Config.ApplicationUri); + Assert.That(self.ApplicationName.Locale, Is.EqualTo(locale)); + Assert.That(self.ApplicationName.Text, Is.Not.Empty); + Assert.That(endpoints.Endpoints.Count, Is.GreaterThan(0)); + foreach (EndpointDescription endpoint in endpoints.Endpoints) + { + Assert.That(endpoint.Server.ApplicationName.Locale, Is.EqualTo(locale)); + } + } + [Test] [Order(100)] public async Task FindServersOnNetworkAsync()