diff --git a/docs/PL导出与辐射定标.md b/docs/PL导出与辐射定标.md new file mode 100644 index 0000000..544c061 --- /dev/null +++ b/docs/PL导出与辐射定标.md @@ -0,0 +1,58 @@ +# PL、ABS 保存与辐射定标 + +## 输出文件 + +菜单导出、快速保存和每轮自动保存共用同一规则。样品名称为 `sample`、启用三个激发通道时: + +- `sample_532.csv`、`sample_577.csv`、`sample_671.csv`:各通道的设备采集 Counts。A1 为波长,B1 分别为 `sample_532 (Count)` 等;C、D 列记录测试信息。 +- `sample_PL.csv`:客户 PL 汇总模板,包括三个通道的 W/nm 及三个通道的原始 Counts。 +- `sample_ABS.csv`:客户 ABS 汇总模板,包括拼接、近红外和可见光吸光度。只有实际存在吸光度结果时输出。 + +未启用的通道不生成单独文件,汇总对应列留空。PL 与 ABS 保持两份汇总文件,不合并工作簿。重复保存时整组使用同一个递增序号,例如 `sample_532_002.csv`、`sample_PL_002.csv`。导出多轮历史数据时另外使用 Loop 编号。采集时间记录在文件内,不自动加入文件名。已有文件不会覆盖。 + +样品名称末尾自带 `_PL` 或 `_ABS` 时原样保留。当前“导入光谱数据”只支持单条 Counts 曲线;PL、ABS 多曲线汇总文件会明确拒绝导入,避免把 W/nm 或 OD 数值误标为 Counts。回读原始数据请选择各激发波长单独保存的文件。 + +新单通道文件回读时从 B1 恢复激发波长,可以继续导出 Counts 和 PL 汇总。无法识别激发波长的旧通用单谱不会生成空 PL 文件,而是提示无法识别通道。普通连续和定时连续都会保留本次会话已完成的各轮结果,即使关闭自动保存,停止后仍可导出各轮文件;开始新会话会清除上一会话的历史。 + +原始 Counts 是 SDK 返回、按配置波长范围截取的数据,未扣纯水背景、未进行软件平滑;SDK 的平均及 Boxcar 等采集设置仍适用。显示继续使用扣除纯水背景的结果。历史数据若未保留原始 Counts,不会将扣背景后的数据冒充原始数据。 + +## W/nm 换算 + +按客户提供的公式逐像素计算: + +`功率谱密度 = (样品Counts - 独立暗谱Counts) × C / (像素带宽nm × 积分时间秒)` + +此处系数 C 的量纲必须为 **J/count**,等价于 W·s/count;除以 nm 和 s 后得到 W/nm。供应商系数若已经包含积分时间、像素带宽或使用其他单位,必须先适配到此约定,不能重复归一化。图片中系数单位文字与公式量纲不一致,以公式及最终 W/nm 为准。 + +不以平均次数乘积分时间代替 t:当前采集参数表示多次读数平均。使用各激光该轮实际请求的积分时间并从 ms 除以 1000 转为 s。当前 SeaSDK 适配器积分时间上限为 10000 ms;超过该范围时不生成换算数据,防止使用被硬件截断之前的请求值计算。 + +使用每个像素的定标带宽,不把仪器的光学分辨率直接当作像素带宽。保留负值。输出使用足够有效数字,避免小功率数值被四位小数舍入成零。这里计算的是光谱功率密度,不是样品的荧光量子产率百分比。 + +## 导入和测量 + +1. 切换为开发者,为荧光模块绑定实际光谱仪,在原上方菜单位置选择“文件 → PL换算参数”。普通用户和管理员不显示该入口;采集或校准期间不能修改。 + 窗口中可以查看、修改定标编号、定标时的增益/Boxcar/暗噪声条件,以及各像素的 C(J/count)和像素带宽(nm)。系数支持 `1e-12` 等科学计数法;“统一 C / 统一带宽 → 应用全部”只修改当前草稿,各像素也可分别编辑。点击“保存参数”后持久化,取消或关闭窗口不生效。积分时间仍自动取各激光的采集设置,单位由 ms 换算为 s,不在此处重复填写。 + 已有 JSON 可通过窗口内“从文件载入”填入草稿,再检查并保存。允许只覆盖实际测量范围的合法像素子集。旧参数文件损坏或不适配当前设备像素时,仍可打开窗口修复,保存前不会覆盖原文件。 +2. 定标文件必须匹配设备序列号、增益、Boxcar 和暗噪声设置;必须覆盖测量所用像素波长。波长严格按像素匹配,不插值或外推系数。 +3. 导入后执行自动校准。软件先关闭所有激光和 shutter,将六槽轮切至第 5 档,按各启用激光通道的积分时间采集独立暗谱,再恢复原档位并执行原有纯水背景校准。此模式需要六槽滤光轮。暗谱按设备、通道、完整采集参数和波长范围保存,不能用纯水背景代替。 +4. 换成样品并采集。原始 Counts、扣背景结果及换算结果在采集时分别保留;之后改变界面参数或导入其他系数,不会重算历史结果。 +5. 若缺少定标文件、暗谱或参数不匹配,仍保存原始 Counts,W/nm 列留空;界面警告及 PL 文件内记录具体原因。未导入定标文件时,原有纯水背景校准流程不增加暗谱动作。 + +JSON 字段(大小写固定): + +| 字段 | 内容 | +| --- | --- | +| DeviceSerialNumber | 光谱仪序列号,字符串 | +| CalibrationId | 定标文件编号或版本,字符串,写入测量结果 | +| InternalAnalogGainLevel | 对应请求增益档位,0 或 1 | +| BoxcarWidth | 对应 Boxcar 参数,非负整数 | +| DarkNoiseCorrection | 对应暗噪声设置,true 或 false | +| WavelengthsNm | 严格递增的像素波长数组,nm | +| FactorsJPerCount | 同长度的逐像素实际定标系数数组,J/count | +| PixelBandwidthsNm | 同长度的逐像素实际带宽数组,nm | + +三组数组要求有限正数,不能用 1 或其他占位系数代替实际标定。定标文件保存在运行目录 `calibration/radiometric/`,荧光独立暗谱保存在对应 `calibration/fluorescence/<参数摘要>/dark.csv`。 + +当前模拟增益硬件写入仍受 `ISSUES.md` 的临时禁用措施约束;SeaSDK 桌面适配器也尚未读取或应用实际硬件暗噪声模式。上述增益和暗噪声匹配检查只能校验请求值,不能证明实际硬件模式。转换状态会记录“实际硬件增益及暗噪声模式需人工确认”。正式测量需要确认实际硬件条件与供应商定标条件一致;本次不恢复增益写入,也不盲目改变硬件暗噪声参数。 + +厂商资料说明绝对辐射定标针对光谱仪及所接光学系统,改变光纤或收光配置会使原定标失效:[Ocean Optics Calibration FAQs](https://www.oceanoptics.com/faq/calibration-faqs/)。用户需确保导入文件适用于当前整套光路。 diff --git a/src/RamanApp/MainWindow.axaml b/src/RamanApp/MainWindow.axaml index 92daab0..3986630 100644 --- a/src/RamanApp/MainWindow.axaml +++ b/src/RamanApp/MainWindow.axaml @@ -21,6 +21,8 @@ + diff --git a/src/RamanApp/MainWindow.axaml.cs b/src/RamanApp/MainWindow.axaml.cs index e215312..010d7a5 100644 --- a/src/RamanApp/MainWindow.axaml.cs +++ b/src/RamanApp/MainWindow.axaml.cs @@ -237,6 +237,17 @@ public partial class MainWindow : Window } } + private async void OnRadiometricSettingsClick(object? sender, RoutedEventArgs e) + { + try + { + var draft = Vm.CreateRadiometricSettings(); + var dialog = new Views.RadiometricSettingsDialog(Vm, draft); + await dialog.ShowDialog(this); + } + catch (Exception ex) { AppToastService.Error(ex.Message, "PL换算参数"); } + } + private async void OnExportClick(object? sender, RoutedEventArgs e) { var exportSeries = Vm.GetExportSeries(); @@ -286,13 +297,17 @@ public partial class MainWindow : Window return; } - var text = SpectrumFileService.ToCustomerTemplateText(exportSeries); - var clipboard = GetTopLevel(this)?.Clipboard; - if (clipboard != null) + try { - await clipboard.SetTextAsync(text); - AppToastService.Success($"已复制 {exportSeries.Count} 组光谱到剪贴板"); + var text = SpectrumFileService.ToCustomerTemplateText(exportSeries); + var clipboard = GetTopLevel(this)?.Clipboard; + if (clipboard != null) + { + await clipboard.SetTextAsync(text); + AppToastService.Success($"已复制 {exportSeries.Count} 组光谱到剪贴板"); + } } + catch (Exception ex) { AppToastService.Error(ex.Message, "复制失败"); } } private async void OnSaveImageClick(object? sender, RoutedEventArgs e) diff --git a/src/RamanApp/Models/SpectrumData.cs b/src/RamanApp/Models/SpectrumData.cs index 2d29b33..75fed12 100644 --- a/src/RamanApp/Models/SpectrumData.cs +++ b/src/RamanApp/Models/SpectrumData.cs @@ -10,13 +10,15 @@ public class SpectrumData public double[] Intensities { get; } public DateTime AcquiredAt { get; } public string Source { get; } + public string? ExcitationWavelengthNm { get; } - public SpectrumData(double[] wavelengths, double[] intensities, string source = "") + public SpectrumData(double[] wavelengths, double[] intensities, string source = "", string? excitationWavelengthNm = null) { Wavelengths = wavelengths; Intensities = intensities; AcquiredAt = DateTime.Now; Source = source; + ExcitationWavelengthNm = excitationWavelengthNm; } public double[] ToWavenumbers(double laserWavelengthNm) @@ -174,7 +176,10 @@ public record SpectrumExportSeries( int? LoopIndex = null, DateTime? LoopTimestamp = null, string DataType = "Raw", - SpectrumExportMetadata? Metadata = null); + SpectrumExportMetadata? Metadata = null, + SpectrumData? OriginalCounts = null, + SpectrumData? RadiometricSpectrum = null, + string? RadiometricStatus = null); public sealed record SpectrumExportMetadata( DateTime? AcquiredAt, diff --git a/src/RamanApp/Services/CalibrationStore.cs b/src/RamanApp/Services/CalibrationStore.cs index 294590e..c4085f0 100644 --- a/src/RamanApp/Services/CalibrationStore.cs +++ b/src/RamanApp/Services/CalibrationStore.cs @@ -35,6 +35,43 @@ public class CalibrationStore public bool HasFluorescenceBackground(FluorescenceCalibrationKey key) => File.Exists(Path.Combine(GetKeyDirectory("fluorescence", key), "background.csv")); + public void SaveFluorescenceDark(FluorescenceCalibrationKey key, SpectrumData spectrum) + { + var directory = GetKeyDirectory("fluorescence", key); + Directory.CreateDirectory(directory); + SpectrumFileService.ExportCsv(Path.Combine(directory, "dark.csv"), spectrum, 0); + } + + public SpectrumData? LoadFluorescenceDark(FluorescenceCalibrationKey key) + { + var path = Path.Combine(GetKeyDirectory("fluorescence", key), "dark.csv"); + return File.Exists(path) ? SpectrumFileService.ImportCsv(path) : null; + } + + public void SaveRadiometricCalibration(RadiometricCalibration calibration) + { + calibration.Validate(); + var directory = GetKeyDirectory("radiometric", calibration.DeviceSerialNumber.ToUpperInvariant()); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "calibration.json"); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(temporary, JsonSerializer.Serialize(calibration)); + File.Move(temporary, path, true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + public RadiometricCalibration? LoadRadiometricCalibration(string serialNumber) + { + var path = Path.Combine(GetKeyDirectory("radiometric", serialNumber.ToUpperInvariant()), "calibration.json"); + return File.Exists(path) ? RadiometricCalibration.Read(path) : null; + } + public void SaveStitchedAbsorbanceCalibration( AbsorbanceCalibrationKey key, SpectrumData reference, SpectrumData dark) { diff --git a/src/RamanApp/Services/RadiometricCalibration.cs b/src/RamanApp/Services/RadiometricCalibration.cs new file mode 100644 index 0000000..0e06327 --- /dev/null +++ b/src/RamanApp/Services/RadiometricCalibration.cs @@ -0,0 +1,73 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using RamanApp.Models; + +namespace RamanApp.Services; + +/// Pixel factors in J/count; bandwidths in nm. Output: W/nm. +public sealed record RadiometricCalibration( + string DeviceSerialNumber, + string CalibrationId, + int InternalAnalogGainLevel, + int BoxcarWidth, + bool DarkNoiseCorrection, + double[] WavelengthsNm, + double[] FactorsJPerCount, + double[] PixelBandwidthsNm) +{ + public static RadiometricCalibration Read(string path) + { + var calibration = JsonSerializer.Deserialize(File.ReadAllText(path)) + ?? throw new InvalidDataException("辐射定标文件为空"); + calibration.Validate(); + return calibration; + } + + public void Validate() + { + if (string.IsNullOrWhiteSpace(DeviceSerialNumber) || string.IsNullOrWhiteSpace(CalibrationId) + || WavelengthsNm == null || FactorsJPerCount == null || PixelBandwidthsNm == null + || WavelengthsNm.Length == 0 || FactorsJPerCount.Length != WavelengthsNm.Length + || PixelBandwidthsNm.Length != WavelengthsNm.Length + || InternalAnalogGainLevel is < 0 or > 1 || BoxcarWidth < 0) + throw new InvalidDataException("辐射定标文件的设备、编号、参数或像素数组无效"); + for (int i = 0; i < WavelengthsNm.Length; i++) + { + if (!double.IsFinite(WavelengthsNm[i]) || WavelengthsNm[i] <= 0 + || (i > 0 && WavelengthsNm[i] <= WavelengthsNm[i - 1]) + || !double.IsFinite(FactorsJPerCount[i]) || FactorsJPerCount[i] <= 0 + || !double.IsFinite(PixelBandwidthsNm[i]) || PixelBandwidthsNm[i] <= 0) + throw new InvalidDataException("辐射定标波长必须递增,系数及像素带宽必须为有限正数"); + } + } + + public SpectrumData Convert(SpectrumData sample, SpectrumData dark, + string serialNumber, SpectrometerSettings settings) + { + Validate(); + if (!string.Equals(DeviceSerialNumber, serialNumber, StringComparison.OrdinalIgnoreCase) + || settings.InternalAnalogGainLevel != InternalAnalogGainLevel + || settings.BoxcarWidth != BoxcarWidth || settings.DarkNoiseCorrection != DarkNoiseCorrection) + throw new InvalidOperationException("辐射定标的设备或增益、Boxcar、暗噪声设置不匹配,请重新导入适用的定标文件"); + if (settings.IntegrationTimeMs <= 0 || settings.IntegrationTimeMs > 10_000 || sample.Wavelengths.Length == 0 + || sample.Intensities.Length != sample.Wavelengths.Length + || dark.Intensities.Length != dark.Wavelengths.Length + || !sample.Wavelengths.SequenceEqual(dark.Wavelengths)) + throw new InvalidOperationException("PL 原始数据与暗谱像素不匹配或积分时间无效,请重新自动校准"); + double seconds = settings.IntegrationTimeMs / 1000.0; + var values = new double[sample.Wavelengths.Length]; + for (int i = 0; i < values.Length; i++) + { + int pixel = Array.BinarySearch(WavelengthsNm, sample.Wavelengths[i]); + if (pixel < 0) + throw new InvalidOperationException("辐射定标文件未覆盖当前像素波长,不允许外推系数"); + values[i] = (sample.Intensities[i] - dark.Intensities[i]) + * FactorsJPerCount[pixel] / (PixelBandwidthsNm[pixel] * seconds); + if (!double.IsFinite(values[i])) + throw new InvalidOperationException("PL 辐射定标产生无效数值"); + } + return new SpectrumData(sample.Wavelengths.ToArray(), values, "radiometric-W-per-nm"); + } +} diff --git a/src/RamanApp/Services/SpectrumFileService.cs b/src/RamanApp/Services/SpectrumFileService.cs index 02ade76..d3bb9bb 100644 --- a/src/RamanApp/Services/SpectrumFileService.cs +++ b/src/RamanApp/Services/SpectrumFileService.cs @@ -10,6 +10,7 @@ namespace RamanApp.Services; public static class SpectrumFileService { + private static readonly object ExportGate = new(); public static IReadOnlyList ExportCustomerTemplates( string directory, string baseFileName, @@ -20,16 +21,17 @@ public static class SpectrumFileService Directory.CreateDirectory(directory); string stem = Path.GetFileNameWithoutExtension(baseFileName); - if (stem.EndsWith("_PL", StringComparison.OrdinalIgnoreCase) - || stem.EndsWith("_ABS", StringComparison.OrdinalIgnoreCase)) - stem = stem[..^3]; var groups = series .GroupBy(item => item.LoopIndex) .OrderBy(group => group.Key ?? 0) .ToArray(); bool appendLoop = groups.Length > 1; - var paths = new List(); + stem = string.IsNullOrWhiteSpace(stem) ? "spectrum" : stem; + stem = new string(stem.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c).ToArray()) + .Trim().TrimEnd('.'); + if (string.IsNullOrWhiteSpace(stem)) stem = "spectrum"; + var outputs = new List<(string Suffix, string Content)>(); foreach (var group in groups) { @@ -49,22 +51,89 @@ public static class SpectrumFileService if (plItems.Length > 0) { - string path = Path.Combine(directory, $"{stem}{loopSuffix}_PL.csv"); - WriteAllTextAtomic(path, BuildPlTemplate(plItems)); - paths.Add(path); + foreach (string laser in new[] { "532", "577", "671" }) + { + var item = FindSeries(plItems, entry => GetLaserLabel(entry) == laser + && !IsRadiometricallyCalibrated(entry)); + if (item != null) + outputs.Add(($"{loopSuffix}_{laser}", BuildSinglePlTemplate(stem, laser, item))); + } + outputs.Add(($"{loopSuffix}_PL", BuildPlTemplate(plItems))); } if (absorbanceItems.Length > 0) { - string path = Path.Combine(directory, $"{stem}{loopSuffix}_ABS.csv"); - WriteAllTextAtomic(path, BuildAbsTemplate(absorbanceItems)); - paths.Add(path); + outputs.Add(($"{loopSuffix}_ABS", BuildAbsTemplate(absorbanceItems))); } } - if (paths.Count == 0) + if (outputs.Count == 0) throw new InvalidDataException("当前数据无法匹配 PL 或 ABS 导出模板"); - return paths; + lock (ExportGate) + { + int sequence = 1; + string Name((string Suffix, string Content) output) => Path.Combine(directory, + $"{stem}{output.Suffix}{(sequence == 1 ? "" : $"_{sequence:D3}")}.csv"); + while (outputs.Any(output => File.Exists(Name(output)))) sequence++; + var paths = new List(); + try + { + foreach (var output in outputs) + { + string path = Name(output); + using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); + paths.Add(path); + using var writer = new StreamWriter(stream, new UTF8Encoding(true)); + writer.Write(output.Content); + } + return paths; + } + catch + { + foreach (string path in paths) + { + try { File.Delete(path); } catch { /* Preserve the write error. */ } + } + throw; + } + } + } + + private static SpectrumData? GetOriginalCounts(SpectrumExportSeries series) + => series.OriginalCounts ?? (series.Spectrum.Source == "fluorescence-background-corrected" + ? null : series.Spectrum); + + private static string BuildSinglePlTemplate(string sampleName, string laser, SpectrumExportSeries series) + { + var sb = new StringBuilder(); + sb.AppendLine($"波长,{EscapeCsv($"{sampleName}_{laser} (Count)")},参数,值"); + var metadata = series.Metadata; + var conditions = new (string Name, string Value)[] + { + ("样品名称", metadata?.SampleName ?? sampleName), + ("激发波长 (nm)", laser), + ("采集时间", (metadata?.AcquiredAt ?? series.Spectrum.AcquiredAt).ToString("yyyy-MM-dd HH:mm:ss.fff")), + ("设备", metadata?.DeviceName ?? series.DeviceName), + ("序列号", metadata?.DeviceSerialNumber ?? ""), + ("积分时间 (ms)", metadata?.IntegrationTimeMs ?? ""), + ("平均次数", metadata?.AverageCount ?? ""), + ("Boxcar", metadata?.BoxcarWidth ?? ""), + ("请求增益", metadata?.InternalAnalogGain ?? ""), + ("滤光轮", metadata?.FilterWheelPositions ?? ""), + ("数据", "设备采集 Counts,未扣纯水背景、未做软件平滑;保留采集时SDK设置"), + ("PL换算", series.RadiometricStatus ?? "未提供辐射定标数据"), + }; + var original = GetOriginalCounts(series); + int count = Math.Max(original?.Wavelengths.Length ?? 0, conditions.Length); + for (int i = 0; i < count; i++) + { + string x = original != null && i < original.Wavelengths.Length ? FormatNumber(original.Wavelengths[i]) : ""; + string y = original != null && i < original.Intensities.Length ? FormatNumber(original.Intensities[i]) : ""; + sb.AppendLine(string.Join(",", new[] { x, y, + i < conditions.Length ? conditions[i].Name : "", + i < conditions.Length ? conditions[i].Value : "" }.Select(EscapeCsv))); + } + return sb.ToString(); } public static void ExportCsv(string path, SpectrumData spectrum, double laserWavelengthNm, @@ -88,13 +157,13 @@ public static class SpectrumFileService if (smoothedSpectrum != null && i < smoothedSpectrum.Intensities.Length) { sb.AppendLine(string.Format(CultureInfo.InvariantCulture, - "{0:F4},{1:F4},{2:F4}", + "{0:G17},{1:G17},{2:G17}", spectrum.Wavelengths[i], spectrum.Intensities[i], smoothedSpectrum.Intensities[i])); } else { sb.AppendLine(string.Format(CultureInfo.InvariantCulture, - "{0:F4},{1:F4}", spectrum.Wavelengths[i], spectrum.Intensities[i])); + "{0:G17},{1:G17}", spectrum.Wavelengths[i], spectrum.Intensities[i])); } } @@ -113,14 +182,27 @@ public static class SpectrumFileService var wavelengths = new List(); var intensities = new List(); string source = Path.GetFileNameWithoutExtension(path); + string? excitationWavelength = null; foreach (var line in File.ReadLines(path, Encoding.UTF8)) { var trimmed = line.Trim(); if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) continue; - if (trimmed.StartsWith("Wavelength", StringComparison.OrdinalIgnoreCase)) + if (trimmed.StartsWith("波长,", StringComparison.Ordinal)) + { + var matches = System.Text.RegularExpressions.Regex.Matches(trimmed, @"_(532|577|671) \(Count\)"); + if (matches.Count > 0) excitationWavelength = matches[^1].Groups[1].Value; continue; + } + if (trimmed.StartsWith("Wavelength", StringComparison.OrdinalIgnoreCase)) + { + if (trimmed.Contains("-PL", StringComparison.OrdinalIgnoreCase) + || trimmed.Contains("-raw", StringComparison.OrdinalIgnoreCase) + || trimmed.Contains("ABS-", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("PL/ABS 汇总文件包含多条曲线及不同单位,不能作为单条 Counts 导入;请选择按激发波长单独保存的 Counts 文件"); + continue; + } var parts = trimmed.Split([',', '\t', ' '], StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 2 @@ -135,7 +217,7 @@ public static class SpectrumFileService if (wavelengths.Count == 0) throw new InvalidDataException("文件中未找到有效的光谱数据"); - return new SpectrumData([.. wavelengths], [.. intensities], source); + return new SpectrumData([.. wavelengths], [.. intensities], source, excitationWavelength); } public static string ToClipboardText(SpectrumData spectrum, SpectrumData? smoothedSpectrum = null) @@ -197,11 +279,25 @@ public static class SpectrumFileService && !IsRadiometricallyCalibrated(item))); var calibrated = lasers.ToDictionary( laser => laser, - laser => FindSeries(series, item => GetLaserLabel(item) == laser - && IsRadiometricallyCalibrated(item))); + laser => + { + var item = raw[laser]; + return item?.RadiometricSpectrum != null + ? item with { Spectrum = item.RadiometricSpectrum, SmoothedSpectrum = null } + : FindSeries(series, entry => GetLaserLabel(entry) == laser + && entry.Metadata?.IntensityUnit == "W/nm"); + }); + foreach (var laser in lasers) + { + var item = raw[laser]; + raw[laser] = item != null && GetOriginalCounts(item) is SpectrumData original + ? item with { Spectrum = original, SmoothedSpectrum = null } : null; + } + if (!series.Any(item => GetLaserLabel(item) is "532" or "577" or "671")) + throw new InvalidDataException("没有可导出的 PL 数据或无法识别激发波长,请使用带532、577或671通道信息的光谱文件"); sb.AppendLine("Wavelength,532-PL,577-PL,671-PL,,532-raw,577-raw,671-raw"); - sb.AppendLine("nm,pW/nm,pW/nm,pW/nm,,Counts,Counts,Counts"); + sb.AppendLine("nm,W/nm,W/nm,W/nm,,Counts,Counts,Counts"); var wavelengths = CollectWavelengths(raw.Values.Concat(calibrated.Values)); foreach (double wavelength in wavelengths) @@ -283,6 +379,15 @@ public static class SpectrumFileService sb.AppendLine($"# {laser} Integration time: {item?.Metadata?.IntegrationTimeMs ?? ""} ms,,{processingCell},,,,"); } sb.AppendLine($"# Averaging time: {first?.AverageCount ?? ""},,,,,,"); + foreach (var laser in lasers) + { + var item = FindSeries(series, entry => GetLaserLabel(entry) == laser); + if (item == null) continue; + sb.AppendLine($"# {laser} PL conversion: {EscapeCsv(item.RadiometricStatus ?? "未提供辐射定标数据,W/nm 列为空")},,,,,,,"); + if (GetOriginalCounts(item) == null) + sb.AppendLine($"# {laser} raw: 历史结果没有保留设备原始Counts,raw列为空,,,,,,,"); + } + sb.AppendLine("# raw: 设备采集Counts,未扣纯水背景、未做软件平滑;W/nm使用独立暗谱校正,,,,,,,"); } else { @@ -372,7 +477,7 @@ public static class SpectrumFileService => candidates.Any(candidate => value.Contains(candidate, StringComparison.OrdinalIgnoreCase)); private static string FormatNumber(double value) - => value.ToString("0.####", CultureInfo.InvariantCulture); + => value.ToString("G17", CultureInfo.InvariantCulture); private static void AppendWideTable(StringBuilder sb, IReadOnlyList series, string delimiter, Func escape) diff --git a/src/RamanApp/ViewModels/AcquisitionViewModel.cs b/src/RamanApp/ViewModels/AcquisitionViewModel.cs index 4d3591e..ded8f7e 100644 --- a/src/RamanApp/ViewModels/AcquisitionViewModel.cs +++ b/src/RamanApp/ViewModels/AcquisitionViewModel.cs @@ -171,6 +171,7 @@ public partial class AcquisitionViewModel : ObservableObject var token = _activeCts.Token; IsContinuousSessionActive = true; StartProgress("连续"); + OnContinuousStarted?.Invoke(0, 0); int cycleIndex = 0; int saveFailureCount = 0; diff --git a/src/RamanApp/ViewModels/MainWindowViewModel.Calibration.cs b/src/RamanApp/ViewModels/MainWindowViewModel.Calibration.cs index aedc707..6715b2b 100644 --- a/src/RamanApp/ViewModels/MainWindowViewModel.Calibration.cs +++ b/src/RamanApp/ViewModels/MainWindowViewModel.Calibration.cs @@ -37,6 +37,9 @@ public partial class MainWindowViewModel throw new InvalidOperationException("请至少启用一个荧光激光通道"); var fluorescencePlans = lasers.Select(laser => (Laser: laser, Settings: CreateFluorescenceSettings(fluorescence, laser))).ToArray(); + bool collectRadiometricDark = calibrateFluorescence + && _calibrationStore.LoadRadiometricCalibration(fluorescence.SelectedDevice!.SerialNumber) != null; + var fluorescenceDarkWheel = collectRadiometricDark ? Ello.GetAbsorbanceCalibrationWheel() : null; EasyTtl.ValidateShutterChannel(); SpectroscopyModuleViewModel[] absorptionModules = @@ -52,15 +55,45 @@ public partial class MainWindowViewModel wheel = Ello.GetAbsorbanceCalibrationWheel(); } - Acquisition.BeginProgressSteps(lasers.Count + (calibrateAbsorbance ? 6 : 0) + 2); + Acquisition.BeginProgressSteps(lasers.Count + (calibrateAbsorbance ? 6 : 0) + 2 + + (collectRadiometricDark ? lasers.Count + 2 : 0)); int restoreSlot = wheel?.CurrentSlot ?? 6; bool wheelMoved = false; var backgrounds = new List<(FluorescenceCalibrationKey Key, SpectrumData Spectrum)>(); + var fluorescenceDarks = new List<(FluorescenceCalibrationKey Key, SpectrumData Spectrum)>(); IReadOnlyList references = []; IReadOnlyList darks = []; try { await TurnOffAllLasersAsync(CancellationToken.None); + if (fluorescenceDarkWheel != null) + { + int originalSlot = fluorescenceDarkWheel.CurrentSlot ?? 6; + try + { + ReportCalibrationStage("关闭激光和 shutter,第5档采集 PL 独立暗谱"); + await EasyTtl.CloseShutterAsync(CancellationToken.None); + await Ello.MoveAbsorbanceWheelAsync(fluorescenceDarkWheel, 5, cancellationToken); + Acquisition.CompleteProgressStep(); + foreach (var plan in fluorescencePlans) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = await AcquireModuleAsync(fluorescence, AcquisitionTarget.Dark, + plan.Laser.SelectedChannel.Channel, plan.Laser.DisplayName, cancellationToken, + settingsOverride: plan.Settings) + ?? throw new InvalidOperationException("PL 暗谱没有有效波长数据"); + fluorescenceDarks.Add((CreateFluorescenceKey(fluorescence, + plan.Laser.SelectedChannel.Channel, plan.Laser.DisplayName, plan.Settings), result.Spectrum)); + Acquisition.CompleteProgressStep(); + } + } + finally + { + await EasyTtl.CloseShutterAsync(CancellationToken.None); + await Ello.MoveAbsorbanceWheelAsync(fluorescenceDarkWheel, originalSlot, CancellationToken.None); + } + Acquisition.CompleteProgressStep(); + } foreach (var plan in fluorescencePlans) { var laser = plan.Laser; @@ -131,6 +164,8 @@ public partial class MainWindowViewModel foreach (var background in backgrounds) _calibrationStore.SaveFluorescenceBackground(background.Key, background.Spectrum); + foreach (var dark in fluorescenceDarks) + _calibrationStore.SaveFluorescenceDark(dark.Key, dark.Spectrum); foreach (var result in references) _lightByModule[result.ModuleKind] = result.Spectrum; foreach (var result in darks) diff --git a/src/RamanApp/ViewModels/MainWindowViewModel.cs b/src/RamanApp/ViewModels/MainWindowViewModel.cs index 69fb458..97171da 100644 --- a/src/RamanApp/ViewModels/MainWindowViewModel.cs +++ b/src/RamanApp/ViewModels/MainWindowViewModel.cs @@ -112,7 +112,10 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable string AcquisitionMode, int? LoopTotal, int? ContinuousIntervalMs, - string FilterWheelPositions); + string FilterWheelPositions, + SpectrumData? OriginalCounts = null, + SpectrumData? RadiometricSpectrum = null, + string? RadiometricStatus = null); private sealed record LoopAcquisitionFrame( int LoopIndex, @@ -217,6 +220,48 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable public Task InitializeStartupHardwareAsync(CancellationToken cancellationToken = default) => Ello.InitializeStartupAsync(cancellationToken); + public void ImportRadiometricCalibration(string path) + => SaveRadiometricCalibration(RadiometricCalibration.Read(path)); + + private void EnsureRadiometricParametersEditable() + { + if (!IsDeveloper) + throw new InvalidOperationException("仅开发者可以修改 PL 换算参数"); + if (Acquisition.IsAcquiring) + throw new InvalidOperationException("采集或校准期间不能修改 PL 换算参数"); + } + + public RadiometricSettingsViewModel CreateRadiometricSettings() + { + EnsureRadiometricParametersEditable(); + var module = DeviceSettings.Fluorescence; + var device = module.SelectedDevice + ?? throw new InvalidOperationException("请先为荧光模块绑定光谱仪"); + var draft = new RadiometricSettingsViewModel(device.SerialNumber, device.GetWavelengths(), + module.ToSettings(), null); + try + { + var calibration = _calibrationStore.LoadRadiometricCalibration(device.SerialNumber); + if (calibration != null) draft.Load(calibration); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or System.Text.Json.JsonException) + { + draft.Status = $"已有参数未能载入:{ex.Message}。可重新填写或从文件载入,保存前不会覆盖已有文件。"; + } + return draft; + } + + public void SaveRadiometricCalibration(RadiometricCalibration calibration) + { + EnsureRadiometricParametersEditable(); + var device = DeviceSettings.Fluorescence.SelectedDevice + ?? throw new InvalidOperationException("请先为荧光模块绑定光谱仪"); + if (!string.Equals(calibration.DeviceSerialNumber, device.SerialNumber, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("定标参数序列号与当前荧光光谱仪不一致"); + _calibrationStore.SaveRadiometricCalibration(calibration); + AppToastService.Success("PL 换算参数已保存"); + } + private void OnModulePropertyChanged(SpectroscopyModuleViewModel module, string? propertyName) { if (propertyName is nameof(SpectroscopyModuleViewModel.IntegrationTimeMs) @@ -406,18 +451,9 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable { int? loopIndex = context.AcquisitionMode == "单次" ? null : context.CycleIndex; var exportSeries = BuildExportSeriesForResults(results, loopIndex, completedAt); - string suffix = context.AcquisitionMode switch - { - "定时连续" => $"_Loop{Math.Max(1, context.CycleIndex):D3}", - "连续" => $"_Run{Math.Max(1, context.CycleIndex):D3}", - _ => "", - }; - string fileStem = $"{SanitizeFileName(context.SampleName)}_" + - context.StartedAt.ToString("yyyyMMdd_HHmmss_fff", CultureInfo.InvariantCulture) + suffix; - string path = GetUniqueCsvPath(context.SaveDirectory, fileStem); var savedPaths = SpectrumFileService.ExportCustomerTemplates( context.SaveDirectory, - Path.GetFileName(path), + SanitizeFileName(context.SampleName) + ".csv", exportSeries); Log.Information( "Spectrum auto saved sample={SampleName} mode={Mode} cycle={Cycle} paths={Paths}", @@ -536,9 +572,28 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable spectrum.Intensities.Select(value => value * 0.1).ToArray(), "simulated-pure-water"); } + SpectrumData? originalCounts = null; + SpectrumData? radiometric = null; + string? radiometricStatus = null; if (target == AcquisitionTarget.Sample && module.Kind == SpectroscopyModuleKind.Fluorescence) { + originalCounts = spectrum; var key = CreateFluorescenceKey(module, laserChannel!.Value, laserName, settings); + try + { + var calibration = _calibrationStore.LoadRadiometricCalibration(device.SerialNumber) + ?? throw new InvalidOperationException("未导入 PL 辐射定标系数,W/nm 列未生成"); + var dark = _calibrationStore.LoadFluorescenceDark(key) + ?? throw new InvalidOperationException("缺少匹配的 PL 暗谱,请在导入定标文件后重新自动校准"); + radiometric = calibration.Convert(originalCounts, dark, device.SerialNumber, settings); + radiometricStatus = $"已转换 W/nm; 定标编号={calibration.CalibrationId}; (S-D)*C/(带宽*积分秒数); 实际硬件增益及暗噪声模式需人工确认"; + } + catch (Exception ex) when (ex is InvalidOperationException or InvalidDataException + or IOException or System.Text.Json.JsonException) + { + radiometricStatus = ex.Message; + AppToastService.Warning($"{laserName} nm: {radiometricStatus}"); + } var background = _calibrationStore.LoadFluorescenceBackground(key) ?? throw new InvalidOperationException($"{laserName} nm 缺少当前参数的纯水背景,请先执行自动校准"); spectrum = SpectrumProcessor.SubtractFluorescenceBackground(spectrum, background); @@ -567,7 +622,10 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable GetCurrentAcquisitionMode(), Acquisition.IsContinuousRunActive ? Acquisition.CurrentLoopTotal : null, Acquisition.IsContinuousRunActive ? Acquisition.ContinuousIntervalMs : null, - GetFilterWheelPositionSummary()); + GetFilterWheelPositionSummary(), + originalCounts, + radiometric, + radiometricStatus); } private SpectrumData? ApplyEffectiveWavelengthRange( @@ -594,7 +652,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable private async Task AcquireOnceAsync(CancellationToken cancellationToken) { - if (!Acquisition.IsContinuousRunActive) + if (!Acquisition.IsContinuousSessionActive) _loopFrames.Clear(); var frameStartedAt = DateTime.Now; @@ -702,7 +760,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable } var frameCompletedAt = DateTime.Now; - if (Acquisition.IsContinuousRunActive) + if (Acquisition.IsContinuousSessionActive) { _loopFrames.Add(new LoopAcquisitionFrame( Acquisition.CurrentLoopIndex, @@ -1256,10 +1314,9 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable : Path.GetFullPath(Acquisition.SaveDirectory); Directory.CreateDirectory(directory); string fileStem = GetSuggestedExportFileName(exportSeries); - string path = GetUniqueCsvPath(directory, fileStem); var paths = SpectrumFileService.ExportCustomerTemplates( directory, - Path.GetFileName(path), + fileStem + ".csv", exportSeries); AppToastService.Success($"已快速保存 {string.Join("、", paths.Select(Path.GetFileName))}"); } @@ -1308,7 +1365,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable ? DataProcessing.ApplySmoothing(_importedSpectrum) : null; series.Add(new SpectrumExportSeries( - "默认", + _importedSpectrum.ExcitationWavelengthNm ?? "默认", null, "导入光谱", _importedSpectrum, @@ -1344,7 +1401,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable .FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)) ?? Acquisition.SampleName; string prefix = SanitizeFileName(sampleName); - return $"{prefix}_{DateTime.Now:yyyyMMdd_HHmmss_fff}"; + return prefix; } private List BuildExportSeriesForResults( @@ -1378,7 +1435,10 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable loopIndex, loopTimestamp, "Raw", - CreateExportMetadata([result], "Counts", absorbance: false, stitched: false))); + CreateExportMetadata([result], "Counts", absorbance: false, stitched: false), + result.OriginalCounts, + result.RadiometricSpectrum, + result.RadiometricStatus)); var absorbance = TryComputeAbsorbance(result); if (absorbance != null) diff --git a/src/RamanApp/ViewModels/RadiometricSettingsViewModel.cs b/src/RamanApp/ViewModels/RadiometricSettingsViewModel.cs new file mode 100644 index 0000000..8f439e7 --- /dev/null +++ b/src/RamanApp/ViewModels/RadiometricSettingsViewModel.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using RamanApp.Services; + +namespace RamanApp.ViewModels; + +public partial class RadiometricPixelViewModel : ObservableObject +{ + public double WavelengthNm { get; } + [ObservableProperty] public partial string Factor { get; set; } = ""; + [ObservableProperty] public partial string Bandwidth { get; set; } = ""; + public RadiometricPixelViewModel(double wavelengthNm) => WavelengthNm = wavelengthNm; +} + +public partial class RadiometricSettingsViewModel : ObservableObject +{ + private readonly HashSet _deviceWavelengths; + public string DeviceSerialNumber { get; } + public ObservableCollection Pixels { get; } = []; + [ObservableProperty] public partial string CalibrationId { get; set; } = ""; + [ObservableProperty] public partial int GainLevel { get; set; } + [ObservableProperty] public partial int BoxcarWidth { get; set; } + [ObservableProperty] public partial bool DarkNoiseCorrection { get; set; } + [ObservableProperty] public partial string UniformFactor { get; set; } = ""; + [ObservableProperty] public partial string UniformBandwidth { get; set; } = ""; + [ObservableProperty] public partial string Status { get; set; } = ""; + + public RadiometricSettingsViewModel(string serialNumber, double[] wavelengths, + SpectrometerSettings settings, RadiometricCalibration? calibration) + { + DeviceSerialNumber = serialNumber; + _deviceWavelengths = new HashSet(wavelengths); + GainLevel = settings.InternalAnalogGainLevel; + BoxcarWidth = settings.BoxcarWidth; + DarkNoiseCorrection = settings.DarkNoiseCorrection; + foreach (double wavelength in wavelengths) Pixels.Add(new(wavelength)); + if (calibration != null) Load(calibration); + } + + public void Load(RadiometricCalibration calibration) + { + calibration.Validate(); + if (!string.Equals(calibration.DeviceSerialNumber, DeviceSerialNumber, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("定标参数的序列号与当前荧光设备不一致"); + // Validate before replacing the draft so a failed import leaves edits intact. + if (calibration.WavelengthsNm.Any(wavelength => !_deviceWavelengths.Contains(wavelength))) + throw new InvalidDataException("定标参数的像素波长与当前设备不一致"); + CalibrationId = calibration.CalibrationId; + GainLevel = calibration.InternalAnalogGainLevel; + BoxcarWidth = calibration.BoxcarWidth; + DarkNoiseCorrection = calibration.DarkNoiseCorrection; + Pixels.Clear(); + for (int i = 0; i < calibration.WavelengthsNm.Length; i++) + Pixels.Add(new(calibration.WavelengthsNm[i]) + { + Factor = calibration.FactorsJPerCount[i].ToString("G17", CultureInfo.InvariantCulture), + Bandwidth = calibration.PixelBandwidthsNm[i].ToString("G17", CultureInfo.InvariantCulture), + }); + } + + private static double PositiveNumber(string text, string name) + { + if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) + || !double.IsFinite(value) || value <= 0) + throw new InvalidDataException($"{name}必须为正数,支持科学计数法,例如 1e-12"); + return value; + } + + public void ApplyUniformFactor() + { + PositiveNumber(UniformFactor, "系数 C"); + foreach (var pixel in Pixels) pixel.Factor = UniformFactor; + } + + public void ApplyUniformBandwidth() + { + PositiveNumber(UniformBandwidth, "像素带宽"); + foreach (var pixel in Pixels) pixel.Bandwidth = UniformBandwidth; + } + + public RadiometricCalibration Build() + { + var result = new RadiometricCalibration(DeviceSerialNumber, CalibrationId.Trim(), GainLevel, + BoxcarWidth, DarkNoiseCorrection, Pixels.Select(p => p.WavelengthNm).ToArray(), + Pixels.Select(p => PositiveNumber(p.Factor, $"{p.WavelengthNm} nm 系数 C")).ToArray(), + Pixels.Select(p => PositiveNumber(p.Bandwidth, $"{p.WavelengthNm} nm 像素带宽")).ToArray()); + result.Validate(); + return result; + } +} diff --git a/src/RamanApp/Views/RadiometricSettingsDialog.axaml b/src/RamanApp/Views/RadiometricSettingsDialog.axaml new file mode 100644 index 0000000..117b321 --- /dev/null +++ b/src/RamanApp/Views/RadiometricSettingsDialog.axaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + +