using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using OxyPlot; using OxyPlot.Axes; using OxyPlot.Series; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Threading; using TabletTester2025.Helpers; using TabletTester2025.Models; using TabletTester2025.Services; namespace TabletTester2025.ViewModels { public partial class StationViewModel : ObservableObject { private DispatcherTimer _hardnessGlobalTimer; private readonly IPlcService _plc; private readonly PlcConfiguration _plcConfig; private readonly DatabaseService _db; private readonly ExcelExportService _excel; private readonly AlarmService _alarm; private DispatcherTimer _disintegrationTimer; private bool _isLoadingDissolution1Time; private bool _isLoadingDissolution2Time; private bool _isLoadingDissolution1SampleInterval; private bool _isLoadingDissolution2SampleInterval; private bool _isLoadingDisintegrationTime; private bool _isLoadingDisintegrationSpeed; private bool _isLoadingFriabilityRounds; private bool _isUpdatingFriabilityWeightFromPlc; private bool _isReadingHardnessLiveForce; private bool _isHardnessRunning; private bool _isFriabilityRunning; private bool _isDisintegrationRunning; private int _hardnessGroupNo; private int _currentHardnessGroupNo; private readonly List _dissolution1Times = new(); private readonly List _dissolution1Values = new(); private readonly List _dissolution2Times = new(); private readonly List _dissolution2Values = new(); public ObservableCollection DissolutionSamplePoints { get; } = new(); private DateTime _dissolution1StartTime = DateTime.MinValue; private DateTime _dissolution2StartTime = DateTime.MinValue; private DateTime _dissolution1LastRunUpdate = DateTime.MinValue; private DateTime _dissolution2LastRunUpdate = DateTime.MinValue; private double _dissolution1ElapsedRunMinutes; private double _dissolution2ElapsedRunMinutes; private bool _isDissolution1Running; private bool _isDissolution2Running; private bool _dissolution1SampleRequestActive; private bool _dissolution2SampleRequestActive; private bool _isDissolution1SamplePromptOpen; private bool _isDissolution2SamplePromptOpen; private bool _discardDisintegrationResult; private string _dissolutionResultChannel = ""; private double _dissolutionResultRate30Min; private double _dissolutionResultRSquared; public int StationId { get; } [ObservableProperty] private TestType _currentTest; [ObservableProperty] private TestPhase _phase = TestPhase.Idle; // 硬度 [ObservableProperty] private double _hardnessValue; [ObservableProperty] private bool _hardnessPass; [ObservableProperty] private double _hardnessAvg; [ObservableProperty] private double _hardnessAverageDeviation; [ObservableProperty] private double _hardnessRSD; [ObservableProperty] private double _hardnessInternalMin = 40; [ObservableProperty] private double _hardnessInternalMax = 60; [ObservableProperty] private bool _isHardnessResetting; public string HardnessResetButtonText => IsHardnessResetting ? "复位中" : "复位"; //硬度新增 //[ObservableProperty] private int _hardnessForward = 0;//硬前进 //[ObservableProperty] private int _hardnessBack = 1;//硬后退 //[ObservableProperty] private int _hardnessOver = 72; //硬度完成 //[ObservableProperty] private int _hardnessStartOver = 92;// 硬复位完成 //[ObservableProperty] private int _hardnessLimit = 298;// 硬度电机极限输入 [ObservableProperty] private double _hardnessSudu = 100;// 硬度速度输入mm/min [ObservableProperty] private double _hardnessWeiyi = 100; // 硬度位移输入mm //[ObservableProperty] private double _hardnessPoSun = 400; // 硬度破损判定输入N //[ObservableProperty] private double _hardnessMaxN = 72; //最大力采集 [ObservableProperty] private double _hardnessShishilizhi = 0.0; //最大力采集 private List _hardnessResults = new(); public ObservableCollection HardnessSamplePoints { get; } = new(); public ObservableCollection HardnessDisplaySamplePoints { get; } = new(); // 脆碎度 [ObservableProperty] private double _weightBefore; [ObservableProperty] private double _weightAfter; [ObservableProperty] private double _lossPercent; [ObservableProperty] private bool _friabilityPass; // 崩解 [ObservableProperty] private double _disintegrationTemp; [ObservableProperty] private int _disintegrationSeconds; [ObservableProperty] private double _disintegrationActualSeconds; [ObservableProperty] private string _disintegrationActualSecondsText = "0"; [ObservableProperty] private bool _canSaveDisintegrationResult; [ObservableProperty] private bool _isBasketMovingUp; [ObservableProperty] private bool[] _tubesCompleted = new bool[6]; [ObservableProperty] private int _remainingTubes; // 溶出 [ObservableProperty] private double _dissolutionRpm; [ObservableProperty] private double _dissolutionPercent; [ObservableProperty] private double _dissolution1Percent; [ObservableProperty] private double _dissolution2Percent; // 硬度相关新增 [ObservableProperty] private int _hardnessTestCount = 6; [ObservableProperty] private int _hardnessIntervalSec = 2; [ObservableProperty] private int _hardnessCurrentCount; [ObservableProperty] private int _hardnessTotalCount; [ObservableProperty] private double _hardnessMax; [ObservableProperty] private double _hardnessMin; [ObservableProperty] private bool _disintegrationPass; // 新增 [ObservableProperty] private bool _dissolutionPass; // 新增 public IAsyncRelayCommand HardnessUpCommand { get; } public IAsyncRelayCommand HardnessDownCommand { get; } public IAsyncRelayCommand HardnessResetCommand { get; } public IAsyncRelayCommand ClearHardnessRecordsCommand { get; } public IAsyncRelayCommand HardnessForward { get; }//前进 public IAsyncRelayCommand HardnessBack { get; }//后退 // 脆碎度新增 [ObservableProperty] private double _friabilityTargetRpm = 25; [ObservableProperty] private double _friabilityTargetTimeMin = 4; [ObservableProperty] private int _friabilityTargetTimeSec = 240; [ObservableProperty] private int _friabilityTargetRounds = 100; [ObservableProperty] private double _friabilityMaxLossPercent = 1.0; [ObservableProperty] private bool _friabilityClockwise = true; [ObservableProperty] private bool _friabilityCounterClockwise; [ObservableProperty] private double _friabilityCurrentRpm; [ObservableProperty] private double _friabilityRealtimeRounds; [ObservableProperty] private int _friabilityRemainingRounds = 100; [ObservableProperty] private bool _canSaveFriabilityResult; public IAsyncRelayCommand StopHardnessCommand { get; } public IAsyncRelayCommand StopFriabilityCommand { get; } public IAsyncRelayCommand ResetFriabilityCommand { get; } public IAsyncRelayCommand PrintFriabilityCommand { get; } public IAsyncRelayCommand SaveFriabilityResultCommand { get; } // 溶出度新增 [ObservableProperty] private double _dissolutionUpDownFreq = 32; [ObservableProperty] private int _dissolutionSampleInterval = 5; [ObservableProperty] private double _dissolutionTargetRpm = 50; [ObservableProperty] private int _dissolution1TimeMin = 30; [ObservableProperty] private int _dissolution2TimeMin = 30; [ObservableProperty] private double _dissolution1SampleIntervalMin = 5; [ObservableProperty] private double _dissolution2SampleIntervalMin = 5; [ObservableProperty] private double _dissolutionMinPercentAt30Min = 80; [ObservableProperty] private double _dissolutionElapsedTime; [ObservableProperty] private double _dissolutionCountdown; [ObservableProperty] private double _dissolution1ElapsedTime; [ObservableProperty] private double _dissolution2ElapsedTime; [ObservableProperty] private double _dissolution1Countdown; [ObservableProperty] private double _dissolution2Countdown; [ObservableProperty] private double _dissolutionRSquared; [ObservableProperty] private double _dissolution1RSquared; [ObservableProperty] private double _dissolution2RSquared; [ObservableProperty] private string _dissolutionCurveStatus = ""; public IAsyncRelayCommand DissolutionUpCommand { get; } public IAsyncRelayCommand DissolutionDownCommand { get; } public IAsyncRelayCommand StopDissolutionCommand { get; } public IAsyncRelayCommand PrintDissolutionCommand { get; } public IAsyncRelayCommand StartDissolution1Command { get; } public IAsyncRelayCommand StopDissolution1Command { get; } public IAsyncRelayCommand ResetDissolution1Command { get; } public IAsyncRelayCommand StartDissolution2Command { get; } public IAsyncRelayCommand StopDissolution2Command { get; } public IAsyncRelayCommand ResetDissolution2Command { get; } // 崩解新增 [ObservableProperty] private double _disintegrationTargetFreq = 31; [ObservableProperty] private double _disintegrationSpeedRpm = 31; [ObservableProperty] private double _disintegrationTimeMin = 15; [ObservableProperty] private string _disintegrationDosageForm = "普通片"; public IAsyncRelayCommand StopDisintegrationCommand { get; } public IAsyncRelayCommand ResetDisintegrationCommand { get; } public IAsyncRelayCommand PrintDisintegrationCommand { get; } public IAsyncRelayCommand SaveDisintegrationResultCommand { get; } public PlotModel DissolutionPlotModel { get; } private readonly LineSeries _dissolution1Series; private readonly LineSeries _dissolution2Series; // 命令 public IAsyncRelayCommand StartHardnessCommand { get; } public IAsyncRelayCommand StartFriabilityCommand { get; } public IAsyncRelayCommand StartDisintegrationCommand { get; } public IAsyncRelayCommand StartDissolutionCommand { get; } public IAsyncRelayCommand ExportHistoryCommand { get; } [ObservableProperty] private string _localAlarm; public StationViewModel(int id, IPlcService plc, PlcConfiguration plcConfig, DatabaseService db, ExcelExportService excel, AlarmService alarm) { StationId = id; _plc = plc; _plcConfig = plcConfig; _db = db; _excel = excel; _alarm = alarm; LoadPharmaDefaults(); StartHardnessCommand = new AsyncRelayCommand(RunHardnessAsync); StartFriabilityCommand = new AsyncRelayCommand(RunFriabilityAsync); SaveFriabilityResultCommand = new AsyncRelayCommand(SaveFriabilityResultAsync); StartDisintegrationCommand = new AsyncRelayCommand(RunDisintegrationAsync); ClearHardnessRecordsCommand = new AsyncRelayCommand(ClearHardnessRecordsAsync); StartDissolutionCommand = new AsyncRelayCommand(StartDissolution1Async); ExportHistoryCommand = new AsyncRelayCommand(ExportHistoryAsync); // 溶出曲线 DissolutionPlotModel = new PlotModel { Title = "溶出曲线" }; DissolutionPlotModel.Axes.Add(new LinearAxis { Position = AxisPosition.Bottom, Title = "时间 (min)", Minimum = 0, MajorGridlineStyle = LineStyle.Solid, MinorGridlineStyle = LineStyle.Dot }); DissolutionPlotModel.Axes.Add(new LinearAxis { Position = AxisPosition.Left, Title = "溶出度 (%)", Minimum = 0, Maximum = 150, MajorGridlineStyle = LineStyle.Solid, MinorGridlineStyle = LineStyle.Dot }); _dissolution1Series = new LineSeries { Title = "溶出1", Color = OxyColors.SeaGreen, StrokeThickness = 2 }; _dissolution2Series = new LineSeries { Title = "溶出2", Color = OxyColors.DodgerBlue, StrokeThickness = 2 }; DissolutionPlotModel.Series.Add(_dissolution1Series); DissolutionPlotModel.Series.Add(_dissolution2Series); StartHardnessLiveForcePolling(); HardnessDownCommand = new AsyncRelayCommand(async () => { await _plc.WriteCoilAsync(0x21, true); await Task.Delay(100); await _plc.WriteCoilAsync(0x21, false); }); //硬复位 HardnessResetCommand = new AsyncRelayCommand(ResetHardnessAsync, CanResetHardness); // 硬前进按钮命令 HardnessForward = new AsyncRelayCommand(async () => { await _plc.WriteCoilAsync(_plcConfig.HardnessForward, true); await Task.Delay(100); // 脉冲宽度,和复位按钮保持一致 await _plc.WriteCoilAsync(_plcConfig.HardnessForward, false); }); // 硬后退按钮命令 HardnessBack = new AsyncRelayCommand(async () => { await _plc.WriteCoilAsync(_plcConfig.HardnessBack, true); await Task.Delay(100); // 脉冲宽度,和复位按钮保持一致 await _plc.WriteCoilAsync(_plcConfig.HardnessBack, false); }); // 硬度命令停止 StopHardnessCommand = new AsyncRelayCommand(async() => { await _plc.WriteCoilAsync(_plcConfig.HardnessStartStop, true); await Task.Delay(100); // 脉冲宽度,可根据PLC程序调整20~100ms await _plc.WriteCoilAsync(_plcConfig.HardnessStartStop, false); _isHardnessRunning = false; RefreshOverallPhase(); }); // 脆碎度命令 StopFriabilityCommand = new AsyncRelayCommand(async () => { //测试停止 if (_plcConfig.FriabilityStartCoilStop != 0) await PulseCoilAsync(_plcConfig.FriabilityStartCoilStop); _isFriabilityRunning = false; CanSaveFriabilityResult = false; RefreshOverallPhase(); }); ResetFriabilityCommand = new AsyncRelayCommand(() => { FriabilityRemainingRounds = FriabilityTargetRounds; CanSaveFriabilityResult = false; LossPercent = 0; // 失重率清零 SetFriabilityWeightFromPlc(0, 0); // 清空界面结果,不向PLC写零 return Task.CompletedTask; }); PrintFriabilityCommand = new AsyncRelayCommand(async () => await PrintReport("脆碎度")); // 溶出度命令 DissolutionUpCommand = new AsyncRelayCommand(async () => await _plc.WriteCoilAsync(0x22, true)); DissolutionDownCommand = new AsyncRelayCommand(async () => await _plc.WriteCoilAsync(0x23, true)); StartDissolution1Command = new AsyncRelayCommand(StartDissolution1Async); StopDissolution1Command = new AsyncRelayCommand(StopDissolution1Async); ResetDissolution1Command = new AsyncRelayCommand(ResetDissolution1Async); StartDissolution2Command = new AsyncRelayCommand(StartDissolution2Async); StopDissolution2Command = new AsyncRelayCommand(StopDissolution2Async); ResetDissolution2Command = new AsyncRelayCommand(ResetDissolution2Async); StopDissolutionCommand = new AsyncRelayCommand(StopDissolution1Async); PrintDissolutionCommand = new AsyncRelayCommand(async () => await PrintReport("溶出度")); // 崩解命令 StopDisintegrationCommand = new AsyncRelayCommand(StopDisintegrationAsync); ResetDisintegrationCommand = new AsyncRelayCommand(ResetDisintegrationAsync); PrintDisintegrationCommand = new AsyncRelayCommand(async () => await PrintReport("崩解")); SaveDisintegrationResultCommand = new AsyncRelayCommand(SaveDisintegrationResultAsync); _ = LoadFriabilitySettingsAsync(); _ = LoadMediumTemperatureAsync(); } public void ApplyPharmaDefaults() { var p = App.CurrentPharmaParams; _isLoadingDisintegrationSpeed = true; _isLoadingDisintegrationTime = true; _isLoadingDissolution1Time = true; _isLoadingDissolution2Time = true; _isLoadingDissolution1SampleInterval = true; _isLoadingDissolution2SampleInterval = true; _isLoadingFriabilityRounds = true; try { HardnessInternalMin = p.HardnessMin_N; HardnessInternalMax = p.HardnessMax_N; HardnessTestCount = Math.Max(1, p.HardnessTestCount); FriabilityTargetRpm = p.FriabilityTargetRpm > 0 ? p.FriabilityTargetRpm : 25; double defaultRounds = p.FriabilityTargetRounds > 0 ? p.FriabilityTargetRounds : 100; FriabilityTargetRounds = Math.Max(1, (int)Math.Round(defaultRounds, MidpointRounding.AwayFromZero)); FriabilityTargetTimeMin = p.FriabilityTargetTimeMin > 0 ? p.FriabilityTargetTimeMin : defaultRounds / FriabilityTargetRpm; UpdateFriabilityTimingFromTime(); FriabilityMaxLossPercent = p.FriabilityMaxLossPercent; FriabilityRemainingRounds = FriabilityTargetRounds; DisintegrationDosageForm = string.IsNullOrWhiteSpace(p.DisintegrationDosageForm) ? "普通片" : p.DisintegrationDosageForm; DisintegrationSpeedRpm = p.DisintegrationSpeedRpm > 0 ? p.DisintegrationSpeedRpm : 31; DisintegrationTemp = p.DisintegrationTemperatureC > 0 ? p.DisintegrationTemperatureC : 37; Dissolution1TimeMin = p.Dissolution1TimeMin > 0 ? p.Dissolution1TimeMin : 30; Dissolution2TimeMin = p.Dissolution2TimeMin > 0 ? p.Dissolution2TimeMin : 30; Dissolution1SampleIntervalMin = p.Dissolution1SampleIntervalMin > 0 ? p.Dissolution1SampleIntervalMin : 5; Dissolution2SampleIntervalMin = p.Dissolution2SampleIntervalMin > 0 ? p.Dissolution2SampleIntervalMin : 5; DissolutionMinPercentAt30Min = p.DissolutionMinPercentAt30min; DissolutionSampleInterval = ToCompatibleSampleInterval(Dissolution1SampleIntervalMin); int seconds = p.DisintegrationMaxSeconds > 0 ? p.DisintegrationMaxSeconds : ResolveDisintegrationLimitSeconds(); if (seconds > 0) DisintegrationTimeMin = seconds / 60.0; } finally { _isLoadingDisintegrationSpeed = false; _isLoadingDisintegrationTime = false; _isLoadingDissolution1Time = false; _isLoadingDissolution2Time = false; _isLoadingDissolution1SampleInterval = false; _isLoadingDissolution2SampleInterval = false; _isLoadingFriabilityRounds = false; } _ = WriteFriabilityRoundsAsync(FriabilityTargetRounds); _ = LoadMediumTemperatureAsync(); } private void LoadPharmaDefaults() { ApplyPharmaDefaults(); } private async Task PrintReport(string testName) { await App.Current.Dispatcher.InvokeAsync(() => { MessageBox.Show($"打印{testName}报告", "打印", MessageBoxButton.OK, MessageBoxImage.Information); }); } public async Task UpdateRealTimeData() { try { await LoadMediumTemperatureAsync(); if (!IsAnyTestRunning()) return; if (_isDisintegrationRunning) { IsBasketMovingUp = await _plc.ReadCoilAsync(_plcConfig.DisintegrationMovingUpCoil); for (int i = 0; i < _plcConfig.DisintegrationCompleteCoils.Length; i++) { bool completed = await _plc.ReadCoilAsync(_plcConfig.DisintegrationCompleteCoils[i]); if (completed && !TubesCompleted[i]) { TubesCompleted[i] = true; RemainingTubes = TubesCompleted.Length - TubesCompleted.Count(c => c); } } } if (IsAnyDissolutionRunning()) await UpdateDissolutionDataAsync(); } catch { } } private async Task UpdateDissolutionDataAsync() { DissolutionRpm = await _plc.ReadFloatAsync(_plcConfig.DissolutionRpm); if (_plcConfig.DisintegrationTemp != 0) DisintegrationTemp = await _plc.ReadFloatAsync(_plcConfig.DisintegrationTemp); if (_isDissolution1Running) { if (ResolveDissolution1PercentAddress() != 0) await ReadDissolutionChannelAsync(1); await CheckDissolutionSampleAsync(1); } if (_isDissolution2Running) { if (_plcConfig.Dissolution2Percent != 0) await ReadDissolutionChannelAsync(2); await CheckDissolutionSampleAsync(2); } UpdateDissolutionClock(); } private async Task CheckDissolutionSampleAsync(int channel) { if (IsDissolutionSampleRequestActive(channel) || IsDissolutionSamplePromptOpen(channel)) return; AccumulateDissolutionRunTime(channel, DateTime.Now); var nextSample = GetNextPendingDissolutionSample(channel); if (nextSample == null || GetDissolutionElapsedMinutes(channel) + 0.0001 < nextSample.ScheduledTimeMin) return; SetDissolutionSampleRequestActive(channel, true); SetDissolutionSamplePromptOpen(channel, true); PauseDissolutionRunClock(channel); try { await PulseCoilAsync(ResolveDissolutionStopCoil(channel)); double percent = await ShowDissolutionSampleDialogAsync(channel); RecordDissolutionSample(channel, percent); SetDissolutionSampleRequestActive(channel, false); LocalAlarm = $"溶出{channel}已记录取样结果"; DissolutionCurveStatus = ""; if (IsDissolutionChannelComplete(channel)) { await FinalizeDissolutionChannelAsync(channel); SetDissolutionRunning(channel, false); ResetDissolutionSampleState(channel); RefreshOverallPhase(); UpdateDissolutionClock(); } else { await PulseCoilAsync(ResolveDissolutionStartCoil(channel)); ResumeDissolutionRunClock(channel); } } catch (Exception ex) { SetDissolutionSampleRequestActive(channel, false); SetDissolutionRunning(channel, false); RefreshOverallPhase(); UpdateDissolutionClock(); await App.Current.Dispatcher.InvokeAsync(() => MessageBox.Show($"溶出{channel}取样确认失败:{ex.Message}", "取样确认失败", MessageBoxButton.OK, MessageBoxImage.Error)); } finally { SetDissolutionSamplePromptOpen(channel, false); } } private bool IsDissolutionSampleRequestActive(int channel) { return channel == 1 ? _dissolution1SampleRequestActive : _dissolution2SampleRequestActive; } private void SetDissolutionSampleRequestActive(int channel, bool value) { if (channel == 1) _dissolution1SampleRequestActive = value; else _dissolution2SampleRequestActive = value; } private bool IsDissolutionSamplePromptOpen(int channel) { return channel == 1 ? _isDissolution1SamplePromptOpen : _isDissolution2SamplePromptOpen; } private void SetDissolutionSamplePromptOpen(int channel, bool value) { if (channel == 1) _isDissolution1SamplePromptOpen = value; else _isDissolution2SamplePromptOpen = value; } private ushort ResolveDissolutionStartCoil(int channel) { return channel == 1 ? _plcConfig.Dissolution1StartCoil : _plcConfig.Dissolution2StartCoil; } private ushort ResolveDissolutionStopCoil(int channel) { return channel == 1 ? _plcConfig.Dissolution1StopCoil : _plcConfig.Dissolution2StopCoil; } private bool IsAnyDissolutionRunning() { return _isDissolution1Running || _isDissolution2Running; } private bool IsAnyTestRunning() { return _isHardnessRunning || _isFriabilityRunning || _isDisintegrationRunning || IsAnyDissolutionRunning(); } private void RefreshOverallPhase() { Phase = IsAnyTestRunning() ? TestPhase.Running : TestPhase.Idle; } private void SetDissolutionRunning(int channel, bool value) { if (channel == 1) _isDissolution1Running = value; else _isDissolution2Running = value; RefreshOverallPhase(); } private void ResetDissolutionRunClock(int channel) { if (channel == 1) { _dissolution1ElapsedRunMinutes = 0; _dissolution1LastRunUpdate = DateTime.MinValue; Dissolution1ElapsedTime = 0; Dissolution1Countdown = 0; } else { _dissolution2ElapsedRunMinutes = 0; _dissolution2LastRunUpdate = DateTime.MinValue; Dissolution2ElapsedTime = 0; Dissolution2Countdown = 0; } UpdateDissolutionClock(); } private void ResumeDissolutionRunClock(int channel) { if (channel == 1) _dissolution1LastRunUpdate = DateTime.Now; else _dissolution2LastRunUpdate = DateTime.Now; UpdateDissolutionClock(); } private void PauseDissolutionRunClock(int channel) { AccumulateDissolutionRunTime(channel, DateTime.Now); if (channel == 1) _dissolution1LastRunUpdate = DateTime.MinValue; else _dissolution2LastRunUpdate = DateTime.MinValue; UpdateDissolutionClock(); } private void AccumulateDissolutionRunTime(int channel, DateTime now) { if (channel == 1) { if (_isDissolution1Running && _dissolution1LastRunUpdate != DateTime.MinValue) { _dissolution1ElapsedRunMinutes = Math.Max(0, _dissolution1ElapsedRunMinutes + (now - _dissolution1LastRunUpdate).TotalMinutes); _dissolution1LastRunUpdate = now; } } else { if (_isDissolution2Running && _dissolution2LastRunUpdate != DateTime.MinValue) { _dissolution2ElapsedRunMinutes = Math.Max(0, _dissolution2ElapsedRunMinutes + (now - _dissolution2LastRunUpdate).TotalMinutes); _dissolution2LastRunUpdate = now; } } } private bool IsDissolutionChannelComplete(int channel) { return GetNextPendingDissolutionSample(channel) == null || GetDissolutionElapsedMinutes(channel) + 0.0001 >= GetDissolutionDurationMinutes(channel); } private async Task ShowDissolutionSampleDialogAsync(int channel) { double? result = await App.Current.Dispatcher.InvokeAsync(() => { var nextSample = GetNextPendingDissolutionSample(channel); double elapsed = GetDissolutionElapsedMinutes(channel); string plannedText = nextSample == null ? "未找到待记录取样点" : $"计划取样时间:{nextSample.ScheduledTimeMin:0.##} min"; var dialog = new Window { Title = $"溶出{channel}取样", Width = 460, SizeToContent = SizeToContent.Height, WindowStartupLocation = WindowStartupLocation.CenterOwner, ResizeMode = ResizeMode.NoResize, Background = Brushes.White, Owner = Application.Current.MainWindow }; var panel = new StackPanel { Margin = new Thickness(24) }; panel.Children.Add(new TextBlock { Text = $"请人工采集样品并完成分析,录入该时间点的溶出度(%)。\n{plannedText}\n当前运行时间:{elapsed:0.##} min", FontSize = 16, Foreground = new SolidColorBrush(Color.FromRgb(16, 42, 67)), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 14) }); panel.Children.Add(new TextBlock { Text = "溶出度(%)", FontSize = 14, FontWeight = System.Windows.FontWeights.SemiBold, Foreground = new SolidColorBrush(Color.FromRgb(51, 65, 85)), Margin = new Thickness(0, 0, 0, 6) }); var input = new TextBox { Height = 42, FontSize = 18, Padding = new Thickness(10, 5, 10, 5), Text = nextSample?.Percent?.ToString("0.###") ?? "", Margin = new Thickness(0, 0, 0, 18) }; NumericInput.SetIsEnabled(input, true); NumericInput.SetAllowDecimal(input, true); NumericInput.SetAllowNegative(input, false); panel.Children.Add(input); var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = System.Windows.HorizontalAlignment.Right }; var cancelButton = new Button { Content = "取消", Width = 96, Height = 38, Margin = new Thickness(0, 0, 10, 0), Background = Brushes.White, Foreground = new SolidColorBrush(Color.FromRgb(51, 65, 85)), BorderBrush = new SolidColorBrush(Color.FromRgb(203, 213, 225)), BorderThickness = new Thickness(1), FontSize = 15, FontWeight = System.Windows.FontWeights.SemiBold, Cursor = System.Windows.Input.Cursors.Hand }; cancelButton.Click += (_, _) => { dialog.DialogResult = false; dialog.Close(); }; var confirmButton = new Button { Content = "记录取样", Width = 112, Height = 38, Background = new SolidColorBrush(Color.FromRgb(21, 101, 169)), Foreground = Brushes.White, BorderThickness = new Thickness(0), FontSize = 15, FontWeight = System.Windows.FontWeights.SemiBold, Cursor = System.Windows.Input.Cursors.Hand }; confirmButton.Click += (_, _) => { if (!double.TryParse(input.Text, out double value) || !IsValidDissolutionPercent(value)) { MessageBox.Show("请输入0-150之间的溶出度百分比。", "取样结果无效", MessageBoxButton.OK, MessageBoxImage.Warning); return; } input.Tag = value; dialog.DialogResult = true; dialog.Close(); }; buttons.Children.Add(cancelButton); buttons.Children.Add(confirmButton); panel.Children.Add(buttons); dialog.Content = panel; return dialog.ShowDialog() == true && input.Tag is double value ? value : null; }); if (!result.HasValue) throw new InvalidOperationException("取样结果未录入"); return result.Value; } private async Task ReadDissolutionChannelAsync(int channel) { ushort registerAddress = channel == 1 ? ResolveDissolution1PercentAddress() : _plcConfig.Dissolution2Percent; if (registerAddress == 0) { DissolutionCurveStatus = $"溶出{channel}溶出度寄存器未配置"; return false; } double value = await _plc.ReadFloatAsync(registerAddress); if (!IsValidDissolutionPercent(value)) { DissolutionCurveStatus = $"溶出{channel}溶出度数据异常"; return false; } DateTime startTime = channel == 1 ? _dissolution1StartTime : _dissolution2StartTime; if (startTime == DateTime.MinValue) return false; if (channel == 1) { Dissolution1Percent = value; DissolutionPercent = value; } else { Dissolution2Percent = value; DissolutionPercent = value; } DissolutionCurveStatus = ""; return true; } private ushort ResolveDissolution1PercentAddress() { return _plcConfig.Dissolution1Percent != 0 ? _plcConfig.Dissolution1Percent : _plcConfig.DissolutionPercent; } private static bool IsValidDissolutionPercent(double value) { return double.IsFinite(value) && value >= 0 && value <= 150; } private void CreateDissolutionSampleSchedule(int channel) { RemoveDissolutionSamples(channel); foreach (double minute in ResolveDissolutionSampleTimes(channel)) { DissolutionSamplePoints.Add(new DissolutionSamplePoint { Channel = channel, ScheduledTimeMin = minute }); } } private List ResolveDissolutionSampleTimes(int channel) { return GenerateIntervalSampleTimes( GetDissolutionDurationMinutes(channel), GetDissolutionIntervalMinutes(channel)); } private double GetDissolutionDurationMinutes(int channel) { return Math.Max(1, channel == 1 ? Dissolution1TimeMin : Dissolution2TimeMin); } private double GetDissolutionIntervalMinutes(int channel) { double intervalMin = channel == 1 ? Dissolution1SampleIntervalMin : Dissolution2SampleIntervalMin; return double.IsFinite(intervalMin) && intervalMin > 0 ? intervalMin : 5; } private static List GenerateIntervalSampleTimes(double durationMin, double intervalMin) { if (!double.IsFinite(intervalMin) || intervalMin <= 0) intervalMin = 5; if (!double.IsFinite(durationMin) || durationMin <= 0) durationMin = 1; var times = new List(); for (double time = intervalMin; time <= durationMin + 0.0001; time += intervalMin) times.Add(Math.Min(time, durationMin)); if (times.Count == 0 || Math.Abs(times[^1] - durationMin) > 0.0001) times.Add(durationMin); return times; } private void RemoveDissolutionSamples(int channel) { for (int i = DissolutionSamplePoints.Count - 1; i >= 0; i--) { if (DissolutionSamplePoints[i].Channel == channel) DissolutionSamplePoints.RemoveAt(i); } } private DissolutionSamplePoint? GetNextPendingDissolutionSample(int channel) { return DissolutionSamplePoints .Where(s => s.Channel == channel && !s.Percent.HasValue) .OrderBy(s => s.ScheduledTimeMin) .FirstOrDefault(); } private double GetDissolutionElapsedMinutes(int channel) { return channel == 1 ? _dissolution1ElapsedRunMinutes : _dissolution2ElapsedRunMinutes; } private void RecordDissolutionSample(int channel, double percent) { var sample = GetNextPendingDissolutionSample(channel) ?? new DissolutionSamplePoint { Channel = channel, ScheduledTimeMin = GetDissolutionElapsedMinutes(channel) }; if (!DissolutionSamplePoints.Contains(sample)) DissolutionSamplePoints.Add(sample); sample.ActualTimeMin = GetDissolutionElapsedMinutes(channel); sample.Percent = percent; sample.RecordedAt = DateTime.Now; RefreshDissolutionSeries(channel); } private void RefreshDissolutionSeries(int channel) { var times = channel == 1 ? _dissolution1Times : _dissolution2Times; var values = channel == 1 ? _dissolution1Values : _dissolution2Values; var series = channel == 1 ? _dissolution1Series : _dissolution2Series; var recordedSamples = DissolutionSamplePoints .Where(s => s.Channel == channel && s.Percent.HasValue) .OrderBy(s => s.ScheduledTimeMin) .ToList(); times.Clear(); values.Clear(); series.Points.Clear(); foreach (var sample in recordedSamples) { double time = sample.ScheduledTimeMin; double value = sample.Percent!.Value; times.Add(time); values.Add(value); series.Points.Add(new DataPoint(time, value)); } double rsquared = CalculateRSquared(times, values); double latestValue = recordedSamples.LastOrDefault()?.Percent ?? 0; if (channel == 1) { Dissolution1Percent = latestValue; Dissolution1RSquared = rsquared; } else { Dissolution2Percent = latestValue; Dissolution2RSquared = rsquared; } DissolutionPercent = latestValue; DissolutionRSquared = rsquared; DissolutionPlotModel.InvalidatePlot(true); } private void UpdateDissolutionClock() { var now = DateTime.Now; AccumulateDissolutionRunTime(1, now); AccumulateDissolutionRunTime(2, now); double elapsed1 = _dissolution1ElapsedRunMinutes; double elapsed2 = _dissolution2ElapsedRunMinutes; Dissolution1ElapsedTime = elapsed1; Dissolution2ElapsedTime = elapsed2; DissolutionElapsedTime = Math.Max(elapsed1, elapsed2); Dissolution1Countdown = ResolveDissolutionCountdown(1); Dissolution2Countdown = ResolveDissolutionCountdown(2); var remaining = new List(); if (_isDissolution1Running) remaining.Add(Dissolution1Countdown); if (_isDissolution2Running) remaining.Add(Dissolution2Countdown); DissolutionCountdown = remaining.Count == 0 ? 0 : remaining.Min(); } private double ResolveDissolutionCountdown(int channel) { if (channel == 1 && !_isDissolution1Running) return 0; if (channel == 2 && !_isDissolution2Running) return 0; double elapsed = GetDissolutionElapsedMinutes(channel); double totalRemaining = Math.Max(0, GetDissolutionDurationMinutes(channel) - elapsed); var nextSample = GetNextPendingDissolutionSample(channel); if (nextSample == null) return totalRemaining; return Math.Max(0, Math.Min(nextSample.ScheduledTimeMin - elapsed, totalRemaining)); } partial void OnDissolution1TimeMinChanged(int value) { if (_isLoadingDissolution1Time || _plcConfig.Dissolution1Time == 0 || value <= 0) return; _ = WriteDissolutionTimeAsync(_plcConfig.Dissolution1Time, value); } partial void OnDissolution2TimeMinChanged(int value) { if (_isLoadingDissolution2Time || _plcConfig.Dissolution2Time == 0 || value <= 0) return; _ = WriteDissolutionTimeAsync(_plcConfig.Dissolution2Time, value); } partial void OnDissolution1SampleIntervalMinChanged(double value) { if (_isLoadingDissolution1SampleInterval || _plcConfig.Dissolution1SampleInterval == 0 || value <= 0) return; _ = WriteDissolutionSampleIntervalAsync(_plcConfig.Dissolution1SampleInterval, value); DissolutionSampleInterval = ToCompatibleSampleInterval(value); } partial void OnDissolution2SampleIntervalMinChanged(double value) { if (_isLoadingDissolution2SampleInterval || _plcConfig.Dissolution2SampleInterval == 0 || value <= 0) return; _ = WriteDissolutionSampleIntervalAsync(_plcConfig.Dissolution2SampleInterval, value); } private async Task LoadDissolutionTimesAsync() { if (_plcConfig.Dissolution1Time != 0) { try { _isLoadingDissolution1Time = true; int value = await _plc.ReadIntAsync(_plcConfig.Dissolution1Time); if (value > 0) Dissolution1TimeMin = value; } catch { } finally { _isLoadingDissolution1Time = false; } } if (_plcConfig.Dissolution2Time != 0) { try { _isLoadingDissolution2Time = true; int value = await _plc.ReadIntAsync(_plcConfig.Dissolution2Time); if (value > 0) Dissolution2TimeMin = value; } catch { } finally { _isLoadingDissolution2Time = false; } } await LoadDissolutionSampleIntervalsAsync(); } private async Task WriteDissolutionTimeAsync(ushort registerAddress, int value) { if (registerAddress == 0 || value <= 0) return; try { await _plc.WriteRegisterAsync(registerAddress, (ushort)Math.Min(value, ushort.MaxValue)); } catch { } } private async Task LoadDissolutionSampleIntervalsAsync() { if (_plcConfig.Dissolution1SampleInterval != 0) { try { _isLoadingDissolution1SampleInterval = true; double value = await _plc.ReadFloatAsync(_plcConfig.Dissolution1SampleInterval); if (value > 0) { Dissolution1SampleIntervalMin = value; DissolutionSampleInterval = ToCompatibleSampleInterval(value); } } catch { } finally { _isLoadingDissolution1SampleInterval = false; } } if (_plcConfig.Dissolution2SampleInterval != 0) { try { _isLoadingDissolution2SampleInterval = true; double value = await _plc.ReadFloatAsync(_plcConfig.Dissolution2SampleInterval); if (value > 0) Dissolution2SampleIntervalMin = value; } catch { } finally { _isLoadingDissolution2SampleInterval = false; } } } private async Task WriteDissolutionSampleIntervalAsync(ushort registerAddress, double value) { if (registerAddress == 0 || value <= 0 || !double.IsFinite(value)) return; try { await _plc.WriteFloatAsync(registerAddress, (float)value); } catch { } } private static int ToCompatibleSampleInterval(double value) { if (!double.IsFinite(value) || value <= 0) return 0; return (int)Math.Min(int.MaxValue, Math.Round(value, MidpointRounding.AwayFromZero)); } partial void OnWeightBeforeChanged(double value) { if (_isUpdatingFriabilityWeightFromPlc) return; ApplyFriabilityLossFromWeights(); _ = WriteFriabilityWeightAsync(ResolveFriabilityWeightBeforeRegister(), value); } partial void OnWeightAfterChanged(double value) { if (_isUpdatingFriabilityWeightFromPlc) return; ApplyFriabilityLossFromWeights(); _ = WriteFriabilityWeightAsync(ResolveFriabilityWeightAfterRegister(), value); } partial void OnFriabilityTargetTimeMinChanged(double value) { UpdateFriabilityTimingFromTime(); } partial void OnFriabilityTargetRpmChanged(double value) { } partial void OnFriabilityTargetRoundsChanged(int value) { if (value > 0 && !_isFriabilityRunning) FriabilityRemainingRounds = value; if (_isLoadingFriabilityRounds || value <= 0) return; _ = WriteFriabilityRoundsAsync(value); } private void UpdateFriabilityTimingFromTime() { if (!double.IsFinite(FriabilityTargetTimeMin) || FriabilityTargetTimeMin <= 0) return; FriabilityTargetTimeSec = (int)Math.Ceiling(FriabilityTargetTimeMin * 60); if (!_isFriabilityRunning) FriabilityRemainingRounds = FriabilityTargetRounds; } private async Task LoadFriabilitySettingsAsync() { await LoadFriabilityRoundsAsync(); await LoadFriabilityWeightsAsync(); ApplyFriabilityLossFromWeights(); await TryRefreshFriabilityLossPercentFromPlcAsync(); } private async Task LoadFriabilityRoundsAsync() { ushort registerAddress = ResolveFriabilityRoundsRegister(); if (registerAddress == 0) return; try { _isLoadingFriabilityRounds = true; int value = await _plc.ReadIntAsync(registerAddress); if (value > 0) { ApplyFriabilityRoundsFromPlc(value); } else { await WriteFriabilityRoundsAsync(FriabilityTargetRounds); } } catch { try { await WriteFriabilityRoundsAsync(FriabilityTargetRounds); } catch { } } finally { _isLoadingFriabilityRounds = false; } } private void ApplyFriabilityRoundsFromPlc(int rounds) { FriabilityTargetRounds = Math.Max(1, rounds); if (!_isFriabilityRunning) FriabilityRemainingRounds = FriabilityTargetRounds; } private async Task LoadFriabilityWeightsAsync() { try { _isUpdatingFriabilityWeightFromPlc = true; ushort beforeRegister = ResolveFriabilityWeightBeforeRegister(); if (beforeRegister != 0) { double before = await ReadFriabilityWeightAsync(beforeRegister, "脆碎前重量"); WeightBefore = before; } ushort afterRegister = ResolveFriabilityWeightAfterRegister(); if (afterRegister != 0) { double after = await ReadFriabilityWeightAsync(afterRegister, "脆碎后重量"); WeightAfter = after; } } catch { } finally { _isUpdatingFriabilityWeightFromPlc = false; } } private async Task WriteFriabilityWeightAsync(ushort registerAddress, double value) { if (registerAddress == 0 || !double.IsFinite(value) || value < 0) return; try { await _plc.WriteFloatAsync(registerAddress, (float)value); } catch { } } private void ApplyFriabilityLossFromWeights() { if (!TryCalculateFriabilityLossFromWeights(out double lossPercent)) return; LossPercent = lossPercent; FriabilityPass = LossPercent <= FriabilityMaxLossPercent; } private bool TryCalculateFriabilityLossFromWeights(out double lossPercent) { try { lossPercent = TestCalculationService.CalculateFriabilityLossPercent(WeightBefore, WeightAfter); return true; } catch { lossPercent = 0; return false; } } private async Task TryRefreshFriabilityLossPercentFromPlcAsync() { ushort registerAddress = ResolveFriabilityLossPercentRegister(); if (registerAddress == 0) return false; try { double lossPercent = await _plc.ReadFloatAsync(registerAddress); if (!double.IsFinite(lossPercent) || lossPercent < 0 || lossPercent > 100) return false; LossPercent = lossPercent; FriabilityPass = LossPercent <= FriabilityMaxLossPercent; return true; } catch { return false; } } private async Task WriteFriabilityRoundsAsync(int value) { ushort registerAddress = ResolveFriabilityRoundsRegister(); if (registerAddress == 0 || value <= 0) return; try { await _plc.WriteRegisterAsync(registerAddress, (ushort)Math.Clamp(value, 0, ushort.MaxValue)); } catch { } } private ushort ResolveFriabilityRoundsRegister() { if (_plcConfig.FriabilityRounds != 0) return _plcConfig.FriabilityRounds; if (_plcConfig.FriabilityRoundsBox != 0) return _plcConfig.FriabilityRoundsBox; return _plcConfig.FriabilityTestTime != 0 ? _plcConfig.FriabilityTestTime : (ushort)410; } private ushort ResolveFriabilityWeightBeforeRegister() { return _plcConfig.FriabilityWeightBefore != 0 ? _plcConfig.FriabilityWeightBefore : _plcConfig.WeightBefore; } private ushort ResolveFriabilityWeightAfterRegister() { return _plcConfig.FriabilityWeightAfter != 0 ? _plcConfig.FriabilityWeightAfter : _plcConfig.WeightAfter; } private ushort ResolveFriabilityLossPercentRegister() { return _plcConfig.FriabilityLossPercent != 0 ? _plcConfig.FriabilityLossPercent : (ushort)416; } private void SetFriabilityWeightFromPlc(double? weightBefore = null, double? weightAfter = null) { _isUpdatingFriabilityWeightFromPlc = true; try { if (weightBefore.HasValue) WeightBefore = weightBefore.Value; if (weightAfter.HasValue) WeightAfter = weightAfter.Value; } finally { _isUpdatingFriabilityWeightFromPlc = false; } } partial void OnDisintegrationTimeMinChanged(double value) { if (_isLoadingDisintegrationTime || _plcConfig.DisintegrationTime == 0 || value <= 0) return; _ = WriteDisintegrationTimeAsync(value); } partial void OnDisintegrationSpeedRpmChanged(double value) { if (_isLoadingDisintegrationSpeed || _plcConfig.DisintegrationSpeed == 0 || value <= 0) return; _ = WriteDisintegrationSpeedAsync(value); } partial void OnDisintegrationDosageFormChanged(string value) { int seconds = ResolveDisintegrationLimitSeconds(value); if (seconds > 0) DisintegrationTimeMin = seconds / 60.0; } partial void OnDisintegrationActualSecondsChanged(double value) { if (CanSaveDisintegrationResult) UpdateDisintegrationPassFromActualTime(); } partial void OnDisintegrationActualSecondsTextChanged(string value) { if (!CanSaveDisintegrationResult) return; if (TryParseDisintegrationActualSeconds(out double seconds, out _)) { DisintegrationActualSeconds = seconds; UpdateDisintegrationPassFromActualTime(); } else { DisintegrationPass = false; } } private int ResolveDisintegrationLimitSeconds(string? dosageForm = null) { string form = string.IsNullOrWhiteSpace(dosageForm) ? DisintegrationDosageForm : dosageForm; if (string.Equals(form, App.CurrentPharmaParams.DisintegrationDosageForm, StringComparison.OrdinalIgnoreCase) && App.CurrentPharmaParams.DisintegrationMaxSeconds > 0) { return App.CurrentPharmaParams.DisintegrationMaxSeconds; } return form switch { "薄膜衣片" => 30 * 60, "糖衣片" => 60 * 60, "胶囊" => 30 * 60, "普通片" => 15 * 60, _ => App.CurrentPharmaParams.DisintegrationMaxSeconds > 0 ? App.CurrentPharmaParams.DisintegrationMaxSeconds : 15 * 60 }; } private async Task LoadDisintegrationSpeedAsync() { if (_plcConfig.DisintegrationSpeed == 0) return; try { _isLoadingDisintegrationSpeed = true; float value = await _plc.ReadFloatAsync(_plcConfig.DisintegrationSpeed); if (value > 0) DisintegrationSpeedRpm = value; } catch { } finally { _isLoadingDisintegrationSpeed = false; } } private async Task LoadDisintegrationTimeAsync() { if (_plcConfig.DisintegrationTime == 0) return; try { _isLoadingDisintegrationTime = true; int seconds = ResolveDisintegrationLimitSeconds(); if (seconds > 0) DisintegrationTimeMin = seconds / 60.0; await WriteDisintegrationTimeAsync(DisintegrationTimeMin); } catch { } finally { _isLoadingDisintegrationTime = false; } } private async Task LoadMediumTemperatureAsync() { if (_plcConfig.DisintegrationTemp == 0) return; try { double value = await _plc.ReadFloatAsync(_plcConfig.DisintegrationTemp); if (double.IsFinite(value) && value > 0) DisintegrationTemp = value; } catch { } } private async Task WriteDisintegrationTimeAsync(double value) { if (_plcConfig.DisintegrationTime == 0 || value <= 0 || !double.IsFinite(value)) return; try { await _plc.WriteRegisterAsync(_plcConfig.DisintegrationTime, ToPlcTimeRegisterValue(value)); } catch { } } private static ushort ToPlcTimeRegisterValue(double value) { return (ushort)Math.Clamp( (int)Math.Round(value, MidpointRounding.AwayFromZero), 0, ushort.MaxValue); } private async Task WriteDisintegrationSpeedAsync(double value) { if (_plcConfig.DisintegrationSpeed == 0 || value <= 0) return; try { await _plc.WriteFloatAsync(_plcConfig.DisintegrationSpeed, (float)value); } catch { } } private async Task PulseCoilAsync(ushort coilAddress) { if (coilAddress == 0) throw new InvalidOperationException("PLC线圈地址未配置"); await _plc.WriteCoilAsync(coilAddress, true); await Task.Delay(100); await _plc.WriteCoilAsync(coilAddress, false); } private void StartHardnessLiveForcePolling() { _hardnessGlobalTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; _hardnessGlobalTimer.Tick += async (_, _) => { if (_isReadingHardnessLiveForce) return; try { _isReadingHardnessLiveForce = true; await ReadHardnessLiveForceAsync(); } catch { // The footer connection status already reflects PLC availability; avoid popup noise during idle polling. } finally { _isReadingHardnessLiveForce = false; } }; _hardnessGlobalTimer.Start(); } private Task ClearHardnessRecordsAsync() { if (_isHardnessRunning) { MessageBox.Show("硬度测试运行中,不能清空记录。"); return Task.CompletedTask; } ResetCurrentHardnessGroup(); HardnessDisplaySamplePoints.Clear(); HardnessTotalCount = 0; _hardnessGroupNo = 0; _currentHardnessGroupNo = 0; return Task.CompletedTask; } private void StartNewHardnessGroup() { _currentHardnessGroupNo = ++_hardnessGroupNo; ResetCurrentHardnessGroup(); } private void ResetCurrentHardnessGroup() { HardnessPass = false; _hardnessResults.Clear(); HardnessSamplePoints.Clear(); HardnessCurrentCount = 0; HardnessAvg = 0; HardnessAverageDeviation = 0; HardnessRSD = 0; HardnessMax = 0; HardnessMin = 0; } private async Task RunHardnessAsync() { if (_isHardnessRunning) return; CurrentTest = TestType.Hardness; _isHardnessRunning = true; RefreshOverallPhase(); StartNewHardnessGroup(); bool resultReady = false; try { int count = Math.Max(1, HardnessTestCount); ushort startCoil = ResolveHardnessStartCoil(); ushort completeCoil = ResolveHardnessCompleteCoil(); if (startCoil == 0) throw new InvalidOperationException("硬度启动线圈未配置"); if (completeCoil == 0) throw new InvalidOperationException("硬度完成线圈未配置"); if (ResolveHardnessLiveForceRegister() == 0) throw new InvalidOperationException("硬度实时力寄存器未配置"); if (ResolveHardnessMaxRegister() == 0) throw new InvalidOperationException("硬度最大采集力寄存器未配置"); if (ResolveHardnessSpeedRegister() == 0) throw new InvalidOperationException("硬度加压速度寄存器未配置"); await LoadHardnessSpeedSettingAsync(); await _plc.WriteFloatAsync(ResolveHardnessSpeedRegister(), (float)HardnessSudu); await _plc.WriteFloatAsync(_plcConfig.HardnessWeiyi, (float)HardnessWeiyi); while (_isHardnessRunning && _hardnessResults.Count < count) { bool completeWasActiveBeforeStart = await _plc.ReadCoilAsync(completeCoil); await PulseCoilAsync(startCoil); if (completeWasActiveBeforeStart) await WaitForCoilStateAsync(completeCoil, false, TimeSpan.FromSeconds(10), "硬度完成信号未复位"); await WaitForHardnessSampleCompleteAsync(completeCoil); double value = await ReadHardnessMaxCaptureAsync(); AddHardnessSample(value); ApplyHardnessStatistics(count); await TryWaitForCoilStateAsync(completeCoil, false, TimeSpan.FromSeconds(2)); } if (_hardnessResults.Count < count) throw new InvalidOperationException("硬度测试已停止,未保存结果"); ApplyHardnessStatistics(count); AddHardnessGroupSummaryRow(); resultReady = true; } catch (Exception ex) { if (!IsHardnessCompleteResetTimeout(ex)) { await App.Current.Dispatcher.InvokeAsync(() => MessageBox.Show($"硬度测试出错: {ex.Message}")); } } finally { _isHardnessRunning = false; RefreshOverallPhase(); if (resultReady) await SaveBatchResult(TestType.Hardness); } } private ushort ResolveHardnessCompleteCoil() { return _plcConfig.HardnessOver != 0 ? _plcConfig.HardnessOver : _plcConfig.HardnessCompleteCoil; } private ushort ResolveHardnessStartCoil() { return _plcConfig.HardnessStartCoil != 0 ? _plcConfig.HardnessStartCoil : (ushort)70; } private static bool IsHardnessCompleteResetTimeout(Exception ex) { return ex is TimeoutException && string.Equals(ex.Message, "硬度完成信号未复位", StringComparison.Ordinal); } private ushort ResolveHardnessLiveForceRegister() { return _plcConfig.HardnessShishilizhi != 0 ? _plcConfig.HardnessShishilizhi : (ushort)1314; } private ushort ResolveHardnessMaxRegister() { return _plcConfig.HardnessMax != 0 ? _plcConfig.HardnessMax : (ushort)72; } private ushort ResolveHardnessSpeedRegister() { return _plcConfig.HardnessSudu != 0 ? _plcConfig.HardnessSudu : (ushort)300; } private async Task ReadHardnessLiveForceAsync() { double value = await _plc.ReadFloatAsync(ResolveHardnessLiveForceRegister()); if (!double.IsFinite(value) || value < 0) throw new InvalidOperationException("硬度实时力数据异常"); HardnessShishilizhi = value; return value; } private async Task ReadHardnessMaxCaptureAsync() { double value = await _plc.ReadFloatAsync(ResolveHardnessMaxRegister()); if (!double.IsFinite(value) || value < 0) throw new InvalidOperationException("硬度最大采集力数据异常"); HardnessMax = value; return value; } private async Task LoadHardnessSpeedSettingAsync() { double value = await _plc.ReadFloatAsync(ResolveHardnessSpeedRegister()); if (double.IsFinite(value) && value > 0) HardnessSudu = value; } partial void OnIsHardnessResettingChanged(bool value) { OnPropertyChanged(nameof(HardnessResetButtonText)); HardnessResetCommand?.NotifyCanExecuteChanged(); } private bool CanResetHardness() { return !IsHardnessResetting; } private async Task ResetHardnessAsync() { if (IsHardnessResetting) return; IsHardnessResetting = true; try { await PulseCoilAsync(_plcConfig.HardnessStartReset); _isHardnessRunning = false; RefreshOverallPhase(); await WaitForHardnessResetCompleteAsync(ResolveHardnessResetCompleteCoil()); } catch (Exception ex) { await Application.Current.Dispatcher.InvokeAsync(() => MessageBox.Show($"硬度复位出错: {ex.Message}")); } finally { IsHardnessResetting = false; } } private ushort ResolveHardnessResetCompleteCoil() { return _plcConfig.HardnessStartOver; } private async Task WaitForHardnessResetCompleteAsync(ushort completeCoil) { if (completeCoil == 0) throw new InvalidOperationException("硬度复位完成线圈未配置"); DateTime deadline = DateTime.Now.AddSeconds(120); while (DateTime.Now <= deadline) { if (await _plc.ReadCoilAsync(completeCoil)) return; await Task.Delay(100); } throw new TimeoutException("等待硬度复位完成信号超时"); } private async Task WaitForHardnessSampleCompleteAsync(ushort completeCoil) { DateTime deadline = DateTime.Now.AddSeconds(120); while (_isHardnessRunning && DateTime.Now <= deadline) { if (await _plc.ReadCoilAsync(completeCoil)) return; await Task.Delay(100); } if (!_isHardnessRunning) throw new InvalidOperationException("硬度测试已停止,未保存结果"); throw new TimeoutException("等待硬度完成信号超时"); } private void AddHardnessSample(double value) { _hardnessResults.Add(value); int sequenceNo = _hardnessResults.Count; DateTime recordedAt = DateTime.Now; HardnessValue = value; HardnessSamplePoints.Add(new HardnessSamplePoint { SequenceNo = sequenceNo, Value = value, RecordedAt = recordedAt }); int cumulativeNo = HardnessDisplaySamplePoints.Count + 1; HardnessDisplaySamplePoints.Add(new HardnessDisplaySamplePoint { CumulativeNo = cumulativeNo, GroupNo = _currentHardnessGroupNo, SequenceNo = sequenceNo, Value = value, RecordedAt = recordedAt }); HardnessTotalCount = HardnessDisplaySamplePoints.Count; } private async Task WaitForCoilStateAsync(ushort coilAddress, bool expectedState, TimeSpan timeout, string timeoutMessage) { DateTime deadline = DateTime.Now.Add(timeout); while (_isHardnessRunning && DateTime.Now <= deadline) { if (await _plc.ReadCoilAsync(coilAddress) == expectedState) return; await Task.Delay(100); } if (!_isHardnessRunning) throw new InvalidOperationException("硬度测试已停止,未保存结果"); throw new TimeoutException(timeoutMessage); } private async Task TryWaitForCoilStateAsync(ushort coilAddress, bool expectedState, TimeSpan timeout) { DateTime deadline = DateTime.Now.Add(timeout); while (_isHardnessRunning && DateTime.Now <= deadline) { if (await _plc.ReadCoilAsync(coilAddress) == expectedState) return true; await Task.Delay(100); } return false; } private void ApplyHardnessStatistics(int requiredCount) { var stats = TestCalculationService.CalculateHardness( _hardnessResults, HardnessInternalMin, HardnessInternalMax, requiredCount); HardnessAvg = stats.Average; HardnessAverageDeviation = stats.AverageDeviation; HardnessRSD = stats.RsdPercent; HardnessMin = stats.Minimum; HardnessCurrentCount = stats.Count; HardnessPass = stats.IsPass; foreach (var sample in HardnessSamplePoints) sample.DeviationFromAverage = Math.Abs(sample.Value - stats.Average); foreach (var sample in HardnessDisplaySamplePoints.Where(s => s.GroupNo == _currentHardnessGroupNo)) { sample.DeviationFromAverage = Math.Abs(sample.Value - stats.Average); sample.GroupAverage = stats.Average; sample.GroupAverageDeviation = stats.AverageDeviation; sample.GroupRSD = stats.RsdPercent; } } private void AddHardnessGroupSummaryRow() { var stats = TestCalculationService.CalculateHardness( _hardnessResults, HardnessInternalMin, HardnessInternalMax, _hardnessResults.Count); HardnessDisplaySamplePoints.Add(new HardnessDisplaySamplePoint { IsSummaryRow = true, GroupNo = _currentHardnessGroupNo, CumulativeNo = 0, SequenceNo = 0, Value = 0, DeviationFromAverage = 0, GroupAverage = stats.Average, GroupAverageDeviation = stats.AverageDeviation, GroupRSD = stats.RsdPercent, RecordedAt = DateTime.MinValue }); } /// 脆碎度测试主逻辑(实时状态显示) private async Task RunFriabilityAsync() { // 1. 防并发:只阻止脆碎度重复启动,不影响其它测试项目 if (_isFriabilityRunning) return; // 2. 标记当前正在运行的是脆碎度测试 CurrentTest = TestType.Friability; _isFriabilityRunning = true; CanSaveFriabilityResult = false; RefreshOverallPhase(); FriabilityPass = false; bool resultReady = false; try { ushort startCoil = _plcConfig.FriabilityStartCoil; if (startCoil == 0) { throw new InvalidOperationException("未配置脆碎度启动线圈地址"); } if (!double.IsFinite(FriabilityTargetTimeMin) || FriabilityTargetTimeMin <= 0) throw new InvalidOperationException("脆碎试验时间必须大于0"); UpdateFriabilityTimingFromTime(); await WriteFriabilityRoundsAsync(FriabilityTargetRounds); double weightBefore = await ReadFriabilityWeightAsync(ResolveFriabilityWeightBeforeRegister(), "脆碎前重量"); SetFriabilityWeightFromPlc(weightBefore: weightBefore); if (WeightBefore <= 0) throw new InvalidOperationException("脆碎前重量必须大于0"); double rpm = FriabilityTargetRpm > 0 ? FriabilityTargetRpm : 25; int totalRounds = Math.Max(1, FriabilityTargetRounds); FriabilityRemainingRounds = totalRounds; FriabilityCurrentRpm = rpm; await RefreshFriabilityRealtimeRoundsAsync(); await PulseCoilAsync(startCoil); bool targetRoundsReached = false; while (_isFriabilityRunning) { await RefreshFriabilityRealtimeRoundsAsync(); double completedRounds = Math.Max(0, FriabilityRealtimeRounds); FriabilityRemainingRounds = (int)Math.Ceiling(Math.Max(0, totalRounds - completedRounds)); if (completedRounds >= totalRounds) { targetRoundsReached = true; FriabilityRemainingRounds = 0; break; } // 等待100ms,再更新下一次 await Task.Delay(100); } if (!_isFriabilityRunning || !targetRoundsReached) throw new InvalidOperationException("脆碎度测试已停止,未保存结果"); if (_plcConfig.FriabilityStartCoilStop != 0) await PulseCoilAsync(_plcConfig.FriabilityStartCoilStop); double weightAfter = await ReadFriabilityWeightAsync(ResolveFriabilityWeightAfterRegister(), "脆碎后重量"); SetFriabilityWeightFromPlc(weightAfter: weightAfter); FriabilityCurrentRpm = rpm; await RefreshFriabilityRealtimeRoundsAsync(); bool localLossReady = TryCalculateFriabilityLossFromWeights(out double localLossPercent); if (localLossReady) { LossPercent = localLossPercent; FriabilityPass = LossPercent <= FriabilityMaxLossPercent; } bool plcLossReady = await TryRefreshFriabilityLossPercentFromPlcAsync(); if (!localLossReady && !plcLossReady) throw new InvalidOperationException("脆碎度失重率数据异常"); resultReady = true; } catch (Exception ex) { await App.Current.Dispatcher.InvokeAsync(() => MessageBox.Show($"脆碎度测试出错: {ex.Message}")); } finally { if (_plcConfig.FriabilityStartCoil != 0) { try { await _plc.WriteCoilAsync(_plcConfig.FriabilityStartCoil, false); } catch { } } _isFriabilityRunning = false; RefreshOverallPhase(); if (resultReady) { CanSaveFriabilityResult = true; FriabilityRemainingRounds = 0; } else { CanSaveFriabilityResult = false; FriabilityRemainingRounds = FriabilityTargetRounds; } } } private async Task SaveFriabilityResultAsync() { if (!CanSaveFriabilityResult) return; if (!TryCalculateFriabilityLossFromWeights(out double localLossPercent)) { bool plcLossReady = await TryRefreshFriabilityLossPercentFromPlcAsync(); if (!plcLossReady) { await App.Current.Dispatcher.InvokeAsync(() => MessageBox.Show("脆碎度实际数据异常,请确认前重、后重和失重率。", "保存失败", MessageBoxButton.OK, MessageBoxImage.Warning)); return; } } else { LossPercent = localLossPercent; FriabilityPass = LossPercent <= FriabilityMaxLossPercent; } bool saved = await SaveBatchResult(TestType.Friability); if (saved) CanSaveFriabilityResult = false; } private async Task ReadFriabilityWeightAsync(ushort registerAddress, string label) { if (registerAddress == 0) throw new InvalidOperationException($"{label}寄存器未配置"); double value = await _plc.ReadFloatAsync(registerAddress); if (!double.IsFinite(value) || value < 0) throw new InvalidOperationException($"{label}数据异常"); return value; } private ushort ResolveFriabilityRealtimeRoundsRegister() { return _plcConfig.FriabilityRealtimeRounds != 0 ? _plcConfig.FriabilityRealtimeRounds : (ushort)82; } private async Task RefreshFriabilityRealtimeRoundsAsync() { ushort registerAddress = ResolveFriabilityRealtimeRoundsRegister(); if (registerAddress == 0) return; try { double value = await _plc.ReadFloatAsync(registerAddress); if (double.IsFinite(value) && value >= 0) FriabilityRealtimeRounds = value; } catch { // Keep the last displayed realtime count if the PLC read fails once. } } private async Task RunDisintegrationAsync() { if (_isDisintegrationRunning) return; CurrentTest = TestType.Disintegration; _isDisintegrationRunning = true; RefreshOverallPhase(); DisintegrationPass = false; // 添加这一行 int tubeCount = Math.Max(1, _plcConfig.DisintegrationCompleteCoils.Length); TubesCompleted = new bool[tubeCount]; RemainingTubes = tubeCount; DisintegrationSeconds = 0; DisintegrationActualSeconds = 0; CanSaveDisintegrationResult = false; bool resultReady = false; DateTime startedAt = DateTime.Now; _disintegrationTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _disintegrationTimer.Tick += (s, e) => { if (_isDisintegrationRunning) SetDisintegrationElapsedSeconds(Math.Max(0, (int)Math.Floor((DateTime.Now - startedAt).TotalSeconds))); }; _disintegrationTimer.Start(); try { await WriteDisintegrationSpeedAsync(DisintegrationSpeedRpm); await WriteDisintegrationTimeAsync(DisintegrationTimeMin); await PulseCoilAsync(_plcConfig.DisintegrationStartCoil); int maxSec = ResolveDisintegrationLimitSeconds(); DisintegrationTimeMin = maxSec / 60.0; while (RemainingTubes > 0 && DisintegrationSeconds < maxSec && _isDisintegrationRunning) { SetDisintegrationElapsedSeconds(Math.Max(0, (int)Math.Floor((DateTime.Now - startedAt).TotalSeconds))); await Task.Delay(500); } SetDisintegrationElapsedSeconds(Math.Max(0, (int)Math.Floor((DateTime.Now - startedAt).TotalSeconds))); _disintegrationTimer.Stop(); resultReady = _isDisintegrationRunning; } catch (Exception ex) { _disintegrationTimer.Stop(); await App.Current.Dispatcher.InvokeAsync(() => MessageBox.Show($"崩解测试出错: {ex.Message}")); } finally { _isDisintegrationRunning = false; RefreshOverallPhase(); UpdateDisintegrationPassFromActualTime(); CanSaveDisintegrationResult = resultReady && !_discardDisintegrationResult; _discardDisintegrationResult = false; } } private void SetDisintegrationElapsedSeconds(int seconds) { DisintegrationSeconds = seconds; DisintegrationActualSeconds = seconds; DisintegrationActualSecondsText = seconds.ToString("0"); } private void UpdateDisintegrationPassFromActualTime() { int maxSec = ResolveDisintegrationLimitSeconds(); DisintegrationPass = !_discardDisintegrationResult && RemainingTubes == 0 && double.IsFinite(DisintegrationActualSeconds) && DisintegrationActualSeconds >= 0 && DisintegrationActualSeconds <= maxSec; } private bool TryParseDisintegrationActualSeconds(out double seconds, out string message) { string text = DisintegrationActualSecondsText?.Trim() ?? ""; if (string.IsNullOrWhiteSpace(text)) { seconds = 0; message = "实际崩解时间不能为空。"; return false; } if (!double.TryParse(text, out seconds) || !double.IsFinite(seconds) || seconds < 0) { message = "实际崩解时间必须为有效的非负秒数。"; return false; } message = ""; return true; } private async Task SaveDisintegrationResultAsync() { //if (!CanSaveDisintegrationResult) // return; if (!TryParseDisintegrationActualSeconds(out double seconds, out string message)) { await App.Current.Dispatcher.InvokeAsync(() => MessageBox.Show(message, "输入错误", MessageBoxButton.OK, MessageBoxImage.Warning)); return; } DisintegrationActualSeconds = seconds; UpdateDisintegrationPassFromActualTime(); bool saved = await SaveBatchResult(TestType.Disintegration); if (saved) CanSaveDisintegrationResult = false; } private async Task StopDisintegrationAsync() { _discardDisintegrationResult = _isDisintegrationRunning; try { await PulseCoilAsync(_plcConfig.DisintegrationStopCoil); } finally { _isDisintegrationRunning = false; RefreshOverallPhase(); _disintegrationTimer?.Stop(); if (_discardDisintegrationResult) CanSaveDisintegrationResult = false; } } private async Task ResetDisintegrationAsync() { bool wasRunning = _isDisintegrationRunning; _discardDisintegrationResult = wasRunning; try { await PulseCoilAsync(ResolveDisintegrationResetCoil()); } finally { _disintegrationTimer?.Stop(); TubesCompleted = new bool[6]; RemainingTubes = 6; DisintegrationSeconds = 0; DisintegrationActualSeconds = 0; DisintegrationActualSecondsText = "0"; DisintegrationPass = false; CanSaveDisintegrationResult = false; _isDisintegrationRunning = false; RefreshOverallPhase(); if (!wasRunning) _discardDisintegrationResult = false; } } private ushort ResolveDisintegrationResetCoil() { return _plcConfig.DisintegrationResetCoil != 0 ? _plcConfig.DisintegrationResetCoil : (ushort)100; } private async Task StartDissolution1Async() { if (_isDissolution1Running) return; CurrentTest = TestType.Dissolution; DissolutionPass = false; ResetDissolutionChannel(1); ResetDissolutionSampleState(1); ResetDissolutionRunClock(1); CreateDissolutionSampleSchedule(1); _dissolution1StartTime = DateTime.Now; SetDissolutionRunning(1, true); ResumeDissolutionRunClock(1); DissolutionPlotModel.Title = "溶出曲线"; await WriteDissolutionTimeAsync(_plcConfig.Dissolution1Time, Dissolution1TimeMin); await WriteDissolutionSampleIntervalAsync(_plcConfig.Dissolution1SampleInterval, Dissolution1SampleIntervalMin); await PulseCoilAsync(_plcConfig.Dissolution1StartCoil); } private async Task StopDissolution1Async() { try { PauseDissolutionRunClock(1); await PulseCoilAsync(_plcConfig.Dissolution1StopCoil); await FinalizeDissolutionChannelAsync(1); } finally { SetDissolutionRunning(1, false); ResetDissolutionSampleState(1); _dissolution1LastRunUpdate = DateTime.MinValue; RefreshOverallPhase(); UpdateDissolutionClock(); } } private async Task ResetDissolution1Async() { try { await PulseCoilAsync(_plcConfig.Dissolution1ResetCoil); } finally { SetDissolutionRunning(1, false); ResetDissolutionChannel(1); ResetDissolutionSampleState(1); ResetDissolutionRunClock(1); RefreshOverallPhase(); UpdateDissolutionClock(); } } private async Task StartDissolution2Async() { if (_isDissolution2Running) return; CurrentTest = TestType.Dissolution; DissolutionPass = false; ResetDissolutionChannel(2); ResetDissolutionSampleState(2); ResetDissolutionRunClock(2); CreateDissolutionSampleSchedule(2); _dissolution2StartTime = DateTime.Now; SetDissolutionRunning(2, true); ResumeDissolutionRunClock(2); DissolutionPlotModel.Title = "溶出曲线"; await WriteDissolutionTimeAsync(_plcConfig.Dissolution2Time, Dissolution2TimeMin); await WriteDissolutionSampleIntervalAsync(_plcConfig.Dissolution2SampleInterval, Dissolution2SampleIntervalMin); await PulseCoilAsync(_plcConfig.Dissolution2StartCoil); } private async Task StopDissolution2Async() { try { PauseDissolutionRunClock(2); await PulseCoilAsync(_plcConfig.Dissolution2StopCoil); await FinalizeDissolutionChannelAsync(2); } finally { SetDissolutionRunning(2, false); ResetDissolutionSampleState(2); _dissolution2LastRunUpdate = DateTime.MinValue; RefreshOverallPhase(); UpdateDissolutionClock(); } } private async Task ResetDissolution2Async() { try { await PulseCoilAsync(_plcConfig.Dissolution2ResetCoil); } finally { SetDissolutionRunning(2, false); ResetDissolutionChannel(2); ResetDissolutionSampleState(2); ResetDissolutionRunClock(2); RefreshOverallPhase(); UpdateDissolutionClock(); } } private void ResetDissolutionChannel(int channel) { RemoveDissolutionSamples(channel); if (channel == 1) { _dissolution1Times.Clear(); _dissolution1Values.Clear(); _dissolution1Series.Points.Clear(); _dissolution1StartTime = DateTime.MinValue; Dissolution1Percent = 0; Dissolution1RSquared = 0; } else { _dissolution2Times.Clear(); _dissolution2Values.Clear(); _dissolution2Series.Points.Clear(); _dissolution2StartTime = DateTime.MinValue; Dissolution2Percent = 0; Dissolution2RSquared = 0; } DissolutionCurveStatus = ""; DissolutionPlotModel.InvalidatePlot(true); } private void ResetDissolutionSampleState(int channel) { SetDissolutionSampleRequestActive(channel, false); SetDissolutionSamplePromptOpen(channel, false); } private async Task FinalizeDissolutionChannelAsync(int channel) { RefreshDissolutionSeries(channel); var times = channel == 1 ? _dissolution1Times : _dissolution2Times; var values = channel == 1 ? _dissolution1Values : _dissolution2Values; if (values.Count == 0) { DissolutionCurveStatus = $"溶出{channel}无有效曲线数据,未保存结果"; LocalAlarm = DissolutionCurveStatus; return; } _dissolutionResultChannel = $"溶出{channel}"; if (!TestCalculationService.TryGetDissolutionRateAt30Min(times, values, out double rate30Min)) { DissolutionCurveStatus = $"溶出{channel}缺少有效30min溶出度,未保存结果"; LocalAlarm = DissolutionCurveStatus; return; } double rsquared = CalculateRSquared(times, values); _dissolutionResultRate30Min = rate30Min; _dissolutionResultRSquared = rsquared; DissolutionPercent = rate30Min; DissolutionRSquared = rsquared; if (channel == 1) Dissolution1RSquared = rsquared; else Dissolution2RSquared = rsquared; DissolutionPass = rate30Min >= App.CurrentPharmaParams.DissolutionMinPercentAt30min; await SaveBatchResult(TestType.Dissolution, _dissolutionResultChannel, rate30Min, rsquared); } private async Task RunDissolutionAsync() { await StartDissolution1Async(); } private async Task SaveBatchResult( TestType testType, string dissolutionChannel = "", double? dissolutionRate30MinOverride = null, double? dissolutionRSquaredOverride = null, bool? forcedQualified = null) { try { double dissolutionRate30Min = testType == TestType.Dissolution ? dissolutionRate30MinOverride ?? _dissolutionResultRate30Min : DissolutionPercent; double rsquared = testType == TestType.Dissolution ? dissolutionRSquaredOverride ?? _dissolutionResultRSquared : DissolutionRSquared; string effectiveDissolutionChannel = testType == TestType.Dissolution ? dissolutionChannel : ""; double batchHardnessMax = testType == TestType.Hardness ? _hardnessResults .Where(value => double.IsFinite(value) && value > 0) .DefaultIfEmpty(HardnessMax) .Max() : HardnessMax; var batch = new TestBatch { TestTime = DateTime.Now, StationId = StationId, SampleName = "样品", // 硬度 HardnessAvg = HardnessAvg, HardnessAverageDeviation = HardnessAverageDeviation, HardnessRSD = HardnessRSD, HardnessMax = batchHardnessMax, HardnessMin = HardnessMin, HardnessTestCount = HardnessTestCount, HardnessInternalMin = HardnessInternalMin, HardnessInternalMax = HardnessInternalMax, // 脆碎度 FriabilityLoss = LossPercent, FriabilityTargetRpm = FriabilityTargetRpm, FriabilityTargetTimeSec = FriabilityTargetTimeSec, FriabilityTargetRounds = FriabilityTargetRounds, FriabilityClockwise = FriabilityClockwise, FriabilityRemainingRounds = FriabilityRemainingRounds, WeightBefore = WeightBefore, WeightAfter = WeightAfter, // 崩解 DisintegrationTimeSec = testType == TestType.Disintegration ? DisintegrationActualSeconds : DisintegrationSeconds, RemainingTubesAtEnd = RemainingTubes, DisintegrationTargetFreq = DisintegrationSpeedRpm, DisintegrationTemp = DisintegrationTemp, DisintegrationDosageForm = DisintegrationDosageForm, DisintegrationLimitSeconds = ResolveDisintegrationLimitSeconds(), // 溶出 DissolutionChannel = effectiveDissolutionChannel, DissolutionRate30Min = dissolutionRate30Min, DissolutionTargetRpm = DissolutionTargetRpm, DissolutionRSquared = rsquared, DissolutionSampleInterval = testType == TestType.Dissolution && effectiveDissolutionChannel == "溶出2" ? ToCompatibleSampleInterval(Dissolution2SampleIntervalMin) : ToCompatibleSampleInterval(Dissolution1SampleIntervalMin), Dissolution1SampleInterval = Dissolution1SampleIntervalMin, Dissolution2SampleInterval = Dissolution2SampleIntervalMin, DissolutionUpDownFreq = DissolutionUpDownFreq, HardnessPass = HardnessPass, FriabilityPass = FriabilityPass, DisintegrationPass = DisintegrationPass, DissolutionPass = DissolutionPass, TestType = testType switch { TestType.Hardness => "硬度", TestType.Friability => "脆碎度", TestType.Disintegration => "崩解", TestType.Dissolution => "溶出", _ => "" }, IsQualified = forcedQualified ?? TestCalculationService.ResolveCurrentTestQualified( testType, HardnessPass, FriabilityPass, DisintegrationPass, DissolutionPass) }; var dissolutionSamples = testType == TestType.Dissolution ? DissolutionSamplePoints .Where(s => s.ChannelName == effectiveDissolutionChannel && s.Percent.HasValue) .ToList() : new List(); var hardnessSamples = testType == TestType.Hardness ? HardnessSamplePoints.ToList() : new List(); await Task.Run(() => _db.InsertBatch(batch, dissolutionSamples, hardnessSamples)); await Application.Current.Dispatcher.InvokeAsync(() => { string projectName = testType switch { TestType.Hardness => "硬度", TestType.Friability => "脆碎度", TestType.Disintegration => "崩解", TestType.Dissolution => string.IsNullOrWhiteSpace(effectiveDissolutionChannel) ? "溶出" : effectiveDissolutionChannel, _ => "" }; LocalAlarm = $"{projectName}测试完成,已保存"; }); return true; } catch (Exception ex) { await App.Current.Dispatcher.InvokeAsync(() => MessageBox.Show($"保存测试结果失败:{ex.Message}")); return false; } } private double CalculateRSquared(List timeMinutes, List concentration) { return TestCalculationService.CalculateRSquared(timeMinutes, concentration); } private async Task ExportHistoryAsync() { var batches = await Task.Run(() => _db.GetBatches(null, 100)); string path = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), $"检测记录_{DateTime.Now:yyyyMMddHHmmss}.xlsx"); _excel.ExportToExcel(batches, path); await App.Current.Dispatcher.InvokeAsync(() => MessageBox.Show($"导出成功: {path}")); } private double StandardDeviation(List values) { if (values.Count == 0) return 0; double avg = values.Average(); double sum = values.Sum(v => Math.Pow(v - avg, 2)); return values.Count < 2 ? 0 : Math.Sqrt(sum / (values.Count - 1)); } } }