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
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
namespace AR.Iec61850.SampledValues.Measurements;

/// <summary>
/// Describes how a decoded Sampled Values number is converted into an engineering value.
/// The source and confidence are deliberately explicit so unknown traffic is never labelled A or V by guesswork.
/// </summary>
public sealed record SvEngineeringScale
{
public static SvEngineeringScale RawOnly(string reason) => new()
{
Multiplier = 1.0,
Unit = "count",
Source = SvEngineeringScaleSource.RawOnly,
Confidence = SvEngineeringScaleConfidence.Unknown,
Reason = reason
};

public double Multiplier { get; init; } = 1.0;
public double Offset { get; init; }
public string Unit { get; init; } = "count";
public SvEngineeringScaleSource Source { get; init; } = SvEngineeringScaleSource.RawOnly;
public SvEngineeringScaleConfidence Confidence { get; init; } = SvEngineeringScaleConfidence.Unknown;
public string Reason { get; init; } = string.Empty;
public bool HasEngineeringUnit => Source != SvEngineeringScaleSource.RawOnly;

public double Apply(double rawValue) => (rawValue * Multiplier) + Offset;
}

public enum SvEngineeringScaleSource
{
RawOnly,
Legacy92LeStyleStructuralInference,
SclBackedLegacy92LeStyle,
ManualOverride
}

public enum SvEngineeringScaleConfidence
{
Unknown,
Inferred,
SclBacked,
DeviceValidated
}

/// <summary>
/// Evidence supplied to the scale resolver. This is intentionally vendor-neutral.
/// Product identity may be retained in an evidence report, but it is not a scaling rule.
/// </summary>
public sealed record SvEngineeringScaleEvidence
{
public string Channel { get; init; } = string.Empty;
public string Kind { get; init; } = string.Empty;
public bool IsSclBound { get; init; }
public bool IsFixedFourCurrentFourVoltageLayout { get; init; }
public int AnalogChannelCount { get; init; }
public int PayloadBytesPerAsdu { get; init; }
public ushort? DeclaredSampleRate { get; init; }
public ushort? DeclaredSampleMode { get; init; }
public double? ObservedSamplesPerSecond { get; init; }
}

/// <summary>
/// Resolves conservative engineering scaling for installed-base 9-2LE-style protection streams.
/// The resolver requires structural evidence plus sampling or SCL evidence. Otherwise it returns raw counts.
/// </summary>
public static class SvEngineeringScaleResolver
{
private const double CurrentAmperesPerCount = 0.001;
private const double VoltageVoltsPerCount = 0.01;
private const double RateToleranceFraction = 0.02;

public static SvEngineeringScale Resolve(SvEngineeringScaleEvidence evidence)
{
ArgumentNullException.ThrowIfNull(evidence);

var domain = ResolveDomain(evidence.Channel, evidence.Kind);
if (domain == SvMeasurementDomain.Unknown)
return SvEngineeringScale.RawOnly("The channel could not be classified as current or voltage.");

var fixedLayout = evidence.IsFixedFourCurrentFourVoltageLayout &&
evidence.AnalogChannelCount == 8 &&
evidence.PayloadBytesPerAsdu == 64;
if (!fixedLayout)
return SvEngineeringScale.RawOnly("The payload is not proven to be the fixed 4I+4U value-quality layout.");

var samplingEvidence = HasProtectionRateEvidence(evidence);
if (!evidence.IsSclBound && !samplingEvidence)
return SvEngineeringScale.RawOnly("The fixed layout is visible, but sampling or SCL evidence is insufficient for engineering scaling.");

var source = evidence.IsSclBound
? SvEngineeringScaleSource.SclBackedLegacy92LeStyle
: SvEngineeringScaleSource.Legacy92LeStyleStructuralInference;
var confidence = evidence.IsSclBound
? SvEngineeringScaleConfidence.SclBacked
: SvEngineeringScaleConfidence.Inferred;
var reason = evidence.IsSclBound
? "SCL binding and fixed 4I+4U structural evidence support installed-base 9-2LE-style scaling."
: "Fixed 4I+4U structure and protection-rate evidence support provisional 9-2LE-style scaling.";

return domain switch
{
SvMeasurementDomain.Current => new SvEngineeringScale
{
Multiplier = CurrentAmperesPerCount,
Unit = "A",
Source = source,
Confidence = confidence,
Reason = reason
},
SvMeasurementDomain.Voltage => new SvEngineeringScale
{
Multiplier = VoltageVoltsPerCount,
Unit = "V",
Source = source,
Confidence = confidence,
Reason = reason
},
_ => SvEngineeringScale.RawOnly("Unsupported measurement domain.")
};
}

public static SvMeasurementDomain ResolveDomain(string channel, string kind)
{
var normalizedKind = kind?.Trim() ?? string.Empty;
if (normalizedKind.Contains("voltage", StringComparison.OrdinalIgnoreCase))
return SvMeasurementDomain.Voltage;
if (normalizedKind.Contains("current", StringComparison.OrdinalIgnoreCase))
return SvMeasurementDomain.Current;

var normalizedChannel = channel?.Trim() ?? string.Empty;
if (normalizedChannel.StartsWith("V", StringComparison.OrdinalIgnoreCase) ||
normalizedChannel.Contains("TVTR", StringComparison.OrdinalIgnoreCase) ||
normalizedChannel.Contains("VolSv", StringComparison.OrdinalIgnoreCase))
return SvMeasurementDomain.Voltage;
if (normalizedChannel.StartsWith("I", StringComparison.OrdinalIgnoreCase) ||
normalizedChannel.Contains("TCTR", StringComparison.OrdinalIgnoreCase) ||
normalizedChannel.Contains("AmpSv", StringComparison.OrdinalIgnoreCase))
return SvMeasurementDomain.Current;

return SvMeasurementDomain.Unknown;
}

private static bool HasProtectionRateEvidence(SvEngineeringScaleEvidence evidence)
{
// smpMod=0 means samples per period. 80 samples/cycle is the installed-base protection variant.
if (evidence.DeclaredSampleMode == 0 && evidence.DeclaredSampleRate == 80)
return true;

// smpMod=1 means samples per second. Accept the 50 Hz and 60 Hz protection rates.
if (evidence.DeclaredSampleMode == 1 &&
IsNearOneOf(evidence.DeclaredSampleRate, 4_000, 4_800))
return true;

return IsNearOneOf(evidence.ObservedSamplesPerSecond, 4_000, 4_800);
}

private static bool IsNearOneOf(double? value, params double[] candidates)
{
if (!value.HasValue || value.Value <= 0)
return false;

return candidates.Any(candidate =>
Math.Abs(value.Value - candidate) <= candidate * RateToleranceFraction);
}
}

