Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
68 changes: 68 additions & 0 deletions docs/sv-measurement-context.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace AR.Iec61850.SampledValues.Measurements;

/// <summary>
/// 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.
/// </summary>
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<string> Validate()
{
var errors = new List<string>();
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<string> 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<SvStreamMeasurementContext> Streams { get; init; }
= Array.Empty<SvStreamMeasurementContext>();

public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
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)
Comment on lines +100 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject null stream arrays during JSON import

A syntactically valid import such as {"streams":null} deserializes with Streams set to null, then this dereference throws NullReferenceException. FromJson only converts JsonException to InvalidDataException, and the UI import handler catches only IO, authorization, and invalid-data exceptions, so importing this malformed-but-valid JSON can escape the handler rather than show the intended validation error.

Useful? React with 👍 / 👎.

.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<SvMeasurementContextDocument>(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));
}
}
Loading
Loading