Skip to content
Merged
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
17 changes: 11 additions & 6 deletions src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@
{
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)

Check warning on line 81 in src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

In member void PhasorDiagramControl.OnRender(DrawingContext dc), change parameter name dc to drawingContext in order to match the identifier as it has been declared in void UIElement.OnRender(DrawingContext drawingContext) (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1725)
{
base.OnRender(dc);

Expand Down Expand Up @@ -121,7 +123,7 @@
DrawSoftOriginGlow(dc, center);

foreach (var item in validItems)
DrawPhasor(dc, center, radius, item, voltageMax, currentMax);
DrawPhasor(dc, bounds, center, radius, item, voltageMax, currentMax);

}

Expand Down Expand Up @@ -159,7 +161,7 @@
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)
Expand Down Expand Up @@ -192,7 +194,7 @@
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)
Expand All @@ -219,7 +221,7 @@
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)
Expand All @@ -240,7 +242,10 @@
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)
Expand Down Expand Up @@ -311,7 +316,7 @@
VisualTreeHelper.GetDpi(Application.Current.MainWindow).PixelsPerDip);
}

private static Brush CreateBrush(string hex)

Check warning on line 319 in src/ProcessBus.App.Wpf/Controls/PhasorDiagramControl.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

Change return type of method 'CreateBrush' from 'System.Windows.Media.Brush' to 'System.Windows.Media.SolidColorBrush' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)
{
var brush = (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
brush.Freeze();
Expand Down
21 changes: 20 additions & 1 deletion src/ProcessBus.App.Wpf/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -2564,7 +2564,7 @@
CornerRadius="9" Padding="12,10"
ToolTip="{Binding Diagnostics.ValidityText}">
<StackPanel>
<TextBlock Text="SV QUALITY / SYNC" Foreground="#9BC7E4" FontSize="10.5"
<TextBlock Text="SV QUALITY" Foreground="#9BC7E4" FontSize="10.5"
FontWeight="SemiBold"/>
<TextBlock Text="{Binding Diagnostics.SvQualityHeadlineText}"
FontSize="20" FontWeight="SemiBold"
Expand All @@ -2586,6 +2586,25 @@
<TextBlock Text="{Binding Diagnostics.SvQualityEvidenceText}"
Foreground="#B9D1E5" FontSize="11"
TextWrapping="Wrap" ToolTip="{Binding Diagnostics.ValidityText}"/>
<DockPanel Margin="0,6,0,0" LastChildFill="False">
<TextBlock Text="SAMPLE SYNC" FontSize="10" Foreground="#9BC7E4"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="{Binding Diagnostics.SvSampleSyncHeadlineText}"
FontSize="11.5" FontWeight="SemiBold">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="#F3C76D"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Diagnostics.SmpSynchText}" Value="0">
<Setter Property="Foreground" Value="#FF8484"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DockPanel>
<TextBlock Text="{Binding Diagnostics.SvSampleSyncEvidenceText}"
FontSize="10" Foreground="#A8C5DD" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Border>
</UniformGrid>
Expand Down
50 changes: 37 additions & 13 deletions src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -878,25 +878,43 @@
public IReadOnlyList<string> WaveformTimebaseOptions { get; } = ["1 cycle", "2 cycles", "4 cycles", "8 cycles"];
public IReadOnlyList<string> WaveformModeOptions { get; } = ["Locked"];
public IReadOnlyList<string> WaveformMarkerOptions { get; } = ["On", "Off"];
public IReadOnlyList<string> EngineeringScaleOptions { get; } = [RawScaleText, NominalScaleText];
public IReadOnlyList<string> 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();
Expand All @@ -909,15 +927,13 @@
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<string> WaveformVoltageScaleOptions { get; } =
Expand Down Expand Up @@ -1540,6 +1556,7 @@

// ===== HEALTH =====
private bool HasDiagnosticWarning =>
Diagnostics.SmpSynchText == "0" ||
Diagnostics.DecodeErrors > 0 ||
Diagnostics.CaptureDriverDroppedFrames > 0 ||
Diagnostics.SequenceErrors > 0 ||
Expand Down Expand Up @@ -1884,6 +1901,8 @@
private string BuildHealthWarningText()
{
var parts = new List<string>(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)
Expand Down Expand Up @@ -2226,6 +2245,11 @@
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
Expand Down Expand Up @@ -4280,7 +4304,7 @@
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
};
}
Expand Down Expand Up @@ -4562,7 +4586,7 @@
public double? DeltaMs { get; init; }
public string SemanticChangedSummaryText { get; init; } = string.Empty;

