添加分组

This commit is contained in:
GukSang.Jin
2026-03-11 16:27:00 +08:00
parent 9e729bcbea
commit 4e27c9ec6b
6 changed files with 849 additions and 184 deletions

View File

@@ -0,0 +1,57 @@
using System.Collections;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace Cardiopulmonarybypasssystems.Converters;
public sealed class TrendPointCollectionConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length < 4
|| values[0] is not IEnumerable enumerable
|| values[1] is not double width
|| values[2] is not double height
|| values[3] is not double maxValue
|| width <= 0
|| height <= 0
|| maxValue <= 0)
{
return new PointCollection();
}
var samples = enumerable.Cast<object>()
.Select(item => item is double value ? value : System.Convert.ToDouble(item, culture))
.ToList();
if (samples.Count == 0)
{
return new PointCollection();
}
if (samples.Count == 1)
{
return new PointCollection
{
new System.Windows.Point(0, height - samples[0] / maxValue * height)
};
}
var points = new PointCollection(samples.Count);
var xStep = width / Math.Max(samples.Count - 1, 1);
for (var index = 0; index < samples.Count; index++)
{
var x = xStep * index;
var yRatio = Math.Clamp(samples[index] / maxValue, 0d, 1d);
var y = height - yRatio * height;
points.Add(new System.Windows.Point(x, y));
}
return points;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}

View File

