Skip to content
Closed
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
14 changes: 14 additions & 0 deletions Models/IoTesting/FatLiveValueObservation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace ArIED61850Tester.Models.IoTesting;

/// <summary>
/// Generic live IEC 61850 value image used by FAT v2. Unlike the legacy digital
/// observation model, the raw value is intentionally not normalized to bool.
/// </summary>
public sealed record FatLiveValueObservation(
string RawValue,
DateTimeOffset CapturedAt,
DateTimeOffset? IedTimestamp,
string Quality,
string AcquisitionSource,
long Sequence,
long ConnectionGeneration);
170 changes: 170 additions & 0 deletions Models/IoTesting/FatVerificationModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System.Text.Json.Serialization;

namespace ArIED61850Tester.Models.IoTesting;

/// <summary>
/// FAT v2 treats every static DataSet member as verification scope by default.
/// Inclusion is an operator decision and must never be changed by acquisition state.
/// </summary>
public enum FatSignalDisposition
{
Included,
ExcludedByOperator
}

public enum FatSignalKind
{
Discrete,
Analog,
Other
}

public enum FatCaptureMode
{
AutomaticTransition,
OperatorSnapshot
}

public enum FatValueSlot
{
Value1,
Value2
}

public enum FatEvidenceCaptureKind
{
AutomaticTransition,
OperatorSnapshot
}

public sealed record FatValueEvidence(
Guid EvidenceId,
FatValueSlot Slot,
FatEvidenceCaptureKind CaptureKind,
string RawValue,
DateTimeOffset CapturedAt,
DateTimeOffset? IedTimestamp,
string Quality,
string AcquisitionSource,
long Sequence,
long ConnectionGeneration);

/// <summary>
/// One FAT row. Identity follows static DataSet membership, not only the resolved runtime
/// reference, so the same IEC object may legitimately appear in more than one DataSet.
/// </summary>
public sealed class FatVerificationSignal
{
[JsonInclude]
public FatSignalDisposition Disposition { get; private set; } = FatSignalDisposition.Included;

public required string SignalId { get; init; }
public required string IedName { get; init; }
public string AccessPointName { get; init; } = string.Empty;
public required string DataSetReference { get; init; }
public int DataSetMemberIndex { get; init; }
public required string StaticMemberReference { get; init; }
public required string RuntimeReference { get; init; }
public string SignalName { get; init; } = string.Empty;
public string FunctionalConstraint { get; init; } = string.Empty;
public string DataType { get; init; } = string.Empty;
public FatSignalKind SignalKind { get; init; } = FatSignalKind.Other;
public FatCaptureMode CaptureMode { get; init; } = FatCaptureMode.OperatorSnapshot;

[JsonInclude]
public FatValueEvidence? Value1Evidence { get; private set; }

[JsonInclude]
public FatValueEvidence? Value2Evidence { get; private set; }

[JsonIgnore]
public bool IsIncludedInFat => Disposition == FatSignalDisposition.Included;

[JsonIgnore]
public bool HasCompleteEvidence => Value1Evidence is not null && Value2Evidence is not null;

/// <summary>
/// Explicit operator action. Removing a row never destroys evidence or source identity.
/// </summary>
public void RemoveFromFat() => Disposition = FatSignalDisposition.ExcludedByOperator;

/// <summary>
/// Explicit operator action. Restoring a row returns the same row and evidence to scope.
/// </summary>
public void RestoreToFat() => Disposition = FatSignalDisposition.Included;

public void SetCurrentEvidence(FatValueEvidence evidence)
{
ArgumentNullException.ThrowIfNull(evidence);
if (evidence.Slot == FatValueSlot.Value1)
Value1Evidence = evidence;
else
Value2Evidence = evidence;
}
}

