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