完善光谱保存格式与PL辐射定标参数
This commit is contained in:
@@ -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/)。用户需确保导入文件适用于当前整套光路。
|
||||
@@ -21,6 +21,8 @@
|
||||
<MenuItem Header="文件">
|
||||
<MenuItem Header="导入光谱数据" Click="OnImportClick" />
|
||||
<MenuItem Header="导出光谱数据" Click="OnExportClick" />
|
||||
<MenuItem Header="PL换算参数" Click="OnRadiometricSettingsClick"
|
||||
IsVisible="{Binding IsDeveloper}" />
|
||||
<MenuItem Header="拷贝光谱数据" Click="OnCopyClick" />
|
||||
<Separator />
|
||||
<MenuItem Header="光谱存为图片" Click="OnSaveImageClick" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using RamanApp.Models;
|
||||
|
||||
namespace RamanApp.Services;
|
||||
|
||||
/// <summary>Pixel factors in J/count; bandwidths in nm. Output: W/nm.</summary>
|
||||
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<RadiometricCalibration>(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");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace RamanApp.Services;
|
||||
|
||||
public static class SpectrumFileService
|
||||
{
|
||||
private static readonly object ExportGate = new();
|
||||
public static IReadOnlyList<string> 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<string>();
|
||||
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<string>();
|
||||
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<double>();
|
||||
var intensities = new List<double>();
|
||||
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<SpectrumExportSeries> series,
|
||||
string delimiter, Func<string, string> escape)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<ModuleAcquisitionResult> references = [];
|
||||
IReadOnlyList<ModuleAcquisitionResult> 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)
|
||||
|
||||
@@ -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<AcquisitionCycleResult> 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<SpectrumExportSeries> 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)
|
||||
|
||||
@@ -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<double> _deviceWavelengths;
|
||||
public string DeviceSerialNumber { get; }
|
||||
public ObservableCollection<RadiometricPixelViewModel> 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<double>(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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:RamanApp.ViewModels"
|
||||
x:Class="RamanApp.Views.RadiometricSettingsDialog"
|
||||
x:DataType="vm:RadiometricSettingsViewModel"
|
||||
Title="PL换算参数" Width="820" Height="700" MinWidth="700" MinHeight="520"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<Grid Margin="20" RowDefinitions="Auto,Auto,Auto,Auto,*,Auto,Auto" RowSpacing="12">
|
||||
<Grid ColumnDefinitions="90,*,90,*" RowDefinitions="Auto,Auto" RowSpacing="8">
|
||||
<TextBlock Text="设备序列号" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" Text="{Binding DeviceSerialNumber}" IsReadOnly="True" Margin="0,0,12,0" />
|
||||
<TextBlock Grid.Column="2" Text="定标编号" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="3" Text="{Binding CalibrationId}" />
|
||||
<TextBlock Grid.Row="1" Text="定标增益档位" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding GainLevel}" Minimum="0" Maximum="1" Increment="1" FormatString="0" Margin="0,0,12,0" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="定标Boxcar" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="3" Value="{Binding BoxcarWidth}" Minimum="0" Maximum="100" Increment="1" FormatString="0" />
|
||||
</Grid>
|
||||
<CheckBox Grid.Row="1" Content="定标时启用暗噪声校正" IsChecked="{Binding DarkNoiseCorrection}" />
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto,Auto,*,Auto" ColumnSpacing="8">
|
||||
<TextBlock Text="统一 C" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" Text="{Binding UniformFactor}" Watermark="J/count" />
|
||||
<Button Grid.Column="2" Content="应用全部" Click="OnApplyFactorClick" />
|
||||
<TextBlock Grid.Column="3" Text="统一带宽" VerticalAlignment="Center" Margin="8,0,0,0" />
|
||||
<TextBox Grid.Column="4" Text="{Binding UniformBandwidth}" Watermark="nm" />
|
||||
<Button Grid.Column="5" Content="应用全部" Click="OnApplyBandwidthClick" />
|
||||
</Grid>
|
||||
<Grid Grid.Row="3" ColumnDefinitions="140,*,*" ColumnSpacing="12">
|
||||
<TextBlock Text="像素波长 (nm)" FontWeight="SemiBold" />
|
||||
<TextBlock Grid.Column="1" Text="系数 C (J/count)" FontWeight="SemiBold" />
|
||||
<TextBlock Grid.Column="2" Text="像素带宽 (nm)" FontWeight="SemiBold" />
|
||||
</Grid>
|
||||
<ListBox Grid.Row="4" ItemsSource="{Binding Pixels}">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RadiometricPixelViewModel">
|
||||
<Grid ColumnDefinitions="140,*,*" ColumnSpacing="12" Margin="0,0,0,6">
|
||||
<TextBlock Text="{Binding WavelengthNm, StringFormat={}{0:G17}}" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" Text="{Binding Factor}" />
|
||||
<TextBox Grid.Column="2" Text="{Binding Bandwidth}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<TextBlock Grid.Row="5" Text="{Binding Status}" TextWrapping="Wrap" Foreground="{DynamicResource SemiColorPrimary}" />
|
||||
<StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10">
|
||||
<Button Content="从文件载入" Click="OnImportClick" />
|
||||
<Button Content="取消" Click="OnCancelClick" />
|
||||
<Button Content="保存参数" Click="OnSaveClick" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using RamanApp.Services;
|
||||
using RamanApp.ViewModels;
|
||||
|
||||
namespace RamanApp.Views;
|
||||
|
||||
public partial class RadiometricSettingsDialog : Window
|
||||
{
|
||||
private readonly MainWindowViewModel? _owner;
|
||||
private RadiometricSettingsViewModel Draft => (RadiometricSettingsViewModel)DataContext!;
|
||||
|
||||
public RadiometricSettingsDialog() => InitializeComponent();
|
||||
|
||||
public RadiometricSettingsDialog(MainWindowViewModel owner, RadiometricSettingsViewModel draft) : this()
|
||||
{
|
||||
_owner = owner;
|
||||
DataContext = draft;
|
||||
}
|
||||
|
||||
private void OnApplyFactorClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try { Draft.ApplyUniformFactor(); Draft.Status = ""; }
|
||||
catch (Exception ex) { Draft.Status = ex.Message; }
|
||||
}
|
||||
|
||||
private void OnApplyBandwidthClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try { Draft.ApplyUniformBandwidth(); Draft.Status = ""; }
|
||||
catch (Exception ex) { Draft.Status = ex.Message; }
|
||||
}
|
||||
|
||||
private async void OnImportClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "载入 PL 定标参数", AllowMultiple = false,
|
||||
FileTypeFilter = [new FilePickerFileType("定标 JSON") { Patterns = ["*.json"] }],
|
||||
});
|
||||
if (files.Count == 0 || files[0].TryGetLocalPath() is not string path) return;
|
||||
Draft.Load(RadiometricCalibration.Read(path));
|
||||
Draft.Status = "已载入";
|
||||
}
|
||||
catch (Exception ex) { Draft.Status = ex.Message; }
|
||||
}
|
||||
|
||||
private void OnSaveClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_owner == null) throw new InvalidOperationException("参数窗口尚未初始化");
|
||||
_owner.SaveRadiometricCalibration(Draft.Build());
|
||||
Close(true);
|
||||
}
|
||||
catch (Exception ex) { Draft.Status = ex.Message; }
|
||||
}
|
||||
|
||||
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
|
||||
}
|
||||
@@ -6,6 +6,138 @@ namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class AutoCalibrationWorkflowTests
|
||||
{
|
||||
private static void EnableRadiometry(CalibrationFixture fixture)
|
||||
{
|
||||
var settings = fixture.Vm.DeviceSettings.Fluorescence.ToSettings();
|
||||
fixture.Store.SaveRadiometricCalibration(new RadiometricCalibration("nir", "test-only",
|
||||
settings.InternalAnalogGainLevel, settings.BoxcarWidth, settings.DarkNoiseCorrection,
|
||||
[900, 1000, 1200, 1400], [1e-12, 2e-12, 3e-12, 4e-12], [100, 100, 200, 200]));
|
||||
fixture.Vm.DeviceSettings.NearInfraredAbsorbance.AcquireEnabled = false;
|
||||
fixture.Vm.DeviceSettings.VisibleAbsorbance.AcquireEnabled = false;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Developer_can_repair_corrupt_parameters_and_save_is_blocked_for_other_roles_or_acquisition()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
EnableRadiometry(fixture);
|
||||
var vm = fixture.Vm;
|
||||
var savedRole = vm.CurrentUserRole;
|
||||
try
|
||||
{
|
||||
vm.CurrentUserRole = AppUserRole.Developer;
|
||||
string path = Directory.GetFiles(fixture.DirectoryPath, "calibration.json", SearchOption.AllDirectories).Single();
|
||||
File.WriteAllText(path, "{broken");
|
||||
var draft = vm.CreateRadiometricSettings();
|
||||
Assert.Contains("已有参数未能载入", draft.Status);
|
||||
Assert.Equal("{broken", File.ReadAllText(path));
|
||||
draft.CalibrationId = "repaired";
|
||||
draft.UniformFactor = "1e-12";
|
||||
draft.UniformBandwidth = "2";
|
||||
draft.ApplyUniformFactor();
|
||||
draft.ApplyUniformBandwidth();
|
||||
var profile = draft.Build();
|
||||
vm.CurrentUserRole = AppUserRole.User;
|
||||
Assert.Throws<InvalidOperationException>(() => vm.CreateRadiometricSettings());
|
||||
Assert.Throws<InvalidOperationException>(() => vm.SaveRadiometricCalibration(profile));
|
||||
vm.CurrentUserRole = AppUserRole.Administrator;
|
||||
Assert.Throws<InvalidOperationException>(() => vm.SaveRadiometricCalibration(profile));
|
||||
vm.CurrentUserRole = AppUserRole.Developer;
|
||||
vm.Acquisition.IsAcquiring = true;
|
||||
Assert.Throws<InvalidOperationException>(() => vm.SaveRadiometricCalibration(profile));
|
||||
vm.Acquisition.IsAcquiring = false;
|
||||
vm.SaveRadiometricCalibration(profile);
|
||||
Assert.Equal("repaired", fixture.Store.LoadRadiometricCalibration("nir")!.CalibrationId);
|
||||
}
|
||||
finally { vm.Acquisition.IsAcquiring = false; vm.CurrentUserRole = savedRole; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Imported_single_channel_counts_keep_excitation_during_reexport()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
fixture.Vm.ImportSpectrum(new SpectrumData([500, 501], [100, 110], "sample_577", "577"));
|
||||
var series = fixture.Vm.GetExportSeries();
|
||||
Assert.Equal("577", Assert.Single(series).LaserName);
|
||||
Assert.Contains("500,,,,,,100,", SpectrumFileService.ToCustomerTemplateText(series));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ordinary_continuous_acquisition_without_auto_save_keeps_every_completed_cycle()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
var vm = fixture.Vm;
|
||||
vm.DeviceSettings.NearInfraredAbsorbance.AcquireEnabled = false;
|
||||
vm.DeviceSettings.VisibleAbsorbance.AcquireEnabled = false;
|
||||
await vm.Acquisition.OnAutoCalibration!(CancellationToken.None);
|
||||
fixture.Readings.Clear();
|
||||
fixture.OnRead = _ =>
|
||||
{
|
||||
if (fixture.Readings.Count == 2) vm.Acquisition.StopAcquireCommand.Execute(null);
|
||||
};
|
||||
await vm.Acquisition.ContinuousAcquireCommand.ExecuteAsync(null);
|
||||
var series = vm.GetExportSeries();
|
||||
Assert.Equal([1, 2], series.Select(item => item.LoopIndex));
|
||||
Assert.All(series, item => Assert.NotNull(item.OriginalCounts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Damaged_radiometric_dark_keeps_acquired_counts_and_records_conversion_failure()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
EnableRadiometry(fixture);
|
||||
await fixture.Vm.Acquisition.OnAutoCalibration!(CancellationToken.None);
|
||||
fixture.Store.SaveFluorescenceDark(fixture.BackgroundKey(fixture.Vm.EasyTtl.Lasers[0]),
|
||||
new SpectrumData([], []));
|
||||
await fixture.Vm.Acquisition.OnSingleAcquire!(CancellationToken.None);
|
||||
var result = Assert.Single(fixture.Vm.GetExportSeries());
|
||||
Assert.NotNull(result.OriginalCounts);
|
||||
Assert.Null(result.RadiometricSpectrum);
|
||||
Assert.Contains("未找到有效的光谱数据", result.RadiometricStatus!);
|
||||
Assert.All(fixture.Vm.EasyTtl.Channels, channel => Assert.False(channel.HardwareEnabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Radiometric_calibration_collects_independent_dark_and_exports_snapshotted_conversion()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
EnableRadiometry(fixture);
|
||||
await fixture.Vm.Acquisition.OnAutoCalibration!(CancellationToken.None);
|
||||
var darkReading = fixture.Readings.First();
|
||||
Assert.Equal("dark", darkReading.Target);
|
||||
Assert.False(darkReading.LaserOn);
|
||||
Assert.False(darkReading.ShutterOpen);
|
||||
Assert.Equal(5, darkReading.Slot);
|
||||
Assert.Equal(6, fixture.Wheel.CurrentSlot);
|
||||
var key = fixture.BackgroundKey(fixture.Vm.EasyTtl.Lasers[0]);
|
||||
var dark = fixture.Store.LoadFluorescenceDark(key);
|
||||
Assert.NotNull(dark);
|
||||
await fixture.Vm.Acquisition.OnSingleAcquire!(CancellationToken.None);
|
||||
var result = Assert.Single(fixture.Vm.GetExportSeries());
|
||||
Assert.NotNull(result.OriginalCounts);
|
||||
Assert.NotNull(result.RadiometricSpectrum);
|
||||
Assert.NotEqual(result.OriginalCounts.Intensities[0], result.Spectrum.Intensities[0]);
|
||||
Assert.Equal((result.OriginalCounts.Intensities[0] - dark.Intensities[0]) * 1e-12 / (100 * 0.1),
|
||||
result.RadiometricSpectrum.Intensities[0], 1e-24);
|
||||
double acquiredValue = result.RadiometricSpectrum.Intensities[0];
|
||||
fixture.Vm.EasyTtl.Lasers[0].FluorescenceIntegrationTimeMs = 200;
|
||||
Assert.Equal(acquiredValue, Assert.Single(fixture.Vm.GetExportSeries()).RadiometricSpectrum!.Intensities[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Radiometric_dark_failure_restores_wheel_and_does_not_save_partial_calibration()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
EnableRadiometry(fixture);
|
||||
fixture.FailureTarget = "dark";
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => fixture.Vm.Acquisition.OnAutoCalibration!(CancellationToken.None));
|
||||
Assert.Equal(6, fixture.Wheel.CurrentSlot);
|
||||
Assert.All(fixture.Vm.EasyTtl.Channels, channel => Assert.False(channel.HardwareEnabled));
|
||||
var key = fixture.BackgroundKey(fixture.Vm.EasyTtl.Lasers[0]);
|
||||
Assert.Null(fixture.Store.LoadFluorescenceDark(key));
|
||||
Assert.Null(fixture.Store.LoadFluorescenceBackground(key));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Combined_calibration_collects_each_laser_then_light_and_dark_with_shutter()
|
||||
{
|
||||
@@ -440,6 +572,7 @@ public sealed class AutoCalibrationWorkflowTests
|
||||
private sealed class CalibrationFixture : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "raman-calibration-tests", Guid.NewGuid().ToString("N"));
|
||||
public string DirectoryPath => _directory;
|
||||
public MainWindowViewModel Vm { get; }
|
||||
public CalibrationStore Store { get; }
|
||||
public ElloDeviceViewModel Wheel { get; }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using RamanApp.Models;
|
||||
using RamanApp.Services;
|
||||
|
||||
namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class RadiometricCalibrationTests
|
||||
{
|
||||
private static RadiometricCalibration Profile() => new("device", "test-only", 0, 0, false,
|
||||
[500, 502], [1e-12, 2e-12], [2, 2]);
|
||||
private static SpectrometerSettings Settings() => new(100, 3, 0, false, 0);
|
||||
|
||||
[Fact]
|
||||
public void Conversion_subtracts_dark_uses_seconds_and_each_pixel_factor_and_bandwidth()
|
||||
{
|
||||
var result = Profile().Convert(new SpectrumData([500, 502], [110, 5]),
|
||||
new SpectrumData([500, 502], [10, 10]), "device", Settings());
|
||||
Assert.Equal(5e-10, result.Intensities[0], 1e-24);
|
||||
Assert.Equal(-5e-11, result.Intensities[1], 1e-24);
|
||||
var longer = Profile().Convert(new SpectrumData([500, 502], [110, 5]),
|
||||
new SpectrumData([500, 502], [10, 10]), "device", Settings() with { IntegrationTimeMs = 200 });
|
||||
Assert.Equal(result.Intensities[0] / 2, longer.Intensities[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Conversion_rejects_wrong_device_settings_and_uncovered_pixels()
|
||||
{
|
||||
var sample = new SpectrumData([500, 502], [110, 120]);
|
||||
var dark = new SpectrumData([500, 502], [10, 10]);
|
||||
Assert.Throws<InvalidOperationException>(() => Profile().Convert(sample, dark, "other", Settings()));
|
||||
Assert.Throws<InvalidOperationException>(() => Profile().Convert(sample, dark, "device", Settings() with { BoxcarWidth = 1 }));
|
||||
Assert.Throws<InvalidOperationException>(() => Profile().Convert(sample, dark, "device", Settings() with { InternalAnalogGainLevel = 1 }));
|
||||
Assert.Throws<InvalidOperationException>(() => Profile().Convert(sample, dark, "device", Settings() with { IntegrationTimeMs = 0 }));
|
||||
Assert.Throws<InvalidOperationException>(() => Profile().Convert(sample, new SpectrumData([500, 503], [10, 10]), "device", Settings()));
|
||||
Assert.Throws<InvalidOperationException>(() => Profile().Convert(new SpectrumData([501], [1]), new SpectrumData([501], [0]), "device", Settings()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_coefficients_bandwidths_and_duplicate_wavelengths_are_rejected()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => (Profile() with { FactorsJPerCount = [double.NaN, 1] }).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => (Profile() with { PixelBandwidthsNm = [0, 1] }).Validate());
|
||||
Assert.Throws<InvalidDataException>(() => (Profile() with { WavelengthsNm = [500, 500] }).Validate());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using RamanApp.Services;
|
||||
using RamanApp.ViewModels;
|
||||
|
||||
namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class RadiometricSettingsViewModelTests
|
||||
{
|
||||
private static RadiometricSettingsViewModel Draft(RadiometricCalibration? calibration = null)
|
||||
=> new("device", [500, 502], new SpectrometerSettings(100, 1, 0, false, 0), calibration);
|
||||
|
||||
[Fact]
|
||||
public void Editing_and_uniform_values_build_valid_parameters_and_persist_without_rounding()
|
||||
{
|
||||
var draft = Draft();
|
||||
draft.CalibrationId = "manual-v1";
|
||||
draft.UniformFactor = "1.2345678901234567e-12";
|
||||
draft.UniformBandwidth = "2";
|
||||
draft.ApplyUniformFactor();
|
||||
draft.ApplyUniformBandwidth();
|
||||
draft.Pixels[1].Factor = "3e-12";
|
||||
var profile = draft.Build();
|
||||
Assert.Equal([1.2345678901234567e-12, 3e-12], profile.FactorsJPerCount);
|
||||
Assert.Equal([2, 2], profile.PixelBandwidthsNm);
|
||||
string directory = Path.Combine(Path.GetTempPath(), "raman-parameters-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var store = new CalibrationStore(directory);
|
||||
store.SaveRadiometricCalibration(profile);
|
||||
var restored = Draft(store.LoadRadiometricCalibration("device"));
|
||||
Assert.Equal(profile.FactorsJPerCount, restored.Build().FactorsJPerCount);
|
||||
restored.Pixels[0].Factor = "9e-12";
|
||||
Assert.Equal(profile.FactorsJPerCount, store.LoadRadiometricCalibration("device")!.FactorsJPerCount);
|
||||
}
|
||||
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("0")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("NaN")]
|
||||
[InlineData("Infinity")]
|
||||
public void Invalid_uniform_input_does_not_change_draft(string value)
|
||||
{
|
||||
var draft = Draft();
|
||||
draft.Pixels[0].Factor = "4e-12";
|
||||
draft.UniformFactor = value;
|
||||
Assert.Throws<InvalidDataException>(draft.ApplyUniformFactor);
|
||||
Assert.Equal("4e-12", draft.Pixels[0].Factor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_or_missing_pixel_parameters_cannot_be_saved()
|
||||
{
|
||||
var draft = Draft();
|
||||
Assert.Throws<InvalidDataException>(() => draft.Build());
|
||||
draft.CalibrationId = "manual";
|
||||
draft.Pixels[0].Factor = "1e-12";
|
||||
draft.Pixels[0].Bandwidth = "2";
|
||||
Assert.Throws<InvalidDataException>(() => draft.Build());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_import_preserves_unsaved_edits()
|
||||
{
|
||||
var draft = Draft();
|
||||
draft.CalibrationId = "draft";
|
||||
var profile = new RadiometricCalibration("other", "file", 0, 0, false, [500, 502], [1, 1], [2, 2]);
|
||||
Assert.Throws<InvalidDataException>(() => draft.Load(profile));
|
||||
Assert.Throws<InvalidDataException>(() => draft.Load(profile with { DeviceSerialNumber = "device", WavelengthsNm = [500, 503] }));
|
||||
Assert.Equal("draft", draft.CalibrationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Existing_subset_calibration_can_be_loaded_edited_and_replaced_with_other_device_pixels()
|
||||
{
|
||||
var subset = new RadiometricCalibration("device", "subset", 0, 0, false, [502], [1e-12], [2]);
|
||||
var draft = Draft(subset);
|
||||
Assert.Equal(502, Assert.Single(draft.Pixels).WavelengthNm);
|
||||
draft.Pixels[0].Factor = "3e-12";
|
||||
Assert.Equal([502], draft.Build().WavelengthsNm);
|
||||
draft.Load(subset with { WavelengthsNm = [500] });
|
||||
Assert.Equal(500, Assert.Single(draft.Pixels).WavelengthNm);
|
||||
}
|
||||
}
|
||||
@@ -3,128 +3,137 @@ using RamanApp.Services;
|
||||
|
||||
namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class SpectrumFileServiceTests
|
||||
public sealed class SpectrumFileServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "raman-export-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
private static SpectrumExportSeries Fluorescence(string laser = "532", int? loop = null)
|
||||
=> new(laser, 1, "荧光光谱", new SpectrumData([500, 501], [8, 9], "fluorescence-background-corrected"),
|
||||
new SpectrumData([500, 501], [7, 7]), LoopIndex: loop,
|
||||
OriginalCounts: new SpectrumData([500, 501], [100, 110]),
|
||||
RadiometricSpectrum: new SpectrumData([500, 501], [1e-12, 2e-12]),
|
||||
RadiometricStatus: "已转换 W/nm; 定标编号=fixture");
|
||||
|
||||
[Fact]
|
||||
public void ExportCsv_writes_multiple_spectra_as_wide_columns()
|
||||
public void Export_keeps_pl_abs_separate_and_adds_laser_counts_file_without_timestamp()
|
||||
{
|
||||
var series = new[]
|
||||
{
|
||||
CreateSeries("Laser1", 1, "光谱仪1", [500, 501], [10, 11]),
|
||||
CreateSeries("Laser2", 2, "光谱仪2", [500, 501], [20, 21]),
|
||||
};
|
||||
|
||||
var lines = ExportLines(series);
|
||||
|
||||
Assert.Contains("# Layout: Wide", lines);
|
||||
Assert.Contains("Wavelength(nm),Raw|Laser1|Ch1|光谱仪1,Raw|Laser2|Ch2|光谱仪2", lines);
|
||||
Assert.Contains("500.0000,10.0000,20.0000", lines);
|
||||
Assert.Contains("501.0000,11.0000,21.0000", lines);
|
||||
Assert.DoesNotContain(lines, line => line.StartsWith("Raw,Laser1", StringComparison.Ordinal));
|
||||
SpectrumExportSeries[] series = [Fluorescence(), new("无激光", null, "可见光吸收光谱",
|
||||
new SpectrumData([500], [0.25]), DataType: "Absorbance")];
|
||||
var paths = SpectrumFileService.ExportCustomerTemplates(_directory, "样品.csv", series);
|
||||
Assert.Equal(["样品_532.csv", "样品_PL.csv", "样品_ABS.csv"], paths.Select(Path.GetFileName));
|
||||
var single = File.ReadAllLines(paths[0]);
|
||||
Assert.Equal("波长,样品_532 (Count),参数,值", single[0]);
|
||||
Assert.StartsWith("500,100,", single[1]);
|
||||
Assert.Contains(single, line => line.Contains("激发波长 (nm),532"));
|
||||
Assert.Contains(single, line => line.Contains("采集时间,"));
|
||||
Assert.Contains("Wavelength,ABS-combined,Wavelength,ABS-NIR,Wavelength,ABS-VIS", File.ReadAllText(paths[2]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportCsv_places_smoothed_column_next_to_source_column()
|
||||
public void Pl_template_uses_original_counts_and_preserves_small_converted_values()
|
||||
{
|
||||
var series = new[]
|
||||
{
|
||||
CreateSeries("Laser1", 1, "光谱仪1", [500, 501], [10, 11], [9.5, 10.5]),
|
||||
};
|
||||
|
||||
var lines = ExportLines(series);
|
||||
|
||||
Assert.Contains("Wavelength(nm),Raw|Laser1|Ch1|光谱仪1,Raw|Laser1|Ch1|光谱仪1|Smoothed", lines);
|
||||
Assert.Contains("500.0000,10.0000,9.5000", lines);
|
||||
var text = SpectrumFileService.ToCustomerTemplateText([Fluorescence()]);
|
||||
Assert.Contains("nm,W/nm,W/nm,W/nm,,Counts,Counts,Counts", text);
|
||||
var row = text.Split('\n').Single(line => line.StartsWith("500,"));
|
||||
var cells = row.Trim().Split(',');
|
||||
Assert.Equal(1e-12, double.Parse(cells[1], System.Globalization.CultureInfo.InvariantCulture));
|
||||
Assert.Equal("100", cells[5]);
|
||||
Assert.Equal("", cells[2]);
|
||||
Assert.Contains("定标编号=fixture", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportCsv_aligns_by_wavelength_union_and_leaves_missing_cells_empty()
|
||||
public void Missing_calibration_keeps_raw_counts_and_records_reason_in_template()
|
||||
{
|
||||
var series = new[]
|
||||
{
|
||||
CreateSeries("Laser1", 1, "光谱仪1", [500, 501], [10, 11]),
|
||||
CreateSeries("Laser2", 2, "光谱仪2", [500.5, 501], [20, 21]),
|
||||
};
|
||||
|
||||
var lines = ExportLines(series);
|
||||
|
||||
Assert.Contains("500.0000,10.0000,", lines);
|
||||
Assert.Contains("500.5000,,20.0000", lines);
|
||||
Assert.Contains("501.0000,11.0000,21.0000", lines);
|
||||
var text = SpectrumFileService.ToCustomerTemplateText([Fluorescence() with
|
||||
{ RadiometricSpectrum = null, RadiometricStatus = "未导入 PL 辐射定标系数" }]);
|
||||
Assert.Contains("500,,,,,100,,", text);
|
||||
Assert.Contains("未导入 PL 辐射定标系数", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportCsv_includes_absorbance_and_loop_metadata_in_wide_headers()
|
||||
public void Historical_corrected_data_is_not_mislabeled_as_original_counts()
|
||||
{
|
||||
var timestamp = new DateTime(2026, 7, 4, 16, 20, 0, 123);
|
||||
var series = new[]
|
||||
{
|
||||
new SpectrumExportSeries(
|
||||
"Laser1",
|
||||
1,
|
||||
"A+B",
|
||||
new SpectrumData([500], [0.1234], "abs"),
|
||||
LoopIndex: 2,
|
||||
LoopTimestamp: timestamp,
|
||||
DataType: "Absorbance"),
|
||||
};
|
||||
|
||||
var lines = ExportLines(series);
|
||||
|
||||
Assert.Contains("Wavelength(nm),Loop2|2026-07-04 16:20:00.123|Absorbance|Laser1|Ch1|A+B", lines);
|
||||
Assert.Contains("500.0000,0.1234", lines);
|
||||
var text = SpectrumFileService.ToCustomerTemplateText([Fluorescence() with
|
||||
{ OriginalCounts = null, RadiometricSpectrum = null }]);
|
||||
Assert.Contains("历史结果没有保留设备原始Counts", text);
|
||||
Assert.DoesNotContain("500,,,,,8", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportCsv_escapes_wide_headers()
|
||||
public void Repeat_export_allocates_one_sequence_for_all_files_and_preserves_existing_data()
|
||||
{
|
||||
var series = new[]
|
||||
{
|
||||
CreateSeries("Laser, \"A\"", 1, "Spec,1", [500], [10]),
|
||||
};
|
||||
|
||||
var lines = ExportLines(series);
|
||||
|
||||
Assert.Contains("\"Raw|Laser, \"\"A\"\"|Ch1|Spec,1\"", lines[5]);
|
||||
var first = SpectrumFileService.ExportCustomerTemplates(_directory, "sample.csv", [Fluorescence()]);
|
||||
File.WriteAllText(first[0], "existing measurement");
|
||||
var second = SpectrumFileService.ExportCustomerTemplates(_directory, "sample.csv", [Fluorescence()]);
|
||||
Assert.Equal(["sample_532_002.csv", "sample_PL_002.csv"], second.Select(Path.GetFileName));
|
||||
Assert.Equal("existing measurement", File.ReadAllText(first[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToClipboardText_uses_same_wide_shape_with_tabs()
|
||||
public void Multi_cycle_export_preserves_each_cycle_and_all_selected_lasers()
|
||||
{
|
||||
var series = new[]
|
||||
{
|
||||
CreateSeries("Laser1", null, "光谱仪1", [500, 501], [10, 11]),
|
||||
CreateSeries("Laser2", null, "光谱仪2", [500, 501], [20, 21]),
|
||||
};
|
||||
|
||||
var text = SpectrumFileService.ToClipboardText(series);
|
||||
|
||||
Assert.StartsWith("Wavelength(nm)\tRaw|Laser1|光谱仪1\tRaw|Laser2|光谱仪2", text);
|
||||
Assert.Contains("500.0000\t10.0000\t20.0000", text);
|
||||
var paths = SpectrumFileService.ExportCustomerTemplates(_directory, "sample.csv",
|
||||
[Fluorescence("532", 1), Fluorescence("577", 1), Fluorescence("532", 2)]);
|
||||
Assert.Equal(5, paths.Count);
|
||||
Assert.Equal(2, paths.Count(path => path.EndsWith("_PL.csv")));
|
||||
Assert.Contains(paths, path => path.Contains("532"));
|
||||
Assert.Contains(paths, path => path.Contains("577"));
|
||||
}
|
||||
|
||||
private static SpectrumExportSeries CreateSeries(string laser, int? channel, string device,
|
||||
double[] wavelengths, double[] intensities, double[]? smoothed = null)
|
||||
=> new(
|
||||
laser,
|
||||
channel,
|
||||
device,
|
||||
new SpectrumData(wavelengths, intensities, "raw"),
|
||||
smoothed == null ? null : new SpectrumData(wavelengths, smoothed, "smoothed"));
|
||||
|
||||
private static string[] ExportLines(IReadOnlyList<SpectrumExportSeries> series)
|
||||
[Fact]
|
||||
public void Calibration_csv_round_trip_preserves_pixel_precision()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.csv");
|
||||
try
|
||||
{
|
||||
SpectrumFileService.ExportCsv(path, series, 785);
|
||||
return File.ReadAllLines(path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
Directory.CreateDirectory(_directory);
|
||||
string path = Path.Combine(_directory, "dark.csv");
|
||||
var dark = new SpectrumData([900.1234567890123, 902.1234567890123], [10.123456789, 11.123456789]);
|
||||
SpectrumFileService.ExportCsv(path, dark, 0);
|
||||
var restored = SpectrumFileService.ImportCsv(path);
|
||||
Assert.Equal(dark.Wavelengths, restored.Wavelengths);
|
||||
Assert.Equal(dark.Intensities, restored.Intensities);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("sample_PL")]
|
||||
[InlineData("sample_ABS")]
|
||||
public void Sample_name_suffix_is_preserved_in_file_names_and_count_header(string sampleName)
|
||||
{
|
||||
var paths = SpectrumFileService.ExportCustomerTemplates(_directory, sampleName + ".csv", [Fluorescence()]);
|
||||
Assert.Equal(sampleName + "_532.csv", Path.GetFileName(paths[0]));
|
||||
Assert.Equal(sampleName + "_PL.csv", Path.GetFileName(paths[1]));
|
||||
Assert.Equal($"波长,{sampleName}_532 (Count),参数,值", File.ReadLines(paths[0]).First());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("532")]
|
||||
[InlineData("577")]
|
||||
public void Summary_import_is_rejected_instead_of_mislabeling_w_per_nm_as_counts(string laser)
|
||||
{
|
||||
var paths = SpectrumFileService.ExportCustomerTemplates(_directory, "sample.csv", [Fluorescence(laser)]);
|
||||
var exception = Assert.Throws<InvalidDataException>(() => SpectrumFileService.ImportCsv(paths[1]));
|
||||
Assert.Contains("不同单位", exception.Message);
|
||||
var counts = SpectrumFileService.ImportCsv(paths[0]);
|
||||
Assert.Equal([100, 110], counts.Intensities);
|
||||
Assert.Equal(laser, counts.ExcitationWavelengthNm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_excitation_is_rejected_instead_of_exporting_an_empty_pl_file()
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => SpectrumFileService.ToCustomerTemplateText(
|
||||
[new SpectrumExportSeries("默认", null, "导入光谱", new SpectrumData([500], [10]))]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Abs_summary_import_is_rejected_instead_of_mislabeling_od_as_counts()
|
||||
{
|
||||
var paths = SpectrumFileService.ExportCustomerTemplates(_directory, "sample.csv",
|
||||
[new SpectrumExportSeries("无激光", null, "可见光吸收光谱", new SpectrumData([500], [0.25]), DataType: "Absorbance")]);
|
||||
Assert.Throws<InvalidDataException>(() => SpectrumFileService.ImportCsv(Assert.Single(paths)));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user