fix(historian): #693 AtTime type hint — Boolean nodes stepped, integer nodes rounded to their type

Review follow-up: on the Gateway every value is a Double (AVEVA stores a Boolean
as a 0/1 Int2), so the raw-evaluated AtTime interpolated a Boolean node to 0.5.

- IHistorianDataSource gains a default-interface overload
  ReadAtTimeAsync(fullReference, timestamps, TypeCode? valueType, ct) that
  forwards to the existing one (SqlServer/QuestDB/Null/fakes unchanged).
- OtOpcUaNodeManager.HistoryReadAtTime passes the node's scalar DataType
  (AtTimeValueType: built-in type -> TypeCode; arrays/other types => null);
  Raw and UNS NodeIds alike.
- GatewayAtTimeReader converts each raw row to the hinted kind BEFORE the shared
  AtTimeEvaluator runs (Boolean = value != 0 => stepped; integers => Int64 =>
  interpolated, rounded half away from zero, returned as the node's integer
  type; floats Double), so the evaluator's own type branch decides.
- AtTimeEvaluator boxed every interpolated Int64 as a Double (`cond ? (long)x :
  v` widens to double); its "keeping its type" test passed because Shouldly
  ShouldBe compares numbers across types. Fixed (affects SqlServer/QuestDB too)
  and pinned with ShouldBeOfType.
This commit is contained in:
Joseph Doherty
2026-10-13 00:52:03 -04:00
parent 37390139fe
commit 0d249f3ee9
9 changed files with 289 additions and 21 deletions
@@ -586,6 +586,78 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
await host.DisposeAsync();
}
/// <summary>
/// #693: AtTime passes each node's scalar value type to the source (the Gateway needs it — its stored values carry no
/// type), through a Raw NodeId and a UNS NodeId alike; an array node passes none. (The fake above implements only the
/// hint-less overload, and <see cref="AtTime_dispatches_requested_timestamps"/> proves the interface default forwards.)
/// </summary>
[Fact]
public async Task AtTime_passes_the_nodes_value_type_for_Raw_and_UNS_nodes()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new HintRecordingSource();
nm.HistorianDataSource = fake;
nm.EnsureVariable("Plant/Modbus/dev1/Flag", parentFolderNodeId: null, displayName: "Flag", dataType: "Boolean",
writable: false, historianTagname: "R.Flag", realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("Area/Line/Eq/Flag", parentFolderNodeId: null, displayName: "Flag", dataType: "Boolean",
writable: false, historianTagname: "U.Flag", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("Plant/Modbus/dev1/Count", parentFolderNodeId: null, displayName: "Count", dataType: "Int32",
writable: false, historianTagname: "R.Count", realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("Area/Line/Eq/Flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double",
writable: false, historianTagname: "U.Flow", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("Plant/Modbus/dev1/Arr", parentFolderNodeId: null, displayName: "Arr", dataType: "Int16",
writable: false, historianTagname: "R.Arr", realm: AddressSpaceRealm.Raw, isArray: true, arrayLength: 4);
string[] paths = ["Plant/Modbus/dev1/Flag", "Area/Line/Eq/Flag", "Plant/Modbus/dev1/Count", "Area/Line/Eq/Flow", "Plant/Modbus/dev1/Arr"];
AddressSpaceRealm[] realms = [AddressSpaceRealm.Raw, AddressSpaceRealm.Uns, AddressSpaceRealm.Raw, AddressSpaceRealm.Uns, AddressSpaceRealm.Raw];
var nodeIds = paths.Select((p, i) => nm.TryGetVariable(p, realms[i])!.NodeId).ToArray();
var (_, errors) = InvokeHistoryRead(server, nm,
new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { DateTime.UtcNow.AddMinutes(-1) } }, nodeIds);
errors.ShouldAllBe(e => StatusCode.IsGood(e.StatusCode));
fake.Hints["R.Flag"].ShouldBe(TypeCode.Boolean);
fake.Hints["U.Flag"].ShouldBe(TypeCode.Boolean);
fake.Hints["R.Count"].ShouldBe(TypeCode.Int32);
fake.Hints["U.Flow"].ShouldBe(TypeCode.Double);
fake.Hints["R.Arr"].ShouldBeNull();
await host.DisposeAsync();
}
/// <summary>A source recording the AtTime value-type hint per tagname (the hinted overload only).</summary>
private sealed class HintRecordingSource : IHistorianDataSource
{
public System.Collections.Concurrent.ConcurrentDictionary<string, TypeCode?> Hints { get; } = new();
public Task<HistorianRead> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, TypeCode? valueType, CancellationToken cancellationToken)
{
Hints[fullReference] = valueType;
return Task.FromResult(Empty());
}
public Task<HistorianRead> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
throw new InvalidOperationException("the node manager must call the hinted overload");
public Task<HistorianRead> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken) => Task.FromResult(Empty());
public Task<HistorianRead> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken) => Task.FromResult(Empty());
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken) =>
Task.FromResult(new HistoricalEventsResult(Array.Empty<HistoricalEvent>(), null));
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
/// <summary>
/// Two nodes in a single Raw HistoryRead batch — one backend succeeds, one throws. Proves that
/// per-node error isolation is enforced across handle indices: the good node's slot is