diff --git a/.github/workflows/validate-io-testing.yml b/.github/workflows/validate-io-testing.yml
new file mode 100644
index 000000000..94e3b147f
--- /dev/null
+++ b/.github/workflows/validate-io-testing.yml
@@ -0,0 +1,96 @@
+name: Validate IO List Testing
+
+on:
+ pull_request:
+ paths:
+ - "MainWindow.IoTesting.cs"
+ - "IoListTestingWindow.xaml"
+ - "IoListTestingWindow.xaml.cs"
+ - "Models/IoTesting/**"
+ - "Services/IoTesting/**"
+ - "tests/ARSAS.Tests/IoTest*"
+ - "tests/ARSAS.Tests/IoList*"
+ - "engines/ARIEC61850.lock.json"
+ - ".github/workflows/validate-io-testing.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ validate-io-testing:
+ name: Build and test IO List Testing
+ runs-on: windows-latest
+
+ steps:
+ - name: Checkout ARSAS application
+ uses: actions/checkout@v4
+ with:
+ path: ARSAS
+
+ - name: Resolve immutable ARIEC61850 engine lock
+ shell: powershell
+ run: |
+ $lock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json
+ if ($lock.repository -notmatch '^[^/]+/[^/]+$' -or $lock.commit -notmatch '^[0-9a-f]{40}$') {
+ throw "ARIEC61850 lock metadata is invalid."
+ }
+ "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+ "ARIEC61850_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+
+ - name: Checkout immutable ARIEC61850 engine revision
+ shell: powershell
+ run: |
+ git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850
+ git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT
+ git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT
+ $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim()
+ if ($actual -ne $env:ARIEC61850_COMMIT) {
+ throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual."
+ }
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Restore focused test project
+ run: dotnet restore .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj
+
+ - name: Build focused IO testing project
+ shell: powershell
+ run: |
+ $log = ".\ARSAS\io-testing-build.log"
+ dotnet build .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-restore *> $log
+ $buildExitCode = $LASTEXITCODE
+ if ($buildExitCode -ne 0) {
+ Write-Host "=== IO Testing compiler errors ===" -ForegroundColor Red
+ $errors = @(Select-String -Path $log -Pattern ': error ')
+ if ($errors.Count -eq 0) {
+ Get-Content $log -Tail 100 | ForEach-Object { Write-Host $_ -ForegroundColor Red }
+ }
+ else {
+ $errors | ForEach-Object { Write-Host $_.Line -ForegroundColor Red }
+ }
+ exit $buildExitCode
+ }
+ Get-Content $log -Tail 12
+
+ - name: Upload IO testing compiler diagnostics
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ARSAS-io-testing-compiler-log
+ path: ARSAS/io-testing-build.log
+ if-no-files-found: error
+
+ - name: Run IO testing regression tests
+ run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --filter "FullyQualifiedName~IoTest|FullyQualifiedName~IoList" --logger "trx;LogFileName=io-testing-tests.trx" --results-directory .\ARSAS\TestResults
+
+ - name: Upload IO testing test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ARSAS-io-testing-test-results
+ path: ARSAS/TestResults/*.trx
+ if-no-files-found: ignore
diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj
index e3efb5d96..9cc245280 100644
--- a/ArIED61850Tester.csproj
+++ b/ArIED61850Tester.csproj
@@ -59,4 +59,4 @@
-
\ No newline at end of file
+
diff --git a/IoListTestingWindow.xaml b/IoListTestingWindow.xaml
new file mode 100644
index 000000000..308ddea18
--- /dev/null
+++ b/IoListTestingWindow.xaml
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/IoListTestingWindow.xaml.cs b/IoListTestingWindow.xaml.cs
new file mode 100644
index 000000000..547fd687d
--- /dev/null
+++ b/IoListTestingWindow.xaml.cs
@@ -0,0 +1,66 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Windows;
+using ArIED61850Tester.Models.IoTesting;
+
+namespace ArIED61850Tester;
+
+public partial class IoListTestingWindow : Window, INotifyPropertyChanged
+{
+ private IoTestIedPlan? _selectedIed;
+
+ public IoListTestingWindow()
+ : this(CreateEmptyProject())
+ {
+ }
+
+ public IoListTestingWindow(IoTestProject project)
+ {
+ Project = project ?? throw new ArgumentNullException(nameof(project));
+ Project.InitializeRuntimeNotifications();
+ InitializeComponent();
+ DataContext = this;
+ SelectedIed = Project.Ieds.FirstOrDefault();
+ }
+
+ public IoTestProject Project { get; }
+
+ public IoTestIedPlan? SelectedIed
+ {
+ get => _selectedIed;
+ set
+ {
+ if (ReferenceEquals(_selectedIed, value))
+ return;
+ _selectedIed = value;
+ Raise();
+ Raise(nameof(SelectedIedSummary));
+ }
+ }
+
+ public string ProjectSummary =>
+ $"{Project.Ieds.Count} IED · {Project.SignalCount} SDI · {Project.ReadySignalCount} ready · {Project.LiveBoundSignalCount} live-bound";
+
+ public string SelectedIedSummary => SelectedIed == null
+ ? "Select an imported IED"
+ : $"{SelectedIed.IpAddress} · {SelectedIed.BoundCount}/{SelectedIed.TestPoints.Count} bound · {SelectedIed.LiveStatusText}";
+
+ public string WorkspaceStatus => Project.LiveBoundSignalCount > 0
+ ? $"{Project.LiveBoundSignalCount:N0} signal(s) already match the loaded ARSAS workspace. FAT execution remains locked until the session controller and evidence journal are enabled."
+ : "The IO test plan is imported. Load or discover an IED in the engineering workspace before starting live FAT execution.";
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void ReturnToEngineering_Click(object sender, RoutedEventArgs e)
+ => Close();
+
+ private void Raise([CallerMemberName] string? propertyName = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ private static IoTestProject CreateEmptyProject() => new()
+ {
+ ProjectId = "EMPTY",
+ SchemaVersion = "ARSAS-FAT-IO-1.0",
+ ProjectName = "No IO List project loaded"
+ };
+}
diff --git a/MainWindow.IoTesting.cs b/MainWindow.IoTesting.cs
new file mode 100644
index 000000000..5a91d5f86
--- /dev/null
+++ b/MainWindow.IoTesting.cs
@@ -0,0 +1,141 @@
+using System.IO;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services.IoTesting;
+using Microsoft.Win32;
+
+namespace ArIED61850Tester;
+
+public partial class MainWindow
+{
+ private readonly IoListExcelImportService _ioListExcelImportService = new();
+ private readonly IoTestLiveBindingService _ioTestLiveBindingService = new();
+ private Button? _ioListTestingLauncher;
+
+ protected override void OnInitialized(EventArgs e)
+ {
+ base.OnInitialized(e);
+ Dispatcher.BeginInvoke(new Action(InstallIoListTestingLauncher));
+ }
+
+ private void InstallIoListTestingLauncher()
+ {
+ if (_ioListTestingLauncher != null || Content is not Grid root)
+ return;
+
+ var header = root.Children
+ .OfType()
+ .FirstOrDefault(child => Grid.GetRow(child) == 0);
+ var actionPanel = header?.Children
+ .OfType()
+ .FirstOrDefault(panel => Grid.GetColumn(panel) == 2);
+ if (actionPanel == null)
+ return;
+
+ var button = new Button
+ {
+ Style = TryFindResource("PrimaryButton") as Style,
+ Padding = new Thickness(12, 7, 12, 7),
+ Margin = new Thickness(0, 0, 8, 0),
+ ToolTip = "Import an ARSAS IO List workbook and enter the dedicated FAT workspace"
+ };
+ var content = new StackPanel { Orientation = Orientation.Horizontal };
+ var icon = new System.Windows.Shapes.Path
+ {
+ Data = TryFindResource("LucideFileInput") as Geometry,
+ Style = TryFindResource("LucideIcon") as Style,
+ Stroke = Brushes.White
+ };
+ content.Children.Add(new Viewbox
+ {
+ Width = 14,
+ Height = 14,
+ Margin = new Thickness(0, 0, 6, 0),
+ Child = icon
+ });
+ content.Children.Add(new TextBlock
+ {
+ Text = "IO List Testing",
+ FontWeight = FontWeights.SemiBold
+ });
+ button.Content = content;
+ button.Click += OpenIoListTesting_Click;
+ actionPanel.Children.Insert(0, button);
+ _ioListTestingLauncher = button;
+ }
+
+ private async void OpenIoListTesting_Click(object sender, RoutedEventArgs e)
+ {
+ var dialog = new OpenFileDialog
+ {
+ Title = "Import ARSAS IO List FAT workbook",
+ Filter = "ARSAS IO List workbook (*.xlsx)|*.xlsx|Excel workbook (*.xlsx)|*.xlsx",
+ CheckFileExists = true,
+ Multiselect = false
+ };
+ if (dialog.ShowDialog(this) != true)
+ return;
+
+ SetStatus($"Importing IO List test plan from {Path.GetFileName(dialog.FileName)}…");
+ try
+ {
+ var import = await _ioListExcelImportService.ImportAsync(
+ dialog.FileName,
+ _applicationCancellation.Token);
+ var errors = import.AllFindings
+ .Where(finding => finding.Severity == IoTestImportFindingSeverity.Error)
+ .ToList();
+ if (!import.IsValid)
+ {
+ var details = string.Join(
+ Environment.NewLine,
+ errors.Take(12).Select(finding => $"• {finding.Code}: {finding.Message}"));
+ if (errors.Count > 12)
+ details += $"{Environment.NewLine}• …and {errors.Count - 12} more error(s).";
+
+ SetStatus("IO List import was rejected. The source workbook was not guessed or partially executed.");
+ MessageBox.Show(
+ this,
+ $"ARSAS could not import this IO List workbook safely.\n\n{details}",
+ "IO List import rejected",
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ return;
+ }
+
+ var binding = _ioTestLiveBindingService.Bind(import.Project, Devices);
+ var warnings = import.AllFindings.Count(finding =>
+ finding.Severity == IoTestImportFindingSeverity.Warning);
+ SetStatus(
+ $"IO List ready: {import.Project.Ieds.Count} IED, {import.Project.SignalCount} SDI, " +
+ $"{binding.SignalBoundCount} matched to the loaded workspace, {warnings} warning(s).");
+
+ var window = new IoListTestingWindow(import.Project) { Owner = this };
+ Hide();
+ try
+ {
+ window.ShowDialog();
+ }
+ finally
+ {
+ Show();
+ if (WindowState == System.Windows.WindowState.Minimized)
+ WindowState = System.Windows.WindowState.Normal;
+ Activate();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ SetStatus("IO List import cancelled.");
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or ArgumentException)
+ {
+ AddLog("ERROR", "IO Testing", ex.Message);
+ MarkDiagnosticAlert();
+ SetStatus("IO List import failed. Diagnostics is marked with !.");
+ MessageBox.Show(this, ex.Message, "IO List import failed", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+}
diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs
new file mode 100644
index 000000000..2f6030e2f
--- /dev/null
+++ b/Models/IoTesting/IoTestModels.cs
@@ -0,0 +1,282 @@
+using ArIED61850Tester.Models;
+
+using System.ComponentModel;
+
+namespace ArIED61850Tester.Models.IoTesting;
+
+public enum IoTestPointState
+{
+ NotStarted,
+ WaitingForBaseline,
+ WaitingForOffBaseline,
+ ArmedForOn,
+ OnCaptured,
+ Passed,
+ Review,
+ Failed
+}
+
+public enum IoEvidenceTransition
+{
+ Baseline,
+ On,
+ Off
+}
+
+public enum IoEvidenceVerdict
+{
+ Accepted,
+ Review,
+ Rejected
+}
+
+public enum IoTestLiveBindingState
+{
+ NotEvaluated,
+ DeviceNotLoaded,
+ SignalNotFound,
+ BoundExact,
+ BoundNormalized,
+ LivePointReady
+}
+
+public sealed record IoTestObservation(
+ bool? NormalizedState,
+ string RawValue,
+ DateTimeOffset CapturedAt,
+ DateTimeOffset? IedTimestamp,
+ string Quality,
+ string AcquisitionSource,
+ long Sequence,
+ long ConnectionGeneration);
+
+public sealed record IoTestTransitionEvidence(
+ Guid EvidenceId,
+ IoEvidenceTransition Transition,
+ bool? PreviousState,
+ bool ObservedState,
+ string RawValue,
+ DateTimeOffset CapturedAt,
+ DateTimeOffset? IedTimestamp,
+ string Quality,
+ string AcquisitionSource,
+ long Sequence,
+ long ConnectionGeneration,
+ IoEvidenceVerdict Verdict,
+ string VerdictReason);
+
+public sealed class IoTestPointRuntime : ObservableObject
+{
+ private IoTestPointState _state = IoTestPointState.NotStarted;
+ private bool? _lastObservedState;
+ private long _lastSequence = -1;
+ private long _connectionGeneration = -1;
+ private IoTestTransitionEvidence? _onEvidence;
+ private IoTestTransitionEvidence? _offEvidence;
+ private string _statusReason = "Not started";
+ private int _attempt;
+ private string _currentValue = "-";
+ private string _currentQuality = "Unknown";
+ private string _currentSource = "Not connected";
+
+ public IoTestPointState State
+ {
+ get => _state;
+ internal set
+ {
+ if (Set(ref _state, value))
+ Raise(nameof(IsComplete));
+ }
+ }
+
+ public bool? LastObservedState { get => _lastObservedState; internal set => Set(ref _lastObservedState, value); }
+ public long LastSequence { get => _lastSequence; internal set => Set(ref _lastSequence, value); }
+ public long ConnectionGeneration { get => _connectionGeneration; internal set => Set(ref _connectionGeneration, value); }
+ public IoTestTransitionEvidence? OnEvidence { get => _onEvidence; internal set => Set(ref _onEvidence, value); }
+ public IoTestTransitionEvidence? OffEvidence { get => _offEvidence; internal set => Set(ref _offEvidence, value); }
+ public string StatusReason { get => _statusReason; internal set => Set(ref _statusReason, value ?? string.Empty); }
+ public int Attempt { get => _attempt; internal set => Set(ref _attempt, value); }
+ public string CurrentValue { get => _currentValue; internal set => Set(ref _currentValue, string.IsNullOrWhiteSpace(value) ? "-" : value); }
+ public string CurrentQuality { get => _currentQuality; internal set => Set(ref _currentQuality, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); }
+ public string CurrentSource { get => _currentSource; internal set => Set(ref _currentSource, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); }
+
+ public bool IsComplete => State is IoTestPointState.Passed or IoTestPointState.Review or IoTestPointState.Failed;
+
+ internal void ApplyObservation(IoTestObservation observation)
+ {
+ CurrentValue = observation.RawValue;
+ CurrentQuality = observation.Quality;
+ CurrentSource = observation.AcquisitionSource;
+ }
+
+ internal void ResetAttempt()
+ {
+ State = IoTestPointState.WaitingForBaseline;
+ LastObservedState = null;
+ LastSequence = -1;
+ ConnectionGeneration = -1;
+ OnEvidence = null;
+ OffEvidence = null;
+ StatusReason = "Waiting for a trustworthy baseline";
+ Attempt++;
+ }
+}
+
+public sealed class IoTestPointPlan : ObservableObject
+{
+ private bool _testEnabled = true;
+ private IoTestLiveBindingState _liveBindingState = IoTestLiveBindingState.NotEvaluated;
+ private string _liveBindingReason = "Live binding has not been evaluated";
+ private string _liveDeviceId = string.Empty;
+ private string _liveSignalReference = string.Empty;
+
+ public required string TestPointId { get; init; }
+ public required string IedName { get; init; }
+ public required string IpAddress { get; init; }
+ public required string SignalName { get; init; }
+ public required string ObjectReference { get; init; }
+ public required string FunctionalConstraint { get; init; }
+ public required string ExpectedOnText { get; init; }
+ public required string ExpectedOffText { get; init; }
+ public int ExpectedOnRaw { get; init; } = 1;
+ public int ExpectedOffRaw { get; init; }
+ public string DataType { get; init; } = "SDI";
+ public string SignalAddress { get; init; } = string.Empty;
+ public string DataSetName { get; init; } = string.Empty;
+ public string LogicalDevice { get; init; } = string.Empty;
+ public string LogicalNode { get; init; } = string.Empty;
+ public string DataObject { get; init; } = string.Empty;
+ public string DataAttribute { get; init; } = string.Empty;
+ public string SourceSheet { get; init; } = string.Empty;
+ public int SourceRow { get; init; }
+ public bool TestEnabled { get => _testEnabled; set => Set(ref _testEnabled, value); }
+ public bool ImportReady { get; init; } = true;
+ public string BindingStatus { get; init; } = string.Empty;
+ public string BindingEvidence { get; init; } = string.Empty;
+ public IoTestPointRuntime Runtime { get; } = new();
+
+ public IoTestLiveBindingState LiveBindingState
+ {
+ get => _liveBindingState;
+ private set
+ {
+ if (Set(ref _liveBindingState, value))
+ {
+ Raise(nameof(IsLiveBound));
+ Raise(nameof(LiveBindingText));
+ }
+ }
+ }
+
+ public string LiveBindingReason { get => _liveBindingReason; private set => Set(ref _liveBindingReason, value ?? string.Empty); }
+ public string LiveDeviceId { get => _liveDeviceId; private set => Set(ref _liveDeviceId, value ?? string.Empty); }
+ public string LiveSignalReference { get => _liveSignalReference; private set => Set(ref _liveSignalReference, value ?? string.Empty); }
+ public bool IsLiveBound => LiveBindingState is IoTestLiveBindingState.BoundExact or IoTestLiveBindingState.BoundNormalized or IoTestLiveBindingState.LivePointReady;
+ public string LiveBindingText => LiveBindingState switch
+ {
+ IoTestLiveBindingState.LivePointReady => "Live",
+ IoTestLiveBindingState.BoundExact => "Model bound",
+ IoTestLiveBindingState.BoundNormalized => "Model bound*",
+ IoTestLiveBindingState.DeviceNotLoaded => "IED not loaded",
+ IoTestLiveBindingState.SignalNotFound => "Signal missing",
+ _ => "Not checked"
+ };
+
+ public void ApplyLiveBinding(
+ IoTestLiveBindingState state,
+ string reason,
+ string? deviceId = null,
+ string? signalReference = null)
+ {
+ LiveDeviceId = deviceId ?? string.Empty;
+ LiveSignalReference = signalReference ?? string.Empty;
+ LiveBindingReason = reason;
+ LiveBindingState = state;
+ }
+}
+
+public sealed class IoTestIedPlan : ObservableObject
+{
+ private string _liveDeviceId = string.Empty;
+ private string _liveStatusText = "Not evaluated";
+ private bool _notificationsInitialized;
+
+ public required string IedName { get; init; }
+ public required string IpAddress { get; init; }
+ public string IedRole { get; init; } = string.Empty;
+ public string Location { get; init; } = string.Empty;
+ public string VoltageLevel { get; init; } = string.Empty;
+ public string Switchgear { get; init; } = string.Empty;
+ public List TestPoints { get; init; } = new();
+
+ public string LiveDeviceId { get => _liveDeviceId; private set => Set(ref _liveDeviceId, value ?? string.Empty); }
+ public string LiveStatusText { get => _liveStatusText; private set => Set(ref _liveStatusText, value ?? string.Empty); }
+ public int EnabledCount => TestPoints.Count(point => point.TestEnabled);
+ public int PassedCount => TestPoints.Count(point => point.Runtime.State == IoTestPointState.Passed);
+ public int ReviewCount => TestPoints.Count(point => point.Runtime.State == IoTestPointState.Review);
+ public int BoundCount => TestPoints.Count(point => point.IsLiveBound);
+ public int PendingCount => Math.Max(0, EnabledCount - PassedCount - ReviewCount);
+
+ public void ApplyLiveDeviceBinding(string? deviceId, string status)
+ {
+ LiveDeviceId = deviceId ?? string.Empty;
+ LiveStatusText = status;
+ RaiseProgressProperties();
+ }
+
+ public void InitializeRuntimeNotifications()
+ {
+ if (_notificationsInitialized)
+ return;
+ _notificationsInitialized = true;
+
+ foreach (var point in TestPoints)
+ {
+ point.PropertyChanged += Point_PropertyChanged;
+ point.Runtime.PropertyChanged += PointRuntime_PropertyChanged;
+ }
+ RaiseProgressProperties();
+ }
+
+ private void Point_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(IoTestPointPlan.TestEnabled) or nameof(IoTestPointPlan.LiveBindingState))
+ RaiseProgressProperties();
+ }
+
+ private void PointRuntime_PropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(IoTestPointRuntime.State) or nameof(IoTestPointRuntime.OnEvidence) or nameof(IoTestPointRuntime.OffEvidence))
+ RaiseProgressProperties();
+ }
+
+ private void RaiseProgressProperties()
+ {
+ Raise(nameof(EnabledCount));
+ Raise(nameof(PassedCount));
+ Raise(nameof(ReviewCount));
+ Raise(nameof(BoundCount));
+ Raise(nameof(PendingCount));
+ }
+}
+
+public sealed class IoTestProject
+{
+ public required string ProjectId { get; init; }
+ public required string SchemaVersion { get; init; }
+ public required string ProjectName { get; init; }
+ public string SourceWorkbookName { get; init; } = string.Empty;
+ public string SourceWorkbookSha256 { get; init; } = string.Empty;
+ public DateTimeOffset ImportedAt { get; init; } = DateTimeOffset.UtcNow;
+ public List Ieds { get; init; } = new();
+
+ public int SignalCount => Ieds.Sum(ied => ied.TestPoints.Count);
+ public int ReadySignalCount => Ieds.Sum(ied => ied.TestPoints.Count(point => point.ImportReady));
+ public int LiveBoundSignalCount => Ieds.Sum(ied => ied.TestPoints.Count(point => point.IsLiveBound));
+
+ public void InitializeRuntimeNotifications()
+ {
+ foreach (var ied in Ieds)
+ ied.InitializeRuntimeNotifications();
+ }
+}
diff --git a/Services/IoTesting/IoListExcelImportService.cs b/Services/IoTesting/IoListExcelImportService.cs
new file mode 100644
index 000000000..36a39cdf0
--- /dev/null
+++ b/Services/IoTesting/IoListExcelImportService.cs
@@ -0,0 +1,436 @@
+using System.Globalization;
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Xml.Linq;
+using ArIED61850Tester.Models.IoTesting;
+
+namespace ArIED61850Tester.Services.IoTesting;
+
+public sealed record IoListExcelImportResult(
+ IoTestProject Project,
+ IoTestImportValidationResult Validation,
+ IReadOnlyList ParserFindings,
+ int ParsedRowCount)
+{
+ public IReadOnlyList AllFindings => ParserFindings.Concat(Validation.Findings).ToList();
+ public bool IsValid => ParserFindings.All(finding => finding.Severity != IoTestImportFindingSeverity.Error) && Validation.IsValid;
+}
+
+public sealed class IoListExcelImportService
+{
+ private const string SignalSheetName = "ARSAS_SIGNAL_IMPORT";
+ private const int MaxWorkbookBytes = 50 * 1024 * 1024;
+ private const int MaxSignalRows = 20_000;
+ private static readonly XNamespace SpreadsheetNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
+ private static readonly XNamespace RelationshipsNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
+ private static readonly XNamespace PackageRelationshipsNs = "http://schemas.openxmlformats.org/package/2006/relationships";
+
+ private static readonly string[] RequiredHeaders =
+ {
+ "ProjectId", "SchemaVersion", "TestPointId", "TestEnabled", "ImportReady",
+ "BindingStatus", "SourceSheet", "SourceRow", "IEDName", "IPAddress",
+ "SignalName", "DataType", "FC", "ObjectReference", "ExpectedONRaw",
+ "ExpectedONText", "ExpectedOFFRaw", "ExpectedOFFText"
+ };
+
+ private readonly IoTestImportValidator _validator;
+
+ public IoListExcelImportService(IoTestImportValidator? validator = null)
+ {
+ _validator = validator ?? new IoTestImportValidator();
+ }
+
+ public Task ImportAsync(string filePath, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(filePath))
+ throw new ArgumentException("An Excel file path is required.", nameof(filePath));
+
+ return Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var info = new FileInfo(filePath);
+ if (!info.Exists)
+ throw new FileNotFoundException("The IO List workbook was not found.", filePath);
+ if (info.Length > MaxWorkbookBytes)
+ throw new InvalidDataException($"The IO List workbook exceeds the {MaxWorkbookBytes / 1024 / 1024} MB safety limit.");
+
+ var bytes = File.ReadAllBytes(filePath);
+ cancellationToken.ThrowIfCancellationRequested();
+ return Import(bytes, Path.GetFileName(filePath), cancellationToken);
+ }, cancellationToken);
+ }
+
+ public IoListExcelImportResult Import(byte[] workbookBytes, string sourceFileName, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(workbookBytes);
+ if (workbookBytes.Length == 0)
+ throw new InvalidDataException("The IO List workbook is empty.");
+ if (workbookBytes.Length > MaxWorkbookBytes)
+ throw new InvalidDataException($"The IO List workbook exceeds the {MaxWorkbookBytes / 1024 / 1024} MB safety limit.");
+
+ var parserFindings = new List();
+ using var memory = new MemoryStream(workbookBytes, writable: false);
+ using var archive = new ZipArchive(memory, ZipArchiveMode.Read, leaveOpen: false);
+ var rows = ReadSheetRows(archive, SignalSheetName, cancellationToken);
+ if (rows.Count == 0)
+ throw new InvalidDataException($"Sheet '{SignalSheetName}' contains no rows.");
+
+ var headerRow = rows[0];
+ var headers = headerRow.Cells
+ .Where(item => !string.IsNullOrWhiteSpace(item.Value))
+ .ToDictionary(item => item.Key, item => item.Value.Trim(), EqualityComparer.Default);
+ var duplicateHeaders = headers.Values
+ .GroupBy(value => value, StringComparer.OrdinalIgnoreCase)
+ .Where(group => group.Count() > 1)
+ .Select(group => group.Key)
+ .ToList();
+ foreach (var duplicate in duplicateHeaders)
+ {
+ parserFindings.Add(new IoTestImportFinding(
+ IoTestImportFindingSeverity.Error,
+ "XLSX_HEADER_DUPLICATE",
+ $"Column header '{duplicate}' occurs more than once in sheet '{SignalSheetName}'.",
+ SourceSheet: SignalSheetName,
+ SourceRow: headerRow.RowNumber));
+ }
+ var headerLookup = headers
+ .GroupBy(item => item.Value, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(group => group.Key, group => group.First().Key, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var required in RequiredHeaders)
+ {
+ if (!headerLookup.ContainsKey(required))
+ {
+ parserFindings.Add(new IoTestImportFinding(
+ IoTestImportFindingSeverity.Error,
+ "XLSX_HEADER_MISSING",
+ $"Required column '{required}' is missing from sheet '{SignalSheetName}'.",
+ SourceSheet: SignalSheetName,
+ SourceRow: headerRow.RowNumber));
+ }
+ }
+
+ if (parserFindings.Any(finding => finding.Severity == IoTestImportFindingSeverity.Error))
+ return BuildEmptyResult(sourceFileName, workbookBytes, parserFindings);
+
+ var pointRows = new List();
+ foreach (var row in rows.Skip(1))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (pointRows.Count >= MaxSignalRows)
+ throw new InvalidDataException($"The IO List exceeds the {MaxSignalRows:N0} signal-row safety limit.");
+
+ var values = BuildRowValues(row, headerLookup);
+ var testPointId = Get(values, "TestPointId");
+ if (string.IsNullOrWhiteSpace(testPointId))
+ continue;
+
+ var dataType = Get(values, "DataType");
+ if (!string.Equals(dataType, "SDI", StringComparison.OrdinalIgnoreCase))
+ {
+ parserFindings.Add(new IoTestImportFinding(
+ IoTestImportFindingSeverity.Warning,
+ "XLSX_NON_SDI_SKIPPED",
+ $"Test point '{testPointId}' uses DataType '{dataType}' and was skipped by the SDI-only first release.",
+ testPointId,
+ SignalSheetName,
+ row.RowNumber));
+ continue;
+ }
+
+ pointRows.Add(new ImportedPointRow(row.RowNumber, values));
+ }
+
+ if (pointRows.Count == 0)
+ {
+ parserFindings.Add(new IoTestImportFinding(
+ IoTestImportFindingSeverity.Error,
+ "XLSX_SIGNAL_ROWS_EMPTY",
+ "No SDI signal rows with TestPointId were found.",
+ SourceSheet: SignalSheetName));
+ return BuildEmptyResult(sourceFileName, workbookBytes, parserFindings);
+ }
+
+ var projectIds = DistinctNonEmpty(pointRows, "ProjectId");
+ var schemas = DistinctNonEmpty(pointRows, "SchemaVersion");
+ if (projectIds.Count != 1)
+ parserFindings.Add(Error("XLSX_PROJECT_ID_INCONSISTENT", "The workbook must contain exactly one non-empty ProjectId."));
+ if (schemas.Count != 1)
+ parserFindings.Add(Error("XLSX_SCHEMA_INCONSISTENT", "The workbook must contain exactly one non-empty SchemaVersion."));
+
+ var iedPlans = pointRows
+ .GroupBy(row => new IedKey(Get(row.Values, "IEDName"), Get(row.Values, "IPAddress")), IedKeyComparer.Instance)
+ .OrderBy(group => group.Key.IedName, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(group => group.Key.IpAddress, StringComparer.OrdinalIgnoreCase)
+ .Select(group => BuildIedPlan(group.Key, group, parserFindings))
+ .ToList();
+
+ var project = new IoTestProject
+ {
+ ProjectId = projectIds.Count == 1 ? projectIds[0] : string.Empty,
+ SchemaVersion = schemas.Count == 1 ? schemas[0] : string.Empty,
+ ProjectName = Path.GetFileNameWithoutExtension(sourceFileName),
+ SourceWorkbookName = sourceFileName,
+ SourceWorkbookSha256 = Convert.ToHexString(SHA256.HashData(workbookBytes)).ToLowerInvariant(),
+ ImportedAt = DateTimeOffset.UtcNow,
+ Ieds = iedPlans
+ };
+ project.InitializeRuntimeNotifications();
+
+ return new IoListExcelImportResult(
+ project,
+ _validator.Validate(project),
+ parserFindings,
+ pointRows.Count);
+ }
+
+ private static IoListExcelImportResult BuildEmptyResult(
+ string sourceFileName,
+ byte[] workbookBytes,
+ IReadOnlyList parserFindings)
+ {
+ var project = new IoTestProject
+ {
+ ProjectId = string.Empty,
+ SchemaVersion = string.Empty,
+ ProjectName = Path.GetFileNameWithoutExtension(sourceFileName),
+ SourceWorkbookName = sourceFileName,
+ SourceWorkbookSha256 = Convert.ToHexString(SHA256.HashData(workbookBytes)).ToLowerInvariant()
+ };
+ var validation = new IoTestImportValidator().Validate(project);
+ return new IoListExcelImportResult(project, validation, parserFindings, 0);
+ }
+
+ private static IoTestIedPlan BuildIedPlan(
+ IedKey key,
+ IEnumerable sourceRows,
+ ICollection findings)
+ {
+ var rows = sourceRows.ToList();
+ var points = rows.Select(row => BuildPoint(key, row, findings)).ToList();
+ return new IoTestIedPlan
+ {
+ IedName = key.IedName,
+ IpAddress = key.IpAddress,
+ IedRole = FirstNonEmpty(rows, "IEDRole"),
+ Location = JoinDistinct(rows, "Location"),
+ VoltageLevel = JoinDistinct(rows, "VoltageLevel"),
+ Switchgear = JoinDistinct(rows, "Switchgear"),
+ TestPoints = points
+ };
+ }
+
+ private static IoTestPointPlan BuildPoint(
+ IedKey key,
+ ImportedPointRow row,
+ ICollection findings)
+ {
+ var values = row.Values;
+ var sourceRow = ParseInt(Get(values, "SourceRow"), row.RowNumber);
+ var onRaw = ParseInt(Get(values, "ExpectedONRaw"), 1);
+ var offRaw = ParseInt(Get(values, "ExpectedOFFRaw"), 0);
+ if (onRaw == offRaw)
+ {
+ findings.Add(new IoTestImportFinding(
+ IoTestImportFindingSeverity.Error,
+ "XLSX_EXPECTED_STATE_INVALID",
+ $"Expected ON and OFF raw values are identical for '{Get(values, "TestPointId")}'.",
+ Get(values, "TestPointId"),
+ Get(values, "SourceSheet"),
+ sourceRow));
+ }
+
+ return new IoTestPointPlan
+ {
+ TestPointId = Get(values, "TestPointId"),
+ IedName = key.IedName,
+ IpAddress = key.IpAddress,
+ SignalName = Get(values, "SignalName"),
+ SignalAddress = Get(values, "SignalAddress"),
+ DataType = Get(values, "DataType"),
+ ObjectReference = Get(values, "ObjectReference"),
+ FunctionalConstraint = Get(values, "FC"),
+ LogicalDevice = Get(values, "LDInst"),
+ LogicalNode = Get(values, "LN"),
+ DataObject = Get(values, "DO"),
+ DataAttribute = Get(values, "DA"),
+ DataSetName = Get(values, "DataSetName"),
+ ExpectedOnRaw = onRaw,
+ ExpectedOnText = Get(values, "ExpectedONText"),
+ ExpectedOffRaw = offRaw,
+ ExpectedOffText = Get(values, "ExpectedOFFText"),
+ SourceSheet = Get(values, "SourceSheet"),
+ SourceRow = sourceRow,
+ TestEnabled = ParseBool(Get(values, "TestEnabled"), defaultValue: true),
+ ImportReady = ParseBool(Get(values, "ImportReady"), defaultValue: false),
+ BindingStatus = Get(values, "BindingStatus"),
+ BindingEvidence = Get(values, "BindingEvidence")
+ };
+ }
+
+ private static Dictionary BuildRowValues(
+ WorksheetRow row,
+ IReadOnlyDictionary headerLookup)
+ {
+ var values = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var header in headerLookup)
+ values[header.Key] = row.Cells.TryGetValue(header.Value, out var value) ? value.Trim() : string.Empty;
+ return values;
+ }
+
+ private static List DistinctNonEmpty(IEnumerable rows, string key)
+ => rows.Select(row => Get(row.Values, key))
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+
+ private static string FirstNonEmpty(IEnumerable rows, string key)
+ => rows.Select(row => Get(row.Values, key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty;
+
+ private static string JoinDistinct(IEnumerable rows, string key)
+ => string.Join(", ", rows.Select(row => Get(row.Values, key))
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .Distinct(StringComparer.OrdinalIgnoreCase));
+
+ private static string Get(IReadOnlyDictionary values, string key)
+ => values.TryGetValue(key, out var value) ? value.Trim() : string.Empty;
+
+ private static bool ParseBool(string value, bool defaultValue)
+ {
+ if (bool.TryParse(value, out var parsed))
+ return parsed;
+ if (value == "1" || value.Equals("yes", StringComparison.OrdinalIgnoreCase) || value.Equals("x", StringComparison.OrdinalIgnoreCase))
+ return true;
+ if (value == "0" || value.Equals("no", StringComparison.OrdinalIgnoreCase))
+ return false;
+ return defaultValue;
+ }
+
+ private static int ParseInt(string value, int defaultValue)
+ => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : defaultValue;
+
+ private static IoTestImportFinding Error(string code, string message)
+ => new(IoTestImportFindingSeverity.Error, code, message, SourceSheet: SignalSheetName);
+
+ private static IReadOnlyList ReadSheetRows(
+ ZipArchive archive,
+ string sheetName,
+ CancellationToken cancellationToken)
+ {
+ var workbook = LoadXml(archive, "xl/workbook.xml");
+ var relationships = LoadXml(archive, "xl/_rels/workbook.xml.rels");
+ var sheet = workbook.Root?
+ .Element(SpreadsheetNs + "sheets")?
+ .Elements(SpreadsheetNs + "sheet")
+ .FirstOrDefault(item => string.Equals((string?)item.Attribute("name"), sheetName, StringComparison.OrdinalIgnoreCase));
+ if (sheet == null)
+ throw new InvalidDataException($"Required sheet '{sheetName}' was not found.");
+
+ var relationshipId = (string?)sheet.Attribute(RelationshipsNs + "id");
+ if (string.IsNullOrWhiteSpace(relationshipId))
+ throw new InvalidDataException($"Sheet '{sheetName}' has no workbook relationship.");
+
+ var relationship = relationships.Root?
+ .Elements(PackageRelationshipsNs + "Relationship")
+ .FirstOrDefault(item => string.Equals((string?)item.Attribute("Id"), relationshipId, StringComparison.Ordinal));
+ var target = (string?)relationship?.Attribute("Target");
+ if (string.IsNullOrWhiteSpace(target))
+ throw new InvalidDataException($"Sheet '{sheetName}' target could not be resolved.");
+
+ var sheetPath = NormalizeWorkbookTarget(target);
+ var sharedStrings = ReadSharedStrings(archive);
+ var sheetDocument = LoadXml(archive, sheetPath);
+ var rows = new List();
+ foreach (var row in sheetDocument.Descendants(SpreadsheetNs + "row"))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var rowNumber = (int?)row.Attribute("r") ?? rows.Count + 1;
+ var cells = new Dictionary();
+ foreach (var cell in row.Elements(SpreadsheetNs + "c"))
+ {
+ var reference = (string?)cell.Attribute("r");
+ var columnIndex = ColumnIndex(reference);
+ if (columnIndex < 0)
+ continue;
+ cells[columnIndex] = ReadCellValue(cell, sharedStrings);
+ }
+ if (cells.Count > 0)
+ rows.Add(new WorksheetRow(rowNumber, cells));
+ }
+ return rows;
+ }
+
+ private static XDocument LoadXml(ZipArchive archive, string entryPath)
+ {
+ var entry = archive.GetEntry(entryPath) ?? throw new InvalidDataException($"XLSX entry '{entryPath}' is missing.");
+ using var stream = entry.Open();
+ return XDocument.Load(stream, LoadOptions.None);
+ }
+
+ private static IReadOnlyList ReadSharedStrings(ZipArchive archive)
+ {
+ var entry = archive.GetEntry("xl/sharedStrings.xml");
+ if (entry == null)
+ return Array.Empty();
+ using var stream = entry.Open();
+ var document = XDocument.Load(stream, LoadOptions.None);
+ return document.Descendants(SpreadsheetNs + "si")
+ .Select(item => string.Concat(item.Descendants(SpreadsheetNs + "t").Select(text => text.Value)))
+ .ToList();
+ }
+
+ private static string ReadCellValue(XElement cell, IReadOnlyList sharedStrings)
+ {
+ var type = ((string?)cell.Attribute("t") ?? string.Empty).Trim();
+ if (type == "inlineStr")
+ return string.Concat(cell.Descendants(SpreadsheetNs + "t").Select(text => text.Value));
+
+ var raw = cell.Element(SpreadsheetNs + "v")?.Value ?? string.Empty;
+ if (type == "s" && int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index) && index >= 0 && index < sharedStrings.Count)
+ return sharedStrings[index];
+ if (type == "b")
+ return raw == "1" ? "true" : "false";
+ return raw;
+ }
+
+ private static int ColumnIndex(string? cellReference)
+ {
+ if (string.IsNullOrWhiteSpace(cellReference))
+ return -1;
+ var index = 0;
+ var found = false;
+ foreach (var ch in cellReference)
+ {
+ if (!char.IsLetter(ch))
+ break;
+ found = true;
+ index = checked(index * 26 + (char.ToUpperInvariant(ch) - 'A' + 1));
+ }
+ return found ? index - 1 : -1;
+ }
+
+ private static string NormalizeWorkbookTarget(string target)
+ {
+ var normalized = target.Replace('\\', '/').TrimStart('/');
+ if (normalized.StartsWith("xl/", StringComparison.OrdinalIgnoreCase))
+ return normalized;
+ while (normalized.StartsWith("../", StringComparison.Ordinal))
+ normalized = normalized[3..];
+ return "xl/" + normalized.TrimStart('/');
+ }
+
+ private sealed record WorksheetRow(int RowNumber, IReadOnlyDictionary Cells);
+ private sealed record ImportedPointRow(int RowNumber, IReadOnlyDictionary Values);
+ private sealed record IedKey(string IedName, string IpAddress);
+
+ private sealed class IedKeyComparer : IEqualityComparer
+ {
+ public static IedKeyComparer Instance { get; } = new();
+ public bool Equals(IedKey? x, IedKey? y)
+ => x != null && y != null &&
+ string.Equals(x.IedName, y.IedName, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(x.IpAddress, y.IpAddress, StringComparison.OrdinalIgnoreCase);
+ public int GetHashCode(IedKey obj)
+ => HashCode.Combine(obj.IedName.ToUpperInvariant(), obj.IpAddress.ToUpperInvariant());
+ }
+}
diff --git a/Services/IoTesting/IoTestImportValidator.cs b/Services/IoTesting/IoTestImportValidator.cs
new file mode 100644
index 000000000..6164a68ca
--- /dev/null
+++ b/Services/IoTesting/IoTestImportValidator.cs
@@ -0,0 +1,205 @@
+using System.Net;
+using ArIED61850Tester.Models.IoTesting;
+
+namespace ArIED61850Tester.Services.IoTesting;
+
+public enum IoTestImportFindingSeverity
+{
+ Warning,
+ Error
+}
+
+public sealed record IoTestImportFinding(
+ IoTestImportFindingSeverity Severity,
+ string Code,
+ string Message,
+ string? TestPointId = null,
+ string? SourceSheet = null,
+ int? SourceRow = null);
+
+public sealed record IoTestImportValidationResult(
+ IReadOnlyList Findings,
+ int IedCount,
+ int SignalCount,
+ int ReadySignalCount)
+{
+ public IReadOnlyList Errors => Findings.Where(finding => finding.Severity == IoTestImportFindingSeverity.Error).ToList();
+ public IReadOnlyList Warnings => Findings.Where(finding => finding.Severity == IoTestImportFindingSeverity.Warning).ToList();
+ public int ErrorCount => Errors.Count;
+ public int WarningCount => Warnings.Count;
+ public bool IsValid => ErrorCount == 0;
+ public bool CanImport => IsValid;
+}
+
+public sealed class IoTestImportValidator
+{
+ public const string SupportedSchemaVersion = "ARSAS-FAT-IO-1.0";
+
+ public IoTestImportValidationResult Validate(IoTestProject project)
+ {
+ ArgumentNullException.ThrowIfNull(project);
+
+ var findings = new List();
+ if (!string.Equals(project.SchemaVersion, SupportedSchemaVersion, StringComparison.Ordinal))
+ {
+ findings.Add(Error(
+ "SCHEMA_UNSUPPORTED",
+ $"Schema '{project.SchemaVersion}' is not supported. Expected '{SupportedSchemaVersion}'."));
+ }
+
+ if (string.IsNullOrWhiteSpace(project.ProjectId))
+ findings.Add(Error("PROJECT_ID_REQUIRED", "ProjectId is required."));
+
+ if (project.Ieds.Count == 0)
+ findings.Add(Error("IED_LIST_EMPTY", "The imported project contains no IED plans."));
+
+ var testPointIds = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var endpointOwners = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var ied in project.Ieds)
+ {
+ ValidateIed(ied, findings, endpointOwners);
+ foreach (var point in ied.TestPoints)
+ {
+ ValidatePoint(ied, point, findings);
+ if (!testPointIds.Add(point.TestPointId))
+ {
+ findings.Add(Error(
+ "TEST_POINT_DUPLICATE",
+ $"TestPointId '{point.TestPointId}' occurs more than once.",
+ point));
+ }
+ }
+ }
+
+ return new IoTestImportValidationResult(
+ findings,
+ project.Ieds.Count,
+ project.SignalCount,
+ project.ReadySignalCount);
+ }
+
+ private static void ValidateIed(
+ IoTestIedPlan ied,
+ ICollection findings,
+ IDictionary endpointOwners)
+ {
+ if (string.IsNullOrWhiteSpace(ied.IedName))
+ findings.Add(Error("IED_NAME_REQUIRED", "IEDName is required."));
+
+ if (!IPAddress.TryParse(ied.IpAddress, out _))
+ {
+ findings.Add(Error(
+ "IED_IP_INVALID",
+ $"IED '{ied.IedName}' has invalid IP address '{ied.IpAddress}'."));
+ }
+ else if (endpointOwners.TryGetValue(ied.IpAddress, out var existingIed) &&
+ !string.Equals(existingIed, ied.IedName, StringComparison.OrdinalIgnoreCase))
+ {
+ findings.Add(new IoTestImportFinding(
+ IoTestImportFindingSeverity.Warning,
+ "IED_IP_SHARED",
+ $"IP address '{ied.IpAddress}' is assigned to both '{existingIed}' and '{ied.IedName}'."));
+ }
+ else
+ {
+ endpointOwners[ied.IpAddress] = ied.IedName;
+ }
+
+ if (ied.TestPoints.Count == 0)
+ {
+ findings.Add(new IoTestImportFinding(
+ IoTestImportFindingSeverity.Warning,
+ "IED_SIGNALS_EMPTY",
+ $"IED '{ied.IedName}' has no SDI test points."));
+ }
+ }
+
+ private static void ValidatePoint(
+ IoTestIedPlan ied,
+ IoTestPointPlan point,
+ ICollection findings)
+ {
+ if (string.IsNullOrWhiteSpace(point.TestPointId))
+ findings.Add(Error("TEST_POINT_ID_REQUIRED", "TestPointId is required.", point));
+
+ if (!string.Equals(point.IedName, ied.IedName, StringComparison.OrdinalIgnoreCase))
+ {
+ findings.Add(Error(
+ "TEST_POINT_IED_MISMATCH",
+ $"Test point '{point.TestPointId}' belongs to '{point.IedName}' but is grouped under '{ied.IedName}'.",
+ point));
+ }
+
+ if (!string.Equals(point.IpAddress, ied.IpAddress, StringComparison.OrdinalIgnoreCase))
+ {
+ findings.Add(Error(
+ "TEST_POINT_IP_MISMATCH",
+ $"Test point '{point.TestPointId}' uses IP '{point.IpAddress}' but its IED plan uses '{ied.IpAddress}'.",
+ point));
+ }
+
+ if (!string.Equals(point.DataType, "SDI", StringComparison.OrdinalIgnoreCase))
+ {
+ findings.Add(Error(
+ "TEST_POINT_TYPE_UNSUPPORTED",
+ $"The first IO Testing release accepts DataType=SDI; received '{point.DataType}'.",
+ point));
+ }
+
+ if (point.ExpectedOnRaw == point.ExpectedOffRaw ||
+ point.ExpectedOnRaw is not (0 or 1) ||
+ point.ExpectedOffRaw is not (0 or 1))
+ {
+ findings.Add(Error(
+ "TEST_POINT_EXPECTED_STATE_INVALID",
+ $"Expected ON/OFF raw states for '{point.TestPointId}' must be distinct binary values 0 and 1.",
+ point));
+ }
+
+ if (string.IsNullOrWhiteSpace(point.SignalName))
+ findings.Add(Error("SIGNAL_NAME_REQUIRED", "SignalName is required.", point));
+
+ if (point.ImportReady && string.IsNullOrWhiteSpace(point.ObjectReference))
+ {
+ findings.Add(Error(
+ "REFERENCE_REQUIRED",
+ "Import-ready signals require ObjectReference.",
+ point));
+ }
+
+ if (point.ImportReady &&
+ !string.Equals(point.FunctionalConstraint, "ST", StringComparison.OrdinalIgnoreCase))
+ {
+ findings.Add(Error(
+ "SDI_FC_INVALID",
+ $"The first IO Testing release accepts SDI status points with FC=ST; received '{point.FunctionalConstraint}'.",
+ point));
+ }
+
+ if (!point.ImportReady)
+ {
+ findings.Add(new IoTestImportFinding(
+ IoTestImportFindingSeverity.Warning,
+ "SIGNAL_REVIEW_REQUIRED",
+ $"Signal '{point.SignalName}' is retained for review and will not be auto-tested.",
+ point.TestPointId,
+ point.SourceSheet,
+ point.SourceRow));
+ }
+ }
+
+ private static IoTestImportFinding Error(
+ string code,
+ string message,
+ IoTestPointPlan? point = null)
+ {
+ return new IoTestImportFinding(
+ IoTestImportFindingSeverity.Error,
+ code,
+ message,
+ point?.TestPointId,
+ point?.SourceSheet,
+ point?.SourceRow);
+ }
+}
diff --git a/Services/IoTesting/IoTestLiveBindingService.cs b/Services/IoTesting/IoTestLiveBindingService.cs
new file mode 100644
index 000000000..c8cf40012
--- /dev/null
+++ b/Services/IoTesting/IoTestLiveBindingService.cs
@@ -0,0 +1,206 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Models.IoTesting;
+
+namespace ArIED61850Tester.Services.IoTesting;
+
+public sealed record IoTestLiveBindingSummary(
+ int IedCount,
+ int DeviceBoundCount,
+ int SignalCount,
+ int SignalBoundCount,
+ int LivePointCount,
+ int MissingSignalCount);
+
+public sealed class IoTestLiveBindingService
+{
+ public IoTestLiveBindingSummary Bind(
+ IoTestProject project,
+ IEnumerable devices)
+ {
+ ArgumentNullException.ThrowIfNull(project);
+ ArgumentNullException.ThrowIfNull(devices);
+
+ var deviceList = devices.ToList();
+ var deviceBoundCount = 0;
+ var signalBoundCount = 0;
+ var livePointCount = 0;
+ var missingSignalCount = 0;
+
+ foreach (var iedPlan in project.Ieds)
+ {
+ var device = FindDevice(iedPlan, deviceList);
+ if (device == null)
+ {
+ iedPlan.ApplyLiveDeviceBinding(null, "IED is present in the IO list but is not loaded in the engineering workspace");
+ foreach (var point in iedPlan.TestPoints)
+ {
+ point.ApplyLiveBinding(
+ IoTestLiveBindingState.DeviceNotLoaded,
+ "Load or connect the imported IED before starting its FAT session.");
+ }
+ continue;
+ }
+
+ deviceBoundCount++;
+ iedPlan.ApplyLiveDeviceBinding(
+ device.DeviceId,
+ device.IsMonitoring
+ ? $"Monitoring · {device.Name} · {device.EndpointText}"
+ : device.IsConnected
+ ? $"Connected · {device.Name} · {device.EndpointText}"
+ : $"Loaded · {device.Name} · {device.EndpointText}");
+
+ foreach (var point in iedPlan.TestPoints)
+ {
+ var binding = BindPoint(point, device);
+ point.ApplyLiveBinding(binding.State, binding.Reason, device.DeviceId, binding.Reference);
+ if (point.IsLiveBound)
+ signalBoundCount++;
+ if (binding.State == IoTestLiveBindingState.LivePointReady)
+ {
+ livePointCount++;
+ if (binding.LivePoint != null)
+ {
+ point.Runtime.CurrentValue = binding.LivePoint.Value;
+ point.Runtime.CurrentQuality = binding.LivePoint.Quality;
+ point.Runtime.CurrentSource = binding.LivePoint.SourceMode;
+ }
+ }
+ else if (binding.State == IoTestLiveBindingState.SignalNotFound)
+ {
+ missingSignalCount++;
+ }
+ }
+ }
+
+ return new IoTestLiveBindingSummary(
+ project.Ieds.Count,
+ deviceBoundCount,
+ project.SignalCount,
+ signalBoundCount,
+ livePointCount,
+ missingSignalCount);
+ }
+
+ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevice device)
+ {
+ if (!point.ImportReady || string.IsNullOrWhiteSpace(point.ObjectReference))
+ {
+ return new PointBinding(
+ IoTestLiveBindingState.SignalNotFound,
+ "The imported row is not ready for automatic live binding.",
+ string.Empty,
+ null);
+ }
+
+ var expected = NormalizeReference(point.ObjectReference);
+ var exactLivePoint = device.Points.FirstOrDefault(item =>
+ NormalizeReference(item.IecReference).Equals(expected, StringComparison.OrdinalIgnoreCase));
+ if (exactLivePoint != null)
+ {
+ return new PointBinding(
+ IoTestLiveBindingState.LivePointReady,
+ "Exact imported object reference is already active in the live monitor.",
+ exactLivePoint.IecReference,
+ exactLivePoint);
+ }
+
+ var exactSignal = device.Signals.FirstOrDefault(item =>
+ !item.IsControlSignal &&
+ NormalizeReference(item.ObjectReference).Equals(expected, StringComparison.OrdinalIgnoreCase));
+ if (exactSignal != null)
+ {
+ return new PointBinding(
+ IoTestLiveBindingState.BoundExact,
+ "Exact imported object reference is present in the discovered IED model.",
+ exactSignal.ObjectReference,
+ null);
+ }
+
+ var expectedTelegram = NormalizeTelegram(point.ObjectReference, point.IedName);
+ var livePointCandidates = device.Points
+ .Where(item => NormalizeTelegram(item.IecReference, device.Name)
+ .Equals(expectedTelegram, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ if (livePointCandidates.Count == 1)
+ {
+ return new PointBinding(
+ IoTestLiveBindingState.LivePointReady,
+ "Live point matched after normalizing the IED-name prefix.",
+ livePointCandidates[0].IecReference,
+ livePointCandidates[0]);
+ }
+
+ var signalCandidates = device.Signals
+ .Where(item => !item.IsControlSignal &&
+ NormalizeTelegram(item.ObjectReference, device.Name)
+ .Equals(expectedTelegram, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ if (signalCandidates.Count == 1)
+ {
+ return new PointBinding(
+ IoTestLiveBindingState.BoundNormalized,
+ "Discovered signal matched after normalizing the IED-name prefix.",
+ signalCandidates[0].ObjectReference,
+ null);
+ }
+
+ var reason = signalCandidates.Count > 1 || livePointCandidates.Count > 1
+ ? "More than one live candidate matched the imported telegram; automatic binding was withheld."
+ : device.Signals.Count == 0
+ ? "The IED is loaded but its signal model has not been discovered yet."
+ : "The imported object reference was not found in the loaded IED model.";
+ return new PointBinding(IoTestLiveBindingState.SignalNotFound, reason, string.Empty, null);
+ }
+
+ private static Iec61850MonitorDevice? FindDevice(
+ IoTestIedPlan plan,
+ IReadOnlyCollection devices)
+ {
+ var exact = devices.FirstOrDefault(device =>
+ DeviceNameMatches(device, plan.IedName) &&
+ device.IpAddress.Equals(plan.IpAddress, StringComparison.OrdinalIgnoreCase));
+ if (exact != null)
+ return exact;
+
+ var nameMatches = devices.Where(device => DeviceNameMatches(device, plan.IedName)).ToList();
+ if (nameMatches.Count == 1)
+ return nameMatches[0];
+
+ var ipMatches = devices.Where(device =>
+ device.IpAddress.Equals(plan.IpAddress, StringComparison.OrdinalIgnoreCase)).ToList();
+ return ipMatches.Count == 1 ? ipMatches[0] : null;
+ }
+
+ private static bool DeviceNameMatches(Iec61850MonitorDevice device, string iedName)
+ => device.Name.Equals(iedName, StringComparison.OrdinalIgnoreCase) ||
+ device.SclIedName.Equals(iedName, StringComparison.OrdinalIgnoreCase);
+
+ internal static string NormalizeReference(string? reference)
+ => (reference ?? string.Empty)
+ .Trim()
+ .Replace('$', '.')
+ .Replace("..", ".")
+ .TrimEnd('.')
+ .ToLowerInvariant();
+
+ internal static string NormalizeTelegram(string? reference, string? iedName)
+ {
+ var normalized = NormalizeReference(reference);
+ var slash = normalized.IndexOf('/');
+ var name = (iedName ?? string.Empty).Trim().ToLowerInvariant();
+ if (slash <= 0 || string.IsNullOrWhiteSpace(name))
+ return normalized;
+
+ var domain = normalized[..slash];
+ if (domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) && domain.Length > name.Length)
+ return domain[name.Length..] + normalized[slash..];
+ return normalized;
+ }
+
+ private sealed record PointBinding(
+ IoTestLiveBindingState State,
+ string Reason,
+ string Reference,
+ Iec61850MonitorPoint? LivePoint);
+}
diff --git a/Services/IoTesting/IoTestTransitionEvaluator.cs b/Services/IoTesting/IoTestTransitionEvaluator.cs
new file mode 100644
index 000000000..d0d582474
--- /dev/null
+++ b/Services/IoTesting/IoTestTransitionEvaluator.cs
@@ -0,0 +1,249 @@
+using ArIED61850Tester.Models.IoTesting;
+
+namespace ArIED61850Tester.Services.IoTesting;
+
+public sealed record IoTestEvaluationResult(
+ bool StateChanged,
+ IoTestPointState State,
+ IoTestTransitionEvidence? Evidence,
+ string Reason);
+
+public sealed class IoTestTransitionEvaluator
+{
+ public IoTestEvaluationResult StartAttempt(IoTestPointPlan point, IoTestObservation baseline)
+ {
+ ArgumentNullException.ThrowIfNull(point);
+ ArgumentNullException.ThrowIfNull(baseline);
+
+ var runtime = point.Runtime;
+ runtime.ResetAttempt();
+ runtime.ApplyObservation(baseline);
+ runtime.ConnectionGeneration = baseline.ConnectionGeneration;
+ runtime.LastSequence = baseline.Sequence;
+ runtime.LastObservedState = baseline.NormalizedState;
+
+ if (!point.TestEnabled)
+ {
+ runtime.State = IoTestPointState.NotStarted;
+ runtime.StatusReason = "Signal is disabled in the FAT test plan";
+ return Result(runtime, changed: true, evidence: null);
+ }
+
+ if (!point.ImportReady || string.IsNullOrWhiteSpace(point.ObjectReference))
+ {
+ runtime.State = IoTestPointState.Review;
+ runtime.StatusReason = "Signal is not safely bound to an IEC 61850 object reference";
+ return Result(runtime, changed: true, evidence: null);
+ }
+
+ ApplyBaseline(runtime, baseline.NormalizedState);
+ return Result(runtime, changed: true, evidence: null);
+ }
+
+ public IoTestEvaluationResult Observe(IoTestPointPlan point, IoTestObservation observation)
+ {
+ ArgumentNullException.ThrowIfNull(point);
+ ArgumentNullException.ThrowIfNull(observation);
+
+ var runtime = point.Runtime;
+ if (!point.TestEnabled)
+ {
+ runtime.ApplyObservation(observation);
+ return Result(runtime, changed: false, evidence: null, "Signal is disabled");
+ }
+
+ if (runtime.State == IoTestPointState.NotStarted)
+ {
+ runtime.ApplyObservation(observation);
+ return Result(runtime, changed: false, evidence: null, "Test attempt has not been started");
+ }
+
+ if (runtime.ConnectionGeneration != observation.ConnectionGeneration)
+ {
+ runtime.ApplyObservation(observation);
+ return HandleConnectionGenerationChange(runtime, observation);
+ }
+
+ if (observation.Sequence <= runtime.LastSequence)
+ return Result(runtime, changed: false, evidence: null, "Duplicate or out-of-order observation ignored");
+
+ runtime.ApplyObservation(observation);
+ var previous = runtime.LastObservedState;
+ runtime.LastSequence = observation.Sequence;
+ runtime.LastObservedState = observation.NormalizedState;
+
+ if (observation.NormalizedState is null)
+ {
+ runtime.StatusReason = "Observation has no normalized digital state";
+ return Result(runtime, changed: false, evidence: null);
+ }
+
+ return runtime.State switch
+ {
+ IoTestPointState.WaitingForBaseline => HandleBaseline(runtime, observation),
+ IoTestPointState.WaitingForOffBaseline => HandleWaitingForOffBaseline(runtime, observation),
+ IoTestPointState.ArmedForOn => HandleArmedForOn(runtime, observation, previous),
+ IoTestPointState.OnCaptured => HandleOnCaptured(runtime, observation, previous),
+ _ => Result(runtime, changed: false, evidence: null, "Observation retained without changing the completed test state")
+ };
+ }
+
+ private static IoTestEvaluationResult HandleConnectionGenerationChange(
+ IoTestPointRuntime runtime,
+ IoTestObservation observation)
+ {
+ runtime.ConnectionGeneration = observation.ConnectionGeneration;
+ runtime.LastSequence = observation.Sequence;
+ runtime.LastObservedState = observation.NormalizedState;
+
+ if (runtime.OnEvidence != null && runtime.OffEvidence == null)
+ {
+ runtime.State = IoTestPointState.Review;
+ runtime.StatusReason = "IED connection changed after ON evidence; OFF transition continuity cannot be proven";
+ return Result(runtime, changed: true, evidence: null);
+ }
+
+ runtime.State = IoTestPointState.WaitingForBaseline;
+ runtime.StatusReason = "Connection changed; first image is baseline only";
+ ApplyBaseline(runtime, observation.NormalizedState);
+ return Result(runtime, changed: true, evidence: null);
+ }
+
+ private static IoTestEvaluationResult HandleBaseline(
+ IoTestPointRuntime runtime,
+ IoTestObservation observation)
+ {
+ ApplyBaseline(runtime, observation.NormalizedState);
+ return Result(runtime, changed: true, evidence: null);
+ }
+
+ private static IoTestEvaluationResult HandleWaitingForOffBaseline(
+ IoTestPointRuntime runtime,
+ IoTestObservation observation)
+ {
+ if (observation.NormalizedState != false)
+ return Result(runtime, changed: false, evidence: null, "Waiting for OFF baseline before arming ON evidence");
+
+ runtime.State = IoTestPointState.ArmedForOn;
+ runtime.StatusReason = "OFF baseline confirmed; waiting for a new ON transition";
+ return Result(runtime, changed: true, evidence: null);
+ }
+
+ private static IoTestEvaluationResult HandleArmedForOn(
+ IoTestPointRuntime runtime,
+ IoTestObservation observation,
+ bool? previous)
+ {
+ if (previous != false || observation.NormalizedState != true)
+ return Result(runtime, changed: false, evidence: null, "Waiting for OFF to ON transition");
+
+ var evidence = CreateEvidence(IoEvidenceTransition.On, previous, observation);
+ if (evidence.Verdict == IoEvidenceVerdict.Rejected)
+ {
+ runtime.StatusReason = $"ON transition rejected: {evidence.VerdictReason}";
+ return Result(runtime, changed: false, evidence);
+ }
+
+ runtime.OnEvidence = evidence;
+ runtime.State = IoTestPointState.OnCaptured;
+ runtime.StatusReason = evidence.Verdict == IoEvidenceVerdict.Accepted
+ ? "ON evidence captured; waiting for OFF transition"
+ : "ON evidence captured for review; waiting for OFF transition";
+ return Result(runtime, changed: true, evidence);
+ }
+
+ private static IoTestEvaluationResult HandleOnCaptured(
+ IoTestPointRuntime runtime,
+ IoTestObservation observation,
+ bool? previous)
+ {
+ if (previous != true || observation.NormalizedState != false)
+ return Result(runtime, changed: false, evidence: null, "Waiting for ON to OFF transition");
+
+ var evidence = CreateEvidence(IoEvidenceTransition.Off, previous, observation);
+ if (evidence.Verdict == IoEvidenceVerdict.Rejected)
+ {
+ runtime.StatusReason = $"OFF transition rejected: {evidence.VerdictReason}";
+ return Result(runtime, changed: false, evidence);
+ }
+
+ runtime.OffEvidence = evidence;
+ var onAccepted = runtime.OnEvidence?.Verdict == IoEvidenceVerdict.Accepted;
+ var offAccepted = evidence.Verdict == IoEvidenceVerdict.Accepted;
+ runtime.State = onAccepted && offAccepted
+ ? IoTestPointState.Passed
+ : IoTestPointState.Review;
+ runtime.StatusReason = runtime.State == IoTestPointState.Passed
+ ? "PASS: ON and OFF transitions captured in order"
+ : "ON and OFF evidence captured, but one or both transitions require review";
+ return Result(runtime, changed: true, evidence);
+ }
+
+ private static void ApplyBaseline(IoTestPointRuntime runtime, bool? state)
+ {
+ runtime.State = state switch
+ {
+ false => IoTestPointState.ArmedForOn,
+ true => IoTestPointState.WaitingForOffBaseline,
+ null => IoTestPointState.WaitingForBaseline
+ };
+ runtime.StatusReason = state switch
+ {
+ false => "OFF baseline confirmed; waiting for a new ON transition",
+ true => "Signal was already ON; waiting for OFF baseline before testing",
+ null => "Waiting for a trustworthy digital baseline"
+ };
+ }
+
+ private static IoTestTransitionEvidence CreateEvidence(
+ IoEvidenceTransition transition,
+ bool? previous,
+ IoTestObservation observation)
+ {
+ var (verdict, reason) = EvaluateQuality(observation.Quality);
+ return new IoTestTransitionEvidence(
+ Guid.NewGuid(),
+ transition,
+ previous,
+ observation.NormalizedState!.Value,
+ observation.RawValue,
+ observation.CapturedAt,
+ observation.IedTimestamp,
+ observation.Quality,
+ observation.AcquisitionSource,
+ observation.Sequence,
+ observation.ConnectionGeneration,
+ verdict,
+ reason);
+ }
+
+ internal static (IoEvidenceVerdict Verdict, string Reason) EvaluateQuality(string? quality)
+ {
+ var normalized = (quality ?? string.Empty).Trim().ToLowerInvariant();
+ if (normalized.Contains("invalid") ||
+ normalized.Contains("bad") ||
+ normalized.Contains("failure") ||
+ normalized.Contains("blocked"))
+ {
+ return (IoEvidenceVerdict.Rejected, "IEC 61850 quality is invalid or bad");
+ }
+
+ if (normalized.Contains("good") || normalized == "valid")
+ return (IoEvidenceVerdict.Accepted, "Quality accepted");
+
+ return (IoEvidenceVerdict.Review, "Quality is missing, unknown, or questionable");
+ }
+
+ private static IoTestEvaluationResult Result(
+ IoTestPointRuntime runtime,
+ bool changed,
+ IoTestTransitionEvidence? evidence,
+ string? reason = null)
+ {
+ return new IoTestEvaluationResult(
+ changed,
+ runtime.State,
+ evidence,
+ reason ?? runtime.StatusReason);
+ }
+}
diff --git a/tests/ARSAS.Tests/IoListExcelImportServiceTests.cs b/tests/ARSAS.Tests/IoListExcelImportServiceTests.cs
new file mode 100644
index 000000000..fc3b40f88
--- /dev/null
+++ b/tests/ARSAS.Tests/IoListExcelImportServiceTests.cs
@@ -0,0 +1,151 @@
+using System.IO.Compression;
+using System.Security;
+using System.Text;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ARSAS.Tests;
+
+public sealed class IoListExcelImportServiceTests
+{
+ private readonly IoListExcelImportService _importer = new();
+
+ [Fact]
+ public void GeneratedContractWorkbook_ImportsAndGroupsSignalsPerIed()
+ {
+ var headers = RequiredHeaders();
+ var rows = new[]
+ {
+ Row(headers,
+ ("ProjectId", "CCPP-260728"), ("SchemaVersion", "ARSAS-FAT-IO-1.0"),
+ ("TestPointId", "TP-001"), ("TestEnabled", "true"), ("ImportReady", "true"),
+ ("BindingStatus", "CID_DATASET_EXACT"), ("BindingEvidence", "FCDA DataSet Digital"),
+ ("SourceSheet", "SoftTags_IEC61850"), ("SourceRow", "12"),
+ ("IEDName", "AA1C1F03R4"), ("IPAddress", "192.168.81.70"), ("IEDRole", "BCU - 6MD85"),
+ ("Location", "ELECT-CCPP"), ("VoltageLevel", "66kV"), ("Switchgear", "261-SWG-51001"),
+ ("SignalName", "CB closed"), ("SignalAddress", "CBClsd"), ("DataType", "SDI"),
+ ("LDInst", "ADD"), ("LN", "GGIO6"), ("FC", "ST"), ("DO", "CBClsd"), ("DA", "stVal"),
+ ("ObjectReference", "AA1C1F03R4ADD/GGIO6.CBClsd.stVal"), ("DataSetName", "Digital"),
+ ("ExpectedONRaw", "1"), ("ExpectedONText", "Active"),
+ ("ExpectedOFFRaw", "0"), ("ExpectedOFFText", "InActive")),
+ Row(headers,
+ ("ProjectId", "CCPP-260728"), ("SchemaVersion", "ARSAS-FAT-IO-1.0"),
+ ("TestPointId", "TP-002"), ("TestEnabled", "true"), ("ImportReady", "true"),
+ ("BindingStatus", "CID_DATASET_EXACT"), ("SourceSheet", "SoftTags_IEC61850"), ("SourceRow", "237"),
+ ("IEDName", "AA1C1F03R3"), ("IPAddress", "192.168.81.69"), ("IEDRole", "IED - 7SX80"),
+ ("SignalName", "Protection operated (51)"), ("SignalAddress", "Op.general"), ("DataType", "SDI"),
+ ("LDInst", "VI3p1_5051OC3phase1"), ("LN", "II_PTOC1"), ("FC", "ST"), ("DO", "Op"), ("DA", "general"),
+ ("ObjectReference", "AA1C1F03R3VI3p1_5051OC3phase1/II_PTOC1.Op.general"),
+ ("ExpectedONRaw", "1"), ("ExpectedONText", "Operated"),
+ ("ExpectedOFFRaw", "0"), ("ExpectedOFFText", "Normal")),
+ Row(headers,
+ ("ProjectId", "CCPP-260728"), ("SchemaVersion", "ARSAS-FAT-IO-1.0"),
+ ("TestPointId", "ANALOG-001"), ("TestEnabled", "true"), ("ImportReady", "true"),
+ ("BindingStatus", "CID_MODEL_EXACT"), ("SourceSheet", "SoftTags_IEC61850"), ("SourceRow", "262"),
+ ("IEDName", "AA1C1F03R4"), ("IPAddress", "192.168.81.70"),
+ ("SignalName", "Phase L1-L2 Voltage"), ("DataType", "SAI"), ("FC", "MX"))
+ };
+ var workbook = BuildWorkbook(headers, rows);
+
+ var result = _importer.Import(workbook, "CCPP-IO-List.xlsx");
+
+ Assert.True(result.IsValid);
+ Assert.Equal(2, result.Project.Ieds.Count);
+ Assert.Equal(2, result.Project.SignalCount);
+ Assert.Equal(2, result.Project.ReadySignalCount);
+ Assert.Equal(2, result.ParsedRowCount);
+ Assert.Equal(64, result.Project.SourceWorkbookSha256.Length);
+ Assert.Contains(result.ParserFindings, finding => finding.Code == "XLSX_NON_SDI_SKIPPED");
+
+ var bcu = Assert.Single(result.Project.Ieds.Where(ied => ied.IedName == "AA1C1F03R4"));
+ var signal = Assert.Single(bcu.TestPoints);
+ Assert.Equal("CB closed", signal.SignalName);
+ Assert.Equal("Active", signal.ExpectedOnText);
+ Assert.Equal("InActive", signal.ExpectedOffText);
+ Assert.Equal(12, signal.SourceRow);
+ }
+
+ [Fact]
+ public void MissingRequiredHeader_IsRejectedWithoutGuessing()
+ {
+ var headers = RequiredHeaders().Where(header => header != "ObjectReference").ToArray();
+ var workbook = BuildWorkbook(headers, new[] { Row(headers, ("TestPointId", "TP-001")) });
+
+ var result = _importer.Import(workbook, "broken.xlsx");
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.ParserFindings, finding =>
+ finding.Code == "XLSX_HEADER_MISSING" && finding.Message.Contains("ObjectReference"));
+ }
+
+ private static string[] RequiredHeaders() =>
+ [
+ "ProjectId", "SchemaVersion", "TestPointId", "TestEnabled", "ImportReady",
+ "BindingStatus", "BindingEvidence", "SourceSheet", "SourceRow", "IEDName", "IPAddress",
+ "IEDRole", "Location", "VoltageLevel", "Switchgear", "SignalName", "SignalAddress",
+ "DataType", "LDInst", "LN", "FC", "DO", "DA", "ObjectReference", "DataSetName",
+ "ExpectedONRaw", "ExpectedONText", "ExpectedOFFRaw", "ExpectedOFFText"
+ ];
+
+ private static string[] Row(string[] headers, params (string Header, string Value)[] values)
+ {
+ var lookup = values.ToDictionary(item => item.Header, item => item.Value, StringComparer.OrdinalIgnoreCase);
+ return headers.Select(header => lookup.TryGetValue(header, out var value) ? value : string.Empty).ToArray();
+ }
+
+ private static byte[] BuildWorkbook(string[] headers, IEnumerable rows)
+ {
+ using var memory = new MemoryStream();
+ using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ Write(archive, "xl/workbook.xml",
+ "" +
+ "" +
+ "");
+ Write(archive, "xl/_rels/workbook.xml.rels",
+ "" +
+ "" +
+ "" +
+ "");
+
+ var allRows = new List { headers };
+ allRows.AddRange(rows);
+ var sheet = new StringBuilder("");
+ for (var rowIndex = 0; rowIndex < allRows.Count; rowIndex++)
+ {
+ sheet.Append("");
+ for (var columnIndex = 0; columnIndex < allRows[rowIndex].Length; columnIndex++)
+ {
+ var value = SecurityElement.Escape(allRows[rowIndex][columnIndex]) ?? string.Empty;
+ sheet.Append("").Append(value).Append("");
+ }
+ sheet.Append("
");
+ }
+ sheet.Append("");
+ Write(archive, "xl/worksheets/sheet1.xml", sheet.ToString());
+ }
+ return memory.ToArray();
+ }
+
+ private static void Write(ZipArchive archive, string path, string content)
+ {
+ var entry = archive.CreateEntry(path, CompressionLevel.Fastest);
+ using var stream = entry.Open();
+ using var writer = new StreamWriter(stream, new UTF8Encoding(false));
+ writer.Write(content);
+ }
+
+ private static string ColumnName(int zeroBasedIndex)
+ {
+ var index = zeroBasedIndex + 1;
+ var name = string.Empty;
+ while (index > 0)
+ {
+ index--;
+ name = (char)('A' + index % 26) + name;
+ index /= 26;
+ }
+ return name;
+ }
+}
diff --git a/tests/ARSAS.Tests/IoTestImportValidatorTests.cs b/tests/ARSAS.Tests/IoTestImportValidatorTests.cs
new file mode 100644
index 000000000..85a0b1970
--- /dev/null
+++ b/tests/ARSAS.Tests/IoTestImportValidatorTests.cs
@@ -0,0 +1,120 @@
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ARSAS.Tests;
+
+public sealed class IoTestImportValidatorTests
+{
+ private readonly IoTestImportValidator _validator = new();
+
+ [Fact]
+ public void ValidProject_IsAccepted()
+ {
+ var project = CreateProject(CreatePoint("TP-001"));
+
+ var result = _validator.Validate(project);
+
+ Assert.True(result.IsValid);
+ Assert.Equal(1, result.IedCount);
+ Assert.Equal(1, result.SignalCount);
+ Assert.Equal(1, result.ReadySignalCount);
+ Assert.Empty(result.Findings);
+ }
+
+ [Fact]
+ public void DuplicateTestPointId_IsRejected()
+ {
+ var project = CreateProject(CreatePoint("TP-001"), CreatePoint("TP-001"));
+
+ var result = _validator.Validate(project);
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.Findings, finding => finding.Code == "TEST_POINT_DUPLICATE");
+ }
+
+ [Fact]
+ public void ImportReadySignalWithoutReference_IsRejected()
+ {
+ var point = CreatePoint("TP-001", objectReference: string.Empty);
+ var project = CreateProject(point);
+
+ var result = _validator.Validate(project);
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.Findings, finding => finding.Code == "REFERENCE_REQUIRED");
+ }
+
+ [Fact]
+ public void NonReadySignal_IsRetainedAsWarning()
+ {
+ var point = CreatePoint("TP-001", objectReference: string.Empty, importReady: false);
+ var project = CreateProject(point);
+
+ var result = _validator.Validate(project);
+
+ Assert.True(result.IsValid);
+ Assert.Contains(result.Findings, finding =>
+ finding.Code == "SIGNAL_REVIEW_REQUIRED" &&
+ finding.Severity == IoTestImportFindingSeverity.Warning);
+ }
+
+ [Fact]
+ public void UnsupportedSchema_IsRejected()
+ {
+ var project = CreateProject(CreatePoint("TP-001"));
+ project = new IoTestProject
+ {
+ ProjectId = project.ProjectId,
+ ProjectName = project.ProjectName,
+ SchemaVersion = "ARSAS-FAT-IO-9.9",
+ Ieds = project.Ieds
+ };
+
+ var result = _validator.Validate(project);
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.Findings, finding => finding.Code == "SCHEMA_UNSUPPORTED");
+ }
+
+ private static IoTestProject CreateProject(params IoTestPointPlan[] points)
+ {
+ return new IoTestProject
+ {
+ ProjectId = "CCPP-260728",
+ ProjectName = "CCPP FAT",
+ SchemaVersion = IoTestImportValidator.SupportedSchemaVersion,
+ Ieds =
+ {
+ new IoTestIedPlan
+ {
+ IedName = "AA1C1F03R4",
+ IpAddress = "192.168.81.70",
+ IedRole = "BCU - 6MD85",
+ TestPoints = points.ToList()
+ }
+ }
+ };
+ }
+
+ private static IoTestPointPlan CreatePoint(
+ string testPointId,
+ string objectReference = "AA1C1F03R4ADD/GGIO6.CBClsd.stVal",
+ bool importReady = true)
+ {
+ return new IoTestPointPlan
+ {
+ TestPointId = testPointId,
+ IedName = "AA1C1F03R4",
+ IpAddress = "192.168.81.70",
+ SignalName = "CB closed",
+ ObjectReference = objectReference,
+ FunctionalConstraint = "ST",
+ ExpectedOnText = "Active",
+ ExpectedOffText = "InActive",
+ ImportReady = importReady,
+ BindingStatus = importReady ? "CID_DATASET_EXACT" : "REFERENCE_MISSING",
+ SourceSheet = "ARSAS_SIGNAL_IMPORT",
+ SourceRow = 2
+ };
+ }
+}
diff --git a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs
new file mode 100644
index 000000000..ce13fbd15
--- /dev/null
+++ b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs
@@ -0,0 +1,131 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ARSAS.Tests;
+
+public sealed class IoTestLiveBindingServiceTests
+{
+ private readonly IoTestLiveBindingService _binding = new();
+
+ [Fact]
+ public void ExactDiscoveredSignal_IsBoundToImportedPoint()
+ {
+ var project = Project("AA1C1F03R4ADD/GGIO6.CBClsd.stVal");
+ var device = Device();
+ device.Signals.Add(new SignalDefinition
+ {
+ Name = "CB closed",
+ ObjectReference = "AA1C1F03R4ADD/GGIO6.CBClsd.stVal",
+ FunctionalConstraint = "ST"
+ });
+
+ var summary = _binding.Bind(project, new[] { device });
+
+ Assert.Equal(1, summary.DeviceBoundCount);
+ Assert.Equal(1, summary.SignalBoundCount);
+ Assert.Equal(IoTestLiveBindingState.BoundExact, project.Ieds[0].TestPoints[0].LiveBindingState);
+ }
+
+ [Fact]
+ public void IedPrefixDifference_IsNormalizedWhenUnique()
+ {
+ var project = Project("ADD/GGIO6.CBClsd.stVal");
+ var device = Device();
+ device.Signals.Add(new SignalDefinition
+ {
+ Name = "CB closed",
+ ObjectReference = "AA1C1F03R4ADD/GGIO6.CBClsd.stVal",
+ FunctionalConstraint = "ST"
+ });
+
+ _binding.Bind(project, new[] { device });
+
+ Assert.Equal(IoTestLiveBindingState.BoundNormalized, project.Ieds[0].TestPoints[0].LiveBindingState);
+ }
+
+ [Fact]
+ public void ActiveLivePoint_PopulatesCurrentEvidencePreview()
+ {
+ var project = Project("AA1C1F03R4ADD/GGIO6.CBClsd.stVal");
+ var device = Device();
+ device.Points.Add(new Iec61850MonitorPoint
+ {
+ DeviceId = device.DeviceId,
+ DeviceName = device.Name,
+ IpAddress = device.IpAddress,
+ SignalName = "CB closed",
+ IecReference = "AA1C1F03R4ADD/GGIO6.CBClsd.stVal",
+ FunctionalConstraint = "ST",
+ Value = "True",
+ Quality = "Good",
+ SourceMode = "BRCB"
+ });
+
+ var summary = _binding.Bind(project, new[] { device });
+
+ Assert.Equal(1, summary.LivePointCount);
+ var point = project.Ieds[0].TestPoints[0];
+ Assert.Equal(IoTestLiveBindingState.LivePointReady, point.LiveBindingState);
+ Assert.Equal("True", point.Runtime.CurrentValue);
+ Assert.Equal("Good", point.Runtime.CurrentQuality);
+ Assert.Equal("BRCB", point.Runtime.CurrentSource);
+ }
+
+ [Fact]
+ public void MissingWorkspaceDevice_IsExplicitlyReported()
+ {
+ var project = Project("AA1C1F03R4ADD/GGIO6.CBClsd.stVal");
+
+ var summary = _binding.Bind(project, Array.Empty());
+
+ Assert.Equal(0, summary.DeviceBoundCount);
+ Assert.Equal(IoTestLiveBindingState.DeviceNotLoaded, project.Ieds[0].TestPoints[0].LiveBindingState);
+ Assert.Contains("load or connect", project.Ieds[0].TestPoints[0].LiveBindingReason.ToLowerInvariant());
+ }
+
+ private static IoTestProject Project(string reference)
+ {
+ var project = new IoTestProject
+ {
+ ProjectId = "CCPP-260728",
+ SchemaVersion = "ARSAS-FAT-IO-1.0",
+ ProjectName = "CCPP FAT",
+ Ieds =
+ {
+ new IoTestIedPlan
+ {
+ IedName = "AA1C1F03R4",
+ IpAddress = "192.168.81.70",
+ IedRole = "BCU - 6MD85",
+ TestPoints = { Point(reference) }
+ }
+ }
+ };
+ project.InitializeRuntimeNotifications();
+ return project;
+ }
+
+ private static IoTestPointPlan Point(string reference) => new()
+ {
+ TestPointId = "TP-001",
+ IedName = "AA1C1F03R4",
+ IpAddress = "192.168.81.70",
+ SignalName = "CB closed",
+ ObjectReference = reference,
+ FunctionalConstraint = "ST",
+ ExpectedOnText = "Active",
+ ExpectedOffText = "InActive",
+ ImportReady = true,
+ BindingStatus = "CID_DATASET_EXACT"
+ };
+
+ private static Iec61850MonitorDevice Device() => new()
+ {
+ Name = "AA1C1F03R4",
+ SclIedName = "AA1C1F03R4",
+ IpAddress = "192.168.81.70",
+ Port = 102,
+ Status = "Ready"
+ };
+}
diff --git a/tests/ARSAS.Tests/IoTestTransitionEvaluatorTests.cs b/tests/ARSAS.Tests/IoTestTransitionEvaluatorTests.cs
new file mode 100644
index 000000000..f6499d832
--- /dev/null
+++ b/tests/ARSAS.Tests/IoTestTransitionEvaluatorTests.cs
@@ -0,0 +1,155 @@
+using ArIED61850Tester.Models.IoTesting;
+using ArIED61850Tester.Services.IoTesting;
+
+namespace ARSAS.Tests;
+
+public sealed class IoTestTransitionEvaluatorTests
+{
+ private readonly IoTestTransitionEvaluator _evaluator = new();
+
+ [Fact]
+ public void OffOnOffSequence_PassesWithOrderedEvidence()
+ {
+ var point = CreatePoint();
+ _evaluator.StartAttempt(point, Observation(false, 1));
+
+ var on = _evaluator.Observe(point, Observation(true, 2));
+ var off = _evaluator.Observe(point, Observation(false, 3));
+
+ Assert.Equal(IoTestPointState.OnCaptured, on.State);
+ Assert.Equal(IoEvidenceTransition.On, on.Evidence?.Transition);
+ Assert.Equal(IoTestPointState.Passed, off.State);
+ Assert.Equal(IoEvidenceTransition.Off, off.Evidence?.Transition);
+ Assert.NotNull(point.Runtime.OnEvidence);
+ Assert.NotNull(point.Runtime.OffEvidence);
+ Assert.True(point.Runtime.OffEvidence!.CapturedAt > point.Runtime.OnEvidence!.CapturedAt);
+ }
+
+ [Fact]
+ public void InitiallyOn_DoesNotCreateOnEvidenceUntilNewCycle()
+ {
+ var point = CreatePoint();
+ var start = _evaluator.StartAttempt(point, Observation(true, 1));
+
+ Assert.Equal(IoTestPointState.WaitingForOffBaseline, start.State);
+ Assert.Null(point.Runtime.OnEvidence);
+
+ var baselineOff = _evaluator.Observe(point, Observation(false, 2));
+ var on = _evaluator.Observe(point, Observation(true, 3));
+
+ Assert.Equal(IoTestPointState.ArmedForOn, baselineOff.State);
+ Assert.Equal(IoTestPointState.OnCaptured, on.State);
+ Assert.Equal(3L, point.Runtime.OnEvidence?.Sequence);
+ }
+
+ [Fact]
+ public void DuplicateValues_DoNotCreateEvidence()
+ {
+ var point = CreatePoint();
+ _evaluator.StartAttempt(point, Observation(false, 1));
+
+ var duplicateOff = _evaluator.Observe(point, Observation(false, 2));
+ var on = _evaluator.Observe(point, Observation(true, 3));
+ var duplicateOn = _evaluator.Observe(point, Observation(true, 4));
+
+ Assert.False(duplicateOff.StateChanged);
+ Assert.Null(duplicateOff.Evidence);
+ Assert.Equal(IoTestPointState.OnCaptured, on.State);
+ Assert.False(duplicateOn.StateChanged);
+ Assert.Null(duplicateOn.Evidence);
+ Assert.Null(point.Runtime.OffEvidence);
+ }
+
+ [Fact]
+ public void ReconnectAfterOnEvidence_ForcesReviewInsteadOfFalsePass()
+ {
+ var point = CreatePoint();
+ _evaluator.StartAttempt(point, Observation(false, 1, generation: 10));
+ _evaluator.Observe(point, Observation(true, 2, generation: 10));
+
+ var firstImageAfterReconnect = _evaluator.Observe(
+ point,
+ Observation(false, 1, generation: 11));
+
+ Assert.Equal(IoTestPointState.Review, firstImageAfterReconnect.State);
+ Assert.Null(point.Runtime.OffEvidence);
+ Assert.Contains("continuity cannot be proven", point.Runtime.StatusReason);
+ }
+
+ [Fact]
+ public void QuestionableOnQuality_CapturesEvidenceButFinalResultNeedsReview()
+ {
+ var point = CreatePoint();
+ _evaluator.StartAttempt(point, Observation(false, 1));
+
+ var on = _evaluator.Observe(point, Observation(true, 2, quality: "Questionable"));
+ var off = _evaluator.Observe(point, Observation(false, 3, quality: "Good"));
+
+ Assert.Equal(IoEvidenceVerdict.Review, on.Evidence?.Verdict);
+ Assert.Equal(IoTestPointState.Review, off.State);
+ Assert.NotNull(point.Runtime.OnEvidence);
+ Assert.NotNull(point.Runtime.OffEvidence);
+ }
+
+ [Fact]
+ public void InvalidQuality_DoesNotAdvanceTheStateMachine()
+ {
+ var point = CreatePoint();
+ _evaluator.StartAttempt(point, Observation(false, 1));
+
+ var invalidOn = _evaluator.Observe(point, Observation(true, 2, quality: "Invalid"));
+
+ Assert.Equal(IoTestPointState.ArmedForOn, invalidOn.State);
+ Assert.Equal(IoEvidenceVerdict.Rejected, invalidOn.Evidence?.Verdict);
+ Assert.Null(point.Runtime.OnEvidence);
+ }
+
+ [Fact]
+ public void OutOfOrderSequence_IsIgnored()
+ {
+ var point = CreatePoint();
+ _evaluator.StartAttempt(point, Observation(false, 10));
+
+ var stale = _evaluator.Observe(point, Observation(true, 9));
+
+ Assert.False(stale.StateChanged);
+ Assert.Null(stale.Evidence);
+ Assert.Equal(IoTestPointState.ArmedForOn, stale.State);
+ }
+
+ private static IoTestPointPlan CreatePoint()
+ {
+ return new IoTestPointPlan
+ {
+ TestPointId = "CCPP-AA1C1F03R4-0001",
+ IedName = "AA1C1F03R4",
+ IpAddress = "192.168.81.70",
+ SignalName = "CB closed",
+ ObjectReference = "AA1C1F03R4ADD/GGIO6.CBClsd.stVal",
+ FunctionalConstraint = "ST",
+ ExpectedOnText = "Active",
+ ExpectedOffText = "InActive",
+ ImportReady = true,
+ BindingStatus = "CID_DATASET_EXACT"
+ };
+ }
+
+ private static IoTestObservation Observation(
+ bool state,
+ long sequence,
+ long generation = 1,
+ string quality = "Good")
+ {
+ var captured = new DateTimeOffset(2026, 7, 28, 8, 0, 0, TimeSpan.Zero)
+ .AddMilliseconds(sequence * 100);
+ return new IoTestObservation(
+ state,
+ state ? "true" : "false",
+ captured,
+ captured.AddMilliseconds(-5),
+ quality,
+ "BRCB",
+ sequence,
+ generation);
+ }
+}