diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs
index 684b8b7c..5b8be190 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs
@@ -132,6 +132,14 @@ public sealed record HistoryReadResult(
byte[]? ContinuationPoint);
/// Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.
+///
+/// The first five members are the original set every historian backend serves. The remainder (#285) are
+/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
+/// supports them (the OPC UA Client driver forwards all of them upstream). A backend that cannot serve a
+/// member must throw — the server maps that to
+/// BadAggregateNotSupported rather than a generic failure. Append only: the numeric values
+/// are not persisted, but every switch over this enum must keep a total default.
+///
public enum HistoryAggregateType
{
Average,
@@ -139,6 +147,42 @@ public enum HistoryAggregateType
Maximum,
Total,
Count,
+ Interpolative,
+ TimeAverage,
+ Start,
+ End,
+ Delta,
+ Range,
+ MinimumActualTime,
+ MaximumActualTime,
+ StartBound,
+ EndBound,
+ DeltaBounds,
+ DurationGood,
+ DurationBad,
+ PercentGood,
+ PercentBad,
+ WorstQuality,
+ NumberOfTransitions,
+ AnnotationCount,
+ StandardDeviationPopulation,
+ VariancePopulation,
+}
+
+///
+/// Thrown by a history backend when it cannot serve the requested .
+/// The Server HistoryRead path translates it to BadAggregateNotSupported for that node only.
+///
+public sealed class HistoryAggregateNotSupportedException : NotSupportedException
+{
+ /// The aggregate the backend could not serve.
+ public HistoryAggregateType Aggregate { get; }
+
+ /// Creates the exception for .
+ /// The unsupported aggregate.
+ public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate)
+ : base($"The historian backend does not support the {aggregate} aggregate.")
+ => Aggregate = aggregate;
}
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs
index 033f5403..556bbde2 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs
@@ -79,11 +79,13 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
+ // Mapped OUTSIDE the failure-recording try: an aggregate the gateway has no mode for is a client
+ // request the server answers BadAggregateNotSupported, not a backend fault to count (#285).
+ var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
try
{
// Total/Count are now native gateway retrieval modes — no client-side scaling
// (unlike the Wonderware path that derived Total as Average × interval-seconds).
- var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
var buckets = new List();
await foreach (var bucket in _client
.ReadAggregateAsync(fullReference, startUtc, endUtc, mode, interval, cancellationToken)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs
index f3d8b3a0..09eb3921 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs
@@ -25,7 +25,8 @@ internal static class AggregateModeMapper
/// Maps an aggregate function to the gateway retrieval mode.
/// The driver-agnostic aggregate function.
/// The matching gateway .
- /// A future, unmapped enum member (fails the matrix guard).
+ /// A defined member the gateway has no retrieval mode for.
+ /// An undefined enum value.
public static RetrievalMode ToRetrievalMode(HistoryAggregateType aggregate) => aggregate switch
{
HistoryAggregateType.Average => RetrievalMode.TimeWeightedAverage,
@@ -33,6 +34,10 @@ internal static class AggregateModeMapper
HistoryAggregateType.Maximum => RetrievalMode.MaximumWithTime,
HistoryAggregateType.Total => RetrievalMode.Integral,
HistoryAggregateType.Count => RetrievalMode.Counter,
+ // Part 13 aggregates the gateway has no retrieval mode for (#285) — an UNDEFINED enum value is a
+ // programming error, but a defined-yet-unserved member is a normal client request the server
+ // answers with BadAggregateNotSupported.
+ _ when Enum.IsDefined(aggregate) => throw new HistoryAggregateNotSupportedException(aggregate),
_ => throw new ArgumentOutOfRangeException(
nameof(aggregate), aggregate, "Unmapped HistoryAggregateType — add a RetrievalMode mapping."),
};
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
index 967111cf..cc9a3f70 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
@@ -1779,6 +1779,26 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum,
HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total,
HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count,
+ HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
+ HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
+ HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start,
+ HistoryAggregateType.End => ObjectIds.AggregateFunction_End,
+ HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta,
+ HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range,
+ HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
+ HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
+ HistoryAggregateType.StartBound => ObjectIds.AggregateFunction_StartBound,
+ HistoryAggregateType.EndBound => ObjectIds.AggregateFunction_EndBound,
+ HistoryAggregateType.DeltaBounds => ObjectIds.AggregateFunction_DeltaBounds,
+ 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.NumberOfTransitions => ObjectIds.AggregateFunction_NumberOfTransitions,
+ HistoryAggregateType.AnnotationCount => ObjectIds.AggregateFunction_AnnotationCount,
+ HistoryAggregateType.StandardDeviationPopulation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
+ HistoryAggregateType.VariancePopulation => ObjectIds.AggregateFunction_VariancePopulation,
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null),
};
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 91cca9b0..22db9ecd 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -5169,6 +5169,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
+ catch (HistoryAggregateNotSupportedException ex)
+ {
+ // The backend cannot serve this Part 13 aggregate (#285) — the spec status for THIS node,
+ // not a logged server fault.
+ _logger.LogDebug(ex, "OtOpcUaNodeManager: backend does not support aggregate for node {NodeId}", handle.NodeId);
+ errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
+ results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported };
+ }
catch (Exception ex)
{
// One node's backend failure (throw / timeout) must not throw out of the batch — surface a
@@ -5424,6 +5432,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_Interpolative) return HistoryAggregateType.Interpolative;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_TimeAverage) return HistoryAggregateType.TimeAverage;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_Start) return HistoryAggregateType.Start;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_End) return HistoryAggregateType.End;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_Delta) return HistoryAggregateType.Delta;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_Range) return HistoryAggregateType.Range;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_MinimumActualTime) return HistoryAggregateType.MinimumActualTime;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_MaximumActualTime) return HistoryAggregateType.MaximumActualTime;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_StartBound) return HistoryAggregateType.StartBound;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_EndBound) return HistoryAggregateType.EndBound;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_DeltaBounds) return HistoryAggregateType.DeltaBounds;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_DurationGood) return HistoryAggregateType.DurationGood;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_DurationBad) return HistoryAggregateType.DurationBad;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_PercentGood) return HistoryAggregateType.PercentGood;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_PercentBad) return HistoryAggregateType.PercentBad;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_WorstQuality) return HistoryAggregateType.WorstQuality;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_NumberOfTransitions) return HistoryAggregateType.NumberOfTransitions;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_AnnotationCount) return HistoryAggregateType.AnnotationCount;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_StandardDeviationPopulation) return HistoryAggregateType.StandardDeviationPopulation;
+ if (aggregateNodeId == ObjectIds.AggregateFunction_VariancePopulation) return HistoryAggregateType.VariancePopulation;
return null;
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs
index 871b513f..a608b500 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs
@@ -16,10 +16,24 @@ public sealed class AggregateModeMapperTests
public void Maps_each_aggregate(HistoryAggregateType a, RetrievalMode expected)
=> Assert.Equal(expected, AggregateModeMapper.ToRetrievalMode(a));
- [Fact] // matrix guard: a new HistoryAggregateType member must fail here
- public void Every_aggregate_member_is_mapped()
+ [Fact] // matrix guard: every member is either mapped or explicitly reported as not supported
+ public void Every_aggregate_member_is_mapped_or_reports_not_supported()
{
+ var original = new[]
+ {
+ HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
+ HistoryAggregateType.Total, HistoryAggregateType.Count,
+ };
foreach (var a in Enum.GetValues())
- _ = AggregateModeMapper.ToRetrievalMode(a); // must not throw for any defined member
+ {
+ if (original.Contains(a))
+ _ = AggregateModeMapper.ToRetrievalMode(a);
+ else
+ Assert.Throws(() => AggregateModeMapper.ToRetrievalMode(a));
+ }
}
+
+ [Fact]
+ public void Undefined_value_is_a_programming_error_not_a_not_supported()
+ => Assert.Throws(() => AggregateModeMapper.ToRetrievalMode((HistoryAggregateType)9999));
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
index a515dac5..7fe81eca 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientHistoryTests.cs
@@ -8,14 +8,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
[Trait("Category", "Unit")]
public sealed class OpcUaClientHistoryTests
{
+ /// Every member (#285: the full Part 13 set, not just five).
+ public static IEnumerable