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
@@ -6,6 +6,11 @@ namespace AVEVA.Historian.Client.Models;
/// String/Int1/Int8/UInt8 types failed at native AddTag — likely require a different
/// path and are intentionally not supported. MinEU/MaxEU/MinRaw/MaxRaw are now encoded
/// into the wire payload (see <c>HistorianTagWriteProtocol</c>).
///
/// Semantics: <c>EnsureTagAsync</c> is an upsert. Calling it twice on the same
/// <see cref="TagName"/> with different fields succeeds both times; the second call
/// updates Description, MinEU, MaxEU, MinRaw, MaxRaw, and AnalogTag.Scaling on the
/// existing row (verified 2026-05-04 by direct SQL inspection after sequential calls).
/// </summary>
public sealed record HistorianTagDefinition
{
@@ -28,18 +33,32 @@ public sealed record HistorianTagDefinition
public double MaxEU { get; init; } = 100.0;
/// <summary>
/// Raw lower bound (pre-scaling). Default 0. Note: with ApplyScaling=false (the
/// only path the SDK currently exposes), the server appears to mirror MinRaw to
/// MinEU on EnsureTags2 verified 2026-05-04 against both native and managed
/// clients with the same input. The value is sent on the wire but not persisted
/// independently. To set distinct raw bounds, ApplyScaling=true plus a follow-up
/// UpdateTags call would be required (not yet wired).
/// Raw lower bound (pre-scaling). Default 0. Persisted distinctly only when
/// <see cref="ApplyScaling"/> is true; with ApplyScaling=false the server mirrors
/// this to MinEU on EnsureTags2 (verified 2026-05-04 against both native and
/// managed clients).
/// </summary>
public double MinRaw { get; init; }
/// <summary>
/// Raw upper bound (pre-scaling). Default 100. See <see cref="MinRaw"/> for the
/// server-side mirror caveat with ApplyScaling=false.
/// ApplyScaling caveat.
/// </summary>
public double MaxRaw { get; init; } = 100.0;
/// <summary>
/// When true, the server persists <see cref="MinRaw"/> / <see cref="MaxRaw"/> as
/// distinct values from <see cref="MinEU"/> / <see cref="MaxEU"/> and sets
/// <c>AnalogTag.Scaling</c> = 1. When false (default), the server mirrors MinRaw
/// to MinEU and MaxRaw to MaxEU and sets <c>AnalogTag.Scaling</c> = 0.
/// </summary>
public bool ApplyScaling { get; init; }
/// <summary>
/// Storage rate in milliseconds. Default 1000ms. The server only accepts
/// quantized values (observed valid set: 1000, 5000, 10000, 60000, 300000) —
/// non-quantized values cause <see cref="HistorianClient.EnsureTagAsync"/> to
/// return false.
/// </summary>
public uint StorageRateMs { get; init; } = 1000u;
}
@@ -26,13 +26,12 @@ namespace AVEVA.Historian.Client.Wcf;
/// compact ASCII engineering unit
/// uint32 = 0x2710 (10000 — purpose unclear; observed constant)
/// 8-byte double = 1.0 (likely IntegralDivisor)
/// 2-byte trailer = FE 00
/// 2-byte trailer = `FE 00` for ApplyScaling=false; `FE 01` for ApplyScaling=true
/// </code>
/// MinEU/MaxEU/MinRaw/MaxRaw fields and their wire positions are NOT yet decoded
/// from the captured fixture (the test tag used the defaults). The serializer accepts
/// those parameters from <see cref="Models.HistorianTagDefinition"/> but their wire
/// representation is currently a TODO; for now they are not encoded into the
/// payload — the server uses defaults from the AnalogTag table after creation.
/// The trailer's second byte is the ApplyScaling flag — verified 2026-05-04 by
/// capturing native CTagMetadata bytes for both values with identical
/// MinEU/MaxEU/MinRaw/MaxRaw inputs and observing that the server persists distinct
/// MinRaw/MaxRaw (and sets AnalogTag.Scaling=1) only when this byte is 0x01.
/// </remarks>
internal static class HistorianTagWriteProtocol
{
@@ -88,10 +87,14 @@ internal static class HistorianTagWriteProtocol
private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
/// <summary>Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.</summary>
private static readonly byte[] AnalogScalingExplicitMarker = [0x1F, 0x00];
// Native trailer is 2 bytes; the prior 5-byte version included WCF EndElement
// closing markers (`01 01 01`) that the binary message encoder writes around the
// <InBuff> element — those are not part of the buffer content.
private static readonly byte[] AnalogTrailer = [0xFE, 0x00];
// 2-byte trailer: `FE` marker + ApplyScaling byte (0x00 = false, 0x01 = true). Verified
// against native captures by toggling ApplyScaling on the harness and confirming that
// the server persists distinct MinRaw/MaxRaw + sets AnalogTag.Scaling=1 only when the
// second byte is 0x01. The WCF binary encoder may split InBuff across two
// Bytes8Text chunks (e.g., `9E B7 ... 9F 01 00`) which can make the trailer look
// 1-byte from the wire, but the semantic CTagMetadata content is always 2 bytes.
private static readonly byte[] AnalogTrailerScalingDisabled = [0xFE, 0x00];
private static readonly byte[] AnalogTrailerScalingEnabled = [0xFE, 0x01];
private const double DefaultMinEU = 0.0;
private const double DefaultMaxEU = 100.0;
@@ -129,8 +132,13 @@ internal static class HistorianTagWriteProtocol
double maxEU = DefaultMaxEU,
double minRaw = DefaultMinRaw,
double maxRaw = DefaultMaxRaw,
uint storageRateMs = DefaultStorageRateMs)
uint storageRateMs = DefaultStorageRateMs,
bool applyScaling = false)
{
if (storageRateMs == 0)
{
throw new ArgumentOutOfRangeException(nameof(storageRateMs), "Storage rate must be > 0 ms.");
}
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
byte typeCode = GetAnalogDataTypeCode(dataType);
@@ -164,7 +172,7 @@ internal static class HistorianTagWriteProtocol
WriteCompactAscii(w, engineeringUnit ?? string.Empty); // var
w.Write(IntegralDivisorMagic); // uint32 (purpose unclear — captured constant)
w.Write(1.0); // double
w.Write(AnalogTrailer); // 2 bytes (FE 00)
w.Write(applyScaling ? AnalogTrailerScalingEnabled : AnalogTrailerScalingDisabled);
return ms.ToArray();
}
@@ -108,7 +108,9 @@ internal sealed class HistorianWcfTagWriteOrchestrator
minEU: definition.MinEU,
maxEU: definition.MaxEU,
minRaw: definition.MinRaw,
maxRaw: definition.MaxRaw);
maxRaw: definition.MaxRaw,
storageRateMs: definition.StorageRateMs,
applyScaling: definition.ApplyScaling);
bool ok = historyChannel.EnsureTags2(
handle: handle,