diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs new file mode 100644 index 0000000..0e4d91f --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs @@ -0,0 +1,172 @@ +namespace AR.Iec61850.SampledValues.Measurements; + +/// +/// Describes how a decoded Sampled Values number is converted into an engineering value. +/// The source and confidence are deliberately explicit so unknown traffic is never labelled A or V by guesswork. +/// +public sealed record SvEngineeringScale +{ + public static SvEngineeringScale RawOnly(string reason) => new() + { + Multiplier = 1.0, + Unit = "count", + Source = SvEngineeringScaleSource.RawOnly, + Confidence = SvEngineeringScaleConfidence.Unknown, + Reason = reason + }; + + public double Multiplier { get; init; } = 1.0; + public double Offset { get; init; } + public string Unit { get; init; } = "count"; + public SvEngineeringScaleSource Source { get; init; } = SvEngineeringScaleSource.RawOnly; + public SvEngineeringScaleConfidence Confidence { get; init; } = SvEngineeringScaleConfidence.Unknown; + public string Reason { get; init; } = string.Empty; + public bool HasEngineeringUnit => Source != SvEngineeringScaleSource.RawOnly; + + public double Apply(double rawValue) => (rawValue * Multiplier) + Offset; +} + +public enum SvEngineeringScaleSource +{ + RawOnly, + Legacy92LeStyleStructuralInference, + SclBackedLegacy92LeStyle, + ManualOverride +} + +public enum SvEngineeringScaleConfidence +{ + Unknown, + Inferred, + SclBacked, + DeviceValidated +} + +/// +/// Evidence supplied to the scale resolver. This is intentionally vendor-neutral. +/// Product identity may be retained in an evidence report, but it is not a scaling rule. +/// +public sealed record SvEngineeringScaleEvidence +{ + public string Channel { get; init; } = string.Empty; + public string Kind { get; init; } = string.Empty; + public bool IsSclBound { get; init; } + public bool IsFixedFourCurrentFourVoltageLayout { get; init; } + public int AnalogChannelCount { get; init; } + public int PayloadBytesPerAsdu { get; init; } + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? ObservedSamplesPerSecond { get; init; } +} + +/// +/// Resolves conservative engineering scaling for installed-base 9-2LE-style protection streams. +/// The resolver requires structural evidence plus sampling or SCL evidence. Otherwise it returns raw counts. +/// +public static class SvEngineeringScaleResolver +{ + private const double CurrentAmperesPerCount = 0.001; + private const double VoltageVoltsPerCount = 0.01; + private const double RateToleranceFraction = 0.02; + + public static SvEngineeringScale Resolve(SvEngineeringScaleEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + + var domain = ResolveDomain(evidence.Channel, evidence.Kind); + if (domain == SvMeasurementDomain.Unknown) + return SvEngineeringScale.RawOnly("The channel could not be classified as current or voltage."); + + var fixedLayout = evidence.IsFixedFourCurrentFourVoltageLayout && + evidence.AnalogChannelCount == 8 && + evidence.PayloadBytesPerAsdu == 64; + if (!fixedLayout) + return SvEngineeringScale.RawOnly("The payload is not proven to be the fixed 4I+4U value-quality layout."); + + var samplingEvidence = HasProtectionRateEvidence(evidence); + if (!evidence.IsSclBound && !samplingEvidence) + return SvEngineeringScale.RawOnly("The fixed layout is visible, but sampling or SCL evidence is insufficient for engineering scaling."); + + var source = evidence.IsSclBound + ? SvEngineeringScaleSource.SclBackedLegacy92LeStyle + : SvEngineeringScaleSource.Legacy92LeStyleStructuralInference; + var confidence = evidence.IsSclBound + ? SvEngineeringScaleConfidence.SclBacked + : SvEngineeringScaleConfidence.Inferred; + var reason = evidence.IsSclBound + ? "SCL binding and fixed 4I+4U structural evidence support installed-base 9-2LE-style scaling." + : "Fixed 4I+4U structure and protection-rate evidence support provisional 9-2LE-style scaling."; + + return domain switch + { + SvMeasurementDomain.Current => new SvEngineeringScale + { + Multiplier = CurrentAmperesPerCount, + Unit = "A", + Source = source, + Confidence = confidence, + Reason = reason + }, + SvMeasurementDomain.Voltage => new SvEngineeringScale + { + Multiplier = VoltageVoltsPerCount, + Unit = "V", + Source = source, + Confidence = confidence, + Reason = reason + }, + _ => SvEngineeringScale.RawOnly("Unsupported measurement domain.") + }; + } + + public static SvMeasurementDomain ResolveDomain(string channel, string kind) + { + var normalizedKind = kind?.Trim() ?? string.Empty; + if (normalizedKind.Contains("voltage", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Voltage; + if (normalizedKind.Contains("current", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Current; + + var normalizedChannel = channel?.Trim() ?? string.Empty; + if (normalizedChannel.StartsWith("V", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("TVTR", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("VolSv", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Voltage; + if (normalizedChannel.StartsWith("I", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("TCTR", StringComparison.OrdinalIgnoreCase) || + normalizedChannel.Contains("AmpSv", StringComparison.OrdinalIgnoreCase)) + return SvMeasurementDomain.Current; + + return SvMeasurementDomain.Unknown; + } + + private static bool HasProtectionRateEvidence(SvEngineeringScaleEvidence evidence) + { + // smpMod=0 means samples per period. 80 samples/cycle is the installed-base protection variant. + if (evidence.DeclaredSampleMode == 0 && evidence.DeclaredSampleRate == 80) + return true; + + // smpMod=1 means samples per second. Accept the 50 Hz and 60 Hz protection rates. + if (evidence.DeclaredSampleMode == 1 && + IsNearOneOf(evidence.DeclaredSampleRate, 4_000, 4_800)) + return true; + + return IsNearOneOf(evidence.ObservedSamplesPerSecond, 4_000, 4_800); + } + + private static bool IsNearOneOf(double? value, params double[] candidates) + { + if (!value.HasValue || value.Value <= 0) + return false; + + return candidates.Any(candidate => + Math.Abs(value.Value - candidate) <= candidate * RateToleranceFraction); + } +} + +public enum SvMeasurementDomain +{ + Unknown, + Current, + Voltage +} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs new file mode 100644 index 0000000..0f84c7b --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvSampleCounterTracker.cs @@ -0,0 +1,156 @@ +namespace AR.Iec61850.SampledValues.Measurements; + +public enum SvSampleCounterTransitionKind +{ + Initial, + Continuous, + NormalWrap, + Gap, + Duplicate, + OutOfOrder, + Restart +} + +public sealed record SvSampleCounterTransition +{ + public SvSampleCounterTransitionKind Kind { get; init; } + public ushort Actual { get; init; } + public ushort? Previous { get; init; } + public ushort? Expected { get; init; } + public ushort? Wrap { get; init; } + public int MissingSamples { get; init; } + public string Detail { get; init; } = string.Empty; + public bool IsAnomaly => Kind is SvSampleCounterTransitionKind.Gap or + SvSampleCounterTransitionKind.Duplicate or + SvSampleCounterTransitionKind.OutOfOrder; +} + +/// +/// Stateful, profile-neutral smpCnt tracker. A known modulus is preferred; unknown traffic falls back to the ushort modulus. +/// Explicit restart hints prevent a publisher restart from being misclassified as out-of-order traffic. +/// +public sealed class SvSampleCounterTracker +{ + private ushort? _last; + private ushort? _expected; + + public ushort? Last => _last; + public ushort? Expected => _expected; + + public void Reset() + { + _last = null; + _expected = null; + } + + public SvSampleCounterTransition Observe(ushort actual, ushort? wrap, bool restartHint = false) + { + if (restartHint && _last.HasValue) + { + var previous = _last; + _last = actual; + _expected = Next(actual, wrap); + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Restart, + Actual = actual, + Previous = previous, + Expected = null, + Wrap = wrap, + Detail = "The counter state was reset by trusted restart/configuration evidence." + }; + } + + if (!_last.HasValue) + { + _last = actual; + _expected = Next(actual, wrap); + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Initial, + Actual = actual, + Wrap = wrap, + Detail = "Initial sample counter observation." + }; + } + + var previousValue = _last.Value; + if (actual == previousValue) + { + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Duplicate, + Actual = actual, + Previous = previousValue, + Expected = _expected, + Wrap = wrap, + Detail = $"Duplicate smpCnt {actual}." + }; + } + + var expectedValue = _expected ?? Next(previousValue, wrap); + if (actual == expectedValue) + { + var wrapped = actual < previousValue; + _last = actual; + _expected = Next(actual, wrap); + return new SvSampleCounterTransition + { + Kind = wrapped ? SvSampleCounterTransitionKind.NormalWrap : SvSampleCounterTransitionKind.Continuous, + Actual = actual, + Previous = previousValue, + Expected = expectedValue, + Wrap = wrap, + Detail = wrapped ? "Normal smpCnt wrap." : "Continuous smpCnt transition." + }; + } + + var modulus = ResolveModulus(wrap); + var forward = DistanceForward(expectedValue, actual, modulus); + var backward = DistanceForward(actual, expectedValue, modulus); + var isForwardGap = forward > 0 && forward < backward; + + _last = actual; + _expected = Next(actual, wrap); + + if (isForwardGap) + { + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.Gap, + Actual = actual, + Previous = previousValue, + Expected = expectedValue, + Wrap = wrap, + MissingSamples = forward, + Detail = $"smpCnt advanced from expected {expectedValue} to {actual}; {forward} sample(s) were not observed." + }; + } + + return new SvSampleCounterTransition + { + Kind = SvSampleCounterTransitionKind.OutOfOrder, + Actual = actual, + Previous = previousValue, + Expected = expectedValue, + Wrap = wrap, + Detail = $"smpCnt {actual} is behind expected {expectedValue}." + }; + } + + private static ushort Next(ushort value, ushort? wrap) + { + if (wrap is > 1) + return (ushort)((value + 1) % wrap.Value); + return unchecked((ushort)(value + 1)); + } + + private static int ResolveModulus(ushort? wrap) + => wrap is > 1 ? wrap.Value : ushort.MaxValue + 1; + + private static int DistanceForward(ushort from, ushort to, int modulus) + { + var distance = ((int)to - from) % modulus; + return distance < 0 ? distance + modulus : distance; + } +} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs new file mode 100644 index 0000000..824acf0 --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvTimebaseResolver.cs @@ -0,0 +1,181 @@ +namespace AR.Iec61850.SampledValues.Measurements; + +public sealed record SvTimebaseEvidence +{ + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? ObservedSamplesPerSecond { get; init; } + public bool IsFixedLegacyProtectionLayout { get; init; } + public double? TrustedNominalFrequencyHz { get; init; } +} + +public sealed record SvTimebaseResolution +{ + public double? NominalFrequencyHz { get; init; } + public int? SamplesPerCycle { get; init; } + public ushort? SampleCounterWrap { get; init; } + public SvTimebaseSource Source { get; init; } = SvTimebaseSource.Unknown; + public string Reason { get; init; } = string.Empty; + public bool IsResolved => NominalFrequencyHz.HasValue && SamplesPerCycle.HasValue; +} + +public enum SvTimebaseSource +{ + Unknown, + TrustedContext, + DeclaredSamplesPerPeriod, + DeclaredSamplesPerSecond, + ObservedLegacyProtectionRate +} + +/// +/// Resolves frequency, samples-per-cycle and sample-counter wrap without silently assuming 50 Hz. +/// +public static class SvTimebaseResolver +{ + private static readonly double[] NominalFrequencyCandidates = [50.0, 60.0]; + private const double FrequencyToleranceHz = 1.0; + private const double RateToleranceFraction = 0.02; + + public static SvTimebaseResolution Resolve(SvTimebaseEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + + if (evidence.TrustedNominalFrequencyHz is > 0 && + TryResolveSamplesPerCycle(evidence, evidence.TrustedNominalFrequencyHz.Value, out var trustedSamplesPerCycle, out var trustedWrap)) + { + return new SvTimebaseResolution + { + NominalFrequencyHz = evidence.TrustedNominalFrequencyHz, + SamplesPerCycle = trustedSamplesPerCycle, + SampleCounterWrap = trustedWrap, + Source = SvTimebaseSource.TrustedContext, + Reason = "Nominal frequency was supplied by trusted configuration context." + }; + } + + if (evidence.DeclaredSampleMode == 0 && evidence.DeclaredSampleRate is > 0) + { + var samplesPerCycle = evidence.DeclaredSampleRate.Value; + var estimatedFrequency = evidence.ObservedSamplesPerSecond.HasValue + ? SnapNominalFrequency(evidence.ObservedSamplesPerSecond.Value / samplesPerCycle) + : null; + var wrap = estimatedFrequency.HasValue + ? ToCounterWrap(samplesPerCycle * estimatedFrequency.Value) + : null; + + return new SvTimebaseResolution + { + NominalFrequencyHz = estimatedFrequency, + SamplesPerCycle = samplesPerCycle, + SampleCounterWrap = wrap, + Source = SvTimebaseSource.DeclaredSamplesPerPeriod, + Reason = estimatedFrequency.HasValue + ? "Samples-per-period was declared and nominal frequency was confirmed by the observed rate." + : "Samples-per-period was declared; nominal frequency remains unknown until rate evidence is available." + }; + } + + if (evidence.DeclaredSampleMode == 1 && evidence.DeclaredSampleRate is > 0) + { + var samplesPerSecond = evidence.DeclaredSampleRate.Value; + var legacy = ResolveLegacyProtectionRate(samplesPerSecond, evidence.IsFixedLegacyProtectionLayout); + if (legacy is not null) + return legacy with { Source = SvTimebaseSource.DeclaredSamplesPerSecond }; + + return new SvTimebaseResolution + { + SampleCounterWrap = ToCounterWrap(samplesPerSecond), + Source = SvTimebaseSource.DeclaredSamplesPerSecond, + Reason = "Samples-per-second was declared, but samples-per-cycle cannot be inferred safely for an unknown profile." + }; + } + + if (evidence.ObservedSamplesPerSecond is > 0) + { + var legacy = ResolveLegacyProtectionRate(evidence.ObservedSamplesPerSecond.Value, evidence.IsFixedLegacyProtectionLayout); + if (legacy is not null) + return legacy with { Source = SvTimebaseSource.ObservedLegacyProtectionRate }; + } + + return new SvTimebaseResolution + { + Source = SvTimebaseSource.Unknown, + Reason = "No trustworthy timebase could be resolved without making a hidden 50/60 Hz assumption." + }; + } + + private static SvTimebaseResolution? ResolveLegacyProtectionRate(double samplesPerSecond, bool fixedLegacyLayout) + { + if (!fixedLegacyLayout) + return null; + + foreach (var nominal in NominalFrequencyCandidates) + { + const int samplesPerCycle = 80; + var expected = nominal * samplesPerCycle; + if (Math.Abs(samplesPerSecond - expected) > expected * RateToleranceFraction) + continue; + + return new SvTimebaseResolution + { + NominalFrequencyHz = nominal, + SamplesPerCycle = samplesPerCycle, + SampleCounterWrap = ToCounterWrap(expected), + Reason = $"The fixed protection layout and observed/declared rate match {samplesPerCycle} samples/cycle at {nominal:0} Hz." + }; + } + + return null; + } + + private static bool TryResolveSamplesPerCycle( + SvTimebaseEvidence evidence, + double nominalFrequencyHz, + out int samplesPerCycle, + out ushort? wrap) + { + samplesPerCycle = 0; + wrap = null; + + if (evidence.DeclaredSampleMode == 0 && evidence.DeclaredSampleRate is > 0) + { + samplesPerCycle = evidence.DeclaredSampleRate.Value; + wrap = ToCounterWrap(samplesPerCycle * nominalFrequencyHz); + return true; + } + + var samplesPerSecond = evidence.DeclaredSampleMode == 1 && evidence.DeclaredSampleRate is > 0 + ? evidence.DeclaredSampleRate.Value + : evidence.ObservedSamplesPerSecond; + if (!samplesPerSecond.HasValue || samplesPerSecond.Value <= 0) + return false; + + var calculated = samplesPerSecond.Value / nominalFrequencyHz; + var rounded = (int)Math.Round(calculated); + if (rounded <= 0 || Math.Abs(calculated - rounded) > 0.05) + return false; + + samplesPerCycle = rounded; + wrap = ToCounterWrap(samplesPerSecond.Value); + return true; + } + + private static double? SnapNominalFrequency(double measured) + { + foreach (var nominal in NominalFrequencyCandidates) + { + if (Math.Abs(measured - nominal) <= FrequencyToleranceHz) + return nominal; + } + + return null; + } + + private static ushort? ToCounterWrap(double samplesPerSecond) + { + if (samplesPerSecond <= 1 || samplesPerSecond > ushort.MaxValue) + return null; + return (ushort)Math.Round(samplesPerSecond); + } +} diff --git a/src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs b/src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs index 9885714..61f48ec 100644 --- a/src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs +++ b/src/ARSVIN.Subscriber/Controls/OscilloscopePlot.cs @@ -11,6 +11,10 @@ namespace ARSVIN.Subscriber.Controls; public sealed class OscilloscopePlot : FrameworkElement { private double _cursorFraction = 0.72; + private double _voltageScale = 1.0; + private double _currentScale = 1.0; + private string _voltageUnit = "count"; + private string _currentUnit = "count"; private bool _dragging; private INotifyCollectionChanged? _pointsNotifier; @@ -72,49 +76,68 @@ protected override void OnRender(DrawingContext dc) return; } + var latest = points[^1]; + ResetScaleWhenUnitChanges(latest.VoltageUnit, latest.CurrentUnit); + const double pad = 10; const double gap = 12; var laneHeight = Math.Max(130, (bounds.Height - (pad * 2) - gap) / 2.0); var voltageRect = new Rect(pad, pad, bounds.Width - pad * 2, laneHeight); var currentRect = new Rect(pad, pad + laneHeight + gap, bounds.Width - pad * 2, laneHeight); - DrawLane(dc, voltageRect, "Voltage", "V", points, + DrawLane(dc, voltageRect, "Voltage", latest.VoltageUnit, points, new[] { new Trace("Va", p => p.Va, "#EF4444"), new Trace("Vb", p => p.Vb, "#D97706"), new Trace("Vc", p => p.Vc, "#2563EB"), new Trace("Vn", p => p.Vn, "#94A3B8") - }); + }, ref _voltageScale); - DrawLane(dc, currentRect, "Current", "A", points, + DrawLane(dc, currentRect, "Current", latest.CurrentUnit, points, new[] { new Trace("Ia", p => p.Ia, "#EF4444"), new Trace("Ib", p => p.Ib, "#D97706"), new Trace("Ic", p => p.Ic, "#2563EB"), new Trace("In", p => p.In, "#94A3B8") - }); + }, ref _currentScale); } - private void DrawLane(DrawingContext dc, Rect lane, string title, string unit, IReadOnlyList points, IReadOnlyList traces) + private void DrawLane( + DrawingContext dc, + Rect lane, + string title, + string unit, + IReadOnlyList points, + IReadOnlyList traces, + ref double retainedScale) { dc.DrawRoundedRectangle(Brush("#FFFFFF"), Pen("#D8E2EF", 1), lane, 8, 8); DrawGrid(dc, lane); - var plot = new Rect(lane.Left + 54, lane.Top + 18, Math.Max(60, lane.Width - 70), Math.Max(40, lane.Height - 36)); - var values = traces.SelectMany(trace => points.Select(trace.Selector)).Where(x => x.HasValue).Select(x => Math.Abs(x!.Value)).ToArray(); - var max = values.DefaultIfEmpty(0).Max(); - if (max <= 0) - max = 1; - max *= 1.15; - + var plot = new Rect(lane.Left + 58, lane.Top + 18, Math.Max(60, lane.Width - 74), Math.Max(40, lane.Height - 36)); + var absoluteValues = traces + .SelectMany(trace => points.Select(trace.Selector)) + .Where(value => value.HasValue) + .Select(value => Math.Abs(value!.Value)) + .ToArray(); + var observedMax = absoluteValues.DefaultIfEmpty(0).Max(); + var floor = ResolveScaleFloor(title, unit); + var targetScale = Math.Max(floor, observedMax * 1.15); + retainedScale = targetScale >= retainedScale + ? targetScale + : Math.Max(targetScale, retainedScale * 0.92); + + var nearZero = observedMax <= floor * 0.2; DrawText(dc, title, new Point(lane.Left + 12, lane.Top + 10), 12.5, "#2563EB", FontWeights.SemiBold); DrawText(dc, unit, new Point(lane.Left + 12, lane.Bottom - 28), 11, "#64748B", FontWeights.Normal); - DrawText(dc, $"±{max:0.#}", new Point(lane.Left + 12, lane.Top + 31), 10.5, "#94A3B8", FontWeights.Normal); + DrawText(dc, $"±{retainedScale:0.###}", new Point(lane.Left + 12, lane.Top + 31), 10.5, "#94A3B8", FontWeights.Normal); + if (nearZero) + DrawText(dc, "near zero", new Point(lane.Left + 12, lane.Top + 48), 9.8, "#94A3B8", FontWeights.Normal); foreach (var trace in traces) - DrawTrace(dc, plot, points, trace, max); + DrawTrace(dc, plot, points, trace, retainedScale); var cursorX = plot.Left + plot.Width * _cursorFraction; var cursorPen = Pen("#0EA5E9", 1.2, 4, 5); @@ -130,9 +153,33 @@ private void DrawLane(DrawingContext dc, Rect lane, string title, string unit, I } } + private void ResetScaleWhenUnitChanges(string voltageUnit, string currentUnit) + { + if (!string.Equals(_voltageUnit, voltageUnit, StringComparison.Ordinal)) + { + _voltageUnit = voltageUnit; + _voltageScale = ResolveScaleFloor("Voltage", voltageUnit); + } + + if (!string.Equals(_currentUnit, currentUnit, StringComparison.Ordinal)) + { + _currentUnit = currentUnit; + _currentScale = ResolveScaleFloor("Current", currentUnit); + } + } + + private static double ResolveScaleFloor(string title, string unit) + { + if (unit.Equals("V", StringComparison.OrdinalIgnoreCase)) + return 0.5; + if (unit.Equals("A", StringComparison.OrdinalIgnoreCase)) + return 0.02; + return title.Equals("Voltage", StringComparison.OrdinalIgnoreCase) ? 100.0 : 10.0; + } + private static void DrawGrid(DrawingContext dc, Rect lane) { - var plot = new Rect(lane.Left + 54, lane.Top + 18, Math.Max(60, lane.Width - 70), Math.Max(40, lane.Height - 36)); + var plot = new Rect(lane.Left + 58, lane.Top + 18, Math.Max(60, lane.Width - 74), Math.Max(40, lane.Height - 36)); var minor = Pen("#E8EEF7", 0.85, 2, 6); var major = Pen("#D6E0EC", 0.95, 4, 6); for (var i = 0; i <= 8; i++) @@ -148,14 +195,22 @@ private static void DrawGrid(DrawingContext dc, Rect lane) } } - private static void DrawTrace(DrawingContext dc, Rect plot, IReadOnlyList points, Trace trace, double scale) + private static void DrawTrace( + DrawingContext dc, + Rect plot, + IReadOnlyList points, + Trace trace, + double scale) { - var usable = points.Select((point, index) => (value: trace.Selector(point), index)).Where(x => x.value.HasValue).ToArray(); - if (usable.Length < 2) + var usable = points + .Select((point, index) => (value: trace.Selector(point), index)) + .Where(item => item.value.HasValue) + .ToArray(); + if (usable.Length < 2 || scale <= 0) return; var geometry = new StreamGeometry(); - using (var ctx = geometry.Open()) + using (var context = geometry.Open()) { for (var i = 0; i < usable.Length; i++) { @@ -164,11 +219,12 @@ private static void DrawTrace(DrawingContext dc, Rect plot, IReadOnlyList + + + + - + @@ -24,20 +45,21 @@ - - + + - + + + - - - + - - + - +