diff --git a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/ViewModels/MainWindowViewModel.cs b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/ViewModels/MainWindowViewModel.cs index c8f1117..adde085 100644 --- a/Footwear Test methodsfor wholeshoe Slipresistanceperformance/ViewModels/MainWindowViewModel.cs +++ b/Footwear Test methodsfor wholeshoe Slipresistanceperformance/ViewModels/MainWindowViewModel.cs @@ -29,6 +29,12 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel private const double SampleIntervalSeconds = 1.0 / 50.0; private const double DynamicWindowStartSeconds = 0.3; private const double DynamicWindowEndSeconds = 0.6; + private const double StaticPeakSearchEndSeconds = DynamicWindowEndSeconds; + private const double StaticPeakMinimumRiseN = 1.0; + private const double StaticPeakDropToleranceN = 0.5; + private const double SlidingStartDisplacementThresholdMm = 0.05; + private const double MinimumAnalysisLoadRatio = 0.8; + private const int StaticPeakDropConfirmationPointCount = 3; private const int MinimumDynamicWindowPointCount = 10; private const int StandardTrialCount = 3; private const string DefaultPlcPortName = "COM3"; @@ -58,6 +64,8 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel private int activeBaudRate; private List lastCompletedRun = []; private DateTime lastRealtimeCurveTraceLoggedAt = DateTime.MinValue; + private double runStartDisplacementMm; + private double? slidingStartTimeSeconds; [ObservableProperty] private string testNumber = $"SLIP-{DateTime.Now:yyyyMMdd-HHmm}"; @@ -672,11 +680,18 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel horizontalFrictionPoints.Clear(); frictionCoefficientPoints.Clear(); displacementPoints.Clear(); + runStartDisplacementMm = deviceService.CurrentSnapshot.DisplacementMm; + slidingStartTimeSeconds = null; runStopwatch.Restart(); UploadProgress = 0; lastRealtimeCurveTraceLoggedAt = DateTime.MinValue; CurrentStatus = "测试运行:按标准采集垂直载荷、摩擦力、位移与摩擦系数"; - Log.Information("测试开始:TestNumber={TestNumber}, TargetLoad={TargetLoad}, TestSpeed={TestSpeed}", TestNumber, TargetLoadText, TestSpeedText); + Log.Information( + "测试开始:TestNumber={TestNumber}, TargetLoad={TargetLoad}, TestSpeed={TestSpeed}, StartDisplacement={StartDisplacement:F3}mm", + TestNumber, + TargetLoadText, + TestSpeedText, + runStartDisplacementMm); } private void RecordPoint(SlipDeviceSnapshot device) @@ -695,6 +710,7 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel device.DisplacementMm, device.FrictionCoefficient); + TryMarkSlidingStart(point); currentRun.Add(point); verticalLoadPoints.Add(new ObservablePoint(time, point.VerticalLoadN)); horizontalFrictionPoints.Add(new ObservablePoint(time, point.HorizontalFrictionN)); @@ -717,22 +733,43 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel } lastCompletedRun = currentRun.ToList(); - var peak = FindStaticPeak(currentRun); + var minimumAnalysisLoad = GetMinimumAnalysisLoad(); + if (!TryFindSlidingStart(currentRun, runStartDisplacementMm, minimumAnalysisLoad, out var slidingStartPoint)) + { + Log.Warning( + "测试停止但未检测到有效滑动开始:TestNumber={TestNumber}, PointCount={PointCount}, StartDisplacement={StartDisplacement:F3}mm, DisplacementThreshold={DisplacementThreshold:F3}mm, MinimumAnalysisLoad={MinimumAnalysisLoad:F3}N, FirstSample={FirstSample}, LastSample={LastSample}", + TestNumber, + currentRun.Count, + runStartDisplacementMm, + SlidingStartDisplacementThresholdMm, + minimumAnalysisLoad, + FormatDataPoint(currentRun[0]), + FormatDataPoint(currentRun[^1])); + CurrentStatus = "测试已停止,但未检测到有效滑动开始,未生成结果"; + return; + } + + var slidingStartTime = slidingStartPoint.TimeSeconds; + var staticPeak = FindStaticPeak(currentRun, slidingStartTime); + var peak = staticPeak.Point; + var dynamicWindowStart = slidingStartTime + DynamicWindowStartSeconds; + var dynamicWindowEnd = slidingStartTime + DynamicWindowEndSeconds; var dynamicWindow = currentRun - .Where(point => point.TimeSeconds >= DynamicWindowStartSeconds && point.TimeSeconds <= DynamicWindowEndSeconds) + .Where(point => point.TimeSeconds >= dynamicWindowStart && point.TimeSeconds <= dynamicWindowEnd) .ToList(); if (dynamicWindow.Count < MinimumDynamicWindowPointCount) { var firstWindowPoint = dynamicWindow.FirstOrDefault(); var lastWindowPoint = dynamicWindow.LastOrDefault(); Log.Warning( - "测试停止但动摩擦窗口采样点不足:TestNumber={TestNumber}, PointCount={PointCount}, DynamicWindow=0.300-0.600s, DynamicWindowPointCount={DynamicWindowPointCount}, RequiredPointCount={RequiredPointCount}, ActualWindowStart={ActualWindowStart}, ActualWindowEnd={ActualWindowEnd}, FirstSampleTime={FirstSampleTime:F3}s, LastSampleTime={LastSampleTime:F3}s", + "测试停止但动摩擦窗口采样点不足:TestNumber={TestNumber}, PointCount={PointCount}, SlidingStartTime={SlidingStartTime:F3}s, DynamicWindow=滑动开始后0.300-0.600s, DynamicWindowPointCount={DynamicWindowPointCount}, RequiredPointCount={RequiredPointCount}, ActualWindowStart={ActualWindowStart}, ActualWindowEnd={ActualWindowEnd}, FirstSampleTime={FirstSampleTime:F3}s, LastSampleTime={LastSampleTime:F3}s", TestNumber, currentRun.Count, + slidingStartTime, dynamicWindow.Count, MinimumDynamicWindowPointCount, - FormatNullable(firstWindowPoint?.TimeSeconds), - FormatNullable(lastWindowPoint?.TimeSeconds), + FormatRelativeTime(firstWindowPoint, slidingStartTime), + FormatRelativeTime(lastWindowPoint, slidingStartTime), currentRun[0].TimeSeconds, currentRun[^1].TimeSeconds); CurrentStatus = "测试已停止,0.3 s~0.6 s 有效采样点不足,未生成结果"; @@ -742,20 +779,38 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel var staticCoefficientValue = CalculateCoefficient(peak.HorizontalFrictionN, peak.VerticalLoadN); var dynamicForce = dynamicWindow.Average(point => point.HorizontalFrictionN); var dynamicLoad = dynamicWindow.Average(point => point.VerticalLoadN); + if (peak.VerticalLoadN < minimumAnalysisLoad || dynamicLoad < minimumAnalysisLoad) + { + Log.Warning( + "测试停止但静/动摩擦窗口载荷不足:TestNumber={TestNumber}, SlidingStartTime={SlidingStartTime:F3}s, MinimumAnalysisLoad={MinimumAnalysisLoad:F3}N, StaticLoad={StaticLoad:F3}N, DynamicAvgLoad={DynamicAvgLoad:F3}N, StaticPoint={StaticPoint}, DynamicPointCount={DynamicPointCount}", + TestNumber, + slidingStartTime, + minimumAnalysisLoad, + peak.VerticalLoadN, + dynamicLoad, + FormatDataPoint(peak), + dynamicWindow.Count); + CurrentStatus = "测试已停止,静/动摩擦窗口载荷不足,未生成结果"; + return; + } + var dynamicCoefficientValue = CalculateCoefficient(dynamicForce, dynamicLoad); var verdict = NeedsRetest(staticCoefficientValue, dynamicCoefficientValue) ? "需重测" : "有效"; var nextIndex = Samples.Count == 0 ? 1 : Samples.Max(sample => sample.Index) + 1; var peakIndex = currentRun.IndexOf(peak) + 1; - var dynamicStart = dynamicWindow[0].TimeSeconds; - var dynamicEnd = dynamicWindow[^1].TimeSeconds; + var dynamicStart = dynamicWindow[0].TimeSeconds - slidingStartTime; + var dynamicEnd = dynamicWindow[^1].TimeSeconds - slidingStartTime; Log.Information( - "静/动摩擦计算明细:TestNumber={TestNumber}, PointCount={PointCount}, StaticSearchWindow=0.000-{StaticSearchEnd:F3}s, StaticPointIndex={StaticPointIndex}, StaticTime={StaticTime:F3}s, StaticFriction={StaticFriction:F3}N, StaticLoad={StaticLoad:F3}N, StaticCoefficient={StaticCoefficient:F5}, DynamicWindow={DynamicWindowStart:F3}-{DynamicWindowEnd:F3}s, DynamicActualWindow={DynamicActualStart:F3}-{DynamicActualEnd:F3}s, DynamicPointCount={DynamicPointCount}, DynamicAvgFriction={DynamicAvgFriction:F3}N, DynamicAvgLoad={DynamicAvgLoad:F3}N, DynamicCoefficient={DynamicCoefficient:F5}", + "静/动摩擦计算明细:TestNumber={TestNumber}, PointCount={PointCount}, SlidingStartTime={SlidingStartTime:F3}s, SlidingStartDisplacement={SlidingStartDisplacement:F3}mm, StaticSearchWindow=滑动开始后首个峰值0.000-{StaticSearchEnd:F3}s, StaticPeakMode={StaticPeakMode}, StaticPointIndex={StaticPointIndex}, StaticTime={StaticTime:F3}s, StaticFriction={StaticFriction:F3}N, StaticLoad={StaticLoad:F3}N, StaticCoefficient={StaticCoefficient:F5}, DynamicWindow=滑动开始后{DynamicWindowStart:F3}-{DynamicWindowEnd:F3}s, DynamicActualWindow={DynamicActualStart:F3}-{DynamicActualEnd:F3}s, DynamicPointCount={DynamicPointCount}, DynamicAvgFriction={DynamicAvgFriction:F3}N, DynamicAvgLoad={DynamicAvgLoad:F3}N, DynamicCoefficient={DynamicCoefficient:F5}", TestNumber, currentRun.Count, - DynamicWindowStartSeconds, + slidingStartTime, + slidingStartPoint.DisplacementMm, + StaticPeakSearchEndSeconds, + staticPeak.Mode, peakIndex, - peak.TimeSeconds, + peak.TimeSeconds - slidingStartTime, peak.HorizontalFrictionN, peak.VerticalLoadN, staticCoefficientValue, @@ -855,16 +910,125 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel ResultSummary = $"近 3 次平均 静 {staticAverage:F2} / 动 {dynamicAverage:F2}"; } - private static SlipDataPoint FindStaticPeak(IReadOnlyList points) + private void TryMarkSlidingStart(SlipDataPoint point) { - var preDynamicWindow = points - .Where(point => point.TimeSeconds <= DynamicWindowStartSeconds) - .ToList(); - return (preDynamicWindow.Count > 0 ? preDynamicWindow : points) - .MaxBy(point => point.HorizontalFrictionN) - ?? points[0]; + if (slidingStartTimeSeconds.HasValue) + { + return; + } + + var minimumAnalysisLoad = GetMinimumAnalysisLoad(); + if (!IsSlidingStartPoint(point, runStartDisplacementMm, minimumAnalysisLoad)) + { + return; + } + + slidingStartTimeSeconds = point.TimeSeconds; + Log.Information( + "检测到有效滑动开始:TestNumber={TestNumber}, SlidingStartTime={SlidingStartTime:F3}s, StartDisplacement={StartDisplacement:F3}mm, CurrentDisplacement={CurrentDisplacement:F3}mm, DisplacementDelta={DisplacementDelta:F3}mm, VerticalLoad={VerticalLoad:F3}N, MinimumAnalysisLoad={MinimumAnalysisLoad:F3}N", + TestNumber, + point.TimeSeconds, + runStartDisplacementMm, + point.DisplacementMm, + Math.Abs(point.DisplacementMm - runStartDisplacementMm), + point.VerticalLoadN, + minimumAnalysisLoad); } + private double GetMinimumAnalysisLoad() => + TryParseLoadValue(TargetLoadText, out var targetLoad) + ? Math.Max(1, targetLoad * MinimumAnalysisLoadRatio) + : 1; + + private static bool TryParseLoadValue(string value, out double load) + { + var numeric = new string(value + .Where(character => char.IsDigit(character) || character is '.' or '-' or '+') + .ToArray()); + return double.TryParse(numeric, NumberStyles.Float, CultureInfo.InvariantCulture, out load); + } + + private static bool TryFindSlidingStart( + IReadOnlyList points, + double startDisplacementMm, + double minimumAnalysisLoad, + out SlipDataPoint slidingStartPoint) + { + foreach (var point in points) + { + if (IsSlidingStartPoint(point, startDisplacementMm, minimumAnalysisLoad)) + { + slidingStartPoint = point; + return true; + } + } + + slidingStartPoint = points[0]; + return false; + } + + private static bool IsSlidingStartPoint(SlipDataPoint point, double startDisplacementMm, double minimumAnalysisLoad) => + point.VerticalLoadN >= minimumAnalysisLoad + && Math.Abs(point.DisplacementMm - startDisplacementMm) >= SlidingStartDisplacementThresholdMm; + + private static StaticPeakSelection FindStaticPeak(IReadOnlyList points, double slidingStartTime) + { + var searchWindow = points + .Where(point => point.TimeSeconds >= slidingStartTime + && point.TimeSeconds <= slidingStartTime + StaticPeakSearchEndSeconds) + .ToList(); + if (searchWindow.Count == 0) + { + return new StaticPeakSelection(points[0], "NoStaticWindowFallback"); + } + + var firstFriction = searchWindow[0].HorizontalFrictionN; + var peakIndex = 0; + for (var index = 1; index < searchWindow.Count; index++) + { + if (searchWindow[index].HorizontalFrictionN > searchWindow[peakIndex].HorizontalFrictionN) + { + peakIndex = index; + } + + var peak = searchWindow[peakIndex]; + var hasMeaningfulRise = peak.HorizontalFrictionN - firstFriction >= StaticPeakMinimumRiseN; + if (!hasMeaningfulRise) + { + continue; + } + + var confirmationEnd = Math.Min( + searchWindow.Count - 1, + index + StaticPeakDropConfirmationPointCount - 1); + if (confirmationEnd - index + 1 < StaticPeakDropConfirmationPointCount) + { + continue; + } + + var isConfirmedFalling = true; + for (var confirmationIndex = index; confirmationIndex <= confirmationEnd; confirmationIndex++) + { + if (searchWindow[confirmationIndex].HorizontalFrictionN > peak.HorizontalFrictionN - StaticPeakDropToleranceN) + { + isConfirmedFalling = false; + break; + } + } + + if (isConfirmedFalling) + { + return new StaticPeakSelection(peak, "FirstConfirmedPeak"); + } + } + + return new StaticPeakSelection( + searchWindow.MaxBy(point => point.HorizontalFrictionN) ?? searchWindow[0], + "MaxFallback"); + } + + private sealed record StaticPeakSelection(SlipDataPoint Point, string Mode); + private static double CalculateCoefficient(double frictionForce, double verticalLoad) => Math.Abs(verticalLoad) > 0.0001 ? frictionForce / verticalLoad : 0; @@ -942,15 +1106,16 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel lastRealtimeCurveTraceLoggedAt = now; Log.Debug( - "实时曲线点同步:TestNumber={TestNumber}, PointIndex={PointIndex}, Time={Time:F3}s, Vertical={Vertical:F3}N, Friction={Friction:F3}N, Coefficient={Coefficient:F5}, Displacement={Displacement:F3}mm, InDynamicWindow={InDynamicWindow}, ChartCount={ChartCount}", + "实时曲线点同步:TestNumber={TestNumber}, PointIndex={PointIndex}, Time={Time:F3}s, StandardTime={StandardTime}, Vertical={Vertical:F3}N, Friction={Friction:F3}N, Coefficient={Coefficient:F5}, Displacement={Displacement:F3}mm, InDynamicWindow={InDynamicWindow}, ChartCount={ChartCount}", TestNumber, expectedCount, point.TimeSeconds, + FormatRelativeTime(point, slidingStartTimeSeconds), point.VerticalLoadN, point.HorizontalFrictionN, point.FrictionCoefficient, point.DisplacementMm, - point.TimeSeconds >= DynamicWindowStartSeconds && point.TimeSeconds <= DynamicWindowEndSeconds, + IsInDynamicWindow(point, slidingStartTimeSeconds), expectedCount); } @@ -968,6 +1133,22 @@ namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModel private static string FormatNullable(double? value) => value.HasValue ? value.Value.ToString("F3", CultureInfo.InvariantCulture) : "null"; + private static string FormatRelativeTime(SlipDataPoint? point, double? startTime) => + point is null || !startTime.HasValue + ? "null" + : (point.TimeSeconds - startTime.Value).ToString("F3", CultureInfo.InvariantCulture); + + private static bool IsInDynamicWindow(SlipDataPoint point, double? startTime) + { + if (!startTime.HasValue) + { + return false; + } + + var standardTime = point.TimeSeconds - startTime.Value; + return standardTime >= DynamicWindowStartSeconds && standardTime <= DynamicWindowEndSeconds; + } + private static string FormatDataPoint(SlipDataPoint? point) => point is null ? "null"