Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
2d9d015
Add conservative Sampled Values engineering scaling
masarray Jul 23, 2026
ff629a7
Add profile-neutral Sampled Values counter tracker
masarray Jul 23, 2026
1af9eb7
Add evidence-driven Sampled Values timebase resolver
masarray Jul 23, 2026
c6aab3e
Add explicit Sampled Values measurement ratio context
masarray Jul 23, 2026
f6cfff9
Add semantic Sampled Values quality analysis
masarray Jul 23, 2026
365b5db
Add versioned Sampled Values measurement context
masarray Jul 23, 2026
0b0c37d
Add generic Sampled Values payload inspector
masarray Jul 23, 2026
74d8fe6
Add generic Sampled Values ASDU inspector
masarray Jul 23, 2026
206ac9d
Test unified Sampled Values analysis core
masarray Jul 23, 2026
8d60cd6
Document Sampled Values engine ownership boundary
masarray Jul 23, 2026
7923a28
Fix nullable measurement assertions in SV core tests
masarray Jul 23, 2026
09f2822
Move Sampled Values publisher quality encoding into core
masarray Jul 23, 2026
8858e52
Move Sampled Values publisher evidence contracts into core
masarray Jul 23, 2026
e5b476c
Move transmitter timing health monitor into core
masarray Jul 23, 2026
b51bee0
Test publisher quality evidence and timing core contracts
masarray Jul 23, 2026
f068b83
Correct IEC 61850 quality validity encoding
masarray Jul 23, 2026
095d493
Test IEC quality validity mapping
masarray Jul 23, 2026
bc951ba
Move Sampled Values counter policy into core
masarray Jul 23, 2026
cfdd451
Move generated Sampled Values PCAP export into core
masarray Jul 23, 2026
9e91cd8
Move Sampled Values frame preview into core
masarray Jul 23, 2026
fd3708b
Move Sampled Values publisher validation into core
masarray Jul 23, 2026
e3acf50
Unify multi-ASDU publisher profile behavior in core
masarray Jul 23, 2026
9629621
Unify quality-aware Sampled Values payload generation
masarray Jul 23, 2026
d197f17
Preserve ARIEC61850 test diagnostics in CI
masarray Jul 23, 2026
a85ffdd
Move reusable SV profile evidence models into core
masarray Jul 23, 2026
1d75412
Move SV observation accumulator into core
masarray Jul 23, 2026
7359153
Move SV configuration comparison contracts into core
masarray Jul 23, 2026
ee33d49
Move SCL expected stream configuration factory into core
masarray Jul 23, 2026
37db0fa
Move vendor-neutral SV evidence detector into core
masarray Jul 23, 2026
138ded1
Move unified live and PCAP SV observation manager into core
masarray Jul 23, 2026
ffe3aa9
Move Subscriber evidence report contracts and serialization into core
masarray Jul 23, 2026
3fd45b5
Move Subscriber evidence comparison contracts into core
masarray Jul 23, 2026
49220cf
Update SCL publisher tests for supported multi-ASDU profiles
masarray Jul 23, 2026
861f853
Support multi-ASDU publisher session batches in core
masarray Jul 23, 2026
cac08b0
Move Sampled Values transmitter timing health into core namespace
masarray Jul 23, 2026
fd03fe0
Keep transmitter timing health in the Sampled Values core namespace
masarray Jul 23, 2026
bb39964
Use the Sampled Values timing-health namespace in core tests
masarray Jul 23, 2026
143e6ca
Test timing health after the warmup window
masarray Jul 23, 2026
fcc05de
Restore MAC value semantics and Publisher evidence compatibility
masarray Jul 24, 2026
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
21 changes: 20 additions & 1 deletion .github/workflows/dotnet-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,23 @@ jobs:
run: dotnet build .\source\ARIEC61850.sln -c Release --no-restore

