feat(historian): serve DurationGood/DurationBad on the SQL backends; pin the #821 late-start facts (#820, #821)

#820: SqlServer + QuestDB now serve Part 13 DurationGood / DurationBad from the
same Good / Bad region totals TimeWeightedAggregator already computes for
PercentGood / PercentBad — reported in milliseconds (OPC UA Duration, the SDK's
unit), Uncertain time in neither (Mantis 0006166), same BadNoData / status /
Partial rules. AggregateFamilies.Served grows 17 -> 19, so i=11201 and the
up-front refusal follow automatically; Gateway still refuses both (explicit
served set, unchanged). Client.Shared AggregateType / mapper / CLI gain both.
The #796 oracle answers DurationGood through the Good-only subclass as well.
QualityMix conformance asserts both directly (Q and QP, hand values, ms, and
= PercentGood x interval), replaces the "refused up front" test, and adds
Duration* to the forward/reverse/truncated oracle comparisons.

#821 (facts only, no decision): SdkAggregateParityTests pins an Uncertain-first
late-start interval (QP over [0,20), both directions) against ours (Part 13
Total 163.96, TimeAverage 10.93), the frozen 1.5.378.182 calculators (188.96,
9.448), SDK 2.0.1's stock calculators (188.96, 9.448) and a Good-first control
(all 163.96); LiveAggregateLateStartFactsTests pins the same through the booted
server's runtime AggregateManager (live AggregateFilter) incl. DurationGood
15000 / PercentGood 75 there vs 8000 / 40 in HistoryRead (#809). QualityMix
adds the reverse late-start variant (reverse TW pin + reverse oracle windows).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-11-11 01:28:54 -05:00
parent 5bbe1e3c7a
commit e7b485c0a2
18 changed files with 328 additions and 59 deletions
@@ -48,7 +48,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, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad")]
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad, DurationGood, DurationBad")]
public string? Aggregate { get; init; }
/// <summary>
@@ -34,6 +34,8 @@ public static class AggregateTypeMapper
AggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
AggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
AggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
AggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood,
AggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad,
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.")
};
}
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// <summary>
/// Aggregate functions for processed history reads — exactly the seventeen the server's SQL historian backends
/// Aggregate functions for processed history reads — exactly the nineteen 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>
@@ -60,5 +60,11 @@ public enum AggregateType
PercentGood = 16,
/// <summary>Percentage of the interval with Bad data (Part 13 PercentBad).</summary>
PercentBad = 17
PercentBad = 17,
/// <summary>Time of the interval with Good data, in milliseconds (Part 13 DurationGood).</summary>
DurationGood = 18,
/// <summary>Time of the interval with Bad data, in milliseconds (Part 13 DurationBad).</summary>
DurationBad = 19
}
@@ -57,7 +57,7 @@ internal static class QuestDbAggregateQueries
private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture);
/// <summary>
/// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here).
/// Average / Minimum / Maximum / Count / Range (DurationGood / DurationBad / PercentGood / PercentBad and Total are time-weighted, not here).
/// Family columns: <c>6 avg_value, 7 min_value, 8 max_value, 9 sum_value</c> over the <b>Good</b> numeric samples
/// (DOUBLE, NULL when none — Part 13 / the SDK), then <c>10 unc_min, 11 unc_max</c> over the Uncertain ones (the
/// Part 13 Minimum/Maximum/Range status rule) and <c>12 good_non_numeric_count</c>.
@@ -434,7 +434,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
/// <inheritdoc />
public bool SupportsReverseEvents => true;
// The seventeen AggregateFamilies.Served - shared with the other SQL backend.
// The nineteen AggregateFamilies.Served - shared with the other SQL backend.
/// <inheritdoc />
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateFamilies.Served;
@@ -16,7 +16,8 @@ public enum AggregateFamily
ActualTime,
/// <summary>
/// TimeAverage / TimeAverage2 / Total / Total2 / Interpolative, and the duration-based PercentGood / PercentBad:
/// TimeAverage / TimeAverage2 / Total / Total2 / Interpolative, and the duration-based DurationGood / DurationBad /
/// PercentGood / PercentBad:
/// Part 13 time-weighted, with bounding values.
/// The backend streams the window's samples plus the bounding rows and <see cref="TimeWeightedAggregator"/>
/// evaluates them (identically for every backend).
@@ -38,12 +39,13 @@ public static class AggregateFamilies
HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime => AggregateFamily.ActualTime,
HistoryAggregateType.TimeAverage or HistoryAggregateType.TimeAverage2 or HistoryAggregateType.Total
or HistoryAggregateType.Total2 or HistoryAggregateType.Interpolative
or HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad => AggregateFamily.TimeWeighted,
or HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad
or HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad => AggregateFamily.TimeWeighted,
_ => null,
};
/// <summary>
/// The seventeen aggregates the SQL backends serve — every member <see cref="FamilyOf"/> assigns a family, derived
/// The nineteen aggregates the SQL backends serve — every member <see cref="FamilyOf"/> assigns a family, derived
/// from it so the set and the dispatch can never disagree. Both <c>SqlServerHistorianDataSource</c> and
/// <c>QuestDbHistorianDataSource</c> return it as <see cref="IHistorianDataSource.SupportedAggregates"/>.
/// </summary>
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
/// <summary>
/// The OPC UA Part 13 time-weighted aggregates (<see cref="AggregateFamily.TimeWeighted"/>): <c>TimeAverage</c>,
/// <c>TimeAverage2</c>, <c>Total</c>, <c>Total2</c>, <c>Interpolative</c> and the duration-based <c>PercentGood</c> /
/// <c>PercentBad</c>, evaluated over a time-ordered stream of stored samples. Both SQL backends fetch the same rows and feed them here, so the two produce identical results
/// <c>TimeAverage2</c>, <c>Total</c>, <c>Total2</c>, <c>Interpolative</c> and the duration-based <c>DurationGood</c> /
/// <c>DurationBad</c> / <c>PercentGood</c> / <c>PercentBad</c>, evaluated over a time-ordered stream of stored samples. Both SQL backends fetch the same rows and feed them here, so the two produce identical results
/// by construction.
/// </summary>
/// <remarks>
@@ -50,6 +50,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
/// <c>GetSimpleBound</c>); time before the first sample is Bad; at the end of the data the last region runs to the
/// end of its own interval (Part 13; the SDK gives it 1 ms) and later intervals are <c>BadNoData</c>; an interval
/// ending before the first sample ⇒ <c>BadNoData</c>. Status Good; any value kind.</description></item>
/// <item><term>DurationGood / DurationBad</term><description>The same Good / Bad region time, in milliseconds (an
/// OPC UA <c>Duration</c>, as the SDK reports it) — PercentGood / PercentBad × interval ÷ 100, with the same
/// <c>BadNoData</c>, status and Partial rules (#820).</description></item>
/// <item><term>Interpolative</term><description>The interpolated-bounding signal's value at each interval start —
/// for a reverse read, at <c>End − i·interval</c> (Part 13 anchors a reverse read at its later time), emitted
/// latest first.</description></item>
@@ -78,7 +81,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
/// interval the data starts inside in the read direction (forward: the first sample fed lies strictly within it;
/// reverse: the latest sample fed lies strictly within it, nothing after — the SDK's <c>m_startOfData</c>,
/// keyed on the first value it is fed); and, forward, the end-of-data interval of TimeAverage2 / Total2 /
/// PercentGood / PercentBad (the SDK's <c>m_endOfData</c>, which it skips for an extrapolated TimeAverage / Total).
/// PercentGood / PercentBad / DurationGood / DurationBad (the SDK's <c>m_endOfData</c>, which it skips for an extrapolated TimeAverage / Total).
/// The timestamp is the interval's <see cref="BucketPlan.BucketTimestamp"/> (its start forward, its later edge
/// reverse); the value is a <see cref="double"/> (Boolean = 0/1, Int64 as double).
/// </para>
@@ -358,14 +361,22 @@ public sealed class TimeWeightedAggregator
? Calc(_rawArea[i] / _usableTicks[i], status, endOfData)
: Calc(_rawArea[i] / TicksPerSecond, status, endOfData);
}
case HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad:
case HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad
or HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad:
{
// No data in or around the interval (it ends before the first sample — the SDK's out-of-range test — or
// starts after the end of the data) ⇒ BadNoData; otherwise the share of the interval's duration.
// starts after the end of the data) ⇒ BadNoData; otherwise the region time (Duration*, in milliseconds —
// an OPC UA Duration) or its share of the interval's duration (Percent*). Uncertain regions count toward
// neither (Part 13 §5.4.3.31-34, Mantis 0006166).
if (_firstSampleUtc is not { } first || hi < first.Ticks || (_dataEndTicks is { } de2 && lo > de2))
return AggregateEvaluator.NoData(stamp, now);
var ticks = _aggregate == HistoryAggregateType.PercentGood ? _goodRegionTicks[i] : _badRegionTicks[i];
return Calc(100d * ticks / duration, HistorianStatusCodes.Good, endOfData);
var ticks = _aggregate is HistoryAggregateType.PercentGood or HistoryAggregateType.DurationGood
? _goodRegionTicks[i]
: _badRegionTicks[i];
var value = _aggregate is HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad
? (double)ticks / TimeSpan.TicksPerMillisecond
: 100d * ticks / duration;
return Calc(value, HistorianStatusCodes.Good, endOfData);
}
default:
throw new HistoryAggregateNotSupportedException(_aggregate);
@@ -47,7 +47,7 @@ internal static class SqlAggregateQueries
private const string NonNumericKinds = "4, 5";
/// <summary>
/// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here).
/// Average / Minimum / Maximum / Count / Range (DurationGood / DurationBad / PercentGood / PercentBad and Total are time-weighted, not here).
/// Family columns: <c>6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue</c> over the <b>Good</b> numeric samples (float,
/// NULL when none — Part 13 and the SDK compute these over Good values only), then <c>10 UncMin, 11 UncMax</c>
/// over the Uncertain ones (the Part 13 Minimum/Maximum/Range status rule, <see cref="AggregateEvaluator"/>), and
@@ -359,7 +359,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
/// <inheritdoc />
public bool SupportsReverseEvents => true;
// <remarks>The seventeen <see cref="AggregateFamilies.Served"/> — shared with the other SQL backend.</remarks>
// <remarks>The nineteen <see cref="AggregateFamilies.Served"/> — shared with the other SQL backend.</remarks>
/// <inheritdoc />
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateFamilies.Served;
@@ -463,7 +463,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
// bucket start (its later edge on a reverse read), except Minimum/MaximumActualTime (the sample's time; ties ⇒
// earliest) and Start/End. Every aggregate but Average sets the Partial bit on the bucket the tag's earliest
// sample lies strictly inside (forward) or its latest sample lies in (reverse: strictly inside).
// PercentGood / PercentBad are duration-based (time-weighted family, below). Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's
// DurationGood / DurationBad / PercentGood / PercentBad are duration-based (time-weighted family, below). Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's
// data range), Start/End are the raw first/last sample of any quality, Delta is Uncertain only when the bucket's
// first or last sample is not usable (see <see cref="AggregateEvaluator"/>).
// </para>
@@ -475,7 +475,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
// <see cref="TimeWeightedAggregator"/>, which fills every interval (an empty one between bounds gets a value).
// </para>
// </remarks>
// <exception cref="HistoryAggregateNotSupportedException">The aggregate is not one of the seventeen served.</exception>
// <exception cref="HistoryAggregateNotSupportedException">The aggregate is not one of the nineteen served.</exception>
// <exception cref="ArgumentOutOfRangeException">The window holds more than <see cref="MaxProcessedBuckets"/> intervals.</exception>
/// <inheritdoc />
public async Task<HistoryReadResult> ReadProcessedAsync(