Skip to content
Merged
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
31 changes: 31 additions & 0 deletions MainWindow.IoTesting.AutoConnect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@ void ReportProgress(string message)
ReportProgress($"{ied.IedName} association ready · reusing the loaded model");
}

// Never let a runtime anchor from an earlier model silently decide a fresh
// FAT preparation. Re-prove every requested row against the current model;
// successful smart matches are anchored again immediately below.
foreach (var point in requestedPoints)
{
point.ApplyLiveBinding(
IoTestLiveBindingState.NotEvaluated,
"Revalidating the imported FAT reference against the current IED model.",
device.DeviceId);
}

ReportProgress($"Matching {requestedPoints.Count} workbook signal(s)");
var selection = _ioTestSignalSelectionService.Resolve(ied, device);
if (!selection.Succeeded && selection.CanRetryWithFreshDiscovery)
Expand All @@ -150,6 +161,13 @@ void ReportProgress(string message)
}

usedSavedModel = false;
foreach (var point in requestedPoints)
{
point.ApplyLiveBinding(
IoTestLiveBindingState.NotEvaluated,
"Revalidating after fresh live-model discovery.",
device.DeviceId);
}
selection = _ioTestSignalSelectionService.Resolve(ied, device);
}

Expand All @@ -163,6 +181,19 @@ void ReportProgress(string message)
var selectionChanged = false;
foreach (var match in selection.Matches)
{
// Preserve the unique reference proven by the preparation pass. This is
// essential for legacy weak rows such as `.Op.general`: the live-binding
// phase must follow the proven exact model object rather than re-guess it.
match.TestPoint.ApplyLiveBinding(
match.UsedNormalizedIedPrefix
? IoTestLiveBindingState.BoundNormalized
: IoTestLiveBindingState.BoundExact,
match.UsedNormalizedIedPrefix
? "FAT preparation resolved one unique canonical IEC 61850 model reference."
: "FAT preparation matched the exact imported IEC 61850 model reference.",
device.DeviceId,
match.Signal.ObjectReference);

if (match.Signal.IsSelected)
continue;
match.Signal.IsSelected = true;
Expand Down
12 changes: 10 additions & 2 deletions Services/IoTesting/IoTestLiveBindingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi
{
return new PointBinding(
IoTestLiveBindingState.LivePointReady,
"Exact imported IEC 61850 reference is already active in the live monitor.",
"Exact imported or prepared IEC 61850 reference is already active in the live monitor.",
exactLivePoints[0].IecReference,
exactLivePoints[0]);
}
Expand All @@ -124,7 +124,7 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi
{
return new PointBinding(
IoTestLiveBindingState.BoundExact,
"Exact imported IEC 61850 reference is present in the discovered IED model.",
"Exact imported or prepared IEC 61850 reference is present in the discovered IED model.",
exactSignals[0].ObjectReference,
null);
}
Expand Down Expand Up @@ -224,6 +224,14 @@ void Add(string? value)
Add(point.SourceIecReference);
Add(point.ReportDisplayReference);

// During FAT preparation the signal-selection pass may prove one unique live
// model reference from otherwise incomplete source metadata (for example a
// legacy 7SX80 ANSI-27 row). Keep that exact prepared reference authoritative
// for subsequent model/live-point binding. It is transient runtime state and is
// cleared automatically whenever ApplyLiveBinding reports a non-bound result.
if (point.IsLiveBound)
Add(point.LiveSignalReference);

var eventReference = !string.IsNullOrWhiteSpace(point.EventLogSearchReference)
? point.EventLogSearchReference.Trim()
: point.SourceIecReference?.Trim() ?? string.Empty;
Expand Down
91 changes: 83 additions & 8 deletions Services/IoTesting/IoTestSignalSelectionService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using ArIED61850Tester.Models;
using ArIED61850Tester.Models.IoTesting;

Expand All @@ -22,9 +23,15 @@ public sealed record IoTestSignalSelectionResult(
/// Resolves the enabled IO-list scope against one discovered IED model without
/// guessing. Exact references remain highest priority. Canonical IEC 61850 forms
/// accept vendor-safe spelling differences only when the best candidate is unique.
/// Weak source rows are resolved only after stronger references have claimed their
/// signals, allowing deterministic sibling evidence without fuzzy text matching.
/// </summary>
public sealed class IoTestSignalSelectionService
{
private static readonly Regex ProtectionCodeRegex = new(
@"\((?<code>\d{2,3}[A-Z]{0,3})(?:\s*-\s*[^)]*)?\)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);

public IoTestSignalSelectionResult Resolve(
IoTestIedPlan ied,
Iec61850MonitorDevice device)
Expand All @@ -39,6 +46,7 @@ public IoTestSignalSelectionResult Resolve(
var missing = new List<IoTestPointPlan>();
var ambiguous = new List<IoTestPointPlan>();
var usedSignals = new HashSet<SignalDefinition>();
var unresolved = new List<CandidateSet>();

foreach (var point in requested)
{
Expand Down Expand Up @@ -70,19 +78,47 @@ public IoTestSignalSelectionResult Resolve(
.Where(item => item.Score == bestScore)
.Select(item => item.Signal)
.ToList();
unresolved.Add(new CandidateSet(point, bestScore, candidates));
}

if (candidates.Count != 1 || !usedSignals.Add(candidates[0]))
// Resolve the strongest references first. This makes the result independent of
// workbook row order and lets a weak legacy row use already-proven sibling
// assignments as elimination evidence. No candidate is ever selected by text
// similarity: it must still be the one unique best IEC object left.
var madeProgress = true;
while (madeProgress && unresolved.Count > 0)
{
madeProgress = false;
foreach (var candidateSet in unresolved
.OrderByDescending(item => item.BestScore)
.ThenBy(item => item.Candidates.Count)
.ToArray())
{
ambiguous.Add(point);
continue;
}
var candidates = candidateSet.Candidates
.Where(signal => !usedSignals.Contains(signal))
.ToList();

matches.Add(new IoTestSignalMatch(
point,
candidates[0],
bestScore < IoTestReferenceMatcher.ExactScore));
if (candidateSet.BestScore <= IoTestReferenceMatcher.PartialObjectScore && candidates.Count > 1)
candidates = NarrowByProtectionIdentity(candidateSet.Point, candidates);

if (candidates.Count != 1)
continue;

var signal = candidates[0];
if (!usedSignals.Add(signal))
continue;

matches.Add(new IoTestSignalMatch(
candidateSet.Point,
signal,
candidateSet.BestScore < IoTestReferenceMatcher.ExactScore));
unresolved.Remove(candidateSet);
madeProgress = true;
}
}

ambiguous.AddRange(unresolved.Select(item => item.Point));

if (missing.Count > 0 || ambiguous.Count > 0)
{
var details = new List<string>();
Expand All @@ -108,6 +144,41 @@ public IoTestSignalSelectionResult Resolve(
$"Resolved {matches.Count} enabled IO-list signal(s) to unique discovered model points.{smartText}");
}

private static List<SignalDefinition> NarrowByProtectionIdentity(
IoTestPointPlan point,
IReadOnlyCollection<SignalDefinition> candidates)
{
var match = ProtectionCodeRegex.Match(point.SignalName ?? string.Empty);
if (!match.Success)
return candidates.ToList();

// P0 field regression: a few legacy 7SX80 rows retained only `.Op.general`
// while the user-visible signal description still proved ANSI 27. Do not turn
// this into a generic fuzzy-name matcher. For ANSI 27 only, accept the explicit
// live IEC identity used by protection models (27Undervoltage / PTUV), and still
// require one unique candidate after stronger sibling references are claimed.
var code = match.Groups["code"].Value.ToUpperInvariant();
if (!code.Equals("27", StringComparison.Ordinal))
return candidates.ToList();

var narrowed = candidates
.Where(signal => IsProtection27Reference(signal.ObjectReference))
.ToList();
return narrowed.Count == 0 ? candidates.ToList() : narrowed;
}

private static bool IsProtection27Reference(string? reference)
{
var normalized = IoTestReferenceMatcher.NormalizeRaw(reference);
if (normalized.Contains("27undervoltage", StringComparison.OrdinalIgnoreCase))
return true;

return Regex.IsMatch(
normalized,
@"(?:^|[/_.])ptuv\d*(?:[/.]|$)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}

private static bool IsEligible(SignalDefinition signal, IoTestPointPlan point)
{
if (signal.IsControlSignal || string.IsNullOrWhiteSpace(signal.ObjectReference))
Expand All @@ -130,4 +201,8 @@ private static string Describe(IReadOnlyCollection<IoTestPointPlan> points)
}

private sealed record ScoredSignal(SignalDefinition Signal, int Score);
private sealed record CandidateSet(
IoTestPointPlan Point,
int BestScore,
IReadOnlyList<SignalDefinition> Candidates);
}
Loading
Loading