@@ -4,11 +4,15 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:Cardiopulmonarybypasssystems.Models"
xmlns:converters="clr-namespace:Cardiopulmonarybypasssystems.Converters"
mc:Ignorable="d"
Title="&#x5FC3;&#x80BA;&#x8F6C;&#x6D41;&#x68C0;&#x6D4B;"
Width="1024"
Height="800"
WindowStartupLocation="CenterScreen">
<Window.Resources>
<converters:TrendPointCollectionConverter x:Key="TrendPointCollectionConverter" />
</Window.Resources>
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
@@ -398,150 +402,380 @@
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="0,6,0,0">
<Border Style="{StaticResource CardBorderStyle}">
<Border.Resources>
<DataTemplate x:Key="PumpControlCardTemplate" DataType="{x:Type models:PumpControlChannel}">
<Border Width="220" Margin="0,0,10,10" Padding="14" Background="#FFF4F8FA" CornerRadius="14">
<StackPanel>
<DockPanel>
<Ellipse Width="12" Height="12" Margin="0,3,8,0" Fill="{Binding IndicatorColor}" DockPanel.Dock="Left" />
<TextBlock FontSize="16" FontWeight="SemiBold" Text="{Binding Name}" TextWrapping="Wrap" />
</DockPanel>
<TextBlock Margin="0,8,0,0" Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding FlowDisplay}" />
<TextBlock Margin="0,4,0,0" Style="{StaticResource CaptionStyle}" Text="{Binding StateText}" />
<TextBlock Style="{StaticResource CaptionStyle}" Text="{Binding StateHint}" />
<Button Margin="0,10,0,0"
Command="{Binding DataContext.TogglePumpControlCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Content="{Binding ActionText}"
Background="#FF4D8C72" />
</StackPanel>
</Border>
</DataTemplate>
</Border.Resources>
<StackPanel>
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="&#x5B9E;&#x65F6;&#x603B;&#x89C8;" />
<WrapPanel Margin="0,0,0,4">
<Border Width="150" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x9636;&#x6BB5;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding CurrentStage}" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Width="150" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x8BBE;&#x5907;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding DeviceStatus}" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Width="150" Margin="0,0,8,8" Padding="14" Background="#FFE7F5F3" CornerRadius="14">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x5408;&#x683C;&#x7387;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="24" Text="{Binding ComplianceDisplay}" />
</StackPanel>
</Border>
<Border Width="150" Margin="0,0,8,8" Padding="14" Background="#FFE9EFF9" CornerRadius="14">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x5408;&#x683C;&#x9879;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="24" Text="{Binding QualifiedCount}" />
</StackPanel>
</Border>
<Border Width="150" Margin="0,0,8,8" Padding="14" Background="#FFFDEBE7" CornerRadius="14">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x9884;&#x8B66;/&#x4E0D;&#x5408;&#x683C;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="24" Text="{Binding WarningCount}" />
</StackPanel>
</Border>
<Border Width="150" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x5F85;&#x68C0;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding PendingCount}" />
</StackPanel>
</Border>
<Border Width="150" Margin="0,0,0,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x544A;&#x8B66;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding AlarmMessages.Count}" />
</StackPanel>
</Border>
</WrapPanel>
</StackPanel>
</Border>
<Border Style="{StaticResource CardBorderStyle}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="&#x6D41;&#x91CF;&#x5FEB;&#x7167;" />
<UniformGrid Columns="3" Margin="0,0,0,8">
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="泵启动控制" />
<UniformGrid Columns="2" Margin="0,10,0,0">
<Border Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x4E3B;&#x6CF5;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding PumpFlowDisplay}" />
<TextBlock Margin="0,4,0,6" Style="{StaticResource CaptionStyle}" Text="{Binding PumpFlowLoadDisplay}" />
<ProgressBar Minimum="0" Maximum="1" Value="{Binding PumpFlowNormalizedValue, Mode=OneWay}" Height="10" Foreground="{StaticResource AccentBrush}" Background="#FFDDE7EC" />
</StackPanel>
</Border>
<Border Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x5F15;&#x6D41;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding DrainageFlowDisplay}" />
<TextBlock Margin="0,4,0,6" Style="{StaticResource CaptionStyle}" Text="{Binding DrainageFlowLoadDisplay}" />
<ProgressBar Minimum="0" Maximum="1" Value="{Binding DrainageFlowNormalizedValue, Mode=OneWay}" Height="10" Foreground="{StaticResource AccentBrush}" Background="#FFDDE7EC" />
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="压力降 / 抗塌陷" />
<ItemsControl Margin="0,8,0,0" ItemsSource="{Binding PressureDropPumpControls}" ItemTemplate="{StaticResource PumpControlCardTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
</Border>
<Border Margin="0,0,0,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x56DE;&#x8F93;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding ReturnFlowDisplay}" />
<TextBlock Margin="0,4,0,6" Style="{StaticResource CaptionStyle}" Text="{Binding ReturnFlowLoadDisplay}" />
<ProgressBar Minimum="0" Maximum="1" Value="{Binding ReturnFlowNormalizedValue, Mode=OneWay}" Height="10" Foreground="{StaticResource AccentBrush}" Background="#FFDDE7EC" />
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="再循环" />
<ItemsControl Margin="0,8,0,0" ItemsSource="{Binding RecirculationPumpControls}" ItemTemplate="{StaticResource PumpControlCardTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
</Border>
<Border Margin="0,0,8,0" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="抗扭结" />
<ItemsControl Margin="0,8,0,0" ItemsSource="{Binding KinkResistancePumpControls}" ItemTemplate="{StaticResource PumpControlCardTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
</Border>
<Border Margin="0,0,0,0" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="血细胞破坏" />
<ItemsControl Margin="0,8,0,0" ItemsSource="{Binding HemolysisPumpControls}" ItemTemplate="{StaticResource PumpControlCardTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
</Border>
</UniformGrid>
<WrapPanel>
<Border Width="180" Margin="0,0,8,8" Padding="14" Background="#FFEAF6F3" CornerRadius="14">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x518D;&#x5FAA;&#x73AF;&#x7387;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding RealtimeRecirculationDisplay}" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,8,8" Padding="14" Background="#FFF6EFE2" CornerRadius="14">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x538B;&#x529B;&#x964D;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding DeltaPressureDisplay}" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,0,8" Padding="14" Background="#FFEFF1FA" CornerRadius="14">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x4E3B;&#x6CF5;/&#x56DE;&#x8F93;&#x5DEE;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding FlowImbalanceDisplay}" />
</StackPanel>
</Border>
</WrapPanel>
</StackPanel>
</Border>
<Border Style="{StaticResource CardBorderStyle}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="&#x538B;&#x529B;&#x4E0E;&#x8F85;&#x52A9;&#x6307;&#x6807;" />
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="关键实时读数" />
<WrapPanel Margin="0,0,0,4">
<Border Width="180" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x8FD1;&#x7AEF;&#x538B;&#x529B;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding Channels[4].DisplayValue}" />
<TextBlock Style="{StaticResource CaptionStyle}" Text="阶段" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding CurrentStage}" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x8FDC;&#x7AEF;&#x538B;&#x529B;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding Channels[3].DisplayValue}" />
<TextBlock Style="{StaticResource CaptionStyle}" Text="设备状态" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding DeviceStatus}" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x8D1F;&#x538B;&#x8F85;&#x52A9;&#x5F15;&#x6D41;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding NegativeAssistPressureDisplay}" />
<TextBlock Style="{StaticResource CaptionStyle}" Text="近端压力" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding ProximalPressureDisplay}" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,0,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x6A21;&#x62DF;&#x8840;&#x6DB2;&#x6E29;&#x5EA6;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding TemperatureDisplay}" />
<TextBlock Style="{StaticResource CaptionStyle}" Text="远端压力" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding DistalPressureDisplay}" />
</StackPanel>
</Border>
</WrapPanel>
<WrapPanel>
<Border Width="220" Margin="0,0,8,0" Style="{StaticResource PanelSectionStyle}">
<Border Width="180" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="fHb &#x8D8B;&#x52BF;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding FreeHemoglobinDisplay}" />
<TextBlock Style="{StaticResource CaptionStyle}" Text="压力降/抗塌陷" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding PressureDropPumpFlowDisplay}" />
</StackPanel>
</Border>
<Border Width="220" Margin="0,0,0,0" Style="{StaticResource PanelSectionStyle}">
<Border Width="180" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="&#x767D;&#x7EC6;&#x80DE;&#x51CF;&#x5C11;&#x7387;" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="22" Text="{Binding WhiteCellLossDisplay}" />
<TextBlock Style="{StaticResource CaptionStyle}" Text="再循环主泵" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding RecirculationPumpFlowDisplay}" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="回流泵" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding ReturnFlowDisplay}" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,0,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="引流泵" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding DrainageFlowDisplay}" />
</StackPanel>
</Border>
</WrapPanel>
<WrapPanel>
<Border Width="180" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="抗扭结" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding KinkResistancePumpFlowDisplay}" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="血细胞破坏单腔引流" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding HemolysisDrainageSingleFlowDisplay}" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,8,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="血细胞破坏单腔回输" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding HemolysisReturnSingleFlowDisplay}" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,0,8" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="血细胞破坏双腔" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding HemolysisDualLumenFlowDisplay}" />
</StackPanel>
</Border>
</WrapPanel>
<WrapPanel>
<Border Width="180" Margin="0,0,8,0" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="负压辅助" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding NegativeAssistPressureDisplay}" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,8,0" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="压力降 ΔP" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding DeltaPressureDisplay}" />
</StackPanel>
</Border>
<Border Width="180" Margin="0,0,0,0" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource CaptionStyle}" Text="再循环率" />
<TextBlock Style="{StaticResource MetricValueStyle}" FontSize="20" Text="{Binding RealtimeRecirculationDisplay}" />
</StackPanel>
</Border>
</WrapPanel>
<DockPanel Margin="0,12,0,8">
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="&#x8D8B;&#x52BF;&#x56FE;" />
<Button DockPanel.Dock="Right"
MinWidth="96"
Command="{Binding ClearTrendDataCommand}"
Content="&#x6E05;&#x7A7A;&#x66F2;&#x7EBF;"
Background="#FF6B8791" />
</DockPanel>
<Grid Margin="0,0,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="压力/ΔP 趋势" />
<WrapPanel Margin="0,0,0,8">
<Border Width="12" Height="12" Margin="0,2,6,0" Background="#FF0B7A75" CornerRadius="6" />
<TextBlock Margin="0,0,12,0" Style="{StaticResource CaptionStyle}" Text="近端压力" />
<Border Width="12" Height="12" Margin="0,2,6,0" Background="#FF3C6FB6" CornerRadius="6" />
<TextBlock Margin="0,0,12,0" Style="{StaticResource CaptionStyle}" Text="远端压力" />
<Border Width="12" Height="12" Margin="0,2,6,0" Background="#FFD38A16" CornerRadius="6" />
<TextBlock Style="{StaticResource CaptionStyle}" Text="ΔP" />
</WrapPanel>
<Grid Height="180" Background="#FFF7FBFC">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0" BorderBrush="#FFE0EAEE" BorderThickness="0,0,0,1" />
<Border Grid.Row="1" BorderBrush="#FFE0EAEE" BorderThickness="0,0,0,1" />
<Border Grid.Row="2" BorderBrush="#FFE0EAEE" BorderThickness="0,0,0,1" />
<Canvas>
<Polyline Stroke="#FF0B7A75" StrokeThickness="2">
<Polyline.Points>
<MultiBinding Converter="{StaticResource TrendPointCollectionConverter}">
<Binding Path="ProximalPressureTrendValues" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualWidth" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualHeight" />
<Binding Path="PressureTrendMax" />
</MultiBinding>
</Polyline.Points>
</Polyline>
<Polyline Stroke="#FF3C6FB6" StrokeThickness="2">
<Polyline.Points>
<MultiBinding Converter="{StaticResource TrendPointCollectionConverter}">
<Binding Path="DistalPressureTrendValues" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualWidth" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualHeight" />
<Binding Path="PressureTrendMax" />
</MultiBinding>
</Polyline.Points>
</Polyline>
<Polyline Stroke="#FFD38A16" StrokeThickness="2">
<Polyline.Points>
<MultiBinding Converter="{StaticResource TrendPointCollectionConverter}">
<Binding Path="DeltaPressureTrendValues" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualWidth" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualHeight" />
<Binding Path="PressureTrendMax" />
</MultiBinding>
</Polyline.Points>
</Polyline>
</Canvas>
</Grid>
</StackPanel>
</Border>
<Border Grid.Column="2" Style="{StaticResource PanelSectionStyle}">
<StackPanel>
<TextBlock Style="{StaticResource SectionTitleStyle}" Text="{Binding FlowTrendTitle}" />
<WrapPanel Margin="0,0,0,8">
<Border Width="12" Height="12" Margin="0,2,6,0" Background="#FF0B7A75" CornerRadius="6" />
<TextBlock Margin="0,0,12,0" Style="{StaticResource CaptionStyle}" Text="{Binding FlowTrendPrimaryLabel}" />
<Border Width="12" Height="12" Margin="0,2,6,0" Background="#FF3C6FB6" CornerRadius="6">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasFlowTrendSecondary}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<TextBlock Margin="0,0,12,0" Text="{Binding FlowTrendSecondaryLabel}">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource CaptionStyle}">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasFlowTrendSecondary}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<Border Width="12" Height="12" Margin="0,2,6,0" Background="#FFD38A16" CornerRadius="6">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasFlowTrendTertiary}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<TextBlock Text="{Binding FlowTrendTertiaryLabel}">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource CaptionStyle}">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasFlowTrendTertiary}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</WrapPanel>
<Grid Height="180" Background="#FFF7FBFC">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0" BorderBrush="#FFE0EAEE" BorderThickness="0,0,0,1" />
<Border Grid.Row="1" BorderBrush="#FFE0EAEE" BorderThickness="0,0,0,1" />
<Border Grid.Row="2" BorderBrush="#FFE0EAEE" BorderThickness="0,0,0,1" />
<Canvas>
<Polyline Stroke="#FF0B7A75" StrokeThickness="2">
<Polyline.Points>
<MultiBinding Converter="{StaticResource TrendPointCollectionConverter}">
<Binding Path="ActiveFlowTrendPrimaryValues" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualWidth" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualHeight" />
<Binding Path="FlowTrendMax" />
</MultiBinding>
</Polyline.Points>
</Polyline>
<Polyline Stroke="#FF3C6FB6" StrokeThickness="2">
<Polyline.Style>
<Style TargetType="Polyline">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasFlowTrendSecondary}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Polyline.Style>
<Polyline.Points>
<MultiBinding Converter="{StaticResource TrendPointCollectionConverter}">
<Binding Path="ActiveFlowTrendSecondaryValues" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualWidth" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualHeight" />
<Binding Path="FlowTrendMax" />
</MultiBinding>
</Polyline.Points>
</Polyline>
<Polyline Stroke="#FFD38A16" StrokeThickness="2">
<Polyline.Style>
<Style TargetType="Polyline">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasFlowTrendTertiary}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Polyline.Style>
<Polyline.Points>
<MultiBinding Converter="{StaticResource TrendPointCollectionConverter}">
<Binding Path="ActiveFlowTrendTertiaryValues" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualWidth" />
<Binding RelativeSource="{RelativeSource AncestorType=Canvas}" Path="ActualHeight" />
<Binding Path="FlowTrendMax" />
</MultiBinding>
</Polyline.Points>
</Polyline>
</Canvas>
</Grid>
</StackPanel>
</Border>
</Grid>
</StackPanel>
</Border>
</StackPanel>

