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
+12 -4
View File
@@ -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)