Files
FootwearTest-20260602/Footwear Test methodsfor wholeshoe Slipresistanceperformance/ViewModels/MainWindowViewModel.cs

1508 lines
61 KiB
C#
Raw Normal View History

2026-06-04 09:22:18 +08:00
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform.Storage;
2026-06-02 18:14:01 +08:00
using Avalonia.Threading;
2026-06-02 17:41:53 +08:00
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
2026-06-02 18:14:01 +08:00
using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Models;
using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Services;
2026-06-02 17:41:53 +08:00
using LiveChartsCore;
using LiveChartsCore.Defaults;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
2026-06-02 18:45:14 +08:00
using Serilog;
2026-06-02 17:41:53 +08:00
using SkiaSharp;
2026-06-02 18:14:01 +08:00
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;
2026-06-02 17:41:53 +08:00
namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.ViewModels
{
2026-06-02 18:14:01 +08:00
public partial class MainWindowViewModel : ViewModelBase, IDisposable
2026-06-02 17:41:53 +08:00
{
2026-06-02 19:16:50 +08:00
private const double SampleIntervalSeconds = 1.0 / 50.0;
private const double DynamicWindowStartSeconds = 0.3;
private const double DynamicWindowEndSeconds = 0.6;
2026-06-05 17:41:11 +08:00
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;
2026-06-02 19:16:50 +08:00
private const int MinimumDynamicWindowPointCount = 10;
private const int StandardTrialCount = 3;
2026-06-03 15:35:55 +08:00
private const string DefaultPlcPortName = "COM3";
private const string DefaultAdcPortName = "COM4";
private const string LegacyPlcPortName = "COM7";
private const string LegacyAdcPortName = "COM8";
2026-06-04 09:22:18 +08:00
private static readonly TimeSpan ResetButtonPendingTimeout = TimeSpan.FromMilliseconds(800);
2026-06-05 15:40:48 +08:00
private static readonly TimeSpan RealtimeCurveTraceInterval = TimeSpan.FromSeconds(1);
2026-06-02 19:16:50 +08:00
2026-06-02 18:14:01 +08:00
private readonly SlipResistanceDeviceService deviceService = new();
private readonly SlipExcelExportService excelExportService = new();
private readonly DispatcherTimer refreshTimer;
private readonly Stopwatch runStopwatch = new();
private readonly List<SlipDataPoint> currentRun = [];
private readonly ObservableCollection<ObservablePoint> verticalLoadPoints = [];
private readonly ObservableCollection<ObservablePoint> horizontalFrictionPoints = [];
private readonly ObservableCollection<ObservablePoint> frictionCoefficientPoints = [];
private readonly ObservableCollection<ObservablePoint> displacementPoints = [];
2026-06-02 17:41:53 +08:00
private bool isLoadingDeviceSettings;
2026-06-02 18:14:01 +08:00
private bool wasRunning;
2026-06-04 09:22:18 +08:00
private bool isResetButtonPending;
private bool hasObservedResetDeviceBusy;
private DateTime resetButtonPendingStartedAt = DateTime.MinValue;
2026-06-03 15:35:55 +08:00
private string activePlcPortName = string.Empty;
private string activeAdcPortName = string.Empty;
private int activeBaudRate;
2026-06-02 18:14:01 +08:00
private List<SlipDataPoint> lastCompletedRun = [];
2026-06-05 15:40:48 +08:00
private DateTime lastRealtimeCurveTraceLoggedAt = DateTime.MinValue;
2026-06-05 17:41:11 +08:00
private double runStartDisplacementMm;
2026-06-08 11:38:57 +08:00
private double slidingStartDisplacementMm;
2026-06-05 17:41:11 +08:00
private double? slidingStartTimeSeconds;
2026-06-08 18:08:30 +08:00
private int activeRunLubricantIndex;
2026-06-02 17:41:53 +08:00
[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]
2026-06-02 18:14:01 +08:00
private string currentStatus = "设备连接中,等待 PLC 与 ADC 实时数据";
2026-06-02 17:41:53 +08:00
[ObservableProperty]
2026-06-02 18:14:01 +08:00
private int uploadProgress;
2026-06-02 17:41:53 +08:00
[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";
2026-06-02 18:14:01 +08:00
[ObservableProperty]
private string normalPressureZero = "0";
2026-06-02 17:41:53 +08:00
[ObservableProperty]
private string normalPressureCoefficient = "0.00";
2026-06-02 18:14:01 +08:00
[ObservableProperty]
private string frictionZero1 = "0";
2026-06-02 17:41:53 +08:00
[ObservableProperty]
private string frictionCoefficient1 = "0.00";
2026-06-02 18:14:01 +08:00
[ObservableProperty]
private string frictionZero2 = "0";
2026-06-02 17:41:53 +08:00
[ObservableProperty]
private string frictionCoefficient2 = "0.00";
2026-06-02 18:14:01 +08:00
[ObservableProperty]
2026-06-03 15:35:55 +08:00
private string plcPortName = DefaultPlcPortName;
2026-06-02 18:14:01 +08:00
[ObservableProperty]
2026-06-03 15:35:55 +08:00
private string adcPortName = DefaultAdcPortName;
2026-06-02 18:14:01 +08:00
[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 次有效试验";
2026-06-03 15:35:55 +08:00
[ObservableProperty]
private string testButtonText = "测试";
[ObservableProperty]
private string resetButtonText = "复位";
2026-06-08 18:08:30 +08:00
[ObservableProperty]
private int completedTestCount;
[ObservableProperty]
private int dryTestCount;
[ObservableProperty]
private int wetTestCount;
[ObservableProperty]
private int frostTestCount;
2026-06-02 18:14:01 +08:00
[ObservableProperty]
2026-06-02 19:16:50 +08:00
private string staticCoefficient = "0.00";
2026-06-02 18:14:01 +08:00
[ObservableProperty]
2026-06-02 19:16:50 +08:00
private string dynamicCoefficient = "0.00";
2026-06-02 18:14:01 +08: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";
2026-06-02 17:41:53 +08:00
public string TestSpeedText => $"{TestSpeed} m/s";
2026-06-02 19:16:50 +08:00
public string SampleRateText { get; } = "50 Hz";
2026-06-02 18:14:01 +08:00
public string BatchNumber { get; } = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
2026-06-02 17:41:53 +08:00
public string UploadProgressText => $"{UploadProgress}%";
2026-06-02 19:23:51 +08:00
public string StandardReference { get; } = "GB/T 3903.6-2024实时采集、静/动摩擦系数、三次平均与重测判定";
2026-06-02 18:14:01 +08:00
public ObservableCollection<TestSample> Samples { get; } = [];
2026-06-02 17:41:53 +08:00
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"));
2026-06-02 18:14:01 +08:00
public ISeries[] Series { get; }
2026-06-02 17:41:53 +08:00
public Axis[] XAxes { get; } =
[
new Axis
{
Name = "时间(s)",
2026-06-02 18:14:01 +08:00
MinLimit = 0,
2026-06-08 11:56:58 +08:00
MinStep = 0.05,
2026-06-02 17:41:53 +08:00
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
{
2026-06-08 11:56:58 +08:00
Name = "压力(N)/距离(mm)",
2026-06-02 17:41:53 +08:00
MinLimit = 0,
2026-06-08 11:56:58 +08:00
MaxLimit = 800,
MinStep = 50,
ForceStepToMin = true,
2026-06-02 17:41:53 +08:00
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,
2026-06-08 11:56:58 +08:00
MaxLimit = 1.5,
MinStep = 0.1,
ForceStepToMin = true,
2026-06-02 17:41:53 +08:00
LabelsPaint = new SolidColorPaint(SKColor.Parse("#7E22CE")),
NamePaint = new SolidColorPaint(SKColor.Parse("#7E22CE")),
2026-06-08 11:38:57 +08:00
SeparatorsPaint = null,
2026-06-02 17:41:53 +08:00
TextSize = 11,
NameTextSize = 12,
Padding = new LiveChartsCore.Drawing.Padding(3, 2, 2, 2)
}
];
public MainWindowViewModel()
{
2026-06-02 18:45:14 +08:00
Log.Information("初始化主页面 ViewModel");
2026-06-02 18:14:01 +08:00
Series =
[
2026-06-08 11:56:58 +08:00
CreateLineSeries("垂直压力(N)", verticalLoadPoints, "#DC2626", 0),
CreateLineSeries("水平拉力(N)", horizontalFrictionPoints, "#16A34A", 0),
CreateLineSeries("摩擦系数", frictionCoefficientPoints, "#C026D3", 1),
CreateLineSeries("距离(mm)", displacementPoints, "#2563EB", 0)
2026-06-02 18:14:01 +08:00
];
2026-06-02 17:41:53 +08:00
LoadDeviceSettings();
2026-06-02 18:14:01 +08:00
UpdateTargetLoad();
2026-06-03 15:35:55 +08:00
RestartDeviceConnection();
2026-06-02 18:45:14 +08:00
_ = LoadPlcParametersAsync();
2026-06-02 18:14:01 +08:00
2026-06-02 19:16:50 +08:00
refreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(20) };
2026-06-02 18:14:01 +08:00
refreshTimer.Tick += (_, _) => RefreshFromDevice();
refreshTimer.Start();
2026-06-02 17:41:53 +08:00
}
[RelayCommand]
2026-06-02 18:14:01 +08:00
private async Task Clear()
2026-06-02 17:41:53 +08:00
{
2026-06-03 15:35:55 +08:00
ApplyConnectionSettings();
2026-06-04 09:22:18 +08:00
BeginResetButtonFeedback();
try
{
await deviceService.PulseResetAsync();
CurrentStatus = "已按老代码逻辑发送复位指令 M90";
}
catch (Exception ex)
{
Log.Error(ex, "设备指令失败:已按老代码逻辑发送复位指令 M90");
CurrentStatus = $"设备指令失败:{ex.Message}";
ClearResetButtonFeedback();
UpdateResetButtonText(deviceService.CurrentSnapshot);
}
2026-06-02 17:41:53 +08:00
}
[RelayCommand]
private void Preload()
{
2026-06-02 18:14:01 +08:00
CurrentStatus = "请在 0.2 s 内施加目标垂直载荷,载荷达到后启动滑动测试";
2026-06-02 17:41:53 +08:00
}
[RelayCommand]
2026-06-02 18:14:01 +08:00
private async Task StartTest()
2026-06-02 17:41:53 +08:00
{
2026-06-03 15:35:55 +08:00
ApplyConnectionSettings();
var device = deviceService.CurrentSnapshot;
if (device.IsTestRunning)
{
await RunDeviceCommand(deviceService.PulseStopTestAsync(), "已按老代码逻辑发送停止指令 M83");
return;
}
if (!ValidateAdcCoefficientsBeforeTest())
{
return;
}
await RunDeviceCommand(deviceService.PulseStartTestAsync(), "已按老代码逻辑发送测试启动指令 M80等待 M81 运行状态");
2026-06-02 17:41:53 +08:00
}
[RelayCommand]
2026-06-02 18:14:01 +08:00
private async Task StopTest()
2026-06-02 17:41:53 +08:00
{
2026-06-02 18:14:01 +08:00
await RunDeviceCommand(deviceService.PulseStopTestAsync(), "已发送测试停止指令 M83");
2026-06-02 17:41:53 +08:00
}
[RelayCommand]
2026-06-04 09:22:18 +08:00
private async Task ExportReport()
2026-06-02 17:41:53 +08:00
{
2026-06-02 18:14:01 +08:00
var points = currentRun.Count > 0 ? currentRun.ToList() : lastCompletedRun;
if (points.Count == 0)
{
2026-06-02 18:45:14 +08:00
Log.Warning("请求导出 Excel 时没有可导出的实时采样数据TestNumber={TestNumber}", TestNumber);
2026-06-02 18:14:01 +08:00
CurrentStatus = "没有可导出的实时采样数据,请先完成一次测试";
return;
}
2026-06-02 17:41:53 +08:00
2026-06-02 18:14:01 +08:00
try
{
2026-06-04 09:22:18 +08:00
CurrentStatus = "请选择 Excel 报告保存目录";
var exportDirectory = await SelectExportDirectoryAsync();
if (exportDirectory is null)
{
Log.Information("用户取消 Excel 导出目录选择TestNumber={TestNumber}, PointCount={PointCount}", TestNumber, points.Count);
CurrentStatus = "已取消 Excel 导出";
return;
}
2026-06-02 18:14:01 +08:00
var report = new SlipReportExport(
TestNumber,
OperatorName,
MethodName,
ReportName,
SampleFeature,
CurrentShoeSize(),
CurrentLubricant(),
CurrentMode(),
CurrentSurface(),
TargetLoadText,
ActualLoadText,
TestSpeedText,
StandardReference,
Samples.ToList(),
points);
2026-06-04 09:22:18 +08:00
var path = excelExportService.Export(report, exportDirectory);
2026-06-02 18:14:01 +08:00
UploadProgress = 100;
CurrentStatus = $"Excel 已导出:{path}";
}
catch (Exception ex)
{
2026-06-02 18:45:14 +08:00
Log.Error(ex, "Excel 导出失败TestNumber={TestNumber}, PointCount={PointCount}", TestNumber, points.Count);
2026-06-02 18:14:01 +08:00
CurrentStatus = $"Excel 导出失败:{ex.Message}";
}
}
2026-06-02 17:41:53 +08:00
2026-06-04 09:22:18 +08:00
private async Task<string?> 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<IStorageFolder?> 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;
}
2026-06-03 15:35:55 +08:00
[RelayCommand]
private Task LiftMotion() => RunDeviceCommand(deviceService.ApplyOldLiftAsync(), "提升按老代码逻辑执行M4=0, M5=1");
2026-06-02 18:53:31 +08:00
2026-06-03 15:35:55 +08:00
[RelayCommand]
private Task LowerMotion() => RunDeviceCommand(deviceService.ApplyOldLowerAsync(), "下降按老代码逻辑执行M5=0, M4=1");
2026-06-02 18:53:31 +08:00
2026-06-03 15:35:55 +08:00
[RelayCommand]
private Task MoveLeftMotion() => RunDeviceCommand(deviceService.ToggleOldMoveLeftAsync(), "左移按老代码逻辑切换 M1");
2026-06-02 18:53:31 +08:00
2026-06-03 15:35:55 +08:00
[RelayCommand]
private Task MoveRightMotion() => RunDeviceCommand(deviceService.ToggleOldMoveRightAsync(), "右移按老代码逻辑切换 M2");
2026-06-02 18:53:31 +08:00
public Task StopAllMotionAsync() => RunDeviceCommand(deviceService.StopAllMotionAsync(), "全部运动已停止");
2026-06-02 17:41:53 +08:00
[RelayCommand]
private void DeleteSelectedSample()
{
var sample = Samples.FirstOrDefault(x => x.Index == SelectedSampleIndex);
if (sample is null)
{
CurrentStatus = $"未找到序号 {SelectedSampleIndex} 的实验数据";
return;
}
Samples.Remove(sample);
2026-06-02 18:14:01 +08:00
UpdateResultSummary();
2026-06-02 17:41:53 +08:00
CurrentStatus = $"已删除序号 {SelectedSampleIndex} 的实验数据";
}
[RelayCommand]
private void ShowSettingsDialog()
{
IsSettingsDialogOpen = true;
}
[RelayCommand]
private void CloseSettingsDialog()
{
IsSettingsDialogOpen = false;
2026-06-03 15:35:55 +08:00
ApplyConnectionSettings();
2026-06-02 17:41:53 +08:00
}
2026-06-02 18:45:14 +08:00
[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}";
}
}
2026-06-02 18:14:01 +08:00
partial void OnUploadProgressChanged(int value) => OnPropertyChanged(nameof(UploadProgressText));
partial void OnManualSpeedChanged(string value)
2026-06-02 17:41:53 +08:00
{
2026-06-02 18:14:01 +08:00
SaveAndApplySettings();
WriteNumericSetting(value, deviceService.WriteManualSpeedAsync, "手动速度");
2026-06-02 17:41:53 +08:00
}
2026-06-02 18:14:01 +08:00
partial void OnManualDisplacementChanged(string value)
{
SaveAndApplySettings();
WriteNumericSetting(value, deviceService.WriteManualDisplacementAsync, "手动位移");
}
2026-06-02 17:41:53 +08:00
partial void OnTestSpeedChanged(string value)
{
OnPropertyChanged(nameof(TestSpeedText));
2026-06-02 18:14:01 +08:00
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();
2026-06-02 18:53:31 +08:00
try
{
deviceService.StopAllMotionAsync().Wait(500);
}
catch (Exception ex)
{
Log.Warning(ex, "关闭窗口时停止全部运动失败");
}
2026-06-02 18:14:01 +08:00
deviceService.Dispose();
}
private void RefreshFromDevice()
{
var device = deviceService.CurrentSnapshot;
2026-06-03 15:35:55 +08:00
if (device.IsConnected)
{
DeviceStatus = device.IsTestRunning ? "联机 / 测试中" : device.IsResetting ? "联机 / 复位中" : "联机 / 待机";
TestButtonText = device.IsTestRunning ? "停止" : "测试";
2026-06-04 09:22:18 +08:00
UpdateResetButtonText(device);
2026-06-03 15:35:55 +08:00
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) ? "数据无效" : "离线";
TestButtonText = device.IsTestRunning ? "停止" : "测试";
2026-06-04 09:22:18 +08:00
UpdateResetButtonText(device);
2026-06-03 15:35:55 +08:00
VerticalPressure = "--";
HorizontalForce = "--";
FrictionCoefficient = "--";
Distance = "--";
ActualLoadText = "-- N";
}
2026-06-02 18:14:01 +08:00
if (!device.IsConnected && !string.IsNullOrWhiteSpace(device.LastError))
{
2026-06-03 15:35:55 +08:00
CurrentStatus = IsAdcCalibrationError(device.LastError)
? $"数据无效:{device.LastError}"
: $"设备离线:{device.LastError}";
2026-06-02 18:14:01 +08:00
}
2026-06-02 19:16:50 +08:00
var isRecording = device.IsConnected && device.IsTestRunning;
if (!wasRunning && isRecording)
2026-06-02 18:14:01 +08:00
{
BeginRun();
}
2026-06-02 19:16:50 +08:00
if (isRecording)
2026-06-02 18:14:01 +08:00
{
RecordPoint(device);
}
2026-06-02 19:16:50 +08:00
if (wasRunning && !isRecording)
2026-06-02 18:14:01 +08:00
{
2026-06-02 19:16:50 +08:00
if (device.IsConnected)
{
CompleteRun();
}
else
{
AbortRun("设备离线,本次采集已停止,未生成试验结果");
}
2026-06-02 18:14:01 +08:00
}
2026-06-02 19:16:50 +08:00
wasRunning = isRecording;
2026-06-02 17:41:53 +08:00
}
2026-06-04 09:22:18 +08:00
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 ? "复位中" : "复位";
}
2026-06-02 18:14:01 +08:00
private void BeginRun()
{
currentRun.Clear();
verticalLoadPoints.Clear();
horizontalFrictionPoints.Clear();
frictionCoefficientPoints.Clear();
displacementPoints.Clear();
2026-06-05 17:41:11 +08:00
runStartDisplacementMm = deviceService.CurrentSnapshot.DisplacementMm;
2026-06-08 11:38:57 +08:00
slidingStartDisplacementMm = runStartDisplacementMm;
2026-06-05 17:41:11 +08:00
slidingStartTimeSeconds = null;
2026-06-08 18:08:30 +08:00
activeRunLubricantIndex = SelectedLubricantIndex;
2026-06-02 18:14:01 +08:00
runStopwatch.Restart();
UploadProgress = 0;
2026-06-05 15:40:48 +08:00
lastRealtimeCurveTraceLoggedAt = DateTime.MinValue;
2026-06-08 11:38:57 +08:00
CurrentStatus = "测试运行:等待检测有效滑动开始,曲线尚未记录";
2026-06-05 17:41:11 +08:00
Log.Information(
2026-06-08 11:38:57 +08:00
"测试开始TestNumber={TestNumber}, TargetLoad={TargetLoad}, TestSpeed={TestSpeed}, StartDisplacement={StartDisplacement:F3}mm, CurveRecording=等待有效滑动开始",
2026-06-05 17:41:11 +08:00
TestNumber,
TargetLoadText,
TestSpeedText,
runStartDisplacementMm);
2026-06-02 18:14:01 +08:00
}
2026-06-02 17:41:53 +08:00
2026-06-02 18:14:01 +08:00
private void RecordPoint(SlipDeviceSnapshot device)
{
2026-06-08 11:38:57 +08:00
var elapsedSinceTestStart = runStopwatch.Elapsed.TotalSeconds;
var detectionPoint = new SlipDataPoint(
device.Timestamp,
elapsedSinceTestStart,
device.VerticalLoadN,
device.HorizontalFrictionN,
device.DisplacementMm,
device.FrictionCoefficient);
TryMarkSlidingStart(detectionPoint);
if (!slidingStartTimeSeconds.HasValue)
{
return;
}
var standardTime = Math.Max(0, elapsedSinceTestStart - slidingStartTimeSeconds.Value);
if (currentRun.Count > 0 && standardTime - currentRun[^1].TimeSeconds < SampleIntervalSeconds)
2026-06-02 18:14:01 +08:00
{
return;
}
var point = new SlipDataPoint(
device.Timestamp,
2026-06-08 11:38:57 +08:00
standardTime,
2026-06-02 18:14:01 +08:00
device.VerticalLoadN,
device.HorizontalFrictionN,
2026-06-08 11:38:57 +08:00
Math.Abs(device.DisplacementMm - slidingStartDisplacementMm),
2026-06-02 18:14:01 +08:00
device.FrictionCoefficient);
currentRun.Add(point);
2026-06-08 11:38:57 +08:00
verticalLoadPoints.Add(new ObservablePoint(standardTime, point.VerticalLoadN));
horizontalFrictionPoints.Add(new ObservablePoint(standardTime, point.HorizontalFrictionN));
2026-06-08 14:35:37 +08:00
UpdateLinearDisplacementSeries(point);
2026-06-08 11:38:57 +08:00
frictionCoefficientPoints.Add(new ObservablePoint(standardTime, point.FrictionCoefficient));
2026-06-02 18:14:01 +08:00
UploadProgress = Math.Min(99, currentRun.Count);
2026-06-05 15:40:48 +08:00
TraceRealtimeCurvePoint(point);
2026-06-02 18:14:01 +08:00
}
2026-06-02 17:41:53 +08:00
2026-06-02 18:14:01 +08:00
private void CompleteRun()
{
runStopwatch.Stop();
2026-06-05 15:40:48 +08:00
LogRealtimeCurveSummary("测试停止");
2026-06-08 18:08:30 +08:00
RecordCompletedTest();
2026-06-08 11:38:57 +08:00
if (!slidingStartTimeSeconds.HasValue)
2026-06-02 18:14:01 +08:00
{
2026-06-08 11:38:57 +08:00
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 = "测试已停止,但未检测到有效滑动开始,曲线未记录且未生成结果";
2026-06-02 18:14:01 +08:00
return;
}
2026-06-08 11:38:57 +08:00
if (currentRun.Count < 3)
2026-06-05 17:41:11 +08:00
{
Log.Warning(
2026-06-08 11:38:57 +08:00
"测试停止但滑动后采样点不足TestNumber={TestNumber}, SlidingDetectionDelay={SlidingDetectionDelay:F3}s, PointCount={PointCount}",
2026-06-05 17:41:11 +08:00
TestNumber,
2026-06-08 11:38:57 +08:00
slidingStartTimeSeconds.Value,
currentRun.Count);
CurrentStatus = "测试已停止,但有效采样点不足,未生成结果";
2026-06-05 17:41:11 +08:00
return;
}
2026-06-08 11:38:57 +08:00
lastCompletedRun = currentRun.ToList();
var minimumAnalysisLoad = GetMinimumAnalysisLoad();
const double slidingStartTime = 0;
var staticPeak = FindStaticPeak(currentRun);
2026-06-05 17:41:11 +08:00
var peak = staticPeak.Point;
2026-06-02 18:14:01 +08:00
var dynamicWindow = currentRun
2026-06-08 11:38:57 +08:00
.Where(IsInDynamicWindow)
2026-06-02 18:14:01 +08:00
.ToList();
2026-06-02 19:16:50 +08:00
if (dynamicWindow.Count < MinimumDynamicWindowPointCount)
2026-06-02 18:14:01 +08:00
{
2026-06-05 15:40:48 +08:00
var firstWindowPoint = dynamicWindow.FirstOrDefault();
var lastWindowPoint = dynamicWindow.LastOrDefault();
2026-06-02 19:16:50 +08:00
Log.Warning(
2026-06-08 11:38:57 +08:00
"测试停止但动摩擦窗口采样点不足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",
2026-06-02 19:16:50 +08:00
TestNumber,
currentRun.Count,
2026-06-08 11:38:57 +08:00
slidingStartTimeSeconds.Value,
2026-06-05 17:41:11 +08:00
slidingStartTime,
2026-06-05 15:40:48 +08:00
dynamicWindow.Count,
MinimumDynamicWindowPointCount,
2026-06-08 11:38:57 +08:00
FormatStandardTime(firstWindowPoint),
FormatStandardTime(lastWindowPoint),
2026-06-05 15:40:48 +08:00
currentRun[0].TimeSeconds,
currentRun[^1].TimeSeconds);
2026-06-02 19:16:50 +08:00
CurrentStatus = "测试已停止0.3 s~0.6 s 有效采样点不足,未生成结果";
return;
2026-06-02 18:14:01 +08:00
}
var staticCoefficientValue = CalculateCoefficient(peak.HorizontalFrictionN, peak.VerticalLoadN);
var dynamicForce = dynamicWindow.Average(point => point.HorizontalFrictionN);
var dynamicLoad = dynamicWindow.Average(point => point.VerticalLoadN);
2026-06-05 17:41:11 +08:00
if (peak.VerticalLoadN < minimumAnalysisLoad || dynamicLoad < minimumAnalysisLoad)
{
Log.Warning(
2026-06-08 11:38:57 +08:00
"测试停止但静/动摩擦窗口载荷不足TestNumber={TestNumber}, SlidingDetectionDelay={SlidingDetectionDelay:F3}s, MinimumAnalysisLoad={MinimumAnalysisLoad:F3}N, StaticLoad={StaticLoad:F3}N, DynamicAvgLoad={DynamicAvgLoad:F3}N, StaticPoint={StaticPoint}, DynamicPointCount={DynamicPointCount}",
2026-06-05 17:41:11 +08:00
TestNumber,
2026-06-08 11:38:57 +08:00
slidingStartTimeSeconds.Value,
2026-06-05 17:41:11 +08:00
minimumAnalysisLoad,
peak.VerticalLoadN,
dynamicLoad,
FormatDataPoint(peak),
dynamicWindow.Count);
CurrentStatus = "测试已停止,静/动摩擦窗口载荷不足,未生成结果";
return;
}
2026-06-02 18:14:01 +08:00
var dynamicCoefficientValue = CalculateCoefficient(dynamicForce, dynamicLoad);
var verdict = NeedsRetest(staticCoefficientValue, dynamicCoefficientValue) ? "需重测" : "有效";
var nextIndex = Samples.Count == 0 ? 1 : Samples.Max(sample => sample.Index) + 1;
2026-06-05 15:40:48 +08:00
var peakIndex = currentRun.IndexOf(peak) + 1;
2026-06-08 11:38:57 +08:00
var dynamicStart = dynamicWindow[0].TimeSeconds;
var dynamicEnd = dynamicWindow[^1].TimeSeconds;
2026-06-05 15:40:48 +08:00
Log.Information(
2026-06-08 11:38:57 +08:00
"静/动摩擦计算明细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}",
2026-06-05 15:40:48 +08:00
TestNumber,
currentRun.Count,
2026-06-08 11:38:57 +08:00
slidingStartTimeSeconds.Value,
2026-06-05 17:41:11 +08:00
slidingStartTime,
2026-06-08 11:38:57 +08:00
slidingStartDisplacementMm,
2026-06-05 17:41:11 +08:00
StaticPeakSearchEndSeconds,
staticPeak.Mode,
2026-06-05 15:40:48 +08:00
peakIndex,
2026-06-08 11:38:57 +08:00
peak.TimeSeconds,
2026-06-05 15:40:48 +08:00
peak.HorizontalFrictionN,
peak.VerticalLoadN,
staticCoefficientValue,
DynamicWindowStartSeconds,
DynamicWindowEndSeconds,
dynamicStart,
dynamicEnd,
dynamicWindow.Count,
dynamicForce,
dynamicLoad,
dynamicCoefficientValue);
2026-06-02 18:14:01 +08:00
Samples.Insert(0, new TestSample(
nextIndex,
DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture),
2026-06-02 19:16:50 +08:00
staticCoefficientValue.ToString("F2", CultureInfo.InvariantCulture),
dynamicCoefficientValue.ToString("F2", CultureInfo.InvariantCulture),
2026-06-02 18:14:01 +08:00
verdict,
staticCoefficientValue,
dynamicCoefficientValue));
2026-06-02 19:16:50 +08:00
StaticCoefficient = staticCoefficientValue.ToString("F2", CultureInfo.InvariantCulture);
DynamicCoefficient = dynamicCoefficientValue.ToString("F2", CultureInfo.InvariantCulture);
2026-06-02 18:14:01 +08:00
UpdateResultSummary();
UploadProgress = 100;
CurrentStatus = verdict == "有效"
? "测试完成:已按标准生成静/动摩擦系数"
: "测试完成:最近三次结果差异超过 10%,建议重新测试";
2026-06-02 18:45:14 +08:00
Log.Information(
"测试完成TestNumber={TestNumber}, PointCount={PointCount}, StaticCoefficient={StaticCoefficient:F3}, DynamicCoefficient={DynamicCoefficient:F3}, Verdict={Verdict}",
TestNumber,
currentRun.Count,
staticCoefficientValue,
dynamicCoefficientValue,
verdict);
2026-06-02 18:14:01 +08:00
}
2026-06-08 18:08:30 +08:00
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);
}
2026-06-02 19:16:50 +08:00
private void AbortRun(string message)
{
runStopwatch.Stop();
Log.Warning(
"测试采集中止TestNumber={TestNumber}, PointCount={PointCount}, Message={Message}",
TestNumber,
currentRun.Count,
message);
CurrentStatus = message;
}
2026-06-02 18:14:01 +08:00
private bool NeedsRetest(double staticCoefficientValue, double dynamicCoefficientValue)
{
2026-06-02 19:16:50 +08:00
var previous = Samples.Take(StandardTrialCount - 1).Reverse().ToList();
if (previous.Count < StandardTrialCount - 1)
2026-06-02 18:14:01 +08:00
{
return false;
}
2026-06-02 19:16:50 +08:00
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);
2026-06-02 18:14:01 +08:00
}
2026-06-02 19:16:50 +08:00
private static bool HasSystematicTrendExceedingTenPercent(double[] values)
2026-06-02 18:14:01 +08:00
{
2026-06-02 19:16:50 +08:00
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;
}
2026-06-02 18:14:01 +08:00
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()
{
2026-06-02 19:16:50 +08:00
var latest = Samples.Take(StandardTrialCount).ToList();
2026-06-02 18:14:01 +08:00
if (latest.Count == 0)
{
ResultSummary = "等待 3 次有效试验";
return;
}
2026-06-02 19:16:50 +08:00
if (latest.Count < StandardTrialCount)
{
ResultSummary = $"已完成 {latest.Count}/3 次有效试验";
return;
}
2026-06-02 18:14:01 +08:00
var staticAverage = latest.Average(sample => sample.StaticCoefficientValue);
var dynamicAverage = latest.Average(sample => sample.DynamicCoefficientValue);
2026-06-02 19:16:50 +08:00
ResultSummary = $"近 3 次平均 静 {staticAverage:F2} / 动 {dynamicAverage:F2}";
2026-06-02 18:14:01 +08:00
}
2026-06-05 17:41:11 +08:00
private void TryMarkSlidingStart(SlipDataPoint point)
{
if (slidingStartTimeSeconds.HasValue)
{
return;
}
var minimumAnalysisLoad = GetMinimumAnalysisLoad();
if (!IsSlidingStartPoint(point, runStartDisplacementMm, minimumAnalysisLoad))
{
return;
}
slidingStartTimeSeconds = point.TimeSeconds;
2026-06-08 11:38:57 +08:00
slidingStartDisplacementMm = point.DisplacementMm;
CurrentStatus = "已检测到有效滑动开始:曲线从 0.000 s 开始记录";
2026-06-05 17:41:11 +08:00
Log.Information(
2026-06-08 11:38:57 +08:00
"检测到有效滑动开始并建立曲线零点TestNumber={TestNumber}, SlidingStartTime={SlidingStartTime:F3}s, SlidingDetectionDelay={SlidingDetectionDelay:F3}s, StandardTimeOrigin=0.000s, StartDisplacement={StartDisplacement:F3}mm, SlidingStartDisplacement={SlidingStartDisplacement:F3}mm, DisplacementDelta={DisplacementDelta:F3}mm, VerticalLoad={VerticalLoad:F3}N, MinimumAnalysisLoad={MinimumAnalysisLoad:F3}N",
2026-06-05 17:41:11 +08:00
TestNumber,
point.TimeSeconds,
2026-06-08 11:38:57 +08:00
point.TimeSeconds,
2026-06-05 17:41:11 +08:00
runStartDisplacementMm,
2026-06-08 11:38:57 +08:00
slidingStartDisplacementMm,
2026-06-05 17:41:11 +08:00
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)
2026-06-02 18:14:01 +08:00
{
2026-06-05 17:41:11 +08:00
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 IsSlidingStartPoint(SlipDataPoint point, double startDisplacementMm, double minimumAnalysisLoad) =>
point.VerticalLoadN >= minimumAnalysisLoad
&& Math.Abs(point.DisplacementMm - startDisplacementMm) >= SlidingStartDisplacementThresholdMm;
2026-06-08 11:38:57 +08:00
private static StaticPeakSelection FindStaticPeak(IReadOnlyList<SlipDataPoint> points)
2026-06-05 17:41:11 +08:00
{
var searchWindow = points
2026-06-08 11:38:57 +08:00
.Where(point => point.TimeSeconds >= 0
&& point.TimeSeconds <= StaticPeakSearchEndSeconds)
2026-06-02 19:16:50 +08:00
.ToList();
2026-06-05 17:41:11 +08:00
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");
2026-06-02 18:14:01 +08:00
}
2026-06-05 17:41:11 +08:00
private sealed record StaticPeakSelection(SlipDataPoint Point, string Mode);
2026-06-02 18:14:01 +08:00
private static double CalculateCoefficient(double frictionForce, double verticalLoad) =>
Math.Abs(verticalLoad) > 0.0001 ? frictionForce / verticalLoad : 0;
2026-06-05 15:40:48 +08:00
private void LogRealtimeCurveSummary(string stage)
{
var expectedCount = currentRun.Count;
2026-06-08 14:35:37 +08:00
var lastPoint = currentRun.Count > 0 ? currentRun[^1] : null;
2026-06-05 15:40:48 +08:00
var countsMatch =
verticalLoadPoints.Count == expectedCount
&& horizontalFrictionPoints.Count == expectedCount
&& frictionCoefficientPoints.Count == expectedCount
2026-06-08 14:35:37 +08:00
&& IsLinearDisplacementSeriesSynchronized(lastPoint);
2026-06-05 15:40:48 +08:00
Log.Information(
2026-06-08 14:35:37 +08:00
"实时曲线同步汇总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}]",
2026-06-05 15:40:48 +08:00
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
2026-06-08 14:35:37 +08:00
&& IsLinearDisplacementSeriesSynchronized(point);
2026-06-05 15:40:48 +08:00
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(
2026-06-08 14:35:37 +08:00
"实时曲线数据不同步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}]",
2026-06-05 15:40:48 +08:00
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(
2026-06-05 17:41:11 +08:00
"实时曲线点同步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}",
2026-06-05 15:40:48 +08:00
TestNumber,
expectedCount,
point.TimeSeconds,
2026-06-08 11:38:57 +08:00
FormatStandardTime(point),
2026-06-05 15:40:48 +08:00
point.VerticalLoadN,
point.HorizontalFrictionN,
point.FrictionCoefficient,
point.DisplacementMm,
2026-06-08 11:38:57 +08:00
IsInDynamicWindow(point),
2026-06-05 15:40:48 +08:00
expectedCount);
}
2026-06-08 14:35:37 +08:00
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));
}
2026-06-05 15:40:48 +08:00
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";
2026-06-08 11:38:57 +08:00
private static string FormatStandardTime(SlipDataPoint? point) =>
point is null ? "null" : point.TimeSeconds.ToString("F3", CultureInfo.InvariantCulture);
2026-06-05 17:41:11 +08:00
2026-06-08 11:38:57 +08:00
private static bool IsInDynamicWindow(SlipDataPoint point) =>
point.TimeSeconds >= DynamicWindowStartSeconds && point.TimeSeconds <= DynamicWindowEndSeconds;
2026-06-05 17:41:11 +08:00
2026-06-05 15:40:48 +08:00
private static string FormatDataPoint(SlipDataPoint? point) =>
point is null
? "null"
2026-06-08 11:38:57 +08:00
: $"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";
2026-06-05 15:40:48 +08:00
2026-06-02 18:14:01 +08:00
private async Task RunDeviceCommand(Task command, string successMessage)
{
try
{
await command;
CurrentStatus = successMessage;
}
catch (Exception ex)
{
2026-06-02 18:45:14 +08:00
Log.Error(ex, "设备指令失败:{SuccessMessage}", successMessage);
2026-06-02 18:14:01 +08:00
CurrentStatus = $"设备指令失败:{ex.Message}";
}
}
private void WriteNumericSetting(string value, Func<double, Task> writer, string label)
{
if (isLoadingDeviceSettings || !TryParseDouble(value, out var numericValue))
{
return;
}
_ = RunDeviceCommand(writer(numericValue), $"{label}已写入 PLC");
}
private void SaveAndApplySettings()
{
SaveDeviceSettings();
deviceService.UpdateSettings(CurrentSettings());
}
2026-06-02 17:41:53 +08:00
2026-06-03 15:35:55 +08:00
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<string>();
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;
}
2026-06-02 18:45:14 +08:00
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 参数失败,将使用本地保存的设备设置");
}
}
2026-06-02 17:41:53 +08:00
private void LoadDeviceSettings()
{
if (!File.Exists(DeviceSettingsPath))
{
return;
}
try
{
var json = File.ReadAllText(DeviceSettingsPath);
var settings = JsonSerializer.Deserialize<DeviceSettings>(json);
if (settings is null)
{
return;
}
isLoadingDeviceSettings = true;
ManualSpeed = settings.ManualSpeed ?? ManualSpeed;
ManualDisplacement = settings.ManualDisplacement ?? ManualDisplacement;
TestSpeed = settings.TestSpeed ?? TestSpeed;
2026-06-02 18:14:01 +08:00
NormalPressureZero = settings.NormalPressureZero ?? NormalPressureZero;
2026-06-02 17:41:53 +08:00
NormalPressureCoefficient = settings.NormalPressureCoefficient ?? NormalPressureCoefficient;
2026-06-02 18:14:01 +08:00
FrictionZero1 = settings.FrictionZero1 ?? FrictionZero1;
2026-06-02 17:41:53 +08:00
FrictionCoefficient1 = settings.FrictionCoefficient1 ?? FrictionCoefficient1;
2026-06-02 18:14:01 +08:00
FrictionZero2 = settings.FrictionZero2 ?? FrictionZero2;
2026-06-02 17:41:53 +08:00
FrictionCoefficient2 = settings.FrictionCoefficient2 ?? FrictionCoefficient2;
2026-06-03 15:35:55 +08:00
PlcPortName = NormalizeSavedPort(settings.PlcPortName, DefaultPlcPortName, LegacyPlcPortName);
AdcPortName = NormalizeSavedPort(settings.AdcPortName, DefaultAdcPortName, LegacyAdcPortName);
2026-06-02 18:14:01 +08:00
BaudRate = settings.BaudRate > 0 ? settings.BaudRate : BaudRate;
2026-06-02 17:41:53 +08:00
}
2026-06-02 18:45:14 +08:00
catch (Exception ex)
2026-06-02 17:41:53 +08:00
{
2026-06-02 18:45:14 +08:00
Log.Warning(ex, "读取设备设置失败Path={Path}", DeviceSettingsPath);
2026-06-02 17:41:53 +08:00
}
finally
{
isLoadingDeviceSettings = false;
}
}
private void SaveDeviceSettings()
{
if (isLoadingDeviceSettings)
{
return;
}
try
{
Directory.CreateDirectory(Path.GetDirectoryName(DeviceSettingsPath)!);
2026-06-02 18:14:01 +08:00
var json = JsonSerializer.Serialize(CurrentSettings(), new JsonSerializerOptions { WriteIndented = true });
2026-06-02 17:41:53 +08:00
File.WriteAllText(DeviceSettingsPath, json);
}
2026-06-02 18:45:14 +08:00
catch (Exception ex)
2026-06-02 17:41:53 +08:00
{
2026-06-02 18:45:14 +08:00
Log.Warning(ex, "保存设备设置失败Path={Path}", DeviceSettingsPath);
2026-06-02 17:41:53 +08:00
}
}
2026-06-02 18:14:01 +08:00
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(含)以上"
};
2026-06-08 18:08:30 +08:00
private string CurrentLubricant() => CurrentLubricant(SelectedLubricantIndex);
private static string CurrentLubricant(int lubricantIndex) => lubricantIndex switch
2026-06-02 18:14:01 +08:00
{
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);
2026-06-03 15:35:55 +08:00
private static void AddInvalidCoefficient(string value, string label, List<string> 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;
}
2026-06-02 18:14:01 +08:00
private static LineSeries<ObservablePoint> CreateLineSeries(
string name,
ObservableCollection<ObservablePoint> values,
string color,
2026-06-08 11:38:57 +08:00
int yAxis) =>
2026-06-02 18:14:01 +08:00
new()
{
Name = name,
Values = values,
MiniatureShapeSize = 8,
2026-06-05 18:10:30 +08:00
MiniatureStrokeThickness = 1.6f,
Stroke = new SolidColorPaint(SKColor.Parse(color)) { StrokeThickness = 1.7f },
2026-06-02 18:14:01 +08:00
Fill = new SolidColorPaint(SKColors.Transparent),
2026-06-05 18:10:30 +08:00
GeometryFill = null,
GeometryStroke = null,
GeometrySize = 0,
2026-06-08 11:38:57 +08:00
LineSmoothness = 0,
2026-06-02 18:14:01 +08:00
ScalesYAt = yAxis
};
2026-06-02 17:41:53 +08:00
private static string DeviceSettingsPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"FootwearSlipResistance",
"device-settings.json");
}
}