Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
02a6242
feat(p8): add defensive telemetry envelope
masarray Sep 11, 2026
a939b51
feat(p8): normalize read values without false timestamps
masarray Sep 11, 2026
d76f8bb
feat(p8): add lossless-safe UI latest-value batcher
masarray Sep 11, 2026
27687fe
feat(p8): add pooled transient byte-buffer lease
masarray Sep 11, 2026
621fe1d
feat(p8): add low-overhead runtime allocation metrics
masarray Sep 11, 2026
dd61712
test(p8): cover defensive telemetry normalization
masarray Sep 11, 2026
013dd0a
test(p8): prove latest-value UI batching without edge loss contract
masarray Sep 11, 2026
f4edd70
test(p8): cover pooled buffers and allocation snapshots
masarray Sep 11, 2026
883d528
test(p8): lock runtime isolation batching virtualization and disposal…
masarray Sep 11, 2026
78c5d00
docs(p8): document runtime performance and resilience invariants
masarray Sep 11, 2026
196b8a8
fix(p8): keep empty placeholder outside process-value semantics
masarray Sep 11, 2026
4c8fd83
test(p8): keep relay-time regression independent of constructor syntax
masarray Sep 11, 2026
663b5f5
refactor(p8): remove unused UI batcher experiment
masarray Sep 11, 2026
2cfa635
refactor(p8): rely on production UI batching regression
masarray Sep 11, 2026
31664ba
refactor(p8): remove unproven buffer pooling surface
masarray Sep 11, 2026
5937399
test(p8): keep allocation profiling deterministic
masarray Sep 11, 2026
f825497
test(p8): avoid flaky GC scheduling assertions
masarray Sep 11, 2026
c4200c8
docs(p8): close audit with production-only hardening scope
masarray Sep 11, 2026
7b4555b
test(p8): guard production authorities and reject speculative pooling
masarray Sep 11, 2026
7f3e1ba
fix(p8): never promote unknown quality to Good
masarray Sep 11, 2026
b6fb3c7
test(p8): cover conservative quality and UTC timestamp semantics
masarray Sep 11, 2026
1752046
test(p8): align relay-time guard with final telemetry contract
masarray Sep 11, 2026
179cf38
fix(p8): clamp reversed allocation snapshot intervals
masarray Sep 11, 2026
fd4f340
test(p8): cover defensive allocation snapshot ordering
masarray Sep 11, 2026
de39f19
fix(p8): distinguish Good validity from questionable usability
masarray Sep 11, 2026
272909f
test(p8): require explicit Good before telemetry is valid
masarray Sep 11, 2026
90eb106
docs(p8): align quality validity semantics with defensive envelope
masarray Sep 11, 2026
041609b
test(p8): lock virtualization on production live grids too
masarray Sep 11, 2026
569ff1a
fix(p8): preserve read receipt timestamp during normalization
masarray Sep 11, 2026
35ade62
test(p8): preserve stored receipt time by default
masarray Sep 11, 2026
ffea9f4
fix(p8): unify missing-value detection across telemetry envelope
masarray Sep 11, 2026
c4445f6
test(p8): cover whitespace missing-value sentinel
masarray Sep 11, 2026
a4f7f95
fix(p8): reject incomplete source timestamps
masarray Sep 11, 2026
f3c025f
fix(p8): use monotonic allocation timing and honest heap metrics
masarray Sep 11, 2026
b36d723
test(p8): reject incomplete relay timestamp evidence
masarray Sep 11, 2026
0ed5ddf
test(p8): lock monotonic allocation timing and heap semantics
masarray Sep 11, 2026
8ad2bab
docs(p8): close second review findings
masarray Sep 11, 2026
de7e228
test(p8): make telemetry architecture guard semantic not comment-based
masarray Sep 11, 2026
cadf024
chore(p8): stage production telemetry patch script
masarray Sep 11, 2026
d6a9849
chore(p8): run production telemetry integration patch
masarray Sep 11, 2026
ccec5f0
fix(p8): wire defensive telemetry into production runtime
github-actions[bot] Sep 11, 2026
a3479e9
docs(p8): lock final IED field verification gate
masarray Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1190,12 +1190,17 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT
? BuildPlanAcquisitionLabel(plan, plan.Status.Contains("Dynamic", StringComparison.OrdinalIgnoreCase))
: state.AcquisitionLabel;