public sealed class FatVerificationProject
{
public string ProjectId { get; init; } = Guid.NewGuid().ToString("N");
public List<FatVerificationSignal> Signals { get; init; } = new();

[JsonIgnore]
public IReadOnlyList<FatVerificationSignal> IncludedSignals =>
Signals.Where(signal => signal.IsIncludedInFat).ToArray();

[JsonIgnore]
public IReadOnlyList<FatVerificationSignal> RemovedSignals =>
Signals.Where(signal => !signal.IsIncludedInFat).ToArray();

public bool RemoveSignal(string signalId)
{
var signal = FindSignal(signalId);
if (signal is null)
return false;
signal.RemoveFromFat();
return true;
}

public bool RestoreSignal(string signalId)
{
var signal = FindSignal(signalId);
if (signal is null)
return false;
signal.RestoreToFat();
return true;
}

public int RestoreSignals(IEnumerable<string> signalIds)
{
ArgumentNullException.ThrowIfNull(signalIds);
var requested = signalIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
var restored = 0;
foreach (var signal in Signals)
{
if (!requested.Contains(signal.SignalId) || signal.IsIncludedInFat)
continue;
signal.RestoreToFat();
restored++;
}
return restored;
}

public int RestoreAllSignals()
{
var restored = 0;
foreach (var signal in Signals.Where(signal => !signal.IsIncludedInFat))
{
signal.RestoreToFat();
restored++;
}
return restored;
}

private FatVerificationSignal? FindSignal(string signalId)
{
if (string.IsNullOrWhiteSpace(signalId))
return null;
return Signals.FirstOrDefault(signal =>
signal.SignalId.Equals(signalId, StringComparison.OrdinalIgnoreCase));
}
}
155 changes: 155 additions & 0 deletions Services/IoTesting/FatDataSetSignalProjectionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using AR.Iec61850.Discovery;
using AR.Iec61850.Scl.Workspace;
using ArIED61850Tester.Models.IoTesting;

namespace ArIED61850Tester.Services.IoTesting;

/// <summary>
/// ARSAS FAT v2 consumer adapter over ARIEC-owned static DataSet authority.
///
/// This service does not parse SCL, infer FCDA semantics, or deduplicate by runtime object
/// reference. ARIEC remains the authority for mandatory DataSet descriptors; ARSAS creates
/// exactly one FAT row per static DataSet membership.
/// </summary>
public static class FatDataSetSignalProjectionService
{
public static IReadOnlyList<FatVerificationSignal> Project(SclIedWorkspace workspace)
{
ArgumentNullException.ThrowIfNull(workspace);
ArgumentNullException.ThrowIfNull(workspace.DesignModel);
return Project(workspace.IedName, workspace.AccessPointName, workspace.DesignModel);
}

public static IReadOnlyList<FatVerificationSignal> Project(
string iedName,
string accessPointName,
LiveIedModelDiscoveryDocument model)
{
ArgumentException.ThrowIfNullOrWhiteSpace(iedName);
ArgumentNullException.ThrowIfNull(model);

var descriptors = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model);
var rows = descriptors
.SelectMany(descriptor => descriptor.DataSetMemberships.Select(membership =>
CreateRow(iedName.Trim(), accessPointName?.Trim() ?? string.Empty, descriptor, membership)))
.OrderBy(row => row.DataSetReference, StringComparer.OrdinalIgnoreCase)
.ThenBy(row => row.DataSetMemberIndex)
.ThenBy(row => row.StaticMemberReference, StringComparer.OrdinalIgnoreCase)
.ToArray();

var staticMemberCount = model.DataSets.Sum(dataSet => dataSet.Members.Count);
if (rows.Length != staticMemberCount)
{
throw new InvalidDataException(
$"ARIEC static DataSet projection returned {rows.Length} FAT membership row(s), " +
$"but the SCL design model contains {staticMemberCount}. FAT import refuses to silently drop or invent DataSet members.");
}

var duplicateIdentity = rows
.GroupBy(row => row.SignalId, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateIdentity is not null)
{
throw new InvalidDataException(
$"Static DataSet membership identity is not unique for FAT signal '{duplicateIdentity.Key}'.");
}

return rows;
}

private static FatVerificationSignal CreateRow(
string iedName,
string accessPointName,
Iec61850SignalDescriptor descriptor,
Iec61850SignalDataSetMembership membership)
{
var staticReference = FirstNonEmpty(
membership.CanonicalMemberReference,
membership.OriginalMemberReference,
descriptor.DesignReference,
descriptor.ObservedReference,
descriptor.PrimaryValueReference);
var runtimeReference = FirstNonEmpty(
descriptor.PrimaryValueReference,
descriptor.DesignReference,
descriptor.ObservedReference,
staticReference);
var dataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown");
var signalKind = Classify(descriptor.FunctionalConstraint, dataType);

return new FatVerificationSignal
{
SignalId = BuildSignalId(
iedName,
accessPointName,
membership.DataSetReference,
membership.MemberIndex,
staticReference),
IedName = iedName,
AccessPointName = accessPointName,
DataSetReference = membership.DataSetReference,
DataSetMemberIndex = membership.MemberIndex,
StaticMemberReference = staticReference,
RuntimeReference = runtimeReference,
SignalName = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, staticReference),
FunctionalConstraint = descriptor.FunctionalConstraint,
DataType = dataType,
SignalKind = signalKind,
CaptureMode = signalKind == FatSignalKind.Discrete
? FatCaptureMode.AutomaticTransition
: FatCaptureMode.OperatorSnapshot
};
}

