diff --git a/FootwearTest/Models/DeviceSnapshot.cs b/FootwearTest/Models/DeviceSnapshot.cs index e7232e0..f72a7eb 100644 --- a/FootwearTest/Models/DeviceSnapshot.cs +++ b/FootwearTest/Models/DeviceSnapshot.cs @@ -9,6 +9,8 @@ public sealed record DeviceSnapshot( double EnvironmentHumidityPercent, double AirSpeedMetersPerSecond, double PowerWatts, + double VoltageVolts, + double CurrentAmps, double EnergyKilojoules, double WaterLossGramsPerHour, double PumpSpeedCubicCentimetersPerHour, @@ -24,6 +26,8 @@ public sealed record DeviceSnapshot( 50.0, 1.10, 4.5, + 220.0, + 0.02, 0.0, 5.0, 5.0, diff --git a/FootwearTest/Models/TestRecords.cs b/FootwearTest/Models/TestRecords.cs index 6d91cc3..4bc299b 100644 --- a/FootwearTest/Models/TestRecords.cs +++ b/FootwearTest/Models/TestRecords.cs @@ -28,6 +28,8 @@ public sealed record MethodASampleRecord( double RelativeHumidityPercent, double WaterLossGramsPerHour, double PowerWatts, + double VoltageVolts, + double CurrentAmps, double MoistureHeatWatts, double DryHeatWatts, double MoistureResistance, diff --git a/FootwearTest/Services/DeviceSettings.cs b/FootwearTest/Services/DeviceSettings.cs index 52d6fc5..7184492 100644 --- a/FootwearTest/Services/DeviceSettings.cs +++ b/FootwearTest/Services/DeviceSettings.cs @@ -16,4 +16,8 @@ public sealed class DeviceSettings public double MethodBAirSpeedMetersPerSecond { get; set; } = 1.10; public double PumpSpeedCubicCentimetersPerHour { get; set; } = 5.0; public double CoefficientOfVariationLimitPercent { get; set; } = 8.0; + public int VoltageRegisterAddress { get; set; } = -1; + public double VoltageRegisterScale { get; set; } = 0.1; + public int CurrentRegisterAddress { get; set; } = -1; + public double CurrentRegisterScale { get; set; } = 0.001; } diff --git a/FootwearTest/Services/ExcelReportService.cs b/FootwearTest/Services/ExcelReportService.cs index 623a1b1..aa77070 100644 --- a/FootwearTest/Services/ExcelReportService.cs +++ b/FootwearTest/Services/ExcelReportService.cs @@ -151,7 +151,7 @@ public sealed class ExcelReportService { var rows = new List> { - Row("序号", "时间", "假脚温度 ℃", "环境温度 ℃", "相对湿度 %", "出汗量 g/h", "加热功率 W", "湿热量 He W", "干热量 Hd W", "湿阻 Pa·m2/W", "热阻 m2·℃/W"), + Row("序号", "时间", "假脚温度 ℃", "环境温度 ℃", "相对湿度 %", "出汗量 g/h", "电压 V", "电流 A", "加热功率 W", "湿热量 He W", "干热量 Hd W", "湿阻 Pa·m2/W", "热阻 m2·℃/W"), }; if (root.TryGetProperty("Samples", out var samples) && samples.ValueKind == JsonValueKind.Array) @@ -169,6 +169,8 @@ public sealed class ExcelReportService GetDouble(sample, "EnvironmentTemperatureC"), GetDouble(sample, "RelativeHumidityPercent"), waterLoss, + GetDouble(sample, "VoltageVolts"), + GetDouble(sample, "CurrentAmps"), power, moistureHeat, dryHeat, diff --git a/FootwearTest/Services/ModbusTcpDeviceClient.cs b/FootwearTest/Services/ModbusTcpDeviceClient.cs index 561f83e..2b0a074 100644 --- a/FootwearTest/Services/ModbusTcpDeviceClient.cs +++ b/FootwearTest/Services/ModbusTcpDeviceClient.cs @@ -1,4 +1,6 @@ using System; +using System.Buffers.Binary; +using System.IO; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; @@ -9,10 +11,14 @@ namespace FootwearTest.Services; public sealed class ModbusTcpDeviceClient : IDeviceClient { + private const byte UnitId = 1; + private const byte ReadHoldingRegistersFunction = 0x03; + private readonly DeviceSettings _settings; private readonly ILogger _logger; private TcpClient? _tcpClient; private bool _lastReadFailed; + private ushort _transactionId; public ModbusTcpDeviceClient(DeviceSettings settings, ILogger logger) { @@ -60,17 +66,104 @@ public sealed class ModbusTcpDeviceClient : IDeviceClient return Task.CompletedTask; } - public Task ReadSnapshotAsync(CancellationToken cancellationToken = default) + public async Task ReadSnapshotAsync(CancellationToken cancellationToken = default) { - LastSnapshot = LastSnapshot with { Timestamp = DateTime.Now }; - SnapshotUpdated?.Invoke(this, LastSnapshot); - if (_lastReadFailed) + try { - _logger.LogInformation("Modbus snapshot read recovered. Timestamp={Timestamp}", LastSnapshot.Timestamp); - _lastReadFailed = false; + var voltage = LastSnapshot.VoltageVolts; + var current = LastSnapshot.CurrentAmps; + if (_settings.VoltageRegisterAddress >= 0) + { + voltage = await ReadScaledHoldingRegisterAsync( + _settings.VoltageRegisterAddress, + _settings.VoltageRegisterScale, + cancellationToken); + } + + if (_settings.CurrentRegisterAddress >= 0) + { + current = await ReadScaledHoldingRegisterAsync( + _settings.CurrentRegisterAddress, + _settings.CurrentRegisterScale, + cancellationToken); + } + + var power = voltage > 0 && current > 0 ? voltage * current : LastSnapshot.PowerWatts; + LastSnapshot = LastSnapshot with + { + Timestamp = DateTime.Now, + VoltageVolts = Math.Round(voltage, 2), + CurrentAmps = Math.Round(current, 3), + PowerWatts = Math.Round(power, 2), + AlarmText = "Modbus 电压/电流寄存器读取正常" + }; + + SnapshotUpdated?.Invoke(this, LastSnapshot); + if (_lastReadFailed) + { + _logger.LogInformation("Modbus snapshot read recovered. Timestamp={Timestamp}", LastSnapshot.Timestamp); + _lastReadFailed = false; + } + + return LastSnapshot; + } + catch (Exception ex) + { + _lastReadFailed = true; + LastSnapshot = LastSnapshot with + { + Timestamp = DateTime.Now, + AlarmText = $"Modbus 读取失败: {ex.Message}" + }; + SnapshotUpdated?.Invoke(this, LastSnapshot); + _logger.LogError(ex, "Failed to read Modbus snapshot"); + throw; + } + } + + private async Task ReadScaledHoldingRegisterAsync( + int registerAddress, + double scale, + CancellationToken cancellationToken) + { + if (!IsConnected || _tcpClient is null) + { + throw new InvalidOperationException("Modbus TCP 未连接。"); } - return Task.FromResult(LastSnapshot); + var stream = _tcpClient.GetStream(); + var request = new byte[12]; + var transactionId = unchecked(++_transactionId); + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(0, 2), transactionId); + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(2, 2), 0); + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(4, 2), 6); + request[6] = UnitId; + request[7] = ReadHoldingRegistersFunction; + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(8, 2), checked((ushort)registerAddress)); + BinaryPrimitives.WriteUInt16BigEndian(request.AsSpan(10, 2), 1); + + await stream.WriteAsync(request, cancellationToken); + + var response = new byte[11]; + await stream.ReadExactlyAsync(response, cancellationToken); + var responseTransactionId = BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(0, 2)); + if (responseTransactionId != transactionId) + { + throw new InvalidDataException($"Modbus 事务号不匹配,期望 {transactionId},实际 {responseTransactionId}。"); + } + + if (response[7] == (ReadHoldingRegistersFunction | 0x80)) + { + throw new InvalidDataException($"Modbus 异常响应码 {response[8]}。"); + } + + if (response[7] != ReadHoldingRegistersFunction || response[8] != 2) + { + throw new InvalidDataException("Modbus 响应格式不正确。"); + } + + var raw = BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(9, 2)); + return raw * scale; } public Task SetOutputsAsync(bool pumpRunning, bool fanRunning, bool heaterRunning, CancellationToken cancellationToken = default) diff --git a/FootwearTest/Services/SimulatedDeviceClient.cs b/FootwearTest/Services/SimulatedDeviceClient.cs index 9e02b92..99232ed 100644 --- a/FootwearTest/Services/SimulatedDeviceClient.cs +++ b/FootwearTest/Services/SimulatedDeviceClient.cs @@ -72,6 +72,8 @@ public sealed class SimulatedDeviceClient : IDeviceClient var airSpeed = (_fanRunning ? _settings.MethodBAirSpeedMetersPerSecond : 0.05) + Noise(0.03); var waterLoss = _pumpRunning ? _settings.PumpSpeedCubicCentimetersPerHour + Noise(0.12) : Math.Max(0, Noise(0.03)); var power = _heaterRunning ? 4.2 + Math.Max(0, targetFoot - chamber) * 0.03 + Noise(0.12) : 0.0; + var voltage = _heaterRunning ? 220.0 + Noise(1.5) : 0.0; + var current = voltage > 0 ? power / voltage : 0.0; _energyKilojoules += power * _settings.PollIntervalMilliseconds / 1_000_000.0; LastSnapshot = new DeviceSnapshot( @@ -81,6 +83,8 @@ public sealed class SimulatedDeviceClient : IDeviceClient Math.Round(humidity, 2), Math.Round(Math.Max(0, airSpeed), 2), Math.Round(Math.Max(0, power), 2), + Math.Round(Math.Max(0, voltage), 2), + Math.Round(Math.Max(0, current), 3), Math.Round(_energyKilojoules, 3), Math.Round(Math.Max(0, waterLoss), 2), _settings.PumpSpeedCubicCentimetersPerHour, diff --git a/FootwearTest/Services/TestFormulaService.cs b/FootwearTest/Services/TestFormulaService.cs index 7f2a89b..794fa47 100644 --- a/FootwearTest/Services/TestFormulaService.cs +++ b/FootwearTest/Services/TestFormulaService.cs @@ -26,6 +26,38 @@ public sealed class TestFormulaService (VaporizationHeatWhPerGram * nakedFootSweatGramsPerHour); } + public MethodASampleRecord CalculateSkinMoistureResistanceSample( + int index, + DeviceSnapshot snapshot, + double footAreaSquareMeters, + double skinSaturatedVaporPressurePa = 5620.0, + double environmentSaturatedVaporPressurePa = 2809.0) + { + var resistance = CalculateSkinMoistureResistance( + footAreaSquareMeters, + skinSaturatedVaporPressurePa, + 1.0, + environmentSaturatedVaporPressurePa, + snapshot.EnvironmentHumidityPercent / 100.0, + snapshot.WaterLossGramsPerHour); + var he = VaporizationHeatWhPerGram * snapshot.WaterLossGramsPerHour; + + return new MethodASampleRecord( + index, + snapshot.Timestamp, + snapshot.FootTemperatureC, + snapshot.EnvironmentTemperatureC, + snapshot.EnvironmentHumidityPercent, + snapshot.WaterLossGramsPerHour, + snapshot.PowerWatts, + snapshot.VoltageVolts, + snapshot.CurrentAmps, + Round(he, 3), + 0, + Round(resistance, 3), + 0); + } + public MethodASampleRecord CalculateMethodASample( int index, DeviceSnapshot snapshot, @@ -53,6 +85,8 @@ public sealed class TestFormulaService snapshot.EnvironmentHumidityPercent, snapshot.WaterLossGramsPerHour, snapshot.PowerWatts, + snapshot.VoltageVolts, + snapshot.CurrentAmps, Round(he, 3), Round(hd, 3), Round(re, 3), diff --git a/FootwearTest/ViewModels/MethodAViewModel.cs b/FootwearTest/ViewModels/MethodAViewModel.cs index b1ebf28..2b1a084 100644 --- a/FootwearTest/ViewModels/MethodAViewModel.cs +++ b/FootwearTest/ViewModels/MethodAViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Text.Json; @@ -12,6 +13,8 @@ namespace FootwearTest.ViewModels; public partial class MethodAViewModel : ViewModelBase { + private const int MethodAMinimumSampleCount = 30; + private readonly IDeviceClient _deviceClient; private readonly DeviceSettings _settings; private readonly TestFormulaService _formulaService; @@ -116,28 +119,46 @@ public partial class MethodAViewModel : ViewModelBase _logger.LogInformation("Method A skin moisture resistance started. SampleDescription={SampleDescription}", SampleDescription); await EnsureConnectedAsync(); IsRunning = true; - StatusText = "皮肤湿阻测定中"; + StatusText = "等待假脚/环境稳定"; Samples.Clear(); await _deviceClient.SetOutputsAsync(true, true, true); - - var skinResistanceValues = new double[30]; - for (var i = 1; i <= 30; i++) + await WaitForMethodAStabilityAsync(); + if (!IsRunning) { - await Task.Delay(60); + StatusText = "已停止"; + return; + } + + StatusText = "皮肤湿阻测定中"; + + while (IsRunning) + { + await Task.Delay(GetMethodASamplingInterval()); + if (!IsRunning) + { + break; + } + var snapshot = await _deviceClient.ReadSnapshotAsync(); - var value = _formulaService.CalculateSkinMoistureResistance( - _settings.FootAreaSquareMeters, - 5620.0, - 1.0, - 2809.0, - snapshot.EnvironmentHumidityPercent / 100.0, - snapshot.WaterLossGramsPerHour); - skinResistanceValues[i - 1] = value; - SkinMoistureResistance = Round(_formulaService.Average(skinResistanceValues.Take(i)), 3); + var sample = _formulaService.CalculateSkinMoistureResistanceSample(Samples.Count + 1, snapshot, _settings.FootAreaSquareMeters); + Samples.Add(sample); + SkinMoistureResistance = Round(_formulaService.Average(Samples.Select(item => item.MoistureResistance)), 3); + MoistureCvPercent = Round(_formulaService.CoefficientOfVariationPercent(Samples.Select(item => item.MoistureResistance)), 2); + ResultText = $"皮肤湿阻: 已采集 {Samples.Count} 次,Res {SkinMoistureResistance:F3} Pa·m²/W,CV {MoistureCvPercent:F2}%"; + + if (HasReachedVariationCoefficient(Samples.Select(item => item.MoistureResistance))) + { + break; + } + } + + if (!IsRunning) + { + StatusText = "已停止"; + return; } await _deviceClient.SetOutputsAsync(false, true, false); - MoistureCvPercent = Round(_formulaService.CoefficientOfVariationPercent(skinResistanceValues), 2); ThermalCvPercent = 0; AverageMoistureResistance = 0; AverageThermalResistance = 0; @@ -170,16 +191,40 @@ public partial class MethodAViewModel : ViewModelBase _logger.LogInformation("Method A whole shoe test started. SampleDescription={SampleDescription}", SampleDescription); await EnsureConnectedAsync(); IsRunning = true; - StatusText = "整鞋热阻/湿阻测定中"; + StatusText = "等待假脚/环境稳定"; Samples.Clear(); await _deviceClient.SetOutputsAsync(true, true, true); - - for (var i = 1; i <= 30; i++) + await WaitForMethodAStabilityAsync(); + if (!IsRunning) { - await Task.Delay(80); + StatusText = "已停止"; + return; + } + + StatusText = "整鞋热阻/湿阻测定中"; + + while (IsRunning) + { + await Task.Delay(GetMethodASamplingInterval()); + if (!IsRunning) + { + break; + } + var snapshot = await _deviceClient.ReadSnapshotAsync(); - Samples.Add(_formulaService.CalculateMethodASample(i, snapshot, _settings.FootAreaSquareMeters, SkinMoistureResistance)); + Samples.Add(_formulaService.CalculateMethodASample(Samples.Count + 1, snapshot, _settings.FootAreaSquareMeters, SkinMoistureResistance)); UpdateMethodAResult("整鞋热阻/湿阻"); + + if (HasReachedWholeShoeVariationCoefficient()) + { + break; + } + } + + if (!IsRunning) + { + StatusText = "已停止"; + return; } await _deviceClient.SetOutputsAsync(false, true, false); @@ -219,6 +264,52 @@ public partial class MethodAViewModel : ViewModelBase } } + private TimeSpan GetMethodASamplingInterval() + { + return _settings.UseSimulator ? TimeSpan.FromMilliseconds(100) : TimeSpan.FromMinutes(1); + } + + private TimeSpan GetMethodAStabilityPollInterval() + { + return _settings.UseSimulator ? TimeSpan.FromMilliseconds(100) : TimeSpan.FromSeconds(5); + } + + private async Task WaitForMethodAStabilityAsync() + { + while (IsRunning) + { + var snapshot = await _deviceClient.ReadSnapshotAsync(); + if (IsMethodAStable(snapshot)) + { + return; + } + + StatusText = $"等待稳定: 假脚 {snapshot.FootTemperatureC:F1} ℃,环境 {snapshot.EnvironmentTemperatureC:F1} ℃ / {snapshot.EnvironmentHumidityPercent:F0}%"; + await Task.Delay(GetMethodAStabilityPollInterval()); + } + } + + private bool IsMethodAStable(DeviceSnapshot snapshot) + { + return Math.Abs(snapshot.FootTemperatureC - _settings.MethodATargetTemperatureC) <= 0.3 && + Math.Abs(snapshot.EnvironmentTemperatureC - _settings.EnvironmentTemperatureC) <= 2.0 && + Math.Abs(snapshot.EnvironmentHumidityPercent - _settings.EnvironmentHumidityPercent) <= 5.0; + } + + private bool HasReachedVariationCoefficient(IEnumerable values) + { + var list = values.ToArray(); + return list.Length >= MethodAMinimumSampleCount && + _formulaService.CoefficientOfVariationPercent(list) <= _settings.CoefficientOfVariationLimitPercent; + } + + private bool HasReachedWholeShoeVariationCoefficient() + { + return Samples.Count >= MethodAMinimumSampleCount && + HasReachedVariationCoefficient(Samples.Select(sample => sample.MoistureResistance)) && + HasReachedVariationCoefficient(Samples.Select(sample => sample.ThermalResistance)); + } + private async Task EnsureConnectedAsync() { if (!_deviceClient.IsConnected) diff --git a/FootwearTest/ViewModels/SettingsViewModel.cs b/FootwearTest/ViewModels/SettingsViewModel.cs index cff4042..69fc54e 100644 --- a/FootwearTest/ViewModels/SettingsViewModel.cs +++ b/FootwearTest/ViewModels/SettingsViewModel.cs @@ -19,6 +19,10 @@ public partial class SettingsViewModel : ViewModelBase FootAreaSquareMeters = settings.FootAreaSquareMeters; CoefficientOfVariationLimitPercent = settings.CoefficientOfVariationLimitPercent; PumpSpeedCubicCentimetersPerHour = settings.PumpSpeedCubicCentimetersPerHour; + VoltageRegisterAddress = settings.VoltageRegisterAddress; + VoltageRegisterScale = settings.VoltageRegisterScale; + CurrentRegisterAddress = settings.CurrentRegisterAddress; + CurrentRegisterScale = settings.CurrentRegisterScale; SaveCommand = new RelayCommand(Save); } @@ -30,6 +34,10 @@ public partial class SettingsViewModel : ViewModelBase private double _footAreaSquareMeters; private double _coefficientOfVariationLimitPercent; private double _pumpSpeedCubicCentimetersPerHour; + private int _voltageRegisterAddress; + private double _voltageRegisterScale; + private int _currentRegisterAddress; + private double _currentRegisterScale; private string _saveStatus = "参数未保存"; public string Host { get => _host; set => SetProperty(ref _host, value); } @@ -38,6 +46,10 @@ public partial class SettingsViewModel : ViewModelBase public double FootAreaSquareMeters { get => _footAreaSquareMeters; set => SetProperty(ref _footAreaSquareMeters, value); } public double CoefficientOfVariationLimitPercent { get => _coefficientOfVariationLimitPercent; set => SetProperty(ref _coefficientOfVariationLimitPercent, value); } public double PumpSpeedCubicCentimetersPerHour { get => _pumpSpeedCubicCentimetersPerHour; set => SetProperty(ref _pumpSpeedCubicCentimetersPerHour, value); } + public int VoltageRegisterAddress { get => _voltageRegisterAddress; set => SetProperty(ref _voltageRegisterAddress, value); } + public double VoltageRegisterScale { get => _voltageRegisterScale; set => SetProperty(ref _voltageRegisterScale, value); } + public int CurrentRegisterAddress { get => _currentRegisterAddress; set => SetProperty(ref _currentRegisterAddress, value); } + public double CurrentRegisterScale { get => _currentRegisterScale; set => SetProperty(ref _currentRegisterScale, value); } public string SaveStatus { get => _saveStatus; set => SetProperty(ref _saveStatus, value); } private void Save() @@ -48,13 +60,19 @@ public partial class SettingsViewModel : ViewModelBase _settings.FootAreaSquareMeters = FootAreaSquareMeters; _settings.CoefficientOfVariationLimitPercent = CoefficientOfVariationLimitPercent; _settings.PumpSpeedCubicCentimetersPerHour = PumpSpeedCubicCentimetersPerHour; + _settings.VoltageRegisterAddress = VoltageRegisterAddress; + _settings.VoltageRegisterScale = VoltageRegisterScale; + _settings.CurrentRegisterAddress = CurrentRegisterAddress; + _settings.CurrentRegisterScale = CurrentRegisterScale; SaveStatus = "参数已保存,新的试验流程将使用当前设置"; _logger.LogInformation( - "Settings saved. UseSimulator={UseSimulator}, Host={Host}, Port={Port}, FootAreaSquareMeters={FootArea}, PumpSpeed={PumpSpeed}", + "Settings saved. UseSimulator={UseSimulator}, Host={Host}, Port={Port}, FootAreaSquareMeters={FootArea}, PumpSpeed={PumpSpeed}, VoltageRegister={VoltageRegister}, CurrentRegister={CurrentRegister}", UseSimulator, Host, Port, FootAreaSquareMeters, - PumpSpeedCubicCentimetersPerHour); + PumpSpeedCubicCentimetersPerHour, + VoltageRegisterAddress, + CurrentRegisterAddress); } } diff --git a/FootwearTest/Views/DashboardView.axaml b/FootwearTest/Views/DashboardView.axaml index c62a078..69e9ff6 100644 --- a/FootwearTest/Views/DashboardView.axaml +++ b/FootwearTest/Views/DashboardView.axaml @@ -30,6 +30,8 @@ + + diff --git a/FootwearTest/Views/MethodAView.axaml b/FootwearTest/Views/MethodAView.axaml index 048ecf1..452a22d 100644 --- a/FootwearTest/Views/MethodAView.axaml +++ b/FootwearTest/Views/MethodAView.axaml @@ -46,34 +46,38 @@ - - + + - - - - - + + + + + + + - + - - - - - + + + + + + + diff --git a/FootwearTest/Views/SettingsView.axaml b/FootwearTest/Views/SettingsView.axaml index 08a2f06..eab1b61 100644 --- a/FootwearTest/Views/SettingsView.axaml +++ b/FootwearTest/Views/SettingsView.axaml @@ -26,6 +26,14 @@ + + + + + + + +