fix(historian): #679 Gateway — Average/Count not supported (no AVEVA equivalent), TimeAverage → TimeWeightedAverage
This commit is contained in:
@@ -1363,7 +1363,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`, never an error). Supported variants: Raw, Processed
|
(historized nodes return `GoodNoData`, never an error). Supported variants: Raw, Processed
|
||||||
(Average/Minimum/Maximum/Total/Count aggregates), and AtTime over historized variable nodes; Events over
|
(backend-dependent: Gateway serves TimeAverage/Minimum/Maximum/Total, with Average/Count `BadAggregateNotSupported` since AVEVA has no mean or sample count, #679; SqlServer/QuestDb serve seventeen), 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.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -210,7 +210,8 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
|||||||
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
|
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
|
||||||
--start "2026-03-25T00:00:00Z" --end "2026-03-30T00:00:00Z"
|
--start "2026-03-25T00:00:00Z" --end "2026-03-30T00:00:00Z"
|
||||||
|
|
||||||
# Aggregate: 1-hour average
|
# Aggregate: 1-hour average (SqlServer/QuestDb; on the Gateway historian backend Average is
|
||||||
|
# BadAggregateNotSupported — use --aggregate TimeAverage there, #679)
|
||||||
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
||||||
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
|
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
|
||||||
--start "2026-03-25T00:00:00Z" --end "2026-03-30T00:00:00Z" \
|
--start "2026-03-25T00:00:00Z" --end "2026-03-30T00:00:00Z" \
|
||||||
|
|||||||
+10
-5
@@ -77,7 +77,7 @@ consumes the historian. The existing enable gates (`ServerHistorian:Enabled`, `A
|
|||||||
| Value kinds recorded | `Double`, `Int64`, `Boolean` (numeric analog only) | all: `Double`, `Int64`, `Boolean`, `String`, `DateTime`, plus `Empty` (a Bad sample with no value) | same as `SqlServer` |
|
| Value kinds recorded | `Double`, `Int64`, `Boolean` (numeric analog only) | all: `Double`, `Int64`, `Boolean`, `String`, `DateTime`, plus `Empty` (a Bad sample with no value) | same as `SqlServer` |
|
||||||
| Status code stored | Sent as DA quality but **discarded** by the gateway (C-002); AVEVA stores every sample Good | Stored bit-for-bit (`StatusCode` int column) | Stored bit-for-bit (unsigned, in a `LONG`) |
|
| Status code stored | Sent as DA quality but **discarded** by the gateway (C-002); AVEVA stores every sample Good | Stored bit-for-bit (`StatusCode` int column) | Stored bit-for-bit (unsigned, in a `LONG`) |
|
||||||
| Comms-loss / driver-Bad publishes | Dropped, counted in `DroppedUnsupportedKind` | Stored as an `Empty` outage-marker sample carrying the Bad code (see [below](#typed-values-real-status-and-outage-markers)) | Stored as an `Empty` outage-marker sample, as on `SqlServer` |
|
| Comms-loss / driver-Bad publishes | Dropped, counted in `DroppedUnsupportedKind` | Stored as an `Empty` outage-marker sample carrying the Bad code (see [below](#typed-values-real-status-and-outage-markers)) | Stored as an `Empty` outage-marker sample, as on `SqlServer` |
|
||||||
| Processed aggregates | Average, Minimum, Maximum, Total, Count | The seventeen in the [aggregate table](#aggregates-processed-reads) (incl. Part 13 time-weighted, #671) | The same seventeen, identical results |
|
| Processed aggregates | TimeAverage, Minimum, Maximum, Total (Average and Count: `BadAggregateNotSupported`, #679) | The seventeen in the [aggregate table](#aggregates-processed-reads) (incl. Part 13 time-weighted, #671) | The same seventeen, identical results |
|
||||||
| HistoryUpdate | No (`BadHistoryOperationUnsupported`) | Yes (Insert / Replace / Update / DeleteRaw / DeleteAtTime) | Yes — soft delete (tombstones), see [below](#historyupdate-soft-delete) |
|
| HistoryUpdate | No (`BadHistoryOperationUnsupported`) | Yes (Insert / Replace / Update / DeleteRaw / DeleteAtTime) | Yes — soft delete (tombstones), see [below](#historyupdate-soft-delete) |
|
||||||
| Modified-value history (`IsReadModified`, #668) | No (`BadHistoryOperationUnsupported`) | Yes — `SampleModified`, atomic with the change | Yes — `{prefix}sample_modified`, written before the change (orphan-on-crash) |
|
| Modified-value history (`IsReadModified`, #668) | No (`BadHistoryOperationUnsupported`) | Yes — `SampleModified`, atomic with the change | Yes — `{prefix}sample_modified`, written before the change (orphan-on-crash) |
|
||||||
| Alarm history | gateway `SendEvent` | `Event` table (insert-if-absent) | `{prefix}event` table (dedup upsert on `(ts, event_id)`) |
|
| Alarm history | gateway `SendEvent` | `Event` table (insert-if-absent) | `{prefix}event` table (dedup upsert on `(ts, event_id)`) |
|
||||||
@@ -745,8 +745,7 @@ otopcua-cli historyread -u opc.tcp://localhost:4840 -U apikey -P otua_<keyId>_<s
|
|||||||
The OPC UA `Total` aggregate is **supported** over the HistorianGateway backend. The gateway exposes a
|
The OPC UA `Total` aggregate is **supported** over the HistorianGateway backend. The gateway exposes a
|
||||||
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`.) `Count` is
|
`Total` column and derived it client-side as time-weighted `Average × interval-seconds`.) Bucket status codes and timestamps are preserved unchanged; empty / null
|
||||||
likewise a native gateway mode. Bucket status codes and timestamps are preserved unchanged; empty / null
|
|
||||||
buckets surface as `BadNoData`.
|
buckets surface as `BadNoData`.
|
||||||
|
|
||||||
**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
|
||||||
@@ -757,13 +756,19 @@ VPN. Reverse processed reads on the `Gateway` backend are answered by AVEVA and
|
|||||||
|
|
||||||
### Aggregate set and `BadAggregateNotSupported` (#285)
|
### Aggregate set and `BadAggregateNotSupported` (#285)
|
||||||
|
|
||||||
|
> ⚠️ **#679 (client-visible): on the `Gateway` backend, `Average` and `Count` are now `BadAggregateNotSupported`.** AVEVA's 15
|
||||||
|
> retrieval modes contain no arithmetic mean and no sample count. `Average` used to be mapped to `TimeWeightedAverage` (a different
|
||||||
|
> statistic from the OPC UA mean the SQL backends return); `Count` to `Counter`, which is a rollover-counter increase, not a sample
|
||||||
|
> count. Request **`TimeAverage`** for the time-weighted number (now mapped to `TimeWeightedAverage`). There is no Gateway sample
|
||||||
|
> count. `TimeAverage` magnitude and timestamping against live AVEVA are unverified (needs the VPN).
|
||||||
|
|
||||||
`HistoryAggregateType` carries the wider Part 13 set (Interpolative, TimeAverage, Start, End, Delta, Range,
|
`HistoryAggregateType` carries the wider Part 13 set (Interpolative, TimeAverage, Start, End, Delta, Range,
|
||||||
Min/MaxActualTime, Start/EndBound, DeltaBounds, DurationGood/Bad, PercentGood/Bad, WorstQuality,
|
Min/MaxActualTime, Start/EndBound, DeltaBounds, DurationGood/Bad, PercentGood/Bad, WorstQuality,
|
||||||
NumberOfTransitions, AnnotationCount, StandardDeviationPopulation, VariancePopulation); `OtOpcUaNodeManager.MapAggregate`
|
NumberOfTransitions, AnnotationCount, StandardDeviationPopulation, VariancePopulation); `OtOpcUaNodeManager.MapAggregate`
|
||||||
maps each standard aggregate NodeId (`TimeAverage2` and `Total2` were appended for #671). The **`SqlServer`** and
|
maps each standard aggregate NodeId (`TimeAverage2` and `Total2` were appended for #671). The **`SqlServer`** and
|
||||||
**`QuestDb`** backends serve the seventeen listed in the [aggregate table](#aggregates-processed-reads); everything else
|
**`QuestDb`** backends serve the seventeen listed in the [aggregate table](#aggregates-processed-reads); everything else
|
||||||
(e.g. `StartBound`, `DurationGood`, `StandardDeviationPopulation`) is `BadAggregateNotSupported` for that node. The **HistorianGateway** backend only has retrieval modes for the original
|
(e.g. `StartBound`, `DurationGood`, `StandardDeviationPopulation`) is `BadAggregateNotSupported` for that node. The **HistorianGateway** backend only has retrieval modes for the four
|
||||||
five (Average, Minimum, Maximum, Total, Count): for any other member `AggregateModeMapper` throws
|
(TimeAverage, Minimum, Maximum, Total): for any other member `AggregateModeMapper` throws
|
||||||
`HistoryAggregateNotSupportedException`, which the HistoryRead path turns into `BadAggregateNotSupported` for that
|
`HistoryAggregateNotSupportedException`, which the HistoryRead path turns into `BadAggregateNotSupported` for that
|
||||||
node only (not a generic failure, and not counted as a backend fault). The OPC UA Client driver forwards every
|
node only (not a generic failure, and not counted as a backend fault). The OPC UA Client driver forwards every
|
||||||
member upstream. A NodeId outside the mapped set (e.g. `StandardDeviationSample`) is still `BadAggregateNotSupported`.
|
member upstream. A NodeId outside the mapped set (e.g. `StandardDeviationSample`) is still `BadAggregateNotSupported`.
|
||||||
|
|||||||
+2
-2
@@ -84,8 +84,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
|||||||
var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
|
var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Total/Count are now native gateway retrieval modes — no client-side scaling
|
// Total is a native gateway retrieval mode (Integral) — no client-side scaling. Average and Count
|
||||||
// (unlike the Wonderware path that derived Total as Average × interval-seconds).
|
// have no AVEVA mode and were rejected by the mapper above (#679).
|
||||||
var buckets = new List<HistorianAggregateSample>();
|
var buckets = new List<HistorianAggregateSample>();
|
||||||
await foreach (var bucket in _client
|
await foreach (var bucket in _client
|
||||||
.ReadAggregateAsync(fullReference, startUtc, endUtc, mode, interval, cancellationToken)
|
.ReadAggregateAsync(fullReference, startUtc, endUtc, mode, interval, cancellationToken)
|
||||||
|
|||||||
+8
-9
@@ -9,15 +9,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Average/Minimum/Maximum line up with the legacy Wonderware client's aggregate mapping. The two
|
/// Served: <see cref="HistoryAggregateType.TimeAverage"/> → <see cref="RetrievalMode.TimeWeightedAverage"/>,
|
||||||
/// remaining members are now served by <b>native</b> gateway retrieval modes:
|
/// Minimum → <see cref="RetrievalMode.MinimumWithTime"/>, Maximum → <see cref="RetrievalMode.MaximumWithTime"/>,
|
||||||
/// <see cref="HistoryAggregateType.Total"/> → <see cref="RetrievalMode.Integral"/> and
|
/// Total → <see cref="RetrievalMode.Integral"/>.
|
||||||
/// <see cref="HistoryAggregateType.Count"/> → <see cref="RetrievalMode.Counter"/>.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This replaces the Wonderware-era client-side workarounds (Total derived as Average × interval,
|
/// Deliberately NOT served (#679): <see cref="HistoryAggregateType.Average"/> (OPC UA Average is the arithmetic mean;
|
||||||
/// Count approximated from a value count): no client-side scaling is performed any more, so the
|
/// AVEVA's modes include no arithmetic mean — only the time-weighted one, which is TimeAverage) and
|
||||||
/// gateway path is a strict improvement.
|
/// <see cref="HistoryAggregateType.Count"/> (AVEVA <c>Counter</c> is a rollover-counter increase, not a sample count).
|
||||||
|
/// Both surface as <c>BadAggregateNotSupported</c> rather than returning a different statistic under the requested name.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static class AggregateModeMapper
|
internal static class AggregateModeMapper
|
||||||
@@ -29,11 +29,10 @@ internal static class AggregateModeMapper
|
|||||||
/// <exception cref="ArgumentOutOfRangeException">An undefined enum value.</exception>
|
/// <exception cref="ArgumentOutOfRangeException">An undefined enum value.</exception>
|
||||||
public static RetrievalMode ToRetrievalMode(HistoryAggregateType aggregate) => aggregate switch
|
public static RetrievalMode ToRetrievalMode(HistoryAggregateType aggregate) => aggregate switch
|
||||||
{
|
{
|
||||||
HistoryAggregateType.Average => RetrievalMode.TimeWeightedAverage,
|
HistoryAggregateType.TimeAverage => RetrievalMode.TimeWeightedAverage,
|
||||||
HistoryAggregateType.Minimum => RetrievalMode.MinimumWithTime,
|
HistoryAggregateType.Minimum => RetrievalMode.MinimumWithTime,
|
||||||
HistoryAggregateType.Maximum => RetrievalMode.MaximumWithTime,
|
HistoryAggregateType.Maximum => RetrievalMode.MaximumWithTime,
|
||||||
HistoryAggregateType.Total => RetrievalMode.Integral,
|
HistoryAggregateType.Total => RetrievalMode.Integral,
|
||||||
HistoryAggregateType.Count => RetrievalMode.Counter,
|
|
||||||
// Part 13 aggregates the gateway has no retrieval mode for (#285) — an UNDEFINED enum value is a
|
// Part 13 aggregates the gateway has no retrieval mode for (#285) — an UNDEFINED enum value is a
|
||||||
// programming error, but a defined-yet-unserved member is a normal client request the server
|
// programming error, but a defined-yet-unserved member is a normal client request the server
|
||||||
// answers with BadAggregateNotSupported.
|
// answers with BadAggregateNotSupported.
|
||||||
|
|||||||
+24
@@ -37,6 +37,30 @@ public sealed class GatewayHistorianDataSourceTests
|
|||||||
Assert.Equal(TimeSpan.FromSeconds(60), fake.LastAggregateInterval);
|
Assert.Equal(TimeSpan.FromSeconds(60), fake.LastAggregateInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadProcessed_TimeAverage_requests_TimeWeightedAverage()
|
||||||
|
{
|
||||||
|
var fake = new FakeHistorianGatewayClient();
|
||||||
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||||
|
await ds.ReadProcessedAsync("T", default, default, TimeSpan.FromSeconds(60), HistoryAggregateType.TimeAverage, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(RetrievalMode.TimeWeightedAverage, fake.LastAggregateMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory] // #679: no AVEVA equivalent — not supported, gateway never called, no backend failure recorded
|
||||||
|
[InlineData(HistoryAggregateType.Average)]
|
||||||
|
[InlineData(HistoryAggregateType.Count)]
|
||||||
|
public async Task ReadProcessed_unsupported_aggregate_throws_without_calling_gateway_or_recording_failure(HistoryAggregateType aggregate)
|
||||||
|
{
|
||||||
|
var fake = new FakeHistorianGatewayClient();
|
||||||
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||||
|
await Assert.ThrowsAsync<HistoryAggregateNotSupportedException>(() => ds.ReadProcessedAsync(
|
||||||
|
"T", default, default, TimeSpan.FromSeconds(60), aggregate, TestContext.Current.CancellationToken));
|
||||||
|
Assert.Equal(0, fake.ReadAggregateCallCount);
|
||||||
|
var h = ds.GetHealthSnapshot();
|
||||||
|
Assert.Equal(0, h.TotalFailures);
|
||||||
|
Assert.Equal(0, h.TotalQueries);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadAtTime_aligns_one_snapshot_per_timestamp_with_gaps_Bad()
|
public async Task ReadAtTime_aligns_one_snapshot_per_timestamp_with_gaps_Bad()
|
||||||
{
|
{
|
||||||
|
|||||||
+9
-4
@@ -8,11 +8,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
|
|||||||
public sealed class AggregateModeMapperTests
|
public sealed class AggregateModeMapperTests
|
||||||
{
|
{
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(HistoryAggregateType.Average, RetrievalMode.TimeWeightedAverage)]
|
[InlineData(HistoryAggregateType.TimeAverage, RetrievalMode.TimeWeightedAverage)]
|
||||||
[InlineData(HistoryAggregateType.Minimum, RetrievalMode.MinimumWithTime)]
|
[InlineData(HistoryAggregateType.Minimum, RetrievalMode.MinimumWithTime)]
|
||||||
[InlineData(HistoryAggregateType.Maximum, RetrievalMode.MaximumWithTime)]
|
[InlineData(HistoryAggregateType.Maximum, RetrievalMode.MaximumWithTime)]
|
||||||
[InlineData(HistoryAggregateType.Total, RetrievalMode.Integral)]
|
[InlineData(HistoryAggregateType.Total, RetrievalMode.Integral)]
|
||||||
[InlineData(HistoryAggregateType.Count, RetrievalMode.Counter)]
|
|
||||||
public void Maps_each_aggregate(HistoryAggregateType a, RetrievalMode expected)
|
public void Maps_each_aggregate(HistoryAggregateType a, RetrievalMode expected)
|
||||||
=> Assert.Equal(expected, AggregateModeMapper.ToRetrievalMode(a));
|
=> Assert.Equal(expected, AggregateModeMapper.ToRetrievalMode(a));
|
||||||
|
|
||||||
@@ -21,8 +20,8 @@ public sealed class AggregateModeMapperTests
|
|||||||
{
|
{
|
||||||
var original = new[]
|
var original = new[]
|
||||||
{
|
{
|
||||||
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
|
HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
|
||||||
HistoryAggregateType.Total, HistoryAggregateType.Count,
|
HistoryAggregateType.Total, HistoryAggregateType.TimeAverage,
|
||||||
};
|
};
|
||||||
foreach (var a in Enum.GetValues<HistoryAggregateType>())
|
foreach (var a in Enum.GetValues<HistoryAggregateType>())
|
||||||
{
|
{
|
||||||
@@ -33,6 +32,12 @@ public sealed class AggregateModeMapperTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory] // #679: AVEVA has no arithmetic mean and no sample count (Counter is a rollover increase)
|
||||||
|
[InlineData(HistoryAggregateType.Average)]
|
||||||
|
[InlineData(HistoryAggregateType.Count)]
|
||||||
|
public void Average_and_Count_are_not_supported(HistoryAggregateType a)
|
||||||
|
=> Assert.Throws<HistoryAggregateNotSupportedException>(() => AggregateModeMapper.ToRetrievalMode(a));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Undefined_value_is_a_programming_error_not_a_not_supported()
|
public void Undefined_value_is_a_programming_error_not_a_not_supported()
|
||||||
=> Assert.Throws<ArgumentOutOfRangeException>(() => AggregateModeMapper.ToRetrievalMode((HistoryAggregateType)9999));
|
=> Assert.Throws<ArgumentOutOfRangeException>(() => AggregateModeMapper.ToRetrievalMode((HistoryAggregateType)9999));
|
||||||
|
|||||||
Reference in New Issue
Block a user