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
@@ -48,8 +48,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// row ceiling (#691.3) is learned and honoured — a short read is ordinary paging here, never re-probed.
/// </para>
/// <para>
/// <b>Values.</b> A gateway value is a Double or a String whatever the node's data type, so numeric tags interpolate
/// linearly (the SQL backends step a Boolean and round an Int64; the gateway wire carries no type).
/// <b>Values.</b> A gateway value is a Double or a String whatever the node's data type (AVEVA stores every historized
/// type as a numeric analog — a Boolean as a 0/1 <c>Int2</c>). The node's type hint converts each row BEFORE the
/// shared evaluator runs, so its own type rules apply exactly as on the SQL backends: Boolean (value ≠ 0) and String
/// are stepped; an integer type is an Int64 — interpolated, rounded half away from zero — and the result is returned
/// as the node's integer type; Float / Double (and no hint) interpolate linearly as Double.
/// </para>
/// </remarks>
internal static class GatewayAtTimeReader
@@ -85,24 +88,48 @@ internal static class GatewayAtTimeReader
/// <param name="ct">Cancellation.</param>
/// <returns>One value per requested time, stamped with it.</returns>
/// <exception cref="HistoryReadLimitExceededException">More than <see cref="ReadRows"/> rows within one millisecond.</exception>
/// <param name="valueType">The node's scalar value type (the hinted <c>IHistorianDataSource.ReadAtTimeAsync</c>), or null.</param>
public static async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(IHistorianGatewayClient client, GatewayRowCeiling ceiling,
string tag, IReadOnlyList<DateTime> timestampsUtc, DateTime now, CancellationToken ct)
string tag, IReadOnlyList<DateTime> timestampsUtc, DateTime now, CancellationToken ct, TypeCode? valueType = null)
{
var requested = timestampsUtc.Select(t => DateTime.SpecifyKind(t, DateTimeKind.Utc)).ToArray();
if (requested.Length == 0) return [];
var neighbours = await ResolveAsync(new Ctx(client, ceiling, tag, ct), requested.Distinct().Order().ToArray(), now)
var neighbours = await ResolveAsync(new Ctx(client, ceiling, tag, ct, valueType), requested.Distinct().Order().ToArray(), now)
.ConfigureAwait(false);
var result = new DataValueSnapshot[requested.Length];
for (var i = 0; i < requested.Length; i++)
{
var t = requested[i];
var (prev, next) = neighbours[t.Ticks];
result[i] = AtTimeEvaluator.Evaluate(t, prev, next, now, now);
result[i] = ToNodeType(AtTimeEvaluator.Evaluate(t, prev, next, now, now), valueType);
}
return result;
}
private sealed record Ctx(IHistorianGatewayClient Client, GatewayRowCeiling Ceiling, string Tag, CancellationToken Ct);
private sealed record Ctx(IHistorianGatewayClient Client, GatewayRowCeiling Ceiling, string Tag, CancellationToken Ct,
TypeCode? ValueType);
/// <summary>True for the integer type codes (evaluated as Int64, returned as the node's own type).</summary>
private static bool IsInteger(TypeCode? t) =>
t is TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16 or TypeCode.Int32 or TypeCode.UInt32
or TypeCode.Int64 or TypeCode.UInt64;
/// <summary>
/// An evaluated Int64 (integer hint) returned as the node's integer type. A value the type cannot hold (only possible
/// for a stored value already outside the node's range) stays the evaluator's Int64.
/// </summary>
private static DataValueSnapshot ToNodeType(DataValueSnapshot v, TypeCode? valueType)
{
if (v.Value is not long l || !IsInteger(valueType) || valueType == TypeCode.Int64) return v;
try
{
return v with { Value = Convert.ChangeType(l, valueType!.Value, System.Globalization.CultureInfo.InvariantCulture) };
}
catch (OverflowException)
{
return v;
}
}
/// <summary>prev / next for every time of <paramref name="times"/> (distinct, ascending).</summary>
private static async Task<Dictionary<long, (StoredSample? Prev, StoredSample? Next)>> ResolveAsync(
@@ -318,22 +345,36 @@ internal static class GatewayAtTimeReader
received++; // drained to completion, never broken out of
if (raw.Timestamp is null) continue;
var t = raw.Timestamp.ToDateTime();
if (t > qs && t < qe && t > Floor) rows.Add(ToStored(raw, t));
if (t > qs && t < qe && t > Floor) rows.Add(ToStored(raw, t, ctx.ValueType));
}
return new Read(rows, received < budget);
}
/// <summary>A gateway row as a stored sample; a Bad row carries no value (#690: a stored NULL arrives as value 0).</summary>
private static StoredSample ToStored(HistorianSample raw, DateTime t)
/// <summary>
/// A gateway row as a stored sample of the node's type (see the type remarks); a Bad row carries no value (#690: a
/// stored NULL arrives as value 0).
/// </summary>
private static StoredSample ToStored(HistorianSample raw, DateTime t, TypeCode? valueType)
{
var status = SampleMapper.ToStatus(raw);
var value = StatusBits.IsBad(status) ? HistorianValue.Empty
: raw.HasNumericValue ? HistorianValue.FromDouble(raw.NumericValue)
: raw.HasNumericValue ? Typed(raw.NumericValue, valueType)
: raw.HasStringValue ? HistorianValue.FromString(raw.StringValue)
: HistorianValue.Empty;
return StoredSample.FromStored(t, value, status);
}
/// <summary>A gateway number as the hinted kind, so the shared evaluator's own type branch decides stepped vs linear.</summary>
private static HistorianValue Typed(double v, TypeCode? valueType) => valueType switch
{
TypeCode.Boolean => HistorianValue.FromBoolean(v != 0),
TypeCode.String => HistorianValue.FromString(v.ToString("R", System.Globalization.CultureInfo.InvariantCulture)),
// In Int64 range only (2^63 is exactly representable; anything at or beyond it stays a Double).
_ when IsInteger(valueType) && v >= -9.2233720368547758E18 && v < 9.2233720368547758E18 =>
HistorianValue.FromInt64((long)Math.Round(v, MidpointRounding.AwayFromZero)),
_ => HistorianValue.FromDouble(v),
};
/// <summary>The window read's start for a time: a millisecond before its millisecond (never before the epoch).</summary>
private static DateTime RequestStart(DateTime t)
{