diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index 9ec8e4f..2a625db 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -183,19 +183,32 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ | `--start` | Start time, ISO 8601 or date string (default: 24 hours ago) | | `--end` | End time, ISO 8601 or date string (default: now) | | `--max` | Maximum number of values (default: 1000) | -| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End | +| `--aggregate` | Aggregate function name (see catalog below). Case-insensitive. | | `--interval` | Processing interval in milliseconds for aggregates (default: 3600000) | #### Aggregate mapping +The CLI accepts the seven aggregates listed below — these are the +human-driven set the operator typically asks for from the command line. + | Name | OPC UA Node ID | |------|---------------| -| `Average` | `AggregateFunction_Average` | -| `Minimum` | `AggregateFunction_Minimum` | -| `Maximum` | `AggregateFunction_Maximum` | +| `Average` (or `avg`) | `AggregateFunction_Average` | +| `Minimum` (or `min`) | `AggregateFunction_Minimum` | +| `Maximum` (or `max`) | `AggregateFunction_Maximum` | | `Count` | `AggregateFunction_Count` | -| `Start` | `AggregateFunction_Start` | -| `End` | `AggregateFunction_End` | +| `Start` (or `first`) | `AggregateFunction_Start` | +| `End` (or `last`) | `AggregateFunction_End` | +| `StandardDeviation` (or `stddev` / `stdev`) | `AggregateFunction_StandardDeviationSample` | + +The driver-side `IHistoryProvider.ReadProcessedAsync` API (used by the +OtOpcUa server's HistoryRead facade) supports the full OPC UA Part 13 §5 +catalog — ~30 aggregates including `TimeAverage`, `Interpolative`, `Range`, +`PercentGood`, `Delta`, etc. See +[`docs/drivers/OpcUaClient.md`](drivers/OpcUaClient.md#historyread-aggregates-part-13-catalog) +for the full list. Adding a new CLI shorthand is a one-line change in +`HistoryReadCommand.ParseAggregateType` — file an issue if you need one +exposed. #### Event-mode coverage diff --git a/docs/drivers/OpcUaClient-Test-Fixture.md b/docs/drivers/OpcUaClient-Test-Fixture.md index 7ca4122..6036c42 100644 --- a/docs/drivers/OpcUaClient-Test-Fixture.md +++ b/docs/drivers/OpcUaClient-Test-Fixture.md @@ -166,6 +166,35 @@ Beyond that: 3. **Dedicated historian integration lab** — only path for historian-specific coverage. +## HistoryRead aggregate coverage + +PR-13 (issue #285) extended `HistoryAggregateType` from 5 to ~30 values +matching the OPC UA Part 13 §5 catalog. The mapping itself +(`OpcUaClientDriver.MapAggregateToNodeId`) is unit-tested via +`OpcUaClientAggregateMappingTests`: + +- The full enum is swept with `Enum.GetValues()` — + every value must resolve to a non-null namespace-0 numeric `NodeId`. +- The 25 new aggregates each assert against a reflection-resolved + `Opc.Ua.ObjectIds.AggregateFunction_*` field by name, so a future SDK + upgrade that renames a constant trips the test loudly. +- The original 5 ordinals stay pinned to their pre-PR-13 NodeIds so existing + config files / persisted enums keep working. + +This is **the well-known-NodeId test path** — the standard Part 13 NodeIds +are stable across SDK versions; round-tripping each one against a live +upstream is the integration suite's job and doesn't add coverage to the +mapping table itself. + +`OpcUaClientAggregateSweepTests` is the integration counterpart. It loops +every enum value against a real opc-plc upstream and asserts the wire path +doesn't crash even when the simulator returns +`BadAggregateNotSupported` for an aggregate it doesn't honour. opc-plc's +default profile doesn't enable HistoryRead on the well-known nodes, so the +test currently `Assert.Skip`s — re-enables when the fixture image is +upgraded to a history-sim profile (`--useslowtypes --ut=10` or similar) and +a known-good historized NodeId is wired into `OpcPlcProfile`. + ## Key fixture / config files - `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` — unit tests with @@ -175,3 +204,7 @@ Beyond that: - `tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` — the server-side integration harness a future loopback client test could piggyback on +- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAggregateMappingTests.cs` + — Part 13 aggregate enum-to-NodeId mapping coverage (PR-13) +- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientAggregateSweepTests.cs` + — wire-side aggregate sweep against opc-plc (build-only scaffold; PR-13) diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index a955423..37d4115 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -193,3 +193,68 @@ HistoryRead facade is responsible for round-tripping these so a paged event read against a chatty upstream completes incrementally. The driver itself doesn't track them — every `ReadEventsAsync` call issues a fresh `HistoryReadAsync`. + +## HistoryRead Aggregates (Part 13 catalog) + +`IHistoryProvider.ReadProcessedAsync` takes a `HistoryAggregateType` and the +driver maps it to the standard `Opc.Ua.ObjectIds.AggregateFunction_*` NodeId +in `MapAggregateToNodeId`. PR-13 (issue #285) extended the enum from the +original 5 values (Average / Minimum / Maximum / Total / Count) to the full +OPC UA Part 13 §5 catalog — ~30 aggregates. + +The mapping is best-effort: not every upstream OPC UA server implements every +aggregate. Aggregates the upstream rejects come back with +`StatusCode=BadAggregateNotSupported` on the per-row HistoryRead result; the +driver passes that through verbatim (cascading-quality rule, Part 11 §8) — it +does not throw. Servers advertise the aggregates they support via the +`AggregateConfiguration` object on the `Server` node; clients can probe it at +runtime. + +### Catalog + +| Enum value | SDK NodeId field | Part 13 § | Server-side support | Typical use | +| --- | --- | --- | --- | --- | +| `Average` | `AggregateFunction_Average` | §5.4 | almost always | smoothing | +| `Minimum` | `AggregateFunction_Minimum` | §5.5 | almost always | low watermark | +| `Maximum` | `AggregateFunction_Maximum` | §5.6 | almost always | high watermark | +| `Total` | `AggregateFunction_Total` | §5.10 | usually | totalisation | +| `Count` | `AggregateFunction_Count` | §5.18 | almost always | sample count | +| `TimeAverage` | `AggregateFunction_TimeAverage` | §5.4.2 | usually | time-weighted mean | +| `TimeAverage2` | `AggregateFunction_TimeAverage2` | §5.4.3 | sometimes | bounded time-weighted mean | +| `Interpolative` | `AggregateFunction_Interpolative` | §5.3 | usually | trend snapshot | +| `MinimumActualTime` | `AggregateFunction_MinimumActualTime` | §5.5.4 | sometimes | when low occurred | +| `MaximumActualTime` | `AggregateFunction_MaximumActualTime` | §5.6.4 | sometimes | when high occurred | +| `Range` | `AggregateFunction_Range` | §5.7 | usually | spread | +| `Range2` | `AggregateFunction_Range2` | §5.7 | sometimes | bounded spread | +| `AnnotationCount` | `AggregateFunction_AnnotationCount` | §5.21 | rarely | operator notes | +| `DurationGood` | `AggregateFunction_DurationGood` | §5.16 | sometimes | quality coverage | +| `DurationBad` | `AggregateFunction_DurationBad` | §5.16 | sometimes | gap accounting | +| `PercentGood` | `AggregateFunction_PercentGood` | §5.17 | sometimes | quality % | +| `PercentBad` | `AggregateFunction_PercentBad` | §5.17 | sometimes | gap % | +| `WorstQuality` | `AggregateFunction_WorstQuality` | §5.20 | sometimes | worst seen | +| `WorstQuality2` | `AggregateFunction_WorstQuality2` | §5.20 | rarely | bounded worst | +| `StandardDeviationSample` | `AggregateFunction_StandardDeviationSample` | §5.13 | sometimes | n-1 stddev | +| `StandardDeviationPopulation` | `AggregateFunction_StandardDeviationPopulation` | §5.13 | sometimes | n stddev | +| `VarianceSample` | `AggregateFunction_VarianceSample` | §5.13 | sometimes | n-1 variance | +| `VariancePopulation` | `AggregateFunction_VariancePopulation` | §5.13 | sometimes | n variance | +| `NumberOfTransitions` | `AggregateFunction_NumberOfTransitions` | §5.12 | sometimes | event count | +| `DurationInStateZero` | `AggregateFunction_DurationInStateZero` | §5.19 | sometimes | OFF time | +| `DurationInStateNonZero` | `AggregateFunction_DurationInStateNonZero` | §5.19 | sometimes | ON time | +| `Start` | `AggregateFunction_Start` | §5.8 | usually | first sample | +| `End` | `AggregateFunction_End` | §5.9 | usually | last sample | +| `Delta` | `AggregateFunction_Delta` | §5.11 | usually | end-start | +| `StartBound` | `AggregateFunction_StartBound` | §5.8 | sometimes | extrapolated start | +| `EndBound` | `AggregateFunction_EndBound` | §5.9 | sometimes | extrapolated end | + +"Server-side support" is heuristic — see your upstream's `AggregateConfiguration` +node for the authoritative list. AVEVA Historian, KEPServerEX, Prosys, and +opc-plc each implement different subsets. + +### Driver-side validation + +The mapping itself is unit-tested over the full enum +(`OpcUaClientAggregateMappingTests`) — every value resolves to a non-null +namespace-0 NodeId, and the original 5 ordinals stay pinned. Wire-side +behaviour against a live server is exercised by +`OpcUaClientAggregateSweepTests` (build-only scaffold pending an opc-plc +history-sim profile). diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs index 195ebd7..032863f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs @@ -184,14 +184,108 @@ public sealed record HistoryReadResult( IReadOnlyList Samples, byte[]? ContinuationPoint); -/// Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates. +/// +/// Aggregate function for processed history reads. Mirrors the OPC UA Part 13 §5 +/// standard aggregate catalog. Each value maps 1:1 onto an +/// Opc.Ua.ObjectIds.AggregateFunction_* NodeId — the OPC UA Client driver does the +/// translation in OpcUaClientDriver.MapAggregateToNodeId; other drivers either +/// evaluate the aggregate locally (Galaxy historian) or surface +/// BadAggregateNotSupported for the values their backend can't honour. +/// +/// +/// +/// Stable ordinals. The first 5 values (..) +/// carry ordinals 0-4 from the original PR — additions are appended to keep prior +/// persisted enums (config files, Admin UI dropdowns) compatible. +/// +/// +/// Server-side support. Not every upstream OPC UA server implements every +/// Part 13 aggregate. Implementations advertise their support through +/// AggregateConfiguration on the Server object; clients can probe it at runtime. +/// Aggregates that the upstream rejects come back with +/// StatusCode=BadAggregateNotSupported on the per-row HistoryRead result — +/// the driver passes that through verbatim (cascading-quality rule, Part 11 §8). +/// +/// public enum HistoryAggregateType { + // ---- Original 5 (ordinals 0-4 — keep stable) ---- + /// Average of all values in the interval. Part 13 §5.4. Average, + /// Minimum value in the interval. Part 13 §5.5. Minimum, + /// Maximum value in the interval. Part 13 §5.6. Maximum, + /// Sum of values in the interval (numeric only). Part 13 §5.10. Total, + /// Count of Good-quality samples in the interval. Part 13 §5.18. Count, + + // ---- Time-weighted averages (Part 13 §5.4) ---- + /// Time-weighted average — values held until next sample. Part 13 §5.4.2. + TimeAverage, + /// Time-weighted average using simple-bounds extrapolation. Part 13 §5.4.3. + TimeAverage2, + + // ---- Interpolation (Part 13 §5.3) ---- + /// Interpolated value at each interval boundary. Part 13 §5.3. + Interpolative, + + // ---- Min/Max with timestamps and range (Part 13 §5.5–§5.7) ---- + /// Timestamp of the minimum-value sample. Part 13 §5.5.4. + MinimumActualTime, + /// Timestamp of the maximum-value sample. Part 13 §5.6.4. + MaximumActualTime, + /// Maximum minus minimum across the interval. Part 13 §5.7. + Range, + /// Range computed using simple-bounds extrapolation. Part 13 §5.7. + Range2, + + // ---- Annotation / duration / quality coverage (Part 13 §5.16–§5.21) ---- + /// Number of annotations attached to samples in the interval. Part 13 §5.21. + AnnotationCount, + /// Total time (ms) covered by Good-quality data. Part 13 §5.16. + DurationGood, + /// Total time (ms) covered by Bad-quality data. Part 13 §5.16. + DurationBad, + /// Percent of the interval covered by Good-quality data (0-100). Part 13 §5.17. + PercentGood, + /// Percent of the interval covered by Bad-quality data (0-100). Part 13 §5.17. + PercentBad, + /// Worst (most-severe) quality code seen in the interval. Part 13 §5.20. + WorstQuality, + /// Worst-quality code using simple-bounds extrapolation. Part 13 §5.20. + WorstQuality2, + + // ---- Statistical (Part 13 §5.13) ---- + /// Sample-population standard deviation (n-1 divisor). Part 13 §5.13. + StandardDeviationSample, + /// Whole-population standard deviation (n divisor). Part 13 §5.13. + StandardDeviationPopulation, + /// Sample-population variance (n-1 divisor). Part 13 §5.13. + VarianceSample, + /// Whole-population variance (n divisor). Part 13 §5.13. + VariancePopulation, + + // ---- State-based (Part 13 §5.12, §5.19) ---- + /// Number of value transitions observed in the interval. Part 13 §5.12. + NumberOfTransitions, + /// Total time (ms) the value was 0 (state Zero). Part 13 §5.19. + DurationInStateZero, + /// Total time (ms) the value was non-zero (state NonZero). Part 13 §5.19. + DurationInStateNonZero, + + // ---- Interval bounds and deltas (Part 13 §5.8–§5.9, §5.11) ---- + /// First Good-quality sample at or after the interval start. Part 13 §5.8. + Start, + /// Last Good-quality sample at or before the interval end. Part 13 §5.9. + End, + /// End sample minus Start sample. Part 13 §5.11. + Delta, + /// Boundary value (extrapolated) at the interval start. Part 13 §5.8. + StartBound, + /// Boundary value (extrapolated) at the interval end. Part 13 §5.9. + EndBound, } /// diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 16025d4..7037d8e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -2700,14 +2700,67 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d finally { _gate.Release(); } } - /// Map to the OPC UA Part 13 standard aggregate NodeId. + /// + /// Map to the OPC UA Part 13 standard aggregate + /// NodeId. Each enum value resolves to Opc.Ua.ObjectIds.AggregateFunction_*; + /// the upstream server may still reject individual aggregates with + /// BadAggregateNotSupported on the per-row HistoryRead result — that's a + /// server-capability signal, not a driver-side error, so callers should treat the + /// mapping itself as best-effort. + /// + /// + /// The supplied enum value is outside the declared + /// range — most likely a future-extension value the driver hasn't been recompiled for. + /// internal static NodeId MapAggregateToNodeId(HistoryAggregateType aggregate) => aggregate switch { + // ---- Original 5 (ordinals 0-4) ---- HistoryAggregateType.Average => ObjectIds.AggregateFunction_Average, HistoryAggregateType.Minimum => ObjectIds.AggregateFunction_Minimum, HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum, HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total, HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count, + + // ---- Time-weighted averages ---- + HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage, + HistoryAggregateType.TimeAverage2 => ObjectIds.AggregateFunction_TimeAverage2, + + // ---- Interpolation ---- + HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative, + + // ---- Min/Max with timestamps + range ---- + HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime, + HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime, + HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range, + HistoryAggregateType.Range2 => ObjectIds.AggregateFunction_Range2, + + // ---- Annotation / duration / quality coverage ---- + HistoryAggregateType.AnnotationCount => ObjectIds.AggregateFunction_AnnotationCount, + HistoryAggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood, + HistoryAggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad, + HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood, + HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad, + HistoryAggregateType.WorstQuality => ObjectIds.AggregateFunction_WorstQuality, + HistoryAggregateType.WorstQuality2 => ObjectIds.AggregateFunction_WorstQuality2, + + // ---- Statistical ---- + HistoryAggregateType.StandardDeviationSample => ObjectIds.AggregateFunction_StandardDeviationSample, + HistoryAggregateType.StandardDeviationPopulation => ObjectIds.AggregateFunction_StandardDeviationPopulation, + HistoryAggregateType.VarianceSample => ObjectIds.AggregateFunction_VarianceSample, + HistoryAggregateType.VariancePopulation => ObjectIds.AggregateFunction_VariancePopulation, + + // ---- State-based ---- + HistoryAggregateType.NumberOfTransitions => ObjectIds.AggregateFunction_NumberOfTransitions, + HistoryAggregateType.DurationInStateZero => ObjectIds.AggregateFunction_DurationInStateZero, + HistoryAggregateType.DurationInStateNonZero => ObjectIds.AggregateFunction_DurationInStateNonZero, + + // ---- Interval bounds and deltas ---- + HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start, + HistoryAggregateType.End => ObjectIds.AggregateFunction_End, + HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta, + HistoryAggregateType.StartBound => ObjectIds.AggregateFunction_StartBound, + HistoryAggregateType.EndBound => ObjectIds.AggregateFunction_EndBound, + _ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null), }; diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientAggregateSweepTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientAggregateSweepTests.cs new file mode 100644 index 0000000..44ce957 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientAggregateSweepTests.cs @@ -0,0 +1,115 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; + +/// +/// Sweep coverage for the full catalog over a real +/// opc-plc upstream. Loops every enum value, calls ReadProcessedAsync with a +/// 1-second processing interval, and asserts the wire path doesn't crash even when the +/// simulator declines to honour a particular aggregate (it returns +/// BadAggregateNotSupported on the per-row HistoryRead result rather than a +/// thrown exception). +/// +/// +/// +/// Build-only scaffold for now. opc-plc's default profile doesn't enable +/// history simulation on the well-known nodes — ns=3;s=StepUp isn't +/// historized out of the box. This test therefore +/// until the fixture image is upgraded to one of the opc-plc history-sim profiles +/// (e.g. --useslowtypes + --ut=10) AND a known-good historized +/// NodeId is wired into . +/// +/// +/// Why it sweeps every enum. The unit-test sweep +/// (OpcUaClientAggregateMappingTests) covers the enum-to-NodeId mapping. +/// This integration test catches any wire-side regression where the SDK rejects +/// a NodeId we thought was well-known — e.g. a future SDK version retires a +/// constant. Aggregates the simulator doesn't honour come back as +/// BadAggregateNotSupported; we count and log them rather than failing, +/// since server-side support is a runtime capability advertisement, not a +/// driver-side bug. +/// +/// +[Collection(OpcPlcCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "opc-plc")] +public sealed class OpcUaClientAggregateSweepTests(OpcPlcFixture sim) +{ + /// + /// Iterates the entire enum against opc-plc. + /// Each call must return a result object (possibly with empty samples and/or a + /// bad-status row inside) without throwing; aggregates the simulator declines are + /// surfaced as BadAggregateNotSupported on the data rows rather than failing + /// the test. + /// + [Fact] + public async Task ReadProcessedAsync_sweeps_every_HistoryAggregateType_without_crashing() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + Assert.Skip( + "opc-plc default profile does not enable HistoryRead on well-known nodes. " + + "Re-enable when OpcPlcFixture is upgraded to a history-sim profile and a known " + + "historized NodeId is added to OpcPlcProfile (e.g. --useslowtypes --ut=10)."); + +#pragma warning disable CS0162 // unreachable scaffold below — kept for the post-fixture-upgrade flip + var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); + await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-aggregate-sweep"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var end = DateTime.UtcNow; + var start = end.AddMinutes(-1); + var interval = TimeSpan.FromSeconds(1); + // Placeholder NodeId — swap to OpcPlcProfile.HistorizedNode once the fixture is upgraded. + const string historizedNode = OpcPlcProfile.StepUp; + + var unsupported = new List(); + var supported = new List(); + + foreach (var aggregate in Enum.GetValues()) + { + HistoryReadResult? result = null; + try + { + result = await drv.ReadProcessedAsync( + historizedNode, start, end, interval, aggregate, + TestContext.Current.CancellationToken); + } + catch (Exception ex) + { + Assert.Fail( + $"ReadProcessedAsync({aggregate}) threw {ex.GetType().Name}: {ex.Message}. " + + "Wire path should never throw — unsupported aggregates surface as BadAggregateNotSupported."); + } + + result.ShouldNotBeNull(); + + // BadAggregateNotSupported = 0x80330000 per OPC UA Part 4 status codes. Detect by + // inspecting the per-row StatusCode — opc-plc returns the bad code on the (single) + // sample row when it can't honour the aggregate. + const uint BadAggregateNotSupported = 0x80330000; + const uint BadHistoryOperationUnsupported = 0x80710000; + var anyBadAggregate = result.Samples.Any(s => + s.StatusCode == BadAggregateNotSupported || + s.StatusCode == BadHistoryOperationUnsupported); + if (anyBadAggregate || result.Samples.Count == 0) + { + unsupported.Add(aggregate); + } + else + { + supported.Add(aggregate); + } + } + + // Sanity: at least one aggregate should round-trip cleanly. If none do, the upstream + // is wholly history-disabled and the fixture-upgrade gate above didn't kick in. + supported.Count.ShouldBeGreaterThan(0, + "at least one Part 13 aggregate should round-trip against opc-plc; " + + $"all {Enum.GetValues().Length} returned BadAggregateNotSupported. " + + $"Unsupported set: {string.Join(", ", unsupported)}"); +#pragma warning restore CS0162 + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAggregateMappingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAggregateMappingTests.cs new file mode 100644 index 0000000..a7da07f --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAggregateMappingTests.cs @@ -0,0 +1,156 @@ +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Sweep coverage for over the full +/// catalog. PR-13 (issue #285) extended the enum from 5 +/// to ~30 values matching OPC UA Part 13 §5; these tests guard the mapping table so a +/// future addition either gets a switch arm or trips the +/// default — never silently returns +/// NodeId.Null. +/// +/// +/// +/// Why this is unit-only. The OPC UA Part 13 aggregate NodeIds are well-known — +/// the SDK exposes them as static readonly fields on Opc.Ua.ObjectIds. Round-trip +/// testing against a live upstream is the integration suite's job (see +/// OpcUaClientAggregateSweepTests); the wire path doesn't add anything to the +/// enum-to-NodeId mapping itself. +/// +/// +/// Cascading-quality rule. Aggregates the upstream server doesn't honour come +/// back with BadAggregateNotSupported on the per-row HistoryRead result, not as +/// a thrown exception — the driver's mapping is best-effort by design. +/// +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientAggregateMappingTests +{ + /// + /// Every declared value resolves to a non-null + /// namespace-0 . Sweeps the full enum so a new value can't land + /// without a switch arm — the default branch throws + /// which the test would surface immediately. + /// + [Theory] + [MemberData(nameof(AllHistoryAggregateTypes))] + public void MapAggregateToNodeId_resolves_every_enum_value_to_a_namespace0_NodeId(HistoryAggregateType aggregate) + { + var nodeId = OpcUaClientDriver.MapAggregateToNodeId(aggregate); + + NodeId.IsNull(nodeId).ShouldBeFalse( + $"HistoryAggregateType.{aggregate} must map to a Part 13 AggregateFunction_* NodeId"); + nodeId.NamespaceIndex.ShouldBe((ushort)0, + $"HistoryAggregateType.{aggregate} maps to a standard NodeId — namespace must be 0"); + nodeId.IdType.ShouldBe(IdType.Numeric, + $"HistoryAggregateType.{aggregate} maps to a numeric Part 13 NodeId"); + } + + /// + /// Regression cover for the original 5 ordinals — Average/Minimum/Maximum/Total/Count + /// stay pinned to their existing SDK NodeIds. Guards against an accidental swap when + /// the switch table grew to ~30 arms. + /// + [Theory] + [InlineData(HistoryAggregateType.Average, "AggregateFunction_Average")] + [InlineData(HistoryAggregateType.Minimum, "AggregateFunction_Minimum")] + [InlineData(HistoryAggregateType.Maximum, "AggregateFunction_Maximum")] + [InlineData(HistoryAggregateType.Total, "AggregateFunction_Total")] + [InlineData(HistoryAggregateType.Count, "AggregateFunction_Count")] + public void MapAggregateToNodeId_original_five_aggregates_stay_pinned_to_their_SDK_NodeIds( + HistoryAggregateType aggregate, string expectedSdkFieldName) + { + var nodeId = OpcUaClientDriver.MapAggregateToNodeId(aggregate); + var expected = GetSdkAggregateNodeId(expectedSdkFieldName); + + nodeId.ShouldBe(expected); + } + + /// + /// The new Part 13 aggregates added in PR-13 each resolve to the SDK constant whose + /// name matches the enum value. Guards against a transposition bug on any of the 25 + /// new arms. + /// + [Theory] + [InlineData(HistoryAggregateType.TimeAverage, "AggregateFunction_TimeAverage")] + [InlineData(HistoryAggregateType.TimeAverage2, "AggregateFunction_TimeAverage2")] + [InlineData(HistoryAggregateType.Interpolative, "AggregateFunction_Interpolative")] + [InlineData(HistoryAggregateType.MinimumActualTime, "AggregateFunction_MinimumActualTime")] + [InlineData(HistoryAggregateType.MaximumActualTime, "AggregateFunction_MaximumActualTime")] + [InlineData(HistoryAggregateType.Range, "AggregateFunction_Range")] + [InlineData(HistoryAggregateType.Range2, "AggregateFunction_Range2")] + [InlineData(HistoryAggregateType.AnnotationCount, "AggregateFunction_AnnotationCount")] + [InlineData(HistoryAggregateType.DurationGood, "AggregateFunction_DurationGood")] + [InlineData(HistoryAggregateType.DurationBad, "AggregateFunction_DurationBad")] + [InlineData(HistoryAggregateType.PercentGood, "AggregateFunction_PercentGood")] + [InlineData(HistoryAggregateType.PercentBad, "AggregateFunction_PercentBad")] + [InlineData(HistoryAggregateType.WorstQuality, "AggregateFunction_WorstQuality")] + [InlineData(HistoryAggregateType.WorstQuality2, "AggregateFunction_WorstQuality2")] + [InlineData(HistoryAggregateType.StandardDeviationSample, "AggregateFunction_StandardDeviationSample")] + [InlineData(HistoryAggregateType.StandardDeviationPopulation, "AggregateFunction_StandardDeviationPopulation")] + [InlineData(HistoryAggregateType.VarianceSample, "AggregateFunction_VarianceSample")] + [InlineData(HistoryAggregateType.VariancePopulation, "AggregateFunction_VariancePopulation")] + [InlineData(HistoryAggregateType.NumberOfTransitions, "AggregateFunction_NumberOfTransitions")] + [InlineData(HistoryAggregateType.DurationInStateZero, "AggregateFunction_DurationInStateZero")] + [InlineData(HistoryAggregateType.DurationInStateNonZero, "AggregateFunction_DurationInStateNonZero")] + [InlineData(HistoryAggregateType.Start, "AggregateFunction_Start")] + [InlineData(HistoryAggregateType.End, "AggregateFunction_End")] + [InlineData(HistoryAggregateType.Delta, "AggregateFunction_Delta")] + [InlineData(HistoryAggregateType.StartBound, "AggregateFunction_StartBound")] + [InlineData(HistoryAggregateType.EndBound, "AggregateFunction_EndBound")] + public void MapAggregateToNodeId_new_Part13_aggregates_resolve_to_matching_SDK_NodeIds( + HistoryAggregateType aggregate, string expectedSdkFieldName) + { + var nodeId = OpcUaClientDriver.MapAggregateToNodeId(aggregate); + var expected = GetSdkAggregateNodeId(expectedSdkFieldName); + + nodeId.ShouldBe(expected); + } + + /// + /// Out-of-range enum values trip the default arm with + /// . int.MaxValue is a future-proof + /// sentinel that is guaranteed never to collide with a real enum ordinal. + /// + [Fact] + public void MapAggregateToNodeId_rejects_out_of_range_enum_value() + { + Should.Throw(() => + OpcUaClientDriver.MapAggregateToNodeId((HistoryAggregateType)int.MaxValue)); + } + + /// + /// The enum sweep is the source of truth — every value declared in + /// participates in + /// . + /// Adding a new enum value automatically adds a new test row. + /// + public static TheoryData AllHistoryAggregateTypes() + { + var data = new TheoryData(); + foreach (var v in Enum.GetValues()) + { + data.Add(v); + } + return data; + } + + /// + /// Reflect the requested static field off . Used so the test + /// table stays declarative — adding a new aggregate is one [InlineData] row plus an + /// enum value, with no SDK-version-specific casting. + /// + private static NodeId GetSdkAggregateNodeId(string fieldName) + { + var field = typeof(ObjectIds).GetField(fieldName) + ?? throw new InvalidOperationException( + $"OPC UA SDK does not expose ObjectIds.{fieldName}. " + + "If the SDK was upgraded and the field was renamed, update the mapping table."); + var value = field.GetValue(null); + return (NodeId)value!; + } +}