diff --git a/docs/sv-measurement-context.md b/docs/sv-measurement-context.md new file mode 100644 index 0000000..8c90ca6 --- /dev/null +++ b/docs/sv-measurement-context.md @@ -0,0 +1,68 @@ +# SV measurement context + +ArSubsv separates three different measurement concepts: + +1. the raw protocol count, +2. the profile-scaled wire engineering value, +3. a primary or secondary-equivalent display value produced from explicit CT/VT context. + +The Subscriber never derives a transformer ratio from vendor identity, signal amplitude, APPID, MAC address, or `svID` naming. + +## User workflow + +1. Select a discovered SV stream. +2. Open **CT/VT** from the responsive toolbar. +3. Declare whether the scaled value on the wire is interpreted as `PrimaryEngineering` or `SecondaryEquivalent`. +4. Choose the preferred display domain. +5. Enter current and/or voltage primary and secondary nominal values. +6. Record the evidence source and reference. +7. Apply the context. + +Waveform, decoded engineering values, RMS, and phasors use the preferred domain only when the relevant ratio is valid. A missing current ratio does not prevent a verified voltage ratio from being used, and vice versa. Channels without sufficient context stay in their wire engineering domain. + +## JSON evidence document + +Use **Ctx Export** to save configured contexts. Use **Ctx Import** to restore them or apply them before the corresponding stream is discovered. + +```json +{ + "schemaVersion": 1, + "exportedAt": "2026-07-22T08:30:00Z", + "streams": [ + { + "streamKey": "logical-stream-key", + "svId": "MU01", + "wireDomain": "PrimaryEngineering", + "displayDomain": "SecondaryEquivalent", + "currentRatio": { + "primaryNominal": 1000, + "secondaryNominal": 1, + "unit": "A", + "source": "DeviceConfiguration", + "reference": "SMU current input configuration" + }, + "voltageRatio": { + "primaryNominal": 20000, + "secondaryNominal": 100, + "unit": "V", + "source": "DeviceConfiguration", + "reference": "SMU voltage input configuration" + }, + "notes": "Matched to the sanitized SCL and known-injection PCAP" + } + ] +} +``` + +## Evidence rules + +- `Manual` means the values were entered by an engineer and should be checked against the test record. +- `Scl` means the ratio was obtained from an explicit, reviewable SCL source. +- `DeviceConfiguration` means the ratio was read from a device configuration or setting export. +- `DeviceValidated` means the context has been checked against real-device capture and known-injection evidence. +- Empty ratio fields are preferable to guessed values. +- Context conversion changes presentation; it does not alter the captured bytes or raw protocol values. + +## Validation boundary + +The context workflow makes primary/secondary interpretation explicit and repeatable. It does not turn a Windows PC into a calibrated test set, and it does not replace device-specific injection validation. diff --git a/src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementContextDocument.cs b/src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementContextDocument.cs new file mode 100644 index 0000000..098e4fc --- /dev/null +++ b/src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementContextDocument.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AR.Iec61850.SampledValues.Measurements; + +/// +/// User-supplied measurement-domain context for one logical SV stream. +/// The stream key is the evidence identity used by the Subscriber, not a vendor name. +/// +public sealed record SvStreamMeasurementContext +{ + public string StreamKey { get; init; } = string.Empty; + public string SvId { get; init; } = string.Empty; + public SvMeasurementValueDomain WireDomain { get; init; } = SvMeasurementValueDomain.PrimaryEngineering; + public SvMeasurementValueDomain DisplayDomain { get; init; } = SvMeasurementValueDomain.PrimaryEngineering; + public SvMeasurementRatio? CurrentRatio { get; init; } + public SvMeasurementRatio? VoltageRatio { get; init; } + public string Notes { get; init; } = string.Empty; + public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow; + + public SvMeasurementRatio? ResolveRatio(string kindOrChannel) + { + var text = kindOrChannel?.Trim() ?? string.Empty; + if (text.Contains("voltage", StringComparison.OrdinalIgnoreCase) || + text.StartsWith("V", StringComparison.OrdinalIgnoreCase) || + text.Contains("TVTR", StringComparison.OrdinalIgnoreCase) || + text.Contains("VolSv", StringComparison.OrdinalIgnoreCase)) + return VoltageRatio; + + if (text.Contains("current", StringComparison.OrdinalIgnoreCase) || + text.StartsWith("I", StringComparison.OrdinalIgnoreCase) || + text.Contains("TCTR", StringComparison.OrdinalIgnoreCase) || + text.Contains("AmpSv", StringComparison.OrdinalIgnoreCase)) + return CurrentRatio; + + return null; + } + + public string Summary + { + get + { + var current = CurrentRatio?.IsValid == true + ? $"I {CurrentRatio.PrimaryNominal:0.###}/{CurrentRatio.SecondaryNominal:0.###} {CurrentRatio.Unit}" + : "I ratio unset"; + var voltage = VoltageRatio?.IsValid == true + ? $"V {VoltageRatio.PrimaryNominal:0.###}/{VoltageRatio.SecondaryNominal:0.###} {VoltageRatio.Unit}" + : "V ratio unset"; + return $"wire {WireDomain} → display {DisplayDomain} · {current} · {voltage}"; + } + } + + public IReadOnlyList Validate() + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(StreamKey)) + errors.Add("StreamKey is required."); + if (WireDomain is not (SvMeasurementValueDomain.PrimaryEngineering or SvMeasurementValueDomain.SecondaryEquivalent)) + errors.Add("WireDomain must be PrimaryEngineering or SecondaryEquivalent."); + if (DisplayDomain is not (SvMeasurementValueDomain.PrimaryEngineering or SvMeasurementValueDomain.SecondaryEquivalent)) + errors.Add("DisplayDomain must be PrimaryEngineering or SecondaryEquivalent."); + + ValidateRatio(CurrentRatio, "current", "A", errors); + ValidateRatio(VoltageRatio, "voltage", "V", errors); + return errors; + } + + private static void ValidateRatio( + SvMeasurementRatio? ratio, + string label, + string expectedUnit, + ICollection errors) + { + if (ratio is null) + return; + if (!ratio.IsValid) + errors.Add($"The {label} ratio requires positive finite primary and secondary nominal values."); + if (!string.Equals(ratio.Unit, expectedUnit, StringComparison.OrdinalIgnoreCase)) + errors.Add($"The {label} ratio unit must be {expectedUnit}."); + if (ratio.Source == SvRatioSource.Unknown) + errors.Add($"The {label} ratio source must be explicit."); + } +} + +public sealed record SvMeasurementContextDocument +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + public DateTimeOffset ExportedAt { get; init; } = DateTimeOffset.UtcNow; + public IReadOnlyList Streams { get; init; } + = Array.Empty(); + + public IReadOnlyList Validate() + { + var errors = new List(); + if (SchemaVersion != CurrentSchemaVersion) + errors.Add($"Unsupported measurement-context schema version {SchemaVersion}; expected {CurrentSchemaVersion}."); + + var duplicateKeys = Streams + .Where(item => !string.IsNullOrWhiteSpace(item.StreamKey)) + .GroupBy(item => item.StreamKey, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToArray(); + errors.AddRange(duplicateKeys.Select(key => $"Duplicate measurement context for stream key '{key}'.")); + + foreach (var stream in Streams) + { + var label = string.IsNullOrWhiteSpace(stream.SvId) ? stream.StreamKey : stream.SvId; + errors.AddRange(stream.Validate().Select(error => $"{label}: {error}")); + } + return errors; + } +} + +public static class SvMeasurementContextSerializer +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + public static string ToJson(SvMeasurementContextDocument document) + { + ArgumentNullException.ThrowIfNull(document); + EnsureValid(document); + return JsonSerializer.Serialize(document, Options); + } + + public static SvMeasurementContextDocument FromJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + throw new InvalidDataException("Measurement-context JSON is empty."); + + SvMeasurementContextDocument document; + try + { + document = JsonSerializer.Deserialize(json, Options) + ?? throw new InvalidDataException("Measurement-context JSON did not contain a document."); + } + catch (JsonException ex) + { + throw new InvalidDataException($"Measurement-context JSON is invalid: {ex.Message}", ex); + } + + EnsureValid(document); + return document; + } + + private static void EnsureValid(SvMeasurementContextDocument document) + { + var errors = document.Validate(); + if (errors.Count > 0) + throw new InvalidDataException("Measurement-context validation failed: " + string.Join(" ", errors)); + } +} diff --git a/src/ARSVIN.Subscriber/MainWindow.xaml.cs b/src/ARSVIN.Subscriber/MainWindow.xaml.cs index 43bd8ab..0a42d46 100644 --- a/src/ARSVIN.Subscriber/MainWindow.xaml.cs +++ b/src/ARSVIN.Subscriber/MainWindow.xaml.cs @@ -1,6 +1,10 @@ +using System.Collections.Specialized; using System.Text.Json; using System.Windows; +using System.Windows.Controls; using System.Windows.Data; +using System.Windows.Media; +using AR.Iec61850.SampledValues.Measurements; using AR.Iec61850.SampledValues.Reporting; using ARSVIN.Subscriber.Controls; using ARSVIN.Subscriber.ViewModels; @@ -11,13 +15,17 @@ namespace ARSVIN.Subscriber; public partial class MainWindow : Window { private readonly SvSubscriberViewModel _viewModel; + private readonly Dictionary _measurementContexts = + new(StringComparer.Ordinal); public MainWindow() { InitializeComponent(); _viewModel = new SvSubscriberViewModel(); DataContext = _viewModel; + _viewModel.Streams.CollectionChanged += Streams_CollectionChanged; AttachLivePlotControls(); + Loaded += (_, _) => AttachMeasurementContextToolbar(); } private void AttachLivePlotControls() @@ -37,6 +45,200 @@ private void AttachLivePlotControls() PhasorHost.Child = phasor; } + private void AttachMeasurementContextToolbar() + { + var toolbar = FindVisualChildren(this) + .FirstOrDefault(panel => panel.Children.OfType