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);
}