- name: Test
run: dotnet test .\source\ARIEC61850.sln -c Release --no-build --verbosity normal
shell: pwsh
run: |
New-Item -ItemType Directory -Path artifacts/test-results -Force | Out-Null
dotnet test .\source\ARIEC61850.sln `
-c Release `
--no-build `
--verbosity normal `
--logger "trx;LogFileName=ARIEC61850.Tests.trx" `
--results-directory artifacts/test-results 2>&1 |
Tee-Object -FilePath artifacts/test-results/dotnet-test.log
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

- name: Upload test diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ariec61850-test-diagnostics
path: artifacts/test-results
if-no-files-found: warn
retention-days: 14
61 changes: 61 additions & 0 deletions docs/SAMPLED_VALUES_CORE_OWNERSHIP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Sampled Values core ownership

## Decision

`ARIEC61850` is the single source of truth for reusable IEC 61850 protocol, Sampled Values decoding, engineering analysis, measurement-domain conversion, diagnostics, and evidence contracts.

Derived applications such as ARSVIN Publisher and ArSubsv Subscriber may provide WPF presentation, commands, file dialogs, adapter selection, orchestration, and user-facing formatting. They must not fork or silently modify protocol and measurement behavior in application repositories.

## Core imported from the ARSVIN application repository

The following reusable components were present in the embedded `ARSVIN.Engine` copy but absent from `ARIEC61850/main` during the July 2026 ownership audit:

- evidence-driven engineering scaling;
- profile-neutral `smpCnt` transition tracking;
- timebase resolution without a hidden 50/60 Hz assumption;
- explicit CT/VT ratio and primary/secondary-domain conversion;
- semantic Sampled Values quality decoding;
- versioned stream measurement-context JSON;
- generic ASDU inspection;
- generic `seqOfData` word inspection with preserved trailing bytes.

These components are vendor-neutral. Manufacturer or product identity must never select a parser, dataset order, scaling rule, quality interpretation, timebase, or health result.

## Ownership boundary

### ARIEC61850

- Ethernet, VLAN, APPID, BER, APDU, and ASDU codecs;
- Sampled Values frame parsing and building;
- SCL parsing and ordered FCDA/DataSet mapping;
- generic raw payload inspection;
- quality semantics;
- continuity, timebase, scaling, CT/VT, waveform, RMS, phasor, noise-floor, and signal-validity analysis;
- protocol, stream, configuration, and measurement health contracts;
- PCAP and Npcap transport abstractions;
- reusable evidence and comparison models;
- deterministic protocol and engineering tests.

### Derived applications

- WPF controls and windows;
- ViewModels and commands;
- selected-stream state and UI refresh policy;
- adapter and file-selection workflows;
- plot rendering and display formatting;
- application settings, branding, and packaging;
- application smoke and presentation tests.

## Integration contract

Local application development uses sibling repositories:

```text
<workspace>/
├── ARIEC61850/
└── arsvin/
```

Application CI must pin and checkout a reviewed ARIEC61850 commit or branch as a sibling. It must not build against a moving, unrecorded engine revision.

The migration is intentionally staged. Existing embedded source can remain temporarily for comparison, but it must be removed from active project references. New reusable IEC 61850 logic belongs here first.
27 changes: 27 additions & 0 deletions src/AR.Iec61850/Ethernet/MacAddress.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ public static bool TryParse(string? text, out MacAddress address)
public byte[] ToArray()
=> _bytes?.ToArray() ?? new byte[6];

/// <summary>
/// Compares the six address octets by value. The backing array is an implementation detail
/// and must not make two equivalent MAC addresses compare as different instances.
/// </summary>
public bool Equals(MacAddress other)
{
for (var i = 0; i < 6; i++)
{
if (ByteAt(i) != other.ByteAt(i))
return false;
}

return true;
}

public override int GetHashCode()
{
var hash = new HashCode();
for (var i = 0; i < 6; i++)
hash.Add(ByteAt(i));

return hash.ToHashCode();
}

public void CopyTo(Span<byte> destination)
{
if (destination.Length < 6)
Expand All @@ -64,4 +88,7 @@ public override string ToString()
var bytes = _bytes ?? new byte[6];
return string.Join(":", bytes.Select(b => b.ToString("X2", CultureInfo.InvariantCulture)));
}

private byte ByteAt(int index)
=> _bytes is null ? (byte)0 : _bytes[index];
}
58 changes: 58 additions & 0 deletions src/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
namespace AR.Iec61850.SampledValues.Analysis;

/// <summary>
/// Generic, vendor-neutral view of one Sampled Values ASDU.
/// It separates fields observed on the wire from semantic dataset interpretation.
/// </summary>
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<string>();
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)
};
}
}
162 changes: 162 additions & 0 deletions src/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using System.Buffers.Binary;

namespace AR.Iec61850.SampledValues.Analysis;

/// <summary>
/// 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.
/// </summary>
public enum SvGenericPayloadWordRole
{
StandaloneWord,
FirstWordInEightByteGroup,
SecondWordInEightByteGroup
}

/// <summary>
/// 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.
/// </summary>
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}";
}

/// <summary>
/// Vendor-neutral structural inspection of one ASDU seqOfData payload.
/// It deliberately preserves unknown semantics instead of inventing current/voltage channels.
/// </summary>
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<SvGenericPayloadWord> Words { get; init; }
= Array.Empty<SvGenericPayloadWord>();
public byte[] TrailingBytes { get; init; } = [];
public IReadOnlyList<string> Diagnostics { get; init; }
= Array.Empty<string>();

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

/// <summary>
/// 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.
/// </summary>
public static class SvGenericPayloadInspector
{
private const int WordBytes = 4;
private const int StructuralGroupBytes = 8;

public static SvGenericPayloadInspection Inspect(ReadOnlyMemory<byte> 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<byte>()
: 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<string> BuildDiagnostics(
int payloadLength,
int completeWordCount,
int trailingByteCount,
bool hasEightByteGroupShape)
{
var diagnostics = new List<string>();
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;
}
}
Loading
Loading