Files
FootwearTest-20260602/Footwear Test methodsfor wholeshoe Slipresistanceperformance/ViewModels/MainWindowViewModel.cs
2026-06-22 11:04:39 +08:00

1762 lines
72 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<SlipDataPoint> currentRun = [];
private readonly List<SlipDataPoint> preTriggerBuffer = [];
private readonly ObservableCollection<ObservablePoint> verticalLoadPoints = [];
private readonly ObservableCollection<ObservablePoint> horizontalFrictionPoints = [];
private readonly ObservableCollection<ObservablePoint> frictionCoefficientPoints = [];
private readonly ObservableCollection<ObservablePoint> 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<SlipDataPoint> 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<string>? 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<TestSample> 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<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;
}
[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<SlipDataPoint> 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<SlipDataPoint> 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<Task> command, string successMessage)
{
if (isLicenseLockPending)
{
CurrentStatus = "软件使用时效已到,仅允许停止或复位设备。";
return Task.CompletedTask;
}
return RunDeviceCommand(command(), successMessage);
}
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());
}
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;
}
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<DeviceSettings>(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<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;
}
private static LineSeries<ObservablePoint> CreateLineSeries(
string name,
ObservableCollection<ObservablePoint> 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");
}
}