Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 6 additions & 9 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -733,18 +733,15 @@ private void UpdateIedBrowserControlStateDetails()
}
else
{
var target = IedBrowserControlStateEvidence.ReadTargets(block, pageKind)
.FirstOrDefault(item => item.Reference.EndsWith(
"." + field, StringComparison.Ordinal));
if (target is not null &&
_iedBrowserReadFailureEvidence?.TryGet(device,
target.Reference, "", target.FunctionalConstraint,
out var failure) == true)
if (IedBrowserControlStateEvidence.TryReadFailure(
device, block, pageKind, field,
_iedBrowserReadFailureEvidence, device.InitialHydrationFailures,
out var failure, out var source, out var failedReference))
{
row.Value = "Unavailable";
row.ObservationSource = "MMS Read failed";
row.ObservationSource = source;
row.ObservationTooltip =
$"{target.Reference} [{target.FunctionalConstraint}] · {failure}";
$"{failedReference} [{block.FunctionalConstraint}] · {source} · {failure}";
}
else
{
Expand Down
36 changes: 36 additions & 0 deletions Models/IedBrowserControlStateEvidence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,42 @@ public static bool TryRead(
}
return true;
}
/// <summary>
/// Error-only status for exactly the verified SGCB / GoCB MMS leaf.
/// Manual Read's latest attempt takes precedence over initial hydration;
/// neither failure is allowed to claim a live value or new FC.
/// The consumer checks LiveValues success before calling this method.
/// </summary>
public static bool TryReadFailure(
Iec61850MonitorDevice device, LiveIedControlBlockModel block,
string pageKind, string field,
IedBrowserReadFailureEvidence? manual,
IedBrowserReadFailureEvidence? initial,
out string reason, out string source, out string exactReference)
{
reason = "";
source = "";
exactReference = "";
var target = ReadTargets(block, pageKind).FirstOrDefault(item =>
item.Reference.EndsWith("." + field, StringComparison.Ordinal));
if (target is null) return false;
exactReference = target.Reference;
if (manual?.TryGet(device, target.Reference, "",
target.FunctionalConstraint, out reason) == true)
{
source = "MMS manual Read failed";
return true;
}
if (initial?.TryGet(device, target.Reference, "",
target.FunctionalConstraint, out reason) == true)
{
source = "MMS initial Read failed";
return true;
}
reason = "";
return false;
}

/// <summary>
/// SGCB LActTm is a real relay value, but an all-zero wire UTC-Time
/// denotes an unset time rather than an actual group-change in 1970.
Expand Down
29 changes: 29 additions & 0 deletions Models/IedBrowserReadFailureEvidence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
/// conversion, retry or live state. Exact reference/FC, same IED object,
/// association and projection epoch are required on every UI lookup.
/// </summary>
public sealed record IedBrowserReadFailureSample(
string Reference, string FunctionalConstraint, string Reason);

public sealed class IedBrowserReadFailureEvidence
{
private const int MaxFailureEntries = 4096;
Expand Down Expand Up @@ -68,6 +71,32 @@
_failed.Remove(Key(target.Reference, target.FunctionalConstraint));
}

/// <summary>
/// Bounded immutable sample of error-only evidence from the current IED,
/// association and verified model/epoch. No process values or MMS IO.
/// </summary>
public IReadOnlyList<IedBrowserReadFailureSample> SnapshotCurrent(
Iec61850MonitorDevice device, int maxEntries = 12)
{
if (maxEntries <= 0 || !IsCurrent(device))
return Array.Empty<IedBrowserReadFailureSample>();
lock (_gate)
{
if (!IsCurrent(device))
return Array.Empty<IedBrowserReadFailureSample>();
return _failed.OrderBy(item => item.Key, StringComparer.Ordinal)
.Take(Math.Min(maxEntries, 32))
.Select(item =>
{
var separator = item.Key.LastIndexOf('|');
return new IedBrowserReadFailureSample(
item.Key[..separator], item.Key[(separator + 1)..],
item.Value);
})
.ToArray();
}
}

public bool TryGet(Iec61850MonitorDevice device,
string reference, string fallbackReference, string fc, out string reason)
{
Expand All @@ -80,10 +109,10 @@
{
if (!IsCurrent(device)) return false;
if (!string.IsNullOrWhiteSpace(reference) &&
_failed.TryGetValue(Key(reference, fc), out reason))

Check warning on line 112 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 112 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 112 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 112 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 112 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 112 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.
return true;
return !string.IsNullOrWhiteSpace(fallbackReference) &&
_failed.TryGetValue(Key(fallbackReference, fc), out reason);

Check warning on line 115 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 115 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 115 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 115 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 115 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.

Check warning on line 115 in Models/IedBrowserReadFailureEvidence.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference assignment.
}
}
}
12 changes: 12 additions & 0 deletions Services/DiagnosticReportBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ public static async Task<string> BuildAsync(
}
builder.AppendLine($"Connected : {device.IsConnected}");
builder.AppendLine($"Monitoring : {device.IsMonitoring}");
// Diagnostic projection only: no MMS reads, new caches, or
// synthetic values. Samples are scoped to the active IED.
builder.AppendLine($"Browser ledger : association={device.LiveValues.Association}, version={device.LiveValues.Version}, offline={device.LiveValues.IsOffline}");
var hydrationFailures = device.InitialHydrationFailures?
.SnapshotCurrent(device, maxEntries: 12) ??
Array.Empty<IedBrowserReadFailureSample>();
builder.AppendLine($"Initial MMS errors (sample max 12): {hydrationFailures.Count}");
foreach (var evidence in hydrationFailures)
{
var detail = evidence.Reason.Replace('\r', ' ').Replace('\n', ' ');
builder.AppendLine($" {evidence.Reference} [{evidence.FunctionalConstraint}] : {detail}");
}
builder.AppendLine($"Busy : {device.IsBusy}");
builder.AppendLine($"Status : {device.Status}");
builder.AppendLine($"Detail : {device.Detail}");
Expand Down
141 changes: 141 additions & 0 deletions tests/ARSAS.Tests/M217DControlReadFailureDiagnosticsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;

