From 1745e39e4e87f9663a0132f0a7a739c85c3d513a Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:33:41 +0700 Subject: [PATCH 01/13] Add versioned SV measurement context document --- .../SvMeasurementContextDocument.cs | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementContextDocument.cs diff --git a/src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementContextDocument.cs b/src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementContextDocument.cs new file mode 100644 index 0000000..0f5d35a --- /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) + errors.AddRange(stream.Validate().Select(error => $"{stream.SvId.DefaultIfEmpty(stream.StreamKey)}: {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)); + } + + private static string DefaultIfEmpty(this string value, string fallback) + => string.IsNullOrWhiteSpace(value) ? fallback : value; +} From 4d3ace8986f81ed63582176899e61aacdffdc45b Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:34:27 +0700 Subject: [PATCH 02/13] Fix measurement context validation label --- .../Measurements/SvMeasurementContextDocument.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementContextDocument.cs b/src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementContextDocument.cs index 0f5d35a..098e4fc 100644 --- a/src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementContextDocument.cs +++ b/src/ARSVIN.Engine/SampledValues/Measurements/SvMeasurementContextDocument.cs @@ -106,7 +106,10 @@ public IReadOnlyList Validate() errors.AddRange(duplicateKeys.Select(key => $"Duplicate measurement context for stream key '{key}'.")); foreach (var stream in Streams) - errors.AddRange(stream.Validate().Select(error => $"{stream.SvId.DefaultIfEmpty(stream.StreamKey)}: {error}")); + { + var label = string.IsNullOrWhiteSpace(stream.SvId) ? stream.StreamKey : stream.SvId; + errors.AddRange(stream.Validate().Select(error => $"{label}: {error}")); + } return errors; } } @@ -154,7 +157,4 @@ private static void EnsureValid(SvMeasurementContextDocument document) if (errors.Count > 0) throw new InvalidDataException("Measurement-context validation failed: " + string.Join(" ", errors)); } - - private static string DefaultIfEmpty(this string value, string fallback) - => string.IsNullOrWhiteSpace(value) ? fallback : value; } From 6f64b75081009f1a1c1e48f04802638f5a0e7922 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:34:57 +0700 Subject: [PATCH 03/13] Expose primary and secondary display domains in decoded values --- .../Models/DecodedValueRow.cs | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs b/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs index c4c64ac..2c690fe 100644 --- a/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs +++ b/src/ARSVIN.Subscriber/Models/DecodedValueRow.cs @@ -13,13 +13,17 @@ public sealed class DecodedValueRow /// Decoded protocol number before any engineering scaling. public double? NumericValue { get; init; } - /// Primary engineering value when scaling evidence is sufficient. + /// Engineering value produced by protocol/profile scaling. public double? EngineeringValue { get; init; } public string EngineeringUnit { get; init; } = string.Empty; public SvEngineeringScaleSource ScalingSource { get; init; } = SvEngineeringScaleSource.RawOnly; public SvEngineeringScaleConfidence ScalingConfidence { get; init; } = SvEngineeringScaleConfidence.Unknown; public string ScalingReason { get; init; } = string.Empty; + /// Optional explicit primary/secondary interpretation supplied by measurement context. + public SvMeasurementDomainValue? DomainValue { get; init; } + public SvMeasurementValueDomain PreferredDisplayDomain { get; init; } = SvMeasurementValueDomain.Unknown; + public bool HasEngineeringValue => EngineeringValue.HasValue && ScalingSource != SvEngineeringScaleSource.RawOnly; public bool IsQuality => Kind.Contains("Quality", StringComparison.OrdinalIgnoreCase); public SvQualityState? QualityState => IsQuality && SvQualityDecoder.TryDecodeHex(Raw, out var quality) @@ -34,12 +38,31 @@ public string DisplayValue { if (QualityState is { } quality) return quality.Summary; + if (TryResolvePreferredDisplay(out var value, out var unit)) + return $"{value:0.###} {unit}".TrimEnd(); return HasEngineeringValue - ? $"{EngineeringValue:0.###} {EngineeringUnit}" + ? $"{EngineeringValue:0.###} {EngineeringUnit}".TrimEnd() : Value; } } + public string MeasurementDomainText + { + get + { + if (QualityState is not null) + return "Quality"; + if (DomainValue is null) + return HasEngineeringValue ? "Engineering" : "Raw"; + + if (PreferredDisplayDomain == SvMeasurementValueDomain.PrimaryEngineering && DomainValue.PrimaryValue.HasValue) + return "Primary"; + if (PreferredDisplayDomain == SvMeasurementValueDomain.SecondaryEquivalent && DomainValue.SecondaryEquivalentValue.HasValue) + return "Secondary"; + return $"{DomainValue.WireDomain} · fallback"; + } + } + public string ScalingText { get @@ -51,4 +74,37 @@ public string ScalingText : "Raw counts"; } } + + private bool TryResolvePreferredDisplay(out double value, out string unit) + { + value = 0; + unit = EngineeringUnit; + if (DomainValue is null) + return false; + + if (PreferredDisplayDomain == SvMeasurementValueDomain.PrimaryEngineering && + DomainValue.PrimaryValue.HasValue) + { + value = DomainValue.PrimaryValue.Value; + unit = DomainValue.Unit; + return true; + } + + if (PreferredDisplayDomain == SvMeasurementValueDomain.SecondaryEquivalent && + DomainValue.SecondaryEquivalentValue.HasValue) + { + value = DomainValue.SecondaryEquivalentValue.Value; + unit = DomainValue.Unit; + return true; + } + + if (DomainValue.WireDomain is SvMeasurementValueDomain.PrimaryEngineering or SvMeasurementValueDomain.SecondaryEquivalent) + { + value = DomainValue.WireValue; + unit = DomainValue.Unit; + return true; + } + + return false; + } } From fa3089cc16fdb9e10c5e53fc1d285e8c46459a10 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:36:50 +0700 Subject: [PATCH 04/13] Apply explicit CT VT context to SV displays --- .../ViewModels/SvStreamViewModel.cs | 166 ++++++++++++++++-- 1 file changed, 151 insertions(+), 15 deletions(-) diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs index 3fd00e7..6362555 100644 --- a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.cs @@ -42,6 +42,7 @@ public sealed class SvStreamViewModel : ObservableObject private string _waveformState = "Waiting for a resolved timebase"; private string _scaling = "Raw counts"; private string _timebase = "Unknown"; + private string _measurementContext = "No explicit CT/VT context"; public IReadOnlyList Values => _values; public IReadOnlyList WaveformPoints => _waveformPoints; @@ -80,8 +81,12 @@ public sealed class SvStreamViewModel : ObservableObject public string WaveformState { get => _waveformState; set => SetProperty(ref _waveformState, value); } public string Scaling { get => _scaling; set => SetProperty(ref _scaling, value); } public string Timebase { get => _timebase; set => SetProperty(ref _timebase, value); } + public string MeasurementContext { get => _measurementContext; set => SetProperty(ref _measurementContext, value); } - public void Apply(SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observation) + public void Apply( + SvStreamSnapshot snapshot, + SvStreamObservationSnapshot? observation, + SvStreamMeasurementContext? measurementContext = null) { Key = snapshot.Key; AppId = $"0x{snapshot.AppId:X4}"; @@ -102,8 +107,13 @@ public void Apply(SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observ CursorSummary = snapshot.CursorSummary; Scaling = snapshot.ScalingSummary; Timebase = BuildTimebaseText(snapshot); + MeasurementContext = measurementContext?.Summary ?? "No explicit CT/VT context · displaying wire engineering domain"; - var qualityStates = ResolveQualityStates(snapshot.Values); + var displayValues = snapshot.Values + .Take(64) + .Select(value => ApplyMeasurementContext(value, measurementContext)) + .ToArray(); + var qualityStates = ResolveQualityStates(displayValues); QualitySummary = BuildQualitySummary(qualityStates, snapshot.QualitySummary); var comparison = observation?.ConfigurationComparison; @@ -147,36 +157,155 @@ public void Apply(SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observ var samples = ResolveObservationSamples(observation?.Facts); Window = $"{duration:0.0} s · {samples:N0} samples"; - _values.ReplaceAll(snapshot.Values.Take(64)); - UpdateStableVisuals(snapshot); - _evidenceDetails.ReplaceAll(BuildEvidenceDetails(snapshot, observation, qualityStates)); + _values.ReplaceAll(displayValues); + UpdateStableVisuals(snapshot, measurementContext); + _evidenceDetails.ReplaceAll(BuildEvidenceDetails(snapshot, observation, qualityStates, measurementContext)); } - private void UpdateStableVisuals(SvStreamSnapshot snapshot) + private void UpdateStableVisuals( + SvStreamSnapshot snapshot, + SvStreamMeasurementContext? measurementContext) { + var convertedWaveform = ConvertWaveform(snapshot.WaveformPoints, measurementContext); + var convertedPhasors = ConvertPhasors(snapshot.Phasors, measurementContext); + var domainSuffix = measurementContext is null + ? string.Empty + : $" · display {measurementContext.DisplayDomain}"; + if (snapshot.SamplesPerCycle is not > 0) { - _waveformPoints.ReplaceAll(snapshot.WaveformPoints); + _waveformPoints.ReplaceAll(convertedWaveform); _phasors.ReplaceAll(Array.Empty()); - WaveformState = $"{snapshot.ScalingSummary} · timebase unresolved; phasor withheld"; + WaveformState = $"{snapshot.ScalingSummary}{domainSuffix} · timebase unresolved; phasor withheld"; return; } var expectedPoints = Math.Clamp(snapshot.SamplesPerCycle.Value * 2, 32, 512); - var fullWindow = snapshot.IsWaveformWindowReady && snapshot.WaveformPoints.Count >= expectedPoints; + var fullWindow = snapshot.IsWaveformWindowReady && convertedWaveform.Count >= expectedPoints; if (fullWindow) { - _waveformPoints.ReplaceAll(snapshot.WaveformPoints.TakeLast(expectedPoints)); - _phasors.ReplaceAll(snapshot.Phasors); - WaveformState = $"2 cycles locked · {expectedPoints:N0} points · {snapshot.ScalingSummary}"; + _waveformPoints.ReplaceAll(convertedWaveform.TakeLast(expectedPoints)); + _phasors.ReplaceAll(convertedPhasors); + WaveformState = $"2 cycles locked · {expectedPoints:N0} points · {snapshot.ScalingSummary}{domainSuffix}"; return; } - _waveformPoints.ReplaceAll(snapshot.WaveformPoints); + _waveformPoints.ReplaceAll(convertedWaveform); _phasors.ReplaceAll(Array.Empty()); - WaveformState = $"Filling verified window · {snapshot.WaveformPoints.Count:N0}/{expectedPoints:N0} points"; + WaveformState = $"Filling verified window · {convertedWaveform.Count:N0}/{expectedPoints:N0} points{domainSuffix}"; + } + + private static DecodedValueRow ApplyMeasurementContext( + DecodedValueRow source, + SvStreamMeasurementContext? context) + { + if (context is null || source.IsQuality || !source.HasEngineeringValue || !source.EngineeringValue.HasValue) + return source; + + var ratio = context.ResolveRatio($"{source.Kind} {source.Signal}"); + var domainValue = SvMeasurementDomainResolver.Resolve( + source.EngineeringValue.Value, + source.EngineeringUnit, + context.WireDomain, + ratio); + + return new DecodedValueRow + { + Index = source.Index, + Signal = source.Signal, + Kind = source.Kind, + Value = source.Value, + Raw = source.Raw, + NumericValue = source.NumericValue, + EngineeringValue = source.EngineeringValue, + EngineeringUnit = source.EngineeringUnit, + ScalingSource = source.ScalingSource, + ScalingConfidence = source.ScalingConfidence, + ScalingReason = source.ScalingReason, + DomainValue = domainValue, + PreferredDisplayDomain = context.DisplayDomain + }; } + private static IReadOnlyList ConvertWaveform( + IReadOnlyList source, + SvStreamMeasurementContext? context) + { + if (context is null) + return source; + + var currentMultiplier = ResolveDisplayMultiplier(context, context.CurrentRatio); + var voltageMultiplier = ResolveDisplayMultiplier(context, context.VoltageRatio); + if (currentMultiplier == 1.0 && voltageMultiplier == 1.0) + return source; + + return source.Select(point => new WaveformPoint + { + Index = point.Index, + SampleCount = point.SampleCount, + CurrentUnit = point.CurrentUnit, + VoltageUnit = point.VoltageUnit, + ScalingSummary = $"{point.ScalingSummary} · {context.DisplayDomain}", + Ia = Scale(point.Ia, currentMultiplier, point.CurrentUnit), + Ib = Scale(point.Ib, currentMultiplier, point.CurrentUnit), + Ic = Scale(point.Ic, currentMultiplier, point.CurrentUnit), + In = Scale(point.In, currentMultiplier, point.CurrentUnit), + Va = Scale(point.Va, voltageMultiplier, point.VoltageUnit), + Vb = Scale(point.Vb, voltageMultiplier, point.VoltageUnit), + Vc = Scale(point.Vc, voltageMultiplier, point.VoltageUnit), + Vn = Scale(point.Vn, voltageMultiplier, point.VoltageUnit) + }).ToArray(); + } + + private static IReadOnlyList ConvertPhasors( + IReadOnlyList source, + SvStreamMeasurementContext? context) + { + if (context is null) + return source; + + return source.Select(vector => + { + var ratio = context.ResolveRatio($"{vector.Kind} {vector.Channel}"); + var multiplier = ResolveDisplayMultiplier(context, ratio); + if (multiplier == 1.0 || string.Equals(vector.Unit, "count", StringComparison.OrdinalIgnoreCase)) + return vector; + + return new PhasorVector + { + Channel = vector.Channel, + Kind = vector.Kind, + Unit = vector.Unit, + Rms = vector.Rms * multiplier, + Peak = vector.Peak * multiplier, + AngleDegrees = vector.AngleDegrees, + IsValid = vector.IsValid, + InvalidReason = vector.InvalidReason + }; + }).ToArray(); + } + + private static double ResolveDisplayMultiplier( + SvStreamMeasurementContext context, + SvMeasurementRatio? ratio) + { + if (context.WireDomain == context.DisplayDomain || ratio?.IsValid != true) + return 1.0; + return context.WireDomain switch + { + SvMeasurementValueDomain.PrimaryEngineering when context.DisplayDomain == SvMeasurementValueDomain.SecondaryEquivalent + => ratio.SecondaryNominal / ratio.PrimaryNominal, + SvMeasurementValueDomain.SecondaryEquivalent when context.DisplayDomain == SvMeasurementValueDomain.PrimaryEngineering + => ratio.PrimaryNominal / ratio.SecondaryNominal, + _ => 1.0 + }; + } + + private static double? Scale(double? value, double multiplier, string unit) + => value.HasValue && !string.Equals(unit, "count", StringComparison.OrdinalIgnoreCase) + ? value.Value * multiplier + : value; + private static string BuildSampleRateText(SvStreamSnapshot snapshot) { var declared = snapshot.SampleRate?.ToString() ?? "-"; @@ -275,16 +404,23 @@ private static string BuildQualitySummary( private static IReadOnlyList BuildEvidenceDetails( SvStreamSnapshot snapshot, SvStreamObservationSnapshot? observation, - IReadOnlyList qualityStates) + IReadOnlyList qualityStates, + SvStreamMeasurementContext? measurementContext) { var lines = new List { $"MEASUREMENT · scaling {snapshot.ScalingSummary} · {snapshot.ScalingReason}", + measurementContext is null + ? "MEASUREMENT CONTEXT · none · wire engineering values are shown without CT/VT conversion" + : $"MEASUREMENT CONTEXT · {measurementContext.Summary} · updated {measurementContext.UpdatedAt:O}", $"TIMEBASE · {BuildTimebaseText(snapshot)} · {snapshot.TimebaseReason}", $"HEALTH · current {snapshot.Health} · session totals gap {snapshot.SequenceGapCount}, duplicate {snapshot.DuplicateCount}, out-of-order {snapshot.OutOfOrderCount}, payload {snapshot.PayloadIssueCount}.", $"QUALITY · {BuildQualitySummary(qualityStates, snapshot.QualitySummary)}" }; + if (!string.IsNullOrWhiteSpace(measurementContext?.Notes)) + lines.Add($"MEASUREMENT CONTEXT NOTE · {measurementContext.Notes}"); + lines.AddRange(snapshot.Values .Where(value => value.IsQuality && value.QualityState is not null) .Select(value => $"QUALITY · {value.Signal} · {value.QualityState!.Severity} · {value.QualityState.Summary} · placement {value.QualityState.Placement} · raw {value.Raw}")); From ad6357b25c5f80171cd88cb8c5359d21970ffe49 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 15:39:09 +0700 Subject: [PATCH 05/13] Add CT VT measurement context editor UI --- .../MeasurementContextWindow.xaml | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/ARSVIN.Subscriber/MeasurementContextWindow.xaml diff --git a/src/ARSVIN.Subscriber/MeasurementContextWindow.xaml b/src/ARSVIN.Subscriber/MeasurementContextWindow.xaml new file mode 100644 index 0000000..4e7365c --- /dev/null +++ b/src/ARSVIN.Subscriber/MeasurementContextWindow.xaml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +