完善光谱保存格式与PL辐射定标参数

This commit is contained in:
chuan
2026-10-27 22:50:24 +08:00 Unverified
parent e03a0d0156
commit a9eda42f71
17 changed files with 1019 additions and 143 deletions
+58
View File
@@ -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/)。用户需确保导入文件适用于当前整套光路。
+2
View File
@@ -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" />
+20 -5
View File
@@ -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)
+7 -2
View File
@@ -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,
+37
View File
@@ -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");
}
}
+125 -20
View File
@@ -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)
+79 -19
View File
@@ -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);
}
}