M3 R3.2: AddHistoricalValuesAsync supports Double + Int (Int2/Int4/UInt4)

Extended the historical-write serializer from Float-only to all five analog types EnsureTagAsync
supports. Captured each type's "ON" buffer live from the native client (sandbox tag per type,
written + captured + deleted):

- The 4-byte value descriptor (C0 10 01 00) is CONSTANT across types — it does not encode the type.
- The value is u32(0) + native-width value, width by the tag's declared type:
  Float->float32, Double->double64, Int2->int16, Int4->int32, UInt4->uint32.

HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer now takes the HistorianDataType and
encodes accordingly (unsupported types throw ProtocolEvidenceMissingException). The orchestrator
resolves the type from the tag-info NativeDataTypeDescriptor via MapDataType. Harness capture-write
gained --data-type. Golden-tested against all five live captures + the gated write/read-back test
validated each type end-to-end through the pure-managed SDK; 281 unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-21 21:48:29 -04:00
parent d527784def
commit d1e96f48de
7 changed files with 137 additions and 69 deletions
@@ -189,9 +189,10 @@ public sealed class HistorianGrpcIntegrationTests
HistorianClient client = new(BuildOptions(host));
// A backfill sample at a fixed historical second, with a distinctive value.
// A backfill sample at a fixed historical second, with a distinctive whole-number value so
// it round-trips for any analog tag type (Float/Double/Int2/Int4/UInt4).
DateTime stamp = new DateTime(DateTime.UtcNow.Year, 1, 2, 3, 4, 5, DateTimeKind.Utc);
const double expected = 222.5;
const double expected = 7777;
bool wrote = await client.AddHistoricalValuesAsync(
sandboxTag!,
[new HistorianHistoricalValue(stamp, expected)],
@@ -1,41 +1,60 @@
using System.Buffers.Binary;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
public sealed class WcfHistoricalWriteProtocolTests
{
// The exact 56-byte HistoryService.AddStreamValues "values" buffer captured live from the native
// 2023 R2 client writing a historical Float sample (124.5) to a sandbox tag — see
// docs/plans/revision-write-path.md §"R3.1 CAPTURED".
private const string CapturedBufferHex =
"4f4e0100380000002e00" + // "ON" + count(1) + totalLen(56) + payloadLen(46)
"d51d3107e9f8664793b155d3d1aef544" + // tag GUID 07311dd5-f8e9-4766-93b1-55d3d1aef544
"70df38b1d101dd01" + // sample FILETIME
"c000" + // OpcQuality = 192
"c0100100" + // analog double descriptor
"e64bcb74e201dd01" + // received/version FILETIME
"000000000000f942"; // double 124.5
[Fact]
public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer()
// Exact HistoryService.AddStreamValues "values" buffers captured live from the native 2023 R2
// client writing one historical sample to a sandbox tag of each analog type — see
// docs/plans/revision-write-path.md §"R3.1 CAPTURED". The value descriptor (C0 10 01 00) is
// constant across types; only the value width differs.
[Theory]
[InlineData(HistorianDataType.Float, 124.5,
"4f4e0100380000002e00d51d3107e9f8664793b155d3d1aef54470df38b1d101dd01c000c0100100e64bcb74e201dd01000000000000f942")]
[InlineData(HistorianDataType.Double, 124.5,
"4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "000000000000000000205f40")]
[InlineData(HistorianDataType.Int2, 1234d,
"4f4e0100360000002c00" + "35bb0ca5865a7f4498f06bf4e4d9ab23" + "f012f356d701dd01" + "c000" + "c0100100" + "9546841ae801dd01" + "00000000d204")]
[InlineData(HistorianDataType.Int4, 12345d,
"4f4e0100380000002e00" + "ca9735f7f841b244b56f9c14ccfeac32" + "b09bc72fd701dd01" + "c000" + "c0100100" + "104b59f3e701dd01" + "0000000039300000")]
[InlineData(HistorianDataType.UInt4, 305419896d,
"4f4e0100380000002e00" + "e7ae22d8e4cc65439ebd8bcb09402974" + "602d6663d701dd01" + "c000" + "c0100100" + "498af726e801dd01" + "0000000078563412")]
public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer(HistorianDataType dataType, double value, string capturedHex)
{
var tagGuid = new Guid("07311dd5-f8e9-4766-93b1-55d3d1aef544");
DateTime sampleTime = DateTime.FromFileTimeUtc(0x01dd01d1b138df70);
DateTime receivedTime = DateTime.FromFileTimeUtc(0x01dd01e274cb4be6);
byte[] captured = Convert.FromHexString(capturedHex);
// The GUID + both FILETIMEs sit at fixed offsets (the value width varies after them).
var tagGuid = new Guid(captured.AsSpan(10, 16).ToArray());
long sampleFt = BinaryPrimitives.ReadInt64LittleEndian(captured.AsSpan(26, 8));
long receivedFt = BinaryPrimitives.ReadInt64LittleEndian(captured.AsSpan(40, 8));
byte[] actual = HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
tagGuid, sampleTime, value: 124.5, receivedTime, quality: 192);
tagGuid,
dataType,
DateTime.FromFileTimeUtc(sampleFt),
value,
DateTime.FromFileTimeUtc(receivedFt),
quality: 192);
Assert.Equal(Convert.FromHexString(CapturedBufferHex), actual);
Assert.Equal(captured, actual);
}
[Fact]
public void SerializeAddStreamValuesBuffer_UnsupportedType_Throws()
{
Assert.Throws<ProtocolEvidenceMissingException>(() =>
HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
Guid.NewGuid(), HistorianDataType.SingleByteString, DateTime.UtcNow, 1.0, DateTime.UtcNow));
}
[Fact]
public void SerializeAddStreamValuesBuffer_HeaderDeclaresLengths()
{
byte[] buffer = HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
Guid.NewGuid(), DateTime.UtcNow, value: 1.0, DateTime.UtcNow);
Guid.NewGuid(), HistorianDataType.Float, DateTime.UtcNow, value: 1.0, DateTime.UtcNow);
Assert.Equal(56, buffer.Length);
Assert.Equal(0x4E4F, BitConverter.ToUInt16(buffer, 0)); // "ON"
Assert.Equal(1, BitConverter.ToUInt16(buffer, 2)); // sampleCount
Assert.Equal((uint)buffer.Length, BitConverter.ToUInt32(buffer, 4));