添加项目文件。

This commit is contained in:
xyy
2026-06-13 14:16:34 +08:00
parent 9df508aa36
commit 3fb35f5814
23 changed files with 1251 additions and 0 deletions

12
App.xaml Normal file
View File

@@ -0,0 +1,12 @@
<Application x:Class="AciTester.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:AciTester.Converters"
StartupUri="Views/MainWindow.xaml">
<Application.Resources>
<converters:BoolToColorConverter x:Key="BoolToColorConverter"/>
<converters:BoolToStringConverter x:Key="BoolToStringConverter"/>
<converters:InverseBoolConverter x:Key="InverseBoolConverter"/>
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter"/>
</Application.Resources>
</Application>

14
App.xaml.cs Normal file
View File

@@ -0,0 +1,14 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace AciTester
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}
}

10
AssemblyInfo.cs Normal file
View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

Binary file not shown.

View File

@@ -0,0 +1,22 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace AciTester.Converters
{
public class BoolToColorConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool b)
return b ? Brushes.Green : Brushes.Red;
return Brushes.Gray;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace AciTester.Converters
{
public class BoolToStringConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool b)
return b ? "运行" : "停止";
return "未知";
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace AciTester.Converters
{
public class BoolToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return (value is bool b && b) ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
public class InverseBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return !(value is bool b && b);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,25 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace AciTester.Models
{
public partial class CalibrationConfig : ObservableObject
{
[ObservableProperty]
private float flowCalibration = 1.0f; // D1328
[ObservableProperty]
private float temperatureCalibration = 1.0f; // D1378
[ObservableProperty]
private float pumpPressureCalibration = 1.0f; // D1428
[ObservableProperty]
private float impactorPressureCalibration = 1.0f; // D1478
[ObservableProperty]
private float flowLowLimit = 25.0f; // D1332 低限
[ObservableProperty]
private float flowHighLimit = 32.0f; // D1332 高限
}
}

101
Models/PlcConfiguration.cs Normal file
View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AciTester.Models
{
/// <summary>
/// PLC 配置类,用于存储从 appsettings.json 读取的 Modbus 参数和寄存器地址。
/// </summary>
public class PlcConfiguration
{
// ========== 网络连接参数 ==========
public string IpAddress { get; set; } // PLC IP 地址
public int Port { get; set; } // Modbus TCP 端口
public byte SlaveId { get; set; } = 1; // 从站地址默认1
// 以下属性用于与上位机交互(但实际按工位读取,此处保留兼容)
public ushort PressureRegister { get; set; } // 不再使用,保留兼容
public ushort WetFlowRegister { get; set; } // 湿膜流量寄存器起始地址
public ushort WetFlowRegister2 { get; set; } // 湿膜流量寄存器起始地址
public ushort WetFlowRegister3 { get; set; } // 湿膜流量寄存器起始地址
// ========== 工位专用寄存器 ==========
public ushort PressureRegisterStation1 { get; set; } // 工位1 压力寄存器起始地址
public ushort PressureRegisterStation2 { get; set; } // 工位2
public ushort PressureRegisterStation3 { get; set; } // 工位3
public ushort PumpCoil { get; set; } // 高压超限 M180
public ushort FlowRegister { get; set; } // 高压超限 M180
// 新增地址
public ushort FlowCalibrationReg { get; set; } = 1328;
public ushort FlowProtectReg { get; set; } = 1332;
public ushort TemperatureReg { get; set; } = 1380;
public ushort TempCalibrationReg { get; set; } = 1378;
public ushort PumpPressureReg { get; set; } = 1430;
public ushort PumpPressureCalibReg { get; set; } = 1428;
public ushort ImpactorPressureReg { get; set; } = 1480;
public ushort ImpactorPressureCalibReg { get; set; } = 1478;
public ushort ImpactorPressureCalibCoil { get; set; } = 1303; // M1303
}
/// <summary>
/// PLC 服务接口,定义与 Modbus 设备通信的方法。
/// </summary>
public interface IPlcService
{
/// <summary> 读取指定工位的压力(浮点数) </summary>
/// <param name="stationId">工位号 1~3</param>
Task<float> ReadPressureAsync(int stationId);
/// <summary> 读取湿膜流量(浮点数) </summary>
Task<float> ReadWetFlowAsync(int stationId);
/// <summary> 写入线圈(如 M 元件) </summary>
Task WriteCoilAsync(ushort coilAddress, bool value);
/// <summary> 写入单个寄存器16位 </summary>
Task WriteRegisterAsync(ushort registerAddress, ushort value);
/// <summary> 读取线圈状态(如 M 元件的 ON/OFF </summary>
Task<bool> ReadCoilAsync(ushort coilAddress);
/// <summary> 读取连续多个保持寄存器16位 </summary>
Task<ushort[]> ReadHoldingRegistersAsync(ushort startAddress, ushort count);
/// <summary> 写入单个保持寄存器16位 </summary>
Task WriteSingleRegisterAsync(ushort registerAddress, ushort value);
Task WriteMultipleRegistersAsync(ushort registerAddress, float value);
float UshortToFloat(ushort P1, ushort P2);
Task<float> ReadFloatAsync(ushort startAddress);
Task EnsureConnectedAsync(int retryCount = 3);
void Dispose();
bool IsConnected { get; } // 新增
}
}

25
Models/RealTimeData.cs Normal file
View File

@@ -0,0 +1,25 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace AciTester.Models
{
public partial class RealTimeData : ObservableObject
{
[ObservableProperty]
private float rawFlow; // D1330原始值
[ObservableProperty]
private float calibratedFlow; // 校准后流量
[ObservableProperty]
private float temperature; // D1380
[ObservableProperty]
private float pumpPressure; // D1430
[ObservableProperty]
private float impactorPressure; // D1480
[ObservableProperty]
private float differentialPressure; // 压差 = impactorPressure - pumpPressure
}
}

20
Models/StageData.cs Normal file
View File

@@ -0,0 +1,20 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace AciTester.Models;
public partial class StageData : ObservableObject
{
[ObservableProperty]
private string stageName = string.Empty;
[ObservableProperty]
private double cutoffDiameter; // 截止直径 (μm)
[ObservableProperty]
private double initialWeight; // 测前质量 (g)
[ObservableProperty]
private double finalWeight; // 测后质量 (g)
public double NetWeight => finalWeight - initialWeight;
}

14
Models/TestResult.cs Normal file
View File

@@ -0,0 +1,14 @@
namespace AciTester.Models;
public class TestResult
{
public DateTime TestTime { get; set; }
public double TotalMass { get; set; } // 总质量 F (g)
public double FineParticleDose { get; set; } // 微细粒子剂量 FPD (mg)
public double FineParticleFraction { get; set; } // 微细粒子分数 FPF (%)
public List<StageData> Stages { get; set; }
public double FlowRate { get; set; } // 实际采样流量 (L/min)
public float Temperature { get; set; }
public float DifferentialPressure { get; set; }
}

View File

@@ -0,0 +1,64 @@
using AciTester.Models;
using OfficeOpenXml;
using System.ComponentModel;
using System.IO;
using System.Threading.Tasks;
namespace AciTester.Services
{
public class ExcelReportService : IReportService
{
public async Task GenerateReportAsync(TestResult result, string filePath)
{
ExcelPackage.LicenseContext = OfficeOpenXml.LicenseContext.NonCommercial;
using var package = new ExcelPackage();
var sheet = package.Workbook.Worksheets.Add("ACI测试报告");
// 标题
sheet.Cells["A1"].Value = "中国药典2025版四部通则0951装置3测试报告";
sheet.Cells["A1:C1"].Merge = true;
sheet.Cells["A1"].Style.Font.Bold = true;
sheet.Cells["A1"].Style.Font.Size = 16;
// 基本信息
sheet.Cells["A3"].Value = "测试时间:";
sheet.Cells["B3"].Value = result.TestTime.ToString("yyyy-MM-dd HH:mm:ss");
sheet.Cells["A4"].Value = "采样流量(L/min):";
sheet.Cells["B4"].Value = result.FlowRate;
sheet.Cells["A5"].Value = "总质量F(g):";
sheet.Cells["B5"].Value = result.TotalMass;
// 在报告生成时添加
sheet.Cells["A6"].Value = "测试环境:";
sheet.Cells["B6"].Value = $"流量: {result.FlowRate:F2} L/min, 温度: {result.Temperature:F1}℃, 压差: {result.DifferentialPressure:F2} kPa";
// 微细粒子指标
sheet.Cells["A7"].Value = "微细粒子剂量(FPD)";
sheet.Cells["B7"].Value = $"{result.FineParticleDose:F2} mg";
sheet.Cells["A8"].Value = "微细粒子分数(FPF)";
sheet.Cells["B8"].Value = $"{result.FineParticleFraction:F2}%";
// 各级数据表
sheet.Cells["A10"].Value = "层级";
sheet.Cells["B10"].Value = "截止直径(μm)";
sheet.Cells["C10"].Value = "净重(g)";
sheet.Cells["D10"].Value = "占比(%)";
sheet.Cells["A10:D10"].Style.Font.Bold = true;
int row = 11;
double total = result.TotalMass;
foreach (var stage in result.Stages)
{
double percent = total > 0 ? stage.NetWeight / total * 100 : 0;
sheet.Cells[row, 1].Value = stage.StageName;
sheet.Cells[row, 2].Value = stage.CutoffDiameter > 0 ? stage.CutoffDiameter.ToString("F1") : "—";
sheet.Cells[row, 3].Value = stage.NetWeight;
sheet.Cells[row, 4].Value = percent.ToString("F2");
row++;
}
sheet.Cells.AutoFitColumns();
await package.SaveAsAsync(new FileInfo(filePath));
}
}
}

View File

@@ -0,0 +1,10 @@
using System.Threading.Tasks;
using AciTester.Models;
namespace AciTester.Services
{
public interface IReportService
{
Task GenerateReportAsync(TestResult result, string filePath);
}
}

View File

@@ -0,0 +1,207 @@
using Modbus.Device;
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using AciTester.Models;
namespace AciTester.Services
{
public class ModbusTcpPlcService : IPlcService, IDisposable
{
private readonly PlcConfiguration _config;
private TcpClient _tcpClient;
private IModbusMaster _master;
public ModbusTcpPlcService(PlcConfiguration config)
{
_config = config;
}
public async Task EnsureConnectedAsync(int retryCount = 3)
{
if (_tcpClient != null && _tcpClient.Connected)
return;
for (int i = 0; i < retryCount; i++)
{
try
{
_tcpClient?.Close();
_tcpClient = new TcpClient();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
// 现在可以直接使用扩展方法
await _tcpClient.ConnectAsync(_config.IpAddress, _config.Port).WithCancellation(cts.Token);
_master = ModbusIpMaster.CreateIp(_tcpClient);
return;
}
catch (Exception ex) when (i < retryCount - 1)
{
System.Diagnostics.Debug.WriteLine($"连接失败,{500}ms 后重试... {ex.Message}");
await Task.Delay(500);
}
}
throw new Exception($"无法连接到 PLC ({_config.IpAddress}:{_config.Port}),请检查网络和 PLC 状态。");
}
// 读取两个连续的保持寄存器转换为32位浮点数假设大端模式
public async Task<float> ReadFloatAsync(ushort startAddress)
{
await EnsureConnectedAsync();
var registers = await ReadHoldingRegistersAsync(startAddress, 2);
return UshortToFloat(registers[1], registers[0]);
}
public async Task<float> ReadPressureAsync() =>
await ReadFloatAsync(_config.PressureRegister);
public async Task<float> ReadWetFlowAsync(int stationId)
{
ushort startAddress = stationId switch
{
1 => _config.WetFlowRegister,
2 => _config.WetFlowRegister2,
3 => _config.WetFlowRegister3,
};
return await ReadFloatAsync(startAddress);
}
public async Task WriteCoilAsync(ushort coilAddress, bool value)
{
await EnsureConnectedAsync();
await _master.WriteSingleCoilAsync(_config.SlaveId, coilAddress, value);
}
public async Task WriteRegisterAsync(ushort registerAddress, ushort value)
{
await EnsureConnectedAsync();
await Task.Delay(100);
await _master.WriteSingleRegisterAsync(_config.SlaveId, registerAddress, value);
}
public async Task<bool> ReadCoilAsync(ushort coilAddress)
{
await EnsureConnectedAsync();
await Task.Delay(100);
bool[] result = await _master?.ReadCoilsAsync(_config.SlaveId, coilAddress, 1);
return result[0];
}
public bool IsConnected => _tcpClient != null && _tcpClient.Connected;
public async Task<ushort[]> ReadHoldingRegistersAsync(ushort startAddress, ushort count)
{
await EnsureConnectedAsync();
// await Task.Delay(100);
return await _master.ReadHoldingRegistersAsync(_config.SlaveId, startAddress, count);
}
public async Task WriteSingleRegisterAsync(ushort registerAddress, ushort value)
{
await EnsureConnectedAsync();
int val = (int)value;
await Task.Delay(100);
await _master.WriteMultipleRegistersAsync(1, registerAddress, intToushorts(val));
}
public async Task WriteMultipleRegistersAsync(ushort registerAddress, float value)
{
await EnsureConnectedAsync();
await Task.Delay(100);
await _master.WriteMultipleRegistersAsync(_config.SlaveId, registerAddress, SplitFloatToUShortArray((float)value));
}
/// <summary>
/// Int转为ushort数组发送
/// </summary>
/// <param name="res"></param>
/// <returns>返回ushort数组</returns>
private ushort[] intToushorts(int res)
{
ushort ust1 = (ushort)(res >> 16);
ushort ust2 = (ushort)res;
return new ushort[] { ust2, ust1 };
}
/// <summary>
/// Float转为Ushort数组发送
/// </summary>
/// <param name="value"></param>
/// <returns>返回ushort数组</returns>
public ushort[] SplitFloatToUShortArray(float value)
{
byte[] floatBytes = BitConverter.GetBytes(value);
ushort[] ushortArray = new ushort[floatBytes.Length / 2];
for (int i = 0, j = 0; i < floatBytes.Length; i += 2, j++)
{
ushortArray[j] = BitConverter.ToUInt16(floatBytes, i);
}
return ushortArray;
}
/// <summary>
/// ushort转为float类型
/// </summary>
/// <param name="P1"></param>
/// <param name="P2"></param>
/// <returns>float型数据</returns>
public float UshortToFloat(ushort P1, ushort P2)
{
int intSign, intSignRest, intExponent, intExponentRest;
float faResult, faDigit;
intSign = P1 / 32768;
intSignRest = P1 % 32768;
intExponent = intSignRest / 128;
intExponentRest = intSignRest % 128;
faDigit = (float)(intExponentRest * 65536 + P2) / 8388608;
faResult = (float)Math.Pow(-1, intSign) * (float)Math.Pow(2, intExponent - 127) * (faDigit + 1);
return faResult;
}
// 新增读取压力(根据工位)
public async Task<float> ReadPressureAsync(int stationId)
{
ushort startAddress = stationId switch
{
1 => _config.PressureRegisterStation1,
2 => _config.PressureRegisterStation2,
3 => _config.PressureRegisterStation3,
_ => throw new ArgumentException("Invalid station")
};
return await ReadFloatAsync(startAddress);
}
public void Dispose()
{
_master?.Dispose();
_tcpClient?.Close();
_tcpClient?.Dispose();
}
}
public static class TaskExtensions
{
public static async Task WithCancellation(this Task task, CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<bool>();
using (cancellationToken.Register(s => ((TaskCompletionSource<bool>)s).TrySetResult(true), tcs))
{
if (task != await Task.WhenAny(task, tcs.Task))
throw new OperationCanceledException(cancellationToken);
}
await task;
}
}
}

View File

@@ -0,0 +1,79 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using AciTester.Models;
using AciTester.Services;
using System.Windows;
namespace AciTester.ViewModels
{
public partial class ConfigViewModel : ObservableObject
{
private readonly IPlcService _plcService;
private readonly PlcConfiguration _config;
private readonly CalibrationConfig _calib;
public ConfigViewModel(IPlcService plcService, PlcConfiguration config, CalibrationConfig calib)
{
_plcService = plcService;
_config = config;
_calib = calib;
LoadConfigCommand = new AsyncRelayCommand(LoadConfigAsync);
SaveConfigCommand = new AsyncRelayCommand(SaveConfigAsync);
}
public IAsyncRelayCommand LoadConfigCommand { get; }
public IAsyncRelayCommand SaveConfigCommand { get; }
public CalibrationConfig Calibration => _calib;
private async Task LoadConfigAsync()
{
if (!_plcService.IsConnected)
{
MessageBox.Show("请先连接PLC", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
try
{
_calib.FlowCalibration = await _plcService.ReadFloatAsync(_config.FlowCalibrationReg);
_calib.TemperatureCalibration = await _plcService.ReadFloatAsync(_config.TempCalibrationReg);
_calib.PumpPressureCalibration = await _plcService.ReadFloatAsync(_config.PumpPressureCalibReg);
_calib.ImpactorPressureCalibration = await _plcService.ReadFloatAsync(_config.ImpactorPressureCalibReg);
// 读取流量保护值(假设高限低限分别存储,这里简化)
var protect = await _plcService.ReadFloatAsync(_config.FlowProtectReg);
_calib.FlowLowLimit = protect - 5; // 示例,实际根据协议解析
_calib.FlowHighLimit = protect + 5;
}
catch (System.Exception ex)
{
MessageBox.Show($"读取配置失败: {ex.Message}", "错误");
}
}
private async Task SaveConfigAsync()
{
if (!_plcService.IsConnected)
{
MessageBox.Show("请先连接PLC", "提示");
return;
}
try
{
await _plcService.WriteMultipleRegistersAsync(_config.FlowCalibrationReg, _calib.FlowCalibration);
await _plcService.WriteMultipleRegistersAsync(_config.TempCalibrationReg, _calib.TemperatureCalibration);
await _plcService.WriteMultipleRegistersAsync(_config.PumpPressureCalibReg, _calib.PumpPressureCalibration);
await _plcService.WriteMultipleRegistersAsync(_config.ImpactorPressureCalibReg, _calib.ImpactorPressureCalibration);
// 写入流量保护值(需根据实际协议拆分高低限)
MessageBox.Show("配置保存成功", "提示");
// 可选:触发重新校准
await _plcService.WriteCoilAsync(_config.ImpactorPressureCalibCoil, true);
await Task.Delay(100);
await _plcService.WriteCoilAsync(_config.ImpactorPressureCalibCoil, false);
}
catch (System.Exception ex)
{
MessageBox.Show($"保存失败: {ex.Message}", "错误");
}
}
}
}

305
ViewModels/MainViewModel.cs Normal file
View File

@@ -0,0 +1,305 @@
using System;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using AciTester.Models;
using AciTester.Services;
using AciTester.Views;
namespace AciTester.ViewModels
{
public partial class MainViewModel : ObservableObject
{
public readonly IPlcService _plcService;
private readonly IReportService _reportService;
public readonly PlcConfiguration _config;
private CancellationTokenSource _testCts;
[ObservableProperty]
private bool isConnected;
[ObservableProperty]
private string connectionStatus = "未连接";
[ObservableProperty]
private float currentFlow;
[ObservableProperty]
private bool isPumpRunning;
[ObservableProperty]
private bool isTesting;
[ObservableProperty]
private int sampleTimeSeconds = 60; // 默认采样60秒
[ObservableProperty]
private int remainingSeconds;
[ObservableProperty]
private ObservableCollection<StageData> stages;
[ObservableProperty]
private TestResult currentResult;
// 在 MainViewModel 中添加
[ObservableProperty]
private RealTimeData realTime;
[ObservableProperty]
private CalibrationConfig calibration;
private Timer _dataTimer;
public MainViewModel()
{
_config = new PlcConfiguration();
_plcService = new ModbusTcpPlcService(_config);
_reportService = new ExcelReportService();
// 初始化ACI 8级 + 滤膜
Stages = new ObservableCollection<StageData>
{
new StageData { StageName = "Stage 0", CutoffDiameter = 9.0 },
new StageData { StageName = "Stage 1", CutoffDiameter = 5.8 },
new StageData { StageName = "Stage 2", CutoffDiameter = 4.7 },
new StageData { StageName = "Stage 3", CutoffDiameter = 3.3 },
new StageData { StageName = "Stage 4", CutoffDiameter = 2.1 },
new StageData { StageName = "Stage 5", CutoffDiameter = 1.1 },
new StageData { StageName = "Stage 6", CutoffDiameter = 0.7 },
new StageData { StageName = "Stage 7", CutoffDiameter = 0.4 },
new StageData { StageName = "Filter", CutoffDiameter = 0.0 }
};
ConnectCommand = new AsyncRelayCommand(ConnectAsync);
DisconnectCommand = new RelayCommand(Disconnect);
StartTestCommand = new AsyncRelayCommand(StartTestAsync);
CalculateCommand = new RelayCommand(CalculateResult);
ExportReportCommand = new AsyncRelayCommand(ExportReportAsync);
// 在构造函数中初始化
RealTime = new RealTimeData();
Calibration = new CalibrationConfig();
//this.Loaded += (s, e) =>
//{
// var keyGesture = new KeyGesture(Key.P, ModifierKeys.Control);
// var inputBinding = new InputBinding(new RelayCommand(OpenConfigWindow), keyGesture);
// this.InputBindings.Add(inputBinding);
//};
}
public IAsyncRelayCommand ConnectCommand { get; }
public IRelayCommand DisconnectCommand { get; }
public IAsyncRelayCommand StartTestCommand { get; }
public IRelayCommand CalculateCommand { get; }
public IAsyncRelayCommand ExportReportCommand { get; }
private async Task ConnectAsync()
{
try
{
await _plcService.EnsureConnectedAsync();
IsConnected = true;
ConnectionStatus = "已连接";
_ = Task.Run(ReadFlowLoop);
_ = Task.Run(ReadRealTimeLoop); // 新增
}
catch (Exception ex)
{
MessageBox.Show($"连接失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
IsConnected = false;
ConnectionStatus = "连接失败";
}
}
private void Disconnect()
{
_plcService.Dispose();
IsConnected = false;
ConnectionStatus = "未连接";
}
private async Task ReadFlowLoop()
{
while (IsConnected)
{
try
{
var flow = await _plcService.ReadFloatAsync(_config.FlowRegister);
Application.Current.Dispatcher.Invoke(() => CurrentFlow = flow);
await Task.Delay(1000);
}
catch { break; }
}
}
private async Task StartTestAsync()
{
if (!IsConnected)
{
await ConnectAsync();
if (!IsConnected) return;
}
if (IsTesting) return;
IsTesting = true;
_testCts = new CancellationTokenSource();
try
{
// 启动真空泵
await _plcService.WriteCoilAsync(_config.PumpCoil, true);
IsPumpRunning = true;
// 倒计时
RemainingSeconds = SampleTimeSeconds;
for (int i = 0; i < SampleTimeSeconds; i++)
{
if (_testCts.Token.IsCancellationRequested) break;
await Task.Delay(1000);
RemainingSeconds--;
}
// 停止泵
await _plcService.WriteCoilAsync(_config.PumpCoil, false);
IsPumpRunning = false;
MessageBox.Show($"采样完成!\n实际采样时间: {SampleTimeSeconds} 秒\n请进行称重并录入数据。",
"提示", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show($"测试异常: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
// 尝试停止泵
try { await _plcService.WriteCoilAsync(_config.PumpCoil, false); } catch { }
IsPumpRunning = false;
}
finally
{
IsTesting = false;
_testCts?.Dispose();
}
}
private void CalculateResult()
{
double totalMass = 0;
foreach (var stage in Stages)
totalMass += stage.NetWeight;
if (totalMass <= 0)
{
MessageBox.Show("总质量为零,无法计算", "警告", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
// 微细粒子剂量:截止直径 ≤ 5μm 的级 + 滤膜
double fineMass = 0;
foreach (var stage in Stages)
{
if (stage.CutoffDiameter <= 5.0 && stage.CutoffDiameter > 0)
fineMass += stage.NetWeight;
}
fineMass += Stages[8].NetWeight; // 滤膜
double fpd = fineMass * 1000; // mg
double fpf = (fineMass / totalMass) * 100;
CurrentResult = new TestResult
{
TestTime = DateTime.Now,
TotalMass = totalMass,
FineParticleDose = fpd,
FineParticleFraction = fpf,
Stages = Stages.ToList(),
FlowRate = CurrentFlow,
Temperature = RealTime.Temperature, // 新增
DifferentialPressure = RealTime.DifferentialPressure // 新增
};
MessageBox.Show($"计算完成\n总质量: {totalMass:F4} g\n微细粒子剂量: {fpd:F2} mg\n微细粒子分数: {fpf:F2}%",
"计算结果", MessageBoxButton.OK, MessageBoxImage.Information);
}
private async Task ExportReportAsync()
{
if (CurrentResult == null)
{
MessageBox.Show("请先计算测试结果", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var saveDialog = new Microsoft.Win32.SaveFileDialog
{
Filter = "Excel文件|*.xlsx",
FileName = $"ACI_Test_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx"
};
if (saveDialog.ShowDialog() == true)
{
await _reportService.GenerateReportAsync(CurrentResult, saveDialog.FileName);
MessageBox.Show("报告已保存", "完成", MessageBoxButton.OK, MessageBoxImage.Information);
}
}
// 启动定时读取(连接成功后)
private async Task ReadRealTimeLoop()
{
while (IsConnected)
{
try
{
// 读取原始流量
var rawFlow = await _plcService.ReadFloatAsync(_config.FlowRegister);
// 校准流量 = 原始流量 * 流量系数
var calibrated = rawFlow * Calibration.FlowCalibration;
Application.Current.Dispatcher.Invoke(() =>
{
RealTime.RawFlow = rawFlow;
RealTime.CalibratedFlow = calibrated;
});
// 温度
var temp = await _plcService.ReadFloatAsync(_config.TemperatureReg);
var calibratedTemp = temp * Calibration.TemperatureCalibration;
Application.Current.Dispatcher.Invoke(() => RealTime.Temperature = calibratedTemp);
// 泵端压力
var pumpPres = await _plcService.ReadFloatAsync(_config.PumpPressureReg);
var calibratedPump = pumpPres * Calibration.PumpPressureCalibration;
Application.Current.Dispatcher.Invoke(() => RealTime.PumpPressure = calibratedPump);
// 撞击器端压力
var impPres = await _plcService.ReadFloatAsync(_config.ImpactorPressureReg);
var calibratedImp = impPres * Calibration.ImpactorPressureCalibration;
Application.Current.Dispatcher.Invoke(() =>
{
RealTime.ImpactorPressure = calibratedImp;
RealTime.DifferentialPressure = calibratedImp - calibratedPump;
});
// 流量报警检查
if (calibrated < Calibration.FlowLowLimit || calibrated > Calibration.FlowHighLimit)
{
// 可触发界面警告
Application.Current.Dispatcher.Invoke(() =>
MessageBox.Show($"流量异常: {calibrated:F2} L/min", "警告", MessageBoxButton.OK, MessageBoxImage.Warning));
}
}
catch { }
await Task.Delay(1000);
}
}
}
}

50
Views/ConfigWindow.xaml Normal file
View File

@@ -0,0 +1,50 @@
<Window x:Class="AciTester.Views.ConfigWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="参数校准配置 - Ctrl+P" Height="450" Width="500"
WindowStartupLocation="CenterOwner">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<GroupBox Header="流量校准" Grid.Row="0">
<StackPanel Orientation="Horizontal" Margin="5">
<TextBlock Text="流量系数:" Width="80" VerticalAlignment="Center"/>
<TextBox Text="{Binding Calibration.FlowCalibration}" Width="100"/>
<TextBlock Text=" 保护低限:" Margin="20,0,0,0"/>
<TextBox Text="{Binding Calibration.FlowLowLimit}" Width="80"/>
<TextBlock Text="高限:" Margin="5,0,0,0"/>
<TextBox Text="{Binding Calibration.FlowHighLimit}" Width="80"/>
</StackPanel>
</GroupBox>
<GroupBox Header="温度校准" Grid.Row="1" Margin="0,10">
<StackPanel Orientation="Horizontal" Margin="5">
<TextBlock Text="温度系数:" Width="80"/>
<TextBox Text="{Binding Calibration.TemperatureCalibration}" Width="100"/>
</StackPanel>
</GroupBox>
<GroupBox Header="压力校准(泵端)" Grid.Row="2">
<StackPanel Orientation="Horizontal" Margin="5">
<TextBlock Text="泵端压力系数:" Width="120"/>
<TextBox Text="{Binding Calibration.PumpPressureCalibration}" Width="100"/>
</StackPanel>
</GroupBox>
<GroupBox Header="压力校准(撞击器端)" Grid.Row="3" Margin="0,10">
<StackPanel Orientation="Horizontal" Margin="5">
<TextBlock Text="撞击器端压力系数:" Width="140"/>
<TextBox Text="{Binding Calibration.ImpactorPressureCalibration}" Width="100"/>
</StackPanel>
</GroupBox>
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10">
<Button Command="{Binding LoadConfigCommand}" Content="读取" Width="80" Margin="5"/>
<Button Command="{Binding SaveConfigCommand}" Content="保存" Width="80" Margin="5"/>
<Button Click="CloseWindow" Content="关闭" Width="80" Margin="5"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
namespace AciTester.Views
{
/// <summary>
/// ConfigWindow.xaml 的交互逻辑
/// </summary>
public partial class ConfigWindow : Window
{
public ConfigWindow()
{
InitializeComponent();
}
private void CloseWindow(object sender, RoutedEventArgs e)
{
this.Close();
}
}
}

144
Views/MainWindow.xaml Normal file
View File

@@ -0,0 +1,144 @@
<Window x:Class="AciTester.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:AciTester.ViewModels"
Title="ACI测试系统 - 中国药典2025装置3"
Height="768" Width="1024"
WindowStartupLocation="CenterScreen">
<Window.DataContext>
<local:MainViewModel/>
</Window.DataContext>
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 状态栏 (Row 0) -->
<StatusBar Grid.Row="0">
<StatusBarItem>
<TextBlock Text="状态:"/>
</StatusBarItem>
<StatusBarItem>
<TextBlock Text="{Binding ConnectionStatus}" Foreground="{Binding IsConnected, Converter={StaticResource BoolToColorConverter}}"/>
</StatusBarItem>
<Separator/>
<StatusBarItem>
<TextBlock Text="流量:"/>
</StatusBarItem>
<StatusBarItem>
<TextBlock Text="{Binding CurrentFlow, StringFormat='{}{0:F2} L/min'}"/>
</StatusBarItem>
<Separator/>
<StatusBarItem>
<TextBlock Text="泵状态:"/>
</StatusBarItem>
<StatusBarItem>
<TextBlock Text="{Binding IsPumpRunning, Converter={StaticResource BoolToStringConverter}}"/>
</StatusBarItem>
<Separator/>
<StatusBarItem>
<TextBlock Text="倒计时:" Visibility="{Binding IsTesting, Converter={StaticResource BoolToVisibilityConverter}}"/>
</StatusBarItem>
<StatusBarItem>
<TextBlock Text="{Binding RemainingSeconds, StringFormat='{}{0} s'}" Visibility="{Binding IsTesting, Converter={StaticResource BoolToVisibilityConverter}}"/>
</StatusBarItem>
</StatusBar>
<!-- 实时监测参数 (Row 1) -->
<GroupBox Header="实时监测参数" Grid.Row="1" Margin="0,5">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Vertical">
<TextBlock Text="流量 (L/min)" FontWeight="Bold"/>
<TextBlock Text="{Binding RealTime.CalibratedFlow, StringFormat='{}{0:F2}'}" FontSize="18" Foreground="Blue"/>
<TextBlock Text="(目标: 28.3)" FontSize="10" Foreground="Gray"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Vertical">
<TextBlock Text="温度 (℃)" FontWeight="Bold"/>
<TextBlock Text="{Binding RealTime.Temperature, StringFormat='{}{0:F1}'}" FontSize="18"/>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Vertical">
<TextBlock Text="泵端压力 (kPa)" FontWeight="Bold"/>
<TextBlock Text="{Binding RealTime.PumpPressure, StringFormat='{}{0:F2}'}" FontSize="18"/>
</StackPanel>
<StackPanel Grid.Column="3" Orientation="Vertical">
<TextBlock Text="撞击器端压力 (kPa)" FontWeight="Bold"/>
<TextBlock Text="{Binding RealTime.ImpactorPressure, StringFormat='{}{0:F2}'}" FontSize="18"/>
</StackPanel>
</Grid>
<Separator Margin="0,5"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock Text="压差: " FontWeight="Bold"/>
<TextBlock Text="{Binding RealTime.DifferentialPressure, StringFormat='{}{0:F2} kPa'}" Foreground="DarkRed"/>
</StackPanel>
</StackPanel>
</GroupBox>
<!-- 主内容 (Row 2) -->
<Grid Grid.Row="2" Margin="0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<!-- 左侧控制区 -->
<StackPanel Grid.Column="0" Margin="5">
<GroupBox Header="通讯控制">
<StackPanel>
<Button Command="{Binding ConnectCommand}" Content="连接PLC" Width="100" Margin="5"/>
<Button Command="{Binding DisconnectCommand}" Content="断开连接" Width="100" Margin="5"/>
</StackPanel>
</GroupBox>
<GroupBox Header="采样参数" Margin="0,10">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="5">
<TextBlock Text="采样时间(秒):" VerticalAlignment="Center" Width="100"/>
<TextBox Text="{Binding SampleTimeSeconds}" Width="60" IsEnabled="{Binding IsTesting, Converter={StaticResource InverseBoolConverter}}"/>
</StackPanel>
<Button Command="{Binding StartTestCommand}" Content="开始测试" Width="100" Margin="5"
IsEnabled="{Binding IsTesting, Converter={StaticResource InverseBoolConverter}}"/>
<TextBlock Text="测试进行中..." Visibility="{Binding IsTesting, Converter={StaticResource BoolToVisibilityConverter}}"/>
</StackPanel>
</GroupBox>
<GroupBox Header="数据分析">
<StackPanel>
<Button Command="{Binding CalculateCommand}" Content="计算结果" Width="100" Margin="5"/>
<Button Command="{Binding ExportReportCommand}" Content="导出报告" Width="100" Margin="5"/>
</StackPanel>
</GroupBox>
</StackPanel>
<!-- 右侧称重数据表格 -->
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto">
<DataGrid ItemsSource="{Binding Stages}" AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="层级" Binding="{Binding StageName}" IsReadOnly="True"/>
<DataGridTextColumn Header="截止直径(μm)" Binding="{Binding CutoffDiameter, StringFormat='{}{0:F1}'}" IsReadOnly="True"/>
<DataGridTextColumn Header="测前质量(g)" Binding="{Binding InitialWeight, StringFormat='{}{0:F4}'}"/>
<DataGridTextColumn Header="测后质量(g)" Binding="{Binding FinalWeight, StringFormat='{}{0:F4}'}"/>
<DataGridTextColumn Header="净重(g)" Binding="{Binding NetWeight, StringFormat='{}{0:F6}'}" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
</ScrollViewer>
</Grid>
<!-- 结果显示 (Row 3) -->
<Border Grid.Row="3" BorderBrush="Gray" BorderThickness="1" Margin="0,5,0,0" Padding="5">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock Text="微细粒子剂量(FPD): " FontWeight="Bold"/>
<TextBlock Text="{Binding CurrentResult.FineParticleDose, StringFormat='{}{0:F2} mg'}" Margin="5,0,20,0"/>
<TextBlock Text="微细粒子分数(FPF): " FontWeight="Bold"/>
<TextBlock Text="{Binding CurrentResult.FineParticleFraction, StringFormat='{}{0:F2} %'}"/>
</StackPanel>
</Border>
</Grid>
</Window>

43
Views/MainWindow.xaml.cs Normal file
View File

@@ -0,0 +1,43 @@
using AciTester.ViewModels;
using CommunityToolkit.Mvvm.Input;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace AciTester.Views
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
this.Loaded += (s, e) =>
{
var keyGesture = new KeyGesture(Key.P, ModifierKeys.Control);
var inputBinding = new InputBinding(
new RelayCommand(OpenConfigWindow),
keyGesture);
this.InputBindings.Add(inputBinding);
};
}
private void OpenConfigWindow()
{
var vm = (MainViewModel)this.DataContext;
var configVm = new ConfigViewModel(vm._plcService, vm._config, vm.Calibration);
var win = new ConfigWindow { DataContext = configVm, Owner = this };
win.ShowDialog();
}
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="EPPlus" Version="7.0.0" />
<PackageReference Include="NModbus4.Core" Version="1.0.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,3 @@
<Solution>
<Project Path="Z173药用撞击器吸入粉雾剂粒径测定仪.csproj" />
</Solution>