@@ -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
|
||||
|
||||
|
||||
@@ -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<HistoryAggregateType>()` —
|
||||
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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -184,14 +184,108 @@ public sealed record HistoryReadResult(
|
||||
IReadOnlyList<DataValueSnapshot> Samples,
|
||||
byte[]? ContinuationPoint);
|
||||
|
||||
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
|
||||
/// <summary>
|
||||
/// Aggregate function for processed history reads. Mirrors the OPC UA Part 13 §5
|
||||
/// standard aggregate catalog. Each value maps 1:1 onto an
|
||||
/// <c>Opc.Ua.ObjectIds.AggregateFunction_*</c> NodeId — the OPC UA Client driver does the
|
||||
/// translation in <c>OpcUaClientDriver.MapAggregateToNodeId</c>; other drivers either
|
||||
/// evaluate the aggregate locally (Galaxy historian) or surface
|
||||
/// <c>BadAggregateNotSupported</c> for the values their backend can't honour.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Stable ordinals.</b> The first 5 values (<see cref="Average"/>..<see cref="Count"/>)
|
||||
/// carry ordinals 0-4 from the original PR — additions are appended to keep prior
|
||||
/// persisted enums (config files, Admin UI dropdowns) compatible.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Server-side support.</b> Not every upstream OPC UA server implements every
|
||||
/// Part 13 aggregate. Implementations advertise their support through
|
||||
/// <c>AggregateConfiguration</c> on the Server object; clients can probe it at runtime.
|
||||
/// Aggregates that the upstream rejects come back with
|
||||
/// <c>StatusCode=BadAggregateNotSupported</c> on the per-row HistoryRead result —
|
||||
/// the driver passes that through verbatim (cascading-quality rule, Part 11 §8).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum HistoryAggregateType
|
||||
{
|
||||
// ---- Original 5 (ordinals 0-4 — keep stable) ----
|
||||
/// <summary>Average of all values in the interval. Part 13 §5.4.</summary>
|
||||
Average,
|
||||
/// <summary>Minimum value in the interval. Part 13 §5.5.</summary>
|
||||
Minimum,
|
||||
/// <summary>Maximum value in the interval. Part 13 §5.6.</summary>
|
||||
Maximum,
|
||||
/// <summary>Sum of values in the interval (numeric only). Part 13 §5.10.</summary>
|
||||
Total,
|
||||
/// <summary>Count of Good-quality samples in the interval. Part 13 §5.18.</summary>
|
||||
Count,
|
||||
|
||||
// ---- Time-weighted averages (Part 13 §5.4) ----
|
||||
/// <summary>Time-weighted average — values held until next sample. Part 13 §5.4.2.</summary>
|
||||
TimeAverage,
|
||||
/// <summary>Time-weighted average using simple-bounds extrapolation. Part 13 §5.4.3.</summary>
|
||||
TimeAverage2,
|
||||
|
||||
// ---- Interpolation (Part 13 §5.3) ----
|
||||
/// <summary>Interpolated value at each interval boundary. Part 13 §5.3.</summary>
|
||||
Interpolative,
|
||||
|
||||
// ---- Min/Max with timestamps and range (Part 13 §5.5–§5.7) ----
|
||||
/// <summary>Timestamp of the minimum-value sample. Part 13 §5.5.4.</summary>
|
||||
MinimumActualTime,
|
||||
/// <summary>Timestamp of the maximum-value sample. Part 13 §5.6.4.</summary>
|
||||
MaximumActualTime,
|
||||
/// <summary>Maximum minus minimum across the interval. Part 13 §5.7.</summary>
|
||||
Range,
|
||||
/// <summary>Range computed using simple-bounds extrapolation. Part 13 §5.7.</summary>
|
||||
Range2,
|
||||
|
||||
// ---- Annotation / duration / quality coverage (Part 13 §5.16–§5.21) ----
|
||||
/// <summary>Number of annotations attached to samples in the interval. Part 13 §5.21.</summary>
|
||||
AnnotationCount,
|
||||
/// <summary>Total time (ms) covered by Good-quality data. Part 13 §5.16.</summary>
|
||||
DurationGood,
|
||||
/// <summary>Total time (ms) covered by Bad-quality data. Part 13 §5.16.</summary>
|
||||
DurationBad,
|
||||
/// <summary>Percent of the interval covered by Good-quality data (0-100). Part 13 §5.17.</summary>
|
||||
PercentGood,
|
||||
/// <summary>Percent of the interval covered by Bad-quality data (0-100). Part 13 §5.17.</summary>
|
||||
PercentBad,
|
||||
/// <summary>Worst (most-severe) quality code seen in the interval. Part 13 §5.20.</summary>
|
||||
WorstQuality,
|
||||
/// <summary>Worst-quality code using simple-bounds extrapolation. Part 13 §5.20.</summary>
|
||||
WorstQuality2,
|
||||
|
||||
// ---- Statistical (Part 13 §5.13) ----
|
||||
/// <summary>Sample-population standard deviation (n-1 divisor). Part 13 §5.13.</summary>
|
||||
StandardDeviationSample,
|
||||
/// <summary>Whole-population standard deviation (n divisor). Part 13 §5.13.</summary>
|
||||
StandardDeviationPopulation,
|
||||
/// <summary>Sample-population variance (n-1 divisor). Part 13 §5.13.</summary>
|
||||
VarianceSample,
|
||||
/// <summary>Whole-population variance (n divisor). Part 13 §5.13.</summary>
|
||||
VariancePopulation,
|
||||
|
||||
// ---- State-based (Part 13 §5.12, §5.19) ----
|
||||
/// <summary>Number of value transitions observed in the interval. Part 13 §5.12.</summary>
|
||||
NumberOfTransitions,
|
||||
/// <summary>Total time (ms) the value was 0 (state Zero). Part 13 §5.19.</summary>
|
||||
DurationInStateZero,
|
||||
/// <summary>Total time (ms) the value was non-zero (state NonZero). Part 13 §5.19.</summary>
|
||||
DurationInStateNonZero,
|
||||
|
||||
// ---- Interval bounds and deltas (Part 13 §5.8–§5.9, §5.11) ----
|
||||
/// <summary>First Good-quality sample at or after the interval start. Part 13 §5.8.</summary>
|
||||
Start,
|
||||
/// <summary>Last Good-quality sample at or before the interval end. Part 13 §5.9.</summary>
|
||||
End,
|
||||
/// <summary>End sample minus Start sample. Part 13 §5.11.</summary>
|
||||
Delta,
|
||||
/// <summary>Boundary value (extrapolated) at the interval start. Part 13 §5.8.</summary>
|
||||
StartBound,
|
||||
/// <summary>Boundary value (extrapolated) at the interval end. Part 13 §5.9.</summary>
|
||||
EndBound,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2700,14 +2700,67 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>Map <see cref="HistoryAggregateType"/> to the OPC UA Part 13 standard aggregate NodeId.</summary>
|
||||
/// <summary>
|
||||
/// Map <see cref="HistoryAggregateType"/> to the OPC UA Part 13 standard aggregate
|
||||
/// NodeId. Each enum value resolves to <c>Opc.Ua.ObjectIds.AggregateFunction_*</c>;
|
||||
/// the upstream server may still reject individual aggregates with
|
||||
/// <c>BadAggregateNotSupported</c> 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.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// The supplied enum value is outside the declared <see cref="HistoryAggregateType"/>
|
||||
/// range — most likely a future-extension value the driver hasn't been recompiled for.
|
||||
/// </exception>
|
||||
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),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Sweep coverage for the full <see cref="HistoryAggregateType"/> catalog over a real
|
||||
/// <c>opc-plc</c> upstream. Loops every enum value, calls <c>ReadProcessedAsync</c> 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
|
||||
/// <c>BadAggregateNotSupported</c> on the per-row HistoryRead result rather than a
|
||||
/// thrown exception).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Build-only scaffold for now.</b> opc-plc's default profile doesn't enable
|
||||
/// history simulation on the well-known nodes — <c>ns=3;s=StepUp</c> isn't
|
||||
/// historized out of the box. This test therefore <see cref="Assert.Skip(string)"/>
|
||||
/// until the fixture image is upgraded to one of the opc-plc history-sim profiles
|
||||
/// (e.g. <c>--useslowtypes</c> + <c>--ut=10</c>) AND a known-good historized
|
||||
/// NodeId is wired into <see cref="OpcPlcProfile"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why it sweeps every enum.</b> The unit-test sweep
|
||||
/// (<c>OpcUaClientAggregateMappingTests</c>) 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
|
||||
/// <c>BadAggregateNotSupported</c>; we count and log them rather than failing,
|
||||
/// since server-side support is a runtime capability advertisement, not a
|
||||
/// driver-side bug.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(OpcPlcCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "opc-plc")]
|
||||
public sealed class OpcUaClientAggregateSweepTests(OpcPlcFixture sim)
|
||||
{
|
||||
/// <summary>
|
||||
/// Iterates the entire <see cref="HistoryAggregateType"/> 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 <c>BadAggregateNotSupported</c> on the data rows rather than failing
|
||||
/// the test.
|
||||
/// </summary>
|
||||
[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<HistoryAggregateType>();
|
||||
var supported = new List<HistoryAggregateType>();
|
||||
|
||||
foreach (var aggregate in Enum.GetValues<HistoryAggregateType>())
|
||||
{
|
||||
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<HistoryAggregateType>().Length} returned BadAggregateNotSupported. " +
|
||||
$"Unsupported set: {string.Join(", ", unsupported)}");
|
||||
#pragma warning restore CS0162
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Sweep coverage for <see cref="OpcUaClientDriver.MapAggregateToNodeId"/> over the full
|
||||
/// <see cref="HistoryAggregateType"/> 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
|
||||
/// <see cref="ArgumentOutOfRangeException"/> default — never silently returns
|
||||
/// <c>NodeId.Null</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this is unit-only.</b> The OPC UA Part 13 aggregate NodeIds are well-known —
|
||||
/// the SDK exposes them as static readonly fields on <c>Opc.Ua.ObjectIds</c>. Round-trip
|
||||
/// testing against a live upstream is the integration suite's job (see
|
||||
/// <c>OpcUaClientAggregateSweepTests</c>); the wire path doesn't add anything to the
|
||||
/// enum-to-NodeId mapping itself.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cascading-quality rule.</b> Aggregates the upstream server doesn't honour come
|
||||
/// back with <c>BadAggregateNotSupported</c> on the per-row HistoryRead result, not as
|
||||
/// a thrown exception — the driver's mapping is best-effort by design.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientAggregateMappingTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Every declared <see cref="HistoryAggregateType"/> value resolves to a non-null
|
||||
/// namespace-0 <see cref="NodeId"/>. Sweeps the full enum so a new value can't land
|
||||
/// without a switch arm — the default branch throws
|
||||
/// <see cref="ArgumentOutOfRangeException"/> which the test would surface immediately.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Out-of-range enum values trip the default arm with
|
||||
/// <see cref="ArgumentOutOfRangeException"/>. <c>int.MaxValue</c> is a future-proof
|
||||
/// sentinel that is guaranteed never to collide with a real enum ordinal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapAggregateToNodeId_rejects_out_of_range_enum_value()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
OpcUaClientDriver.MapAggregateToNodeId((HistoryAggregateType)int.MaxValue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The enum sweep is the source of truth — every value declared in
|
||||
/// <see cref="HistoryAggregateType"/> participates in
|
||||
/// <see cref="MapAggregateToNodeId_resolves_every_enum_value_to_a_namespace0_NodeId"/>.
|
||||
/// Adding a new enum value automatically adds a new test row.
|
||||
/// </summary>
|
||||
public static TheoryData<HistoryAggregateType> AllHistoryAggregateTypes()
|
||||
{
|
||||
var data = new TheoryData<HistoryAggregateType>();
|
||||
foreach (var v in Enum.GetValues<HistoryAggregateType>())
|
||||
{
|
||||
data.Add(v);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflect the requested static field off <see cref="ObjectIds"/>. 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.
|
||||
/// </summary>
|
||||
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!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user