namespace ARSAS.Tests;

public sealed class M217DControlReadFailureDiagnosticsTests
{
private static LiveIedControlBlockModel Block(string kind = "SettingGroupControl",
string fc = "SP", string name = "SGCB", string status = "AttributeInventoryOnly")
=> new()
{
Domain = "LD", LogicalNode = "LLN0", Name = name,
Kind = kind, FunctionalConstraint = fc,
DiscoveryStatus = status,
Reference = $"LD/LLN0.{fc}.{name}",
Attributes = kind == "SettingGroupControl"
? ["NumOfSG", "ActSG", "LActTm", "EditSG"]
: ["GoEna", "NdsCom"]
};

private static (Iec61850MonitorDevice Device, IedBrowserReadFailureEvidence Initial)
Connected()
{
var model = new LiveIedModelDiscoveryDocument
{
LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }]
};
var device = new Iec61850MonitorDevice
{
LiveDiscoveryModel = model, IsConnected = true
};
return (device, new IedBrowserReadFailureEvidence(
device, bindToLiveModel: true));
}

[Fact]
public void VerifiedSgcbInitialReadErrorIsSurfacedWithoutInventingValue()
{
var (device, initial) = Connected();
var block = Block();
initial.RecordFailure(new IedBrowserReadTarget(
"LD/LLN0$SP$SGCB$ActSG", "SP", "UInt32"), "data-access-error: object-non-existent");

Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device,
block, "SGCB", "ActSG", null, initial,
out var reason, out var origin, out var reference));
Assert.Equal("MMS initial Read failed", origin);
Assert.Equal("LD/LLN0.SGCB.ActSG", reference);
Assert.Contains("object-non-existent", reason);
Assert.False(device.LiveValues.TryGet(reference, "SP", out _));
Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device,
block, "SGCB", "EditSG", null, initial, out _, out _, out _));
Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device,
block, "SGCB", "ActSG", null, initial, out _, out _, out _)
&& device.LiveValues.IsOffline);
}

