Wire ApplyScaling, StorageRate; close out write-commands plan

ApplyScaling (HistorianTagDefinition.ApplyScaling):
The EnsT2 trailer's second byte controls server-side scaling — `FE 00`
mirrors MinRaw to MinEU and sets AnalogTag.Scaling=0; `FE 01` persists
distinct MinRaw/MaxRaw and sets Scaling=1. Decoded by toggling
set_ApplyScaling on the native harness and capturing the wire bytes for
both values with identical inputs. The earlier docs claimed
EnsureTagAsync needed a follow-up "UpdateTags" call; the WCF surface has
no such operation — toggling that one byte is the whole fix.

StorageRate (HistorianTagDefinition.StorageRateMs):
Serializer accepts a non-default rate, validated empirically against
the live server which only accepts quantized values
(1000/5000/10000/60000/300000 ms).

EnsureTagAsync upsert semantics:
Second call on the same tag name with different fields succeeds and
updates Description, MinEU, MaxEU, MinRaw, MaxRaw, Scaling in place
(verified by direct SQL inspection in a live test).

Plan + doc closeout:
write-commands-reverse-engineering.md rewritten as a current-state
plan with three workstreams (A doc closeout / B idempotency / C1
StorageRate) and a parallelism table; prior phase notes preserved as
appendix. handoff.md, implementation-status.md, wcf-contract-evidence.md,
README.md updated to remove "writes are out of scope" / non-existent
UpdateTags references and document the actual EnsT2 wire format
including the `FE xx` trailer.

Reverse-engineering harness gains --write-apply-scaling and a SQL
post-check that prints the persisted AnalogTag bounds so future RE
sessions can verify wire→DB causality without leaving the harness.

