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
430 changes: 267 additions & 163 deletions Controls/ComtradeHarmonicsWorkstationView.cs

Large diffs are not rendered by default.

157 changes: 123 additions & 34 deletions Controls/ComtradePhasorView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,50 +37,65 @@ public sealed class ComtradePhasorView : FrameworkElement
private PreparedPhasorPanel _voltagePanel = PreparedPhasorPanel.Empty;
private PreparedPhasorPanel _currentPanel = PreparedPhasorPanel.Empty;
private const double PresentationTimeConstantMs = 78.0;
private const double PresentationAnimationMaximumMs = 260.0;
private string _headerLabel = "Fundamental phasors at C1";
private string _referenceDetail = "Select a valid analysis reference";
private string _message = string.Empty;
private ComtradePhasorVector[] _targetVoltageVectors = Array.Empty<ComtradePhasorVector>();
private ComtradePhasorVector[] _targetCurrentVectors = Array.Empty<ComtradePhasorVector>();
private ComtradePhasorVector[] _smoothedVoltageVectors = Array.Empty<ComtradePhasorVector>();
private ComtradePhasorVector[] _smoothedCurrentVectors = Array.Empty<ComtradePhasorVector>();
private bool _presentationRenderingHooked;
private long _lastPresentationTimestamp;
private long _presentationAnimationStartedTimestamp;

internal void ShowPhasors(
string referenceLabel,
string referenceDetail,
IReadOnlyList<ComtradePhasorVector> voltageVectors,
IReadOnlyList<ComtradePhasorVector> currentVectors)
{
var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel;
_headerLabel = $"Fundamental phasors at {resolvedReference}";
_referenceDetail = referenceDetail ?? string.Empty;

var now = Stopwatch.GetTimestamp();
var elapsedMilliseconds = _lastPresentationTimestamp == 0
? double.PositiveInfinity
: Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds;
_lastPresentationTimestamp = now;
public ComtradePhasorView()
{
Unloaded += (_, _) => StopPresentationAnimation();
}

_smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, voltageVectors, elapsedMilliseconds);
_smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, currentVectors, elapsedMilliseconds);
_voltagePanel = PreparePanel(_smoothedVoltageVectors);
_currentPanel = PreparePanel(_smoothedCurrentVectors);
_message = string.Empty;
InvalidateVisual();
}
internal void ShowPhasors(
string referenceLabel,
string referenceDetail,
IReadOnlyList<ComtradePhasorVector> voltageVectors,
IReadOnlyList<ComtradePhasorVector> currentVectors)
{
var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel;
_headerLabel = $"Fundamental phasors at {resolvedReference}";
_referenceDetail = referenceDetail ?? string.Empty;
_targetVoltageVectors = CloneVectors(voltageVectors);
_targetCurrentVectors = CloneVectors(currentVectors);

// First sample/topology changes snap. Normal scrub updates keep the current
// presentation and converge to the newest exact native target on composition frames.
_smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, _targetVoltageVectors, 0.0);
_smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, _targetCurrentVectors, 0.0);
RefreshPreparedPanels();
_message = string.Empty;

if (VectorsDiffer(_smoothedVoltageVectors, _targetVoltageVectors) ||
VectorsDiffer(_smoothedCurrentVectors, _targetCurrentVectors))
StartPresentationAnimation();
else
StopPresentationAnimation();
InvalidateVisual();
}

internal void ShowMessage(string title, string message)
{
var resolvedTitle = string.IsNullOrWhiteSpace(title) ? "Phasor" : title;
_headerLabel = $"Fundamental phasors at {resolvedTitle}";
_referenceDetail = message ?? string.Empty;
_voltagePanel = PreparedPhasorPanel.Empty;
_currentPanel = PreparedPhasorPanel.Empty;
_smoothedVoltageVectors = Array.Empty<ComtradePhasorVector>();
_smoothedCurrentVectors = Array.Empty<ComtradePhasorVector>();
_lastPresentationTimestamp = 0;
_message = message ?? string.Empty;
InvalidateVisual();
}
{
var resolvedTitle = string.IsNullOrWhiteSpace(title) ? "Phasor" : title;
_headerLabel = $"Fundamental phasors at {resolvedTitle}";
_referenceDetail = message ?? string.Empty;
StopPresentationAnimation();
_voltagePanel = PreparedPhasorPanel.Empty;
_currentPanel = PreparedPhasorPanel.Empty;
_targetVoltageVectors = Array.Empty<ComtradePhasorVector>();
_targetCurrentVectors = Array.Empty<ComtradePhasorVector>();
_smoothedVoltageVectors = Array.Empty<ComtradePhasorVector>();
_smoothedCurrentVectors = Array.Empty<ComtradePhasorVector>();
_message = message ?? string.Empty;
InvalidateVisual();
}

