using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Platform.Storage; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Models; using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Services; using LiveChartsCore; using LiveChartsCore.Defaults; using LiveChartsCore.SkiaSharpView; using LiveChartsCore.SkiaSharpView.Painting; using Serilog; using SkiaSharp; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModels { public partial class MainWindowViewModel : ViewModelBase, IDisposable { 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 double PreTriggerWindowSeconds = 0.5; // 滑动检测:载荷达标后只接受明显陡升,避免低幅慢漂移提前把曲线 0 点定错。 private const double SlidingFrictionTriggerRiseN = 35.0; private const double SlidingFrictionTriggerLookbackSeconds = 0.12; private const double SlidingFrictionTriggerSlopeN = 25.0; private const double SlidingDisplacementFrictionRiseN = 20.0; // 回溯滑动起点:在触发前短窗口内找摩擦最低点,贴近标准曲线中首峰前的真实滑动起点。 private const double SlidingOnsetSearchBackSeconds = 0.12; // 曲线/分析窗口:滑动开始后只保留该时长,剔除加载段与滑动后回程,对应标准曲线图的有效区间。 private const double CurveEndSeconds = 1.0; private const int StaticPeakDropConfirmationPointCount = 3; private const int MinimumDynamicWindowPointCount = 10; private const int StandardTrialCount = 3; private const string DefaultPlcPortName = "COM3"; private const string DefaultAdcPortName = "COM4"; private const string LegacyPlcPortName = "COM7"; private const string LegacyAdcPortName = "COM8"; private static readonly TimeSpan ResetButtonPendingTimeout = TimeSpan.FromMilliseconds(800); private static readonly TimeSpan TestButtonPendingTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan RealtimeCurveTraceInterval = TimeSpan.FromSeconds(1); private static readonly TimeSpan LicenseCheckInterval = TimeSpan.FromMinutes(1); private readonly SlipResistanceDeviceService deviceService = new(); private readonly SlipExcelExportService excelExportService = new(); private readonly MachineLicenseService licenseService; private readonly DispatcherTimer refreshTimer; private readonly Stopwatch runStopwatch = new(); private readonly List currentRun = []; private readonly List preTriggerBuffer = []; private readonly ObservableCollection verticalLoadPoints = []; private readonly ObservableCollection horizontalFrictionPoints = []; private readonly ObservableCollection frictionCoefficientPoints = []; private readonly ObservableCollection displacementPoints = []; private bool isLoadingDeviceSettings; private bool wasRunning; private bool isTestStartPending; private DateTime testStartPendingStartedAt = DateTime.MinValue; private bool isResetButtonPending; private bool hasObservedResetDeviceBusy; private DateTime resetButtonPendingStartedAt = DateTime.MinValue; private string activePlcPortName = string.Empty; private string activeAdcPortName = string.Empty; private int activeBaudRate; private List lastCompletedRun = []; private DateTime lastRealtimeCurveTraceLoggedAt = DateTime.MinValue; private double runStartDisplacementMm; private double slidingStartDisplacementMm; private double? slidingStartTimeSeconds; private double slidingBaselineFrictionN; private bool hasSlidingBaseline; private int activeRunLubricantIndex; private DateTime lastLicenseCheckAt = DateTime.MinValue; private bool isLicenseLockPending; private bool licenseLockRequestedRaised; private string licenseLockMessage = string.Empty; public event Action? LicenseLockRequested; [ObservableProperty] private string testNumber = $"SLIP-{DateTime.Now:yyyyMMdd-HHmm}"; [ObservableProperty] private string operatorName = string.Empty; [ObservableProperty] private string methodName = "GB/T 3903.6-2024"; [ObservableProperty] private string reportName = string.Empty; [ObservableProperty] private string sampleFeature = "整鞋样品 / 瓷砖接触面 / 水平测试"; [ObservableProperty] private string currentStatus = "设备连接中,等待 PLC 与 ADC 实时数据"; [ObservableProperty] private int uploadProgress; [ObservableProperty] private string manualDistance = "0"; [ObservableProperty] private int selectedSampleIndex; [ObservableProperty] private bool isSettingsDialogOpen; [ObservableProperty] private string manualSpeed = "0.00"; [ObservableProperty] private string manualDisplacement = "0.00"; [ObservableProperty] private string testSpeed = "0.30"; [ObservableProperty] private string normalPressureZero = "0"; [ObservableProperty] private string normalPressureCoefficient = "0.00"; [ObservableProperty] private string frictionZero1 = "0"; [ObservableProperty] private string frictionCoefficient1 = "0.00"; [ObservableProperty] private string frictionZero2 = "0"; [ObservableProperty] private string frictionCoefficient2 = "0.00"; [ObservableProperty] private string plcPortName = DefaultPlcPortName; [ObservableProperty] private string adcPortName = DefaultAdcPortName; [ObservableProperty] private int baudRate = 115200; [ObservableProperty] private int selectedShoeSizeIndex; [ObservableProperty] private int selectedLubricantIndex; [ObservableProperty] private int selectedModeIndex = 2; [ObservableProperty] private int selectedSurfaceIndex; [ObservableProperty] private string targetLoadText = "400 N"; [ObservableProperty] private string actualLoadText = "0.0 N"; [ObservableProperty] private string deviceStatus = "离线"; [ObservableProperty] private string activeMode = "水平测试模式"; [ObservableProperty] private string resultSummary = "等待 3 次有效试验"; [ObservableProperty] private string testButtonText = "测试"; [ObservableProperty] private bool isTestButtonEnabled = true; [ObservableProperty] private string resetButtonText = "复位"; [ObservableProperty] private int completedTestCount; [ObservableProperty] private int dryTestCount; [ObservableProperty] private int wetTestCount; [ObservableProperty] private int frostTestCount; [ObservableProperty] private string staticCoefficient = "0.00"; [ObservableProperty] private string dynamicCoefficient = "0.00"; [ObservableProperty] private string verticalPressure = "0.0"; [ObservableProperty] private string horizontalForce = "0.0"; [ObservableProperty] private string frictionCoefficient = "0.000"; [ObservableProperty] private string distance = "0.0"; public string TestSpeedText => $"{TestSpeed} m/s"; public string SampleRateText { get; } = "50 Hz"; public string BatchNumber { get; } = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); public string UploadProgressText => $"{UploadProgress}%"; public string StandardReference { get; } = "GB/T 3903.6-2024:实时采集、静/动摩擦系数、三次平均与重测判定"; public ObservableCollection Samples { get; } = []; public DrawMarginFrame ChartFrame { get; } = new() { Fill = new SolidColorPaint(SKColor.Parse("#FFFFFF")), Stroke = new SolidColorPaint(SKColor.Parse("#CBD5E1")) { StrokeThickness = 1.4f } }; public SolidColorPaint LegendTextPaint { get; } = new(SKColor.Parse("#334155")); public ISeries[] Series { get; } public Axis[] XAxes { get; } = [ new Axis { Name = "时间(s)", MinLimit = 0, MinStep = 0.05, SeparatorsPaint = new SolidColorPaint(SKColor.Parse("#D7E0EA")) { StrokeThickness = 1 }, SubseparatorsPaint = new SolidColorPaint(SKColor.Parse("#EEF3F8")) { StrokeThickness = 1 }, SubseparatorsCount = 4, LabelsPaint = new SolidColorPaint(SKColor.Parse("#475569")), NamePaint = new SolidColorPaint(SKColor.Parse("#334155")), TextSize = 12, NameTextSize = 13, Padding = new LiveChartsCore.Drawing.Padding(4, 3, 4, 3) } ]; public Axis[] YAxes { get; } = [ new Axis { Name = "压力(N)/距离(mm)", MinLimit = 0, MaxLimit = 800, MinStep = 50, ForceStepToMin = true, SeparatorsPaint = new SolidColorPaint(SKColor.Parse("#D7E0EA")) { StrokeThickness = 1 }, SubseparatorsPaint = new SolidColorPaint(SKColor.Parse("#EEF3F8")) { StrokeThickness = 1 }, SubseparatorsCount = 4, LabelsPaint = new SolidColorPaint(SKColor.Parse("#475569")), NamePaint = new SolidColorPaint(SKColor.Parse("#334155")), TextSize = 11, NameTextSize = 12, Padding = new LiveChartsCore.Drawing.Padding(2, 2, 3, 2) }, new Axis { Name = "摩擦系数", Position = LiveChartsCore.Measure.AxisPosition.End, MinLimit = 0, MaxLimit = 1.5, MinStep = 0.1, ForceStepToMin = true, LabelsPaint = new SolidColorPaint(SKColor.Parse("#7E22CE")), NamePaint = new SolidColorPaint(SKColor.Parse("#7E22CE")), SeparatorsPaint = null, TextSize = 11, NameTextSize = 12, Padding = new LiveChartsCore.Drawing.Padding(3, 2, 2, 2) } ]; public MainWindowViewModel() : this(new MachineLicenseService()) { } public MainWindowViewModel(MachineLicenseService licenseService) { this.licenseService = licenseService; Log.Information("初始化主页面 ViewModel"); Series = [ CreateLineSeries("垂直压力(N)", verticalLoadPoints, "#DC2626", 0), CreateLineSeries("水平拉力(N)", horizontalFrictionPoints, "#16A34A", 0), CreateLineSeries("摩擦系数", frictionCoefficientPoints, "#C026D3", 1), CreateLineSeries("距离(mm)", displacementPoints, "#2563EB", 0) ]; LoadDeviceSettings(); UpdateTargetLoad(); RestartDeviceConnection(); _ = LoadPlcParametersAsync(); refreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(20) }; refreshTimer.Tick += (_, _) => RefreshFromDevice(); refreshTimer.Start(); RefreshLicenseState(); } [RelayCommand] private async Task Clear() { ApplyConnectionSettings(); BeginResetButtonFeedback(); try { await deviceService.PulseResetAsync(); CurrentStatus = "已按老代码逻辑发送复位指令 M90"; } catch (Exception ex) { Log.Error(ex, "设备指令失败:已按老代码逻辑发送复位指令 M90"); CurrentStatus = $"设备指令失败:{ex.Message}"; ClearResetButtonFeedback(); UpdateResetButtonText(deviceService.CurrentSnapshot); } } [RelayCommand] private void Preload() { CurrentStatus = "请在 0.2 s 内施加目标垂直载荷,载荷达到后启动滑动测试"; } [RelayCommand] private async Task StartTest() { if (!IsTestButtonEnabled || isLicenseLockPending) { return; } ApplyConnectionSettings(); var device = deviceService.CurrentSnapshot; if (device.IsTestRunning) { return; } if (!ValidateAdcCoefficientsBeforeTest()) { return; } BeginTestButtonFeedback(); try { await deviceService.PulseStartTestAsync(); CurrentStatus = "已按老代码逻辑发送测试启动指令 M80,等待 M81 运行状态"; } catch (Exception ex) { Log.Error(ex, "设备指令失败:已按老代码逻辑发送测试启动指令 M80"); CurrentStatus = $"设备指令失败:{ex.Message}"; ClearTestButtonFeedback(); UpdateTestButtonState(deviceService.CurrentSnapshot); } } [RelayCommand] private async Task StopTest() { await RunDeviceCommand(deviceService.PulseStopTestAsync(), "已发送测试停止指令 M83"); } [RelayCommand] private async Task ExportReport() { var points = currentRun.Count > 0 ? currentRun.ToList() : lastCompletedRun; if (points.Count == 0) { Log.Warning("请求导出 Excel 时没有可导出的实时采样数据:TestNumber={TestNumber}", TestNumber); CurrentStatus = "没有可导出的实时采样数据,请先完成一次测试"; return; } try { CurrentStatus = "请选择 Excel 报告保存目录"; var exportDirectory = await SelectExportDirectoryAsync(); if (exportDirectory is null) { Log.Information("用户取消 Excel 导出目录选择:TestNumber={TestNumber}, PointCount={PointCount}", TestNumber, points.Count); CurrentStatus = "已取消 Excel 导出"; return; } var actualLoadForReport = FormatActualLoad(points); var report = new SlipReportExport( TestNumber, OperatorName, MethodName, ReportName, SampleFeature, CurrentShoeSize(), CurrentLubricant(), CurrentMode(), CurrentSurface(), TargetLoadText, actualLoadForReport, TestSpeedText, StandardReference, Samples.ToList(), points); var path = excelExportService.Export(report, exportDirectory); UploadProgress = 100; CurrentStatus = $"Excel 已导出:{path}"; } catch (Exception ex) { Log.Error(ex, "Excel 导出失败:TestNumber={TestNumber}, PointCount={PointCount}", TestNumber, points.Count); CurrentStatus = $"Excel 导出失败:{ex.Message}"; } } private async Task SelectExportDirectoryAsync() { var storageProvider = GetStorageProvider(); if (storageProvider is null || !storageProvider.CanPickFolder) { var defaultDirectory = SlipExcelExportService.GetDefaultExportDirectory(); Log.Warning("当前平台无法打开 Excel 导出目录选择器,将使用默认目录:Path={Path}", defaultDirectory); return defaultDirectory; } var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "选择报告保存目录", AllowMultiple = false, SuggestedStartLocation = await TryGetSuggestedExportFolderAsync(storageProvider) }); var selectedFolder = folders.FirstOrDefault(); if (selectedFolder is null) { return null; } var localPath = selectedFolder.TryGetLocalPath(); if (string.IsNullOrWhiteSpace(localPath) && selectedFolder.Path.IsFile) { localPath = selectedFolder.Path.LocalPath; } if (string.IsNullOrWhiteSpace(localPath)) { throw new InvalidOperationException("选择的目录不是本地文件夹,无法保存 Excel"); } return localPath; } private static async Task TryGetSuggestedExportFolderAsync(IStorageProvider storageProvider) { try { var defaultDirectory = SlipExcelExportService.GetDefaultExportDirectory(); if (Directory.Exists(defaultDirectory)) { return await storageProvider.TryGetFolderFromPathAsync(defaultDirectory); } var parentDirectory = Path.GetDirectoryName(defaultDirectory); if (!string.IsNullOrWhiteSpace(parentDirectory) && Directory.Exists(parentDirectory)) { return await storageProvider.TryGetFolderFromPathAsync(parentDirectory); } return null; } catch (Exception ex) { Log.Warning(ex, "准备 Excel 默认导出目录失败,将不设置目录选择初始位置"); return null; } } private static IStorageProvider? GetStorageProvider() { if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow is not null) { return desktop.MainWindow.StorageProvider; } return null; } [RelayCommand] private Task LiftMotion() => RunLicensedDeviceCommand(deviceService.ApplyOldLiftAsync, "提升按老代码逻辑执行:M4=0, M5=1"); [RelayCommand] private Task LowerMotion() => RunLicensedDeviceCommand(deviceService.ApplyOldLowerAsync, "下降按老代码逻辑执行:M5=0, M4=1"); [RelayCommand] private Task MoveLeftMotion() => RunLicensedDeviceCommand(deviceService.ToggleOldMoveLeftAsync, "左移按老代码逻辑切换 M1"); [RelayCommand] private Task MoveRightMotion() => RunLicensedDeviceCommand(deviceService.ToggleOldMoveRightAsync, "右移按老代码逻辑切换 M2"); public Task StopAllMotionAsync() => RunDeviceCommand(deviceService.StopAllMotionAsync(), "全部运动已停止"); [RelayCommand] private void DeleteSelectedSample() { var sample = Samples.FirstOrDefault(x => x.Index == SelectedSampleIndex); if (sample is null) { CurrentStatus = $"未找到序号 {SelectedSampleIndex} 的实验数据"; return; } Samples.Remove(sample); UpdateResultSummary(); CurrentStatus = $"已删除序号 {SelectedSampleIndex} 的实验数据"; } [RelayCommand] private void ShowSettingsDialog() { IsSettingsDialogOpen = true; } [RelayCommand] private void CloseSettingsDialog() { IsSettingsDialogOpen = false; ApplyConnectionSettings(); } [RelayCommand] private void CalibrateNormalPressureZero() { try { var zero = deviceService.CaptureCurrentAdcZero(); NormalPressureZero = zero.NormalPressureZero.ToString(CultureInfo.InvariantCulture); SaveAndApplySettings(); CurrentStatus = "正压力零点已按当前 ADC 原始值采集"; } catch (Exception ex) { Log.Error(ex, "正压力零点采集失败"); CurrentStatus = $"正压力零点采集失败:{ex.Message}"; } } [RelayCommand] private void CalibrateFrictionZero() { try { var zero = deviceService.CaptureCurrentAdcZero(); FrictionZero1 = zero.FrictionZero1.ToString(CultureInfo.InvariantCulture); FrictionZero2 = zero.FrictionZero2.ToString(CultureInfo.InvariantCulture); SaveAndApplySettings(); CurrentStatus = "摩擦零点已按当前 ADC 原始值采集"; } catch (Exception ex) { Log.Error(ex, "摩擦零点采集失败"); CurrentStatus = $"摩擦零点采集失败:{ex.Message}"; } } partial void OnUploadProgressChanged(int value) => OnPropertyChanged(nameof(UploadProgressText)); partial void OnManualSpeedChanged(string value) { SaveAndApplySettings(); WriteNumericSetting(value, deviceService.WriteManualSpeedAsync, "手动速度"); } partial void OnManualDisplacementChanged(string value) { SaveAndApplySettings(); WriteNumericSetting(value, deviceService.WriteManualDisplacementAsync, "手动位移"); } partial void OnTestSpeedChanged(string value) { OnPropertyChanged(nameof(TestSpeedText)); SaveAndApplySettings(); WriteNumericSetting(value, deviceService.WriteTestSpeedAsync, "测试速度"); } partial void OnNormalPressureZeroChanged(string value) => SaveAndApplySettings(); partial void OnNormalPressureCoefficientChanged(string value) => SaveAndApplySettings(); partial void OnFrictionZero1Changed(string value) => SaveAndApplySettings(); partial void OnFrictionCoefficient1Changed(string value) => SaveAndApplySettings(); partial void OnFrictionZero2Changed(string value) => SaveAndApplySettings(); partial void OnFrictionCoefficient2Changed(string value) => SaveAndApplySettings(); partial void OnPlcPortNameChanged(string value) => SaveAndApplySettings(); partial void OnAdcPortNameChanged(string value) => SaveAndApplySettings(); partial void OnBaudRateChanged(int value) => SaveAndApplySettings(); partial void OnSelectedShoeSizeIndexChanged(int value) => UpdateTargetLoad(); partial void OnSelectedModeIndexChanged(int value) { ActiveMode = CurrentMode(); } public void Dispose() { refreshTimer.Stop(); try { deviceService.StopAllMotionAsync().Wait(500); } catch (Exception ex) { Log.Warning(ex, "关闭窗口时停止全部运动失败"); } deviceService.Dispose(); } public void RefreshLicenseState() { var check = licenseService.Check(updateHeartbeat: true); lastLicenseCheckAt = DateTime.UtcNow; isLicenseLockPending = !check.CanUseSoftware; licenseLockMessage = check.Message; if (!isLicenseLockPending) { licenseLockRequestedRaised = false; } UpdateTestButtonState(deviceService.CurrentSnapshot); } private void RefreshFromDevice() { var device = deviceService.CurrentSnapshot; CheckRuntimeLicense(device); if (device.IsConnected) { DeviceStatus = device.IsTestRunning ? "联机 / 测试中" : device.IsResetting ? "联机 / 复位中" : "联机 / 待机"; UpdateResetButtonText(device); VerticalPressure = device.VerticalLoadN.ToString("F1", CultureInfo.InvariantCulture); HorizontalForce = device.HorizontalFrictionN.ToString("F1", CultureInfo.InvariantCulture); FrictionCoefficient = device.FrictionCoefficient.ToString("F3", CultureInfo.InvariantCulture); Distance = device.DisplacementMm.ToString("F1", CultureInfo.InvariantCulture); ActualLoadText = $"{VerticalPressure} N"; } else { DeviceStatus = IsAdcCalibrationError(device.LastError) ? "数据无效" : "离线"; UpdateResetButtonText(device); VerticalPressure = "--"; HorizontalForce = "--"; FrictionCoefficient = "--"; Distance = "--"; ActualLoadText = "-- N"; } UpdateTestButtonState(device); if (!device.IsConnected && !string.IsNullOrWhiteSpace(device.LastError)) { CurrentStatus = IsAdcCalibrationError(device.LastError) ? $"数据无效:{device.LastError}" : $"设备离线:{device.LastError}"; } var isRecording = device.IsConnected && device.IsTestRunning; if (!wasRunning && isRecording) { BeginRun(); } if (isRecording) { RecordPoint(device); } if (wasRunning && !isRecording) { if (device.IsConnected) { CompleteRun(); } else { AbortRun("设备离线,本次采集已停止,未生成试验结果"); } } wasRunning = isRecording; } private void BeginTestButtonFeedback() { isTestStartPending = true; testStartPendingStartedAt = DateTime.UtcNow; TestButtonText = "测试中"; IsTestButtonEnabled = false; } private void ClearTestButtonFeedback() { isTestStartPending = false; testStartPendingStartedAt = DateTime.MinValue; } private void UpdateTestButtonState(SlipDeviceSnapshot device) { if (isLicenseLockPending) { ClearTestButtonFeedback(); TestButtonText = "授权到期"; IsTestButtonEnabled = false; return; } if (device.IsTestRunning) { ClearTestButtonFeedback(); TestButtonText = "测试中"; IsTestButtonEnabled = false; return; } if (isTestStartPending && DateTime.UtcNow - testStartPendingStartedAt < TestButtonPendingTimeout) { TestButtonText = "测试中"; IsTestButtonEnabled = false; return; } ClearTestButtonFeedback(); TestButtonText = "测试"; IsTestButtonEnabled = true; } private void CheckRuntimeLicense(SlipDeviceSnapshot device) { if (DateTime.UtcNow - lastLicenseCheckAt >= LicenseCheckInterval) { var check = licenseService.Check(updateHeartbeat: true); lastLicenseCheckAt = DateTime.UtcNow; isLicenseLockPending = !check.CanUseSoftware; licenseLockMessage = check.Message; } if (isLicenseLockPending && !device.IsTestRunning && !device.IsResetting && !licenseLockRequestedRaised) { licenseLockRequestedRaised = true; LicenseLockRequested?.Invoke(licenseLockMessage); } } private void BeginResetButtonFeedback() { isResetButtonPending = true; hasObservedResetDeviceBusy = false; resetButtonPendingStartedAt = DateTime.UtcNow; ResetButtonText = "复位中"; } private void ClearResetButtonFeedback() { isResetButtonPending = false; hasObservedResetDeviceBusy = false; resetButtonPendingStartedAt = DateTime.MinValue; } private void UpdateResetButtonText(SlipDeviceSnapshot device) { if (device.IsResetting) { hasObservedResetDeviceBusy = true; } else if (!device.IsConnected || hasObservedResetDeviceBusy || (isResetButtonPending && DateTime.UtcNow - resetButtonPendingStartedAt >= ResetButtonPendingTimeout)) { ClearResetButtonFeedback(); } ResetButtonText = device.IsResetting || isResetButtonPending ? "复位中" : "复位"; } private void BeginRun() { currentRun.Clear(); preTriggerBuffer.Clear(); verticalLoadPoints.Clear(); horizontalFrictionPoints.Clear(); frictionCoefficientPoints.Clear(); displacementPoints.Clear(); runStartDisplacementMm = deviceService.CurrentSnapshot.DisplacementMm; slidingStartDisplacementMm = runStartDisplacementMm; slidingStartTimeSeconds = null; slidingBaselineFrictionN = 0; hasSlidingBaseline = false; activeRunLubricantIndex = SelectedLubricantIndex; runStopwatch.Restart(); UploadProgress = 0; lastRealtimeCurveTraceLoggedAt = DateTime.MinValue; CurrentStatus = "测试运行:等待检测有效滑动开始,曲线尚未记录"; Log.Information( "测试开始:TestNumber={TestNumber}, TargetLoad={TargetLoad}, TestSpeed={TestSpeed}, StartDisplacement={StartDisplacement:F3}mm, CurveRecording=等待有效滑动开始", TestNumber, TargetLoadText, TestSpeedText, runStartDisplacementMm); } private void RecordPoint(SlipDeviceSnapshot device) { var elapsedSinceTestStart = runStopwatch.Elapsed.TotalSeconds; var samplePoint = new SlipDataPoint( device.Timestamp, elapsedSinceTestStart, device.VerticalLoadN, device.HorizontalFrictionN, device.DisplacementMm, device.FrictionCoefficient); // 滑动起点尚未确定:把样本缓存进滚动预触发缓冲,并尝试检测滑动起点。 if (!slidingStartTimeSeconds.HasValue) { UpdatePreTrigger(samplePoint); return; } var standardTime = elapsedSinceTestStart - slidingStartTimeSeconds.Value; // 只保留滑动开始后 CurveEndSeconds 区间,剔除滑动结束后的回程/抬升段。 if (standardTime > CurveEndSeconds) { return; } AppendCurvePoint(samplePoint, standardTime); } // 滑动检测前持续缓存样本,并在载荷达标后用水平摩擦力陡升判定滑动起点。 private void UpdatePreTrigger(SlipDataPoint samplePoint) { preTriggerBuffer.Add(samplePoint); var oldestAllowed = samplePoint.TimeSeconds - PreTriggerWindowSeconds; while (preTriggerBuffer.Count > 0 && preTriggerBuffer[0].TimeSeconds < oldestAllowed) { preTriggerBuffer.RemoveAt(0); } var minimumAnalysisLoad = GetMinimumAnalysisLoad(); if (samplePoint.VerticalLoadN < minimumAnalysisLoad) { // 载荷尚未到达规定压力(静置接触前),基线需在载荷达标后重新捕获。 hasSlidingBaseline = false; return; } if (!hasSlidingBaseline) { // 载荷首次达标:以当前(仅法向加载、尚未水平滑动)的摩擦力为静置接触基线。 slidingBaselineFrictionN = samplePoint.HorizontalFrictionN; hasSlidingBaseline = true; return; } var frictionRiseFromBaseline = samplePoint.HorizontalFrictionN - slidingBaselineFrictionN; var lookbackPoint = FindLookbackPoint(samplePoint.TimeSeconds - SlidingFrictionTriggerLookbackSeconds); var frictionRiseFromLookback = lookbackPoint is null ? 0 : samplePoint.HorizontalFrictionN - lookbackPoint.HorizontalFrictionN; var frictionRose = frictionRiseFromBaseline >= SlidingFrictionTriggerRiseN && frictionRiseFromLookback >= SlidingFrictionTriggerSlopeN; var displacementMoved = Math.Abs(samplePoint.DisplacementMm - runStartDisplacementMm) >= SlidingStartDisplacementThresholdMm; var displacementWithFriction = displacementMoved && frictionRiseFromBaseline >= SlidingDisplacementFrictionRiseN; if (frictionRose || displacementWithFriction) { MarkSlidingStartFromBuffer(frictionRose ? "FrictionSharpRise" : "DisplacementWithFrictionRise"); return; } if (displacementMoved) { Log.Debug( "滑动起点候选未触发:TestNumber={TestNumber}, Reason=DisplacementWithoutFrictionRise, DisplacementDelta={DisplacementDelta:F3}mm, FrictionRiseFromBaseline={FrictionRiseFromBaseline:F3}N, FrictionRiseFromLookback={FrictionRiseFromLookback:F3}N, BaselineFriction={BaselineFriction:F3}N, CurrentFriction={CurrentFriction:F3}N", TestNumber, Math.Abs(samplePoint.DisplacementMm - runStartDisplacementMm), frictionRiseFromBaseline, frictionRiseFromLookback, slidingBaselineFrictionN, samplePoint.HorizontalFrictionN); } } private SlipDataPoint? FindLookbackPoint(double targetTime) { for (var index = preTriggerBuffer.Count - 1; index >= 0; index--) { if (preTriggerBuffer[index].TimeSeconds <= targetTime) { return preTriggerBuffer[index]; } } return preTriggerBuffer.Count > 0 ? preTriggerBuffer[0] : null; } // 回溯预触发缓冲到摩擦力陡升前的低点设为 t=0,并把该时刻起的缓冲点补进曲线,保证第一个峰值被采到。 private void MarkSlidingStartFromBuffer(string trigger) { var triggerPoint = preTriggerBuffer[^1]; var earliestOnsetTime = triggerPoint.TimeSeconds - SlidingOnsetSearchBackSeconds; var onsetIndex = preTriggerBuffer.Count - 1; for (var index = preTriggerBuffer.Count - 1; index >= 0; index--) { if (preTriggerBuffer[index].TimeSeconds < earliestOnsetTime) { break; } if (preTriggerBuffer[index].HorizontalFrictionN <= preTriggerBuffer[onsetIndex].HorizontalFrictionN) { onsetIndex = index; } } var onset = preTriggerBuffer[onsetIndex]; slidingStartTimeSeconds = onset.TimeSeconds; slidingStartDisplacementMm = onset.DisplacementMm; for (var index = onsetIndex; index < preTriggerBuffer.Count; index++) { var buffered = preTriggerBuffer[index]; var standardTime = buffered.TimeSeconds - onset.TimeSeconds; if (standardTime > CurveEndSeconds) { break; } AppendCurvePoint(buffered, standardTime); } preTriggerBuffer.Clear(); CurrentStatus = "已检测到有效滑动开始:曲线从 0.000 s 开始记录"; Log.Information( "检测到有效滑动开始并建立曲线零点:TestNumber={TestNumber}, Trigger={Trigger}, SlidingStartTime={SlidingStartTime:F3}s, TriggerTime={TriggerTime:F3}s, BaselineFriction={BaselineFriction:F3}N, OnsetFriction={OnsetFriction:F3}N, TriggerFriction={TriggerFriction:F3}N, FrictionRise={FrictionRise:F3}N, OnsetDisplacement={OnsetDisplacement:F3}mm, TriggerDisplacement={TriggerDisplacement:F3}mm, FlushedPointCount={FlushedPointCount}", TestNumber, trigger, onset.TimeSeconds, triggerPoint.TimeSeconds, slidingBaselineFrictionN, onset.HorizontalFrictionN, triggerPoint.HorizontalFrictionN, triggerPoint.HorizontalFrictionN - onset.HorizontalFrictionN, slidingStartDisplacementMm, triggerPoint.DisplacementMm, currentRun.Count); } // 把一个样本按标准时间(相对滑动起点)追加到数据与曲线集合,保持各曲线序列同步(节流到 SampleIntervalSeconds)。 private void AppendCurvePoint(SlipDataPoint source, double standardTime) { standardTime = Math.Max(0, standardTime); if (currentRun.Count > 0 && standardTime - currentRun[^1].TimeSeconds < SampleIntervalSeconds) { return; } var point = new SlipDataPoint( source.Timestamp, standardTime, source.VerticalLoadN, source.HorizontalFrictionN, Math.Abs(source.DisplacementMm - slidingStartDisplacementMm), source.FrictionCoefficient); currentRun.Add(point); verticalLoadPoints.Add(new ObservablePoint(standardTime, point.VerticalLoadN)); horizontalFrictionPoints.Add(new ObservablePoint(standardTime, point.HorizontalFrictionN)); UpdateLinearDisplacementSeries(point); frictionCoefficientPoints.Add(new ObservablePoint(standardTime, point.FrictionCoefficient)); UploadProgress = Math.Min(99, currentRun.Count); TraceRealtimeCurvePoint(point); } private void CompleteRun() { runStopwatch.Stop(); LogRealtimeCurveSummary("测试停止"); RecordCompletedTest(); if (!slidingStartTimeSeconds.HasValue) { Log.Warning( "测试停止但未检测到有效滑动开始:TestNumber={TestNumber}, StartDisplacement={StartDisplacement:F3}mm, DisplacementThreshold={DisplacementThreshold:F3}mm, MinimumAnalysisLoad={MinimumAnalysisLoad:F3}N, CurvePointCount={CurvePointCount}", TestNumber, runStartDisplacementMm, SlidingStartDisplacementThresholdMm, GetMinimumAnalysisLoad(), currentRun.Count); CurrentStatus = "测试已停止,但未检测到有效滑动开始,曲线未记录且未生成结果"; return; } if (currentRun.Count < 3) { Log.Warning( "测试停止但滑动后采样点不足:TestNumber={TestNumber}, SlidingDetectionDelay={SlidingDetectionDelay:F3}s, PointCount={PointCount}", TestNumber, slidingStartTimeSeconds.Value, currentRun.Count); CurrentStatus = "测试已停止,但有效采样点不足,未生成结果"; return; } lastCompletedRun = currentRun.ToList(); var minimumAnalysisLoad = GetMinimumAnalysisLoad(); const double slidingStartTime = 0; var staticPeak = FindStaticPeak(currentRun); var peak = staticPeak.Point; var dynamicWindow = currentRun .Where(IsInDynamicWindow) .ToList(); if (dynamicWindow.Count < MinimumDynamicWindowPointCount) { var firstWindowPoint = dynamicWindow.FirstOrDefault(); var lastWindowPoint = dynamicWindow.LastOrDefault(); Log.Warning( "测试停止但动摩擦窗口采样点不足:TestNumber={TestNumber}, PointCount={PointCount}, SlidingDetectionDelay={SlidingDetectionDelay:F3}s, StandardTimeOrigin={StandardTimeOrigin: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, slidingStartTimeSeconds.Value, slidingStartTime, dynamicWindow.Count, MinimumDynamicWindowPointCount, FormatStandardTime(firstWindowPoint), FormatStandardTime(lastWindowPoint), currentRun[0].TimeSeconds, currentRun[^1].TimeSeconds); CurrentStatus = "测试已停止,0.3 s~0.6 s 有效采样点不足,未生成结果"; return; } 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}, SlidingDetectionDelay={SlidingDetectionDelay:F3}s, MinimumAnalysisLoad={MinimumAnalysisLoad:F3}N, StaticLoad={StaticLoad:F3}N, DynamicAvgLoad={DynamicAvgLoad:F3}N, StaticPoint={StaticPoint}, DynamicPointCount={DynamicPointCount}", TestNumber, slidingStartTimeSeconds.Value, 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; Log.Information( "静/动摩擦计算明细:TestNumber={TestNumber}, PointCount={PointCount}, SlidingDetectionDelay={SlidingDetectionDelay:F3}s, StandardTimeOrigin={StandardTimeOrigin:F3}s, AbsoluteSlidingStartDisplacement={AbsoluteSlidingStartDisplacement: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, slidingStartTimeSeconds.Value, slidingStartTime, slidingStartDisplacementMm, StaticPeakSearchEndSeconds, staticPeak.Mode, peakIndex, peak.TimeSeconds, peak.HorizontalFrictionN, peak.VerticalLoadN, staticCoefficientValue, DynamicWindowStartSeconds, DynamicWindowEndSeconds, dynamicStart, dynamicEnd, dynamicWindow.Count, dynamicForce, dynamicLoad, dynamicCoefficientValue); Samples.Insert(0, new TestSample( nextIndex, DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture), staticCoefficientValue.ToString("F2", CultureInfo.InvariantCulture), dynamicCoefficientValue.ToString("F2", CultureInfo.InvariantCulture), verdict, staticCoefficientValue, dynamicCoefficientValue)); StaticCoefficient = staticCoefficientValue.ToString("F2", CultureInfo.InvariantCulture); DynamicCoefficient = dynamicCoefficientValue.ToString("F2", CultureInfo.InvariantCulture); UpdateResultSummary(); UploadProgress = 100; CurrentStatus = verdict == "有效" ? "测试完成:已按标准生成静/动摩擦系数" : "测试完成:最近三次结果差异超过 10%,建议重新测试"; Log.Information( "测试完成:TestNumber={TestNumber}, PointCount={PointCount}, StaticCoefficient={StaticCoefficient:F3}, DynamicCoefficient={DynamicCoefficient:F3}, Verdict={Verdict}", TestNumber, currentRun.Count, staticCoefficientValue, dynamicCoefficientValue, verdict); } private void RecordCompletedTest() { CompletedTestCount++; switch (activeRunLubricantIndex) { case 1: case 2: WetTestCount++; break; case 3: FrostTestCount++; break; default: DryTestCount++; break; } Log.Information( "完整测试次数已更新:TestNumber={TestNumber}, Lubricant={Lubricant}, CompletedTestCount={CompletedTestCount}, DryTestCount={DryTestCount}, WetTestCount={WetTestCount}, FrostTestCount={FrostTestCount}", TestNumber, CurrentLubricant(activeRunLubricantIndex), CompletedTestCount, DryTestCount, WetTestCount, FrostTestCount); } private void AbortRun(string message) { runStopwatch.Stop(); Log.Warning( "测试采集中止:TestNumber={TestNumber}, PointCount={PointCount}, Message={Message}", TestNumber, currentRun.Count, message); CurrentStatus = message; } private bool NeedsRetest(double staticCoefficientValue, double dynamicCoefficientValue) { var previous = Samples.Take(StandardTrialCount - 1).Reverse().ToList(); if (previous.Count < StandardTrialCount - 1) { return false; } var staticValues = previous.Select(sample => sample.StaticCoefficientValue).Append(staticCoefficientValue).ToArray(); var dynamicValues = previous.Select(sample => sample.DynamicCoefficientValue).Append(dynamicCoefficientValue).ToArray(); return HasSystematicTrendExceedingTenPercent(staticValues) || HasSystematicTrendExceedingTenPercent(dynamicValues); } private static bool HasSystematicTrendExceedingTenPercent(double[] values) { var isIncreasing = values.Zip(values.Skip(1), (left, right) => right > left).All(result => result); var isDecreasing = values.Zip(values.Skip(1), (left, right) => right < left).All(result => result); if (!isIncreasing && !isDecreasing) { return false; } var average = values.Average(); if (Math.Abs(average) < 0.0001) { return false; } return (values.Max() - values.Min()) / Math.Abs(average) > 0.10; } private void UpdateResultSummary() { var latest = Samples.Take(StandardTrialCount).ToList(); if (latest.Count == 0) { ResultSummary = "等待 3 次有效试验"; return; } if (latest.Count < StandardTrialCount) { ResultSummary = $"已完成 {latest.Count}/3 次有效试验"; return; } var staticAverage = latest.Average(sample => sample.StaticCoefficientValue); var dynamicAverage = latest.Average(sample => sample.DynamicCoefficientValue); ResultSummary = $"近 3 次平均 静 {staticAverage:F2} / 动 {dynamicAverage:F2}"; } private double GetMinimumAnalysisLoad() => TryParseLoadValue(TargetLoadText, out var targetLoad) ? Math.Max(1, targetLoad * MinimumAnalysisLoadRatio) : 1; private static string FormatActualLoad(IReadOnlyList points) { var actualLoad = points.Average(point => point.VerticalLoadN); return $"{actualLoad.ToString("F1", CultureInfo.InvariantCulture)} N"; } 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 StaticPeakSelection FindStaticPeak(IReadOnlyList points) { var searchWindow = points .Where(point => point.TimeSeconds >= 0 && point.TimeSeconds <= 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; private void LogRealtimeCurveSummary(string stage) { var expectedCount = currentRun.Count; var lastPoint = currentRun.Count > 0 ? currentRun[^1] : null; var countsMatch = verticalLoadPoints.Count == expectedCount && horizontalFrictionPoints.Count == expectedCount && frictionCoefficientPoints.Count == expectedCount && IsLinearDisplacementSeriesSynchronized(lastPoint); Log.Information( "实时曲线同步汇总:Stage={Stage}, TestNumber={TestNumber}, DataPointCount={DataPointCount}, ChartCounts=[Vertical:{VerticalCount}, Friction:{FrictionCount}, Coefficient:{CoefficientCount}, DisplacementLine:{DisplacementCount}], DisplacementMode=零点到真实端点直线, CountsMatch={CountsMatch}, LastDataPoint={LastDataPoint}, ChartLast=[Vertical:{VerticalChart}, Friction:{FrictionChart}, Coefficient:{CoefficientChart}, DisplacementEndpoint:{DisplacementChart}]", stage, TestNumber, expectedCount, verticalLoadPoints.Count, horizontalFrictionPoints.Count, frictionCoefficientPoints.Count, displacementPoints.Count, countsMatch, FormatDataPoint(lastPoint), FormatChartPoint(verticalLoadPoints.Count > 0 ? verticalLoadPoints[^1] : null), FormatChartPoint(horizontalFrictionPoints.Count > 0 ? horizontalFrictionPoints[^1] : null), FormatChartPoint(frictionCoefficientPoints.Count > 0 ? frictionCoefficientPoints[^1] : null), FormatChartPoint(displacementPoints.Count > 0 ? displacementPoints[^1] : null)); } private void TraceRealtimeCurvePoint(SlipDataPoint point) { var expectedCount = currentRun.Count; var countsMatch = verticalLoadPoints.Count == expectedCount && horizontalFrictionPoints.Count == expectedCount && frictionCoefficientPoints.Count == expectedCount && IsLinearDisplacementSeriesSynchronized(point); var valuesMatch = countsMatch && ChartPointMatches(verticalLoadPoints[^1], point.TimeSeconds, point.VerticalLoadN) && ChartPointMatches(horizontalFrictionPoints[^1], point.TimeSeconds, point.HorizontalFrictionN) && ChartPointMatches(frictionCoefficientPoints[^1], point.TimeSeconds, point.FrictionCoefficient) && ChartPointMatches(displacementPoints[^1], point.TimeSeconds, point.DisplacementMm); if (!countsMatch || !valuesMatch) { Log.Warning( "实时曲线数据不同步:TestNumber={TestNumber}, PointIndex={PointIndex}, ExpectedCount={ExpectedCount}, Counts=[Vertical:{VerticalCount}, Friction:{FrictionCount}, Coefficient:{CoefficientCount}, DisplacementLine:{DisplacementCount}], DisplacementMode=零点到真实端点直线, ValuesMatch={ValuesMatch}, DataPoint=[Time:{Time:F3}s, Vertical:{Vertical:F3}N, Friction:{Friction:F3}N, Coefficient:{Coefficient:F5}, Displacement:{Displacement:F3}mm], ChartLast=[Vertical:{VerticalChart}, Friction:{FrictionChart}, Coefficient:{CoefficientChart}, DisplacementEndpoint:{DisplacementChart}]", TestNumber, expectedCount, expectedCount, verticalLoadPoints.Count, horizontalFrictionPoints.Count, frictionCoefficientPoints.Count, displacementPoints.Count, valuesMatch, point.TimeSeconds, point.VerticalLoadN, point.HorizontalFrictionN, point.FrictionCoefficient, point.DisplacementMm, FormatChartPoint(verticalLoadPoints.Count > 0 ? verticalLoadPoints[^1] : null), FormatChartPoint(horizontalFrictionPoints.Count > 0 ? horizontalFrictionPoints[^1] : null), FormatChartPoint(frictionCoefficientPoints.Count > 0 ? frictionCoefficientPoints[^1] : null), FormatChartPoint(displacementPoints.Count > 0 ? displacementPoints[^1] : null)); return; } var now = DateTime.UtcNow; if (expectedCount > 3 && now - lastRealtimeCurveTraceLoggedAt < RealtimeCurveTraceInterval) { return; } lastRealtimeCurveTraceLoggedAt = now; Log.Debug( "实时曲线点同步: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, FormatStandardTime(point), point.VerticalLoadN, point.HorizontalFrictionN, point.FrictionCoefficient, point.DisplacementMm, IsInDynamicWindow(point), expectedCount); } private void UpdateLinearDisplacementSeries(SlipDataPoint endpoint) { if (displacementPoints.Count == 0) { displacementPoints.Add(new ObservablePoint(0, 0)); } if (endpoint.TimeSeconds <= 0.000001) { while (displacementPoints.Count > 1) { displacementPoints.RemoveAt(displacementPoints.Count - 1); } return; } var endpointPoint = new ObservablePoint(endpoint.TimeSeconds, endpoint.DisplacementMm); if (displacementPoints.Count == 1) { displacementPoints.Add(endpointPoint); } else { displacementPoints[1] = endpointPoint; } while (displacementPoints.Count > 2) { displacementPoints.RemoveAt(displacementPoints.Count - 1); } } private bool IsLinearDisplacementSeriesSynchronized(SlipDataPoint? endpoint) { if (endpoint is null) { return displacementPoints.Count == 0; } var expectedCount = endpoint.TimeSeconds <= 0.000001 ? 1 : 2; return displacementPoints.Count == expectedCount && ChartPointMatches(displacementPoints[0], 0, 0) && (expectedCount == 1 || ChartPointMatches(displacementPoints[^1], endpoint.TimeSeconds, endpoint.DisplacementMm)); } private static bool ChartPointMatches(ObservablePoint chartPoint, double expectedX, double expectedY) => NearlyEquals(chartPoint.X, expectedX) && NearlyEquals(chartPoint.Y, expectedY); private static bool NearlyEquals(double? actual, double expected) => actual.HasValue && Math.Abs(actual.Value - expected) < 0.000001; private static string FormatChartPoint(ObservablePoint? point) => point is null ? "null" : $"({FormatNullable(point.X)},{FormatNullable(point.Y)})"; private static string FormatNullable(double? value) => value.HasValue ? value.Value.ToString("F3", CultureInfo.InvariantCulture) : "null"; private static string FormatStandardTime(SlipDataPoint? point) => point is null ? "null" : point.TimeSeconds.ToString("F3", CultureInfo.InvariantCulture); private static bool IsInDynamicWindow(SlipDataPoint point) => point.TimeSeconds >= DynamicWindowStartSeconds && point.TimeSeconds <= DynamicWindowEndSeconds; private static string FormatDataPoint(SlipDataPoint? point) => point is null ? "null" : $"StandardTime={point.TimeSeconds.ToString("F3", CultureInfo.InvariantCulture)}s, Vertical={point.VerticalLoadN.ToString("F3", CultureInfo.InvariantCulture)}N, Friction={point.HorizontalFrictionN.ToString("F3", CultureInfo.InvariantCulture)}N, Coefficient={point.FrictionCoefficient.ToString("F5", CultureInfo.InvariantCulture)}, SlidingDisplacement={point.DisplacementMm.ToString("F3", CultureInfo.InvariantCulture)}mm"; private async Task RunDeviceCommand(Task command, string successMessage) { try { await command; CurrentStatus = successMessage; } catch (Exception ex) { Log.Error(ex, "设备指令失败:{SuccessMessage}", successMessage); CurrentStatus = $"设备指令失败:{ex.Message}"; } } private Task RunLicensedDeviceCommand(Func command, string successMessage) { if (isLicenseLockPending) { CurrentStatus = "软件使用时效已到,仅允许停止或复位设备。"; return Task.CompletedTask; } return RunDeviceCommand(command(), successMessage); } private void WriteNumericSetting(string value, Func writer, string label) { if (isLoadingDeviceSettings || !TryParseDouble(value, out var numericValue)) { return; } _ = RunDeviceCommand(writer(numericValue), $"{label}已写入 PLC"); } private void SaveAndApplySettings() { SaveDeviceSettings(); deviceService.UpdateSettings(CurrentSettings()); } private void ApplyConnectionSettings() { var settings = CurrentSettings(); if (ConnectionSettingsChanged(settings)) { RestartDeviceConnection(settings); return; } deviceService.UpdateSettings(settings); } private bool ConnectionSettingsChanged(DeviceSettings settings) => !string.Equals(activePlcPortName, settings.PlcPortName, StringComparison.OrdinalIgnoreCase) || !string.Equals(activeAdcPortName, settings.AdcPortName, StringComparison.OrdinalIgnoreCase) || activeBaudRate != settings.BaudRate; private void RestartDeviceConnection() { RestartDeviceConnection(CurrentSettings()); } private void RestartDeviceConnection(DeviceSettings settings) { deviceService.Start(settings); activePlcPortName = settings.PlcPortName; activeAdcPortName = settings.AdcPortName; activeBaudRate = settings.BaudRate; } private bool ValidateAdcCoefficientsBeforeTest() { var invalid = new List(); AddInvalidCoefficient(NormalPressureCoefficient, "正压力校准系数", invalid); AddInvalidCoefficient(FrictionCoefficient1, "摩擦1校准系数", invalid); AddInvalidCoefficient(FrictionCoefficient2, "摩擦2校准系数", invalid); if (invalid.Count == 0) { return true; } var message = "ADC 校准系数无效:" + string.Join("、", invalid) + ";请填写旧机实际标定系数后再开始测试"; Log.Warning("阻止测试启动:{Message}", message); CurrentStatus = message; return false; } private async Task LoadPlcParametersAsync() { try { var parameters = await deviceService.ReadDeviceParametersAsync(); isLoadingDeviceSettings = true; ManualSpeed = parameters.ManualSpeed.ToString("F2", CultureInfo.InvariantCulture); ManualDisplacement = parameters.ManualDisplacement.ToString("F2", CultureInfo.InvariantCulture); TestSpeed = parameters.TestSpeed.ToString("F2", CultureInfo.InvariantCulture); isLoadingDeviceSettings = false; SaveAndApplySettings(); } catch (Exception ex) { isLoadingDeviceSettings = false; Log.Warning(ex, "启动时读取 PLC 参数失败,将使用本地保存的设备设置"); } } private void LoadDeviceSettings() { if (!File.Exists(DeviceSettingsPath)) { return; } try { var json = File.ReadAllText(DeviceSettingsPath); var settings = JsonSerializer.Deserialize(json); if (settings is null) { return; } isLoadingDeviceSettings = true; ManualSpeed = settings.ManualSpeed ?? ManualSpeed; ManualDisplacement = settings.ManualDisplacement ?? ManualDisplacement; TestSpeed = settings.TestSpeed ?? TestSpeed; NormalPressureZero = settings.NormalPressureZero ?? NormalPressureZero; NormalPressureCoefficient = settings.NormalPressureCoefficient ?? NormalPressureCoefficient; FrictionZero1 = settings.FrictionZero1 ?? FrictionZero1; FrictionCoefficient1 = settings.FrictionCoefficient1 ?? FrictionCoefficient1; FrictionZero2 = settings.FrictionZero2 ?? FrictionZero2; FrictionCoefficient2 = settings.FrictionCoefficient2 ?? FrictionCoefficient2; PlcPortName = NormalizeSavedPort(settings.PlcPortName, DefaultPlcPortName, LegacyPlcPortName); AdcPortName = NormalizeSavedPort(settings.AdcPortName, DefaultAdcPortName, LegacyAdcPortName); BaudRate = settings.BaudRate > 0 ? settings.BaudRate : BaudRate; } catch (Exception ex) { Log.Warning(ex, "读取设备设置失败:Path={Path}", DeviceSettingsPath); } finally { isLoadingDeviceSettings = false; } } private void SaveDeviceSettings() { if (isLoadingDeviceSettings) { return; } try { Directory.CreateDirectory(Path.GetDirectoryName(DeviceSettingsPath)!); var json = JsonSerializer.Serialize(CurrentSettings(), new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(DeviceSettingsPath, json); } catch (Exception ex) { Log.Warning(ex, "保存设备设置失败:Path={Path}", DeviceSettingsPath); } } private DeviceSettings CurrentSettings() => new( ManualSpeed, ManualDisplacement, TestSpeed, NormalPressureZero, NormalPressureCoefficient, FrictionZero1, FrictionCoefficient1, FrictionZero2, FrictionCoefficient2, PlcPortName, AdcPortName, BaudRate); private void UpdateTargetLoad() { TargetLoadText = SelectedShoeSizeIndex switch { 1 => "350 N", 2 => "160 N", _ => "400 N" }; } private string CurrentShoeSize() => SelectedShoeSizeIndex switch { 1 => "205-250", 2 => "205以下", _ => "250(含)以上" }; private string CurrentLubricant() => CurrentLubricant(SelectedLubricantIndex); private static string CurrentLubricant(int lubricantIndex) => lubricantIndex switch { 1 => "湿态 - 蒸馏水", 2 => "湿态 - 洗涤剂", 3 => "冰霜", _ => "干态" }; private string CurrentMode() => SelectedModeIndex switch { 0 => "后跟测试模式", 1 => "前掌测试模式", _ => "水平测试模式" }; private string CurrentSurface() => SelectedSurfaceIndex switch { 1 => "木地板", 2 => "石材", 3 => "冰霜表面", _ => "瓷砖" }; private static bool TryParseDouble(string value, out double numericValue) => double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out numericValue) || double.TryParse(value, NumberStyles.Float, CultureInfo.CurrentCulture, out numericValue); private static void AddInvalidCoefficient(string value, string label, List invalid) { if (!TryParseDouble(value, out var coefficient) || Math.Abs(coefficient) < 0.0001) { invalid.Add($"{label}={value}"); } } private static bool IsAdcCalibrationError(string error) => error.Contains("ADC 校准参数无效", StringComparison.Ordinal); private static string NormalizeSavedPort(string? savedPort, string defaultPort, string legacyPort) { if (string.IsNullOrWhiteSpace(savedPort) || string.Equals(savedPort, legacyPort, StringComparison.OrdinalIgnoreCase)) { return defaultPort; } return savedPort; } private static LineSeries CreateLineSeries( string name, ObservableCollection values, string color, int yAxis) => new() { Name = name, Values = values, MiniatureShapeSize = 8, MiniatureStrokeThickness = 1.6f, Stroke = new SolidColorPaint(SKColor.Parse(color)) { StrokeThickness = 1.7f }, Fill = new SolidColorPaint(SKColors.Transparent), GeometryFill = null, GeometryStroke = null, GeometrySize = 0, LineSmoothness = 0, ScalesYAt = yAxis }; private static string DeviceSettingsPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "FootwearSlipResistance", "device-settings.json"); } }