169/169 tests pass (was 165; +4 new tests covering ApplyScaling,
StorageRate golden bytes, StorageRate live persistence, and
EnsureTagAsync upsert semantics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-04 22:04:27 -04:00
parent a175c6e5a0
commit 5ce62a5900
13 changed files with 561 additions and 721 deletions
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
@@ -488,6 +488,179 @@ public sealed class HistorianClientIntegrationTests
}
}
[Fact]
public async Task EnsureTagAsync_NonDefaultStorageRate_PersistsToTagTable()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
const string sandboxTag = "RetestSdkWriteStorageRateRT";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe,
});
try
{
bool ok = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "SDK StorageRate round-trip",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
// Server only accepts quantized rates — 1000, 5000, 10000, 60000, 300000 ms.
StorageRateMs = 5000u,
}, CancellationToken.None);
Assert.True(ok, "EnsureTagAsync returned false");
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
sql.Open();
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT StorageRate FROM Tag WHERE TagName = @t";
cmd.Parameters.AddWithValue("@t", sandboxTag);
object? rate = cmd.ExecuteScalar();
Assert.NotNull(rate);
Assert.Equal(5000, Convert.ToInt32(rate));
}
finally
{
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
}
}
[Fact]
public async Task EnsureTagAsync_CalledTwiceOnSameTag_UpdatesFieldsInPlace()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
const string sandboxTag = "RetestSdkWriteIdempotencyRT";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe,
});
try
{
bool firstOk = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "First version",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
MinEU = 0.0, MaxEU = 100.0, MinRaw = 0.0, MaxRaw = 100.0,
ApplyScaling = false,
}, CancellationToken.None);
Assert.True(firstOk, "First EnsureTagAsync returned false");
(string desc1, double minEU1, double maxEU1, double minRaw1, double maxRaw1, int scaling1) = ReadTagState(sandboxTag);
Assert.Equal("First version", desc1);
Assert.Equal(0.0, minEU1);
Assert.Equal(0, scaling1);
bool secondOk = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "Second version",
EngineeringUnit = "kPa",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
MinEU = -50.0, MaxEU = 200.0, MinRaw = 10.0, MaxRaw = 4095.0,
ApplyScaling = true,
}, CancellationToken.None);
Assert.True(secondOk, "Second EnsureTagAsync returned false");
(string desc2, double minEU2, double maxEU2, double minRaw2, double maxRaw2, int scaling2) = ReadTagState(sandboxTag);
// EnsureTagAsync upserts: second call updates the existing row in place.
Assert.Equal("Second version", desc2);
Assert.Equal(-50.0, minEU2);
Assert.Equal(200.0, maxEU2);
Assert.Equal(10.0, minRaw2);
Assert.Equal(4095.0, maxRaw2);
Assert.Equal(1, scaling2);
}
finally
{
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
}
static (string desc, double minEU, double maxEU, double minRaw, double maxRaw, int scaling) ReadTagState(string tagName)
{
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
sql.Open();
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT t.[Description], a.MinEU, a.MaxEU, a.MinRaw, a.MaxRaw, a.Scaling FROM Tag t JOIN AnalogTag a ON a.TagName=t.TagName WHERE t.TagName=@t";
cmd.Parameters.AddWithValue("@t", tagName);
using Microsoft.Data.SqlClient.SqlDataReader r = cmd.ExecuteReader();
Assert.True(r.Read(), $"Tag {tagName} not found");
return (r.GetString(0), r.GetDouble(1), r.GetDouble(2), r.GetDouble(3), r.GetDouble(4), Convert.ToInt32(r.GetValue(5)));
}
}
[Fact]
public async Task EnsureTagAsync_ApplyScalingTrue_PersistsDistinctMinRawAndMaxRaw()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
const string sandboxTag = "RetestSdkWriteApplyScalingRT";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe,
});
AVEVA.Historian.Client.Models.HistorianTagDefinition definition = new()
{
TagName = sandboxTag,
Description = "SDK ApplyScaling round-trip",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
MinEU = -50.0,
MaxEU = 200.0,
MinRaw = 10.0,
MaxRaw = 4095.0,
ApplyScaling = true,
};
try
{
bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
Assert.True(ensured, "EnsureTagAsync(ApplyScaling=true) returned false against the live Historian.");
// Verify directly against the AnalogTag table — the read-path GetTagMetadataAsync
// surfaces only one of (MinRaw, MinEU); SQL is the unambiguous source of truth.
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
sql.Open();
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT MinEU, MaxEU, MinRaw, MaxRaw, Scaling FROM AnalogTag WHERE TagName = @t";
cmd.Parameters.AddWithValue("@t", sandboxTag);
using Microsoft.Data.SqlClient.SqlDataReader r = cmd.ExecuteReader();
Assert.True(r.Read(), $"AnalogTag row for {sandboxTag} not found after EnsureTag.");
Assert.Equal(-50.0, r.GetDouble(0));
Assert.Equal(200.0, r.GetDouble(1));
Assert.Equal(10.0, r.GetDouble(2));
Assert.Equal(4095.0, r.GetDouble(3));
Assert.Equal(1, Convert.ToInt32(r.GetValue(4)));
}
finally
{
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
}
}
[Fact]
public async Task GetTagMetadataAsync_PopulatesDescriptionAndEuRangeForAnalogTag()
{
@@ -8,16 +8,9 @@ public sealed class HistorianTagWriteProtocolTests
[Fact]
public void SerializeAnalogCTagMetadata_MatchesCapturedNativeBytesByteForByte()
{
// Reproduces the captured native EnsT2(Float) CTagMetadata bytes from
// artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/
// fresh-enst2-latest.ndjson — 144 bytes. Inputs:
// tagName = "RetestSdkWriteSandbox" (the sandbox)
// description = "SDK write-RE sandbox tag"
// eu = "test"
// FILETIME = 0x01DCDBBFCD87D049 (captured at run time)
// The earlier 146-byte version mistakenly included the WCF EndElement closing
// markers (`01 01 01`) and was missing the 0x4E leading marker — both have been
// corrected by walking the native InBuff field-by-field.
// Reproduces the captured native EnsT2(Float) CTagMetadata bytes for the sandbox
// tag with default ranges and ApplyScaling=false. 2-byte trailer = `FE 00` where
// the second byte is the ApplyScaling flag (0x00 = false; 0x01 = true).
const string ExpectedHex =
"4E6703000100000004C6020100000000000000000000000000000000"
+ "09150052657465737453646B577269746553616E64626F78"
@@ -118,6 +111,82 @@ public sealed class HistorianTagWriteProtocolTests
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
}
[Fact]
public void SerializeAnalogCTagMetadata_NonDefaultStorageRate_EncodesUInt32LittleEndianAtKnownOffset()
{
byte[] defaultRate = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteRate",
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL));
byte[] customRate = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteRate",
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
storageRateMs: 2500u);
Assert.Equal(defaultRate.Length, customRate.Length);
// Storage-rate uint32 is at the byte position immediately after the
// "MDAS" + flag-block sequence; the only diff between the two payloads
// is those 4 bytes.
int firstDiff = 0;
while (firstDiff < defaultRate.Length && defaultRate[firstDiff] == customRate[firstDiff]) firstDiff++;
Assert.Equal(0xE8, defaultRate[firstDiff]); // 1000 = 0x000003E8 LE → 0xE8 0x03 0x00 0x00
Assert.Equal(0x03, defaultRate[firstDiff + 1]);
Assert.Equal(0xC4, customRate[firstDiff]); // 2500 = 0x000009C4 LE → 0xC4 0x09 0x00 0x00
Assert.Equal(0x09, customRate[firstDiff + 1]);
// Beyond the 4-byte rate field, the rest is identical.
Assert.Equal(
Convert.ToHexString(defaultRate.AsSpan(firstDiff + 4)),
Convert.ToHexString(customRate.AsSpan(firstDiff + 4)));
}
[Fact]
public void SerializeAnalogCTagMetadata_ZeroStorageRate_Throws()
{
Assert.Throws<ArgumentOutOfRangeException>(() => HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteRate",
description: "x",
engineeringUnit: "test",
dateCreatedUtc: DateTime.UtcNow,
storageRateMs: 0u));
}
[Fact]
public void SerializeAnalogCTagMetadata_ApplyScalingTrue_FlipsTrailerSecondByte()
{
// Captured 2026-05-04 by toggling --write-apply-scaling on the native harness:
// ApplyScaling=true sets the trailer's second byte to 0x01 (vs 0x00 for false).
// Live-verified: with 0x01 the server persists distinct MinRaw/MaxRaw and sets
// AnalogTag.Scaling=1; with 0x00 it mirrors MinRaw to MinEU and sets Scaling=0.
byte[] withFlag = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteFloatRanges",
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
dataType: AVEVA.Historian.Client.Models.HistorianDataType.Float,
minEU: -50.0, maxEU: 200.0, minRaw: 10.0, maxRaw: 4095.0,
applyScaling: true);
byte[] withoutFlag = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteFloatRanges",
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
dataType: AVEVA.Historian.Client.Models.HistorianDataType.Float,
minEU: -50.0, maxEU: 200.0, minRaw: 10.0, maxRaw: 4095.0,
applyScaling: false);
Assert.Equal(withoutFlag.Length, withFlag.Length);
Assert.Equal(0xFE, withFlag[^2]);
Assert.Equal(0x01, withFlag[^1]);
Assert.Equal(0xFE, withoutFlag[^2]);
Assert.Equal(0x00, withoutFlag[^1]);
Assert.Equal(
Convert.ToHexString(withoutFlag.AsSpan(0, withoutFlag.Length - 1)),
Convert.ToHexString(withFlag.AsSpan(0, withFlag.Length - 1)));
}
[Fact]
public void GetAnalogDataTypeCode_UnsupportedType_Throws()
{