feat(historian): typed HistorianValue contracts, IHistorianDataUpdater, IHistorianBackendProvider (no behaviour change)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 12:44:19 -04:00
parent a6643cedf9
commit ff7c6c531a
15 changed files with 357 additions and 65 deletions
@@ -87,7 +87,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
// A value that is exactly representable in float32 (integer < 2^24) so the analog
// store/read round-trip is not muddied by single-precision rounding. The millisecond
// component keeps consecutive runs from colliding on the same value.
const ushort goodQuality = 192; // OPC-DA "Good" floor.
const uint goodStatus = 0u; // OPC UA Good.
var writeUtc = DateTime.UtcNow;
double written = 1_000_000 + writeUtc.Millisecond;
@@ -112,7 +112,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
// WriteLiveValues through the real recorder writer (SQL live-write path).
var valueWriter = new GatewayHistorianValueWriter(writeClient, NullLogger<GatewayHistorianValueWriter>.Instance);
var acked = await valueWriter.WriteLiveValuesAsync(
tag, new[] { new HistorizationValue(writeUtc, written, goodQuality) }, ct);
tag, new[] { new HistorizationValue(writeUtc, HistorianValue.FromDouble(written), goodStatus) }, ct);
acked.ShouldBeTrue(
"the live write must be acked — needs the gateway running RuntimeDb:Enabled=true and the tag EnsureTags-provisioned.");
@@ -286,12 +286,12 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
provision.Failed.ShouldBe(0);
// WriteLiveValues a Boolean-as-1.0 and read it back (SQL live path can lag a flush cadence → poll).
const ushort goodQuality = 192; // OPC-DA "Good" floor.
const uint goodStatus = 0u; // OPC UA Good.
var writeUtc = DateTime.UtcNow;
const double written = 1.0;
var valueWriter = new GatewayHistorianValueWriter(writeClient, NullLogger<GatewayHistorianValueWriter>.Instance);
var acked = await valueWriter.WriteLiveValuesAsync(
tag, new[] { new HistorizationValue(writeUtc, written, goodQuality) }, ct);
tag, new[] { new HistorizationValue(writeUtc, HistorianValue.FromDouble(written), goodStatus) }, ct);
acked.ShouldBeTrue("the Boolean live write must be acked (gateway RuntimeDb:Enabled=true + EnsureTags-provisioned tag).");
await using var dataSource = _fx.CreateDataSource();
@@ -25,7 +25,7 @@ public sealed class FasterLogHistorizationOutboxTests : IDisposable
}
private static HistorizationOutboxEntry E(string tag, double v) =>
new(Guid.NewGuid(), tag, v, 192, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
new(Guid.NewGuid(), tag, HistorianValue.FromDouble(v), 0u, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
[Fact]
public async Task Append_then_peek_returns_fifo()
@@ -23,7 +23,7 @@ public sealed class GatewayHistorianValueWriterTests
public async Task Success_ack_returns_true()
{
var fake = new FakeHistorianGatewayClient { WriteLiveValuesResult = new WriteAck { Success = true } };
var values = new[] { new HistorizationValue(DateTime.UtcNow, 42.0, 192) };
var values = new[] { new HistorizationValue(DateTime.UtcNow, HistorianValue.FromDouble(42.0), 0u) };
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -40,7 +40,7 @@ public sealed class GatewayHistorianValueWriterTests
{
WriteLiveValuesResult = new WriteAck { Success = false, Queued = true },
};
var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) };
var values = new[] { new HistorizationValue(DateTime.UtcNow, HistorianValue.FromDouble(1.0), 0u) };
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -54,7 +54,7 @@ public sealed class GatewayHistorianValueWriterTests
{
WriteLiveValuesResult = new WriteAck { Success = false, Queued = false },
};
var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) };
var values = new[] { new HistorizationValue(DateTime.UtcNow, HistorianValue.FromDouble(1.0), 0u) };
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -66,7 +66,7 @@ public sealed class GatewayHistorianValueWriterTests
{
// NEVER throw out of the writer — the recorder's drain loop expects a plain bool.
var fake = new FakeHistorianGatewayClient { WriteLiveValuesThrows = new InvalidOperationException("boom") };
var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) };
var values = new[] { new HistorizationValue(DateTime.UtcNow, HistorianValue.FromDouble(1.0), 0u) };
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -92,8 +92,8 @@ public sealed class GatewayHistorianValueWriterTests
var stamped = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var values = new[]
{
new HistorizationValue(stamped, 1.0, 192),
new HistorizationValue(null, 2.0, 192),
new HistorizationValue(stamped, HistorianValue.FromDouble(1.0), 0u),
new HistorizationValue(null, HistorianValue.FromDouble(2.0), 0u),
};
await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -108,7 +108,7 @@ public sealed class GatewayHistorianValueWriterTests
public async Task Quality_and_numeric_value_are_forwarded_unchanged()
{
var fake = new FakeHistorianGatewayClient();
var values = new[] { new HistorizationValue(null, 123.5, 192) };
var values = new[] { new HistorizationValue(null, HistorianValue.FromDouble(123.5), 0u) };
await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
@@ -121,7 +121,7 @@ public sealed class GatewayHistorianValueWriterTests
public async Task Null_tag_throws()
{
var fake = new FakeHistorianGatewayClient();
var values = new[] { new HistorizationValue(null, 1.0, 192) };
var values = new[] { new HistorizationValue(null, HistorianValue.FromDouble(1.0), 0u) };
await Assert.ThrowsAsync<ArgumentNullException>(() =>
Writer(fake).WriteLiveValuesAsync(null!, values, TestContext.Current.CancellationToken));
@@ -18,7 +18,7 @@ public sealed class HistorizationOutboxEntrySerializerTests
public void Round_trips_all_fields()
{
var entry = new HistorizationOutboxEntry(
Guid.NewGuid(), "Pump1.Temp", 123.456, 192,
Guid.NewGuid(), "Pump1.Temp", HistorianValue.FromDouble(123.456), 0u,
new DateTime(2026, 1, 1, 12, 34, 56, DateTimeKind.Utc));
var bytes = HistorizationOutboxEntrySerializer.Serialize(entry);
@@ -26,8 +26,8 @@ public sealed class HistorizationOutboxEntrySerializerTests
Assert.Equal(entry.Id, round.Id);
Assert.Equal(entry.Tag, round.Tag);
Assert.Equal(entry.NumericValue, round.NumericValue);
Assert.Equal(entry.Quality, round.Quality);
Assert.Equal(entry.Value, round.Value);
Assert.Equal(entry.StatusCode, round.StatusCode);
Assert.Equal(entry.TimestampUtc, round.TimestampUtc);
Assert.Equal(entry.TimestampUtc.Kind, round.TimestampUtc.Kind); // ToBinary/FromBinary round-trips Kind
}
@@ -39,19 +39,19 @@ public sealed class HistorizationOutboxEntrySerializerTests
// byte count diverge (e.g. Ω is 2 bytes, 日 is 3) is the one case that would desync the layout
// if GetByteCount/GetBytes ever disagreed with what Deserialize slices out.
var entry = new HistorizationOutboxEntry(
Guid.NewGuid(), "Pumpe_Ω_日本語_Temp", -1.5, 0, DateTime.UtcNow);
Guid.NewGuid(), "Pumpe_Ω_日本語_Temp", HistorianValue.FromDouble(-1.5), 0u, DateTime.UtcNow);
var round = HistorizationOutboxEntrySerializer.Deserialize(
HistorizationOutboxEntrySerializer.Serialize(entry));
Assert.Equal(entry.Tag, round.Tag);
Assert.Equal(entry.NumericValue, round.NumericValue);
Assert.Equal(entry.Value, round.Value);
}
[Fact]
public void Round_trips_an_empty_tag()
{
var entry = new HistorizationOutboxEntry(Guid.NewGuid(), string.Empty, 0.0, 0, DateTime.UtcNow);
var entry = new HistorizationOutboxEntry(Guid.NewGuid(), string.Empty, HistorianValue.FromDouble(0.0), 0u, DateTime.UtcNow);
var round = HistorizationOutboxEntrySerializer.Deserialize(
HistorizationOutboxEntrySerializer.Serialize(entry));
@@ -60,14 +60,13 @@ public sealed class HistorizationOutboxEntrySerializerTests
}
[Fact]
public void Round_trips_boundary_quality_and_negative_value()
public void Round_trips_boundary_negative_value()
{
var entry = new HistorizationOutboxEntry(Guid.NewGuid(), "T", double.MinValue, ushort.MaxValue, DateTime.UtcNow);
var entry = new HistorizationOutboxEntry(Guid.NewGuid(), "T", HistorianValue.FromDouble(double.MinValue), 0u, DateTime.UtcNow);
var round = HistorizationOutboxEntrySerializer.Deserialize(
HistorizationOutboxEntrySerializer.Serialize(entry));
Assert.Equal(ushort.MaxValue, round.Quality);
Assert.Equal(double.MinValue, round.NumericValue);
Assert.Equal(double.MinValue, round.Value.Number);
}
}