View File

@@ -0,0 +1,58 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Cardiopulmonarybypasssystems.Models;
public partial class PumpControlChannel : ObservableObject
{
private const double FlowEstablishedThreshold = 0.15d;
public required string Key { get; init; }
public required string Name { get; init; }
public int StartAddress { get; init; }
public int? FlowAddress { get; init; }
[ObservableProperty]
private bool isRunning;
[ObservableProperty]
private double flowValue;
public string StartAddressDisplay => $"M{StartAddress}";
public string FlowAddressDisplay => FlowAddress.HasValue ? $"D{FlowAddress.Value}" : "-";
public bool HasFlowTelemetry => FlowAddress.HasValue;
public bool IsFlowEstablished => !HasFlowTelemetry || FlowValue >= FlowEstablishedThreshold;
public string StateText => !IsRunning
? "停止"
: IsFlowEstablished
? "运行"
: "启动中";
public string StateHint => !IsRunning
? "泵未启动"
: IsFlowEstablished
? "流量已建立"
: "等待流量建立";
public string IndicatorColor => !IsRunning
? "#FFC8D4DA"
: IsFlowEstablished
? "#FF32B06A"
: "#FFD38A16";
public string FlowDisplay => FlowAddress.HasValue ? $"{FlowValue:F2} L/min" : "-";
public string ActionText => IsRunning ? "停止" : "启动";
partial void OnIsRunningChanged(bool value)
{
OnPropertyChanged(nameof(StateText));
OnPropertyChanged(nameof(StateHint));
OnPropertyChanged(nameof(IndicatorColor));
OnPropertyChanged(nameof(ActionText));
}
partial void OnFlowValueChanged(double value)
{
OnPropertyChanged(nameof(FlowDisplay));
OnPropertyChanged(nameof(IsFlowEstablished));
OnPropertyChanged(nameof(StateText));
OnPropertyChanged(nameof(StateHint));
OnPropertyChanged(nameof(IndicatorColor));
}
}

View File

@@ -5,5 +5,7 @@ namespace Cardiopulmonarybypasssystems.Services;
public interface IModbusTelemetryService
{
IReadOnlyList<DeviceChannel> GetChannels();
IReadOnlyList<PumpControlChannel> GetPumpControls();
IReadOnlyList<AlarmMessage> UpdateChannels();
void SetPumpRunning(string pumpKey, bool isRunning);
}

