diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
index b19e20fb..64d5d665 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
@@ -47,7 +47,7 @@ public class HistoryReadCommand : CommandBase
///
/// Gets the optional aggregate name used when the operator wants processed history instead of raw samples.
///
- [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative")]
+ [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad")]
public string? Aggregate { get; init; }
///
@@ -199,24 +199,33 @@ public class HistoryReadCommand : CommandBase
}
}
+ private static readonly string SupportedAggregates = string.Join(", ", Enum.GetNames());
+
private static AggregateType ParseAggregateType(string name)
{
- return name.Trim().ToLowerInvariant() switch
+ var trimmed = name.Trim();
+ var alias = trimmed.ToLowerInvariant() switch
{
- "average" or "avg" => AggregateType.Average,
- "minimum" or "min" => AggregateType.Minimum,
- "maximum" or "max" => AggregateType.Maximum,
- "count" => AggregateType.Count,
- "start" or "first" => AggregateType.Start,
- "end" or "last" => AggregateType.End,
- "standarddeviation" or "stddev" or "stdev" => AggregateType.StandardDeviation,
- "timeaverage" or "timeavg" => AggregateType.TimeAverage,
- "timeaverage2" or "timeavg2" => AggregateType.TimeAverage2,
- "total" => AggregateType.Total,
- "total2" => AggregateType.Total2,
- "interpolative" or "interp" => AggregateType.Interpolative,
- _ => throw new ArgumentException(
- $"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative")
+ "avg" => AggregateType.Average,
+ "min" => AggregateType.Minimum,
+ "max" => AggregateType.Maximum,
+ "first" => AggregateType.Start,
+ "last" => AggregateType.End,
+ "timeavg" => AggregateType.TimeAverage,
+ "timeavg2" => AggregateType.TimeAverage2,
+ "interp" => AggregateType.Interpolative,
+ "mintime" => AggregateType.MinimumActualTime,
+ "maxtime" => AggregateType.MaximumActualTime,
+ _ => (AggregateType?)null
};
+ if (alias is { } a) return a;
+
+ // Every enum name (the served set; a parity test pins it), case-insensitive. Numeric strings are refused:
+ // Enum.TryParse would accept "6" or "99" as an undefined value.
+ if (!int.TryParse(trimmed, out _) &&
+ Enum.TryParse(trimmed, ignoreCase: true, out var parsed) && Enum.IsDefined(parsed))
+ return parsed;
+
+ throw new ArgumentException($"Unknown aggregate: '{name}'. Supported: {SupportedAggregates}");
}
}
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs
index 44462199..23b70ede 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs
@@ -23,12 +23,17 @@ public static class AggregateTypeMapper
AggregateType.Count => ObjectIds.AggregateFunction_Count,
AggregateType.Start => ObjectIds.AggregateFunction_Start,
AggregateType.End => ObjectIds.AggregateFunction_End,
- AggregateType.StandardDeviation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
AggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
AggregateType.TimeAverage2 => ObjectIds.AggregateFunction_TimeAverage2,
AggregateType.Total => ObjectIds.AggregateFunction_Total,
AggregateType.Total2 => ObjectIds.AggregateFunction_Total2,
AggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
+ AggregateType.Range => ObjectIds.AggregateFunction_Range,
+ AggregateType.Delta => ObjectIds.AggregateFunction_Delta,
+ AggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
+ AggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
+ AggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
+ AggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.")
};
}
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs
index b7f48ab7..17dddc51 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs
@@ -1,43 +1,64 @@
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
///
-/// Aggregate functions for processed history reads.
+/// Aggregate functions for processed history reads — exactly the seventeen the server's SQL historian backends
+/// serve (AggregateFamilies.Served; a parity test pins it). The Gateway backend serves a subset and refuses
+/// the rest with BadAggregateNotSupported.
///
+///
+/// Values are explicit so the pre-existing members keep their numbers. 6 (the former StandardDeviation,
+/// which no backend serves) is retired; do not reuse it.
+///
public enum AggregateType
{
/// Average of values in the interval.
- Average,
+ Average = 0,
/// Minimum value in the interval.
- Minimum,
+ Minimum = 1,
/// Maximum value in the interval.
- Maximum,
+ Maximum = 2,
/// Count of values in the interval.
- Count,
+ Count = 3,
/// First value in the interval.
- Start,
+ Start = 4,
/// Last value in the interval.
- End,
-
- /// Population standard deviation of values in the interval.
- StandardDeviation,
+ End = 5,
/// Time-weighted average, interpolated bounds (Part 13 TimeAverage).
- TimeAverage,
+ TimeAverage = 7,
/// Time-weighted average, stepped bounds (Part 13 TimeAverage2).
- TimeAverage2,
+ TimeAverage2 = 8,
/// Time-weighted integral over the interval (Part 13 Total).
- Total,
+ Total = 9,
/// Time-weighted integral, stepped bounds (Part 13 Total2).
- Total2,
+ Total2 = 10,
/// Interpolated value at the interval start (Part 13 Interpolative).
- Interpolative
-}
\ No newline at end of file
+ Interpolative = 11,
+
+ /// Maximum minus minimum in the interval (Part 13 Range).
+ Range = 12,
+
+ /// Last minus first value in the interval (Part 13 Delta).
+ Delta = 13,
+
+ /// Minimum value with its own timestamp (Part 13 MinimumActualTime).
+ MinimumActualTime = 14,
+
+ /// Maximum value with its own timestamp (Part 13 MaximumActualTime).
+ MaximumActualTime = 15,
+
+ /// Percentage of the interval with Good data (Part 13 PercentGood).
+ PercentGood = 16,
+
+ /// Percentage of the interval with Bad data (Part 13 PercentBad).
+ PercentBad = 17
+}
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs
index aab55811..b883be03 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/HistoryViewModel.cs
@@ -43,23 +43,9 @@ public partial class HistoryViewModel : ObservableObject
_dispatcher = dispatcher;
}
- /// Available aggregate types (null means "Raw").
+ /// Available aggregate types (null means "Raw"): every client aggregate, which is the served set.
public IReadOnlyList AggregateTypes { get; } =
- [
- null,
- AggregateType.Average,
- AggregateType.Minimum,
- AggregateType.Maximum,
- AggregateType.Count,
- AggregateType.Start,
- AggregateType.End,
- AggregateType.StandardDeviation,
- AggregateType.TimeAverage,
- AggregateType.TimeAverage2,
- AggregateType.Total,
- AggregateType.Total2,
- AggregateType.Interpolative
- ];
+ [null, .. Enum.GetValues().Select(a => (AggregateType?)a)];
/// Gets a value indicating whether an aggregate read is selected.
public bool IsAggregateRead => SelectedAggregateType != null;
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadAggregateParsingTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadAggregateParsingTests.cs
new file mode 100644
index 00000000..8d150ae2
--- /dev/null
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/HistoryReadAggregateParsingTests.cs
@@ -0,0 +1,87 @@
+using CliFx.Exceptions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
+using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
+
+namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
+
+///
+/// historyread --aggregate accepts every aggregate the client offers (which is exactly the served set), so the
+/// gate recipes in docs/Historian.md that need Range / Delta / PercentGood / ... can run from this CLI.
+///
+public class HistoryReadAggregateParsingTests
+{
+ /// Every client aggregate, by its name.
+ /// Each value.
+ public static TheoryData AllAggregates() => new(Enum.GetValues());
+
+ /// Every aggregate name (in any case) reaches the service as that aggregate.
+ /// The aggregate.
+ [Theory]
+ [MemberData(nameof(AllAggregates))]
+ public async Task Every_aggregate_name_parses(AggregateType aggregate)
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=HistNode",
+ Aggregate = aggregate.ToString().ToUpperInvariant()
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.HistoryReadAggregateCalls.ShouldHaveSingleItem().Aggregate.ShouldBe(aggregate);
+ }
+
+ /// The short aliases still parse.
+ /// The alias.
+ /// The aggregate it names.
+ [Theory]
+ [InlineData("avg", AggregateType.Average)]
+ [InlineData("min", AggregateType.Minimum)]
+ [InlineData("last", AggregateType.End)]
+ [InlineData("interp", AggregateType.Interpolative)]
+ [InlineData("mintime", AggregateType.MinimumActualTime)]
+ [InlineData("maxtime", AggregateType.MaximumActualTime)]
+ public async Task Aliases_parse(string alias, AggregateType expected)
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=HistNode",
+ Aggregate = alias
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ await command.ExecuteAsync(console);
+
+ fakeService.HistoryReadAggregateCalls.ShouldHaveSingleItem().Aggregate.ShouldBe(expected);
+ }
+
+ ///
+ /// StandardDeviation is no longer offered (no backend serves it): it is refused before connecting, naming the
+ /// supported set.
+ ///
+ [Fact]
+ public async Task StandardDeviation_is_refused_before_connecting()
+ {
+ var fakeService = new FakeOpcUaClientService();
+ var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
+ {
+ Url = "opc.tcp://localhost:4840",
+ NodeId = "ns=2;s=HistNode",
+ Aggregate = "stddev"
+ };
+
+ using var console = TestConsoleHelper.CreateConsole();
+ var ex = await Should.ThrowAsync(async () => await command.ExecuteAsync(console));
+
+ ex.Message.ShouldContain("PercentGood");
+ fakeService.HistoryReadAggregateCalls.ShouldBeEmpty();
+ }
+}
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs
index 663ab473..267baf02 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Helpers/AggregateTypeMapperTests.cs
@@ -3,6 +3,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
@@ -17,7 +18,6 @@ public class AggregateTypeMapperTests
[InlineData(AggregateType.Count)]
[InlineData(AggregateType.Start)]
[InlineData(AggregateType.End)]
- [InlineData(AggregateType.StandardDeviation)]
[InlineData(AggregateType.TimeAverage)]
[InlineData(AggregateType.TimeAverage2)]
[InlineData(AggregateType.Total)]
@@ -72,14 +72,6 @@ public class AggregateTypeMapperTests
AggregateTypeMapper.ToNodeId(AggregateType.End).ShouldBe(ObjectIds.AggregateFunction_End);
}
- /// Verifies that StandardDeviation aggregate type maps to the correct OPC UA node ID.
- [Fact]
- public void ToNodeId_StandardDeviation_MapsCorrectly()
- {
- AggregateTypeMapper.ToNodeId(AggregateType.StandardDeviation)
- .ShouldBe(ObjectIds.AggregateFunction_StandardDeviationPopulation);
- }
-
/// Verifies that invalid aggregate type value throws ArgumentOutOfRangeException.
[Fact]
public void ToNodeId_InvalidValue_Throws()
@@ -98,4 +90,31 @@ public class AggregateTypeMapperTests
AggregateTypeMapper.ToNodeId(AggregateType.Total2).ShouldBe(ObjectIds.AggregateFunction_Total2);
AggregateTypeMapper.ToNodeId(AggregateType.Interpolative).ShouldBe(ObjectIds.AggregateFunction_Interpolative);
}
-}
\ No newline at end of file
+
+ ///
+ /// The client offers exactly the aggregates the server's SQL backends serve ():
+ /// nothing the server always refuses (the old StandardDeviation), nothing served left unreachable
+ /// (Range, Delta, Min/MaxActualTime, PercentGood/Bad).
+ ///
+ [Fact]
+ public void Client_aggregates_are_exactly_the_served_set()
+ {
+ Enum.GetNames().Order()
+ .ShouldBe(AggregateFamilies.Served.Select(a => a.ToString()).Order());
+ }
+
+ /// Each aggregate maps to the Part 13 ObjectId of the same name.
+ /// The aggregate.
+ [Theory]
+ [MemberData(nameof(AllAggregates))]
+ public void ToNodeId_maps_to_the_same_named_AggregateFunction(AggregateType aggregate)
+ {
+ var field = typeof(ObjectIds).GetField("AggregateFunction_" + aggregate);
+ field.ShouldNotBeNull();
+ AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)field.GetValue(null)!);
+ }
+
+ /// Every client aggregate.
+ /// Each value.
+ public static TheoryData AllAggregates() => new(Enum.GetValues());
+}
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs
index 3d3a32a4..a9f01b51 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs
@@ -132,10 +132,10 @@ public class ModelConstructionTests
((int)ConnectionState.Reconnecting).ShouldBe(3);
}
- /// Verifies that AggregateType enum has the expected number of values.
+ /// AggregateType holds the seventeen served aggregates (the parity test pins which).
[Fact]
public void AggregateType_Enum_HasExpectedValues()
{
- Enum.GetValues().Length.ShouldBe(12);
+ Enum.GetValues().Length.ShouldBe(17);
}
}
\ No newline at end of file
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj
index 903b39f9..e588e7af 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj
@@ -21,6 +21,8 @@
+
+
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/HistoryViewModelTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/HistoryViewModelTests.cs
index e056f78b..a333e699 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/HistoryViewModelTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/HistoryViewModelTests.cs
@@ -141,7 +141,16 @@ public class HistoryViewModelTests
public void AggregateTypes_ContainsNullForRaw()
{
_vm.AggregateTypes.ShouldContain((AggregateType?)null);
- _vm.AggregateTypes.Count.ShouldBe(13); // null + 12 enum values
+ _vm.AggregateTypes.Count.ShouldBe(Enum.GetValues().Length + 1); // null + every aggregate
+ }
+
+ /// The picker offers every client aggregate (the served set), including Range/Delta/PercentGood/...
+ [Fact]
+ public void AggregateTypes_offers_every_aggregate()
+ {
+ _vm.AggregateTypes.Where(a => a != null).Select(a => a!.Value)
+ .ShouldBe(Enum.GetValues(), ignoreOrder: true);
+ _vm.AggregateTypes.ShouldContain(AggregateType.PercentGood);
}
/// Verifies that the Clear method resets the view model state.