diff --git a/docs/arsubsv-generic-explorer-roadmap.md b/docs/arsubsv-generic-explorer-roadmap.md new file mode 100644 index 0000000..50e947b --- /dev/null +++ b/docs/arsubsv-generic-explorer-roadmap.md @@ -0,0 +1,216 @@ +# ArSubsv generic Sampled Values explorer roadmap + +## Product decision + +ArSubsv is a generic IEC 61850 Sampled Values subscriber, inspector, measurement viewer, and evidence tool. + +The receive path must not contain manufacturer or device-family branches. Product identity may appear in imported evidence or reports, but it must never select a parser, dataset order, engineering scale, quality layout, or health result. + +The intended receive pipeline is: + +```text +Ethernet / VLAN / APPID + ↓ +generic IEC 61850-9-2 APDU and ASDU parser + ↓ +generic seqOfData structural inspection + ↓ +optional SCL-driven ordered dataset mapping + ↓ +explicit standard-layout or engineering-context evidence + ↓ +waveform, RMS, phasor, quality, and report +``` + +## Reference behavior + +A proven SV engineering workflow discovers one or more streams, shows waveforms, RMS values, phase angles, phasors, individual samples, recording/replay, and reports. Configurable IEC 61869-9 datasets are interpreted and checked through imported SCL rather than manufacturer heuristics. + +ArSubsv should reproduce that workflow while keeping every interpretation explainable. + +## Audit findings + +### Correct foundations already present + +- One generic Ethernet/APDU/ASDU parser is shared across live capture and PCAP replay. +- SCL can provide an ordered payload layout and configuration comparison. +- Raw protocol values, scaling provenance, timebase provenance, quality diagnostics, and evidence reports already exist. +- Selected-stream visualization is stable and bounded. +- CT/VT primary/secondary display context is explicit and does not alter raw bytes. + +### Incorrect or unsafe behavior to remove + +1. **Unbound payload auto-layout** + + The current Subscriber recognizes selected payload pair counts and assigns names such as Ia, Ib, Ic, In, Va, Vb, Vc, and Vn. A 64-byte payload can therefore be interpreted as a fixed 4I+4V layout without SCL. This is structural guessing and can mislabel configurable IEC 61869-9 datasets. + +2. **Synthetic IEC references** + + Generic traffic can receive generated references such as `TCTR1/AmpSv.instMag.i`. Those references look authoritative even though they were not read from SCL or the wire. + +3. **Unbound engineering scaling** + + Packet length and observed sampling rate were previously sufficient to activate provisional A/V scaling. This is now hardened: generic unbound traffic remains raw counts. + +4. **Unknown mapping treated as stream warning** + + A valid stream without SCL is currently classified as WARN because channel names or scaling are unresolved. Missing semantics are not a protocol failure. Protocol/stream integrity and interpretation completeness must be separate states. + +5. **Waveform model fixed to eight named channels** + + The current waveform container is optimized for Ia/Ib/Ic/In and Va/Vb/Vc/Vn. Configurable datasets require a generic selected-element trace model before semantic aliases are added. + +6. **Profile-oriented compact state** + + The UI emphasizes profile and confidence. The primary receive workflow should instead emphasize wire validity, mapping source, semantic completeness, and measurement provenance. + +7. **Global parse health can remain sticky** + + Lifetime parse-error count can keep the global state BAD after the current traffic has recovered. Lifetime counters should remain evidence, while current health should use a rolling window. + +## Implemented in P3A + +### Generic seqOfData inspection + +`SvGenericPayloadInspector` exposes every complete big-endian 32-bit word through: + +- byte offset, +- raw hexadecimal bytes, +- signed INT32 representation, +- unsigned UINT32 representation, +- IEEE-754 FLOAT32 representation, +- structural position in an eight-byte group when applicable. + +An eight-byte grouping shape is only structural evidence. The second word is not called quality until SCL or an explicit reviewed standard layout resolves it. + +Trailing bytes are preserved and reported instead of silently discarded. + +### Generic ASDU inspection + +`SvGenericAsduInspector` exposes: + +- `svID`, +- dataset reference, +- `smpCnt`, +- `confRev`, +- `smpSynch`, +- presence of `refrTm`, +- optional `smpRate` and `smpMod`, +- generic payload inspection. + +Dataset reference presence does not mean dataset semantics are already known; SCL binding is still required. + +### Scaling hardening + +Engineering A/V scaling now requires all of the following: + +1. SCL-derived current or voltage semantics, +2. fixed 4-current/4-voltage value-quality structure, +3. expected payload size, +4. declared or observed protection-rate evidence. + +Packet shape, rate, product name, MAC, APPID, `svID`, amplitude, and manufacturer identity cannot activate scaling on their own. + +### Generic Subscriber presentation + +When an SCL mapping is not bound, the Subscriber presentation layer now: + +- replaces synthetic channel labels with generic word labels and byte offsets, +- shows signed and unsigned representations of each 32-bit word, +- labels semantics as unresolved, +- suppresses semantic waveform, RMS, and phasor displays, +- renames the compact profile indicators to mapping and semantics. + +When SCL is bound, existing ordered SCL decoding and measurement views remain available. + +## Next implementation tranches + +### P3B — replace the runtime auto-layout path + +Replace the unbound runtime auto-layout decoder with `SvGenericPayloadInspector`, so synthetic channel references are not created internally at all. + +Without SCL, the Decoded table contract is: + +| Field | Meaning | +|---|---| +| Offset | byte position inside seqOfData | +| Word | generic word index | +| INT32 | signed big-endian representation | +| UINT32 | unsigned representation | +| FLOAT32 | alternate representation, clearly marked | +| Raw | exact bytes | +| Semantics | unresolved | + +No Ia/Va labels, no A/V units, no quality health decisions, and no phasor calculation are permitted in raw generic mode. + +### P3C — SCL-driven semantic mapping + +Resolve the ordered path: + +```text +SampledValueControl + → datSet + → DataSet FCDA order + → DO/DA/BType/CDC + → payload element decoder +``` + +Each displayed field must carry a mapping source: + +- wire observed, +- SCL derived, +- explicit standard-layout selection, +- manual engineering context, +- device-validated evidence. + +Unknown, absent, and conflicting states must remain distinct. + +### P3D — generic selected-element waveform + +Introduce a generic numeric trace model independent of Ia/Ib/Ic/Va/Vb/Vc. + +- Any mapped numeric element can be selected for waveform display. +- Raw mode may plot an explicitly selected word as counts. +- RMS is available when a complete time window exists. +- Phase angle and phasor are available only when nominal frequency/timebase and signal semantics are resolved. +- Standard phase aliases are conveniences derived from SCL, not parser assumptions. + +### P3E — explainable health layers + +Expose four independent states: + +```text +PROTOCOL GOOD / BAD +STREAM GOOD / WARN / BAD +CONFIGURATION MATCH / WARN / BAD / NOT LOADED +MEASUREMENT RESOLVED / PARTIAL / RAW +``` + +The global headline should be driven by current protocol and stream integrity. Missing SCL or unresolved engineering scale must not turn a valid stream BAD. + +### P3F — evidence and workflow parity + +- record/replay and COMTRADE export, +- detailed individual-sample view, +- zero-crossing and timing statistics, +- packet-interval histogram, +- optional-field verification, +- SCL orphan discovery, +- printable and JSON evidence reports, +- selected-stream stability under live refresh, +- capture/application drop attribution without claiming kernel drops that were not observed. + +## Real-device evidence policy + +PCAP and SCL from any conforming or installed-base device are regression fixtures for the generic engine. They are not permission to add manufacturer-specific decoding branches. + +A real-device fixture should prove: + +- generic parser compatibility, +- SCL mapping correctness, +- sample-counter and timing behavior, +- quality interpretation, +- engineering-value agreement with known injection, +- report reproducibility. + +Any device-specific exception must be documented as an implementation defect or explicit compatibility observation, isolated from the generic standards path, and never silently selected by manufacturer identity. diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs new file mode 100644 index 0000000..da5ef74 --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs @@ -0,0 +1,58 @@ +namespace AR.Iec61850.SampledValues.Analysis; + +/// +/// Generic, vendor-neutral view of one Sampled Values ASDU. +/// It separates fields observed on the wire from semantic dataset interpretation. +/// +public sealed record SvGenericAsduInspection +{ + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public ushort SampleCount { get; init; } + public uint ConfigurationRevision { get; init; } + public bool HasReferenceTime { get; init; } + public byte SampleSynchronization { get; init; } + public ushort? SampleRate { get; init; } + public ushort? SampleMode { get; init; } + public SvGenericPayloadInspection Payload { get; init; } = new(); + + public string MappingState => string.IsNullOrWhiteSpace(DataSetReference) + ? "Dataset reference not present · semantic mapping unresolved" + : "Dataset reference observed · import or bind SCL to resolve ordered semantics"; + + public string OptionalFieldSummary + { + get + { + var fields = new List(); + if (HasReferenceTime) + fields.Add("refrTm"); + if (SampleRate.HasValue) + fields.Add("smpRate"); + if (SampleMode.HasValue) + fields.Add("smpMod"); + return fields.Count == 0 ? "No optional ASDU fields observed" : string.Join(", ", fields); + } + } +} + +public static class SvGenericAsduInspector +{ + public static SvGenericAsduInspection Inspect(SampledValueAsdu asdu) + { + ArgumentNullException.ThrowIfNull(asdu); + + return new SvGenericAsduInspection + { + SvId = asdu.SvId, + DataSetReference = asdu.DataSetReference, + SampleCount = asdu.SampleCount, + ConfigurationRevision = asdu.ConfigurationRevision, + HasReferenceTime = asdu.ReferenceTime is not null, + SampleSynchronization = asdu.SampleSynchronization, + SampleRate = asdu.SampleRate, + SampleMode = asdu.SampleMode, + Payload = SvGenericPayloadInspector.Inspect(asdu.SamplePayload) + }; + } +} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs new file mode 100644 index 0000000..6b201c7 --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs @@ -0,0 +1,162 @@ +using System.Buffers.Binary; + +namespace AR.Iec61850.SampledValues.Analysis; + +/// +/// Describes only the structural position of a 32-bit word in seqOfData. +/// These roles do not assert channel semantics, engineering units, or IEC 61850 quality meaning. +/// +public enum SvGenericPayloadWordRole +{ + StandaloneWord, + FirstWordInEightByteGroup, + SecondWordInEightByteGroup +} + +/// +/// One four-byte big-endian word from an SV sample payload, exposed through multiple numeric views. +/// The views are representations of the same bytes and are not automatic semantic interpretations. +/// +public sealed record SvGenericPayloadWord +{ + public int Index { get; init; } + public int ByteOffset { get; init; } + public SvGenericPayloadWordRole StructuralRole { get; init; } + public byte[] RawBytes { get; init; } = []; + public string Hex { get; init; } = string.Empty; + public int SignedInt32 { get; init; } + public uint UnsignedInt32 { get; init; } + public float Float32 { get; init; } + public bool IsFiniteFloat32 => float.IsFinite(Float32); + + public string GenericLabel => $"Word {Index + 1}"; + public string OffsetLabel => $"+0x{ByteOffset:X2}"; +} + +/// +/// Vendor-neutral structural inspection of one ASDU seqOfData payload. +/// It deliberately preserves unknown semantics instead of inventing current/voltage channels. +/// +public sealed record SvGenericPayloadInspection +{ + public int PayloadLength { get; init; } + public int CompleteWordCount { get; init; } + public int TrailingByteCount { get; init; } + public bool IsFourByteAligned { get; init; } + public bool HasEightByteGroupShape { get; init; } + public IReadOnlyList Words { get; init; } + = Array.Empty(); + public byte[] TrailingBytes { get; init; } = []; + public IReadOnlyList Diagnostics { get; init; } + = Array.Empty(); + + public string Summary + { + get + { + if (PayloadLength == 0) + return "Empty seqOfData payload"; + + var grouping = HasEightByteGroupShape + ? $"{PayloadLength / 8} structural 8-byte group(s)" + : $"{CompleteWordCount} complete 32-bit word(s)"; + return TrailingByteCount == 0 + ? $"Raw generic inspection · {PayloadLength} bytes · {grouping}" + : $"Raw generic inspection · {PayloadLength} bytes · {grouping} · {TrailingByteCount} trailing byte(s)"; + } + } +} + +/// +/// Generic seqOfData inspector used when no trusted dataset layout is available. +/// It never labels words as phase channels, voltage, current, quality, primary, secondary, A, or V. +/// +public static class SvGenericPayloadInspector +{ + private const int WordBytes = 4; + private const int StructuralGroupBytes = 8; + + public static SvGenericPayloadInspection Inspect(ReadOnlyMemory payload) + { + var span = payload.Span; + var completeWordCount = span.Length / WordBytes; + var trailingByteCount = span.Length % WordBytes; + var hasEightByteGroupShape = span.Length >= StructuralGroupBytes && span.Length % StructuralGroupBytes == 0; + var words = new SvGenericPayloadWord[completeWordCount]; + + for (var index = 0; index < completeWordCount; index++) + { + var byteOffset = index * WordBytes; + var wordBytes = span.Slice(byteOffset, WordBytes); + var unsigned = BinaryPrimitives.ReadUInt32BigEndian(wordBytes); + words[index] = new SvGenericPayloadWord + { + Index = index, + ByteOffset = byteOffset, + StructuralRole = ResolveRole(index, hasEightByteGroupShape), + RawBytes = wordBytes.ToArray(), + Hex = Convert.ToHexString(wordBytes), + SignedInt32 = unchecked((int)unsigned), + UnsignedInt32 = unsigned, + Float32 = BitConverter.Int32BitsToSingle(unchecked((int)unsigned)) + }; + } + + var trailingBytes = trailingByteCount == 0 + ? Array.Empty() + : span[^trailingByteCount..].ToArray(); + var diagnostics = BuildDiagnostics(span.Length, completeWordCount, trailingByteCount, hasEightByteGroupShape); + + return new SvGenericPayloadInspection + { + PayloadLength = span.Length, + CompleteWordCount = completeWordCount, + TrailingByteCount = trailingByteCount, + IsFourByteAligned = trailingByteCount == 0, + HasEightByteGroupShape = hasEightByteGroupShape, + Words = words, + TrailingBytes = trailingBytes, + Diagnostics = diagnostics + }; + } + + private static SvGenericPayloadWordRole ResolveRole(int wordIndex, bool hasEightByteGroupShape) + { + if (!hasEightByteGroupShape) + return SvGenericPayloadWordRole.StandaloneWord; + return wordIndex % 2 == 0 + ? SvGenericPayloadWordRole.FirstWordInEightByteGroup + : SvGenericPayloadWordRole.SecondWordInEightByteGroup; + } + + private static IReadOnlyList BuildDiagnostics( + int payloadLength, + int completeWordCount, + int trailingByteCount, + bool hasEightByteGroupShape) + { + var diagnostics = new List(); + if (payloadLength == 0) + { + diagnostics.Add("seqOfData is empty."); + return diagnostics; + } + + diagnostics.Add( + $"Generic inspection exposed {completeWordCount} complete big-endian 32-bit word(s) without assigning channel names or engineering units."); + + if (hasEightByteGroupShape) + { + diagnostics.Add( + "The payload has an 8-byte grouping shape. This is structural evidence only; the second word is not treated as IEC 61850 quality until SCL or an explicit standard layout resolves it."); + } + + if (trailingByteCount > 0) + { + diagnostics.Add( + $"The payload contains {trailingByteCount} trailing byte(s) after the last complete 32-bit word; those bytes are preserved verbatim."); + } + + return diagnostics; + } +} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs index 0e4d91f..2d5fc8a 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs @@ -29,7 +29,13 @@ public sealed record SvEngineeringScale public enum SvEngineeringScaleSource { RawOnly, + + /// + /// Retained for evidence-document compatibility. Generic receive-side analysis no longer activates + /// engineering scaling from packet shape or observed rate alone. + /// Legacy92LeStyleStructuralInference, + SclBackedLegacy92LeStyle, ManualOverride } @@ -60,8 +66,9 @@ public sealed record SvEngineeringScaleEvidence } /// -/// 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. +/// Resolves conservative engineering scaling for an explicitly SCL-bound installed-base +/// 4-current/4-voltage protection layout. Packet shape, rate, product name, APPID, MAC address, +/// svID, and amplitude are never sufficient by themselves. /// public static class SvEngineeringScaleResolver { @@ -75,27 +82,31 @@ public static SvEngineeringScale Resolve(SvEngineeringScaleEvidence 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."); + return SvEngineeringScale.RawOnly("The SCL-derived channel semantics do not prove a current or voltage domain."); + + if (!evidence.IsSclBound) + { + return SvEngineeringScale.RawOnly( + "No SCL dataset mapping is bound. Generic packet inspection preserves raw counts and does not infer engineering units."); + } 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 SvEngineeringScale.RawOnly( + "The SCL-mapped payload is not the fixed 4-current/4-voltage value-quality layout required by this scaling rule."); + } + + if (!HasProtectionRateEvidence(evidence)) + { + return SvEngineeringScale.RawOnly( + "The fixed SCL layout is visible, but declared or observed protection-rate evidence is insufficient for the installed-base scaling rule."); + } + + var reason = + "SCL dataset mapping, fixed 4-current/4-voltage structure, and protection-rate evidence support the installed-base 9-2LE-style scale."; return domain switch { @@ -103,16 +114,16 @@ public static SvEngineeringScale Resolve(SvEngineeringScaleEvidence evidence) { Multiplier = CurrentAmperesPerCount, Unit = "A", - Source = source, - Confidence = confidence, + Source = SvEngineeringScaleSource.SclBackedLegacy92LeStyle, + Confidence = SvEngineeringScaleConfidence.SclBacked, Reason = reason }, SvMeasurementDomain.Voltage => new SvEngineeringScale { Multiplier = VoltageVoltsPerCount, Unit = "V", - Source = source, - Confidence = confidence, + Source = SvEngineeringScaleSource.SclBackedLegacy92LeStyle, + Confidence = SvEngineeringScaleConfidence.SclBacked, Reason = reason }, _ => SvEngineeringScale.RawOnly("Unsupported measurement domain.") diff --git a/src/ARSVIN.Subscriber/MainWindow.xaml.cs b/src/ARSVIN.Subscriber/MainWindow.xaml.cs index 0a42d46..0a3398d 100644 --- a/src/ARSVIN.Subscriber/MainWindow.xaml.cs +++ b/src/ARSVIN.Subscriber/MainWindow.xaml.cs @@ -25,7 +25,11 @@ public MainWindow() DataContext = _viewModel; _viewModel.Streams.CollectionChanged += Streams_CollectionChanged; AttachLivePlotControls(); - Loaded += (_, _) => AttachMeasurementContextToolbar(); + Loaded += (_, _) => + { + AttachMeasurementContextToolbar(); + AttachGenericExplorerUi(); + }; } private void AttachLivePlotControls() @@ -34,17 +38,85 @@ private void AttachLivePlotControls() BindingOperations.SetBinding( scope, OscilloscopePlot.PointsProperty, - new Binding("SelectedStream.WaveformPoints")); + new Binding("SelectedStream.GenericWaveformPoints")); ScopeHost.Child = scope; var phasor = new PhasorPlot(); BindingOperations.SetBinding( phasor, PhasorPlot.VectorsProperty, - new Binding("SelectedStream.Phasors")); + new Binding("SelectedStream.GenericPhasors")); PhasorHost.Child = phasor; } + private void AttachGenericExplorerUi() + { + foreach (var text in FindVisualChildren(this)) + { + if (string.Equals(text.Text, "PROFILE", StringComparison.Ordinal)) + text.Text = "MAPPING"; + else if (string.Equals(text.Text, "CONFIDENCE", StringComparison.Ordinal)) + text.Text = "SEMANTICS"; + + var binding = BindingOperations.GetBinding(text, TextBlock.TextProperty); + var path = binding?.Path?.Path; + if (string.Equals(path, "SelectedStream.Profile", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + text, + TextBlock.TextProperty, + new Binding("SelectedStream.GenericMappingState")); + } + else if (string.Equals(path, "SelectedStream.Confidence", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + text, + TextBlock.TextProperty, + new Binding("SelectedStream.GenericSemanticState")); + } + else if (string.Equals(path, "SelectedStream.WaveformState", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + text, + TextBlock.TextProperty, + new Binding("SelectedStream.GenericWaveformState")); + } + } + + foreach (var grid in FindVisualChildren(this)) + { + var binding = BindingOperations.GetBinding(grid, ItemsControl.ItemsSourceProperty); + var path = binding?.Path?.Path; + if (string.Equals(path, "SelectedStream.Phasors", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + grid, + ItemsControl.ItemsSourceProperty, + new Binding("SelectedStream.GenericPhasors")); + } + else if (string.Equals(path, "SelectedValues", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + grid, + ItemsControl.ItemsSourceProperty, + new Binding("SelectedStream.GenericValues")); + UpdateGenericDecodedColumns(grid); + } + } + } + + private static void UpdateGenericDecodedColumns(DataGrid grid) + { + if (grid.Columns.Count < 6) + return; + + grid.Columns[1].Header = "Word / SCL signal"; + grid.Columns[2].Header = "Representation"; + grid.Columns[3].Header = "Value"; + grid.Columns[4].Header = "Semantics"; + grid.Columns[5].Header = "Raw bytes"; + } + private void AttachMeasurementContextToolbar() { var toolbar = FindVisualChildren(this) diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs new file mode 100644 index 0000000..689f1ad --- /dev/null +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs @@ -0,0 +1,146 @@ +using System.Buffers.Binary; +using System.Collections.Specialized; +using System.ComponentModel; +using AR.Iec61850.SampledValues.Measurements; +using ARSVIN.Subscriber.Models; + +namespace ARSVIN.Subscriber.ViewModels; + +public sealed partial class SvStreamViewModel +{ + private readonly BulkObservableCollection _genericValues = new(); + private readonly BulkObservableCollection _genericWaveformPoints = new(); + private readonly BulkObservableCollection _genericPhasors = new(); + private string _genericMappingState = "Raw seqOfData"; + private string _genericSemanticState = "Unresolved · no assumptions"; + private string _genericWaveformState = "Waiting for stream data"; + + public SvStreamViewModel() + { + _values.CollectionChanged += SourceCollectionChanged; + _waveformPoints.CollectionChanged += SourceCollectionChanged; + _phasors.CollectionChanged += SourceCollectionChanged; + PropertyChanged += StreamPropertyChanged; + RefreshGenericPresentation(); + } + + public IReadOnlyList GenericValues => _genericValues; + public IReadOnlyList GenericWaveformPoints => _genericWaveformPoints; + public IReadOnlyList GenericPhasors => _genericPhasors; + + public string GenericMappingState + { + get => _genericMappingState; + private set => SetProperty(ref _genericMappingState, value); + } + + public string GenericSemanticState + { + get => _genericSemanticState; + private set => SetProperty(ref _genericSemanticState, value); + } + + public string GenericWaveformState + { + get => _genericWaveformState; + private set => SetProperty(ref _genericWaveformState, value); + } + + private void SourceCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + => RefreshGenericPresentation(); + + private void StreamPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(Bound) or nameof(WaveformState) or nameof(Scaling)) + RefreshGenericPresentation(); + } + + private void RefreshGenericPresentation() + { + if (HasSclSemanticMapping()) + { + GenericMappingState = "SCL dataset mapping"; + GenericSemanticState = "Resolved from ordered SCL elements"; + GenericWaveformState = WaveformState; + _genericValues.ReplaceAll(_values); + _genericWaveformPoints.ReplaceAll(_waveformPoints); + _genericPhasors.ReplaceAll(_phasors); + return; + } + + GenericMappingState = "Raw seqOfData"; + GenericSemanticState = "Unresolved · words shown without channel, unit, or quality claims"; + GenericWaveformState = _values.Count == 0 + ? "Waiting for seqOfData" + : "Raw words available · import SCL before semantic waveform and phasor analysis"; + _genericValues.ReplaceAll(BuildGenericRows(_values)); + _genericWaveformPoints.ReplaceAll(Array.Empty()); + _genericPhasors.ReplaceAll(Array.Empty()); + } + + private bool HasSclSemanticMapping() + => Bound.StartsWith("SCL:", StringComparison.OrdinalIgnoreCase); + + private static IReadOnlyList BuildGenericRows(IReadOnlyList source) + { + if (source.Count == 0) + return Array.Empty(); + + var rows = new List(source.Count); + for (var index = 0; index < source.Count; index++) + { + var original = source[index]; + var byteOffset = index * 4; + if (!TryReadWord(original.Raw, out var signed, out var unsigned)) + { + rows.Add(new DecodedValueRow + { + Index = index + 1, + Signal = $"Bytes {index + 1} (+0x{byteOffset:X2})", + Kind = "Raw bytes", + Value = original.Raw, + Raw = original.Raw, + ScalingSource = SvEngineeringScaleSource.RawOnly, + ScalingConfidence = SvEngineeringScaleConfidence.Unknown, + ScalingReason = "No SCL mapping is bound; bytes are preserved without semantic interpretation." + }); + continue; + } + + rows.Add(new DecodedValueRow + { + Index = index + 1, + Signal = $"Word {index + 1} (+0x{byteOffset:X2})", + Kind = index % 2 == 0 ? "INT32 / UINT32 · group word 1" : "INT32 / UINT32 · group word 2", + Value = $"{signed} / {unsigned}", + Raw = original.Raw, + NumericValue = signed, + ScalingSource = SvEngineeringScaleSource.RawOnly, + ScalingConfidence = SvEngineeringScaleConfidence.Unknown, + ScalingReason = "Generic 32-bit representation only. Channel, quality, unit, and scaling semantics are unresolved until SCL or explicit reviewed mapping is available." + }); + } + + return rows; + } + + private static bool TryReadWord(string rawHex, out int signed, out uint unsigned) + { + signed = 0; + unsigned = 0; + if (string.IsNullOrWhiteSpace(rawHex) || rawHex.Length != 8) + return false; + + try + { + var bytes = Convert.FromHexString(rawHex); + unsigned = BinaryPrimitives.ReadUInt32BigEndian(bytes); + signed = unchecked((int)unsigned); + return true; + } + catch (FormatException) + { + return false; + } + } +} diff --git a/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericAsduInspectorTests.cs b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericAsduInspectorTests.cs new file mode 100644 index 0000000..988fa45 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericAsduInspectorTests.cs @@ -0,0 +1,51 @@ +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Analysis; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Analysis; + +public sealed class SvGenericAsduInspectorTests +{ + [Fact] + public void InspectKeepsWireFieldsSeparateFromDatasetSemantics() + { + var asdu = new SampledValueAsdu + { + SvId = "MU01", + DataSetReference = "LD0/LLN0$Dataset1", + SampleCount = 123, + ConfigurationRevision = 7, + SampleSynchronization = 2, + SampleRate = 4_800, + SampleMode = 1, + SamplePayload = new byte[12] + }; + + var inspection = SvGenericAsduInspector.Inspect(asdu); + + Assert.Equal("MU01", inspection.SvId); + Assert.Equal((ushort)123, inspection.SampleCount); + Assert.Equal((uint)7, inspection.ConfigurationRevision); + Assert.Equal((ushort)4_800, inspection.SampleRate); + Assert.Equal((ushort)1, inspection.SampleMode); + Assert.Equal(3, inspection.Payload.CompleteWordCount); + Assert.Contains("bind SCL", inspection.MappingState, StringComparison.OrdinalIgnoreCase); + Assert.Contains("smpRate", inspection.OptionalFieldSummary); + Assert.Contains("smpMod", inspection.OptionalFieldSummary); + } + + [Fact] + public void InspectReportsUnresolvedMappingWhenDatasetReferenceIsAbsent() + { + var asdu = new SampledValueAsdu + { + SvId = "MU01", + SamplePayload = new byte[8] + }; + + var inspection = SvGenericAsduInspector.Inspect(asdu); + + Assert.Contains("unresolved", inspection.MappingState, StringComparison.OrdinalIgnoreCase); + Assert.Equal("No optional ASDU fields observed", inspection.OptionalFieldSummary); + } +} diff --git a/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPayloadInspectorTests.cs b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPayloadInspectorTests.cs new file mode 100644 index 0000000..55eba76 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPayloadInspectorTests.cs @@ -0,0 +1,81 @@ +using AR.Iec61850.SampledValues.Analysis; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Analysis; + +public sealed class SvGenericPayloadInspectorTests +{ + [Fact] + public void InspectExposesEveryWordWithoutInventingChannelSemantics() + { + var payload = new byte[64]; + payload[2] = 0x03; + payload[3] = 0xE8; + payload[8] = 0xFF; + payload[9] = 0xFF; + payload[10] = 0xFC; + payload[11] = 0x18; + + var inspection = SvGenericPayloadInspector.Inspect(payload); + + Assert.Equal(64, inspection.PayloadLength); + Assert.Equal(16, inspection.CompleteWordCount); + Assert.True(inspection.IsFourByteAligned); + Assert.True(inspection.HasEightByteGroupShape); + Assert.Equal(1000, inspection.Words[0].SignedInt32); + Assert.Equal(-1000, inspection.Words[2].SignedInt32); + Assert.All(inspection.Words, word => Assert.StartsWith("Word ", word.GenericLabel)); + Assert.DoesNotContain(inspection.Words, word => + word.GenericLabel.Contains("Ia", StringComparison.OrdinalIgnoreCase) || + word.GenericLabel.Contains("Voltage", StringComparison.OrdinalIgnoreCase) || + word.GenericLabel.Contains("Quality", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void InspectUsesBigEndianSignedAndUnsignedViewsOfTheSameBytes() + { + var inspection = SvGenericPayloadInspector.Inspect( + new byte[] { 0xFF, 0xFF, 0xFF, 0xFE }); + + var word = Assert.Single(inspection.Words); + Assert.Equal(-2, word.SignedInt32); + Assert.Equal(4_294_967_294u, word.UnsignedInt32); + Assert.Equal("FFFFFFFE", word.Hex); + Assert.Equal("+0x00", word.OffsetLabel); + Assert.Equal(SvGenericPayloadWordRole.StandaloneWord, word.StructuralRole); + } + + [Fact] + public void InspectMarksEightByteGroupingAsStructuralOnly() + { + var inspection = SvGenericPayloadInspector.Inspect(new byte[16]); + + Assert.Equal(SvGenericPayloadWordRole.FirstWordInEightByteGroup, inspection.Words[0].StructuralRole); + Assert.Equal(SvGenericPayloadWordRole.SecondWordInEightByteGroup, inspection.Words[1].StructuralRole); + Assert.Contains(inspection.Diagnostics, diagnostic => + diagnostic.Contains("structural evidence only", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void InspectPreservesTrailingBytesInsteadOfDroppingPayload() + { + var inspection = SvGenericPayloadInspector.Inspect( + new byte[] { 0x00, 0x00, 0x00, 0x01, 0xAA, 0xBB }); + + Assert.False(inspection.IsFourByteAligned); + Assert.Equal(1, inspection.CompleteWordCount); + Assert.Equal(2, inspection.TrailingByteCount); + Assert.Equal(new byte[] { 0xAA, 0xBB }, inspection.TrailingBytes); + Assert.Contains("2 trailing byte(s)", inspection.Summary); + } + + [Fact] + public void InspectHandlesEmptyPayloadExplicitly() + { + var inspection = SvGenericPayloadInspector.Inspect(ReadOnlyMemory.Empty); + + Assert.Empty(inspection.Words); + Assert.Equal("Empty seqOfData payload", inspection.Summary); + Assert.Contains("empty", Assert.Single(inspection.Diagnostics), StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs b/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs index d120bbb..a09634c 100644 --- a/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs +++ b/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs @@ -6,7 +6,7 @@ namespace ARSVIN.Tests; public sealed class SampledValuesMeasurementsTests { [Fact] - public void ScaleResolverConvertsFixedProtectionCurrentToAmperes() + public void ScaleResolverDoesNotInferCurrentUnitsWithoutSclMapping() { var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence { @@ -19,14 +19,15 @@ public void ScaleResolverConvertsFixedProtectionCurrentToAmperes() DeclaredSampleRate = 4_000 }); - Assert.Equal(SvEngineeringScaleSource.Legacy92LeStyleStructuralInference, scale.Source); - Assert.Equal(SvEngineeringScaleConfidence.Inferred, scale.Confidence); - Assert.Equal("A", scale.Unit); - Assert.Equal(1.0, scale.Apply(1_000), 9); + Assert.Equal(SvEngineeringScaleSource.RawOnly, scale.Source); + Assert.Equal(SvEngineeringScaleConfidence.Unknown, scale.Confidence); + Assert.Equal("count", scale.Unit); + Assert.Equal(1_000, scale.Apply(1_000)); + Assert.Contains("No SCL dataset mapping", scale.Reason, StringComparison.OrdinalIgnoreCase); } [Fact] - public void ScaleResolverConvertsFixedProtectionVoltageToVolts() + public void ScaleResolverConvertsSclMappedFixedProtectionVoltageToVolts() { var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence { @@ -35,7 +36,9 @@ public void ScaleResolverConvertsFixedProtectionVoltageToVolts() IsSclBound = true, IsFixedFourCurrentFourVoltageLayout = true, AnalogChannelCount = 8, - PayloadBytesPerAsdu = 64 + PayloadBytesPerAsdu = 64, + DeclaredSampleMode = 1, + DeclaredSampleRate = 4_000 }); Assert.Equal(SvEngineeringScaleSource.SclBackedLegacy92LeStyle, scale.Source); @@ -44,6 +47,23 @@ public void ScaleResolverConvertsFixedProtectionVoltageToVolts() Assert.Equal(100.0, scale.Apply(10_000), 9); } + [Fact] + public void ScaleResolverWithholdsFixedLayoutScalingWhenRateEvidenceIsMissing() + { + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = "TCTR1/AmpSv.instMag.i", + Kind = "Current", + IsSclBound = true, + IsFixedFourCurrentFourVoltageLayout = true, + AnalogChannelCount = 8, + PayloadBytesPerAsdu = 64 + }); + + Assert.Equal(SvEngineeringScaleSource.RawOnly, scale.Source); + Assert.Contains("rate evidence", scale.Reason, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ScaleResolverKeepsUnknownLayoutAsRawCounts() { @@ -51,6 +71,7 @@ public void ScaleResolverKeepsUnknownLayoutAsRawCounts() { Channel = "Ia", Kind = "Current", + IsSclBound = true, AnalogChannelCount = 12, PayloadBytesPerAsdu = 96, ObservedSamplesPerSecond = 4_000