View File

@@ -11,7 +11,6 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
private const string IpAddress = "192.168.1.10";
private const int Port = 502;
private const byte SlaveId = 1;
private const ushort PumpFlowRegister = 1040;
private const ushort ProximalPressureRegister = 1330;
private const ushort DistalPressureRegister = 1380;
private const double FlowRegisterScale = 0.01d;
@@ -19,23 +18,64 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
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 List<DeviceChannel> _channels =
[
new() { Name = "主泵流量", Unit = "L/min", Value = 4.82, Min = 0, Max = 7 },
new() { Name = "静脉引流流量", Unit = "L/min", Value = 4.54, Min = 0, Max = 7 },
new() { Name = "动脉回输流量", Unit = "L/min", Value = 4.86, Min = 0, Max = 7 },
new() { Name = "主泵流量", Unit = "L/min", Value = 4.32, Min = 0, Max = 7 },
new() { Name = "再循环主泵流量", Unit = "L/min", Value = 4.86, Min = 0, Max = 7 },
new() { Name = "动脉回输流量", Unit = "L/min", Value = 4.74, Min = 0, Max = 7 },
new() { Name = "静脉引流流量", Unit = "L/min", Value = 4.46, Min = 0, Max = 7 },
new() { Name = "抗扭结主泵流量", Unit = "L/min", Value = 4.68, Min = 0, Max = 7 },
new() { Name = "血细胞破坏-单腔引流流量", Unit = "L/min", Value = 4.25, Min = 0, Max = 7 },
new() { Name = "血细胞破坏-单腔回输流量", Unit = "L/min", Value = 4.30, Min = 0, Max = 7 },
new() { Name = "血细胞破坏-双腔流量", Unit = "L/min", Value = 4.12, Min = 0, Max = 7 },
new() { Name = "远端压力", Unit = "mmHg", Value = 94, Min = 40, Max = 180 },
new() { Name = "近端压力", Unit = "mmHg", Value = 112, Min = 60, Max = 220 },
new() { Name = "负压辅助引流", Unit = "kPa", Value = -10.4, Min = -20, Max = 0 },
new() { Name = "负压辅助引流", Unit = "kPa", Value = -6.7, Min = -20, Max = 0 },
new() { Name = "模拟血液温度", Unit = "°C", Value = 37.1, Min = 34, Max = 40 },
new() { Name = "再循环率", Unit = "%", Value = 6.8, 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 }
];
private readonly List<PumpControlChannel> _pumpControls =
[
new() { Key = "NegativeAssistPump", Name = "负压泵", StartAddress = 0 },
new() { Key = "PressureDropPump", Name = "压力降/抗塌陷泵", StartAddress = 1, FlowAddress = 1000, IsRunning = true },
new() { Key = "RecirculationMainPump", Name = "再循环主泵", StartAddress = 2, FlowAddress = 1010, IsRunning = true },
new() { Key = "RecirculationReturnPump", Name = "回流泵", StartAddress = 3, FlowAddress = 1020, IsRunning = true },
new() { Key = "RecirculationDrainagePump", Name = "引流泵", StartAddress = 4, FlowAddress = 1030, IsRunning = true },
new() { Key = "KinkResistancePump", Name = "抗扭结泵", StartAddress = 5, FlowAddress = 1040, IsRunning = true },
new() { Key = "HemolysisDrainageSinglePump", Name = "血细胞破坏-单腔引流泵", StartAddress = 6, FlowAddress = 1050 },
new() { Key = "HemolysisReturnSinglePump", Name = "血细胞破坏-单腔回输泵", StartAddress = 7, FlowAddress = 1060 },
new() { Key = "HemolysisDualLumenPump", Name = "血细胞破坏-双腔泵", StartAddress = 8, FlowAddress = 1070 }
];
private TcpClient? _tcpClient;
private IModbusMaster? _master;
@@ -49,19 +89,55 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
return _channels;
}
public IReadOnlyList<PumpControlChannel> GetPumpControls()
{
EnsureConnectionScheduled();
return _pumpControls;
}
public IReadOnlyList<AlarmMessage> UpdateChannels()
{
EnsureConnectionScheduled();
lock (_syncRoot)
{
var liveReadSucceeded = TryReadProcessChannels();
var liveReadSucceeded = TryReadPumpStatesAndFlows();
TryReadPressureChannels(liveReadSucceeded);
SimulateAuxiliaryChannels(liveReadSucceeded);
SyncDerivedChannels(liveReadSucceeded);
SyncDerivedChannels();
return BuildAlarms();
}
}
public void SetPumpRunning(string pumpKey, bool isRunning)
{
lock (_syncRoot)
{
var pump = _pumpControls.FirstOrDefault(item => item.Key == pumpKey);
if (pump is null)
{
return;
}
pump.IsRunning = isRunning;
if (_master is null)
{
return;
}
try
{
_master.WriteSingleCoil(SlaveId, (ushort)pump.StartAddress, isRunning);
}
catch
{
ReleaseConnection();
_nextConnectionAttemptUtc = DateTime.MinValue;
}
}
}
public void Dispose()
{
lock (_syncRoot)
@@ -127,59 +203,86 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
}
}
private bool TryReadProcessChannels()
private bool TryReadPumpStatesAndFlows()
{
if (_master is null)
{
SimulatePressureChannels();
SimulatePumpFlows();
return false;
}
try
{
var pumpFlowRaw = _master.ReadHoldingRegisters(SlaveId, PumpFlowRegister, 1)[0];
var proximalRaw = _master.ReadHoldingRegisters(SlaveId, ProximalPressureRegister, 1)[0];
var distalRaw = _master.ReadHoldingRegisters(SlaveId, DistalPressureRegister, 1)[0];
var coilStates = _master.ReadCoils(SlaveId, 0, (ushort)_pumpControls.Count);
for (var index = 0; index < _pumpControls.Count; index++)
{
_pumpControls[index].IsRunning = coilStates[index];
}
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);
}
SetSmoothedValue("主泵流量", ConvertRegisterToFlow(pumpFlowRaw));
SetSmoothedValue("近端压力", ConvertRegisterToPressure(proximalRaw, Channel("近端压力")));
SetSmoothedValue("远端压力", ConvertRegisterToPressure(distalRaw, Channel("远端压力")));
return true;
}
catch
{
ReleaseConnection();
_nextConnectionAttemptUtc = DateTime.MinValue;
SimulatePressureChannels();
SimulatePumpFlows();
return false;
}
}
private void SimulateAuxiliaryChannels(bool preservePumpFlow)
private void TryReadPressureChannels(bool liveReadSucceeded)
{
foreach (var channel in _channels.Where(channel =>
channel.Name is not "近端压力" and not "远端压力"
&& (!preservePumpFlow || channel.Name != "主泵流量")))
if (_master is null || !liveReadSucceeded)
{
var offset = channel.Name switch
{
"主泵流量" => Next(-0.08, 0.08),
"静脉引流流量" => Next(-0.08, 0.08),
"动脉回输流量" => Next(-0.06, 0.06),
"负压辅助引流" => Next(-0.6, 0.6),
"模拟血液温度" => Next(-0.15, 0.15),
"游离血红蛋白" => Next(-0.003, 0.003),
_ => Next(-0.5, 0.5)
};
SimulatePressureChannels();
return;
}
SetSmoothedValue(channel.Name, channel.Value + offset);
try
{
var proximalRaw = _master.ReadHoldingRegisters(SlaveId, ProximalPressureRegister, 1)[0];
var distalRaw = _master.ReadHoldingRegisters(SlaveId, DistalPressureRegister, 1)[0];
SetSmoothedValue("近端压力", ConvertRegisterToPressure(proximalRaw, Channel("近端压力")));
SetSmoothedValue("远端压力", ConvertRegisterToPressure(distalRaw, Channel("远端压力")));
}
catch
{
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()
{
SetSmoothedValue("近端压力", Channel("近端压力").Value + Next(-3.0, 3.0));
SetSmoothedValue("远端压力", Channel("远端压力").Value + Next(-2.5, 2.5));
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("远端压力");
@@ -189,34 +292,34 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
}
}
private void SyncDerivedChannels(bool livePumpFlow)
private void SimulateAuxiliaryChannels(bool liveReadSucceeded)
{
var pumpFlow = Channel("主泵流量");
var negativePump = Pump("NegativeAssistPump");
var negativeTarget = negativePump.IsRunning ? -6.67d : 0d;
SetSmoothedValue("负压辅助引流", negativeTarget + Next(-0.5, 0.5));
if (livePumpFlow)
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)
{
SetSmoothedValue("动脉回输流量", Math.Max(0d, pumpFlow.Value + Next(-0.08, 0.08)));
SetSmoothedValue("静脉引流流量", Math.Max(0d, pumpFlow.Value - Next(0.12, 0.42)));
}
else
{
var drainageFlow = Channel("静脉引流流量");
var returnFlow = Channel("动脉回输流量");
SetSmoothedValue(
"静脉引流流量",
Math.Min(drainageFlow.Value, returnFlow.Value - Next(0.12, 0.48)));
SetSmoothedValue(
"主泵流量",
(Channel("静脉引流流量").Value + returnFlow.Value) / 2d + Next(-0.05, 0.05));
return;
}
var recirculationRate = Channel("动脉回输流量").Value <= 0.01
? 0
: Math.Clamp(
(Channel("动脉回输流量").Value - Channel("静脉引流流量").Value)
/ Channel("动脉回输流量").Value * 100d,
0d,
100d);
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);
}
@@ -225,8 +328,6 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
var alarms = new List<AlarmMessage>();
var deltaPressure = Channel("近端压力").Value - Channel("远端压力").Value;
var recirculationRate = Channel("再循环率").Value;
var pumpFlow = Channel("主泵流量").Value;
var returnFlow = Channel("动脉回输流量").Value;
if (deltaPressure > 24)
{
@@ -234,7 +335,7 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
{
Timestamp = DateTime.Now,
Level = "高",
Message = $"压力降 ΔP {deltaPressure:F1} mmHg 偏高,请复核近端/远端压力与流量点。"
Message = $"压力降 ΔP {deltaPressure:F1} mmHg 偏高,请复核 D{ProximalPressureRegister}/D{DistalPressureRegister}。"
});
}
@@ -244,7 +345,7 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
{
Timestamp = DateTime.Now,
Level = "中",
Message = $"ModbusTcp 未连接,当前主泵流量 D{PumpFlowRegister} 与压力通道使用平滑模拟数据。目标 {IpAddress}:{Port}。"
Message = $"ModbusTcp 未连接,当前 M/D 泵控与流量使用本地平滑模拟。目标 {IpAddress}:{Port}。"
});
}
@@ -254,27 +355,17 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
{
Timestamp = DateTime.Now,
Level = "中",
Message = $"再循环率 {recirculationRate:F1}% 偏高,建议复核双腔位置和回流方向。"
Message = $"再循环率 {recirculationRate:F1}% 偏高,建议复核回路与泵状态。"
});
}
if (Math.Abs(pumpFlow - returnFlow) > 0.35)
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 = $"主泵/回输流量差 {Math.Abs(pumpFlow - returnFlow):F2} L/min建议检查流量传感器标定。"
});
}
if (Channel("游离血红蛋白").Value > 0.04)
{
alarms.Add(new AlarmMessage
{
Timestamp = DateTime.Now,
Level = "中",
Message = $"游离血红蛋白 {Channel("").Value:F3} g/L上升趋势需要关注。"
Message = $"{pump.Name} 已启动但流量未建立,请检查回路、泵头和传感器。"
});
}
@@ -316,6 +407,19 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
_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 ConvertRegisterToPressure(ushort rawValue, DeviceChannel channel)
@@ -324,6 +428,8 @@ public sealed class MockModbusTelemetryService : IModbusTelemetryService, IDispo
return Math.Clamp(signedValue, channel.Min, channel.Max);
}
private PumpControlChannel Pump(string key) => _pumpControls.First(pump => pump.Key == key);
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

