This commit is contained in:
GukSang.Jin
2026-04-05 16:22:52 +08:00
parent 2c480686a4
commit 719d248197
10 changed files with 1096 additions and 1350 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,22 @@ public partial class MainWindow : Window
InspectionItemsGrid.Focus();
}
private void OpenEngineeringRegistersButton_OnClick(object sender, RoutedEventArgs e)
{
if (DataContext is not MainViewModel viewModel)
{
return;
}
viewModel.EngineeringRegisterPanelVisible = true;
if (viewModel.RefreshEngineeringRegistersCommand.CanExecute(null))
{
viewModel.RefreshEngineeringRegistersCommand.Execute(null);
}
EngineeringRegistersTab.IsSelected = true;
}
private void ConfigureTrendBindings()
{
var converter = (IMultiValueConverter)Resources["TrendPointCollectionConverter"];

View File

@@ -1,3 +1,5 @@
using System;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Cardiopulmonarybypasssystems.Models;
@@ -56,6 +58,9 @@ public partial class PumpControlChannel : ObservableObject
[ObservableProperty]
private double rs485RawOffset;
[ObservableProperty]
private bool rs485CalibrationConfirmed;
[ObservableProperty]
private double rs485MinFlowLpm;
@@ -98,14 +103,26 @@ public partial class PumpControlChannel : ObservableObject
[ObservableProperty]
private double confirmedSetpointFlowValue;
[ObservableProperty]
private bool? pendingRs485RunningState;
public string StartAddressDisplay => $"M{StartAddress}";
public string FlowAddressDisplay => FlowAddress.HasValue ? $"D{FlowAddress.Value}" : "-";
public bool HasFlowTelemetry => FlowAddress.HasValue;
public bool SupportsRs485Preset => HasFlowTelemetry && Rs485Enabled;
public bool SupportsRs485DirectControl => SupportsRs485Preset && Rs485MotorControlRegister > 0;
public bool UsesLegacyPlcDirectControl => Key == "NegativeAssistPump";
public bool HasSetpointCalibration => Rs485RawPerLitrePerMinute > 0;
public bool HasConfirmedSetpointCalibration => HasSetpointCalibration && Rs485CalibrationConfirmed;
public bool IsFlowEstablished => !HasFlowTelemetry || (FlowAvailable && FlowValue >= FlowEstablishedThreshold);
public string Rs485SlaveAddressDisplay => SupportsRs485Preset ? Rs485SlaveAddress.ToString() : "-";
public string CalibrationStatusText => !Rs485Enabled
? "未启用"
: !HasSetpointCalibration
? "未配置"
: Rs485CalibrationConfirmed
? "已确认"
: "待确认";
public string SetpointReadbackDisplay => !SupportsRs485Preset
? "-"
: SetpointAvailable
@@ -124,11 +141,11 @@ public partial class PumpControlChannel : ObservableObject
1 => "运行",
2 => "暂停",
null => "--",
_ => $"状态 {Rs485RunStatusCode}"
_ => $"状态{Rs485RunStatusCode}"
};
public string PumpGroupName => Key switch
{
"PressureDropPump" => "压力降/抗塌陷",
"PressureDropPump" => "压力降",
"RecirculationMainPump" or "RecirculationReturnPump" or "RecirculationDrainagePump" => "再循环",
"KinkResistancePump" => "抗扭结",
"HemolysisDrainageSinglePump" or "HemolysisReturnSinglePump" or "HemolysisDualLumenPump" => "血细胞破坏",
@@ -144,7 +161,7 @@ public partial class PumpControlChannel : ObservableObject
public string StateHint => IsRs485Busy
? string.IsNullOrWhiteSpace(Rs485BusyOperation) ? "RS485 操作中" : $"RS485 {Rs485BusyOperation}中"
: !StateAvailable
? "未取得 PLC 状态"
? "未收到状态反馈"
: !IsRunning
? "泵未启动"
: IsFlowEstablished
@@ -157,10 +174,15 @@ public partial class PumpControlChannel : ObservableObject
: IsFlowEstablished
? "#FF32B06A"
: "#FFD38A16";
public string CardPrimaryDisplay => UsesLegacyPlcDirectControl ? StateText : FlowDisplay;
public string FlowDisplay => !FlowAddress.HasValue ? "-" : FlowAvailable ? $"{FlowValue:F2} L/min" : "--";
public string ActionText => IsRs485Busy ? "处理中" : IsRunning ? "停止" : "启动";
public bool CanToggleRs485Action => IsRunning || HasConfirmedSetpointCalibration;
public string ToggleActionHint => CanToggleRs485Action ? string.Empty : "未完成流量换算标定确认";
public string Rs485ReadActionText => IsRs485Busy ? "处理中" : "读取";
public string Rs485WriteActionText => IsRs485Busy ? "处理中" : "写入";
public string SetpointStatusForeground => ResolveSetpointStatusForeground();
public string SetpointStatusBackground => ResolveSetpointStatusBackground();
partial void OnIsRunningChanged(bool value)
{
@@ -168,6 +190,9 @@ public partial class PumpControlChannel : ObservableObject
OnPropertyChanged(nameof(StateHint));
OnPropertyChanged(nameof(IndicatorColor));
OnPropertyChanged(nameof(ActionText));
OnPropertyChanged(nameof(CanToggleRs485Action));
OnPropertyChanged(nameof(ToggleActionHint));
OnPropertyChanged(nameof(CardPrimaryDisplay));
}
partial void OnFlowValueChanged(double value)
@@ -177,6 +202,7 @@ public partial class PumpControlChannel : ObservableObject
OnPropertyChanged(nameof(StateText));
OnPropertyChanged(nameof(StateHint));
OnPropertyChanged(nameof(IndicatorColor));
OnPropertyChanged(nameof(CardPrimaryDisplay));
}
partial void OnStateAvailableChanged(bool value)
@@ -184,6 +210,7 @@ public partial class PumpControlChannel : ObservableObject
OnPropertyChanged(nameof(StateText));
OnPropertyChanged(nameof(StateHint));
OnPropertyChanged(nameof(IndicatorColor));
OnPropertyChanged(nameof(CardPrimaryDisplay));
}
partial void OnFlowAvailableChanged(bool value)
@@ -193,6 +220,7 @@ public partial class PumpControlChannel : ObservableObject
OnPropertyChanged(nameof(StateText));
OnPropertyChanged(nameof(StateHint));
OnPropertyChanged(nameof(IndicatorColor));
OnPropertyChanged(nameof(CardPrimaryDisplay));
}
partial void OnRs485EnabledChanged(bool value)
@@ -200,6 +228,7 @@ public partial class PumpControlChannel : ObservableObject
OnPropertyChanged(nameof(SupportsRs485Preset));
OnPropertyChanged(nameof(SupportsRs485DirectControl));
OnPropertyChanged(nameof(Rs485SlaveAddressDisplay));
OnPropertyChanged(nameof(CalibrationStatusText));
OnPropertyChanged(nameof(SetpointReadbackDisplay));
OnPropertyChanged(nameof(RawSetpointDisplay));
}
@@ -210,10 +239,32 @@ public partial class PumpControlChannel : ObservableObject
partial void OnRs485RawPerLitrePerMinuteChanged(double value)
{
Rs485CalibrationConfirmed = false;
OnPropertyChanged(nameof(HasSetpointCalibration));
OnPropertyChanged(nameof(HasConfirmedSetpointCalibration));
OnPropertyChanged(nameof(CalibrationStatusText));
OnPropertyChanged(nameof(CanToggleRs485Action));
OnPropertyChanged(nameof(ToggleActionHint));
OnPropertyChanged(nameof(SetpointReadbackDisplay));
}
partial void OnRs485RawOffsetChanged(double value)
{
Rs485CalibrationConfirmed = false;
OnPropertyChanged(nameof(HasConfirmedSetpointCalibration));
OnPropertyChanged(nameof(CalibrationStatusText));
OnPropertyChanged(nameof(CanToggleRs485Action));
OnPropertyChanged(nameof(ToggleActionHint));
}
partial void OnRs485CalibrationConfirmedChanged(bool value)
{
OnPropertyChanged(nameof(HasConfirmedSetpointCalibration));
OnPropertyChanged(nameof(CalibrationStatusText));
OnPropertyChanged(nameof(CanToggleRs485Action));
OnPropertyChanged(nameof(ToggleActionHint));
}
partial void OnSetpointFlowValueChanged(double value) => OnPropertyChanged(nameof(SetpointReadbackDisplay));
partial void OnRawSetpointValueChanged(int value) => OnPropertyChanged(nameof(RawSetpointDisplay));
@@ -231,6 +282,12 @@ public partial class PumpControlChannel : ObservableObject
OnPropertyChanged(nameof(RawSetpointDisplay));
}
partial void OnSetpointStatusTextChanged(string value)
{
OnPropertyChanged(nameof(SetpointStatusForeground));
OnPropertyChanged(nameof(SetpointStatusBackground));
}
partial void OnRs485RunStatusCodeChanged(ushort? value) => OnPropertyChanged(nameof(Rs485RunStateText));
partial void OnIsRs485BusyChanged(bool value)
@@ -239,7 +296,64 @@ public partial class PumpControlChannel : ObservableObject
OnPropertyChanged(nameof(ActionText));
OnPropertyChanged(nameof(Rs485ReadActionText));
OnPropertyChanged(nameof(Rs485WriteActionText));
OnPropertyChanged(nameof(SetpointStatusForeground));
OnPropertyChanged(nameof(SetpointStatusBackground));
}
partial void OnRs485BusyOperationChanged(string value) => OnPropertyChanged(nameof(StateHint));
private string ResolveSetpointStatusForeground()
{
if (IsRs485Busy)
{
return "#FF0F6C81";
}
var text = SetpointStatusText ?? string.Empty;
if (string.IsNullOrWhiteSpace(text) || text.Contains("未读取", StringComparison.Ordinal))
{
return "#FF60737E";
}
if (ContainsAny(text, "成功", "完成", "已应用", "已更新", "已在运行", "已确认"))
{
return "#FF2B8F6A";
}
if (ContainsAny(text, "失败", "禁止", "超出", "未配置", "未找到", "未选择", "离线", "不可用", "未启用", "待确认"))
{
return "#FFCC4A42";
}
return "#FF123744";
}
private string ResolveSetpointStatusBackground()
{
if (IsRs485Busy)
{
return "#FFE8F4F7";
}
var text = SetpointStatusText ?? string.Empty;
if (string.IsNullOrWhiteSpace(text) || text.Contains("未读取", StringComparison.Ordinal))
{
return "#FFF2F6F8";
}
if (ContainsAny(text, "成功", "完成", "已应用", "已更新", "已在运行", "已确认"))
{
return "#FFE7F5EF";
}
if (ContainsAny(text, "失败", "禁止", "超出", "未配置", "未找到", "未选择", "离线", "不可用", "未启用", "待确认"))
{
return "#FFFBE9E7";
}
return "#FFF3F7F8";
}
private static bool ContainsAny(string text, params string[] keywords) =>
keywords.Any(keyword => text.Contains(keyword, StringComparison.Ordinal));
}

