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