From d11e1d4270c8d582130a6b67f3ab7e48147c880d Mon Sep 17 00:00:00 2001 From: "GukSang.Jin" Date: Tue, 5 May 2026 18:47:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ConeCalorimeter/MainWindow.xaml.cs | 1 + ConeCalorimeter/Models/RealtimeDataRecord.cs | 6 ++ ConeCalorimeter/Models/RealtimeSnapshot.cs | 3 + .../Services/ExperimentDataService.cs | 3 +- .../Services/IRealtimeDataExportService.cs | 8 ++ .../Services/ModbusRealtimeDataService.cs | 5 ++ .../Services/NpoiRealtimeDataExportService.cs | 87 +++++++++++++++++++ .../Services/NpoiReportExportService.cs | 54 ++++++++++-- ConeCalorimeter/ViewModels/MainViewModel.cs | 3 +- .../ViewModels/RealtimeDataViewModel.cs | 43 ++++++++- .../ViewModels/ReportFieldViewModel.cs | 28 +++++- .../ViewModels/ReportPageViewModel.cs | 46 +++++++++- 12 files changed, 270 insertions(+), 17 deletions(-) create mode 100644 ConeCalorimeter/Services/IRealtimeDataExportService.cs create mode 100644 ConeCalorimeter/Services/NpoiRealtimeDataExportService.cs diff --git a/ConeCalorimeter/MainWindow.xaml.cs b/ConeCalorimeter/MainWindow.xaml.cs index 836fd7f..3b7eb06 100644 --- a/ConeCalorimeter/MainWindow.xaml.cs +++ b/ConeCalorimeter/MainWindow.xaml.cs @@ -21,6 +21,7 @@ namespace ConeCalorimeter experimentDataService, _tcpDeviceConnectionService, new NpoiReportExportService(), + new NpoiRealtimeDataExportService(), new HelpDialogService()); } diff --git a/ConeCalorimeter/Models/RealtimeDataRecord.cs b/ConeCalorimeter/Models/RealtimeDataRecord.cs index 2bef4ec..b0c8cec 100644 --- a/ConeCalorimeter/Models/RealtimeDataRecord.cs +++ b/ConeCalorimeter/Models/RealtimeDataRecord.cs @@ -13,11 +13,14 @@ public sealed record RealtimeDataRecord( double CarbonDioxide, double CarbonMonoxide, double HeatReleaseRate, + double PeakHeatReleaseRate, + double CFactor, double Qa180, double Qa300, double TotalHeatRelease, double SmokeProduction, double CurrentMass, + double InitialMass, double MassLoss, int IgnitionSeconds, int TestSeconds, @@ -38,11 +41,14 @@ public sealed record RealtimeDataRecord( snapshot.CarbonDioxide, snapshot.CarbonMonoxide, snapshot.HeatReleaseRate, + snapshot.PeakHeatReleaseRate, + snapshot.CFactor, snapshot.Qa180, snapshot.Qa300, snapshot.TotalHeatRelease, snapshot.SmokeProduction, snapshot.CurrentMass, + snapshot.InitialMass, snapshot.MassLoss, snapshot.IgnitionSeconds, snapshot.TestSeconds, diff --git a/ConeCalorimeter/Models/RealtimeSnapshot.cs b/ConeCalorimeter/Models/RealtimeSnapshot.cs index e5031bf..ae92312 100644 --- a/ConeCalorimeter/Models/RealtimeSnapshot.cs +++ b/ConeCalorimeter/Models/RealtimeSnapshot.cs @@ -12,11 +12,14 @@ public sealed record RealtimeSnapshot( double CarbonDioxide, double CarbonMonoxide, double HeatReleaseRate, + double PeakHeatReleaseRate, + double CFactor, double Qa180, double Qa300, double TotalHeatRelease, double SmokeProduction, double CurrentMass, + double InitialMass, double MassLoss, int IgnitionSeconds, int TestSeconds, diff --git a/ConeCalorimeter/Services/ExperimentDataService.cs b/ConeCalorimeter/Services/ExperimentDataService.cs index e37009a..8394dc5 100644 --- a/ConeCalorimeter/Services/ExperimentDataService.cs +++ b/ConeCalorimeter/Services/ExperimentDataService.cs @@ -55,7 +55,6 @@ public sealed class ExperimentDataService : IExperimentDataService public void StopTest() { _isTestRunning = false; - _initialMass = null; } public void ClearRecords() @@ -139,6 +138,7 @@ public sealed class ExperimentDataService : IExperimentDataService { return snapshot with { + InitialMass = _initialMass ?? double.NaN, MassLoss = double.NaN }; } @@ -154,6 +154,7 @@ public sealed class ExperimentDataService : IExperimentDataService return snapshot with { + InitialMass = _initialMass ?? double.NaN, MassLoss = massLoss }; } diff --git a/ConeCalorimeter/Services/IRealtimeDataExportService.cs b/ConeCalorimeter/Services/IRealtimeDataExportService.cs new file mode 100644 index 0000000..35351da --- /dev/null +++ b/ConeCalorimeter/Services/IRealtimeDataExportService.cs @@ -0,0 +1,8 @@ +using ConeCalorimeter.Models; + +namespace ConeCalorimeter.Services; + +public interface IRealtimeDataExportService +{ + void Export(string outputPath, IReadOnlyList records); +} diff --git a/ConeCalorimeter/Services/ModbusRealtimeDataService.cs b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs index 9ea94f0..42ad1cb 100644 --- a/ConeCalorimeter/Services/ModbusRealtimeDataService.cs +++ b/ConeCalorimeter/Services/ModbusRealtimeDataService.cs @@ -13,7 +13,9 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService private const ushort ConeTemperatureRegister = 26; private const ushort OrificeTemperatureRegister = 30; private const ushort SampleTemperatureRegister = 36; + private const ushort CFactorRegister = 312; private const ushort HeatReleaseRateRegister = 354; + private const ushort PeakHeatReleaseRateRegister = 376; private const ushort Qa180Register = 366; private const ushort Qa300Register = 370; private const ushort SmokeProductionRegister = 392; @@ -42,11 +44,14 @@ public sealed class ModbusRealtimeDataService : IRealtimeDataService CarbonDioxide: ReadFloatOrEmpty(CarbonDioxideRegister), CarbonMonoxide: ReadFloatOrEmpty(CarbonMonoxideRegister), HeatReleaseRate: ReadFloatOrEmpty(HeatReleaseRateRegister), + PeakHeatReleaseRate: ReadFloatOrEmpty(PeakHeatReleaseRateRegister), + CFactor: ReadFloatOrEmpty(CFactorRegister), Qa180: ReadFloatOrEmpty(Qa180Register), Qa300: ReadFloatOrEmpty(Qa300Register), TotalHeatRelease: double.NaN, SmokeProduction: ReadFloatOrEmpty(SmokeProductionRegister), CurrentMass: ReadFloatOrEmpty(CurrentMassRegister), + InitialMass: double.NaN, MassLoss: double.NaN, IgnitionSeconds: ReadInt16OrEmpty(IgnitionSecondsRegister), TestSeconds: ReadInt16OrEmpty(TestSecondsRegister), diff --git a/ConeCalorimeter/Services/NpoiRealtimeDataExportService.cs b/ConeCalorimeter/Services/NpoiRealtimeDataExportService.cs new file mode 100644 index 0000000..1289a92 --- /dev/null +++ b/ConeCalorimeter/Services/NpoiRealtimeDataExportService.cs @@ -0,0 +1,87 @@ +using System.IO; +using ConeCalorimeter.Models; +using NPOI.HSSF.UserModel; +using NPOI.SS.UserModel; + +namespace ConeCalorimeter.Services; + +public sealed class NpoiRealtimeDataExportService : IRealtimeDataExportService +{ + private static readonly string[] Headers = + [ + "Time(s)", + "O2 (%)", + "CO2 (%)", + "CO (%)", + "孔板压差 (Pa)", + "孔板温度 (℃)", + "HRR", + "THR (MJ/m2)", + "SPR", + "TSR (m2)", + "MLR (g/s)", + "热释放KW/m2", + "EHC", + "损失质量", + "试样温度(℃)" + ]; + + public void Export(string outputPath, IReadOnlyList records) + { + var workbook = new HSSFWorkbook(); + var sheet = workbook.CreateSheet("实时数据"); + + WriteHeader(sheet); + WriteRecords(sheet, records); + + using var outputStream = File.Create(outputPath); + workbook.Write(outputStream); + } + + private static void WriteHeader(ISheet sheet) + { + var row = sheet.CreateRow(0); + for (var i = 0; i < Headers.Length; i++) + { + row.CreateCell(i).SetCellValue(Headers[i]); + } + } + + private static void WriteRecords(ISheet sheet, IReadOnlyList records) + { + for (var i = 0; i < records.Count; i++) + { + var record = records[i]; + var row = sheet.CreateRow(i + 1); + + SetNumeric(row, 0, record.TestSeconds >= 0 ? record.TestSeconds : double.NaN); + SetNumeric(row, 1, record.Oxygen); + SetNumeric(row, 2, record.CarbonDioxide); + SetNumeric(row, 3, record.CarbonMonoxide); + SetNumeric(row, 4, record.OrificePressure); + SetNumeric(row, 5, record.OrificeTemperature); + SetNumeric(row, 6, record.HeatReleaseRate); + SetNumeric(row, 7, record.TotalHeatRelease); + SetNumeric(row, 8, record.SmokeProduction); + SetNumeric(row, 9, record.TotalSmoke); + SetNumeric(row, 10, record.MassLossRate); + SetNumeric(row, 11, record.HeatReleaseRateKw); + SetNumeric(row, 12, record.EffectiveHeatOfCombustion); + SetNumeric(row, 13, record.MassLoss); + SetNumeric(row, 14, record.SampleTemperature); + } + + for (var i = 0; i < Headers.Length; i++) + { + sheet.AutoSizeColumn(i); + } + } + + private static void SetNumeric(IRow row, int columnIndex, double value) + { + if (double.IsFinite(value)) + { + row.CreateCell(columnIndex).SetCellValue(value); + } + } +} diff --git a/ConeCalorimeter/Services/NpoiReportExportService.cs b/ConeCalorimeter/Services/NpoiReportExportService.cs index 73d8abd..cd6e013 100644 --- a/ConeCalorimeter/Services/NpoiReportExportService.cs +++ b/ConeCalorimeter/Services/NpoiReportExportService.cs @@ -59,7 +59,7 @@ public sealed class NpoiReportExportService : IReportExportService SetValueBesideLabel(sheet, "材料", input.Material); SetValueBesideLabel(sheet, "样品", input.SampleName); SetValueBesideLabel(sheet, "厚度", input.Thickness); - SetValueBesideLabel(sheet, "初始质量", FirstNonEmpty(input.InitialMass, FormatWithUnit(records.FirstOrDefault()?.CurrentMass, "g"))); + SetValueBesideLabel(sheet, "初始质量", FirstNonEmpty(input.InitialMass, summary.InitialMass)); SetValueBesideLabel(sheet, "辐射面积", input.IrradiatedArea); SetValueBesideLabel(sheet, "热辐射值", input.Irradiance); SetValueBesideLabel(sheet, "辐射距离", input.IrradianceDistance); @@ -87,7 +87,7 @@ public sealed class NpoiReportExportService : IReportExportService SetValueBesideLabel(sheet, "结束标准", input.EndCriteria); SetValueBesideLabel(sheet, "结束时间", FirstNonEmpty(input.EndTime, summary.EndTime)); SetValueBesideLabel(sheet, "E等价热值", input.EquivalentHeatValue); - SetValueBesideLabel(sheet, "C-系数", input.CFactor); + SetValueBesideLabel(sheet, "C-系数", FirstNonEmpty(input.CFactor, summary.CFactor)); SetValueBesideLabel(sheet, "光程", input.LightPath); SetValueBesideLabel(sheet, "O2延迟时间", input.O2DelayTime); SetValueBesideLabel(sheet, "CO2延迟时间", input.CO2DelayTime); @@ -255,18 +255,20 @@ public sealed class NpoiReportExportService : IReportExportService { if (records.Count == 0) { - return new ReportSummary("", "", "", "", "", "", ""); + return new ReportSummary("", "", "", "", "", "", "", "", ""); } var last = records[^1]; var ignition = records.FirstOrDefault(record => record.FlameDetected)?.TestSeconds; return new ReportSummary( - PeakHeatReleaseRate: FormatWithUnit(records.Max(record => record.HeatReleaseRate), "kW/㎡"), - PeakSmokeProduction: FormatWithUnit(records.Max(record => record.SmokeProduction), "m²/s"), - TotalHeatRelease: FormatWithUnit(last.TotalHeatRelease, "MJ/㎡"), - TotalSmoke: FormatWithUnit(last.TotalSmoke, "m²"), - MassLoss: FormatWithUnit(last.MassLoss, "g"), + PeakHeatReleaseRate: FormatWithUnit(LastFinite(records, record => record.PeakHeatReleaseRate), "kW/㎡"), + PeakSmokeProduction: FormatWithUnit(MaxFinite(records, record => record.SmokeProduction), "m²/s"), + TotalHeatRelease: FormatWithUnit(LastFinite(records, record => record.TotalHeatRelease), "MJ/㎡"), + TotalSmoke: FormatWithUnit(LastFinite(records, record => record.TotalSmoke), "m²"), + MassLoss: FormatWithUnit(LastFinite(records, record => record.MassLoss), "g"), + InitialMass: FormatWithUnit(LastFinite(records, record => record.InitialMass), "g"), + CFactor: FormatValue(LastFinite(records, record => record.CFactor)), IgnitionTime: ignition is null ? "" : $"{ignition.Value} s", EndTime: $"{last.TestSeconds} s"); } @@ -276,6 +278,40 @@ public sealed class NpoiReportExportService : IReportExportService return value is null || !double.IsFinite(value.Value) ? "" : $"{value.Value:0.00} {unit}"; } + private static string FormatValue(double? value) + { + return value is null || !double.IsFinite(value.Value) ? "" : $"{value.Value:0.00}"; + } + + private static double? LastFinite(IReadOnlyList records, Func selector) + { + for (var i = records.Count - 1; i >= 0; i--) + { + var value = selector(records[i]); + if (double.IsFinite(value)) + { + return value; + } + } + + return null; + } + + private static double? MaxFinite(IReadOnlyList records, Func selector) + { + double? max = null; + foreach (var record in records) + { + var value = selector(record); + if (double.IsFinite(value) && (!max.HasValue || value > max.Value)) + { + max = value; + } + } + + return max; + } + private static string FirstNonEmpty(params string[] values) { return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; @@ -287,6 +323,8 @@ public sealed class NpoiReportExportService : IReportExportService string TotalHeatRelease, string TotalSmoke, string MassLoss, + string InitialMass, + string CFactor, string IgnitionTime, string EndTime); } diff --git a/ConeCalorimeter/ViewModels/MainViewModel.cs b/ConeCalorimeter/ViewModels/MainViewModel.cs index 7944b73..2b279e3 100644 --- a/ConeCalorimeter/ViewModels/MainViewModel.cs +++ b/ConeCalorimeter/ViewModels/MainViewModel.cs @@ -14,6 +14,7 @@ public sealed class MainViewModel : ObservableObject IExperimentDataService experimentDataService, ITcpDeviceConnectionService tcpDeviceConnectionService, IReportExportService reportExportService, + IRealtimeDataExportService realtimeDataExportService, IHelpDialogService helpDialogService) { _helpDialogService = helpDialogService; @@ -39,7 +40,7 @@ public sealed class MainViewModel : ObservableObject new SmokeDensitySettingsViewModel(() => SelectPage(testItem), tcpDeviceConnectionService)); var realtimeDataItem = new NavigationItemViewModel( "实时数据", - new RealtimeDataViewModel(experimentDataService, () => SelectPage(testItem))); + new RealtimeDataViewModel(experimentDataService, realtimeDataExportService, () => SelectPage(testItem))); var helpItem = new NavigationItemViewModel( "帮助", ShowMainHelp); diff --git a/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs b/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs index 53f588f..ec28c07 100644 --- a/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs +++ b/ConeCalorimeter/ViewModels/RealtimeDataViewModel.cs @@ -1,8 +1,10 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Windows; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ConeCalorimeter.Services; +using Microsoft.Win32; namespace ConeCalorimeter.ViewModels; @@ -10,11 +12,16 @@ public sealed class RealtimeDataViewModel : PageViewModel { private readonly Action _closeAction; private readonly IExperimentDataService _experimentDataService; + private readonly IRealtimeDataExportService _realtimeDataExportService; private string _statusText = string.Empty; - public RealtimeDataViewModel(IExperimentDataService experimentDataService, Action closeAction) : base("实时数据") + public RealtimeDataViewModel( + IExperimentDataService experimentDataService, + IRealtimeDataExportService realtimeDataExportService, + Action closeAction) : base("实时数据") { _experimentDataService = experimentDataService; + _realtimeDataExportService = realtimeDataExportService; _closeAction = closeAction; Rows = []; @@ -77,7 +84,39 @@ public sealed class RealtimeDataViewModel : PageViewModel private void ExportRows() { - StatusText = Rows.Count == 0 ? "没有可导出的数据" : $"已准备导出 {Rows.Count} 条记录"; + var records = _experimentDataService.Records.ToList(); + if (records.Count == 0) + { + StatusText = "没有可导出的数据"; + return; + } + + var dialog = new SaveFileDialog + { + Title = "导出实时数据", + Filter = "Excel 97-2003 工作簿 (*.xls)|*.xls", + FileName = $"实时数据_{DateTime.Now:yyyyMMdd_HHmmss}.xls", + DefaultExt = ".xls", + AddExtension = true, + OverwritePrompt = true + }; + + if (dialog.ShowDialog() != true) + { + StatusText = "已取消导出"; + return; + } + + try + { + _realtimeDataExportService.Export(dialog.FileName, records); + StatusText = $"已导出:{dialog.FileName}"; + } + catch (Exception ex) + { + StatusText = $"导出失败:{ex.Message}"; + MessageBox.Show(StatusText, "导出实时数据", MessageBoxButton.OK, MessageBoxImage.Error); + } } } diff --git a/ConeCalorimeter/ViewModels/ReportFieldViewModel.cs b/ConeCalorimeter/ViewModels/ReportFieldViewModel.cs index f50cc4a..8c4c86b 100644 --- a/ConeCalorimeter/ViewModels/ReportFieldViewModel.cs +++ b/ConeCalorimeter/ViewModels/ReportFieldViewModel.cs @@ -5,6 +5,8 @@ namespace ConeCalorimeter.ViewModels; public sealed class ReportFieldViewModel : ObservableObject { private string _value; + private bool _isAutoFilled; + private bool _isSettingAutoValue; public ReportFieldViewModel(string key, string label, string value = "") { @@ -20,6 +22,30 @@ public sealed class ReportFieldViewModel : ObservableObject public string Value { get => _value; - set => SetProperty(ref _value, value); + set + { + if (SetProperty(ref _value, value) && !_isSettingAutoValue) + { + _isAutoFilled = false; + } + } + } + + public void SetAutoValue(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + if (!string.IsNullOrWhiteSpace(Value) && !_isAutoFilled) + { + return; + } + + _isSettingAutoValue = true; + Value = value; + _isSettingAutoValue = false; + _isAutoFilled = true; } } diff --git a/ConeCalorimeter/ViewModels/ReportPageViewModel.cs b/ConeCalorimeter/ViewModels/ReportPageViewModel.cs index e316cdc..c7b40bc 100644 --- a/ConeCalorimeter/ViewModels/ReportPageViewModel.cs +++ b/ConeCalorimeter/ViewModels/ReportPageViewModel.cs @@ -141,10 +141,12 @@ public sealed class ReportPageViewModel : PageViewModel SummaryItems[1].Value = records.First().Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture); SummaryItems[2].Value = records.Last().Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture); - SummaryItems[3].Value = $"{records.Max(row => row.HeatReleaseRate):0.00} kW/㎡"; - SummaryItems[4].Value = $"{records.Last().TotalHeatRelease:0.00} MJ/㎡"; - SummaryItems[5].Value = $"{records.Last().TotalSmoke:0.00} m²"; - SummaryItems[6].Value = $"{records.Last().MassLoss:0.00} g"; + var last = records.Last(); + SummaryItems[3].Value = FormatWithUnit(last.PeakHeatReleaseRate, "kW/㎡"); + SummaryItems[4].Value = FormatWithUnit(last.TotalHeatRelease, "MJ/㎡"); + SummaryItems[5].Value = FormatWithUnit(last.TotalSmoke, "m²"); + SummaryItems[6].Value = FormatWithUnit(last.MassLoss, "g"); + FillCollectedReportFields(records); } private void ExportReport() @@ -224,4 +226,40 @@ public sealed class ReportPageViewModel : PageViewModel { return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; } + + private static string FormatWithUnit(double value, string unit) + { + return double.IsFinite(value) ? $"{value:0.00} {unit}" : "-"; + } + + private void FillCollectedReportFields(IReadOnlyList records) + { + FindField("InitialMass")?.SetAutoValue(FormatWithUnit(LastFinite(records, record => record.InitialMass), "g")); + FindField("CFactor")?.SetAutoValue(FormatValue(LastFinite(records, record => record.CFactor))); + } + + private ReportFieldViewModel? FindField(string key) + { + return Sections.SelectMany(section => section.Fields) + .FirstOrDefault(field => field.Key == key); + } + + private static double LastFinite(IReadOnlyList records, Func selector) + { + for (var i = records.Count - 1; i >= 0; i--) + { + var value = selector(records[i]); + if (double.IsFinite(value)) + { + return value; + } + } + + return double.NaN; + } + + private static string FormatValue(double value) + { + return double.IsFinite(value) ? $"{value:0.00}" : string.Empty; + } }