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
@@ -0,0 +1,96 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>The value kinds a historian backend can store. Persisted as a byte (SQL ValueKind, outbox v2) — never renumber.</summary>
public enum HistorianValueKind : byte { Empty = 0, Double = 1, Int64 = 2, Boolean = 3, String = 4, DateTime = 5 }
/// <summary>A typed historized value. Number carries Double (and the double view of Int64/Boolean);
/// Integer carries Int64, Boolean (1/0) and DateTime (UTC ticks); Text carries String.</summary>
/// <param name="Kind">Which payload field(s) are meaningful.</param>
/// <param name="Number">Double payload (also the double view of Int64/Boolean).</param>
/// <param name="Integer">Int64 payload, Boolean (1/0) or DateTime UTC ticks.</param>
/// <param name="Text">String payload.</param>
public readonly record struct HistorianValue(HistorianValueKind Kind, double Number, long Integer, string? Text)
{
/// <summary>No value (e.g. a Bad/Uncertain sample carrying null).</summary>
public static HistorianValue Empty => default;
/// <summary>Creates a Double value.</summary>
/// <param name="v">The value.</param>
/// <returns>The typed value.</returns>
public static HistorianValue FromDouble(double v) => new(HistorianValueKind.Double, v, 0, null);
/// <summary>Creates an Int64 value.</summary>
/// <param name="v">The value.</param>
/// <returns>The typed value.</returns>
public static HistorianValue FromInt64(long v) => new(HistorianValueKind.Int64, v, v, null);
/// <summary>Creates a Boolean value (Integer and Number are 1/0).</summary>
/// <param name="v">The value.</param>
/// <returns>The typed value.</returns>
public static HistorianValue FromBoolean(bool v) => new(HistorianValueKind.Boolean, v ? 1d : 0d, v ? 1 : 0, null);
/// <summary>Creates a String value.</summary>
/// <param name="v">The value; must not be null.</param>
/// <returns>The typed value.</returns>
public static HistorianValue FromString(string v) =>
new(HistorianValueKind.String, 0d, 0, v ?? throw new ArgumentNullException(nameof(v)));
/// <summary>Creates a DateTime value stored as UTC ticks (Local is converted, Unspecified is taken as UTC).</summary>
/// <param name="v">The value.</param>
/// <returns>The typed value.</returns>
public static HistorianValue FromDateTime(DateTime v) =>
new(HistorianValueKind.DateTime, 0d, (v.Kind == DateTimeKind.Local ? v.ToUniversalTime() : DateTime.SpecifyKind(v, DateTimeKind.Utc)).Ticks, null);
/// <summary>Coerces a CLR value into a typed historian value.</summary>
/// <param name="value">The CLR value (null maps to <see cref="Empty"/>).</param>
/// <param name="result">The typed value, or <see cref="Empty"/> when not historizable.</param>
/// <returns><c>false</c> for arrays, references and any other non-historizable type.</returns>
public static bool TryFromObject(object? value, out HistorianValue result)
{
switch (value)
{
case null: result = Empty; return true;
case double d: result = FromDouble(d); return true;
case float f: result = FromDouble(f); return true;
case decimal m: result = FromDouble((double)m); return true;
case sbyte sb: result = FromInt64(sb); return true;
case byte b: result = FromInt64(b); return true;
case short s: result = FromInt64(s); return true;
case ushort us: result = FromInt64(us); return true;
case int i: result = FromInt64(i); return true;
case uint ui: result = FromInt64(ui); return true;
case long l: result = FromInt64(l); return true;
case ulong ul: result = ul <= long.MaxValue ? FromInt64((long)ul) : FromDouble(ul); return true;
case bool bo: result = FromBoolean(bo); return true;
case string str: result = FromString(str); return true;
case DateTime dt: result = FromDateTime(dt); return true;
default: result = Empty; return false; // arrays, references, structures => not historizable
}
}
/// <summary>The double view of the value.</summary>
/// <param name="value">The double view; 0 when unavailable.</param>
/// <returns><c>true</c> for Double, Int64 and Boolean kinds.</returns>
public bool TryGetDouble(out double value)
{
switch (Kind)
{
case HistorianValueKind.Double:
case HistorianValueKind.Int64:
case HistorianValueKind.Boolean: value = Number; return true;
default: value = 0d; return false;
}
}
/// <summary>Converts back to a CLR value (DateTime is returned as <see cref="DateTimeKind.Utc"/>).</summary>
/// <returns>The boxed CLR value, or null for <see cref="HistorianValueKind.Empty"/>.</returns>
public object? ToObject() => Kind switch
{
HistorianValueKind.Double => Number,
HistorianValueKind.Int64 => Integer,
HistorianValueKind.Boolean => Integer != 0,
HistorianValueKind.String => Text,
HistorianValueKind.DateTime => new DateTime(Integer, DateTimeKind.Utc),
_ => null,
};
}
@@ -2,17 +2,16 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// One durable record buffered by the continuous-historization outbox before it is written to
/// the historian. Carries the minimal payload the SQL analog live-value write path can ingest:
/// a numeric value, a quality code, and a UTC timestamp keyed by tag.
/// the historian: a typed value, an OPC UA status code, and a UTC timestamp keyed by tag.
/// </summary>
/// <param name="Id">Stable identifier used to ack (remove) the entry once written. Unique per append.</param>
/// <param name="Tag">Fully-qualified historian tag name the value is recorded against.</param>
/// <param name="NumericValue">The coerced numeric sample value (the SQL write path is numeric-only).</param>
/// <param name="Quality">OPC-UA-derived quality code (e.g. 192 = Good) carried through to the historian.</param>
/// <param name="Value">The typed sample value.</param>
/// <param name="StatusCode">OPC UA status code of the sample (0 = Good).</param>
/// <param name="TimestampUtc">UTC source timestamp of the sample.</param>
public sealed record HistorizationOutboxEntry(
Guid Id,
string Tag,
double NumericValue,
ushort Quality,
HistorianValue Value,
uint StatusCode,
DateTime TimestampUtc);
@@ -0,0 +1,49 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>OPC UA Part 11 PerformUpdateType minus Remove.</summary>
public enum HistoryUpdateMode
{
/// <summary>Insert new samples; an existing sample at the timestamp is refused.</summary>
Insert,
/// <summary>Replace existing samples; a missing sample is refused.</summary>
Replace,
/// <summary>Insert or replace (upsert).</summary>
Update,
}
/// <summary>
/// Optional historian capability behind OPC UA HistoryUpdate. A backend that cannot modify history
/// supplies no updater (the server then reports HistoryUpdate as unsupported).
/// </summary>
public interface IHistorianDataUpdater
{
/// <summary>Inserts, replaces or upserts samples for one tag.</summary>
/// <param name="tag">Historian tag name.</param>
/// <param name="mode">Update mode.</param>
/// <param name="values">Samples to apply.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// One status code per value, in order: Insert gives <c>GoodEntryInserted</c> or <c>BadEntryExists</c>;
/// Replace gives <c>GoodEntryReplaced</c> or <c>BadNoEntryExists</c>; Update gives
/// <c>GoodEntryInserted</c> or <c>GoodEntryReplaced</c>; a null timestamp gives <c>BadInvalidTimestamp</c>.
/// </returns>
Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode,
IReadOnlyList<HistorizationValue> values, CancellationToken ct);
/// <summary>Deletes raw samples in a time range.</summary>
/// <param name="tag">Historian tag name.</param>
/// <param name="startUtc">Range start (UTC).</param>
/// <param name="endUtc">Range end (UTC).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns><c>Good</c>, or <c>BadNoData</c> when the range held nothing.</returns>
Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct);
/// <summary>Deletes the samples at exact timestamps.</summary>
/// <param name="tag">Historian tag name.</param>
/// <param name="timesUtc">Timestamps to delete (UTC).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>One status code per time, in order: <c>Good</c> or <c>BadNoEntryExists</c>.</returns>
Task<IReadOnlyList<uint>> DeleteAtTimeAsync(string tag, IReadOnlyList<DateTime> timesUtc, CancellationToken ct);
}
@@ -2,16 +2,16 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// One numeric sample the continuous-historization recorder drains to the historian's SQL
/// live-value write path. Carries the minimal payload that path can ingest: an optional UTC
/// timestamp, the coerced numeric value, and an OPC-DA quality byte.
/// live-value write path: an optional UTC timestamp, the typed value, and the OPC UA status code
/// the sample was recorded with.
/// </summary>
/// <param name="TimestampUtc">
/// UTC source timestamp of the sample, or <c>null</c> to defer to the historian's server-stamped
/// current-time path (the SQL writer uses <c>SYSDATETIME()</c> when the timestamp is absent).
/// </param>
/// <param name="Value">The coerced numeric value (the SQL analog write path is numeric-only).</param>
/// <param name="Quality">OPC-DA-derived quality code carried to the historian (192 = Good).</param>
public readonly record struct HistorizationValue(DateTime? TimestampUtc, double Value, ushort Quality);
/// <param name="Value">The typed sample value.</param>
/// <param name="StatusCode">OPC UA status code of the sample (0 = Good).</param>
public readonly record struct HistorizationValue(DateTime? TimestampUtc, HistorianValue Value, uint StatusCode);
/// <summary>
/// Seam over the historian's live-value write path used by the continuous-historization recorder.
@@ -13,6 +13,13 @@ public interface IHistorizationOutbox : IDisposable
/// <summary>Lifetime count of entries dropped because an append would have exceeded capacity.</summary>
long DroppedCount { get; }
/// <summary>
/// Lifetime count of persisted records that could not be decoded and were skipped (never
/// thrown, so a corrupt record cannot jam the drain). Defaults to 0 for implementations that
/// cannot encounter unreadable records.
/// </summary>
long UnreadableCount => 0;
/// <summary>
/// Appends <paramref name="entry"/> to the tail of the durable buffer.
/// <para>
@@ -38,6 +38,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
/// </remarks>
public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncDisposable
{
private const uint LegacyGoodQuality = 192;
private readonly IHistorianGatewayClient _client;
private readonly ILogger<GatewayHistorianValueWriter> _logger;
@@ -70,10 +72,10 @@ public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncD
{
var live = new HistorianLiveValue
{
NumericValue = value.Value,
NumericValue = value.Value.Number,
// Documented-dead input on the WriteLiveValues SQL path (0.2.0 Contracts C-002 —
// server-managed, silently discarded). Populated deliberately; see the class <remarks>.
Quality = value.Quality,
Quality = LegacyGoodQuality,
};
if (value.TimestampUtc is { } timestampUtc)
@@ -17,6 +17,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
/// </remarks>
internal static class HistorizationOutboxEntrySerializer
{
private const ushort LegacyGoodQuality = 192;
/// <summary>Serializes <paramref name="entry"/> to a fixed-layout little-endian byte array.</summary>
/// <param name="entry">The outbox entry to serialize.</param>
/// <returns>The fixed-layout little-endian byte array.</returns>
@@ -33,9 +35,9 @@ internal static class HistorizationOutboxEntrySerializer
Encoding.UTF8.GetBytes(entry.Tag, span.Slice(20, tagLen));
int p = 20 + tagLen;
BinaryPrimitives.WriteDoubleLittleEndian(span.Slice(p, 8), entry.NumericValue);
BinaryPrimitives.WriteDoubleLittleEndian(span.Slice(p, 8), entry.Value.Number);
p += 8;
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(p, 2), entry.Quality);
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(p, 2), LegacyGoodQuality);
p += 2;
BinaryPrimitives.WriteInt64LittleEndian(span.Slice(p, 8), entry.TimestampUtc.ToBinary());
@@ -54,10 +56,11 @@ internal static class HistorizationOutboxEntrySerializer
int p = 20 + tagLen;
double value = BinaryPrimitives.ReadDoubleLittleEndian(span.Slice(p, 8));
p += 8;
ushort quality = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(p, 2));
// Legacy layout carries a DA quality that was always 192 (Good); the typed entry
// carries status 0 instead, so the field is skipped.
p += 2;
long timestamp = BinaryPrimitives.ReadInt64LittleEndian(span.Slice(p, 8));
return new HistorizationOutboxEntry(id, tag, value, quality, DateTime.FromBinary(timestamp));
return new HistorizationOutboxEntry(id, tag, HistorianValue.FromDouble(value), 0u, DateTime.FromBinary(timestamp));
}
}
@@ -524,7 +524,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
+ "resuming value capture.");
}
if (!TryCoerceToDouble(msg.Value, out double numeric))
if (!(HistorianValue.TryFromObject(msg.Value, out HistorianValue historianValue)
&& historianValue.TryGetDouble(out double numeric)))
{
// The SQL analog live-value path is numeric-only — drop non-coercible values (string/null/
// reference) and meter the drop. No value content is logged.
@@ -548,8 +549,8 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
entries.Add(new HistorizationOutboxEntry(
Guid.NewGuid(),
historianName,
numeric,
GoodQuality,
HistorianValue.FromDouble(numeric),
0u, // Good
timestampUtc));
}
}
@@ -689,7 +690,7 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
.Select(g => (
Key: g.Key,
Values: (IReadOnlyList<HistorizationValue>)g
.Select(e => new HistorizationValue(e.TimestampUtc, e.NumericValue, e.Quality))
.Select(e => new HistorizationValue(e.TimestampUtc, e.Value, e.StatusCode))
.ToList()))
.ToList();
@@ -879,24 +880,4 @@ public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
}
private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;
private static bool TryCoerceToDouble(object? value, out double result)
{
switch (value)
{
case double d: result = d; return true;
case float f: result = f; return true;
case int i: result = i; return true;
case long l: result = l; return true;
case short s: result = s; return true;
case ushort us: result = us; return true;
case uint ui: result = ui; return true;
case ulong ul: result = ul; return true;
case byte b: result = b; return true;
case sbyte sb: result = sb; return true;
case decimal dec: result = (double)dec; return true;
case bool bo: result = bo ? 1d : 0d; return true; // Boolean historizes as Int1 (1/0).
default: result = 0d; return false; // string / null / DateTime / reference -> dropped.
}
}
}
@@ -0,0 +1,42 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// <summary>
/// Builds the historian seams (read, live-value write, alarm write, provisioning, optional update)
/// for one historian backend. The Host selects a provider by <see cref="Name"/>.
/// </summary>
public interface IHistorianBackendProvider
{
/// <summary>Backend name, e.g. "Gateway" or "SqlServer".</summary>
string Name { get; }
/// <summary>The value kinds this backend can store; the recorder drops other kinds at capture.</summary>
IReadOnlySet<HistorianValueKind> SupportedKinds { get; }
/// <summary>Creates the HistoryRead data source.</summary>
/// <param name="sp">Service provider.</param>
/// <returns>The data source.</returns>
IHistorianDataSource CreateDataSource(IServiceProvider sp);
/// <summary>Creates the continuous-historization live-value writer.</summary>
/// <param name="sp">Service provider.</param>
/// <returns>The value writer.</returns>
IHistorianValueWriter CreateValueWriter(IServiceProvider sp);
/// <summary>Creates the alarm-history writer.</summary>
/// <param name="sp">Service provider.</param>
/// <returns>The alarm writer.</returns>
IAlarmHistorianWriter CreateAlarmWriter(IServiceProvider sp);
/// <summary>Creates the tag auto-provisioning hook.</summary>
/// <param name="sp">Service provider.</param>
/// <returns>The provisioning hook.</returns>
IHistorianProvisioning CreateProvisioning(IServiceProvider sp);
/// <summary>Creates the HistoryUpdate capability.</summary>
/// <param name="sp">Service provider.</param>
/// <returns>The updater, or <c>null</c> when the backend does not support HistoryUpdate.</returns>
IHistorianDataUpdater? CreateUpdater(IServiceProvider sp);
}
@@ -0,0 +1,114 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
public sealed class HistorianValueTests
{
[Theory]
[InlineData(1.5d, HistorianValueKind.Double)]
[InlineData(1.5f, HistorianValueKind.Double)]
[InlineData(7, HistorianValueKind.Int64)]
[InlineData((short)7, HistorianValueKind.Int64)]
[InlineData((byte)7, HistorianValueKind.Int64)]
[InlineData(7u, HistorianValueKind.Int64)]
[InlineData(7L, HistorianValueKind.Int64)]
[InlineData(true, HistorianValueKind.Boolean)]
[InlineData("x", HistorianValueKind.String)]
public void Maps_clr_values_to_kinds(object input, HistorianValueKind expected)
{
HistorianValue.TryFromObject(input, out var v).ShouldBeTrue();
v.Kind.ShouldBe(expected);
}
[Fact]
public void Decimal_maps_to_double()
{
HistorianValue.TryFromObject(2.5m, out var v).ShouldBeTrue();
v.Kind.ShouldBe(HistorianValueKind.Double);
v.Number.ShouldBe(2.5);
}
[Fact]
public void Ulong_above_long_max_maps_to_double()
{
HistorianValue.TryFromObject(ulong.MaxValue, out var v).ShouldBeTrue();
v.Kind.ShouldBe(HistorianValueKind.Double);
v.Number.ShouldBe((double)ulong.MaxValue);
}
[Fact]
public void Ulong_within_long_range_maps_to_int64()
{
HistorianValue.TryFromObject((ulong)5, out var v).ShouldBeTrue();
v.Kind.ShouldBe(HistorianValueKind.Int64);
v.Integer.ShouldBe(5);
HistorianValue.TryFromObject((ulong)long.MaxValue, out var edge).ShouldBeTrue();
edge.Kind.ShouldBe(HistorianValueKind.Int64);
}
[Fact]
public void Null_maps_to_empty()
{
HistorianValue.TryFromObject(null, out var v).ShouldBeTrue();
v.Kind.ShouldBe(HistorianValueKind.Empty);
v.ToObject().ShouldBeNull();
}
[Fact]
public void Arrays_and_unknown_objects_are_not_historizable()
{
HistorianValue.TryFromObject(new[] { 1, 2 }, out _).ShouldBeFalse();
HistorianValue.TryFromObject(new object(), out _).ShouldBeFalse();
}
[Fact]
public void Local_datetime_is_stored_as_utc_ticks()
{
var local = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Local);
HistorianValue.TryFromObject(local, out var v).ShouldBeTrue();
v.Kind.ShouldBe(HistorianValueKind.DateTime);
v.Integer.ShouldBe(local.ToUniversalTime().Ticks);
var back = (DateTime)v.ToObject()!;
back.Kind.ShouldBe(DateTimeKind.Utc);
back.Ticks.ShouldBe(local.ToUniversalTime().Ticks);
}
[Fact]
public void Boolean_carries_integer_and_number()
{
var v = HistorianValue.FromBoolean(true);
v.Integer.ShouldBe(1);
v.Number.ShouldBe(1d);
v.TryGetDouble(out var d).ShouldBeTrue();
d.ShouldBe(1d);
HistorianValue.FromBoolean(false).Integer.ShouldBe(0);
}
[Fact]
public void TryGetDouble_is_true_only_for_double_int64_boolean()
{
HistorianValue.FromDouble(1.5).TryGetDouble(out _).ShouldBeTrue();
HistorianValue.FromInt64(3).TryGetDouble(out var i).ShouldBeTrue();
i.ShouldBe(3d);
HistorianValue.FromBoolean(true).TryGetDouble(out _).ShouldBeTrue();
HistorianValue.FromString("a").TryGetDouble(out _).ShouldBeFalse();
HistorianValue.FromDateTime(DateTime.UtcNow).TryGetDouble(out _).ShouldBeFalse();
HistorianValue.Empty.TryGetDouble(out _).ShouldBeFalse();
}
[Fact]
public void ToObject_round_trips_each_kind()
{
HistorianValue.FromDouble(1.5).ToObject().ShouldBe(1.5);
HistorianValue.FromInt64(9).ToObject().ShouldBe(9L);
HistorianValue.FromBoolean(true).ToObject().ShouldBe(true);
HistorianValue.FromBoolean(false).ToObject().ShouldBe(false);
HistorianValue.FromString("a").ToObject().ShouldBe("a");
var utc = new DateTime(2026, 5, 6, 7, 8, 9, DateTimeKind.Utc);
var dt = (DateTime)HistorianValue.FromDateTime(utc).ToObject()!;
dt.ShouldBe(utc);
dt.Kind.ShouldBe(DateTimeKind.Utc);
HistorianValue.Empty.ToObject().ShouldBeNull();
}
}
@@ -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);
}
}
@@ -1467,7 +1467,7 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit
_calls++;
foreach (HistorizationValue v in values)
{
_written.Add(new WrittenValue(tag, v.Value, v.Quality, v.TimestampUtc));
_written.Add(new WrittenValue(tag, v.Value.Number, (ushort)v.StatusCode, v.TimestampUtc));
}
// A throwing tag overrides every ack rule. Thrown synchronously from inside the awaited