public enum SvMeasurementDomain
{
Unknown,
Current,
Voltage
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
namespace AR.Iec61850.SampledValues.Measurements;

public enum SvSampleCounterTransitionKind
{
Initial,
Continuous,
NormalWrap,
Gap,
Duplicate,
OutOfOrder,
Restart
}

public sealed record SvSampleCounterTransition
{
public SvSampleCounterTransitionKind Kind { get; init; }
public ushort Actual { get; init; }
public ushort? Previous { get; init; }
public ushort? Expected { get; init; }
public ushort? Wrap { get; init; }
public int MissingSamples { get; init; }
public string Detail { get; init; } = string.Empty;
public bool IsAnomaly => Kind is SvSampleCounterTransitionKind.Gap or
SvSampleCounterTransitionKind.Duplicate or
SvSampleCounterTransitionKind.OutOfOrder;
}

/// <summary>
/// Stateful, profile-neutral smpCnt tracker. A known modulus is preferred; unknown traffic falls back to the ushort modulus.
/// Explicit restart hints prevent a publisher restart from being misclassified as out-of-order traffic.
/// </summary>
public sealed class SvSampleCounterTracker
{
private ushort? _last;
private ushort? _expected;

public ushort? Last => _last;
public ushort? Expected => _expected;

public void Reset()
{
_last = null;
_expected = null;
}

public SvSampleCounterTransition Observe(ushort actual, ushort? wrap, bool restartHint = false)
{
if (restartHint && _last.HasValue)
{
var previous = _last;
_last = actual;
_expected = Next(actual, wrap);
return new SvSampleCounterTransition
{
Kind = SvSampleCounterTransitionKind.Restart,
Actual = actual,
Previous = previous,
Expected = null,
Wrap = wrap,
Detail = "The counter state was reset by trusted restart/configuration evidence."
};
}

if (!_last.HasValue)
{
_last = actual;
_expected = Next(actual, wrap);
return new SvSampleCounterTransition
{
Kind = SvSampleCounterTransitionKind.Initial,
Actual = actual,
Wrap = wrap,
Detail = "Initial sample counter observation."
};
}

var previousValue = _last.Value;
if (actual == previousValue)
{
return new SvSampleCounterTransition
{
Kind = SvSampleCounterTransitionKind.Duplicate,
Actual = actual,
Previous = previousValue,
Expected = _expected,
Wrap = wrap,
Detail = $"Duplicate smpCnt {actual}."
};
}

var expectedValue = _expected ?? Next(previousValue, wrap);
if (actual == expectedValue)
{
var wrapped = actual < previousValue;
_last = actual;
_expected = Next(actual, wrap);
return new SvSampleCounterTransition
{
Kind = wrapped ? SvSampleCounterTransitionKind.NormalWrap : SvSampleCounterTransitionKind.Continuous,
Actual = actual,
Previous = previousValue,
Expected = expectedValue,
Wrap = wrap,
Detail = wrapped ? "Normal smpCnt wrap." : "Continuous smpCnt transition."
};
}

var modulus = ResolveModulus(wrap);
var forward = DistanceForward(expectedValue, actual, modulus);
var backward = DistanceForward(actual, expectedValue, modulus);
var isForwardGap = forward > 0 && forward < backward;

_last = actual;
_expected = Next(actual, wrap);

if (isForwardGap)
{
return new SvSampleCounterTransition
{
Kind = SvSampleCounterTransitionKind.Gap,
Actual = actual,
Previous = previousValue,
Expected = expectedValue,
Wrap = wrap,
MissingSamples = forward,
Detail = $"smpCnt advanced from expected {expectedValue} to {actual}; {forward} sample(s) were not observed."
};
}

return new SvSampleCounterTransition
{
Kind = SvSampleCounterTransitionKind.OutOfOrder,
Actual = actual,
Previous = previousValue,
Expected = expectedValue,
Wrap = wrap,
Detail = $"smpCnt {actual} is behind expected {expectedValue}."
};
}

private static ushort Next(ushort value, ushort? wrap)
{
if (wrap is > 1)
return (ushort)((value + 1) % wrap.Value);
return unchecked((ushort)(value + 1));
}

private static int ResolveModulus(ushort? wrap)
=> wrap is > 1 ? wrap.Value : ushort.MaxValue + 1;

private static int DistanceForward(ushort from, ushort to, int modulus)
{
var distance = ((int)to - from) % modulus;
return distance < 0 ? distance + modulus : distance;
}
}
Loading
Loading