fix(historian): #685 Gateway native TimeAverage/Total — drop AVEVA's pre-window row, keep Uncertain values
AVEVA answers one extra row for the interval before the window; it was mapped as a result stamped before StartTime (N+1 values). Rows whose interval lies outside the requested span (either direction) are now dropped after the drained stream. An Uncertain (opc 64-191) interval kept no value (opc >= 192 was the only data test); it now carries its value as UncertainDataSubNormal | Calculated. Docs: AVEVA's TWA excludes NULL-gap time while the SQL backends bridge it; end-to-end per-interval values also need historiangw#27.
This commit is contained in:
+4
-2
@@ -136,7 +136,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>TimeAverage / Total</b> stay native AVEVA retrieval modes (<see cref="AggregateModeMapper"/>), stamped with
|
||||
/// the interval start and the Calculated bit. Everything else is <see cref="HistoryAggregateNotSupportedException"/>
|
||||
/// the interval start and the Calculated bit; AVEVA's extra row for the interval before the window is dropped and
|
||||
/// an Uncertain interval keeps its value (#685, <see cref="SampleMapper.ToAggregateSnapshots"/>). Everything else is <see cref="HistoryAggregateNotSupportedException"/>
|
||||
/// (see <see cref="ServedAggregates"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -165,7 +166,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
buckets.Add(bucket);
|
||||
}
|
||||
|
||||
var snapshots = SampleMapper.ToAggregateSnapshots(buckets);
|
||||
// Filtered while mapping, after the stream was drained (#685: AVEVA's extra pre-window row).
|
||||
var snapshots = SampleMapper.ToAggregateSnapshots(buckets, startUtc, endUtc);
|
||||
RecordOutcome(success: true, error: null);
|
||||
return new HistoryReadResult(snapshots, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ internal static class SampleMapper
|
||||
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
|
||||
private const byte GoodQualityFloor = 192;
|
||||
|
||||
/// <summary>OPC DA "Uncertain" family floor (64–191).</summary>
|
||||
private const byte UncertainQualityFloor = 64;
|
||||
|
||||
/// <summary>Maps a single raw sample to a value snapshot.</summary>
|
||||
/// <param name="sample">The gateway raw sample.</param>
|
||||
/// <returns>The driver-agnostic snapshot.</returns>
|
||||
@@ -66,38 +69,77 @@ internal static class SampleMapper
|
||||
/// <returns>The driver-agnostic snapshot.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Unlike the legacy Wonderware DTO (a nullable <c>double?</c>), the gateway proto carries a
|
||||
/// non-optional <c>double value</c>, so an unavailable (no-data) bucket cannot be signalled by a
|
||||
/// null value. Instead it is signalled by a non-Good <c>opc_quality</c>: a Good bucket
|
||||
/// (<c>opc_quality >= 192</c>) yields its value with a Good status, anything else maps to
|
||||
/// <c>BadNoData</c> with a null value — preserving the Wonderware aggregate contract (binary
|
||||
/// Good-with-value / BadNoData-null).
|
||||
/// The gateway proto carries a non-optional <c>double value</c>, so an unavailable (no-data) bucket cannot be signalled
|
||||
/// by a null value; it is signalled by the OPC DA quality family of <c>opc_quality</c> (its low byte, as the raw mapping
|
||||
/// reads it):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Good (≥ 192) ⇒ the value, <c>Good</c>.</item>
|
||||
/// <item>Uncertain (64–191) ⇒ the value, <c>UncertainDataSubNormal</c> (#685): AVEVA marks an interval that holds a
|
||||
/// NULL/Bad sample <c>opc_quality = 64</c> and still answers its value (computed over the interval's good time only —
|
||||
/// see docs/Historian.md). It used to map to <c>BadNoData</c> with the value discarded. The sub-code is the one the SQL
|
||||
/// backends' aggregates give an interval that includes non-good data.</item>
|
||||
/// <item>Bad (< 64) ⇒ <c>BadNoData</c>, null value (the wire's 0 is not a value).</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// #681: both statuses carry the Calculated info bits (<c>0x401</c>) like every other backend's processed value, and
|
||||
/// #681: every status carries the Calculated info bits (<c>0x401</c>) like every other backend's processed value, and
|
||||
/// the bucket is stamped with its interval START (Part 13; it was the interval end), falling back to the end only when
|
||||
/// the start is absent. Pending the live leg: histsdk decodes <c>start_time</c> from a row field (<c>tail + 43</c>) its
|
||||
/// reverse-engineering notes still mark unverified.
|
||||
/// the start is absent (verified on live AVEVA 2026-10-12: <c>start_time</c> is the interval start).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static DataValueSnapshot ToAggregateSnapshot(HistorianAggregateSample aggregate)
|
||||
{
|
||||
bool hasData = aggregate.OpcQuality >= GoodQualityFloor;
|
||||
var q = (byte)aggregate.OpcQuality;
|
||||
var status = q >= GoodQualityFloor ? StatusGood
|
||||
: q >= UncertainQualityFloor ? Sql.Common.HistorianStatusCodes.UncertainDataSubNormal
|
||||
: StatusBadNoData;
|
||||
return new DataValueSnapshot(
|
||||
Value: hasData ? aggregate.Value : null, // boxes as System.Double when present
|
||||
StatusCode: HistorianInfoBits.WithCalculated(hasData ? StatusGood : StatusBadNoData),
|
||||
Value: status == StatusBadNoData ? null : aggregate.Value, // boxes as System.Double when present
|
||||
StatusCode: HistorianInfoBits.WithCalculated(status),
|
||||
SourceTimestampUtc: (aggregate.StartTime ?? aggregate.EndTime)?.ToDateTime(), // bucket timestamp = interval start
|
||||
ServerTimestampUtc: DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Maps a batch of aggregate buckets to value snapshots, in order.</summary>
|
||||
/// <summary>
|
||||
/// True when the aggregate row's interval lies in the requested window — the span between <paramref name="startUtc"/>
|
||||
/// and <paramref name="endUtc"/> in either order (a reverse request covers the same span). AVEVA answers one extra row
|
||||
/// for the interval BEFORE the window (<c>[start − interval, start)</c>, #685), which Part 11/13 has no place for: an
|
||||
/// interval belongs to the window when its start lies in <c>[lo, hi)</c>; a row without a start time (the mapper then
|
||||
/// stamps it with its end) belongs when its end lies in <c>(lo, hi]</c>.
|
||||
/// </summary>
|
||||
/// <param name="aggregate">The gateway aggregate sample.</param>
|
||||
/// <param name="startUtc">The request's start time.</param>
|
||||
/// <param name="endUtc">The request's end time.</param>
|
||||
/// <returns>Whether the row is one of the window's intervals.</returns>
|
||||
public static bool IsInWindow(HistorianAggregateSample aggregate, DateTime startUtc, DateTime endUtc)
|
||||
{
|
||||
var lo = startUtc <= endUtc ? startUtc : endUtc;
|
||||
var hi = startUtc <= endUtc ? endUtc : startUtc;
|
||||
if (aggregate.StartTime is { } s)
|
||||
{
|
||||
var t = s.ToDateTime();
|
||||
return t >= lo && t < hi;
|
||||
}
|
||||
if (aggregate.EndTime is { } e)
|
||||
{
|
||||
var t = e.ToDateTime();
|
||||
return t > lo && t <= hi;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Maps the aggregate buckets of a window to value snapshots, in order, dropping rows outside it (#685).</summary>
|
||||
/// <param name="aggregates">The gateway aggregate samples.</param>
|
||||
/// <returns>The driver-agnostic snapshots.</returns>
|
||||
public static IReadOnlyList<DataValueSnapshot> ToAggregateSnapshots(IEnumerable<HistorianAggregateSample> aggregates)
|
||||
/// <param name="startUtc">The request's start time.</param>
|
||||
/// <param name="endUtc">The request's end time.</param>
|
||||
/// <returns>The driver-agnostic snapshots, one per in-window interval row.</returns>
|
||||
public static IReadOnlyList<DataValueSnapshot> ToAggregateSnapshots(IEnumerable<HistorianAggregateSample> aggregates,
|
||||
DateTime startUtc, DateTime endUtc)
|
||||
{
|
||||
var result = new List<DataValueSnapshot>();
|
||||
foreach (var aggregate in aggregates)
|
||||
result.Add(ToAggregateSnapshot(aggregate));
|
||||
if (IsInWindow(aggregate, startUtc, endUtc))
|
||||
result.Add(ToAggregateSnapshot(aggregate));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user