internal static FatSignalKind Classify(string? functionalConstraint, string? dataType)
{
var fc = (functionalConstraint ?? string.Empty).Trim().ToUpperInvariant();
var type = (dataType ?? string.Empty).Trim().ToUpperInvariant();

if (fc == "MX" ||
type.Contains("FLOAT", StringComparison.Ordinal) ||
type.Contains("DOUBLE", StringComparison.Ordinal) ||
type.Contains("ANALOG", StringComparison.Ordinal))
{
return FatSignalKind.Analog;
}

if (type.Contains("BOOLEAN", StringComparison.Ordinal) ||
type.Contains("BOOL", StringComparison.Ordinal) ||
type.Contains("DBPOS", StringComparison.Ordinal) ||
type.Contains("DOUBLE-POINT", StringComparison.Ordinal) ||
type.Contains("DOUBLE POINT", StringComparison.Ordinal))
{
return FatSignalKind.Discrete;
}

// ST is intentionally not assumed to be boolean by itself. Enumerated/integer ST
// values need an explicit discrete semantic before automatic two-state capture.
return FatSignalKind.Other;
}

private static string BuildSignalId(
string iedName,
string accessPointName,
string dataSetReference,
int memberIndex,
string staticReference)
{
var identity = string.Join("|", new[]
{
iedName.Trim(),
accessPointName.Trim(),
dataSetReference.Trim(),
memberIndex.ToString(CultureInfo.InvariantCulture),
staticReference.Trim()
});
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(identity));
return "fat-" + Convert.ToHexString(bytes).ToLowerInvariant();
}

private static string FirstNonEmpty(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
}
57 changes: 57 additions & 0 deletions Services/IoTesting/FatOperatorSnapshotCaptureService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using ArIED61850Tester.Models.IoTesting;

namespace ArIED61850Tester.Services.IoTesting;

/// <summary>
/// Captures the value currently read from the IED when the FAT operator explicitly
/// confirms Value 1 or Value 2. No injected/reference value or tolerance exists here;
/// this first public milestone proves trustworthy reading capture only.
/// </summary>
public static class FatOperatorSnapshotCaptureService
{
public static FatValueEvidence Capture(
FatVerificationSignal signal,
FatValueSlot slot,
FatLiveValueObservation observation)
{
ArgumentNullException.ThrowIfNull(signal);
ArgumentNullException.ThrowIfNull(observation);

if (!signal.IsIncludedInFat)
{
throw new InvalidOperationException(
$"FAT signal '{signal.SignalName}' is removed from the active FAT scope and cannot capture evidence until it is restored.");
}

if (signal.CaptureMode != FatCaptureMode.OperatorSnapshot)
{
throw new InvalidOperationException(
$"FAT signal '{signal.SignalName}' uses {signal.CaptureMode} capture and cannot be manually snapshotted.");
}

if (string.IsNullOrWhiteSpace(observation.RawValue) ||
observation.RawValue.Trim() is "-" or "—")
{
throw new InvalidOperationException(
$"FAT signal '{signal.SignalName}' does not have a readable live value to capture.");
}

var evidence = new FatValueEvidence(
Guid.NewGuid(),
slot,
FatEvidenceCaptureKind.OperatorSnapshot,
observation.RawValue.Trim(),
observation.CapturedAt,
observation.IedTimestamp,
string.IsNullOrWhiteSpace(observation.Quality) ? "Unknown" : observation.Quality.Trim(),
string.IsNullOrWhiteSpace(observation.AcquisitionSource) ? "Unknown" : observation.AcquisitionSource.Trim(),
observation.Sequence,
observation.ConnectionGeneration);

// This intentionally replaces only the current Value 1/Value 2 evidence pointer.
// The session/journal adapter remains responsible for appending every capture to
// immutable historical evidence before this service is wired into the public UI.
signal.SetCurrentEvidence(evidence);
return evidence;
}
}
Loading
Loading