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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user