feat(historian): typed HistorianValue contracts, IHistorianDataUpdater, IHistorianBackendProvider (no behaviour change)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -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>
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
+7
-4
@@ -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);
|
||||
}
|
||||
+114
@@ -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();
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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();
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+8
-8
@@ -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));
|
||||
|
||||
+9
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user