@@ -17,6 +17,7 @@ public partial class MainViewModel : ObservableObject
private readonly IModbusTelemetryService _telemetryService;
private readonly DispatcherTimer _timer;
private const double AntiCollapseTargetNegativePressure = -6.67;
private const int TrendHistoryCapacity = 60;
private static readonly string LimitSettingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Cardiopulmonarybypasssystems",
@@ -135,6 +136,7 @@ public partial class MainViewModel : ObservableObject
FilteredItemsView = CollectionViewSource.GetDefaultView(InspectionItems);
FilteredItemsView.Filter = MatchesFilteredItem;
Channels = new ObservableCollection<DeviceChannel>(telemetryService.GetChannels());
PumpControls = new ObservableCollection<PumpControlChannel>(telemetryService.GetPumpControls());
TraceEvents = new ObservableCollection<TraceEvent>(repository.GetSeedTraceEvents());
AlarmMessages = new ObservableCollection<AlarmMessage>();
ResultStatusOptions = new ObservableCollection<string>(["待检", "合格", "预警", "不合格"]);
@@ -187,12 +189,28 @@ public partial class MainViewModel : ObservableObject
public ObservableCollection<InspectionItem> InspectionItems { get; }
public ICollectionView FilteredItemsView { get; }
public ObservableCollection<DeviceChannel> Channels { get; }
public ObservableCollection<PumpControlChannel> PumpControls { get; }
public IEnumerable<PumpControlChannel> PressureDropPumpControls => PumpControlsFor("NegativeAssistPump", "PressureDropPump");
public IEnumerable<PumpControlChannel> RecirculationPumpControls => PumpControlsFor("RecirculationMainPump", "RecirculationReturnPump", "RecirculationDrainagePump");
public IEnumerable<PumpControlChannel> KinkResistancePumpControls => PumpControlsFor("KinkResistancePump");
public IEnumerable<PumpControlChannel> HemolysisPumpControls => PumpControlsFor("HemolysisDrainageSinglePump", "HemolysisReturnSinglePump", "HemolysisDualLumenPump");
public ObservableCollection<TraceEvent> TraceEvents { get; }
public ObservableCollection<AlarmMessage> AlarmMessages { get; }
public ObservableCollection<string> ResultStatusOptions { get; }
public ObservableCollection<PressureDropPointEntry> PressureDropEntries { get; }
public ObservableCollection<KinkResistancePointEntry> KinkResistanceEntries { get; }
public ObservableCollection<RecirculationPointEntry> RecirculationEntries { get; }
public ObservableCollection<double> ProximalPressureTrendValues { get; } = [];
public ObservableCollection<double> DistalPressureTrendValues { get; } = [];
public ObservableCollection<double> DeltaPressureTrendValues { get; } = [];
public ObservableCollection<double> PressureDropPumpTrendValues { get; } = [];
public ObservableCollection<double> RecirculationMainPumpTrendValues { get; } = [];
public ObservableCollection<double> RecirculationReturnPumpTrendValues { get; } = [];
public ObservableCollection<double> RecirculationDrainagePumpTrendValues { get; } = [];
public ObservableCollection<double> KinkResistancePumpTrendValues { get; } = [];
public ObservableCollection<double> HemolysisDrainageSingleTrendValues { get; } = [];
public ObservableCollection<double> HemolysisReturnSingleTrendValues { get; } = [];
public ObservableCollection<double> HemolysisDualLumenTrendValues { get; } = [];
public ObservableCollection<string> ItemFilterOptions { get; } = new(["全部", "待填写", "已完成", "实时监控", "手动填写"]);
public bool HasFilteredItems => !FilteredItemsView.IsEmpty;
public IEnumerable<DeviceChannel> FlowSensorChannels => Channels.Where(IsFlowSensorChannel);
@@ -207,6 +225,61 @@ public partial class MainViewModel : ObservableObject
public string PumpFlowDisplay => $"{PumpFlow:F2} L/min";
public string DrainageFlowDisplay => $"{DrainageFlow:F2} L/min";
public string ReturnFlowDisplay => $"{ReturnFlow:F2} L/min";
public string PressureDropPumpFlowDisplay => $"{PressureDropPumpFlow:F2} L/min";
public string RecirculationPumpFlowDisplay => $"{RecirculationPumpFlow:F2} L/min";
public string KinkResistancePumpFlowDisplay => $"{KinkResistancePumpFlow:F2} L/min";
public string HemolysisDrainageSingleFlowDisplay => $"{HemolysisDrainageSingleFlow:F2} L/min";
public string HemolysisReturnSingleFlowDisplay => $"{HemolysisReturnSingleFlow:F2} L/min";
public string HemolysisDualLumenFlowDisplay => $"{HemolysisDualLumenFlow:F2} L/min";
public string FlowTrendTitle => SelectedItem?.Clause switch
{
"4.3.3" => "再循环流量趋势",
"4.2.3" => "抗扭结流量趋势",
"4.3.4" => "血细胞破坏流量趋势",
_ => "压力降/抗塌陷流量趋势"
};
public string FlowTrendPrimaryLabel => SelectedItem?.Clause switch
{
"4.3.3" => "主泵",
"4.2.3" => "抗扭结泵",
"4.3.4" => "单腔引流",
_ => "主泵"
};
public string FlowTrendSecondaryLabel => SelectedItem?.Clause switch
{
"4.3.3" => "回流",
"4.3.4" => "单腔回输",
_ => string.Empty
};
public string FlowTrendTertiaryLabel => SelectedItem?.Clause switch
{
"4.3.3" => "引流",
"4.3.4" => "双腔",
_ => string.Empty
};
public bool HasFlowTrendSecondary => !string.IsNullOrWhiteSpace(FlowTrendSecondaryLabel);
public bool HasFlowTrendTertiary => !string.IsNullOrWhiteSpace(FlowTrendTertiaryLabel);
public ObservableCollection<double> ActiveFlowTrendPrimaryValues => SelectedItem?.Clause switch
{
"4.3.3" => RecirculationMainPumpTrendValues,
"4.2.3" => KinkResistancePumpTrendValues,
"4.3.4" => HemolysisDrainageSingleTrendValues,
_ => PressureDropPumpTrendValues
};
public ObservableCollection<double> ActiveFlowTrendSecondaryValues => SelectedItem?.Clause switch
{
"4.3.3" => RecirculationReturnPumpTrendValues,
"4.3.4" => HemolysisReturnSingleTrendValues,
_ => []
};
public ObservableCollection<double> ActiveFlowTrendTertiaryValues => SelectedItem?.Clause switch
{
"4.3.3" => RecirculationDrainagePumpTrendValues,
"4.3.4" => HemolysisDualLumenTrendValues,
_ => []
};
public double PressureTrendMax => MaxTrendValue([ProximalPressureTrendValues, DistalPressureTrendValues, DeltaPressureTrendValues], 40d);
public double FlowTrendMax => MaxTrendValue([ActiveFlowTrendPrimaryValues, ActiveFlowTrendSecondaryValues, ActiveFlowTrendTertiaryValues], Math.Max(RatedMaxFlow, 1d));
public string ProximalPressureDisplay => $"{ChannelValue("è¿ç«¯åŽåŠ"):F1} mmHg";
public string DistalPressureDisplay => $"{ChannelValue("远端åŽåŠ"):F1} mmHg";
public string FlowImbalanceDisplay => $"{Math.Abs(PumpFlow - ReturnFlow):F2} L/min";
@@ -265,6 +338,12 @@ public partial class MainViewModel : ObservableObject
public string RecirculationSamplingSummary => BuildRecirculationSamplingSummary();
public string RecirculationLimitDisplay => $"制造商声明限值R ≤ {RecirculationAllowedLimit:F1}%";
public double PressureDropPumpFlow => ChannelValue("主泵流量");
public double RecirculationPumpFlow => ChannelValue("再循环主泵流量");
public double KinkResistancePumpFlow => ChannelValue("抗扭结主泵流量");
public double HemolysisDrainageSingleFlow => ChannelValue("血细胞破坏-单腔引流流量");
public double HemolysisReturnSingleFlow => ChannelValue("血细胞破坏-单腔回输流量");
public double HemolysisDualLumenFlow => ChannelValue("血细胞破坏-双腔流量");
public double PumpFlow => ChannelValue("主泵流量");
public double DrainageFlow => ChannelValue("静脉引流流量");
public double ReturnFlow => ChannelValue("动脉回输流量");
@@ -323,6 +402,16 @@ public partial class MainViewModel : ObservableObject
OnPropertyChanged(nameof(RealtimeMeasurementHint));
OnPropertyChanged(nameof(SelectedItemLiveDisplay));
OnPropertyChanged(nameof(SelectedItemLiveHint));
OnPropertyChanged(nameof(FlowTrendTitle));
OnPropertyChanged(nameof(FlowTrendPrimaryLabel));
OnPropertyChanged(nameof(FlowTrendSecondaryLabel));
OnPropertyChanged(nameof(FlowTrendTertiaryLabel));
OnPropertyChanged(nameof(HasFlowTrendSecondary));
OnPropertyChanged(nameof(HasFlowTrendTertiary));
OnPropertyChanged(nameof(ActiveFlowTrendPrimaryValues));
OnPropertyChanged(nameof(ActiveFlowTrendSecondaryValues));
OnPropertyChanged(nameof(ActiveFlowTrendTertiaryValues));
OnPropertyChanged(nameof(FlowTrendMax));
if (value is not null)
{
LoadSelectedItemDraft(value);
@@ -346,6 +435,43 @@ public partial class MainViewModel : ObservableObject
ItemSearchText = string.Empty;
}
[RelayCommand]
private void TogglePumpControl(PumpControlChannel? pump)
{
if (pump is null)
{
return;
}
var nextState = !pump.IsRunning;
_telemetryService.SetPumpRunning(pump.Key, nextState);
pump.IsRunning = nextState;
LatestAction = $"{pump.Name} 已{(nextState ? "" : "")}。";
TraceEvents.Insert(0, NewTrace("泵控", $"{pump.Name} => {(nextState ? "" : "")}"));
RefreshTelemetry();
}
[RelayCommand]
private void ClearTrendData()
{
ClearTrendSeries(
ProximalPressureTrendValues,
DistalPressureTrendValues,
DeltaPressureTrendValues,
PressureDropPumpTrendValues,
RecirculationMainPumpTrendValues,
RecirculationReturnPumpTrendValues,
RecirculationDrainagePumpTrendValues,
KinkResistancePumpTrendValues,
HemolysisDrainageSingleTrendValues,
HemolysisReturnSingleTrendValues,
HemolysisDualLumenTrendValues);
LatestAction = "已清空实时趋势曲线。";
TraceEvents.Insert(0, NewTrace("趋势图", "已清空实时趋势曲线"));
RaiseTrendPropertyChanges();
}
[RelayCommand]
private void CapturePressureDrop50() => CapturePressureDropSample("50%");
@@ -636,6 +762,12 @@ public partial class MainViewModel : ObservableObject
OnPropertyChanged(nameof(PumpFlowDisplay));
OnPropertyChanged(nameof(DrainageFlowDisplay));
OnPropertyChanged(nameof(ReturnFlowDisplay));
OnPropertyChanged(nameof(PressureDropPumpFlowDisplay));
OnPropertyChanged(nameof(RecirculationPumpFlowDisplay));
OnPropertyChanged(nameof(KinkResistancePumpFlowDisplay));
OnPropertyChanged(nameof(HemolysisDrainageSingleFlowDisplay));
OnPropertyChanged(nameof(HemolysisReturnSingleFlowDisplay));
OnPropertyChanged(nameof(HemolysisDualLumenFlowDisplay));
OnPropertyChanged(nameof(ProximalPressureDisplay));
OnPropertyChanged(nameof(DistalPressureDisplay));
OnPropertyChanged(nameof(RealtimeRecirculationDisplay));
@@ -662,9 +794,11 @@ public partial class MainViewModel : ObservableObject
OnPropertyChanged(nameof(ReturnFlowNormalizedValue));
OnPropertyChanged(nameof(FlowSensorChannels));
OnPropertyChanged(nameof(OtherChannels));
OnPropertyChanged(nameof(PumpControls));
OnPropertyChanged(nameof(SelectedItemLiveDisplay));
OnPropertyChanged(nameof(SelectedItemLiveHint));
CaptureTrendSamples();
SyncRealtimeItems();
}
@@ -676,6 +810,70 @@ public partial class MainViewModel : ObservableObject
ComplianceRate = InspectionItems.Count == 0 ? 0 : QualifiedCount * 100d / InspectionItems.Count;
}
private void CaptureTrendSamples()
{
AppendTrendValue(ProximalPressureTrendValues, ChannelValue("近端压力"));
AppendTrendValue(DistalPressureTrendValues, ChannelValue("远端压力"));
AppendTrendValue(DeltaPressureTrendValues, DeltaPressure);
AppendTrendValue(PressureDropPumpTrendValues, PressureDropPumpFlow);
AppendTrendValue(RecirculationMainPumpTrendValues, RecirculationPumpFlow);
AppendTrendValue(RecirculationReturnPumpTrendValues, ReturnFlow);
AppendTrendValue(RecirculationDrainagePumpTrendValues, DrainageFlow);
AppendTrendValue(KinkResistancePumpTrendValues, KinkResistancePumpFlow);
AppendTrendValue(HemolysisDrainageSingleTrendValues, HemolysisDrainageSingleFlow);
AppendTrendValue(HemolysisReturnSingleTrendValues, HemolysisReturnSingleFlow);
AppendTrendValue(HemolysisDualLumenTrendValues, HemolysisDualLumenFlow);
RaiseTrendPropertyChanges();
}
private static void AppendTrendValue(ObservableCollection<double> series, double value)
{
series.Add(value);
while (series.Count > TrendHistoryCapacity)
{
series.RemoveAt(0);
}
}
private static double MaxTrendValue(IEnumerable<IEnumerable<double>> seriesGroup, double fallback)
{
var max = seriesGroup
.SelectMany(series => series.DefaultIfEmpty(0d))
.DefaultIfEmpty(fallback)
.Max();
return Math.Max(fallback, max) * 1.1d;
}
private void ClearTrendSeries(params ObservableCollection<double>[] seriesGroup)
{
foreach (var series in seriesGroup)
{
series.Clear();
}
}
private void RaiseTrendPropertyChanges()
{
OnPropertyChanged(nameof(ProximalPressureTrendValues));
OnPropertyChanged(nameof(DistalPressureTrendValues));
OnPropertyChanged(nameof(DeltaPressureTrendValues));
OnPropertyChanged(nameof(PressureDropPumpTrendValues));
OnPropertyChanged(nameof(RecirculationMainPumpTrendValues));
OnPropertyChanged(nameof(RecirculationReturnPumpTrendValues));
OnPropertyChanged(nameof(RecirculationDrainagePumpTrendValues));
OnPropertyChanged(nameof(KinkResistancePumpTrendValues));
OnPropertyChanged(nameof(HemolysisDrainageSingleTrendValues));
OnPropertyChanged(nameof(HemolysisReturnSingleTrendValues));
OnPropertyChanged(nameof(HemolysisDualLumenTrendValues));
OnPropertyChanged(nameof(ActiveFlowTrendPrimaryValues));
OnPropertyChanged(nameof(ActiveFlowTrendSecondaryValues));
OnPropertyChanged(nameof(ActiveFlowTrendTertiaryValues));
OnPropertyChanged(nameof(PressureTrendMax));
OnPropertyChanged(nameof(FlowTrendMax));
}
private void RefreshFilteredItemsView()
{
FilteredItemsView.Refresh();
@@ -811,6 +1009,17 @@ public partial class MainViewModel : ObservableObject
private double ChannelValue(string name) => Channels.First(channel => channel.Name == name).Value;
private double ChannelNormalizedValue(string name) => Channels.First(channel => channel.Name == name).NormalizedValue;
private IEnumerable<PumpControlChannel> PumpControlsFor(params string[] pumpKeys)
{
var orderLookup = pumpKeys
.Select((key, index) => new { key, index })
.ToDictionary(item => item.key, item => item.index);
return PumpControls
.Where(pump => orderLookup.ContainsKey(pump.Key))
.OrderBy(pump => orderLookup[pump.Key]);
}
private List<InspectionItem> ActiveItemScope() => FilteredItemsView.Cast<InspectionItem>().ToList();
private bool MatchesFilteredItem(object item) =>
@@ -865,8 +1074,7 @@ public partial class MainViewModel : ObservableObject
return keywordIndex == keyword.Length;
}
private static bool IsFlowSensorChannel(DeviceChannel channel) =>
channel.Name is "主泵流量" or "静脉引流流量" or "动脉回输流量";
private static bool IsFlowSensorChannel(DeviceChannel channel) => channel.Unit == "L/min";
private string BuildSelectedItemLiveDisplay()
{
@@ -893,7 +1101,7 @@ public partial class MainViewModel : ObservableObject
$"条件:血液/模拟血液 2.0~3.5 mPa·s / 当前温度 {TemperatureDisplay} / 40±1 °C\n" +
$"流量点:{KinkResistanceFlowPointDisplay}\n" +
$"{KinkResistanceMandrelDiameterDisplay}\n" +
$"当前主泵:{PumpFlow:F2} L/min(寄存器 D1040 平滑值)\n" +
$"当前主泵:{KinkResistancePumpFlow:F2} L/min\n" +
$"{BuildKinkResistanceSamplingSummary()}";
}
@@ -948,7 +1156,7 @@ public partial class MainViewModel : ObservableObject
}
var entry = KinkResistanceEntries.First(item => item.Label == label);
entry.BaselineFlow = PumpFlow;
entry.BaselineFlow = KinkResistancePumpFlow;
entry.Temperature = ChannelValue("模拟血液温度");
entry.BaselineCapturedAt = DateTime.Now;
@@ -976,7 +1184,7 @@ public partial class MainViewModel : ObservableObject
return;
}
entry.KinkedFlow = PumpFlow;
entry.KinkedFlow = KinkResistancePumpFlow;
entry.Temperature = ChannelValue("模拟血液温度");
entry.KinkedCapturedAt = DateTime.Now;
@@ -1337,7 +1545,7 @@ public partial class MainViewModel : ObservableObject
$"条件:试验液体为水 + 示踪粒子 / 当前温度 {TemperatureDisplay}\n" +
$"目标流量点:{RecirculationFlowPointDisplay}\n" +
$"{RecirculationLimitDisplay}\n" +
$"当前:主泵 {PumpFlow:F2} L/min / 引流 {DrainageFlow:F2} L/min / 回输 {ReturnFlow:F2} L/min / 在线参考 {RecirculationRate:F1}%\n" +
$"当前:主泵 {RecirculationPumpFlow:F2} L/min / 引流 {DrainageFlow:F2} L/min / 回输 {ReturnFlow:F2} L/min / 在线参考 {RecirculationRate:F1}%\n" +
$"{BuildRecirculationSamplingSummary()}";
}
@@ -1414,7 +1622,7 @@ public partial class MainViewModel : ObservableObject
}
var entry = RecirculationEntries.First(item => item.Label == label);
entry.ActualPumpFlow = PumpFlow;
entry.ActualPumpFlow = RecirculationPumpFlow;
entry.DrainageFlow = DrainageFlow;
entry.ReturnFlow = ReturnFlow;
entry.OnlineEstimate = RecirculationRate;
@@ -1428,7 +1636,7 @@ public partial class MainViewModel : ObservableObject
_lastAutoRecirculationResult = resultText;
_lastAutoRecirculationNote = noteText;
LatestAction = $"已采集再循环 {entry.Label} 流量点快照,当前在线参考 {RecirculationRate:F1}%。";
TraceEvents.Insert(0, NewTrace("再循环采样", $"{entry.Label} / 主泵 {PumpFlow:F2} L/min / 在线参考 {RecirculationRate:F1}%"));
TraceEvents.Insert(0, NewTrace("再循环采样", $"{entry.Label} / 主泵 {RecirculationPumpFlow:F2} L/min / 在线参考 {RecirculationRate:F1}%"));
OnPropertyChanged(nameof(RecirculationSamplingSummary));
OnPropertyChanged(nameof(SelectedItemLiveDisplay));