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"));
+ }
+}