fix(historian): map Boolean historization to Int2, not degenerate Int1 (#441) #442

Merged
dohertj2 merged 1 commits from fix/441-boolean-historization-int2 into master 2026-07-14 12:38:06 -04:00
7 changed files with 106 additions and 96 deletions
+7 -6
View File
@@ -319,11 +319,12 @@ When the section is disabled the applier binds the no-op `NullHistorianProvision
run. Every dispatch logs a tally (`dispatched/requested/ensured/skipped/failed`); `dispatched=N` with
`requested=0` flags the dormant no-op. The API key must carry `historian:tags:write` for `EnsureTags`. (PR
#423 shipped `GatewayTagProvisioner` but left this wiring out, so provisioning was dormant; restored 2026-06-27.)
**0.2.0 DataType delta (archreview 06/U-7):** `EnsureTags` is an **upsert** (create-or-update, not
skip-if-exists), and the 0.2.0 `HistorianTagDefinition.DataType` proto3-optional change means Boolean tags now
provision as **Int1** (0.1.0 silently defaulted them to Float). A pre-bump Float Boolean is asked to retype
on its next deploy; the AVEVA-side retype policy is pinned by the live retype probe — see the "0.2.0 DataType
provisioning delta" section in `docs/Historian.md`.
**DataType mapping (issue #441):** `EnsureTags` is an **upsert** (create-or-update, not skip-if-exists).
`HistorianTypeMapper` maps **`Boolean → Int2`** (Boolean historizes as a small 0/1 integer) because the
historian's `Int1` analog-tag creation path is **server-degenerate**`EnsureTags(Int1)` stores an unusable
stub (live-probed; gateway-side resolution in HistorianGateway #11 / PR #16). Both Float and Int2 are
supported analog types, so a pre-existing Float Boolean tag retypes cleanly to Int2 on its next deploy — see
the "Boolean historization maps to Int2" section in `docs/Historian.md`.
### Gateway-side prerequisites
@@ -358,7 +359,7 @@ export HISTGW_GATEWAY_ENDPOINT=https://wonder-sql-vd03:5222 # absolute gateway
export HISTGW_GATEWAY_APIKEY=histgw_<id>_<secret> # must carry historian:read + historian:write (+ tags:write) scopes
export HISTGW_TEST_TAG=<existing-tag> # read round-trip
export HISTGW_WRITE_SANDBOX_TAG=<writable-float-tag> # e.g. HistGW.LiveTest.Sandbox — write round-trip (EnsureTags + write)
export HISTGW_BOOL_SANDBOX_TAG=<writable-bool-tag> # e.g. HistGW.LiveTest.BoolSandbox — 0.2.0 Boolean EnsureTags leg + retype probe (06/U-7)
export HISTGW_BOOL_SANDBOX_TAG=<writable-bool-tag> # e.g. HistGW.LiveTest.BoolSandbox — Boolean→Int2 EnsureTags leg + retype probe (issue #441)
export HISTGW_ALARM_SOURCE=<source-name> # alarm SendEvent → ReadEvents round-trip
dotnet test --filter "Category=LiveIntegration"
```
+21 -14
View File
@@ -327,24 +327,31 @@ convention) + a regression unit test + `StorageRateMs` on the live-suite inline
- ✅ `Write_then_read_on_sandbox_tag` — `EnsureTags(Float)` + `WriteLiveValues` (SQL) + read-back.
- ✅ `Alarm_SendEvent_is_acked` + ✅ `Alarm_SendEvent_then_ReadEvents` — alarm `SendEvent` + SQL `ReadEvents`.
- ✅ `EnsureTags_type_change_outcome_is_observable` — now observes a **real** retype outcome (not a storage-rate error).
- `EnsureTags_boolean_sandbox_provisions_as_Int1` — **Int1 (Boolean) tag creation is not supported by the
gateway's histsdk on this historian**: `EnsureTags(Int1)` returns `ProtocolEvidenceMissingException` (the vendored
client's "no capture evidence for this type" guard → gRPC `Unimplemented`), both fresh and as a retype target.
A **type sweep** confirmed it is Int1-specific: **Int2/UInt2/Int4/UInt4/Float/Double all provision `success=True`;
only Int1 fails.** Root cause: `histsdk` `HistorianTagWriteProtocol.GetAnalogDataTypeCode` has no captured
CTagMetadata wire code for Int1. This is a **gateway/histsdk type-support gap**, not an OtOpcUa defect — **filed as
[historiangw#11](https://gitea.dohertylan.com/dohertj2/historiangw/issues/11)**.
- ⚠️ `EnsureTags_boolean_sandbox_provisions_as_Int1` (as originally written) — **Int1 (Boolean) tag creation is not
supported by the gateway's histsdk on this historian**: `EnsureTags(Int1)` returns `ProtocolEvidenceMissingException`
(the vendored client's "no capture evidence for this type" guard → gRPC `Unimplemented`), both fresh and as a retype
target. A **type sweep** confirmed it is Int1-specific: **Int2/UInt2/Int4/UInt4/Float/Double all provision
`success=True`; only Int1 fails.** Root cause: `histsdk` `HistorianTagWriteProtocol.GetAnalogDataTypeCode` has no
captured CTagMetadata wire code for Int1 a **gateway/histsdk type-support gap**, not an OtOpcUa defect
(**filed as [historiangw#11](https://gitea.dohertylan.com/dohertj2/historiangw/issues/11)**, which routed the
decision back as **[issue #441](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/441)**).
**✅ RESOLVED (issue #441, 2026-07-14):** `HistorianTypeMapper` now maps **`Boolean → Int2`** (0/1 integer) instead
of the degenerate `Int1`. Int2 is a proven-creatable analog type on this historian, and the value-write path already
sends every value as a `double` on `WriteLiveValues` regardless of tag type, so Boolean `0.0/1.0` round-trips
cleanly. The live gate test is renamed `EnsureTags_boolean_sandbox_provisions_as_Int2` and now passes → **6/6**. Unit
pins updated (`HistorianTypeMapperTests`, `GatewayTagProvisionerTests.Boolean_definition_carries_explicit_Int2_presence`);
gateway driver unit `103/103`.
**✅ U-7 retype policy — ANSWERED (via `grpcurl` against the real historian):**
- `EnsureTags` is a **real create-or-update upsert** — re-`EnsureTags` of an existing tag at the same type → `success`.
- An **in-place retype between SUPPORTED types is ACCEPTED** — `Float→Double` on an existing tag returns `success`
(of the three 06/U-7 hypotheses: **accepted-retyped**, not rejected).
- **But the specific 0.2.0 Boolean case (Float→Int1) can't retype** — Int1 isn't a creatable type here, so a
pre-0.2.0 **Float Boolean tag asked to become Int1 fails the provision** with `ProtocolEvidenceMissingException`
— it is **neither silently retyped nor data-lost**; the provision just fails (and is metered `failed=N`). That is
the practically-relevant U-7 answer for Boolean tags on this deployment.
- **The specific `Int1` case can't retype** — Int1 isn't a creatable type here. Rather than accept a broken Boolean
path, OtOpcUa **remapped `Boolean → Int2`** (issue #441, above), which retypes cleanly from a pre-existing Float
Boolean tag (both are supported analog types) and never hits the Int1 gap.
The R2-06 **code** deliverables (S-11 fail-fast + `ServerHistorianOptionsValidator`; U-7 Boolean→Int1 mapping +
The R2-06 **code** deliverables (S-11 fail-fast + `ServerHistorianOptionsValidator`; Boolean provisioning mapping +
upsert; C-7 doc) were already unit-covered + merged; this live gate additionally **found + fixed the StorageRateMs
provisioning bug** and **answered the U-7 retype policy**. The one remaining red test is a documented gateway
histsdk Int1-support gap.
provisioning bug**, **answered the U-7 retype policy**, and surfaced the Int1 histsdk gap that drove the
**Boolean→Int2 remap (issue #441)** — closing the last red test to reach **6/6**.
+36 -40
View File
@@ -141,48 +141,44 @@ for this node), which is the signal to check that `ServerHistorian:Enabled=true`
> so a HistoryRead on it runs to the full `CallTimeout` (~30 s) before erroring. Auto-provisioning on deploy
> keeps freshly-historized tags from hitting that slow path. See the `CallTimeout` row above.
### 0.2.0 DataType provisioning delta (archreview 06/U-7)
### Boolean historization maps to Int2 (issue #441)
`EnsureTags` is contract-documented (HistorianGateway 0.2.0) as an **upsert** — *"creates or updates
`EnsureTags` is contract-documented (HistorianGateway 0.2.0+) as an **upsert** — *"creates or updates
historian tags according to the given definitions"* — that maps per-tag onto AVEVA's
`HistorianClient.EnsureTagAsync(definition) -> bool`. It is **not idempotent** (not routed through the
idempotent retry pipeline) and **not skip-if-exists**.
The 0.1.0→0.2.0 client bump made `HistorianTagDefinition.DataType` **proto3-optional** (explicit presence).
Under 0.1.0, the provisioner's `Boolean → Int1` mapping sent wire value `0`, indistinguishable from unset, so
the gateway defaulted such tags to the SDK-default **Float**. Under 0.2.0 the same call carries explicit
presence, so Boolean tags now provision as **Int1** (the pre-existing intent of `HistorianTypeMapper` — no
mapper code change is needed or wanted). Consequences:
`HistorianTypeMapper` maps **`DriverDataType.Boolean → HistorianDataType.Int2`** and Boolean values
historize as a small **0/1 integer**. Int2 is used rather than the semantically-exact `Int1` because the
historian's **`Int1` analog-tag creation path is server-degenerate** — `EnsureTags(Int1)` stores an
unusable stub, so a Boolean tag mapped to `Int1` never becomes historizable. This is an evidence-based
decision from a live probe against the deployed 2023 R2 historian; the root cause and gateway-side
resolution are tracked in [issue #441](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/441) and
HistorianGateway [#11](https://gitea.dohertylan.com/dohertj2/historiangw/issues/11) / PR #16 (which
documents `Int1` as an explicit, evidence-based exclusion). The value-write path is unaffected: the
recorder sends every value as a `double` on `WriteLiveValues` regardless of tag type, so a Boolean
`0.0/1.0` round-trips cleanly into the Int2 tag.
1. **New Boolean tags provision correctly as Int1.** This is the fix arriving as a silent, zero-diff
semantic change from the bump.
2. **Pre-bump Boolean tags exist historian-side as Float.** On the next deploy that dispatches provisioning
for such a tag, the gateway is asked (via the upsert) to update it to Int1.
**Observed live (2026-07-13, real 0.2.0 gateway → real AVEVA historian on `wonder-sql-vd03`, probed via
`grpcurl` — archreview R2-06 T12):**
- `EnsureTags` is a **real create-or-update upsert** — re-`EnsureTags` of an existing tag at the same type
returns `Success=true`.
- An **in-place retype between SUPPORTED types is ACCEPTED**`Float→Double` on an existing tag returns
`Success=true`.
- A type sweep confirmed **`Int2/UInt2/Int4/UInt4/Float/Double` all provision `Success=true`; only `Int1`
fails** (`Success=false, Error="ProtocolEvidenceMissingException"` — the vendored client's "no capture
evidence for this type" guard → gRPC `Unimplemented`), both for a fresh tag and as a retype target. Mapping
Boolean to `Int2` sidesteps that gap entirely.
**Observed live (2026-07-13, real 0.2.0 gateway → real AVEVA historian on `wonder-sql-vd03`, probed via
`grpcurl` — archreview R2-06 T12):**
- `EnsureTags` is a **real create-or-update upsert** — re-`EnsureTags` of an existing tag at the same type
returns `Success=true`.
- An **in-place retype between SUPPORTED types is ACCEPTED**`Float→Double` on an existing tag returns
`Success=true` (hypothesis **(ii) accepted as a plain retype**, not rejected).
- **BUT `Int1` — the 0.2.0 Boolean target — is *not a creatable type* on this historian's histsdk:**
`EnsureTags(Int1)` returns `Success=false, Error="ProtocolEvidenceMissingException"` (the vendored client's
"no capture evidence for this type" guard → gRPC `Unimplemented`), both for a fresh tag and as a retype
target. A type sweep confirmed it is **Int1-specific**: `Int2/UInt2/Int4/UInt4/Float/Double` all provision
`Success=true`; only `Int1` fails.
- **Net for a pre-bump Float Boolean tag asked to become Int1:** the provision **fails** (per-tag
`Success=false``failed=N` tally). It is **neither silently retyped nor data-lost** — the tag stays Float
and remains readable; the Int1 provision simply doesn't land until the gateway's histsdk gains `Int1`
create-evidence — filed as
[historiangw#11](https://gitea.dohertylan.com/dohertj2/historiangw/issues/11). (Prerequisite for reaching this
outcome at all: the provisioner must stamp a **positive
`StorageRateMs`** — a `0` made `EnsureTags` throw `ArgumentOutOfRangeException` and fail *every* provision;
fixed in `GatewayTagProvisioner`, PR #439.)
3. **The failure mode is bounded and observable either way.** Provisioning is fire-and-forget and never
blocks/fails a deploy; a per-tag rejection surfaces only as the tally's `failed` count. Values written to
a still-Float tag by the recorder remain readable (Float supersets Int1's 0/1 range); nothing is lost
pending a reconcile. A bulk retype sweep is **not** done in code — assess via the probe first, then decide
whether reconciliation is a one-time operator action.
> **Prerequisite for provisioning to land at all:** the provisioner must stamp a **positive `StorageRateMs`**
> — a `0` made `EnsureTags` throw `ArgumentOutOfRangeException` and fail *every* provision (fixed in
> `GatewayTagProvisioner`, PR #439).
**Migration note for pre-existing Boolean tags.** A Boolean tag historian-side as Float (e.g. from the
0.1.0-era default) is asked, on its next deploy, to upsert to Int2. Both are supported analog types, so the
retype is accepted. Provisioning is fire-and-forget and never blocks/fails a deploy; a per-tag rejection (if
any) surfaces only as the tally's `failed` count, and values written to a still-Float tag remain readable
(Float supersets the 0/1 range) pending reconcile. A bulk retype sweep is **not** done in code.
---
@@ -407,11 +403,11 @@ The gateway-backed driver also ships an env-gated live suite (`Category=LiveInte
`HISTGW_GATEWAY_ENDPOINT` / `HISTGW_GATEWAY_APIKEY` / `HISTGW_TEST_TAG` / `HISTGW_WRITE_SANDBOX_TAG` /
`HISTGW_BOOL_SANDBOX_TAG` / `HISTGW_ALARM_SOURCE` env vars (it skips cleanly when they are absent).
`HISTGW_BOOL_SANDBOX_TAG` (e.g. `HistGW.LiveTest.BoolSandbox`, a **dedicated** writable Boolean tag —
never the shared Float write sandbox) drives the 0.2.0 Boolean `EnsureTags` leg
(`EnsureTags_boolean_sandbox_provisions_as_Int1`) and the retype probe
(`EnsureTags_type_change_outcome_is_observable`) that resolve the
[0.2.0 DataType provisioning delta](#020-datatype-provisioning-delta-archreview-06u-7) open question. Running
the full suite against a **0.2.0** gateway (all six tests, none skipped) also discharges 06/U-2's
never the shared Float write sandbox) drives the Boolean historization leg
(`EnsureTags_boolean_sandbox_provisions_as_Int2`) and the retype probe
(`EnsureTags_type_change_outcome_is_observable`) that back the
[Boolean historization maps to Int2](#boolean-historization-maps-to-int2-issue-441) decision. Running
the full suite against the gateway (all six tests, none skipped) also discharges 06/U-2's
"full documented run" for the historian suite.
See [AlarmHistorian.md](AlarmHistorian.md) for the alarm write path and
@@ -9,11 +9,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
/// </summary>
/// <remarks>
/// Only the nine numeric types are historizable on the gateway's analog write path. Two of them
/// fall back to a wider historian type because the narrower one's write path is deferred upstream:
/// <see cref="DriverDataType.UInt16"/> maps to <see cref="HistorianDataType.Uint4"/> (the historian's
/// <c>UInt2</c> write path is not proven). String / DateTime / Reference are not historized in v1
/// and throw <see cref="NotSupportedException"/>; callers that want to skip them without catching an
/// exception should consult <see cref="IsHistorizable(DriverDataType)"/> first.
/// fall back to a wider/adjacent historian type because the exact one's write path is not usable
/// upstream: <see cref="DriverDataType.UInt16"/> maps to <see cref="HistorianDataType.Uint4"/> (the
/// historian's <c>UInt2</c> write path is not proven), and <see cref="DriverDataType.Boolean"/> maps
/// to <see cref="HistorianDataType.Int2"/> — the historian's <c>Int1</c> analog-tag creation path is
/// server-degenerate (issue #441 / HistorianGateway #11: <c>EnsureTags(Int1)</c> stores an unusable
/// stub), so Boolean historizes as a small 0/1 integer instead. String / DateTime / Reference are not
/// historized in v1 and throw <see cref="NotSupportedException"/>; callers that want to skip them
/// without catching an exception should consult <see cref="IsHistorizable(DriverDataType)"/> first.
/// </remarks>
internal static class HistorianTypeMapper
{
@@ -25,7 +28,8 @@ internal static class HistorianTypeMapper
/// </exception>
public static HistorianDataType ToHistorianDataType(DriverDataType dataType) => dataType switch
{
DriverDataType.Boolean => HistorianDataType.Int1,
DriverDataType.Boolean => HistorianDataType.Int2, // Int1 analog-tag creation is server-degenerate (issue #441) → store 0/1
DriverDataType.Int16 => HistorianDataType.Int2,
DriverDataType.Int32 => HistorianDataType.Int4,
DriverDataType.Int64 => HistorianDataType.Int8,
@@ -26,24 +26,23 @@ public sealed class GatewayTagProvisionerTests
Assert.NotNull(fake.LastEnsureDefinitions);
Assert.Equal(2, fake.LastEnsureDefinitions!.Count);
Assert.Equal(HistorianDataType.Float, fake.LastEnsureDefinitions[0].DataType);
Assert.Equal(HistorianDataType.Int1, fake.LastEnsureDefinitions[1].DataType);
Assert.Equal(HistorianDataType.Int2, fake.LastEnsureDefinitions[1].DataType);
Assert.Equal(2, result.Requested);
Assert.Equal(0, result.Skipped);
}
/// <summary>
/// archreview 06/U-7 — pins the 0.2.0 Boolean→Int1 provisioning semantics at TWO levels. The
/// 0.1.0→0.2.0 client bump made <c>HistorianTagDefinition.DataType</c> proto3-optional (explicit
/// presence): under 0.1.0 the provisioner's <c>Int1</c> (wire 0) was indistinguishable from unset,
/// so the gateway defaulted Boolean tags to Float; under 0.2.0 the same call carries explicit
/// presence and provisions Int1 (the intended behavior). Asserting BOTH the mapped type AND
/// <c>HasDataType</c> compile-pins the package floor (the <c>HasDataType</c>/<c>ClearDataType</c>
/// members exist only on the 0.2.0 proto3-optional contract — a downgrade breaks the build) and
/// assert-pins that the provisioner always sends explicit presence (a future contract change that
/// stopped sending it would break the assert). This is a pin, not a fix — it passes on current code.
/// Pins the Boolean→Int2 provisioning semantics at TWO levels. Boolean maps to <c>Int2</c> because
/// the historian's <c>Int1</c> analog-tag creation path is server-degenerate — <c>EnsureTags(Int1)</c>
/// stores an unusable stub (issue #441 / HistorianGateway #11) — so Boolean historizes as a small 0/1
/// integer. Asserting BOTH the mapped type AND <c>HasDataType</c> compile-pins the package floor (the
/// <c>HasDataType</c>/<c>ClearDataType</c> members exist only on the 0.2.0+ proto3-optional contract —
/// a downgrade breaks the build) and assert-pins that the provisioner always sends explicit presence
/// (a future contract change that stopped sending it would default the tag to the SDK's Float and
/// break this assert). This is a pin, not a fix — it passes on current code.
/// </summary>
[Fact]
public async Task Boolean_definition_carries_explicit_Int1_presence()
public async Task Boolean_definition_carries_explicit_Int2_presence()
{
var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
var p = Provisioner(fake);
@@ -54,9 +53,9 @@ public sealed class GatewayTagProvisionerTests
var defs = fake.LastEnsureDefinitions!;
Assert.Single(defs);
Assert.Equal(HistorianDataType.Int1, defs[0].DataType);
// 0.2.0 proto3-optional presence witness — explicit presence is what flips the gateway from its
// SDK-default Float to the requested Int1. Without this, a Boolean tag would provision as Float.
Assert.Equal(HistorianDataType.Int2, defs[0].DataType);
// proto3-optional presence witness — explicit presence is what flips the gateway from its
// SDK-default Float to the requested Int2. Without this, a Boolean tag would provision as Float.
Assert.True(defs[0].HasDataType);
}
@@ -255,25 +255,26 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
}
/// <summary>
/// archreview 06/U-7 — Boolean provisioning leg on the 0.2.0 contract. Through the <b>real</b>
/// issue #441 — Boolean historization leg. Through the <b>real</b>
/// <see cref="GatewayTagProvisioner"/>, <c>EnsureTags</c> a <see cref="DriverDataType.Boolean"/>
/// request for the dedicated Boolean sandbox tag (mapped to <c>Int1</c> with explicit proto3
/// presence), assert it is ensured (not failed), then <c>WriteLiveValues</c> a <c>1.0</c> and read
/// it back over a wide window. This retires "the live EnsureTags leg has never run on 0.2.0" for
/// the Boolean path. The ±12h read window + timezone caveat mirror <see cref="Write_then_read_on_sandbox_tag"/>.
/// request for the dedicated Boolean sandbox tag (mapped to <c>Int2</c> — the historian's
/// <c>Int1</c> analog-tag creation path is server-degenerate, see issue #441 / HistorianGateway
/// #11, so Boolean historizes as a small 0/1 integer), assert it is ensured (not failed), then
/// <c>WriteLiveValues</c> a <c>1.0</c> and read it back over a wide window. The ±12h read window
/// + timezone caveat mirror <see cref="Write_then_read_on_sandbox_tag"/>.
/// </summary>
[Fact]
[Trait("Category", "LiveIntegration")]
public async Task EnsureTags_boolean_sandbox_provisions_as_Int1()
public async Task EnsureTags_boolean_sandbox_provisions_as_Int2()
{
if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
if (_fx.BoolSandboxTag is null)
Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a writable Boolean sandbox tag (e.g. HistGW.LiveTest.BoolSandbox) to run the 0.2.0 Boolean EnsureTags leg.");
Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a writable Boolean sandbox tag (e.g. HistGW.LiveTest.BoolSandbox) to run the Boolean historization leg.");
var ct = TestContext.Current.CancellationToken;
var tag = _fx.BoolSandboxTag;
// EnsureTags (Boolean → Int1) through the REAL provisioner seam (the same code path deploys use).
// EnsureTags (Boolean → Int2) through the REAL provisioner seam (the same code path deploys use).
await using var writeClient = _fx.CreateClient();
var provisioner = new GatewayTagProvisioner(writeClient, NullLogger<GatewayTagProvisioner>.Instance);
var provision = await provisioner.EnsureTagsAsync(
@@ -281,7 +282,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
ct);
provision.Ensured.ShouldBe(1,
"the Boolean sandbox tag must be ensured (needs the gateway on 0.2.0 contracts + an API key carrying historian:tags:write).");
"the Boolean sandbox tag must be ensured as Int2 (needs the gateway on 0.2.0+ contracts + an API key carrying historian:tags:write).");
provision.Failed.ShouldBe(0);
// WriteLiveValues a Boolean-as-1.0 and read it back (SQL live path can lag a flush cadence → poll).
@@ -308,14 +309,16 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
hit.ShouldNotBeNull($"the written Boolean sample ({written}) should read back from '{tag}'.");
TestContext.Current.SendDiagnosticMessage(
$"Boolean round-trip: EnsureTags(Int1) + WriteLiveValues '{tag}'={written} → read back at {hit!.SourceTimestampUtc:O}.");
$"Boolean round-trip: EnsureTags(Int2) + WriteLiveValues '{tag}'={written} → read back at {hit!.SourceTimestampUtc:O}.");
}
/// <summary>
/// archreview 06/U-7 retype probe — answers the assessment note's open question: what does the
/// deployed AVEVA Historian do when <c>EnsureTags</c> (an <b>upsert</b>) is asked to change an
/// existing tag's type? Against the <b>dedicated Boolean sandbox tag only</b> (never the shared
/// Float write sandbox), EnsureTags first as <c>Float</c> then as <c>Int1</c> and assert the second
/// Float write sandbox), EnsureTags first as <c>Float</c> then as <c>Int2</c> (both supported
/// analog types — <c>Int2</c> is the type Boolean now historizes as; the previously-probed
/// <c>Int1</c> is a documented server-degenerate exclusion, issue #441) and assert the second
/// call's per-tag outcome is <em>deterministic and observable</em> — either <c>Success=true</c>
/// (retype / tag-versioning accepted) or <c>Success=false</c> with a non-empty <c>Error</c> (mapped
/// to the provisioner's <c>failed</c> tally). The assertion accepts both because the contract
@@ -336,7 +339,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
await using var client = _fx.CreateClient();
// First establish the tag as Float, then ask EnsureTags to retype it to Int1 (Boolean).
// First establish the tag as Float, then ask EnsureTags to retype it to Int2.
await client.EnsureTagsAsync(
new[]
{
@@ -357,9 +360,9 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
new HistorianTagDefinition
{
TagName = tag,
DataType = HistorianDataType.Int1,
DataType = HistorianDataType.Int2,
EngineeringUnit = string.Empty,
Description = "OtOpcUa live validation retype probe (stage 2: Int1)",
Description = "OtOpcUa live validation retype probe (stage 2: Int2)",
StorageRateMs = 1000, // 0 makes the gateway's EnsureTags throw (storageRateMs must be > 0).
},
},
@@ -372,7 +375,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
"or Success=false with a non-empty Error (mapped to the provisioner's failed tally).");
TestContext.Current.SendDiagnosticMessage(
$"retype probe '{tag}' Float→Int1: Success={outcome.Success}, Error='{outcome.Error}'. " +
$"retype probe '{tag}' Float→Int2: Success={outcome.Success}, Error='{outcome.Error}'. " +
"(records the deployed historian's in-place analog-retype policy — feed this back into docs/Historian.md).");
}
}
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
public sealed class HistorianTypeMapperTests
{
[Theory]
[InlineData(DriverDataType.Boolean, HistorianDataType.Int1)]
[InlineData(DriverDataType.Boolean, HistorianDataType.Int2)] // Int1 analog-tag creation is server-degenerate (issue #441) → store 0/1
[InlineData(DriverDataType.Int16, HistorianDataType.Int2)]
[InlineData(DriverDataType.Int32, HistorianDataType.Int4)]
[InlineData(DriverDataType.Int64, HistorianDataType.Int8)]