protected override void OnRender(DrawingContext dc)
{
Expand Down Expand Up @@ -129,6 +144,80 @@ protected override void OnRender(DrawingContext dc)
}
}

private void StartPresentationAnimation()
{
var now = Stopwatch.GetTimestamp();
_presentationAnimationStartedTimestamp = now;
if (_presentationRenderingHooked) return;
_lastPresentationTimestamp = now;
CompositionTarget.Rendering += PresentationCompositionFrame;
_presentationRenderingHooked = true;
}

private void StopPresentationAnimation()
{
if (_presentationRenderingHooked)
{
CompositionTarget.Rendering -= PresentationCompositionFrame;
_presentationRenderingHooked = false;
}
_lastPresentationTimestamp = 0;
_presentationAnimationStartedTimestamp = 0;
}

private void PresentationCompositionFrame(object? sender, EventArgs e)
{
if (!_presentationRenderingHooked) return;
var now = Stopwatch.GetTimestamp();
var dt = _lastPresentationTimestamp == 0
? 16.0
: Math.Clamp(Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds, 0.0, 50.0);
_lastPresentationTimestamp = now;
_smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, _targetVoltageVectors, dt);
_smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, _targetCurrentVectors, dt);

var age = _presentationAnimationStartedTimestamp == 0
? PresentationAnimationMaximumMs
: Stopwatch.GetElapsedTime(_presentationAnimationStartedTimestamp, now).TotalMilliseconds;
if (age >= PresentationAnimationMaximumMs)
{
// Presentation may ease; the settled engineering display is the exact native target.
_smoothedVoltageVectors = CloneVectors(_targetVoltageVectors);
_smoothedCurrentVectors = CloneVectors(_targetCurrentVectors);
StopPresentationAnimation();
}
RefreshPreparedPanels();
InvalidateVisual();
}

private void RefreshPreparedPanels()
{
_voltagePanel = PreparePanel(_smoothedVoltageVectors);
_currentPanel = PreparePanel(_smoothedCurrentVectors);
}

private static ComtradePhasorVector[] CloneVectors(IReadOnlyList<ComtradePhasorVector>? source)
{
if (source is null || source.Count == 0) return Array.Empty<ComtradePhasorVector>();
var result = new ComtradePhasorVector[source.Count];
for (var i = 0; i < source.Count; i++) result[i] = source[i];
return result;
}

private static bool VectorsDiffer(IReadOnlyList<ComtradePhasorVector> left, IReadOnlyList<ComtradePhasorVector> right)
{
if (left.Count != right.Count) return true;
for (var i = 0; i < left.Count; i++)
{
var a = left[i]; var b = right[i];
if (!string.Equals(a.Label, b.Label, StringComparison.Ordinal) ||
!string.Equals(a.Phase, b.Phase, StringComparison.Ordinal) ||
!string.Equals(a.Units, b.Units, StringComparison.Ordinal) ||
!a.MagnitudeRms.Equals(b.MagnitudeRms) || !a.AngleDegrees.Equals(b.AngleDegrees)) return true;
}
return false;
}

