fix(client): offer exactly the served aggregates in the CLI and UI

R08 2.3: AggregateType drops StandardDeviation (no backend serves it; always
BadAggregateNotSupported) and gains Range, Delta, MinimumActualTime,
MaximumActualTime, PercentGood and PercentBad. A parity test pins the enum to
AggregateFamilies.Served and each member to its same-named Part 13 ObjectId.
The CLI parses every enum name (plus aliases) and the UI picker lists them all.
Existing members keep their numeric values; 6 is retired.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:24:18 -04:00
parent 57ae69a05e
commit a74a18e49a
9 changed files with 200 additions and 62 deletions
@@ -47,7 +47,7 @@ public class HistoryReadCommand : CommandBase
/// <summary>
/// Gets the optional aggregate name used when the operator wants processed history instead of raw samples.
/// </summary>
[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; }
/// <summary>
@@ -199,24 +199,33 @@ public class HistoryReadCommand : CommandBase
}
}
private static readonly string SupportedAggregates = string.Join(", ", Enum.GetNames<AggregateType>());
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<AggregateType>(trimmed, ignoreCase: true, out var parsed) && Enum.IsDefined(parsed))
return parsed;
throw new ArgumentException($"Unknown aggregate: '{name}'. Supported: {SupportedAggregates}");
}
}
@@ -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.")
};
}
@@ -1,43 +1,64 @@
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// <summary>
/// Aggregate functions for processed history reads.
/// Aggregate functions for processed history reads — exactly the seventeen the server's SQL historian backends
/// serve (<c>AggregateFamilies.Served</c>; a parity test pins it). The Gateway backend serves a subset and refuses
/// the rest with <c>BadAggregateNotSupported</c>.
/// </summary>
/// <remarks>
/// Values are explicit so the pre-existing members keep their numbers. 6 (the former <c>StandardDeviation</c>,
/// which no backend serves) is retired; do not reuse it.
/// </remarks>
public enum AggregateType
{
/// <summary>Average of values in the interval.</summary>
Average,
Average = 0,
/// <summary>Minimum value in the interval.</summary>
Minimum,
Minimum = 1,
/// <summary>Maximum value in the interval.</summary>
Maximum,
Maximum = 2,
/// <summary>Count of values in the interval.</summary>
Count,
Count = 3,
/// <summary>First value in the interval.</summary>
Start,
Start = 4,
/// <summary>Last value in the interval.</summary>
End,
/// <summary>Population standard deviation of values in the interval.</summary>
StandardDeviation,
End = 5,
/// <summary>Time-weighted average, interpolated bounds (Part 13 TimeAverage).</summary>
TimeAverage,
TimeAverage = 7,
/// <summary>Time-weighted average, stepped bounds (Part 13 TimeAverage2).</summary>
TimeAverage2,
TimeAverage2 = 8,
/// <summary>Time-weighted integral over the interval (Part 13 Total).</summary>
Total,
Total = 9,
/// <summary>Time-weighted integral, stepped bounds (Part 13 Total2).</summary>
Total2,
Total2 = 10,
/// <summary>Interpolated value at the interval start (Part 13 Interpolative).</summary>
Interpolative
}
Interpolative = 11,
/// <summary>Maximum minus minimum in the interval (Part 13 Range).</summary>
Range = 12,
/// <summary>Last minus first value in the interval (Part 13 Delta).</summary>
Delta = 13,
/// <summary>Minimum value with its own timestamp (Part 13 MinimumActualTime).</summary>
MinimumActualTime = 14,
/// <summary>Maximum value with its own timestamp (Part 13 MaximumActualTime).</summary>
MaximumActualTime = 15,
/// <summary>Percentage of the interval with Good data (Part 13 PercentGood).</summary>
PercentGood = 16,
/// <summary>Percentage of the interval with Bad data (Part 13 PercentBad).</summary>
PercentBad = 17
}
@@ -43,23 +43,9 @@ public partial class HistoryViewModel : ObservableObject
_dispatcher = dispatcher;
}
/// <summary>Available aggregate types (null means "Raw").</summary>
/// <summary>Available aggregate types (null means "Raw"): every client aggregate, which is the served set.</summary>
public IReadOnlyList<AggregateType?> 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<AggregateType>().Select(a => (AggregateType?)a)];
/// <summary>Gets a value indicating whether an aggregate read is selected.</summary>
public bool IsAggregateRead => SelectedAggregateType != null;