public string TimeText => Source.LastSeenUtc.ToString("HH:mm:ss.fff");

Check warning on line 4589 in src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

The behavior of 'DateTime.ToString(string)' could vary based on the current user's locale settings. Replace this call in 'GooseTrafficRow.TimeText.get' with a call to 'DateTime.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
public string DeltaText => DeltaMs.HasValue ? $"{DeltaMs.Value:0.0}" : "-";

public string GoId => Source.GoId;
Expand Down Expand Up @@ -4747,7 +4771,7 @@
var age = DateTime.UtcNow - LastSeenUtc.Value;
return age.TotalSeconds < 60
? $"last {age.TotalSeconds:0.0}s"
: LastSeenUtc.Value.ToLocalTime().ToString("HH:mm:ss");

Check warning on line 4774 in src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

The behavior of 'DateTime.ToString(string)' could vary based on the current user's locale settings. Replace this call in 'TrafficHealthTargetRow.LastSeenText.get' with a call to 'DateTime.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
}
}

Expand Down Expand Up @@ -4828,7 +4852,7 @@
public string EntryCountText => $"{Entries.Count} entry(s)";
public string SummaryText => $"{IedName} - {TransportText}";
public string ExpectedKey => $"{Protocol}|{SourceFileName}|{IedName}|{ControlBlockReference}|{DisplayName}";
public string ExpectedConfRevText => ConfRev > 0 ? ConfRev.ToString() : "N/A";

Check warning on line 4855 in src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

The behavior of 'int.ToString()' could vary based on the current user's locale settings. Replace this call in 'SclStreamCatalogRow.ExpectedConfRevText.get' with a call to 'int.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

public static SclStreamCatalogRow FromSv(string sourceFileName, SclSvStreamModel stream, string liveStatus)
=> new()
Expand Down Expand Up @@ -5057,7 +5081,7 @@
return ("No evidence captured yet", string.Empty);

var parts = evidence
.Split(new[] { "; " }, StringSplitOptions.RemoveEmptyEntries)

Check warning on line 5084 in src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

Prefer 'static readonly' fields over constant array arguments if the called method is called repeatedly and is not mutating the passed array (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1861)
.Select(part => part.Trim())
.Where(part => part.Length > 0)
.ToArray();
Expand Down
16 changes: 13 additions & 3 deletions src/ProcessBus.Core/Models/SvDiagnosticsSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 =>
Expand Down
8 changes: 8 additions & 0 deletions src/ProcessBus.Core/Models/SvEngineeringScale.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
34 changes: 26 additions & 8 deletions src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@
return null;

var appId = FormatAppId(frame.AppId);
var vlanId = frame.Ethernet.Vlan?.VlanId.ToString() ?? "Untagged";

Check warning on line 311 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Build, test, and repository health

The behavior of 'ushort.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.ResolveSvMappingProfile(ProcessBusFrame, SampledValueAsdu)' with a call to 'ushort.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
var confRev = asdu.ConfRev?.ToString() ?? "N/A";

return _svChannelMappings
Expand Down Expand Up @@ -1421,8 +1421,14 @@
// 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)
Expand Down Expand Up @@ -1680,21 +1686,33 @@

private static double? GetFundamentalAngleDegrees(IReadOnlyList<ScopeSampleFrame> 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);
Expand Down
Loading
Loading