private static ComtradePhasorVector[] SmoothVectors(
IReadOnlyList<ComtradePhasorVector> previous,
IReadOnlyList<ComtradePhasorVector>? target,
Expand Down
21 changes: 17 additions & 4 deletions MainWindow.RcbExport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,30 @@ private static IReadOnlyList<RcbExportRow> BuildRcbExportRows(
if (sourceInventory == null)
return liveRows;

// Never let an older/source SCL hide RCBs that the connected IED actually
// exposes. Source-backed rows are preferred for exact export identity, then
// unmatched live-discovery rows are appended as first-class export choices.
// Present concrete live instances when the IED exposes them, but never append the
// corresponding logical SCL ReportControl as a duplicate row. For example, a source
// `Buffer` with RptEnabled max=2 may appear online as Buffer01 + Buffer02. The source
// logical identity remains export authority; the concrete instance names are UX/runtime evidence.
var rows = BuildSourceBackedRcbRows(device, sourceInventory, availability).ToList();
var seen = rows
.Select(row => NormalizeRcbReference(row.Reference))
.Where(reference => !string.IsNullOrWhiteSpace(reference))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var sourceBackedSelectionKeys = rows
.Where(row => row.IsSourceBacked && !string.IsNullOrWhiteSpace(row.SourceSelectionKey))
.Select(row => row.SourceSelectionKey)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var logicalSourceReferences = sourceInventory.ReportControls
.Where(descriptor => sourceBackedSelectionKeys.Contains(descriptor.SelectionKey))
.Select(descriptor => NormalizeRcbReference(descriptor.DisplayReference))
.Where(reference => !string.IsNullOrWhiteSpace(reference))
.ToHashSet(StringComparer.OrdinalIgnoreCase);

foreach (var liveRow in liveRows)
{
var key = NormalizeRcbReference(liveRow.Reference);
if (logicalSourceReferences.Contains(key))
continue;
if (!seen.Add(key))
continue;
rows.Add(liveRow);
Expand Down Expand Up @@ -361,6 +373,7 @@ private async Task<RcbExportCompletion> ExportLegacySasRcbAsync(
SchemaProfile = schema,
SelectedReportControl = new SclReportControlSelection(row.SourceSelectionKey, row.ExportName),
RemoveUnreferencedDataSets = false,
PreserveSourceReportControlIdentity = true,
ToolId = "ARIEC61850"
}), cancellationToken);

Expand Down Expand Up @@ -459,4 +472,4 @@ private static string LastReferenceSegment(string? reference)

private static string NormalizeRcbReference(string? reference)
=> (reference ?? string.Empty).Trim().Replace('$', '.').ToLowerInvariant();
}
}
30 changes: 10 additions & 20 deletions Models/RcbExportModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ namespace ArIED61850Tester.Models;

public sealed class RcbExportRow : ObservableObject
{
private static readonly Brush ReadyIndicatorBrush = BrushFrom(22, 163, 74);
private static readonly Brush OccupiedIndicatorBrush = BrushFrom(234, 179, 8);

private bool _isSelected;
private int _memberCount;
private MmsRcbOperationalAvailability _availability = MmsRcbOperationalAvailability.Unknown;
Expand Down Expand Up @@ -51,6 +54,7 @@ public MmsRcbOperationalAvailability Availability
if (!Set(ref _availability, value)) return;
Raise(nameof(IsSelectable));
Raise(nameof(RequiresConfirmation));
Raise(nameof(IsClientOccupied));
Raise(nameof(StatusGlyph));
Raise(nameof(StatusBrush));
}
Expand All @@ -61,30 +65,16 @@ public MmsRcbOperationalAvailability Availability
public string Reason { get => _reason; set => Set(ref _reason, value?.Trim() ?? string.Empty); }
public string Owner { get => _owner; set => Set(ref _owner, value?.Trim() ?? string.Empty); }

// Availability/ownership is evidence for the operator, not an export lock.
// Every discovered RCB remains selectable so the exported engineering model
// can truthfully represent what the IED exposes, including InUse/NoDataSet.
// Availability/ownership remains engineering evidence and still drives confirmation.
// The table intentionally presents only a calm client-occupancy indicator: yellow means
// an active client owns/uses the RCB; every other state is green and details stay in tooltip/evidence.
public bool IsSelectable => true;

public bool IsClientOccupied => Availability is MmsRcbOperationalAvailability.InUse or MmsRcbOperationalAvailability.UsedByCaller;
public bool RequiresConfirmation => HasEvidenceConflict || Availability is not MmsRcbOperationalAvailability.Available;

public string MemberCountText => MemberCount > 0 ? $"{MemberCount:N0} FCDA" : "0 FCDA";
public string StatusGlyph => Availability switch
{
MmsRcbOperationalAvailability.Available => "✅",
MmsRcbOperationalAvailability.UsedByCaller => "●",
MmsRcbOperationalAvailability.Unknown => "⚠",
_ => "❌"
};
public Brush StatusBrush => HasEvidenceConflict
? BrushFrom(201, 42, 50)
: Availability switch
{
MmsRcbOperationalAvailability.Available => BrushFrom(22, 163, 74),
MmsRcbOperationalAvailability.UsedByCaller => BrushFrom(37, 99, 235),
MmsRcbOperationalAvailability.Unknown => BrushFrom(202, 138, 4),
_ => BrushFrom(201, 42, 50)
};
public string StatusGlyph => "■";
public Brush StatusBrush => IsClientOccupied ? OccupiedIndicatorBrush : ReadyIndicatorBrush;
public string SelectionIdentity => string.IsNullOrWhiteSpace(Reference) ? Name : Reference;