var reportQuality = update.HasQuality && IsUsefulProcessField(update.Quality)
? NormalizeQuality(update.Quality)
: state.HasValue ? state.Quality : "Pending / q not supplied";
var reportTimestamp = update.HasTimestamp && IsUsefulProcessField(update.Timestamp)
? update.Timestamp
: state.HasValue ? state.DeviceTimestamp : "-";
var reportEnvelope = Iec61850ProductionTelemetryNormalizer.FromComponents(
update.HasValue ? update.Value : null,
update.HasValue ? display : "-",
update.HasQuality && IsUsefulProcessField(update.Quality) ? update.Quality : null,
update.HasTimestamp && IsUsefulProcessField(update.Timestamp) ? update.Timestamp : null,
new DateTimeOffset(DateTime.SpecifyKind(receivedUtc, DateTimeKind.Utc)),
update.Reference);
var reportQuality = reportEnvelope.QualityText;
var reportTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(
reportEnvelope,
update.Timestamp);

ApplyValueUpdate(
session,
Expand All @@ -1214,7 +1219,7 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT
? string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report verified" : $"Live / report verified ({update.ProjectionStatus})"
: string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report traffic + MMS verification" : $"Live / report traffic + MMS verification ({update.ProjectionStatus})",
trustReportEdge: true,
hasProcessValue: update.HasValue);
hasProcessValue: reportEnvelope.HasProcessValue);
}
}