View File

@@ -4,6 +4,7 @@ public sealed class Rs485PumpBindingSettings
{
public string PumpKey { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public bool CalibrationConfirmed { get; set; }
public byte SlaveAddress { get; set; } = 1;
public ushort ForwardSpeedRegister { get; set; } = 0x00A2;
public ushort ReverseSpeedRegister { get; set; } = 0x00A3;

View File

@@ -1,718 +0,0 @@
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Cardiopulmonarybypasssystems.Models;
using NModbus;
namespace Cardiopulmonarybypasssystems.Services;
public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDisposable
{
private const string IpAddress = "192.168.1.10";
private const int Port = 502;
private const byte SlaveId = 1;
private const ushort ProximalPressureRegister = 1330;
private const ushort DistalPressureRegister = 1380;
private const double FlowRegisterScale = 0.01d;
private const double KpaToMmHg = 7.50061683d;
private const ushort PressureRegisterBlockLength = (ushort)(DistalPressureRegister - ProximalPressureRegister + 2);
private const int SmoothingWindowSize = 5;
private static readonly TimeSpan ConnectionAttemptTimeout = TimeSpan.FromMilliseconds(300);
private static readonly TimeSpan ConnectionRetryInterval = TimeSpan.FromSeconds(5);
private static readonly IReadOnlyDictionary<string, ushort> FlowRegisters = new Dictionary<string, ushort>(StringComparer.Ordinal)
{
["PressureDropPump"] = 1000,
["RecirculationMainPump"] = 1010,
["RecirculationReturnPump"] = 1020,
["RecirculationDrainagePump"] = 1030,
["KinkResistancePump"] = 1040,
["HemolysisDrainageSinglePump"] = 1050,
["HemolysisReturnSinglePump"] = 1060,
["HemolysisDualLumenPump"] = 1070
};
private static readonly IReadOnlyDictionary<string, string> FlowChannelNames = new Dictionary<string, string>(StringComparer.Ordinal)
{
["PressureDropPump"] = "主泵流量",
["RecirculationMainPump"] = "再循环主泵流量",
["RecirculationReturnPump"] = "动脉回输流量",
["RecirculationDrainagePump"] = "静脉引流流量",
["KinkResistancePump"] = "抗扭结主泵流量",
["HemolysisDrainageSinglePump"] = "血细胞破坏-单腔引流/回输流量",
["HemolysisReturnSinglePump"] = "双腔插管试验回路流量",
["HemolysisDualLumenPump"] = "双腔插管试验回路流量(两个管腔)"
};
private readonly object _syncRoot = new();
private readonly Random _random = new();
private readonly ModbusFactory _factory = new();
private readonly Dictionary<string, Queue<double>> _channelWindows = new(StringComparer.Ordinal);
private readonly Dictionary<ushort, float> _engineeringFloatRegisters = new()
{
[1006] = 1.0f,
[1016] = 1.0f,
[1026] = 1.0f,
[1036] = 1.0f,
[1046] = 1.0f,
[1056] = 1.0f,
[1066] = 1.0f,
[1076] = 1.0f,
[1328] = 1.0f,
[1378] = 1.0f
};
private readonly List<DeviceChannel> _channels =
[
new() { Name = "主泵流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
new() { Name = "再循环主泵流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
new() { Name = "动脉回输流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
new() { Name = "静脉引流流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
new() { Name = "抗扭结主泵流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
new() { Name = "血细胞破坏-单腔引流/回输流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
new() { Name = "双腔插管试验回路流量", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
new() { Name = "双腔插管试验回路流量(两个管腔)", Unit = "L/min", Value = 0, Min = 0, Max = 7 },
new() { Name = "远端压力", Unit = "mmHg", Value = 68, Min = 40, Max = 180 },
new() { Name = "近端压力", Unit = "mmHg", Value = 80, Min = 60, Max = 220 },
new() { Name = "负压辅助引流", Unit = "kPa", Value = 0, Min = -20, Max = 0 },
new() { Name = "模拟血液温度", Unit = "°C", Value = 37.1, Min = 34, Max = 40 },
new() { Name = "再循环率", Unit = "%", Value = 0, Min = 0, Max = 20 },
new() { Name = "游离血红蛋白", Unit = "g/L", Value = 0.028, Min = 0, Max = 0.08 },
new() { Name = "白细胞减少率", Unit = "%", Value = 7.1, Min = 0, Max = 20 }
];
// The 8 production pumps are actuator-controlled by servo RS485.
// Pump StartAddress values are retained only for telemetry compatibility and historical mapping.
private readonly List<PumpControlChannel> _pumpControls =
[
new() { Key = "NegativeAssistPump", Name = "负压泵", StartAddress = 0 },
new() { Key = "PressureDropPump", Name = "压力降/抗塌陷泵", StartAddress = 1, FlowAddress = FlowRegisters["PressureDropPump"] },
new() { Key = "RecirculationMainPump", Name = "再循环主泵", StartAddress = 2, FlowAddress = FlowRegisters["RecirculationMainPump"] },
new() { Key = "RecirculationReturnPump", Name = "回流泵", StartAddress = 3, FlowAddress = FlowRegisters["RecirculationReturnPump"] },
new() { Key = "RecirculationDrainagePump", Name = "引流泵", StartAddress = 4, FlowAddress = FlowRegisters["RecirculationDrainagePump"] },
new() { Key = "KinkResistancePump", Name = "抗扭结泵", StartAddress = 5, FlowAddress = FlowRegisters["KinkResistancePump"] },
new() { Key = "HemolysisDrainageSinglePump", Name = "血细胞破坏-单腔引流/回输泵", StartAddress = 6, FlowAddress = FlowRegisters["HemolysisDrainageSinglePump"] },
new() { Key = "HemolysisReturnSinglePump", Name = "双腔插管试验回路泵", StartAddress = 7, FlowAddress = FlowRegisters["HemolysisReturnSinglePump"] },
new() { Key = "HemolysisDualLumenPump", Name = "双腔插管试验回路泵(两个管腔)", StartAddress = 8, FlowAddress = FlowRegisters["HemolysisDualLumenPump"] }
];
private readonly List<ValveControlChannel> _valveControls =
[
new() { Key = "TestLoopValve1", Name = "测试回路阀 1", StartAddress = 10 },
new() { Key = "TestLoopValve2", Name = "测试回路阀 2", StartAddress = 11 }
];
private TcpClient? _tcpClient;
private IModbusMaster? _master;
private bool _connectionInitialized;
private Task? _connectionTask;
private DateTime _nextConnectionAttemptUtc = DateTime.MinValue;
private double? _proximalPressureRawKpa;
private double? _distalPressureRawKpa;
private double? _proximalPressureDecodedKpa;
private double? _distalPressureDecodedKpa;
private int HighestConfiguredCoilAddress => Math.Max(
_pumpControls.Max(item => item.StartAddress),
_valveControls.Max(item => item.StartAddress));
public bool IsLiveConnected
{
get
{
lock (_syncRoot)
{
return _master is not null && _tcpClient?.Connected == true;
}
}
}
public string EndpointDescription => $"{IpAddress}:{Port} / Slave {SlaveId}";
public DateTime? LastSuccessfulReadAt => null;
public string LastErrorMessage => "未使用的兼容服务";
public IReadOnlyList<DeviceChannel> GetChannels()
{
EnsureConnectionScheduled();
return _channels;
}
public IReadOnlyList<PumpControlChannel> GetPumpControls()
{
EnsureConnectionScheduled();
return _pumpControls;
}
public IReadOnlyList<ValveControlChannel> GetValveControls()
{
EnsureConnectionScheduled();
return _valveControls;
}
public TelemetryUpdateSnapshot UpdateChannels()
{
EnsureConnectionScheduled();
lock (_syncRoot)
{
var liveReadSucceeded = TryReadPumpStatesAndFlows();
TryReadPressureChannels(liveReadSucceeded);
SimulateAuxiliaryChannels(liveReadSucceeded);
SyncDerivedChannels();
return BuildSnapshot(BuildAlarms());
}
}
public ushort? ReadHoldingRegister(ushort address)
{
lock (_syncRoot)
{
if (!_engineeringFloatRegisters.TryGetValue(address, out var value))
{
return null;
}
return (ushort)Math.Clamp((int)Math.Round(value), ushort.MinValue, ushort.MaxValue);
}
}
public float? ReadHoldingFloatRegister(ushort address)
{
lock (_syncRoot)
{
return address switch
{
ProximalPressureRegister => (float)(_proximalPressureRawKpa ?? (_channels.First(channel => channel.Name == "近端压力").Value / KpaToMmHg)),
DistalPressureRegister => (float)(_distalPressureRawKpa ?? (_channels.First(channel => channel.Name == "远端压力").Value / KpaToMmHg)),
_ when _engineeringFloatRegisters.TryGetValue(address, out var value) => value,
_ => null
};
}
}
public bool WriteHoldingRegister(ushort address, ushort value)
{
lock (_syncRoot)
{
if (!_engineeringFloatRegisters.ContainsKey(address))
{
return false;
}
_engineeringFloatRegisters[address] = value;
return true;
}
}
public bool WriteHoldingFloatRegister(ushort address, float value)
{
lock (_syncRoot)
{
if (!_engineeringFloatRegisters.ContainsKey(address))
{
return false;
}
_engineeringFloatRegisters[address] = value;
return true;
}
}
public void SetPumpRunning(string pumpKey, bool isRunning)
{
lock (_syncRoot)
{
var pump = _pumpControls.FirstOrDefault(item => item.Key == pumpKey);
if (pump is null)
{
return;
}
// Legacy PLC coil path retained only for non-RS485 pump compatibility.
if (PumpActuationProfiles.IsServoRs485ManagedPump(pump.Key))
{
return;
}
pump.IsRunning = isRunning;
if (_master is null)
{
return;
}
try
{
_master.WriteSingleCoil(SlaveId, (ushort)pump.StartAddress, isRunning);
}
catch
{
ReleaseConnection();
_nextConnectionAttemptUtc = DateTime.MinValue;
}
}
}
public void SetValveOpen(string valveKey, bool isOpen)
{
lock (_syncRoot)
{
var valve = _valveControls.FirstOrDefault(item => item.Key == valveKey);
if (valve is null)
{
return;
}
valve.IsOpen = isOpen;
if (_master is null)
{
return;
}
try
{
_master.WriteSingleCoil(SlaveId, (ushort)valve.StartAddress, isOpen);
}
catch
{
ReleaseConnection();
_nextConnectionAttemptUtc = DateTime.MinValue;
}
}
}
public void Dispose()
{
lock (_syncRoot)
{
ReleaseConnection();
}
}
private void EnsureConnectionScheduled()
{
lock (_syncRoot)
{
if (_master is not null && _tcpClient?.Connected == true)
{
return;
}
if (_connectionTask is { IsCompleted: false })
{
return;
}
if (DateTime.UtcNow < _nextConnectionAttemptUtc)
{
return;
}
_nextConnectionAttemptUtc = DateTime.UtcNow.Add(ConnectionRetryInterval);
_connectionTask = Task.Run(ConnectWithTimeout);
}
}
private void ConnectWithTimeout()
{
TcpClient? tcpClient = null;
try
{
tcpClient = new TcpClient();
using var cancellation = new CancellationTokenSource(ConnectionAttemptTimeout);
tcpClient.ConnectAsync(IpAddress, Port, cancellation.Token).GetAwaiter().GetResult();
tcpClient.ReceiveTimeout = (int)ConnectionAttemptTimeout.TotalMilliseconds;
tcpClient.SendTimeout = (int)ConnectionAttemptTimeout.TotalMilliseconds;
var master = _factory.CreateMaster(tcpClient);
ApplySafeStartupState(master);
lock (_syncRoot)
{
ReleaseConnection();
_tcpClient = tcpClient;
_master = master;
_connectionInitialized = true;
tcpClient = null;
}
}
catch
{
tcpClient?.Dispose();
lock (_syncRoot)
{
ReleaseConnection();
}
}
}
private bool TryReadPumpStatesAndFlows()
{
if (_master is null)
{
SimulatePumpFlows();
return false;
}
try
{
var coilStates = _master.ReadCoils(SlaveId, 0, (ushort)(HighestConfiguredCoilAddress + 1));
foreach (var pump in _pumpControls)
{
if (!PumpActuationProfiles.IsServoRs485ManagedPump(pump.Key))
{
pump.IsRunning = coilStates[pump.StartAddress];
}
}
foreach (var valve in _valveControls)
{
valve.IsOpen = coilStates[valve.StartAddress];
}
foreach (var pump in _pumpControls.Where(item => item.FlowAddress.HasValue))
{
var registerValue = _master.ReadHoldingRegisters(SlaveId, (ushort)pump.FlowAddress!.Value, 1)[0];
var flowValue = ConvertRegisterToFlow(registerValue);
pump.FlowValue = flowValue;
SetSmoothedValue(FlowChannelNames[pump.Key], flowValue);
}
return true;
}
catch
{
ReleaseConnection();
_nextConnectionAttemptUtc = DateTime.MinValue;
SimulatePumpFlows();
return false;
}
}
private void TryReadPressureChannels(bool liveReadSucceeded)
{
if (_master is null || !liveReadSucceeded)
{
_proximalPressureRawKpa = null;
_distalPressureRawKpa = null;
_proximalPressureDecodedKpa = null;
_distalPressureDecodedKpa = null;
SimulatePressureChannels();
return;
}
try
{
var pressureRegisters = _master.ReadHoldingRegisters(SlaveId, ProximalPressureRegister, PressureRegisterBlockLength);
var distalOffset = DistalPressureRegister - ProximalPressureRegister;
var proximalRawKpa = ConvertRegistersToPressureKpa(pressureRegisters[0], pressureRegisters[1]);
var distalRawKpa = ConvertRegistersToPressureKpa(pressureRegisters[distalOffset], pressureRegisters[distalOffset + 1]);
_proximalPressureRawKpa = proximalRawKpa;
_distalPressureRawKpa = distalRawKpa;
_proximalPressureDecodedKpa = proximalRawKpa;
_distalPressureDecodedKpa = distalRawKpa;
SetSmoothedValue("近端压力", ConvertPressureKpaToMmHg(proximalRawKpa));
SetSmoothedValue("远端压力", ConvertPressureKpaToMmHg(distalRawKpa));
}
catch
{
_proximalPressureRawKpa = null;
_distalPressureRawKpa = null;
_proximalPressureDecodedKpa = null;
_distalPressureDecodedKpa = null;
ReleaseConnection();
_nextConnectionAttemptUtc = DateTime.MinValue;
SimulatePressureChannels();
}
}
private void SimulatePumpFlows()
{
foreach (var pump in _pumpControls.Where(item => item.FlowAddress.HasValue))
{
var target = pump.IsRunning ? SimulatedRunningTarget(pump.Key) : 0d;
var nextValue = pump.IsRunning
? target + Next(-0.10, 0.10)
: Math.Max(0d, pump.FlowValue * 0.35 + Next(-0.02, 0.02));
pump.FlowValue = Math.Clamp(nextValue, 0d, 7d);
SetSmoothedValue(FlowChannelNames[pump.Key], pump.FlowValue);
}
}
private void SimulatePressureChannels()
{
var pressurePumpRunning = Pump("PressureDropPump").IsRunning;
var proximalTarget = pressurePumpRunning ? 112d : 80d;
var distalTarget = pressurePumpRunning ? 94d : 68d;
SetSmoothedValue("近端压力", proximalTarget + Next(-3.0, 3.0));
SetSmoothedValue("远端压力", distalTarget + Next(-2.5, 2.5));
var proximal = Channel("近端压力");
var distal = Channel("远端压力");
if (distal.Value > proximal.Value - 2)
{
SetSmoothedValue("远端压力", Math.Max(distal.Min, proximal.Value - Next(6, 18)));
}
}
private void SimulateAuxiliaryChannels(bool liveReadSucceeded)
{
var negativePump = Pump("NegativeAssistPump");
var negativeTarget = negativePump.IsRunning ? -6.67d : 0d;
SetSmoothedValue("负压辅助引流", negativeTarget + Next(-0.5, 0.5));
SetSmoothedValue("模拟血液温度", Channel("模拟血液温度").Value + Next(-0.15, 0.15));
SetSmoothedValue("游离血红蛋白", Channel("游离血红蛋白").Value + Next(-0.003, 0.003));
SetSmoothedValue("白细胞减少率", Channel("白细胞减少率").Value + Next(-0.4, 0.4));
if (!liveReadSucceeded)
{
return;
}
foreach (var pump in _pumpControls.Where(item => item.FlowAddress.HasValue))
{
SetSmoothedValue(FlowChannelNames[pump.Key], pump.FlowValue);
}
}
private void SyncDerivedChannels()
{
var returnFlow = Channel("动脉回输流量").Value;
var drainageFlow = Channel("静脉引流流量").Value;
var recirculationRate = returnFlow <= 0.01
? 0d
: Math.Clamp((returnFlow - drainageFlow) / returnFlow * 100d, 0d, 100d);
SetSmoothedValue("再循环率", recirculationRate);
}
private List<AlarmMessage> BuildAlarms()
{
var alarms = new List<AlarmMessage>();
var deltaPressure = Channel("近端压力").Value - Channel("远端压力").Value;
var recirculationRate = Channel("再循环率").Value;
if (deltaPressure > 24)
{
alarms.Add(new AlarmMessage
{
Timestamp = DateTime.Now,
Level = "高",
Message = $"压力降 ΔP {deltaPressure:F1} mmHg 偏高,请复核 D{ProximalPressureRegister}/D{DistalPressureRegister}。"
});
}
if (!_connectionInitialized || _master is null)
{
alarms.Add(new AlarmMessage
{
Timestamp = DateTime.Now,
Level = "中",
Message = $"ModbusTcp 未连接,当前 M/D 泵控与流量使用本地平滑模拟。目标 {IpAddress}:{Port}。"
});
}
if (recirculationRate > 8)
{
alarms.Add(new AlarmMessage
{
Timestamp = DateTime.Now,
Level = "中",
Message = $"再循环率 {recirculationRate:F1}% 偏高,建议复核回路与泵状态。"
});
}
foreach (var pump in _pumpControls.Where(item => item.IsRunning && item.FlowAddress.HasValue && item.FlowValue < 0.15d))
{
alarms.Add(new AlarmMessage
{
Timestamp = DateTime.Now,
Level = "中",
Message = $"{pump.Name} 已启动但流量未建立,请检查回路、泵头和传感器。"
});
}
return alarms;
}
private TelemetryUpdateSnapshot BuildSnapshot(IReadOnlyList<AlarmMessage> alarms) => new()
{
Channels = _channels.Select(channel => new TelemetryChannelSnapshot
{
Name = channel.Name,
Value = channel.Value,
IsAvailable = true
}).ToList(),
PumpControls = _pumpControls.Select(pump => new PumpControlSnapshot
{
Key = pump.Key,
IsRunning = pump.IsRunning,
FlowValue = pump.FlowValue,
StateAvailable = true,
FlowAvailable = pump.FlowAddress.HasValue
}).ToList(),
ValveControls = _valveControls.Select(valve => new ValveControlSnapshot
{
Key = valve.Key,
IsOpen = valve.IsOpen,
StateAvailable = true
}).ToList(),
Alarms = alarms
.Select(alarm => new AlarmMessage
{
Timestamp = alarm.Timestamp,
Level = alarm.Level,
Message = alarm.Message
})
.ToList(),
ProximalPressureRawKpa = _proximalPressureRawKpa,
DistalPressureRawKpa = _distalPressureRawKpa,
IsLiveConnected = _master is not null && _tcpClient?.Connected == true,
EndpointDescription = EndpointDescription,
LastSuccessfulReadAt = null,
LastErrorMessage = LastErrorMessage
};
private void SetSmoothedValue(string channelName, double nextValue)
{
var channel = Channel(channelName);
var clampedValue = ShouldClampChannelValue(channelName)
? Math.Clamp(nextValue, channel.Min, channel.Max)
: nextValue;
var window = Window(channelName);
window.Enqueue(clampedValue);
while (window.Count > SmoothingWindowSize)
{
window.Dequeue();
}
channel.Value = window.Average();
}
private Queue<double> Window(string channelName)
{
if (_channelWindows.TryGetValue(channelName, out var window))
{
return window;
}
window = new Queue<double>();
_channelWindows[channelName] = window;
return window;
}
private void ApplySafeStartupState(IModbusMaster master)
{
foreach (var pump in _pumpControls)
{
master.WriteSingleCoil(SlaveId, (ushort)pump.StartAddress, false);
}
foreach (var valve in _valveControls)
{
master.WriteSingleCoil(SlaveId, (ushort)valve.StartAddress, false);
}
ApplyLocalSafeState();
}
private void ApplyLocalSafeState()
{
foreach (var pump in _pumpControls)
{
pump.IsRunning = false;
pump.FlowValue = 0d;
}
foreach (var valve in _valveControls)
{
valve.IsOpen = false;
}
foreach (var channelName in FlowChannelNames.Values)
{
SetChannelValueDirect(channelName, 0d);
}
SetChannelValueDirect("负压辅助引流", 0d);
SetChannelValueDirect("再循环率", 0d);
}
private void SetChannelValueDirect(string channelName, double nextValue)
{
var channel = Channel(channelName);
var clampedValue = ShouldClampChannelValue(channelName)
? Math.Clamp(nextValue, channel.Min, channel.Max)
: nextValue;
var window = Window(channelName);
window.Clear();
window.Enqueue(clampedValue);
channel.Value = clampedValue;
}
private void ReleaseConnection()
{
_master?.Dispose();
_tcpClient?.Dispose();
_master = null;
_tcpClient = null;
}
private static double SimulatedRunningTarget(string pumpKey) => pumpKey switch
{
"PressureDropPump" => 4.2d,
"RecirculationMainPump" => 4.8d,
"RecirculationReturnPump" => 4.7d,
"RecirculationDrainagePump" => 4.4d,
"KinkResistancePump" => 4.6d,
"HemolysisDrainageSinglePump" => 4.3d,
"HemolysisReturnSinglePump" => 4.3d,
"HemolysisDualLumenPump" => 4.1d,
_ => 4.0d
};
private static double ConvertRegisterToFlow(ushort rawValue) => rawValue * FlowRegisterScale;
private static double ConvertRegistersToPressureKpa(ushort firstWord, ushort secondWord)
{
var lowWordFirst = DecodeFloat(firstWord, secondWord, lowWordFirst: true);
var highWordFirst = DecodeFloat(firstWord, secondWord, lowWordFirst: false);
var lowWordFirstValid = IsPlausiblePressureKpa(lowWordFirst);
var highWordFirstValid = IsPlausiblePressureKpa(highWordFirst);
if (lowWordFirstValid && !highWordFirstValid)
{
return lowWordFirst;
}
if (!lowWordFirstValid && highWordFirstValid)
{
return highWordFirst;
}
return lowWordFirst;
}
private static double ConvertPressureKpaToMmHg(double pressureKpa) => pressureKpa * KpaToMmHg;
private static float DecodeFloat(ushort firstWord, ushort secondWord, bool lowWordFirst)
{
var bits = lowWordFirst
? ((uint)secondWord << 16) | firstWord
: ((uint)firstWord << 16) | secondWord;
return BitConverter.Int32BitsToSingle(unchecked((int)bits));
}
private static bool IsPlausiblePressureKpa(float value) =>
!float.IsNaN(value) && !float.IsInfinity(value) && value is > -1000f and < 1000f;
private PumpControlChannel Pump(string key) => _pumpControls.First(pump => pump.Key == key);
private static bool ShouldClampChannelValue(string channelName) =>
channelName is not "近端压力" and not "远端压力";
private DeviceChannel Channel(string name) => _channels.First(channel => channel.Name == name);
private double Next(double min, double max) => min + (_random.NextDouble() * (max - min));
}

View File

@@ -580,7 +580,7 @@ public sealed class ModbusTelemetryService : IModbusTelemetryService, IDisposabl
{
Timestamp = DateTime.Now,
Level = "中",
Message = $"ModbusTcp 未连接,当前不再使用本地模拟数据。目标 {_ipAddress}:{_port}。"
Message = $"ModbusTcp 未连接,请检查 PLC 通讯。目标 {_ipAddress}:{_port}。"
});
}

View File

@@ -1,4 +1,5 @@
using System.IO.Ports;
using System.Linq;
using System.Text;
using System.Threading;
using Cardiopulmonarybypasssystems.Models;
@@ -20,6 +21,7 @@ public sealed class Rs485PumpFlowService : IRs485PumpFlowService
private const int WriteRetryCount = 3;
private const int RetryDelayMs = 80;
private const int PostWriteSettleDelayMs = 60;
private const int PostCommandVerifyDelayMs = 120;
private readonly ModbusFactory _factory = new();
private readonly object _syncRoot = new();
@@ -129,7 +131,7 @@ public sealed class Rs485PumpFlowService : IRs485PumpFlowService
{
Success = false,
RawForwardSpeed = readbackForward,
Message = $"RS485 写入未生效:目标值={rawForwardSpeed},回读值={readbackForward}。请确认驱动器上限或预设模式。"
Message = $"RS485 写入未生效:目标值 {rawForwardSpeed},回读值 {readbackForward}。请确认驱动器上限或预设模式。"
};
}
@@ -158,20 +160,19 @@ public sealed class Rs485PumpFlowService : IRs485PumpFlowService
return Execute(request, master =>
{
var slave = request.PumpSettings.SlaveAddress;
var warnings = new List<string>();
if (rawMotorSpeed == 0)
{
rawMotorSpeed = 1;
}
if (!TryWriteRegister(master, slave, DirectControlModeRegister, DirectControlModeValue, out _))
if (!TryWriteRegister(master, slave, DirectControlModeRegister, DirectControlModeValue, out var modeError))
{
warnings.Add("未切入 RS485 直接控制模式");
return Failure($"RS485 直启失败:未切入直接控制模式,{modeError}");
}
if (!TryWriteRegister(master, slave, ReleaseRegister, ReleaseValue, out _))
if (!TryWriteRegister(master, slave, ReleaseRegister, ReleaseValue, out var releaseError))
{
warnings.Add("未执行释放命令");
return Failure($"RS485 直启失败:未执行释放命令,{releaseError}");
}
if (!TryWriteSignedRegister(master, slave, request.PumpSettings.MotorControlRegister, rawMotorSpeed, out var writeError))
@@ -179,11 +180,30 @@ public sealed class Rs485PumpFlowService : IRs485PumpFlowService
return Failure($"RS485 直接启动失败:{writeError}");
}
if (PostCommandVerifyDelayMs > 0)
{
Thread.Sleep(PostCommandVerifyDelayMs);
}
if (TryReadRegister(master, slave, request.PumpSettings.RunStatusRegister, out var runStatusValue, out _))
{
if (runStatusValue != 1)
{
return Failure($"RS485 直启失败:运行状态返回 {runStatusValue},未确认启动");
}
return new Rs485PumpFlowOperationResult
{
Success = true,
Message = $"RS485 直接启动成功,控制值 {rawMotorSpeed}",
RunStatus = runStatusValue
};
}
return new Rs485PumpFlowOperationResult
{
Success = true,
Message = BuildSuccessMessage($"RS485 直接启动成功,控制值={rawMotorSpeed}", warnings),
RunStatus = 1
Message = $"RS485 启动命令已下发,控制值 {rawMotorSpeed},等待流量确认"
};
});
}
@@ -193,22 +213,40 @@ public sealed class Rs485PumpFlowService : IRs485PumpFlowService
return Execute(request, master =>
{
var slave = request.PumpSettings.SlaveAddress;
var warnings = new List<string>();
if (!TryWriteSignedRegister(master, slave, request.PumpSettings.MotorControlRegister, 0, out var writeError))
{
return Failure($"RS485 直接停止失败:{writeError}");
}
if (!TryWriteRegister(master, slave, SelfLockRegister, SelfLockValue, out _))
if (!TryWriteRegister(master, slave, SelfLockRegister, SelfLockValue, out var selfLockError))
{
warnings.Add("未执行自锁命令");
return Failure($"RS485 直停失败:未执行自锁命令,{selfLockError}");
}
if (PostCommandVerifyDelayMs > 0)
{
Thread.Sleep(PostCommandVerifyDelayMs);
}
if (TryReadRegister(master, slave, request.PumpSettings.RunStatusRegister, out var runStatusValue, out _))
{
if (runStatusValue != 0)
{
return Failure($"RS485 直停失败:运行状态返回 {runStatusValue},未确认停止");
}
return new Rs485PumpFlowOperationResult
{
Success = true,
Message = "RS485 直接停止成功",
RunStatus = runStatusValue
};
}
return new Rs485PumpFlowOperationResult
{
Success = true,
Message = BuildSuccessMessage("RS485 直接停止成功", warnings),
RunStatus = 0
Message = "RS485 停止命令已下发,等待流量确认"
};
});
}

View File

@@ -2,6 +2,7 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.Threading.Tasks;
using Cardiopulmonarybypasssystems.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -25,6 +26,7 @@ public partial class MainViewModel
private static readonly HashSet<string> Rs485PumpKeySet = [.. Rs485PumpKeys];
private const double DefaultRs485RawPerLitrePerMinute = 1000d;
private const double DefaultRs485RawOffset = 0d;
private const int MaxRs485MotorCommand = short.MaxValue;
[ObservableProperty]
private string rs485PortName = "COM3";
@@ -56,15 +58,28 @@ public partial class MainViewModel
[ObservableProperty]
private bool rs485AutoStartPumpAfterWrite;
[ObservableProperty]
private bool rs485AdvancedSettingsVisible;
[ObservableProperty]
private string rs485StatusText = "RS485 待确认";
public ObservableCollection<string> Rs485ParityOptions { get; } = new(["Even", "Odd", "None"]);
public ObservableCollection<PumpControlChannel> Rs485FlowPumpControls { get; } = [];
public ObservableCollection<PumpControlChannel> ActiveRs485FlowPumpControls { get; } = [];
public string Rs485AdvancedSettingsToggleText => Rs485AdvancedSettingsVisible ? "收起高级维护" : "展开高级维护";
public string Rs485ConnectionSummary =>
$"{Rs485PortName} / {Rs485BaudRate}bps / {Rs485Parity} / {Rs485DataBits}-{Rs485StopBits}";
public int Rs485EnabledPumpCount => Rs485FlowPumpControls.Count(item => item.Rs485Enabled);
public int Rs485CalibrationConfirmedPumpCount =>
Rs485FlowPumpControls.Count(item => item.Rs485Enabled && item.HasConfirmedSetpointCalibration);
public string Rs485CalibrationSummary => Rs485EnabledPumpCount == 0
? "当前未启用 RS485 泵通道。"
: Rs485CalibrationConfirmedPumpCount == Rs485EnabledPumpCount
? $"已确认全部 {Rs485EnabledPumpCount} 台泵的流量换算。"
: $"已确认 {Rs485CalibrationConfirmedPumpCount}/{Rs485EnabledPumpCount} 台泵的流量换算,其余仍待标定确认。";
public string Rs485ManualHint =>
"8 泵启停由伺服器 RS485 主控;目标流量会按换算系数写入驱动器速度寄存器,PLC 仅保留实时采集。";
"主操作区按 8 泵批量预设设计,可逐行录入目标流量后一次批量写入;PLC 仅保留实时采集。";
partial void OnRs485PortNameChanged(string value) => UpdateAndPersistRs485Settings();
partial void OnRs485BaudRateChanged(int value) => UpdateAndPersistRs485Settings();
@@ -87,6 +102,7 @@ public partial class MainViewModel
partial void OnRs485AutoSwitchPresetModeChanged(bool value) => UpdateAndPersistRs485Settings();
partial void OnRs485PersistPresetAfterWriteChanged(bool value) => UpdateAndPersistRs485Settings();
partial void OnRs485AutoStartPumpAfterWriteChanged(bool value) => UpdateAndPersistRs485Settings();
partial void OnRs485AdvancedSettingsVisibleChanged(bool value) => OnPropertyChanged(nameof(Rs485AdvancedSettingsToggleText));
private void InitializeRs485FlowControl()
{
@@ -115,6 +131,7 @@ public partial class MainViewModel
pump.PropertyChanged += OnRs485PumpConfigurationChanged;
}
RefreshActiveRs485FlowPumpControls();
OnPropertyChanged(nameof(Rs485ConnectionSummary));
}
@@ -145,6 +162,8 @@ public partial class MainViewModel
ApplyRs485Binding(pump, binding);
}
}
RefreshActiveRs485FlowPumpControls();
}
private Rs485SerialSettings BuildRs485SerialSettings() => new()
@@ -167,6 +186,7 @@ public partial class MainViewModel
{
PumpKey = pump.Key,
Enabled = pump.Rs485Enabled,
CalibrationConfirmed = pump.Rs485CalibrationConfirmed,
SlaveAddress = pump.Rs485SlaveAddress,
ForwardSpeedRegister = pump.Rs485ForwardSpeedRegister,
ReverseSpeedRegister = pump.Rs485ReverseSpeedRegister,
@@ -191,6 +211,7 @@ public partial class MainViewModel
{
PumpKey = Rs485PumpKeys[index],
Enabled = true,
CalibrationConfirmed = false,
SlaveAddress = (byte)(index + 1),
MotorControlRegister = 0x0040,
RawPerLitrePerMinute = DefaultRs485RawPerLitrePerMinute,
@@ -209,6 +230,7 @@ public partial class MainViewModel
?? new Rs485PumpBindingSettings { PumpKey = pump.Key };
pump.Rs485Enabled = binding.Enabled;
pump.Rs485CalibrationConfirmed = binding.CalibrationConfirmed;
pump.Rs485SlaveAddress = binding.SlaveAddress;
pump.Rs485ForwardSpeedRegister = binding.ForwardSpeedRegister;
pump.Rs485ReverseSpeedRegister = binding.ReverseSpeedRegister;
@@ -241,6 +263,14 @@ public partial class MainViewModel
{
pump.SetpointStatusText = "未配置 L/min 与速度换算系数";
}
else if (!pump.Rs485CalibrationConfirmed)
{
pump.SetpointStatusText = "换算系数待标定确认";
}
else
{
pump.SetpointStatusText = "换算系数已确认";
}
}
private void OnRs485PumpConfigurationChanged(object? sender, PropertyChangedEventArgs e)
@@ -254,6 +284,7 @@ public partial class MainViewModel
and not nameof(PumpControlChannel.Rs485SlaveAddress)
and not nameof(PumpControlChannel.Rs485RawPerLitrePerMinute)
and not nameof(PumpControlChannel.Rs485RawOffset)
and not nameof(PumpControlChannel.Rs485CalibrationConfirmed)
and not nameof(PumpControlChannel.Rs485MinFlowLpm)
and not nameof(PumpControlChannel.Rs485MaxFlowLpm)
and not nameof(PumpControlChannel.Rs485ForwardSpeedRegister)
@@ -271,10 +302,48 @@ public partial class MainViewModel
{
pump.SetpointStatusText = "未配置 L/min 与速度换算系数";
}
else if (!pump.Rs485CalibrationConfirmed)
{
pump.SetpointStatusText = "换算系数待标定确认";
}
else
{
pump.SetpointStatusText = "换算系数已确认";
}
if (e.PropertyName == nameof(PumpControlChannel.Rs485Enabled))
{
RefreshActiveRs485FlowPumpControls();
}
RaiseRs485CalibrationSummaryChanges();
UpdateAndPersistRs485Settings();
}
private void RefreshActiveRs485FlowPumpControls()
{
ActiveRs485FlowPumpControls.Clear();
foreach (var pump in Rs485FlowPumpControls.Where(item => item.Rs485Enabled))
{
ActiveRs485FlowPumpControls.Add(pump);
}
RaiseRs485CalibrationSummaryChanges();
}
private void RaiseRs485CalibrationSummaryChanges()
{
OnPropertyChanged(nameof(Rs485EnabledPumpCount));
OnPropertyChanged(nameof(Rs485CalibrationConfirmedPumpCount));
OnPropertyChanged(nameof(Rs485CalibrationSummary));
}
[RelayCommand]
private void ToggleRs485AdvancedSettings()
{
Rs485AdvancedSettingsVisible = !Rs485AdvancedSettingsVisible;
}
private void UpdateAndPersistRs485Settings()
{
OnPropertyChanged(nameof(Rs485ConnectionSummary));
@@ -292,7 +361,7 @@ public partial class MainViewModel
Rs485WriteTimeoutMs = 500;
Rs485AutoSwitchPresetMode = false;
Rs485PersistPresetAfterWrite = false;
Rs485AutoStartPumpAfterWrite = true;
Rs485AutoStartPumpAfterWrite = false;
var primaryPump = Rs485FlowPumpControls.FirstOrDefault();
foreach (var pump in Rs485FlowPumpControls)
{
@@ -307,6 +376,7 @@ public partial class MainViewModel
pump.Rs485MotorControlRegister = 0x0040;
pump.Rs485RawPerLitrePerMinute = DefaultRs485RawPerLitrePerMinute;
pump.Rs485RawOffset = DefaultRs485RawOffset;
pump.Rs485CalibrationConfirmed = false;
pump.Rs485MinFlowLpm = 0;
pump.Rs485MaxFlowLpm = 1.0;
pump.SetpointStatusText = pump.Rs485Enabled
@@ -321,30 +391,62 @@ public partial class MainViewModel
}
[RelayCommand]
private void TestRs485Connection()
private async Task TestRs485Connection()
{
var pump = Rs485FlowPumpControls.FirstOrDefault(item => item.SupportsRs485Preset);
if (pump is null)
var pumps = Rs485FlowPumpControls
.Where(item => item.SupportsRs485Preset)
.ToList();
if (pumps.Count == 0)
{
Rs485StatusText = "未找到已启用的 RS485 泵通道";
return;
}
var result = _rs485PumpFlowService.TestConnection(BuildRs485Request(pump));
ApplyRs485OperationResult(pump, result, "RS485 通讯确认");
var successCount = 0;
foreach (var pump in pumps)
{
if (!TryBeginRs485PumpOperation(pump, "通讯确认"))
{
continue;
}
try
{
var request = BuildRs485Request(pump);
var result = await Task.Run(() => _rs485PumpFlowService.TestConnection(request));
ApplyRs485OperationResult(pump, result, "RS485 通讯确认");
if (result.Success)
{
successCount++;
}
}
finally
{
EndRs485PumpOperation(pump);
}
}
var failedCount = pumps.Count - successCount;
var summary = failedCount == 0
? $"RS485 通讯确认完成:{successCount} 台全部正常。"
: $"RS485 通讯确认完成:成功 {successCount} 台,失败 {failedCount} 台。";
Rs485StatusText = summary;
LatestAction = summary;
TraceEvents.Insert(0, NewTrace("RS485 通讯确认", summary));
}
[RelayCommand]
private void RefreshAllPumpSetpoints()
private async Task RefreshAllPumpSetpoints()
{
foreach (var pump in Rs485FlowPumpControls.Where(item => item.SupportsRs485Preset))
{
ReadPumpSetpoint(pump);
await ReadPumpSetpoint(pump);
}
}
[RelayCommand]
private void ReadPumpSetpoint(PumpControlChannel? pump)
private async Task ReadPumpSetpoint(PumpControlChannel? pump)
{
if (!TryPreparePumpForRs485(pump, requireCalibration: false, out var activePump, out _))
{
@@ -358,7 +460,8 @@ public partial class MainViewModel
try
{
var result = _rs485PumpFlowService.ReadPumpPreset(BuildRs485Request(activePump));
var request = BuildRs485Request(activePump);
var result = await Task.Run(() => _rs485PumpFlowService.ReadPumpPreset(request));
ApplyRs485OperationResult(activePump, result, "读取泵预设");
}
finally
@@ -368,48 +471,100 @@ public partial class MainViewModel
}
[RelayCommand]
private void WritePumpSetpoint(PumpControlChannel? pump)
private async Task WritePumpSetpoint(PumpControlChannel? pump)
{
if (!EnsureSessionEditable("RS485 预设流量写入"))
await TryWritePumpSetpointCore(pump, "RS485 预设流量写入", allowAutoStartAfterWrite: true);
}
[RelayCommand]
private async Task WriteAllPumpSetpoints()
{
if (!EnsureSessionEditable("RS485 批量预设流量写入"))
{
return;
}
var candidates = Rs485FlowPumpControls
.Where(item => item.SupportsRs485Preset)
.ToList();
if (candidates.Count == 0)
{
Rs485StatusText = "未找到已启用的 RS485 泵通道";
return;
}
var successCount = 0;
var failedCount = 0;
foreach (var pump in candidates)
{
if (await TryWritePumpSetpointCore(pump, "RS485 批量预设流量写入", allowAutoStartAfterWrite: false))
{
successCount++;
}
else
{
failedCount++;
}
}
var summary = $"批量写入完成:成功 {successCount} 台,失败 {failedCount} 台。批量模式仅写入目标值,不自动启动。";
Rs485StatusText = summary;
LatestAction = summary;
TraceEvents.Insert(0, NewTrace("RS485 批量写入", summary));
}
private async Task<bool> TryWritePumpSetpointCore(
PumpControlChannel? pump,
string operationName,
bool allowAutoStartAfterWrite)
{
if (!EnsureSessionEditable(operationName))
{
return false;
}
if (!TryPreparePumpForRs485(pump, requireCalibration: true, out var activePump, out var targetFlow))
{
return;
return false;
}
if (activePump.IsRunning)
{
activePump.SetpointStatusText = "泵运行中,禁止改写预设";
Rs485StatusText = $"{activePump.Name} 运行中,禁止改写预设";
return;
return false;
}
var rawValue = ConvertFlowToRawSpeed(activePump, targetFlow!.Value);
if (rawValue is < 0 or > ushort.MaxValue)
if (rawValue is < 0 or > MaxRs485MotorCommand)
{
activePump.SetpointStatusText = "换算后的速度值超出 0~65535";
activePump.SetpointStatusText = $"换算后的速度值超出 0~{MaxRs485MotorCommand}";
Rs485StatusText = $"{activePump.Name} 写入失败:速度值超出范围";
return;
return false;
}
if (!TryBeginRs485PumpOperation(activePump, "写入"))
{
return;
return false;
}
try
{
var result = _rs485PumpFlowService.WritePumpPreset(BuildRs485Request(activePump), (ushort)rawValue);
var request = BuildRs485Request(activePump);
var result = await Task.Run(() => _rs485PumpFlowService.WritePumpPreset(request, (ushort)rawValue));
ApplyRs485OperationResult(activePump, result, "写入泵预设");
if (result.Success)
{
activePump.PendingSetpointText = targetFlow.Value.ToString("F2", CultureInfo.InvariantCulture);
CacheConfirmedRs485Setpoint(activePump, rawValue, targetFlow.Value);
TryAutoStartPumpAfterRs485Write(activePump, targetFlow.Value);
if (allowAutoStartAfterWrite)
{
await TryAutoStartPumpAfterRs485Write(activePump, targetFlow.Value);
}
}
return result.Success;
}
finally
{
@@ -417,7 +572,7 @@ public partial class MainViewModel
}
}
private void TryAutoStartPumpAfterRs485Write(PumpControlChannel pump, double targetFlow)
private async Task TryAutoStartPumpAfterRs485Write(PumpControlChannel pump, double targetFlow)
{
if (!Rs485AutoStartPumpAfterWrite)
{
@@ -445,12 +600,14 @@ public partial class MainViewModel
return;
}
var directResult = _pumpActuationService.StartPump(BuildRs485Request(pump), rawMotorSpeed);
var request = BuildRs485Request(pump);
var directResult = await Task.Run(() => _pumpActuationService.StartPump(request, rawMotorSpeed));
ApplyRs485OperationResult(pump, directResult, "RS485 直接启泵");
if (directResult.Success)
{
CacheResolvedRs485Setpoint(pump, rawMotorSpeed);
ApplyOptimisticRs485PumpState(pump, isRunning: true);
ApplyPostCommandPumpState(pump, directResult, expectedRunning: true);
await RefreshTelemetryAsync();
}
return;
@@ -585,6 +742,14 @@ public partial class MainViewModel
pump.IsRunning = result.RunStatus.Value == 1;
}
}
else if (IsRs485ManagedPump(pump))
{
pump.Rs485RunStatusCode = null;
if (!pump.PendingRs485RunningState.HasValue)
{
pump.StateAvailable = false;
}
}
LatestAction = $"{traceCategory}{pump.Name} / {result.Message}";
TraceEvents.Insert(0, NewTrace(traceCategory, $"{pump.Name} / {result.Message}"));
@@ -601,6 +766,12 @@ public partial class MainViewModel
rawMotorSpeed = 0;
message = string.Empty;
if (!pump.HasConfirmedSetpointCalibration)
{
message = $"{pump.Name} 尚未完成流量换算标定确认";
return false;
}
var candidateRaw = pump.ConfirmedSetpointAvailable ? pump.ConfirmedRawSetpointValue : 0;
if (candidateRaw <= 0)
{
@@ -608,9 +779,9 @@ public partial class MainViewModel
return false;
}
if (candidateRaw > short.MaxValue)
if (candidateRaw > MaxRs485MotorCommand)
{
message = $"{pump.Name} 的直接启停控制值超过 32767";
message = $"{pump.Name} 的直接启停控制值超过 {MaxRs485MotorCommand}";
return false;
}
@@ -669,14 +840,26 @@ public partial class MainViewModel
}
}
private static void ApplyOptimisticRs485PumpState(PumpControlChannel pump, bool isRunning)
private static void ApplyPostCommandPumpState(
PumpControlChannel pump,
Rs485PumpFlowOperationResult result,
bool expectedRunning)
{
pump.IsRunning = isRunning;
pump.StateAvailable = true;
pump.Rs485RunStatusCode = (ushort)(isRunning ? 1 : 0);
if (result.RunStatus.HasValue)
{
pump.PendingRs485RunningState = null;
pump.IsRunning = result.RunStatus.Value == 1;
pump.StateAvailable = result.RunStatus.Value is 0 or 1;
pump.Rs485RunStatusCode = result.RunStatus.Value;
return;
}
pump.PendingRs485RunningState = expectedRunning;
pump.StateAvailable = false;
pump.Rs485RunStatusCode = null;
}
private bool TryTogglePumpControlViaRs485(PumpControlChannel pump, bool nextState)
private async Task<bool> TryTogglePumpControlViaRs485(PumpControlChannel pump, bool nextState)
{
if (!ShouldUseRs485DirectPumpControl(pump))
{
@@ -712,22 +895,26 @@ public partial class MainViewModel
return true;
}
var startResult = _pumpActuationService.StartPump(BuildRs485Request(pump), rawMotorSpeed);
var request = BuildRs485Request(pump);
var startResult = await Task.Run(() => _pumpActuationService.StartPump(request, rawMotorSpeed));
ApplyRs485OperationResult(pump, startResult, "RS485 泵控");
if (startResult.Success)
{
CacheResolvedRs485Setpoint(pump, rawMotorSpeed);
ApplyOptimisticRs485PumpState(pump, isRunning: true);
ApplyPostCommandPumpState(pump, startResult, expectedRunning: true);
await RefreshTelemetryAsync();
}
return true;
}
var stopResult = _pumpActuationService.StopPump(BuildRs485Request(pump));
var stopRequest = BuildRs485Request(pump);
var stopResult = await Task.Run(() => _pumpActuationService.StopPump(stopRequest));
ApplyRs485OperationResult(pump, stopResult, "RS485 泵控");
if (stopResult.Success)
{
ApplyOptimisticRs485PumpState(pump, isRunning: false);
ApplyPostCommandPumpState(pump, stopResult, expectedRunning: false);
await RefreshTelemetryAsync();
}
return true;

