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:
@@ -1365,7 +1365,7 @@ historian tagname, which is the tag's driver `FullName`.
|
|||||||
The server dispatches all OPC UA HistoryRead to the registered `IHistorianDataSource` — the
|
The server dispatches all OPC UA HistoryRead to the registered `IHistorianDataSource` — the
|
||||||
`GatewayHistorianDataSource` read client when enabled, else the `NullHistorianDataSource` default
|
`GatewayHistorianDataSource` read client when enabled, else the `NullHistorianDataSource` default
|
||||||
(historized nodes return `GoodNoData` for Raw/AtTime — Processed is `BadAggregateNotSupported` since #682). Supported variants: Raw, Processed
|
(historized nodes return `GoodNoData` for Raw/AtTime — Processed is `BadAggregateNotSupported` since #682). Supported variants: Raw, Processed
|
||||||
(backend-dependent: SqlServer/QuestDb serve seventeen; Gateway twelve = `GatewayHistorianDataSource.ServedAggregates` — ⚠️ #681, client-visible: the ten per-sample ones (Average, Count, Min/Max, Range, Start/End/Delta, Min/MaxActualTime) are evaluated from RAW gateway reads by the shared `Sql.Common` evaluator — identical to the SQL backends, Calculated bits, interval-start stamps, #677 reverse; Min/Max are no longer AVEVA modes, the read moves every raw sample of the window (a month of 1 Hz ⇒ `BadTooManyOperations` at the default `MaxTimeWeightedRawRows` cap), and every gateway stream is consumed to completion (an abandoned one makes the gateway evict its shared historian session) — plus TimeAverage/Total via AVEVA modes (now interval-start stamped — pending the live leg — + Calculated); the other time-weighted five stay `BadAggregateNotSupported` because the gateway has no prior-sample read for Part 13 bounds; `docs/Historian.md` §Aggregate set), and AtTime over historized variable nodes; Events over
|
(backend-dependent: SqlServer/QuestDb serve seventeen; Gateway twelve = `GatewayHistorianDataSource.ServedAggregates` — ⚠️ #681, client-visible: the ten per-sample ones (Average, Count, Min/Max, Range, Start/End/Delta, Min/MaxActualTime) are evaluated from RAW gateway reads by the shared `Sql.Common` evaluator — identical to the SQL backends, Calculated bits, interval-start stamps, #677 reverse; Min/Max are no longer AVEVA modes, the read moves every raw sample of the window (a month of 1 Hz ⇒ `BadTooManyOperations` at the default `MaxTimeWeightedRawRows` cap), and every gateway stream is consumed to completion (an abandoned one makes the gateway evict its shared historian session) — plus TimeAverage/Total via AVEVA modes (interval-start stamped + Calculated; ⚠️ #685: AVEVA's extra pre-window row is dropped ⇒ one row per interval in `[Start, End)`, and an Uncertain (opc 64) interval keeps its value as `UncertainDataSubNormal` — AVEVA's TWA excludes NULL-gap time where the SQL backends bridge it; per-interval values still need gateway fix historiangw#27); the other time-weighted five stay `BadAggregateNotSupported` because the gateway has no prior-sample read for Part 13 bounds; `docs/Historian.md` §Aggregate set), and AtTime over historized variable nodes; Events over
|
||||||
alarm-owning equipment-folder event-notifier nodes. Reads are ungated (served from any redundancy node);
|
alarm-owning equipment-folder event-notifier nodes. Reads are ungated (served from any redundancy node);
|
||||||
authorization uses the standard `AccessLevels.HistoryRead` bit set at materialization.
|
authorization uses the standard `AccessLevels.HistoryRead` bit set at materialization.
|
||||||
|
|
||||||
|
|||||||
+28
-5
@@ -795,17 +795,40 @@ The OPC UA `Total` aggregate is **supported** over the HistorianGateway backend.
|
|||||||
native **`Integral`** retrieval mode, so `Total` maps straight to it (`HistoryAggregateType.Total →
|
native **`Integral`** retrieval mode, so `Total` maps straight to it (`HistoryAggregateType.Total →
|
||||||
RetrievalMode.Integral`) — no client-side scaling. (This replaces the retired Wonderware path, which had no
|
RetrievalMode.Integral`) — no client-side scaling. (This replaces the retired Wonderware path, which had no
|
||||||
`Total` column and derived it client-side as time-weighted `Average × interval-seconds`.) Values pass through unchanged; a Good
|
`Total` column and derived it client-side as time-weighted `Average × interval-seconds`.) Values pass through unchanged; a Good
|
||||||
bucket is `Good | Calculated` (`0x00000401`) and an empty / null bucket `BadNoData | Calculated` (#681), stamped with its interval
|
bucket is `Good | Calculated` (`0x00000401`), an Uncertain one `UncertainDataSubNormal | Calculated` (`0x40A40401`) **with its
|
||||||
start (see below).
|
value** (#685), and an empty / null bucket `BadNoData | Calculated` (#681), stamped with its interval start (see below).
|
||||||
|
|
||||||
|
**Native `TimeAverage` / `Total` rows (#685 — client-visible).** Two mapping defects found by the live AVEVA gate (2026-10-12) are
|
||||||
|
fixed:
|
||||||
|
|
||||||
|
- **One result per interval in `[Start, End)`.** AVEVA always answers one extra row for the interval *before* the window
|
||||||
|
(`[start − interval, start)` — a real value when data exists there, a no-data row otherwise). It used to be passed through, so a
|
||||||
|
client got N + 1 values, the first stamped before its StartTime. Rows whose interval start lies outside the requested span are now
|
||||||
|
dropped (a row with no start time is placed by its end time, `(lo, hi]`); the span is taken in either order, so the filter does
|
||||||
|
not depend on the read direction. The stream is still drained in full and filtered afterwards — never broken out of.
|
||||||
|
- **An Uncertain interval keeps its value.** AVEVA marks an interval that holds a NULL/Bad sample `opc_quality = 64` (Uncertain) and
|
||||||
|
still supplies its value. It used to be mapped `BadNoData` with the value discarded (`opc_quality >= 192` was the only "has data"
|
||||||
|
test); every Uncertain-family quality (64–191, low byte) now returns the value as `UncertainDataSubNormal | Calculated` — the
|
||||||
|
sub-code the SQL backends give an interval that includes non-good data. Only a Bad-family quality (< 64) is `BadNoData`, null.
|
||||||
|
- ⚠️ **AVEVA's semantics differ from the SQL backends' across a NULL gap.** AVEVA's `TimeWeightedAverage` holds the previous value
|
||||||
|
up to the NULL sample and **excludes the gap time from both numerator and denominator** (live: an interval with 10 good seconds
|
||||||
|
of (5 → 1) answers 3, `Integral` 30); the `SqlServer`/`QuestDb` evaluator bridges across the outage (Part 13 interpolated
|
||||||
|
bounding — 6.08 Uncertain for the same samples, see [Time-weighted aggregates](#time-weighted-aggregates-part-13-671)).
|
||||||
|
Extrapolation past the last sample is `Good` on AVEVA and `Uncertain` on the SQL backends. On all-Good spans the magnitudes
|
||||||
|
match exactly (both sloped/trapezoidal).
|
||||||
|
- ⚠️ **End-to-end correctness also needs the gateway fix historiangw#27:** the gateway sends the aggregate interval to AVEVA in
|
||||||
|
ticks where AVEVA reads milliseconds (10,000× too large), so today a native read returns one row for the whole window (plus the
|
||||||
|
pre-window row, now dropped). OtOpcUa does not work around it; per-interval values return once the gateway is fixed.
|
||||||
|
|
||||||
**Integral unit (#677).** AVEVA's integral is `∫ value dt` (seconds) ÷ the tag's `IntegralDivisor`. The proto3 `double` default for
|
**Integral unit (#677).** AVEVA's integral is `∫ value dt` (seconds) ÷ the tag's `IntegralDivisor`. The proto3 `double` default for
|
||||||
`HistorianTagDefinition.IntegralDivisor` is `0`, so `GatewayTagProvisioner` now sends **`IntegralDivisor = 1`** explicitly on every
|
`HistorianTagDefinition.IntegralDivisor` is `0`, so `GatewayTagProvisioner` now sends **`IntegralDivisor = 1`** explicitly on every
|
||||||
`EnsureTags` — the value·seconds unit the SQL backends' `Total` uses. A tag provisioned earlier keeps its divisor until its next
|
`EnsureTags` — the value·seconds unit the SQL backends' `Total` uses. A tag provisioned earlier keeps its divisor until its next
|
||||||
`EnsureTags` (an upsert). The live magnitude check against AVEVA (a constant 1 integrated over 1 s reads 1) is still open — it needs the
|
`EnsureTags` (an upsert). The live magnitude check against AVEVA (a constant 1 integrated over 1 s reads 1) passed on 2026-10-12 at the AVEVA
|
||||||
VPN. Reverse processed reads of the AVEVA-native `TimeAverage`/`Total` are not re-anchored by #677 (the gateway refuses `end < start`);
|
level; end to end it waits on historiangw#27 (above). Reverse processed reads of the AVEVA-native `TimeAverage`/`Total` are not re-anchored by #677 (the gateway refuses `end < start`);
|
||||||
the raw-evaluated per-sample aggregates (#681) do follow #677. Since #681 every native bucket is stamped with its interval **start**
|
the raw-evaluated per-sample aggregates (#681) do follow #677. Since #681 every native bucket is stamped with its interval **start**
|
||||||
(Part 13; it was the interval end) and carries the Calculated info bits (`Good` ⇒ `0x00000401`, no data ⇒ `BadNoData | 0x401`).
|
(Part 13; it was the interval end) and carries the Calculated info bits (`Good` ⇒ `0x00000401`, no data ⇒ `BadNoData | 0x401`).
|
||||||
⚠️ **The interval-start stamp is pending the live leg:** it reads the aggregate row's `start_time`, which histsdk decodes from a row
|
The live AVEVA gate (2026-10-12) confirmed `start_time` is the interval start (and the `value·s` unit). Previously noted as
|
||||||
|
pending: it reads the aggregate row's `start_time`, which histsdk decodes from a row
|
||||||
field (`tail + 43`) its own reverse-engineering notes still mark unverified (HistorianGateway
|
field (`tail + 43`) its own reverse-engineering notes still mark unverified (HistorianGateway
|
||||||
`docs/plans/2026-07-13-archreview2-D-histsdk-residue-design.md`); if that field proves wrong on live AVEVA the stamp is wrong with it.
|
`docs/plans/2026-07-13-archreview2-D-histsdk-residue-design.md`); if that field proves wrong on live AVEVA the stamp is wrong with it.
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -136,7 +136,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>TimeAverage / Total</b> stay native AVEVA retrieval modes (<see cref="AggregateModeMapper"/>), stamped with
|
/// <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"/>).
|
/// (see <see cref="ServedAggregates"/>).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -165,7 +166,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
|||||||
buckets.Add(bucket);
|
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);
|
RecordOutcome(success: true, error: null);
|
||||||
return new HistoryReadResult(snapshots, ContinuationPoint: 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>
|
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
|
||||||
private const byte GoodQualityFloor = 192;
|
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>
|
/// <summary>Maps a single raw sample to a value snapshot.</summary>
|
||||||
/// <param name="sample">The gateway raw sample.</param>
|
/// <param name="sample">The gateway raw sample.</param>
|
||||||
/// <returns>The driver-agnostic snapshot.</returns>
|
/// <returns>The driver-agnostic snapshot.</returns>
|
||||||
@@ -66,38 +69,77 @@ internal static class SampleMapper
|
|||||||
/// <returns>The driver-agnostic snapshot.</returns>
|
/// <returns>The driver-agnostic snapshot.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Unlike the legacy Wonderware DTO (a nullable <c>double?</c>), the gateway proto carries a
|
/// The gateway proto carries a non-optional <c>double value</c>, so an unavailable (no-data) bucket cannot be signalled
|
||||||
/// non-optional <c>double value</c>, so an unavailable (no-data) bucket cannot be signalled by a
|
/// 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
|
||||||
/// null value. Instead it is signalled by a non-Good <c>opc_quality</c>: a Good bucket
|
/// reads it):
|
||||||
/// (<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).
|
|
||||||
/// </para>
|
/// </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>
|
/// <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 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
|
/// the start is absent (verified on live AVEVA 2026-10-12: <c>start_time</c> is the interval start).
|
||||||
/// reverse-engineering notes still mark unverified.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static DataValueSnapshot ToAggregateSnapshot(HistorianAggregateSample aggregate)
|
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(
|
return new DataValueSnapshot(
|
||||||
Value: hasData ? aggregate.Value : null, // boxes as System.Double when present
|
Value: status == StatusBadNoData ? null : aggregate.Value, // boxes as System.Double when present
|
||||||
StatusCode: HistorianInfoBits.WithCalculated(hasData ? StatusGood : StatusBadNoData),
|
StatusCode: HistorianInfoBits.WithCalculated(status),
|
||||||
SourceTimestampUtc: (aggregate.StartTime ?? aggregate.EndTime)?.ToDateTime(), // bucket timestamp = interval start
|
SourceTimestampUtc: (aggregate.StartTime ?? aggregate.EndTime)?.ToDateTime(), // bucket timestamp = interval start
|
||||||
ServerTimestampUtc: DateTime.UtcNow);
|
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>
|
/// <param name="aggregates">The gateway aggregate samples.</param>
|
||||||
/// <returns>The driver-agnostic snapshots.</returns>
|
/// <param name="startUtc">The request's start time.</param>
|
||||||
public static IReadOnlyList<DataValueSnapshot> ToAggregateSnapshots(IEnumerable<HistorianAggregateSample> aggregates)
|
/// <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>();
|
var result = new List<DataValueSnapshot>();
|
||||||
foreach (var aggregate in aggregates)
|
foreach (var aggregate in aggregates)
|
||||||
result.Add(ToAggregateSnapshot(aggregate));
|
if (IsInWindow(aggregate, startUtc, endUtc))
|
||||||
|
result.Add(ToAggregateSnapshot(aggregate));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #685: the AVEVA-native <c>TimeAverage</c> / <c>Total</c> rows. The fake emits the REAL wire shape captured by the live
|
||||||
|
/// AVEVA gate (2026-10-12): AVEVA always adds one row for the interval BEFORE the window (<c>[start − interval, start)</c>,
|
||||||
|
/// carrying a real value when data exists there, a no-data row otherwise — and, under historiangw#27, starting days
|
||||||
|
/// early), and marks an interval holding a NULL/Bad sample <c>opc_quality = 64</c> while still supplying its value.
|
||||||
|
/// Every row carries a <c>value</c> on the wire (proto3 non-optional double; a no-data row is 0 + a Bad OPC quality).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GatewayNativeAggregateRowsTests
|
||||||
|
{
|
||||||
|
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||||
|
|
||||||
|
/// <summary>2026-10-12 18:06:00 UTC — the live gate's tag <c>HistGW.LiveTest.Gate681</c>.</summary>
|
||||||
|
private static readonly DateTime T0 = new(2026, 10, 12, 18, 6, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static readonly TimeSpan I20 = TimeSpan.FromSeconds(20);
|
||||||
|
|
||||||
|
private const uint GoodCalc = 0x00000401u;
|
||||||
|
private const uint UncertainCalc = 0x40A40401u; // UncertainDataSubNormal + Calculated, as the SQL backends' aggregates
|
||||||
|
private const uint NoDataCalc = 0x809B0401u;
|
||||||
|
|
||||||
|
private static HistorianAggregateSample Row(DateTime? start, DateTime? end, double value, uint opc) => new()
|
||||||
|
{
|
||||||
|
Tag = "T",
|
||||||
|
StartTime = start is { } s ? Timestamp.FromDateTime(s) : null,
|
||||||
|
EndTime = end is { } e ? Timestamp.FromDateTime(e) : null,
|
||||||
|
Value = value,
|
||||||
|
OpcQuality = opc,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<DataValueSnapshot>> Read(IReadOnlyList<HistorianAggregateSample> wire,
|
||||||
|
DateTime start, DateTime end, HistoryAggregateType aggregate = HistoryAggregateType.TimeAverage)
|
||||||
|
{
|
||||||
|
var fake = new FakeHistorianGatewayClient { AggregateSamples = wire };
|
||||||
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||||
|
return (await ds.ReadProcessedAsync("T", start, end, I20, aggregate, Ct)).Samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- D1: rows outside the requested window -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_leading_pre_window_row_carrying_a_real_value_is_dropped()
|
||||||
|
{
|
||||||
|
// Live: TimeAverage over [18:06:20, 18:07:20) returned "18:06:00 22.5 Good" first — the interval before the window.
|
||||||
|
var start = T0.AddSeconds(20);
|
||||||
|
var wire = new[]
|
||||||
|
{
|
||||||
|
Row(T0, start, 22.5, 192),
|
||||||
|
Row(start, start + I20, 30, 192),
|
||||||
|
Row(start + I20, start + 2 * I20, 3, 64),
|
||||||
|
Row(start + 2 * I20, start + 3 * I20, 1, 192),
|
||||||
|
};
|
||||||
|
var s = await Read(wire, start, start + 3 * I20);
|
||||||
|
s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { start, start + I20, start + 2 * I20 });
|
||||||
|
s[0].Value.ShouldBe(30d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_no_data_pre_window_row_is_dropped_rather_than_answered_BadNoData()
|
||||||
|
{
|
||||||
|
var wire = new[] { Row(T0 - I20, T0, 0, 0), Row(T0, T0 + I20, 22.5, 192) };
|
||||||
|
var s = await Read(wire, T0, T0 + I20, HistoryAggregateType.Total);
|
||||||
|
s.Count.ShouldBe(1);
|
||||||
|
s[0].SourceTimestampUtc.ShouldBe(T0);
|
||||||
|
s[0].StatusCode.ShouldBe(GoodCalc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_pre_window_row_starting_days_early_is_dropped()
|
||||||
|
{
|
||||||
|
// historiangw#27: the interval reaches AVEVA 10,000× too large, so the leading row starts days before the window.
|
||||||
|
var wire = new[] { Row(T0.AddSeconds(-200_000), T0, 0, 0), Row(T0, T0.AddMinutes(2), 11.727, 64) };
|
||||||
|
var s = await Read(wire, T0, T0.AddMinutes(2));
|
||||||
|
s.Count.ShouldBe(1);
|
||||||
|
s[0].SourceTimestampUtc.ShouldBe(T0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_row_whose_interval_starts_at_or_after_the_window_end_is_dropped()
|
||||||
|
{
|
||||||
|
var wire = new[] { Row(T0, T0 + I20, 1, 192), Row(T0 + I20, T0 + 2 * I20, 2, 192) };
|
||||||
|
var s = await Read(wire, T0, T0 + I20);
|
||||||
|
s.Count.ShouldBe(1);
|
||||||
|
s[0].Value.ShouldBe(1d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Without_a_start_time_a_row_is_placed_by_its_end_time()
|
||||||
|
{
|
||||||
|
// Mapper fallback: no StartTime ⇒ stamped with its EndTime. The pre-window row then ENDS at the window start (out);
|
||||||
|
// an in-window row ends inside (lo, hi] (in).
|
||||||
|
var wire = new[] { Row(null, T0, 5, 192), Row(null, T0 + I20, 6, 192), Row(null, T0 + 2 * I20, 7, 192) };
|
||||||
|
var s = await Read(wire, T0, T0 + 2 * I20);
|
||||||
|
s.Select(x => x.Value).ShouldBe(new object?[] { 6d, 7d });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_window_filter_is_direction_agnostic()
|
||||||
|
{
|
||||||
|
// A reverse request (start > end) covers the same span (end, start]; a row for the interval before the span is out.
|
||||||
|
var wire = new[] { Row(T0 - I20, T0, 9, 192), Row(T0, T0 + I20, 1, 192), Row(T0 + I20, T0 + 2 * I20, 2, 192) };
|
||||||
|
var s = await Read(wire, T0 + 2 * I20, T0);
|
||||||
|
s.Select(x => x.Value).ShouldBe(new object?[] { 1d, 2d });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- D2: an Uncertain row keeps its value ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_Uncertain_row_keeps_its_value_with_an_Uncertain_Calculated_status()
|
||||||
|
{
|
||||||
|
// Live: interval 18:06:40 holds the NULL sample ⇒ AVEVA TWA 3 / Integral 30 with opc_quality 64.
|
||||||
|
var at = T0.AddSeconds(40);
|
||||||
|
var s = await Read([Row(at, at + I20, 3, 64)], at, at + I20);
|
||||||
|
s.Single().Value.ShouldBe(3d);
|
||||||
|
s.Single().StatusCode.ShouldBe(UncertainCalc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(64u)]
|
||||||
|
[InlineData(68u)]
|
||||||
|
[InlineData(191u)]
|
||||||
|
[InlineData(0x1_0040u)] // only the low byte is the OPC DA quality (as the raw mapping reads it)
|
||||||
|
public async Task Every_Uncertain_family_quality_keeps_its_value(uint opc)
|
||||||
|
{
|
||||||
|
var s = await Read([Row(T0, T0 + I20, 4.5, opc)], T0, T0 + I20);
|
||||||
|
s.Single().Value.ShouldBe(4.5d);
|
||||||
|
s.Single().StatusCode.ShouldBe(UncertainCalc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0u)]
|
||||||
|
[InlineData(1u)]
|
||||||
|
[InlineData(63u)]
|
||||||
|
public async Task A_Bad_family_row_is_BadNoData_with_no_value_although_the_wire_carries_one(uint opc)
|
||||||
|
{
|
||||||
|
var s = await Read([Row(T0, T0 + I20, 0, opc)], T0, T0 + I20);
|
||||||
|
s.Single().Value.ShouldBeNull();
|
||||||
|
s.Single().StatusCode.ShouldBe(NoDataCalc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(192u)]
|
||||||
|
[InlineData(216u)]
|
||||||
|
public async Task A_Good_family_row_is_Good_Calculated(uint opc)
|
||||||
|
{
|
||||||
|
var s = await Read([Row(T0, T0 + I20, 2.5, opc)], T0, T0 + I20);
|
||||||
|
s.Single().Value.ShouldBe(2.5d);
|
||||||
|
s.Single().StatusCode.ShouldBe(GoodCalc);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user