This commit is contained in:
GukSang.Jin
2026-05-05 18:47:25 +08:00
parent 69fb616ab9
commit d11e1d4270
12 changed files with 270 additions and 17 deletions

View File

@@ -21,6 +21,7 @@ namespace ConeCalorimeter
experimentDataService,
_tcpDeviceConnectionService,
new NpoiReportExportService(),
new NpoiRealtimeDataExportService(),
new HelpDialogService());
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
};
}

View File

@@ -0,0 +1,8 @@
using ConeCalorimeter.Models;
namespace ConeCalorimeter.Services;
public interface IRealtimeDataExportService
{
void Export(string outputPath, IReadOnlyList<RealtimeDataRecord> records);
}

View File

@@ -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),

View File

@@ -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<RealtimeDataRecord> 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<RealtimeDataRecord> 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);
}
}
}

View File

@@ -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<RealtimeDataRecord> records, Func<RealtimeDataRecord, double> 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<RealtimeDataRecord> records, Func<RealtimeDataRecord, double> 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);
}

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<RealtimeDataRecord> 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<RealtimeDataRecord> records, Func<RealtimeDataRecord, double> 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;
}
}