View File

@@ -26,10 +26,12 @@ public partial class MainViewModel : ObservableObject, IDisposable
private const double AntiCollapseTargetNegativePressure = -6.67;
private const double PressureKpaToMmHg = 7.50061683d;
private const int TrendHistoryCapacity = 60;
private static readonly string LimitSettingsPath = Path.Combine(
private const string LimitSettingsFileName = "manufacturer-limits.json";
private static readonly string LegacyLimitSettingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Cardiopulmonarybypasssystems",
"manufacturer-limits.json");
LimitSettingsFileName);
private static readonly string LimitSettingsPath = ResolveLimitSettingsPath();
private double? _antiCollapseBaselinePressureDrop;
private double? _antiCollapseBaselineFlow;
private DateTime? _antiCollapseBaselineCapturedAt;
@@ -49,6 +51,73 @@ public partial class MainViewModel : ObservableObject, IDisposable
private double? _proximalPressureRawKpa;
private double? _distalPressureRawKpa;
private static string ResolveLimitSettingsPath()
{
var applicationPath = Path.Combine(AppContext.BaseDirectory, LimitSettingsFileName);
var developmentProjectDirectory = ResolveDevelopmentProjectDirectory();
if (!string.IsNullOrWhiteSpace(developmentProjectDirectory))
{
var projectPath = Path.Combine(developmentProjectDirectory, LimitSettingsFileName);
if (File.Exists(projectPath))
{
return projectPath;
}
}
return applicationPath;
}
private static string? ResolveDevelopmentProjectDirectory()
{
var candidates = new[]
{
Directory.GetCurrentDirectory(),
AppContext.BaseDirectory
};
foreach (var candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}
var directory = new DirectoryInfo(candidate);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Cardiopulmonarybypasssystems.csproj")))
{
return directory.FullName;
}
directory = directory.Parent;
}
}
return null;
}
private static void MigrateLegacyLimitSettingsIfNeeded()
{
if (string.Equals(LegacyLimitSettingsPath, LimitSettingsPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!File.Exists(LegacyLimitSettingsPath) || File.Exists(LimitSettingsPath))
{
return;
}
var directory = Path.GetDirectoryName(LimitSettingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
File.Copy(LegacyLimitSettingsPath, LimitSettingsPath, overwrite: false);
}
[ObservableProperty]
private bool engineeringRegisterPanelVisible;
@@ -234,8 +303,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
}
HemolysisTestParameters.PropertyChanged += OnHemolysisTestParametersPropertyChanged;
LoadManufacturerLimitSettings();
InitializeRs485FlowControl();
LoadManufacturerLimitSettings();
SelectedItem = InspectionItems.FirstOrDefault();
if (SelectedItem is not null)
@@ -259,6 +328,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
public ObservableCollection<DeviceChannel> Channels { get; }
public ObservableCollection<PumpControlChannel> PumpControls { get; }
public ObservableCollection<ValveControlChannel> ValveControls { get; }
public IEnumerable<PumpControlChannel> NegativeAssistPumpControls => PumpControlsFor("NegativeAssistPump");
public IEnumerable<PumpControlChannel> PressureDropPumpControls => PumpControlsFor("NegativeAssistPump", "PressureDropPump");
public IEnumerable<PumpControlChannel> RecirculationPumpControls => PumpControlsFor("RecirculationMainPump", "RecirculationReturnPump", "RecirculationDrainagePump");
public IEnumerable<PumpControlChannel> KinkResistancePumpControls => PumpControlsFor("KinkResistancePump");
@@ -610,7 +680,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
}
[RelayCommand]
private void TogglePumpControl(PumpControlChannel? pump)
private async Task TogglePumpControl(PumpControlChannel? pump)
{
if (!EnsureSessionEditable("泵控"))
{
@@ -623,7 +693,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
}
var nextState = !pump.IsRunning;
if (TryTogglePumpControlViaRs485(pump, nextState))
if (await TryTogglePumpControlViaRs485(pump, nextState))
{
return;
}
@@ -1722,14 +1792,6 @@ public partial class MainViewModel : ObservableObject, IDisposable
private ObservableCollection<EngineeringRegisterItem> BuildEngineeringRegisters() =>
[
CreateEngineeringRegister("流量系数 1", 1006, true),
CreateEngineeringRegister("流量系数 2", 1016, true),
CreateEngineeringRegister("流量系数 3", 1026, true),
CreateEngineeringRegister("流量系数 4", 1036, true),
CreateEngineeringRegister("流量系数 5", 1046, true),
CreateEngineeringRegister("流量系数 6", 1056, true),
CreateEngineeringRegister("流量系数 7", 1066, true),
CreateEngineeringRegister("流量系数 8", 1076, true),
CreateEngineeringRegister("近端压力系数", 1328, true),
CreateEngineeringRegister("远端压力系数", 1378, true),
CreateEngineeringRegister("近端压力显示", 1330, false, usesFloatDisplay: true),
@@ -2335,6 +2397,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
{
try
{
MigrateLegacyLimitSettingsIfNeeded();
if (!File.Exists(LimitSettingsPath))
{
return;
@@ -2698,6 +2762,33 @@ public partial class MainViewModel : ObservableObject, IDisposable
if (IsRs485ManagedPump(pump))
{
if (pump.PendingRs485RunningState.HasValue)
{
if (pump.FlowAvailable)
{
var inferredRunning = pump.IsFlowEstablished;
if (inferredRunning == pump.PendingRs485RunningState.Value)
{
pump.IsRunning = inferredRunning;
pump.StateAvailable = true;
pump.PendingRs485RunningState = null;
}
else
{
pump.StateAvailable = false;
}
}
else
{
pump.StateAvailable = false;
}
}
else if (!pump.Rs485RunStatusCode.HasValue && pump.FlowAvailable)
{
pump.IsRunning = pump.IsFlowEstablished;
pump.StateAvailable = true;
}
continue;
}

View File

@@ -0,0 +1,162 @@
{
"ProductModel": "24Fr/32Fr \u53CC\u8154",
"ApplicablePopulation": "\u6210\u4EBA",
"RatedMaxFlow": 6,
"KinkResistanceMinimumFlow": 1,
"KinkResistanceOuterDiameter": 8,
"PressureDropLimit50": 20,
"PressureDropLimit75": 22,
"PressureDropLimit100": 24,
"AntiCollapseAllowedIncreaseRate": 50,
"RecirculationAllowedLimit": 8,
"Rs485SerialSettings": {
"PortName": "COM3",
"BaudRate": 9600,
"Parity": "Even",
"DataBits": 8,
"StopBits": 1,
"ReadTimeoutMs": 500,
"WriteTimeoutMs": 500,
"AutoSwitchPresetMode": false,
"PersistPresetAfterWrite": false,
"AutoStartPumpAfterWrite": false
},
"Rs485PumpBindings": [
{
"PumpKey": "PressureDropPump",
"Enabled": true,
"CalibrationConfirmed": false,
"SlaveAddress": 1,
"ForwardSpeedRegister": 162,
"ReverseSpeedRegister": 163,
"RunStatusRegister": 243,
"DeviceAddressRegister": 250,
"PresetModeRegister": 251,
"SavePresetRegister": 416,
"MotorControlRegister": 64,
"RawPerLitrePerMinute": 1000,
"RawOffset": 0,
"MinFlowLpm": 0,
"MaxFlowLpm": 7
},
{
"PumpKey": "RecirculationMainPump",
"Enabled": true,
"CalibrationConfirmed": false,
"SlaveAddress": 2,
"ForwardSpeedRegister": 162,
"ReverseSpeedRegister": 163,
"RunStatusRegister": 243,
"DeviceAddressRegister": 250,
"PresetModeRegister": 251,
"SavePresetRegister": 416,
"MotorControlRegister": 64,
"RawPerLitrePerMinute": 1000,
"RawOffset": 0,
"MinFlowLpm": 0,
"MaxFlowLpm": 7
},
{
"PumpKey": "RecirculationReturnPump",
"Enabled": true,
"CalibrationConfirmed": false,
"SlaveAddress": 3,
"ForwardSpeedRegister": 162,
"ReverseSpeedRegister": 163,
"RunStatusRegister": 243,
"DeviceAddressRegister": 250,
"PresetModeRegister": 251,
"SavePresetRegister": 416,
"MotorControlRegister": 64,
"RawPerLitrePerMinute": 1000,
"RawOffset": 0,
"MinFlowLpm": 0,
"MaxFlowLpm": 7
},
{
"PumpKey": "RecirculationDrainagePump",
"Enabled": true,
"CalibrationConfirmed": false,
"SlaveAddress": 4,
"ForwardSpeedRegister": 162,
"ReverseSpeedRegister": 163,
"RunStatusRegister": 243,
"DeviceAddressRegister": 250,
"PresetModeRegister": 251,
"SavePresetRegister": 416,
"MotorControlRegister": 64,
"RawPerLitrePerMinute": 1000,
"RawOffset": 0,
"MinFlowLpm": 0,
"MaxFlowLpm": 7
},
{
"PumpKey": "KinkResistancePump",
"Enabled": true,
"CalibrationConfirmed": false,
"SlaveAddress": 5,
"ForwardSpeedRegister": 162,
"ReverseSpeedRegister": 163,
"RunStatusRegister": 243,
"DeviceAddressRegister": 250,
"PresetModeRegister": 251,
"SavePresetRegister": 416,
"MotorControlRegister": 64,
"RawPerLitrePerMinute": 1000,
"RawOffset": 0,
"MinFlowLpm": 0,
"MaxFlowLpm": 7
},
{
"PumpKey": "HemolysisDrainageSinglePump",
"Enabled": true,
"CalibrationConfirmed": false,
"SlaveAddress": 6,
"ForwardSpeedRegister": 162,
"ReverseSpeedRegister": 163,
"RunStatusRegister": 243,
"DeviceAddressRegister": 250,
"PresetModeRegister": 251,
"SavePresetRegister": 416,
"MotorControlRegister": 64,
"RawPerLitrePerMinute": 1000,
"RawOffset": 0,
"MinFlowLpm": 0,
"MaxFlowLpm": 7
},
{
"PumpKey": "HemolysisReturnSinglePump",
"Enabled": true,
"CalibrationConfirmed": false,
"SlaveAddress": 7,
"ForwardSpeedRegister": 162,
"ReverseSpeedRegister": 163,
"RunStatusRegister": 243,
"DeviceAddressRegister": 250,
"PresetModeRegister": 251,
"SavePresetRegister": 416,
"MotorControlRegister": 64,
"RawPerLitrePerMinute": 1000,
"RawOffset": 0,
"MinFlowLpm": 0,
"MaxFlowLpm": 7
},
{
"PumpKey": "HemolysisDualLumenPump",
"Enabled": true,
"CalibrationConfirmed": false,
"SlaveAddress": 8,
"ForwardSpeedRegister": 162,
"ReverseSpeedRegister": 163,
"RunStatusRegister": 243,
"DeviceAddressRegister": 250,
"PresetModeRegister": 251,
"SavePresetRegister": 416,
"MotorControlRegister": 64,
"RawPerLitrePerMinute": 1000,
"RawOffset": 0,
"MinFlowLpm": 0,
"MaxFlowLpm": 7
}
]
}