diff --git a/docs/Historian.md b/docs/Historian.md index 82ff266f..bee5c14b 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -793,10 +793,18 @@ data. (#690). Nothing is asked past now + 1 day (the gateway's AVEVA may refuse a far-future end; a time beyond it is stepped from the last sample). Every stream is drained. A strict gateway's ceiling (#691.3) is learned on its first refusal and honoured; a full read is ordinary paging here, so it is not re-probed. -- ⚠️ **Values are Double (or String).** The gateway wire carries no data type — every historizable type is stored as an AVEVA analog - (a Boolean as a 0/1 `Int2`) — so every numeric tag is **interpolated linearly**: a Boolean or integer node can read a fractional - value between two samples. The SQL backends step a Boolean and round an Int64; AVEVA's own interpolation follows the tag's - interpolation type (stair-step by default for integer tags). Matching them needs the node's data type plumbed to the data source. +- **Values follow the node's type (type hint).** The gateway wire carries no data type — every historizable type is stored as an + AVEVA analog (a Boolean as a 0/1 `Int2`) — so the node manager passes each node's scalar DataType with the AtTime call + (`IHistorianDataSource.ReadAtTimeAsync(…, TypeCode? valueType, …)`, a default-interface overload: SqlServer / QuestDB / Null + ignore it; Raw and UNS NodeIds give the same hint, arrays and other types none). The Gateway converts each raw row to that type + **before** the shared evaluator runs, so its own type rules apply exactly as on the SQL backends: a **Boolean** node + (value ≠ 0) is **stepped** — never 0.5; an **integer** node is interpolated, rounded half away from zero and returned as the + node's own integer type (`Int32` ⇒ `int`); Float / Double interpolate as Double (as the SQL backends return them). With no + hint (an unknown node) every numeric tag interpolates as Double. +- ⚠️ **Shared-evaluator fix (all backends that use it):** `AtTimeEvaluator` returned every interpolated `Int64` boxed as a + **Double** (a C# `cond ? (long)x : double` conditional widens the long); its "keeping the Int64 type" tests passed because + Shouldly's `ShouldBe` compares numbers across types. SqlServer / QuestDB interpolated `Int64` AtTime values are now `Int64`, + as documented in [AtTime reads](#attime-reads). ### Bounding values (`ReturnBounds`, #691) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs index 309a0b63..7b0cc72c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs @@ -81,6 +81,31 @@ public interface IHistorianDataSource : IDisposable IReadOnlyList timestampsUtc, CancellationToken cancellationToken); + /// + /// Gitea #693: with the value type of + /// the node being read, for a backend whose stored values do not carry their type (the Gateway: AVEVA stores every + /// historized type as a numeric analog, so a Boolean comes back as a 0/1 Double). Such a backend uses the hint to apply + /// the AtTime rules exactly as the typed backends do — a Boolean (or String / DateTime) node is stepped, an integer node + /// is interpolated, rounded half away from zero and returned as the node's integer type, a floating-point node is + /// interpolated. A backend that stores typed values ignores it: the default forwards to the hint-less overload, so + /// SqlServer, QuestDB and the Null source need no change. + /// + /// The full reference of the tag to read. + /// The list of timestamps to read values at. + /// + /// The scalar value type of the node (, the integer codes, , + /// , , ), or null when + /// unknown, not scalar, or not one of those — the backend's own policy then applies. + /// + /// A cancellation token that can be used to cancel the operation. + /// One sample per requested timestamp, in the same order as . + Task ReadAtTimeAsync( + string fullReference, + IReadOnlyList timestampsUtc, + TypeCode? valueType, + CancellationToken cancellationToken) => + ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken); + /// /// Read historical alarm / event records — OPC UA HistoryReadEvents service. /// Distinct from any live event stream; sources here come from the historian's @@ -95,7 +120,7 @@ public interface IHistorianDataSource : IDisposable /// uint maxValuesPerNode because their OPC UA HistoryRead surface has no /// equivalent "use default" sentinel. /// - /// This surface declares and + /// This surface declares and /// as required members — a server-side historian owns the full read surface. /// /// The source name to filter events, or null to return events from all sources. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAtTimeReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAtTimeReader.cs index 16b572f1..5273dde4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAtTimeReader.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAtTimeReader.cs @@ -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. /// /// -/// Values. 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). +/// Values. 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 Int2). 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. /// /// internal static class GatewayAtTimeReader @@ -85,24 +88,48 @@ internal static class GatewayAtTimeReader /// Cancellation. /// One value per requested time, stamped with it. /// More than rows within one millisecond. + /// The node's scalar value type (the hinted IHistorianDataSource.ReadAtTimeAsync), or null. public static async Task> ReadAsync(IHistorianGatewayClient client, GatewayRowCeiling ceiling, - string tag, IReadOnlyList timestampsUtc, DateTime now, CancellationToken ct) + string tag, IReadOnlyList 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); + + /// True for the integer type codes (evaluated as Int64, returned as the node's own type). + 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; + + /// + /// 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. + /// + 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; + } + } /// prev / next for every time of (distinct, ascending). private static async Task> 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); } - /// A gateway row as a stored sample; a Bad row carries no value (#690: a stored NULL arrives as value 0). - private static StoredSample ToStored(HistorianSample raw, DateTime t) + /// + /// 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). + /// + 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); } + /// A gateway number as the hinted kind, so the shared evaluator's own type branch decides stepped vs linear. + 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), + }; + /// The window read's start for a time: a millisecond before its millisecond (never before the epoch). private static DateTime RequestStart(DateTime t) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs index b1390bd0..1d250222 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs @@ -381,11 +381,23 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis /// AVEVA's own interpolated answer (the gateway's ReadAtTime) returned the previous value as Good at a stored /// NULL's time, null + Bad at a Good sample right after a NULL, a drifted value at an exact stored sample, and no /// Interpolated bit; it is only used when ServerHistorian:GatewayAtTimeFromRaw = false (kill switch). - /// A gateway value is a Double (or a String) whatever the node's type, so every numeric tag is interpolated linearly - /// (on the SQL backends a Boolean is stepped and an Int64 rounded — the gateway wire carries no type to tell). + /// A gateway value is a Double (or a String) whatever the node's type (AVEVA stores a Boolean as a 0/1 Int2), so + /// without a type hint every numeric tag is interpolated linearly; the node manager passes the node's type through + /// . + /// + public Task ReadAtTimeAsync( + string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => + ReadAtTimeAsync(fullReference, timestampsUtc, valueType: null, cancellationToken); + + /// + /// + /// #693: the hint makes the AtTime rules type-exact, as on the SQL backends — the gateway's Double rows are converted to + /// the node's type BEFORE the shared evaluator runs (Boolean = value ≠ 0 ⇒ stepped; an integer type ⇒ Int64 ⇒ + /// interpolated and rounded half away from zero, then returned as the node's integer type; String ⇒ stepped; Float / + /// Double / no hint ⇒ linear Double). Ignored with the kill switch (AVEVA's own answer). /// public async Task ReadAtTimeAsync( - string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) + string fullReference, IReadOnlyList timestampsUtc, TypeCode? valueType, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(timestampsUtc); if (!_atTimeFromRaw) return await ReadAtTimeNativeAsync(fullReference, timestampsUtc, cancellationToken).ConfigureAwait(false); @@ -393,7 +405,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis { var now = _time.GetUtcNow().UtcDateTime; var values = await GatewayAtTimeReader - .ReadAsync(_client, _ceiling, fullReference, timestampsUtc, now, cancellationToken) + .ReadAsync(_client, _ceiling, fullReference, timestampsUtc, now, cancellationToken, valueType) .ConfigureAwait(false); RecordOutcome(success: true, error: null); return new HistoryReadResult(values, ContinuationPoint: null); @@ -544,7 +556,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis /// /// Reconciles a gateway at-time reply against the requested timestamps to honour the - /// contract: exactly one snapshot per + /// contract: exactly one snapshot per /// requested timestamp, in request order. Returned samples are indexed by timestamp ticks; /// any requested timestamp the gateway did not return is filled with a Bad-quality /// (0x80000000) snapshot stamped at the requested time rather than positionally diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AtTimeEvaluator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AtTimeEvaluator.cs index 9440b843..c324680c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AtTimeEvaluator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AtTimeEvaluator.cs @@ -49,8 +49,10 @@ public static class AtTimeEvaluator { var fraction = (double)(t.Ticks - p.TimeUtc.Ticks) / (n.TimeUtc.Ticks - p.TimeUtc.Ticks); var v = p.Value.Number + (nv - p.Value.Number) * fraction; + // Each arm boxed on its own: `cond ? (long)x : v` is a DOUBLE expression in C# (the long widens), which returned + // every interpolated Int64 as a Double until #693. object value = p.Value.Kind == HistorianValueKind.Int64 - ? (long)Math.Round(v, MidpointRounding.AwayFromZero) + ? (object)(long)Math.Round(v, MidpointRounding.AwayFromZero) : v; var status = p.IsGood && n.IsGood ? HistorianStatusCodes.Good : HistorianStatusCodes.UncertainDataSubNormal; return new DataValueSnapshot(value, HistorianInfoBits.WithInterpolated(status), t, serverTime); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index eb0c81d8..284635f4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -3398,6 +3398,36 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return NodeId.Null; } + /// + /// Gitea #693: the AtTime value-type hint of a historized variable — its DataType's built-in type as a + /// when the node is a scalar of a type the historian evaluates (Boolean, the integers, Float, + /// Double, String, DateTime); null for an array, an unknown node, or any other type. A UNS mirror carries its + /// backing raw tag's DataType, so either NodeId gives the same hint. + /// + /// The HistoryRead request's node id. + /// The hint, or null. + internal TypeCode? AtTimeValueType(NodeId nodeId) + { + if (!_variables.TryGetValue(MapKey(nodeId), out var variable) || variable.ValueRank != ValueRanks.Scalar) return null; + return TypeInfo.GetBuiltInType(variable.DataType) switch + { + BuiltInType.Boolean => TypeCode.Boolean, + BuiltInType.SByte => TypeCode.SByte, + BuiltInType.Byte => TypeCode.Byte, + BuiltInType.Int16 => TypeCode.Int16, + BuiltInType.UInt16 => TypeCode.UInt16, + BuiltInType.Int32 => TypeCode.Int32, + BuiltInType.UInt32 => TypeCode.UInt32, + BuiltInType.Int64 => TypeCode.Int64, + BuiltInType.UInt64 => TypeCode.UInt64, + BuiltInType.Float => TypeCode.Single, + BuiltInType.Double => TypeCode.Double, + BuiltInType.String => TypeCode.String, + BuiltInType.DateTime => TypeCode.DateTime, + _ => null, + }; + } + /// Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String", /// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType /// (matches CreateVariable's default for lazy-created nodes). @@ -5176,9 +5206,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var work = new List>(nodesToProcess.Count); foreach (var handle in nodesToProcess) { + // #693: the node's scalar value type, for a backend whose stored values carry none (the Gateway). + var valueType = AtTimeValueType(handle.NodeId); work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadAtTimeAsync( tagname, timestamps, + valueType, token), ct)); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAtTimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAtTimeTests.cs index 1b7a8202..06378294 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAtTimeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAtTimeTests.cs @@ -257,6 +257,76 @@ public sealed class GatewayAtTimeTests s.StatusCode.ShouldBe(GoodInterpolated); } + // ---- the node's value type (the node manager's hint; AVEVA stores a Boolean as a 0/1 Int2) ------------------------ + + /// Reads through the INTERFACE's hinted overload, as the node manager does. + private static async Task> AtTimeTyped( + FakeHistorianGatewayClient fake, TypeCode? valueType, params DateTime[] times) + { + IHistorianDataSource source = Source(fake); + var r = await source.ReadAtTimeAsync("T", times, valueType, Ct); + r.Samples.Count.ShouldBe(times.Length); + fake.ReadAtTimeCallCount.ShouldBe(0); + return r.Samples; + } + + [Fact] + public async Task A_Boolean_node_is_stepped_never_a_fraction() + { + var fake = new AvevaStore([(At(0), 0, false), (At(10), 1, false), (At(20), 0, false)]).Client(); + var s = await AtTimeTyped(fake, TypeCode.Boolean, At(5), At(10), At(15), At(25)); + s.Select(x => x.Value).ShouldBe(new object?[] { false, true, true, false }); + s.ShouldAllBe(x => x.Value is bool); + s.Select(x => x.StatusCode).ShouldBe(new[] { GoodInterpolated, Good, GoodInterpolated, GoodInterpolated }); + } + + [Fact] + public async Task Without_a_hint_a_Boolean_tag_interpolates_as_before() + { + var fake = new AvevaStore([(At(0), 0, false), (At(10), 1, false)]).Client(); + (await AtTimeTyped(fake, null, At(5))).Single().Value.ShouldBeOfType().ShouldBe(0.5d); + } + + [Fact] + public async Task An_Int32_node_interpolates_rounds_half_away_from_zero_and_keeps_its_type() + { + var s = await AtTimeTyped(new AvevaStore(Gate685()).Client(), TypeCode.Int32, At(0), At(0.5), At(1), At(2.5), At(3)); + s[0].Value.ShouldBe(100); // exact hit, as stored, Int32 + s[0].StatusCode.ShouldBe(Good); + s[1].Value.ShouldBe(102); // 101.75 ⇒ 102 + s[2].Value.ShouldBe(104); // 103.5 ⇒ 104 (half away from zero) + s[1].StatusCode.ShouldBe(GoodInterpolated); + s[3].Value.ShouldBe(107); // held before the NULL, Uncertain + s[3].StatusCode.ShouldBe(UncertainInterpolated); + s[4].Value.ShouldBeNull(); // the NULL itself + s[4].StatusCode.ShouldBe(Bad); + s.Take(4).Select(x => x.Value + ":" + x.Value!.GetType().Name).ShouldBe(["100:Int32", "102:Int32", "104:Int32", "107:Int32"]); + } + + [Fact] + public async Task A_negative_midpoint_rounds_away_from_zero() + { + var fake = new AvevaStore([(At(0), -100, false), (At(2), -107, false)]).Client(); + (await AtTimeTyped(fake, TypeCode.Int16, At(1))).Single().Value.ShouldBeOfType().ShouldBe((short)-104); + } + + [Fact] + public async Task Other_integer_nodes_keep_their_own_type() + { + var fake = new AvevaStore(Gate685()).Client(); + (await AtTimeTyped(fake, TypeCode.UInt16, At(1))).Single().Value.ShouldBeOfType().ShouldBe((ushort)104); + (await AtTimeTyped(fake, TypeCode.Int64, At(1))).Single().Value.ShouldBeOfType().ShouldBe(104L); + (await AtTimeTyped(fake, TypeCode.Byte, At(1))).Single().Value.ShouldBeOfType().ShouldBe((byte)104); + } + + [Fact] + public async Task Float_and_Double_nodes_interpolate_unchanged() + { + var fake = new AvevaStore(Gate685()).Client(); + (await AtTimeTyped(fake, TypeCode.Double, At(1))).Single().Value.ShouldBeOfType().ShouldBe(103.5d); + (await AtTimeTyped(fake, TypeCode.Single, At(1))).Single().Value.ShouldBeOfType().ShouldBe(103.5d); // as the SQL backends (Double kind) + } + // ---- cost ------------------------------------------------------------------------------------------------------ private static IEnumerable<(DateTime T, double V, bool Null)> OneHertz(DateTime from, int seconds) => diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AtTimeEvaluatorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AtTimeEvaluatorTests.cs index 70dc5a96..deaaceca 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AtTimeEvaluatorTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/AtTimeEvaluatorTests.cs @@ -83,6 +83,11 @@ public sealed class AtTimeEvaluatorTests var n = S(10, HistorianValue.FromInt64(15), Good); AtTimeEvaluator.Evaluate(At(5), p, n, Now, Now).Value.ShouldBe(13L); // 12.5 ⇒ 13 AtTimeEvaluator.Evaluate(At(2), p, n, Now, Now).Value.ShouldBe(11L); + // #693: ShouldBe compares numbers across types, so the type needs its own assertion — a C# conditional + // `long : double` boxed a Double here, and the line above still passed. + AtTimeEvaluator.Evaluate(At(5), p, n, Now, Now).Value.ShouldBeOfType(); + AtTimeEvaluator.Evaluate(At(5), S(0, HistorianValue.FromDouble(10), Good), S(10, HistorianValue.FromDouble(15), Good), Now, Now) + .Value.ShouldBeOfType(); } [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs index a3c8a266..b19223d0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs @@ -586,6 +586,78 @@ public sealed class NodeManagerHistoryReadTests : IDisposable await host.DisposeAsync(); } + /// + /// #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 proves the interface default forwards.) + /// + [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(); + } + + /// A source recording the AtTime value-type hint per tagname (the hinted overload only). + private sealed class HintRecordingSource : IHistorianDataSource + { + public System.Collections.Concurrent.ConcurrentDictionary Hints { get; } = new(); + + public Task ReadAtTimeAsync( + string fullReference, IReadOnlyList timestampsUtc, TypeCode? valueType, CancellationToken cancellationToken) + { + Hints[fullReference] = valueType; + return Task.FromResult(Empty()); + } + + public Task ReadAtTimeAsync( + string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => + throw new InvalidOperationException("the node manager must call the hinted overload"); + + public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, + CancellationToken cancellationToken) => Task.FromResult(Empty()); + + public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, + HistoryAggregateType aggregate, CancellationToken cancellationToken) => Task.FromResult(Empty()); + + public Task ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, + CancellationToken cancellationToken) => + Task.FromResult(new HistoricalEventsResult(Array.Empty(), null)); + + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); + + public void Dispose() { } + } + /// /// 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