[Fact]
public void ManualFailureTakesPrecedenceThenFallsBackAfterCatalogReplacement()
{
var (device, initial) = Connected();
var block = Block();
var target = new IedBrowserReadTarget(
"LD/LLN0.SGCB.NumOfSG", "SP", "UInt32");
initial.RecordFailure(target, "initial denied");
var manual = new IedBrowserReadFailureEvidence(device);
manual.RecordFailure(target, "manual timeout");
Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device,
block, "SGCB", "NumOfSG", manual, initial,
out var reason, out var origin, out _));
Assert.Equal("manual timeout", reason);
Assert.Equal("MMS manual Read failed", origin);
device.Signals.ReplaceAll([new SignalDefinition
{
ObjectReference = "LD/LLN0.Mod.stVal", FunctionalConstraint = "ST"
}]);
Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device,
block, "SGCB", "NumOfSG", manual, initial,
out reason, out origin, out _));
Assert.Equal("initial denied", reason);
Assert.Equal("MMS initial Read failed", origin);
}

[Fact]
public void GoCbReadFailureIsNotGooseFrameEvidenceOrAnSclOnlyAssumption()
{
var (device, initial) = Connected();
var block = Block(kind: "GSEControl", fc: "GO", name: "BCU");
initial.RecordFailure(new IedBrowserReadTarget(
"LD/LLN0.BCU.GoEna", "GO", "Boolean"), "MMS reject");
Assert.True(IedBrowserControlStateEvidence.TryReadFailure(device,
block, "GoCB", "GoEna", null, initial,
out _, out var source, out _));
Assert.Equal("MMS initial Read failed", source);
Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device,
block, "GoCB", "NdsCom", null, initial, out _, out _, out _));
Assert.False(IedBrowserControlStateEvidence.TryReadFailure(device,
Block(kind: "GSEControl", fc: "GO", name: "BCU", status: "SCLOnly"),
"GoCB", "GoEna", null, initial, out _, out _, out _));
}

[Fact]
public void FailureDiagnosticSampleIsBoundedSortedAndOnlyCurrentAssociation()
{
var (device, initial) = Connected();
for (var i = 40; i >= 1; i--)
initial.RecordFailure(new IedBrowserReadTarget(
$"LD/LLN0.Mod{i:000}.stVal", "ST", "Enum"),
"read error " + i);
var sample = initial.SnapshotCurrent(device, maxEntries: 200);
Assert.Equal(32, sample.Count);
Assert.Equal("LD/LLN0.Mod001.stVal", sample[0].Reference);
Assert.All(sample, s => Assert.Equal("ST", s.FunctionalConstraint));
Assert.Empty(initial.SnapshotCurrent(device, maxEntries: 0));
var (other, _) = Connected();
Assert.Empty(initial.SnapshotCurrent(other));
device.IsConnected = false;
Assert.Empty(initial.SnapshotCurrent(device));
device.IsConnected = true;
Assert.Empty(initial.SnapshotCurrent(device));
}

[Fact]
public void DiagnosticsUseExistingFailureLedgerWithoutNetworkReadOrShadowCache()
{
DirectoryInfo? folder = new(AppContext.BaseDirectory);
while (folder is not null &&
!File.Exists(Path.Combine(folder.FullName, "Services", "DiagnosticReportBuilder.cs")))
folder = folder.Parent;
Assert.NotNull(folder);
var report = File.ReadAllText(Path.Combine(folder!.FullName,
"Services", "DiagnosticReportBuilder.cs"));
var browser = File.ReadAllText(Path.Combine(folder.FullName,
"MainWindow.IedBrowser.cs"));
Assert.Contains("SnapshotCurrent(device, maxEntries: 12)", report);
Assert.Contains("Browser ledger : association=", report);
Assert.Contains("Initial MMS errors (sample max 12)", report);
Assert.Contains("device.InitialHydrationFailures,", browser);
Assert.Contains("IedBrowserControlStateEvidence.TryReadFailure(", browser);
}
}
Loading