Expand Down Expand Up @@ -1430,8 +1435,11 @@ private async Task PollDuePointsAsync(DeviceSession session, CancellationToken c
var rich = resolved.Value as Iec61850ReadValue;
var raw = Iec61850ReadValue.Unwrap(resolved.Value);
var display = Iec61850ValueFormatter.Format(raw, point.IecDataType, point.Unit);
var quality = rich?.HasQuality == true ? rich.Quality : state.Quality;
var deviceTimestamp = rich?.HasDeviceTimestamp == true ? rich.DeviceTimestamp : state.DeviceTimestamp;
// Never carry forward stale Good/q or relay time when the current network read
// did not actually supply them. Companion reads may enrich this sample, but if
// they fail the defensive envelope below keeps quality Unknown and source time '-'.
var quality = rich?.HasQuality == true ? rich.Quality : string.Empty;
var deviceTimestamp = rich?.HasDeviceTimestamp == true ? rich.DeviceTimestamp : string.Empty;

if ((rich?.HasQuality != true || rich?.HasDeviceTimestamp != true) &&
nowUtc >= session.RecoveryWarmupUntilUtc &&
Expand All @@ -1449,8 +1457,19 @@ private async Task PollDuePointsAsync(DeviceSession session, CancellationToken c
deviceTimestamp = companions.DeviceTimestamp;
}

var normalizedQuality = NormalizeQuality(quality);
var normalizedTimestamp = string.IsNullOrWhiteSpace(deviceTimestamp) ? "-" : deviceTimestamp;
var receivedAtUtc = rich?.ReceivedAtUtc ?? DateTimeOffset.UtcNow;
var envelope = Iec61850ProductionTelemetryNormalizer.FromComponents(
raw,
display,
quality,
deviceTimestamp,
receivedAtUtc,
point.IecReference,
resolved.EffectiveReference);
var normalizedQuality = envelope.QualityText;
var normalizedTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(
envelope,
deviceTimestamp);
if (reportAssigned && state.AwaitingCommandReportEdge &&
nowUtc >= state.CommandReportDeadlineUtc && !state.CommandReportMissLogged)
{
Expand Down Expand Up @@ -1514,7 +1533,8 @@ private async Task PollDuePointsAsync(DeviceSession session, CancellationToken c
reason,
DateTime.UtcNow,
status,
trustReportEdge: false);
trustReportEdge: false,
hasProcessValue: envelope.HasProcessValue);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Expand Down
57 changes: 57 additions & 0 deletions Services/Iec61850ProductionTelemetryNormalizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
namespace ArIED61850Tester.Services;

/// <summary>
/// Single production boundary between decoded IEC 61850 network data and UI/runtime state.
/// It preserves a valid source timestamp, never fabricates PC time as relay evidence, and
/// never promotes missing/unknown quality to Good.
/// </summary>
public static class Iec61850ProductionTelemetryNormalizer
{
public static Iec61850TelemetryEnvelope FromReadObject(
object? value,
string dataType,
string unit,
DateTimeOffset? receivedAtUtc = null,
string? sourceReference = null,
string? readReference = null)
{
if (value is Iec61850ReadValue rich)
return Iec61850TelemetryEnvelope.FromReadValue(rich, receivedAtUtc);

var display = Iec61850ValueFormatter.Format(value, dataType, unit);
return FromComponents(
value,
display,
quality: null,
deviceTimestamp: null,
receivedAtUtc ?? DateTimeOffset.UtcNow,
sourceReference,
readReference);
}

public static Iec61850TelemetryEnvelope FromComponents(
object? value,
string? displayValue,
string? quality,
string? deviceTimestamp,
DateTimeOffset receivedAtUtc,
string? sourceReference = null,
string? readReference = null)
=> Iec61850TelemetryEnvelope.FromReadValue(new Iec61850ReadValue
{
Value = value,
DisplayValue = displayValue?.Trim() ?? string.Empty,
Quality = quality?.Trim() ?? string.Empty,
DeviceTimestamp = deviceTimestamp?.Trim() ?? string.Empty,
SourceReference = sourceReference?.Trim() ?? string.Empty,
ReadReference = readReference?.Trim() ?? string.Empty,
ReceivedAtUtc = receivedAtUtc
}, receivedAtUtc);

public static string SourceTimestampTextOrUnknown(
Iec61850TelemetryEnvelope envelope,
string? originalTimestamp)
=> envelope.SourceTimestampUtc.HasValue
? originalTimestamp?.Trim() ?? "-"
: "-";
}
11 changes: 11 additions & 0 deletions Services/Iec61850ReadValue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,19 @@ public sealed class Iec61850ReadValue
public string ReadReference { get; init; } = string.Empty;
public string Projection { get; init; } = string.Empty;

/// <summary>
/// Local receipt time. This is deliberately separate from DeviceTimestamp: an absent or
/// malformed relay timestamp must never be replaced with the PC clock and presented as
/// source evidence.
/// </summary>
public DateTimeOffset ReceivedAtUtc { get; init; } = DateTimeOffset.UtcNow;

public bool HasQuality => !string.IsNullOrWhiteSpace(Quality) && Quality != "-";
public bool HasDeviceTimestamp => !string.IsNullOrWhiteSpace(DeviceTimestamp) && DeviceTimestamp != "-";
public DateTimeOffset? SourceTimestampUtc => Iec61850TelemetryEnvelope.TryParseSourceTimestampUtc(DeviceTimestamp);

public Iec61850TelemetryEnvelope ToTelemetryEnvelope()
=> Iec61850TelemetryEnvelope.FromReadValue(this, ReceivedAtUtc);
Comment thread
masarray marked this conversation as resolved.

public override string ToString()
{
Expand Down
167 changes: 167 additions & 0 deletions Services/Iec61850TelemetryEnvelope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using System.Globalization;
using System.Text.RegularExpressions;

namespace ArIED61850Tester.Services;

/// <summary>
/// Normalized IEC 61850 telemetry boundary used between network decoding and application logic.
/// Missing/ambiguous data is never promoted to a valid process value: the source timestamp
/// stays unknown and quality is preserved conservatively while ReceivedAtUtc records local receipt.
/// </summary>
public enum Iec61850TelemetryQualityState
{
Good,
Questionable,
Invalid
}

public readonly record struct Iec61850TelemetryEnvelope(
object? Value,
string DisplayValue,
Iec61850TelemetryQualityState QualityState,
string QualityText,
DateTimeOffset? SourceTimestampUtc,
DateTimeOffset ReceivedAtUtc,
string SourceReference,
string Diagnostic)
{
private static readonly Regex CompleteSourceTimestampPattern = new(
@"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|\s?[+-]\d{2}:\d{2})?$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);

public bool HasProcessValue => IsProcessValuePresent(Value, DisplayValue);

/// <summary>
/// True only when both the process value and IEC quality are explicitly Good.
/// Questionable data may still be presented to an engineer, but it is not promoted
/// to valid evidence by this boundary.
/// </summary>
public bool IsValid => HasProcessValue && QualityState == Iec61850TelemetryQualityState.Good;

public bool IsUsable => HasProcessValue && QualityState != Iec61850TelemetryQualityState.Invalid;

public static Iec61850TelemetryEnvelope FromReadValue(
Iec61850ReadValue? read,
DateTimeOffset? receivedAtUtc = null)
{
// Preserve the timestamp captured when the read projection was created. Normalization
// can happen later after queuing/batching and must not move local receipt evidence
// forward to the conversion time. UtcNow is only a last resort for a null read.
var received = receivedAtUtc ?? read?.ReceivedAtUtc ?? DateTimeOffset.UtcNow;
if (read is null)
{
return Invalid(
received,
sourceReference: string.Empty,
diagnostic: "IEC 61850 read returned no value object.");
}

var display = read.DisplayValue?.Trim() ?? string.Empty;
var hasValue = IsProcessValuePresent(read.Value, display);
var qualityText = NormalizeQualityText(read.Quality);
var qualityState = ClassifyQuality(qualityText, hasValue);
var sourceTimestamp = TryParseSourceTimestampUtc(read.DeviceTimestamp);
var sourceReference = FirstNonEmpty(read.SourceReference, read.ReadReference);

var diagnostic = qualityState == Iec61850TelemetryQualityState.Invalid
? hasValue
? $"Telemetry quality is invalid ({qualityText})."
: "Telemetry contains no process value."
: qualityState == Iec61850TelemetryQualityState.Questionable
? $"Telemetry quality is not proven Good ({qualityText})."
: sourceTimestamp is null && read.HasDeviceTimestamp
? "Device timestamp was present but could not be parsed safely; source timestamp remains unknown."
: string.Empty;

return new Iec61850TelemetryEnvelope(
read.Value,
display,
qualityState,
qualityText,
sourceTimestamp,
received,
sourceReference,
diagnostic);
}

public static Iec61850TelemetryEnvelope Invalid(
DateTimeOffset receivedAtUtc,
string sourceReference,
string diagnostic,
object? safePlaceholder = null)
=> new(
safePlaceholder,
safePlaceholder?.ToString() ?? "-",
Iec61850TelemetryQualityState.Invalid,
"Invalid",
null,
receivedAtUtc,
sourceReference?.Trim() ?? string.Empty,
diagnostic?.Trim() ?? string.Empty);

internal static DateTimeOffset? TryParseSourceTimestampUtc(string? value)
{
var text = value?.Trim() ?? string.Empty;
if (text.Length == 0 || text == "-")
return null;

// DateTimeOffset.TryParse accepts partial values such as "10:00:31" and fills the
// missing date from the local PC. That would fabricate source evidence. Accept only
// complete ARIEC/ISO date-time shapes before parsing. A zone-less decoded IEC UtcTime
// is semantically UTC; malformed or incomplete input remains unknown.
if (!CompleteSourceTimestampPattern.IsMatch(text) ||
!DateTimeOffset.TryParse(
text,
CultureInfo.InvariantCulture,
DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return null;
}

return parsed.ToUniversalTime();
}

private static bool IsProcessValuePresent(object? value, string? displayValue)
{
if (value is not null)
return true;

var display = displayValue?.Trim() ?? string.Empty;
return display.Length > 0 && display != "-";
}

private static Iec61850TelemetryQualityState ClassifyQuality(string quality, bool hasValue)
{
if (!hasValue)
return Iec61850TelemetryQualityState.Invalid;

// Never infer Good from an unknown/vendor token. IEC validity is only considered
// Good when the decoder explicitly said Good. This prevents missing or future
// quality representations from being silently promoted to trustworthy evidence.
if (quality.Equals("Good", StringComparison.OrdinalIgnoreCase))
return Iec61850TelemetryQualityState.Good;

if (quality.Contains("invalid", StringComparison.OrdinalIgnoreCase) ||
quality.Contains("failure", StringComparison.OrdinalIgnoreCase) ||
quality.Contains("bad", StringComparison.OrdinalIgnoreCase) ||
quality.Contains("reserved", StringComparison.OrdinalIgnoreCase) ||
quality.Contains("outofrange", StringComparison.OrdinalIgnoreCase) ||
quality.Contains("out-of-range", StringComparison.OrdinalIgnoreCase))
{
return Iec61850TelemetryQualityState.Invalid;
}

return Iec61850TelemetryQualityState.Questionable;
}

private static string NormalizeQualityText(string? value)
{
var text = value?.Trim() ?? string.Empty;
return text.Length == 0 ? "Unknown" : text;
}

private static string FirstNonEmpty(params string?[] values)
=> values.Select(value => value?.Trim() ?? string.Empty)
.FirstOrDefault(value => value.Length > 0) ?? string.Empty;
}
31 changes: 19 additions & 12 deletions Services/NativeIec61850Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2356,18 +2356,25 @@ private static string GetEngineeringUnitOwner(string reference)

private static void ApplyDiscoveryReadValue(SignalDefinition signal, object value)
{
if (value is Iec61850ReadValue rich)
{
signal.Value = Iec61850ValueFormatter.Format(rich.Value ?? rich.ToString(), signal.DataType, signal.Unit);
signal.Quality = rich.HasQuality ? rich.Quality : "Good";
signal.DeviceTimestamp = rich.HasDeviceTimestamp ? rich.DeviceTimestamp : "-";
}
else
{
signal.Value = Iec61850ValueFormatter.Format(value, signal.DataType, signal.Unit);
signal.Quality = "Good";
}
signal.ProbeStatus = "Readable";
var receivedAtUtc = value is Iec61850ReadValue rich
? rich.ReceivedAtUtc
: DateTimeOffset.UtcNow;
var envelope = Iec61850ProductionTelemetryNormalizer.FromReadObject(
value,
signal.DataType,
signal.Unit,
receivedAtUtc,
signal.ObjectReference,
value is Iec61850ReadValue read ? read.ReadReference : signal.ObjectReference);

signal.Value = envelope.HasProcessValue
? Iec61850ValueFormatter.Format(envelope.Value ?? envelope.DisplayValue, signal.DataType, signal.Unit)
: "-";
signal.Quality = envelope.QualityText;
signal.DeviceTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(
envelope,
value is Iec61850ReadValue sourceRead ? sourceRead.DeviceTimestamp : null);
signal.ProbeStatus = envelope.HasProcessValue ? "Readable" : "Readable / no process value";
signal.Timestamp = DateTime.Now;
}

Expand Down
Loading
Loading