public static string ToStatusText(MmsRcbOperationalAvailability availability)
Expand Down
12 changes: 5 additions & 7 deletions RcbExportFilterWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -183,15 +183,13 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>

<DataGridTemplateColumn Header="STATUS" Width="1.2*">
<DataGridTemplateColumn Header="STATUS" Width="82">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="12,0,8,0"
VerticalAlignment="Center" ToolTip="{Binding Reason}">
<Ellipse Width="7" Height="7" Fill="{Binding StatusBrush}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding StatusText}" Margin="7,0,0,0" FontWeight="SemiBold"
Foreground="{Binding StatusBrush}" VerticalAlignment="Center"/>
</StackPanel>
<Grid Margin="12,0,8,0" VerticalAlignment="Center" ToolTip="{Binding Reason}">
<Border Width="11" Height="11" CornerRadius="1" Background="{Binding StatusBrush}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
Expand Down
12 changes: 6 additions & 6 deletions engines/ARIEC61850.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
"commit": "0023ef9a4373855497464ed3979e359c4041c95d",
"sourcePullRequest": 132,
"purpose": "Temporary ARSAS 1.6.36 combined-workstream qualification pin for the exact green ARIEC convergence head. ARIEC .NET CI #603 passed on this SHA, including provenance/source/license verification, restore, build, tests, and diagnostics. The convergence preserves the trusted-SCL golden-wire contracts used by ARSAS: SCL-authoritative DataSet/RCB identity, LDevice ldName and ReportControl indexed semantics, quoted Edition-1/vendor OSI-AP-Title compatibility, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, receiver-before-write report registration, URCB Resv -> RptEna, BRCB direct RptEna with ResvTms retry-only, two whole-RCB verification reads, one-shot GI after routing is registered, GI fail-closed cleanup, no cyclic process polling, no network DataSet-directory browse, and no dynamic DataSet mutation on the trusted-SCL path. The same convergence also locks buffered BRCB latest-state semantics through the canonical runtime value plane and preserves SCL RptEnabled@max only as diagnostics metadata; it is never authority to synthesize concrete runtime RCB names.",
"commit": "3afc924c97627fe86adbe784c905e2f35dff0b1a",
"sourcePullRequest": 133,
"purpose": "Pins merged ARIEC61850 PR #133 on top of the exact PR #132 golden-wire tree. ARIEC .NET CI #605 passed source/provenance verification, restore, build, and tests. This adds only an opt-in source-backed Legacy SAS export boundary: ARSAS may display concrete live runtime RCB slots such as Buffer01/Buffer02 while CID/IID export retains the logical source ReportControl identity and RptEnabled indexing metadata. The option is default-off, so existing exact-runtime live-model export remains unchanged. All trusted-SCL acquisition, reporting and control contracts from PR #132 remain unchanged: SCL-authoritative DataSet/RCB identity, LDevice ldName and ReportControl indexed semantics, quoted Edition-1/vendor OSI-AP-Title compatibility, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, receiver-before-write report registration, URCB Resv -> RptEna, BRCB direct RptEna with ResvTms retry-only, two whole-RCB verification reads, one-shot GI after routing is registered, GI fail-closed cleanup, no cyclic process polling, no network DataSet-directory browse, and no dynamic DataSet mutation on the trusted-SCL path. SCL RptEnabled@max remains declarative design metadata and is never authority to synthesize concrete runtime RCB names.",
"previousTrialPin": {
"commit": "d50e5bcb9fd428fe3d80ac72f8d4015a575cfda5",
"sourcePullRequest": 125,
"purpose": "Previous ARSAS 1.6.36 trusted-SCL golden-wire trial pin retained for explicit ancestry."
"commit": "0023ef9a4373855497464ed3979e359c4041c95d",
"sourcePullRequest": 132,
"purpose": "Previous ARSAS 1.6.36 combined golden-wire convergence pin retained for explicit ancestry."
},
"fieldProvenBaseline": {
"commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9",
Expand Down
Loading
Loading