diff --git a/src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs b/src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs index f800076b..453c68d0 100644 --- a/src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs +++ b/src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs @@ -73,7 +73,9 @@ protected override Size MeasureOverride(Size availableSize) { var width = double.IsInfinity(availableSize.Width) ? 360 : availableSize.Width; var height = double.IsInfinity(availableSize.Height) ? 360 : availableSize.Height; - return new Size(Math.Max(280, width), Math.Max(280, height)); + // Do not impose 280px into a 210px high WPF row: the previous desired + // size overflowed its clip and cut off vectors / phase labels. + return new Size(Math.Max(120, width), Math.Max(140, height)); } protected override void OnRender(DrawingContext dc) @@ -121,7 +123,7 @@ protected override void OnRender(DrawingContext dc) DrawSoftOriginGlow(dc, center); foreach (var item in validItems) - DrawPhasor(dc, center, radius, item, voltageMax, currentMax); + DrawPhasor(dc, bounds, center, radius, item, voltageMax, currentMax); } @@ -159,7 +161,7 @@ private static void DrawSoftOriginGlow(DrawingContext dc, Point center) dc.DrawEllipse(OriginBrush, null, center, 4.0, 4.0); } - private static void DrawPhasor(DrawingContext dc, Point center, double radius, PhasorDisplayItem item, double voltageMax, double currentMax) + private static void DrawPhasor(DrawingContext dc, Rect bounds, Point center, double radius, PhasorDisplayItem item, double voltageMax, double currentMax) { var maxMagnitude = item.Family == PhasorFamily.Voltage ? voltageMax : currentMax; if (maxMagnitude <= 0) @@ -192,7 +194,7 @@ private static void DrawPhasor(DrawingContext dc, Point center, double radius, P dc.DrawLine(pen, center, end); DrawArrowHead(dc, center, end, brush, item.Family == PhasorFamily.Voltage ? 17.0 : 15.0); - DrawVectorLabel(dc, item, center, end, brush); + DrawVectorLabel(dc, bounds, item, center, end, brush); } private static void DrawArrowHead(DrawingContext dc, Point start, Point end, Brush brush, double arrowLength) @@ -219,7 +221,7 @@ private static void DrawArrowHead(DrawingContext dc, Point start, Point end, Bru dc.DrawGeometry(brush, null, geometry); } - private static void DrawVectorLabel(DrawingContext dc, PhasorDisplayItem item, Point center, Point end, Brush brush) + private static void DrawVectorLabel(DrawingContext dc, Rect bounds, PhasorDisplayItem item, Point center, Point end, Brush brush) { var direction = end - center; if (direction.Length <= 0.001) @@ -240,7 +242,10 @@ private static void DrawVectorLabel(DrawingContext dc, PhasorDisplayItem item, P else labelPoint.Y -= 17; - DrawText(dc, label, 13, labelPoint, brush, LabelTypeface); + var text = CreateText(label, 11.5, brush, LabelTypeface); + labelPoint.X = Math.Clamp(labelPoint.X, bounds.Left + 4, Math.Max(bounds.Left + 4, bounds.Right - text.Width - 4)); + labelPoint.Y = Math.Clamp(labelPoint.Y, bounds.Top + 4, Math.Max(bounds.Top + 4, bounds.Bottom - text.Height - 4)); + dc.DrawText(text, labelPoint); } private static void DrawCenteredMessage(DrawingContext dc, Point center, string text) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 3c27643e..97dfe7b8 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -2564,7 +2564,7 @@ CornerRadius="9" Padding="12,10" ToolTip="{Binding Diagnostics.ValidityText}"> - + + + + + + + + + diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index d8bc7f4c..936b7ae1 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -878,25 +878,43 @@ public int CurrentWorkspaceTabIndex public IReadOnlyList WaveformTimebaseOptions { get; } = ["1 cycle", "2 cycles", "4 cycles", "8 cycles"]; public IReadOnlyList WaveformModeOptions { get; } = ["Locked"]; public IReadOnlyList WaveformMarkerOptions { get; } = ["On", "Off"]; - public IReadOnlyList EngineeringScaleOptions { get; } = [RawScaleText, NominalScaleText]; + public IReadOnlyList EngineeringScaleOptions { get; } = [NominalScaleText, RawScaleText]; + + private SvEngineeringScale ResolveSelectedStreamScale(string streamId) + { + if (_explicitSvScales.TryGetValue(streamId, out var explicitMode)) + return explicitMode; + + // Use the decoder's recognized 4I+4V instMag/q layout, not an APPID or + // publisher name, as eligibility for an ASSUMED nominal display. + var details = SelectedStreamDetails; + return SelectedStream is not null && + string.Equals(SelectedStream.StreamId, streamId, StringComparison.OrdinalIgnoreCase) && + details is not null && + string.Equals(details.SvId, SelectedStream.SvId, StringComparison.OrdinalIgnoreCase) && + SvEngineeringScaleFactors.IsRecognizedNominalCandidate(details.SampleValueMappingText) + ? SvEngineeringScale.Nominal4I4V + : SvEngineeringScale.RawCounts; + } + public string SelectedEngineeringScale { get => SelectedStream is not null && - _explicitSvScales.TryGetValue(SelectedStream.StreamId, out var mode) && - mode == SvEngineeringScale.Nominal4I4V ? NominalScaleText : RawScaleText; + ResolveSelectedStreamScale(SelectedStream.StreamId) == SvEngineeringScale.Nominal4I4V + ? NominalScaleText : RawScaleText; set { if (SelectedStream is null || (value != RawScaleText && value != NominalScaleText)) return; var next = value == NominalScaleText ? SvEngineeringScale.Nominal4I4V : SvEngineeringScale.RawCounts; var key = SelectedStream.StreamId; - if (_explicitSvScales.TryGetValue(key, out var previous) ? previous == next : next == SvEngineeringScale.RawCounts) + if (ResolveSelectedStreamScale(key) == next) return; - if (next == SvEngineeringScale.RawCounts) _explicitSvScales.Remove(key); - else _explicitSvScales[key] = next; + // Store raw overrides too: selecting raw must not auto-revert to + // nominal on the next 500ms stream refresh. + _explicitSvScales[key] = next; ApplySelectedStreamScale(key); - // No stale physical metering, vectors, waveform, or wrong-stream cached state. _streamWorkspaces.Remove(key); RestoreSelectedStreamWorkspace(SelectedStream); OnPropertyChanged(); @@ -909,15 +927,13 @@ public string SelectedEngineeringScale public string EngineeringScaleExplanation => SelectedStream is null ? "Select an SV stream to see its measurement units." : SelectedEngineeringScale == NominalScaleText - ? "Operator-selected 4I+4V nominal scaling; not calibrated or independently verified. Applies to this stream only." - : "Uncalibrated raw counts: waveform/angles remain available; physical power is unavailable."; + ? "Assumed 4I+4V nominal: 1 mA / 10 mV per count. Display estimate only; not calibrated or SCL-verified. Choose Raw counts anytime." + : "Raw SV counts: no electrical units inferred. Use nominal 4I+4V only when its layout is confirmed."; private void ApplySelectedStreamScale(string? streamId) { if (string.IsNullOrWhiteSpace(streamId)) return; - var mode = _explicitSvScales.TryGetValue(streamId, out var selected) - ? selected : SvEngineeringScale.RawCounts; - _rawDataSource.SetSvEngineeringScale(streamId, mode); + _rawDataSource.SetSvEngineeringScale(streamId, ResolveSelectedStreamScale(streamId)); } public IReadOnlyList WaveformVoltageScaleOptions { get; } = @@ -1540,6 +1556,7 @@ public string WaveformCurrentScale // ===== HEALTH ===== private bool HasDiagnosticWarning => + Diagnostics.SmpSynchText == "0" || Diagnostics.DecodeErrors > 0 || Diagnostics.CaptureDriverDroppedFrames > 0 || Diagnostics.SequenceErrors > 0 || @@ -1884,6 +1901,8 @@ public string EvidenceCapturePathText private string BuildHealthWarningText() { var parts = new List(5); + if (Diagnostics.SmpSynchText == "0") + parts.Add("SV samples report NOT SYNCED (smpSynch=0)"); if (Diagnostics.DecodeErrors > 0) parts.Add($"decode rejects {Diagnostics.DecodeErrors}"); if (Diagnostics.SequenceErrors > 0) @@ -2226,6 +2245,11 @@ private void RestoreSelectedStreamWorkspace(SvStreamItem? stream) private void UpdateSelectedStreamWorkspaceFromSnapshot(string? selectedStreamId, AnalogValuesSnapshot analogValues, WaveformSnapshot waveform) { _displayedSelectedStreamDetails = _state.SelectedStreamDetails; + // Refresh auto-nominal eligibility after the decoder has discovered a + // recognized mapping; next coherent snapshot uses the chosen units. + ApplySelectedStreamScale(selectedStreamId); + OnPropertyChanged(nameof(SelectedEngineeringScale)); + OnPropertyChanged(nameof(EngineeringScaleExplanation)); // ARSVIN-style display contract: phasor vectors are derived from the latest // engineering snapshot on every UI refresh. Do not use object-reference caching @@ -4280,7 +4304,7 @@ private static PhasorDisplayItem CreatePhasor(string name, PhasorFamily family, Name = name, Family = family, Magnitude = channel.RmsValue, - AngleDegrees = forceAngleZero && channel.RmsValue.HasValue ? 0.0 : channel.AngleDegrees, + AngleDegrees = forceAngleZero && channel.AngleDegrees.HasValue ? 0.0 : channel.AngleDegrees, Unit = channel.Unit }; } diff --git a/src/ProcessBus.Core/Models/SvDiagnosticsSnapshot.cs b/src/ProcessBus.Core/Models/SvDiagnosticsSnapshot.cs index 1f7639ba..a4644a69 100644 --- a/src/ProcessBus.Core/Models/SvDiagnosticsSnapshot.cs +++ b/src/ProcessBus.Core/Models/SvDiagnosticsSnapshot.cs @@ -73,7 +73,7 @@ public sealed class SvDiagnosticsSnapshot : "N/A"; public string SvJitterStatisticsText => - $"Mean |Δ| {FormatMicroseconds(AverageAbsJitterMicroseconds)} · Max |Δ| {FormatMicroseconds(MaxAbsJitterMicroseconds)}"; + $"Mean |Δ| {FormatMicroseconds(AverageAbsJitterMicroseconds)} · Max |Δ| {FormatMicroseconds(MaxAbsJitterMicroseconds)} (last 4000 intervals)"; public string SvJitterExcursionsText => $"≥300 µs: {RecentJitterOver300MicrosecondsCount} / 5s · {JitterOver300MicrosecondsCount} total"; @@ -96,8 +96,18 @@ public sealed class SvDiagnosticsSnapshot _ => "UNVERIFIED" }; - public string SvQualityEvidenceText => $"{ValidityText} · smpSynch: {SmpSynchText}" + - (SmpSynchText == "N/A" ? " (unavailable)" : " (observed)"); + public string SvQualityEvidenceText => ValidityText; + + // The source claims synchronization through smpSynch; observed PTP traffic + // is not independent proof that the publisher's sampling clock is locked. + public string SvSampleSyncHeadlineText => SmpSynchText switch + { + "0" => "NOT SYNCED", + "1" => "LOCAL REF REPORTED", + "2" => "GLOBAL REF REPORTED", + _ => "UNVERIFIED" + }; + public string SvSampleSyncEvidenceText => $"smpSynch={SmpSynchText} · publisher flag only"; public string SvArrivalIntervalText => $"Actual Δ {FormatMicroseconds(CurrentDeltaMicroseconds)} · Expected Δ {FormatMicroseconds(ExpectedDeltaMicroseconds)}"; public string SvArrivalAuthorityText => diff --git a/src/ProcessBus.Core/Models/SvEngineeringScale.cs b/src/ProcessBus.Core/Models/SvEngineeringScale.cs index 9e617413..9f9bf89c 100644 --- a/src/ProcessBus.Core/Models/SvEngineeringScale.cs +++ b/src/ProcessBus.Core/Models/SvEngineeringScale.cs @@ -25,6 +25,14 @@ public static string Unit(SvEngineeringScale mode, string channel) ? "counts" : channel.StartsWith("U", StringComparison.OrdinalIgnoreCase) ? "V" : "A"; + // Offer engineering-friendly nominal units only when the receive decoder + // has identified the exact 16-entry 4I+4V instMag/q layout. This is an + // ASSUMED profile, not SCL-certified calibration or universal SV scaling. + // Never use svID, APPID or raw frame length alone for scale authority. + public static bool IsRecognizedNominalCandidate(string? mappingText) => + !string.IsNullOrWhiteSpace(mappingText) && + mappingText.Contains("raw 4I4V instMag/q", StringComparison.OrdinalIgnoreCase); + public static bool IsPhysical(SvEngineeringScale mode) => mode == SvEngineeringScale.Nominal4I4V; } diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 8c42d7f3..9bca7db7 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -1421,8 +1421,14 @@ private ChannelValueModel CreateChannelValue( // not freeze the vector display. The old display-hold path kept returning the // previous angle after transient sequence warnings, so the phasor diagram only // appeared to refresh after a user clicked SV Explorer and rebuilt the workspace. - if (!rawAngle.HasValue || reference is null) - return _displayAngles[channel] ?? DefaultAngleDegrees(channel); + // Never synthesize a nominal phase angle or replay a previous channel + // angle when this coherent SV window lacks valid phasor evidence. + // In particular an absent Ic must not produce a convincing +90° arrow. + if (!rawAngle.HasValue || !double.IsFinite(rawAngle.Value) || reference is null) + { + _displayAngles[channel] = null; + return null; + } var referenceValue = reference.Value; var relativeAngle = string.Equals(channel, referenceValue.Channel, StringComparison.OrdinalIgnoreCase) @@ -1680,21 +1686,33 @@ private static IReadOnlyList GetChannelSamples(IReadOnlyList frames, string channel, int samplesPerCycle) { - var values = GetChannelSamples(frames, channel); - if (samplesPerCycle <= 1 || values.Count < samplesPerCycle) + if (samplesPerCycle <= 1 || frames.Count < samplesPerCycle) return null; + // Keep every channel on the SAME acquisition slot index. Filtering out + // absent channel values and reindexing them shifts its electrical phase + // relative to Ua without changing the displayed waveform's slot positions. var sine = 0.0; var cosine = 0.0; + var sumSquares = 0.0; var phaseStep = 2.0 * Math.PI / samplesPerCycle; - for (var index = 0; index < values.Count; index++) + for (var index = 0; index < frames.Count; index++) { + var value = frames[index].GetValue(channel); + if (!value.HasValue || !double.IsFinite(value.Value)) + return null; + var angle = phaseStep * index; - sine += values[index] * Math.Sin(angle); - cosine += values[index] * Math.Cos(angle); + sine += value.Value * Math.Sin(angle); + cosine += value.Value * Math.Cos(angle); + sumSquares += value.Value * value.Value; } - if (Math.Abs(sine) < 1e-9 && Math.Abs(cosine) < 1e-9) + // An all-zero/vanishing fundamental has no mathematically meaningful angle. + // Refuse an invented vector rather than using a fixed R/S/T template. + var fundamentalMagnitude = Math.Sqrt(sine * sine + cosine * cosine); + if (sumSquares <= 0 || + fundamentalMagnitude <= 1e-8 * Math.Sqrt(sumSquares * frames.Count)) return null; return NormalizeAngleDegrees(Math.Atan2(cosine, sine) * 180.0 / Math.PI); diff --git a/tests/ProcessBus.Tests/LiveCurrentPhasorRegressionTests.cs b/tests/ProcessBus.Tests/LiveCurrentPhasorRegressionTests.cs new file mode 100644 index 00000000..a0f3255d --- /dev/null +++ b/tests/ProcessBus.Tests/LiveCurrentPhasorRegressionTests.cs @@ -0,0 +1,96 @@ +using ProcessBus.Core.Models; +using ProcessBus.Iec61850.Raw.Analysis; +using Xunit; + +namespace ProcessBus.Tests; + +/// +/// Captured wire values define electrical phase. Never make Ic equal Uc or force a +/// convenient reference angle merely because the sample belongs to phase C. +/// +public sealed class LiveCurrentPhasorRegressionTests +{ + private const int PerCycle = 80; + + private static int Sample(int n, double degrees, double amplitude) => + (int)Math.Round(amplitude * Math.Sin(2 * Math.PI * (n % PerCycle) / PerCycle + + degrees * Math.PI / 180.0)); + + private static void Feed(RawProcessBusAnalyzer analyzer, int start, int count, + double icDegrees, bool zeroIc = false) + { + for (var n = start; n < start + count; n++) + { + var samples = new[] + { + Sample(n, -30, 1414.0), // Ia + Sample(n, -150, 1414.0), // Ib + zeroIc ? 0 : Sample(n, icDegrees, 1414.0), // Ic is independently driven + 0, // In + Sample(n, 0, 8165.0), // Ua + Sample(n, -120, 8165.0),// Ub + Sample(n, 120, 8165.0), // Uc + 0 // Un + }; + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples( + (ushort)(n % 4000), samples, "ARSTACK_SV01", 0x4000)); + } + } + + private static void Near(double? value, double expected) + { + Assert.True(value.HasValue, "Expected a decoded sample-derived phasor."); + var delta = (value.Value - expected + 540.0) % 360.0 - 180.0; + Assert.InRange(delta, -1.25, 1.25); + } + + [Fact] + public void FourI4V_IndependentCurrentCIsPlus90WhileVoltageCIsPlus120() + { + var analyzer = new RawProcessBusAnalyzer(); + Feed(analyzer, 0, 560, 90.0); + var snap = analyzer.GetAnalyzerSnapshot(); + var id = Assert.IsType(snap.SelectedStreamId); + analyzer.SetSvEngineeringScale(id, SvEngineeringScale.Nominal4I4V); + snap = analyzer.GetAnalyzerSnapshot(); + + Near(snap.AnalogValues.Ua.AngleDegrees, 0); + Near(snap.AnalogValues.Ub.AngleDegrees, -120); + Near(snap.AnalogValues.Uc.AngleDegrees, 120); + Near(snap.AnalogValues.Ia.AngleDegrees, -30); + Near(snap.AnalogValues.Ib.AngleDegrees, -150); + Near(snap.AnalogValues.Ic.AngleDegrees, 90); + Assert.Equal("A", snap.AnalogValues.Ic.Unit); + Assert.NotEqual(snap.AnalogValues.Ic.AngleDegrees, snap.AnalogValues.Uc.AngleDegrees); + } + + [Fact] + public void CurrentCChangesInLiveWireSamples_NotInFixedPhaseTemplate() + { + var analyzer = new RawProcessBusAnalyzer(); + Feed(analyzer, 0, 480, 90); + Near(analyzer.GetAnalyzerSnapshot().AnalogValues.Ic.AngleDegrees, 90); + Feed(analyzer, 480, 480, 45); + var changed = analyzer.GetAnalyzerSnapshot(); + Near(changed.AnalogValues.Ic.AngleDegrees, 45); + Near(changed.AnalogValues.Uc.AngleDegrees, 120); + } + + [Fact] + public void ZeroMagnitudeIcNeverFabricatesA90DegreeVector() + { + var analyzer = new RawProcessBusAnalyzer(); + Feed(analyzer, 0, 480, 90, zeroIc: true); + var snap = analyzer.GetAnalyzerSnapshot(); + Assert.Null(snap.AnalogValues.Ic.AngleDegrees); + Near(snap.AnalogValues.Uc.AngleDegrees, 120); + } + + [Fact] + public void MissingVoltageOrCurrentDoesNotUseFixedPhaseFallback() + { + var analog = new AnalogValuesSnapshot(); + Assert.Null(analog.Ic.AngleDegrees); + Assert.Null(analog.Uc.AngleDegrees); + } +} diff --git a/tests/ProcessBus.Tests/SvDiagnosticsReadoutTests.cs b/tests/ProcessBus.Tests/SvDiagnosticsReadoutTests.cs index 61ed1ff9..6135b236 100644 --- a/tests/ProcessBus.Tests/SvDiagnosticsReadoutTests.cs +++ b/tests/ProcessBus.Tests/SvDiagnosticsReadoutTests.cs @@ -19,7 +19,7 @@ public void SelectedStreamShowsSignedArrivalJitterAndBoundedEvidence() ExpectedDeltaMicroseconds = 250 }; Assert.Equal("-413.2 µs", d.SvJitterHeadlineText); - Assert.Equal("Mean |Δ| 77.1 µs · Max |Δ| 813.1 µs", d.SvJitterStatisticsText); + Assert.Equal("Mean |Δ| 77.1 µs · Max |Δ| 813.1 µs (last 4000 intervals)", d.SvJitterStatisticsText); Assert.Equal("≥300 µs: 5 / 5s · 1530 total", d.SvJitterExcursionsText); Assert.Equal("Actual Δ 663.2 µs · Expected Δ 250.0 µs", d.SvArrivalIntervalText); Assert.Contains("Host/Npcap", d.SvArrivalAuthorityText); @@ -45,7 +45,7 @@ public void NonFiniteInputRemainsUnavailable() MaxAbsJitterMicroseconds = double.NegativeInfinity }; Assert.Equal("N/A", d.SvJitterHeadlineText); - Assert.Equal("Mean |Δ| N/A · Max |Δ| N/A", d.SvJitterStatisticsText); + Assert.Equal("Mean |Δ| N/A · Max |Δ| N/A (last 4000 intervals)", d.SvJitterStatisticsText); } [Fact] @@ -72,7 +72,21 @@ public void QualityVerdictTracksObservedQualityWithoutInferringGood(string evide { var d = new SvDiagnosticsSnapshot { ValidityText = evidence, SmpSynchText = "0" }; Assert.Equal(expected, d.SvQualityHeadlineText); - Assert.Contains("smpSynch: 0 (observed)", d.SvQualityEvidenceText); + Assert.Equal(evidence, d.SvQualityEvidenceText); + Assert.Equal("NOT SYNCED", d.SvSampleSyncHeadlineText); + Assert.Contains("smpSynch=0", d.SvSampleSyncEvidenceText); + } + + [Theory] + [InlineData("0", "NOT SYNCED")] + [InlineData("1", "LOCAL REF REPORTED")] + [InlineData("2", "GLOBAL REF REPORTED")] + [InlineData("N/A", "UNVERIFIED")] + public void QualityAndSampleSynchronizationRemainIndependent(string wire, string expected) + { + var d = new SvDiagnosticsSnapshot { ValidityText = "4I+4V quality good", SmpSynchText = wire }; + Assert.Equal("GOOD", d.SvQualityHeadlineText); + Assert.Equal(expected, d.SvSampleSyncHeadlineText); } [Fact] diff --git a/tests/ProcessBus.Tests/SvNominalProfilePolicyTests.cs b/tests/ProcessBus.Tests/SvNominalProfilePolicyTests.cs new file mode 100644 index 00000000..9c73ff32 --- /dev/null +++ b/tests/ProcessBus.Tests/SvNominalProfilePolicyTests.cs @@ -0,0 +1,26 @@ +using ProcessBus.Core.Models; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class SvNominalProfilePolicyTests +{ + [Theory] + [InlineData("Raw 4I4V instMag/q candidate profile", true)] + [InlineData("OMICRON raw 4I4V instMag/q profile / OMICRON_CMC_SV1", true)] + [InlineData("Raw SV payload evidence", false)] + [InlineData("SCL generic 16 elements of uncertain layout", false)] + [InlineData(null, false)] + public void NominalAssumptionRequiresRecognizedDecodedLayout(string? mapping, bool eligible) + { + Assert.Equal(eligible, SvEngineeringScaleFactors.IsRecognizedNominalCandidate(mapping)); + } + + [Fact] + public void ElectricalScalingRemainsAnAssumptionNotAPhasorAngleTransformation() + { + Assert.Equal(0.01, SvEngineeringScaleFactors.Factor(SvEngineeringScale.Nominal4I4V, "Ua")); + Assert.Equal(0.001, SvEngineeringScaleFactors.Factor(SvEngineeringScale.Nominal4I4V, "Ic")); + Assert.Equal("counts", SvEngineeringScaleFactors.Unit(SvEngineeringScale.RawCounts, "Ic")); + } +}