完善自动校准、光谱处理和采集参数设置
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Project startup
|
||||
|
||||
- To build and start/restart the desktop application, run `.\restart.bat` from the repository root.
|
||||
- The script stops existing RamanApp processes belonging to this checkout, builds current code for win-x86, copies SeaSDK.dll, and starts the application. Build failures must prevent startup of stale binaries.
|
||||
- Default to real hardware mode. Only pass `-Mock` when the user explicitly requests mock/simulation mode.
|
||||
- Use this script for subsequent restarts rather than starting the executable manually.
|
||||
@@ -0,0 +1,55 @@
|
||||
# 已知问题
|
||||
|
||||
## ISSUE-001:设置光谱仪模拟增益后,采集停在 98% 并出现设备掉线
|
||||
|
||||
- 记录日期:2026-09-14
|
||||
- 状态:已临时规避,根因及正式修复待确认。
|
||||
- 验证结果:用户反馈,屏蔽模拟增益设置代码后问题不再出现,确认该设置调用是当前故障的重点关联因素。尚未确认 SDK 内部故障机制。
|
||||
|
||||
### 运行环境与触发场景
|
||||
|
||||
- Windows,桌面应用以 win-x86、真实硬件模式运行,使用 SeaSDK.dll。
|
||||
- 同时使用两台光谱仪:NIRPRO(JEB~I40090347)和 XS11639(LCA~I40090258)。
|
||||
- 荧光与近红外吸收共用 NIRPRO;可见光吸收使用 XS11639。
|
||||
- 荧光模块配置 InternalAnalogGainLevel=1,近红外吸收配置为 0。
|
||||
- 从荧光采集切换到近红外吸收时,对同一台 NIRPRO 下发增益从 1 到 0 的设置,随后读取光谱出现阻塞。
|
||||
- 此处“模拟增益”指 Analog Gain,不是 Mock/模拟设备模式。
|
||||
|
||||
### 故障日志与现象
|
||||
|
||||
2026-09-14 的故障运行日志显示:
|
||||
|
||||
1. 16:52:08 前,NIRPRO 的荧光采集正常完成,返回 512 点。
|
||||
2. 16:52:08,近红外吸收阶段设置增益为 0;设置调用返回后,GetFormattedSpectrum 读取阻塞。
|
||||
3. 16:52:40,约 32 秒后返回 Sea SDK error 6;项目定义为 TransferError。
|
||||
4. 界面显示采集停在 98%,用户报告光谱仪卡住、掉线。98% 是按预计时间计算的进度上限,不是设备报告的真实进度。
|
||||
5. 自动重连仍使用 nativeIndex=0,日志标记原设备序列号,但像素数变为 2048。尚不能确定是设备索引变化还是 SDK 状态异常,不应将其视为原设备已正确恢复的证据。
|
||||
|
||||
原始日志位于 src/RamanApp/bin/Debug/net9.0/win-x86/startup.log,每次重启会覆盖;以上时间线已摘录保存。
|
||||
|
||||
### 当前临时规避
|
||||
|
||||
- 文件:src/RamanApp/Services/SeaSpectrometerAdapter.cs。
|
||||
- 将 InternalAnalogGainWritesEnabled 设为 false。
|
||||
- ApplyInternalAnalogGainIfNeededAsync 提前返回,不调用 SetInternalAnalogGainAsync,并记录诊断禁用日志。
|
||||
- 正常采集及重连后的增益设置均被屏蔽。
|
||||
- 屏蔽范围是桌面应用中经该适配器访问的所有 SeaSDK 光谱仪,不仅是 NIRPRO 的 1 到 0 切换。
|
||||
- 不修改原有配置,不改变积分时间、平均次数等设置;继续使用真实硬件模式。
|
||||
|
||||
### 限制与注意事项
|
||||
|
||||
- 这是禁用增益写入,不是把实际硬件增益固定为 1;硬件当前增益未核实。
|
||||
- 界面、配置及结果元数据中的增益仍可能是请求值,不能据此认定硬件已应用该增益。
|
||||
- 屏蔽后采集仅用于故障对照;正式测量前应确认实际增益,并重新验证暗谱、参考谱与样品采集条件的一致性。
|
||||
- 本次验证禁用了全部增益写入,尚未单独区分首次设置、重复设置和 1 到 0 切换各自的影响。
|
||||
|
||||
### 后续处理
|
||||
|
||||
- [ ] 核对厂商接口定义、调用约定和适用型号,确认当前 SetInternalAnalogGainAsync 封装是否正确。
|
||||
- [ ] 确认采集间切换增益是否需要停止采集、等待稳定、确认状态或重初始化。
|
||||
- [ ] 在受控条件下分别验证不设置增益、首次设置、相同值重复设置、1 到 0 和 0 到 1 切换。
|
||||
- [ ] 重连后核对真实序列号、型号及像素数,禁止仅凭旧 nativeIndex 接收数据。
|
||||
- [ ] 修正读取阻塞时的进度提示、取消和故障状态,避免失败后仍报告已连接。
|
||||
- [ ] 正式修复通过双光谱仪连续采集、模式切换与异常恢复验证后,再启用增益写入。
|
||||
|
||||
恢复入口:将 InternalAnalogGainWritesEnabled 改为 true,然后调用 .\restart.bat 重新编译启动;未经验证不要直接恢复用于正式测量。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 纯水自动校准
|
||||
|
||||
## 操作
|
||||
|
||||
1. 选择荧光激光通道,以及需要测量的荧光/吸收模块,分别设置积分时间和积分次数(默认 1 次)。实际采集使用各模块设置的积分次数。
|
||||
2. 点击“自动校准”,放置装有纯水的比色皿,确认安全防护和光路就绪,再点击“已放置,开始校准”。取消或关闭确认窗口不会启动校准硬件动作。
|
||||
3. 等待校准完成,成功后显示 toast 通知,不在界面常驻显示校准阶段或各通道校准状态;详细阶段记录在日志中。
|
||||
4. 更换样品溶液后执行单次或连续采集。
|
||||
|
||||
采集期间的进度条按实际完成步骤推进,不表示剩余时间。激光采集、明暗谱读取、光路清理及数据处理完成后才推进对应步骤,整个任务返回后才到 100%。等待纯水确认或定时采集下一轮时显示不定进度;设备读取中不会按时间虚增进度。界面只显示进度条和百分比,不显示步骤说明或轮次;连续采集的百分比表示当前轮进度。结束后隐藏进度区,仅保留 toast 通知。
|
||||
|
||||
## 荧光背景
|
||||
|
||||
- 仅荧光模块启用时执行;按每个已启用激光通道逐一采集纯水荧光背景,采集时其他输出关闭。
|
||||
- 背景按设备序列号、激光通道/波长、采集设置和波长范围分别保存。
|
||||
- 样品荧光先按波长对齐并减去匹配的纯水背景,再进行显示、平滑或导出。保留负值,不强行截为零。
|
||||
- 没有匹配背景时,在开启采集激光前提示重新自动校准。更改积分时间、积分次数、增益设置、Boxcar、波长范围、设备或激光通道后,不能沿用不匹配的荧光背景。
|
||||
- 导出元数据标记“已扣除对应激光通道及采集参数的纯水荧光背景”。重新校准不会再次扣除历史结果。
|
||||
|
||||
## 吸收明暗谱
|
||||
|
||||
任意一个吸收模块启用,自动校准都会采集可见光、近红外两台光谱仪,不受另一个吸收模块是否勾选影响;两模块必须绑定不同设备。
|
||||
|
||||
| 阶段 | 输出状态及步骤 |
|
||||
| --- | --- |
|
||||
| 明谱 | 关闭所有激光 → 打开 shutter 并等待配置延时 → 六槽滤光轮第 6 档到位 → 按各自参数依次采集可见光、近红外 → 关闭 shutter |
|
||||
| 暗谱 | 关闭所有激光 → 打开 shutter 并等待配置延时 → 六槽滤光轮第 5 档到位 → 按各自参数依次采集可见光、近红外 → 关闭 shutter |
|
||||
|
||||
- 只操作唯一的六槽滤光轮;四槽滤光轮保持不变。第 5、6 档分别为全挡、全透。
|
||||
- shutter 使用外部硬件面板的通道映射,不能与任一激光映射共用通道。
|
||||
- 保存每个模块的原始明暗谱,同时按波长拼接保存双光谱仪明暗谱。拼接使用原始强度合并,不使用吸光度曲线的偏移校正算法。
|
||||
- 吸光度继续基于各模块匹配的明、暗、样品谱计算,再按显示设置拼接吸光度,避免混用两个模块不同的积分时间。
|
||||
- 样品吸收采集也会打开 shutter、切到第 6 档,完成后关闭 shutter。
|
||||
- 校准或样品吸收阶段结束时,在 shutter 关闭后恢复六槽滤光轮原来的位置;没有已知位置时恢复第 6 档。
|
||||
|
||||
## 失败与取消
|
||||
|
||||
- 确认窗口之后的校准可以通过“停止”取消。
|
||||
- 读取异常或取消时关闭激光和 shutter,正常通信情况下恢复滤光轮位置。
|
||||
- 所需光谱全部采集完成、硬件清理完成后才开始保存本轮校准数据;任一采集阶段失败,不保存本轮已采集的部分背景/明暗谱。
|
||||
- 保存失败不会提示校准成功。之前已保存的校准文件不会主动删除。
|
||||
- 采集和校准期间不能修改模块参数或手动控制外部硬件。
|
||||
|
||||
## 文件与验证
|
||||
|
||||
运行目录下 `calibration/` 保存校准文件:
|
||||
|
||||
- `fluorescence/<参数摘要>/background.csv`:分通道的纯水荧光背景;同目录 `parameters.json` 记录完整匹配参数。
|
||||
- `modules/<模块>/<积分时间>us/<增益>/<设备>/`:模块明暗谱。
|
||||
- 吸收模块及双光谱仪拼接明暗谱的保存、匹配均不绑定积分次数;只修改吸收模块积分次数可以继续使用已有明暗谱。荧光纯水背景仍按完整采集参数匹配。
|
||||
- `stitched-absorbance/<参数摘要>/reference.csv`、`dark.csv`:双光谱仪拼接明暗谱;`parameters.json` 记录两台设备的参数。
|
||||
|
||||
软件默认使用真实硬件;无硬件验证使用 `restart.bat -Mock`。Mock 模式的纯水背景是模拟生成的数据,只用于流程验证,不代表实际纯水光谱。
|
||||
|
||||
当前 SeaSDK 增益写入仍按 `ISSUES.md` 中的临时措施禁用,本功能没有恢复该调用。参数中记录的是请求增益,真实硬件验证前仍需确认实际增益状态。
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "RAMAN_PROJECT_ROOT=%~dp0"
|
||||
set "RAMAN_APP_ROOT=%~dp0src\RamanApp\bin"
|
||||
set "RAMAN_OUTPUT_DIR=%~dp0src\RamanApp\bin\Debug\net9.0\win-x86"
|
||||
set "RAMAN_MOCK=0"
|
||||
|
||||
if not "%~2"=="" goto usage
|
||||
if "%~1"=="" goto run
|
||||
if /I not "%~1"=="-Mock" goto usage
|
||||
set "RAMAN_MOCK=1"
|
||||
|
||||
:run
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; try { Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'RamanApp.exe' -and $_.ExecutablePath -and $_.ExecutablePath.StartsWith($env:RAMAN_APP_ROOT + '\', [StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { $appProcess = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue; if ($appProcess) { if ($appProcess.CloseMainWindow()) { $null = $appProcess.WaitForExit(5000) }; if (-not $appProcess.HasExited) { Stop-Process -Id $appProcess.Id -Force; $appProcess.WaitForExit() } } } } catch { Write-Error $_; exit 1 }"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
dotnet build "%RAMAN_PROJECT_ROOT%src\RamanApp\RamanApp.csproj" -r win-x86 -p:PlatformTarget=x86 --nologo
|
||||
if errorlevel 1 (
|
||||
echo Build failed; application was not started.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; try { $outputDir = $env:RAMAN_OUTPUT_DIR; $sdkSource = 'C:\Program Files\Raman\SeaSDK.dll'; $sdkTarget = Join-Path $outputDir 'SeaSDK.dll'; if (Test-Path -LiteralPath $sdkSource) { Copy-Item -LiteralPath $sdkSource -Destination $sdkTarget -Force } elseif (-not (Test-Path -LiteralPath $sdkTarget)) { throw 'SeaSDK.dll is missing from the output directory and C:\Program Files\Raman.' }; $startOptions = @{ FilePath = Join-Path $outputDir 'RamanApp.exe'; WorkingDirectory = $outputDir; PassThru = $true; RedirectStandardOutput = Join-Path $outputDir 'startup.log'; RedirectStandardError = Join-Path $outputDir 'startup-error.log' }; if ($env:RAMAN_MOCK -eq '1') { $startOptions.ArgumentList = '--simulate' }; $appProcess = Start-Process @startOptions; Start-Sleep -Seconds 3; $appProcess.Refresh(); if ($appProcess.HasExited) { throw ('Application exited during startup. Check logs in ' + $outputDir) }; Write-Host ('RamanApp started. PID={0} Mock={1}' -f $appProcess.Id, ($env:RAMAN_MOCK -eq '1')) } catch { Write-Error $_; exit 1 }"
|
||||
exit /b %errorlevel%
|
||||
|
||||
:usage
|
||||
echo Usage: restart.bat [-Mock]
|
||||
exit /b 2
|
||||
@@ -100,11 +100,13 @@
|
||||
|
||||
<!-- Right side: acquisition progress -->
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal"
|
||||
IsVisible="{Binding Acquisition.IsAcquiring}"
|
||||
Spacing="8" VerticalAlignment="Center" Margin="8,0">
|
||||
<ProgressBar Value="{Binding Acquisition.Progress}" Minimum="0" Maximum="100"
|
||||
IsIndeterminate="{Binding Acquisition.IsProgressIndeterminate}"
|
||||
Width="140" Height="14" VerticalAlignment="Center"
|
||||
Foreground="#4CAF50" />
|
||||
<TextBlock Text="{Binding Acquisition.ProgressText}"
|
||||
<TextBlock Text="{Binding Acquisition.Progress, StringFormat={}{0:0}%}"
|
||||
VerticalAlignment="Center" FontSize="10" Foreground="Gray" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -234,13 +236,13 @@
|
||||
<StackPanel Spacing="12">
|
||||
<views:AcquisitionView DataContext="{Binding Acquisition}" />
|
||||
<Separator />
|
||||
<views:DeviceSettingsView DataContext="{Binding}" />
|
||||
<views:DeviceSettingsView DataContext="{Binding}" IsEnabled="{Binding !Acquisition.IsAcquiring}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="外部硬件" IsVisible="{Binding CanAccessAdvancedTabs}">
|
||||
<ScrollViewer Padding="8">
|
||||
<StackPanel Spacing="12">
|
||||
<StackPanel Spacing="12" IsEnabled="{Binding !Acquisition.IsAcquiring}">
|
||||
<views:EasyTtlHardwareView DataContext="{Binding EasyTtl}" />
|
||||
<Separator />
|
||||
<views:ElloFilterView DataContext="{Binding Ello}" />
|
||||
|
||||
@@ -41,6 +41,17 @@ public partial class MainWindow : Window
|
||||
RawChartView.ProbeXChanged += OnChartProbeXChanged;
|
||||
AbsorbanceChartView.ProbeXChanged += OnChartProbeXChanged;
|
||||
Vm.Acquisition.OnAcquisitionSucceeded = NotifyAcquisitionSucceeded;
|
||||
Vm.Acquisition.ConfirmAutoCalibration = ConfirmPureWaterCalibrationAsync;
|
||||
}
|
||||
|
||||
private async Task<bool> ConfirmPureWaterCalibrationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var dialog = new Views.PureWaterCalibrationDialog();
|
||||
var response = dialog.ShowDialog<bool>(this);
|
||||
using var registration = cancellationToken.Register(() =>
|
||||
Dispatcher.UIThread.Post(() => dialog.Close(false)));
|
||||
return await response;
|
||||
}
|
||||
|
||||
private void OnToastRequested(string title, string message, AppToastLevel level)
|
||||
@@ -179,6 +190,11 @@ public partial class MainWindow : Window
|
||||
|
||||
private async void OnSettingsClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Vm.Acquisition.IsAcquiring)
|
||||
{
|
||||
AppToastService.Warning("采集或校准期间不能修改设备参数");
|
||||
return;
|
||||
}
|
||||
var dialog = new Views.SettingsDialog
|
||||
{
|
||||
DataContext = Vm
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace RamanApp.Services;
|
||||
|
||||
public sealed record FluorescenceCalibrationKey(
|
||||
string DeviceSerialNumber,
|
||||
int LaserChannel,
|
||||
string LaserWavelength,
|
||||
SpectrometerSettings Settings,
|
||||
double MinWavelengthNm,
|
||||
double MaxWavelengthNm);
|
||||
|
||||
public sealed record AbsorbanceCalibrationKey(
|
||||
string VisibleDeviceSerialNumber,
|
||||
SpectrometerSettings VisibleSettings,
|
||||
string NearInfraredDeviceSerialNumber,
|
||||
SpectrometerSettings NearInfraredSettings,
|
||||
double VisibleMinWavelengthNm,
|
||||
double VisibleMaxWavelengthNm,
|
||||
double NearInfraredMinWavelengthNm,
|
||||
double NearInfraredMaxWavelengthNm);
|
||||
@@ -2,6 +2,9 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using RamanApp.Models;
|
||||
|
||||
namespace RamanApp.Services;
|
||||
@@ -15,6 +18,52 @@ public class CalibrationStore
|
||||
_baseDir = baseDir ?? Path.Combine(AppContext.BaseDirectory, "calibration");
|
||||
}
|
||||
|
||||
public void SaveFluorescenceBackground(FluorescenceCalibrationKey key, SpectrumData spectrum)
|
||||
{
|
||||
var directory = GetKeyDirectory("fluorescence", key);
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(Path.Combine(directory, "parameters.json"), JsonSerializer.Serialize(key));
|
||||
SpectrumFileService.ExportCsv(Path.Combine(directory, "background.csv"), spectrum, 0);
|
||||
}
|
||||
|
||||
public SpectrumData? LoadFluorescenceBackground(FluorescenceCalibrationKey key)
|
||||
{
|
||||
var path = Path.Combine(GetKeyDirectory("fluorescence", key), "background.csv");
|
||||
return File.Exists(path) ? SpectrumFileService.ImportCsv(path) : null;
|
||||
}
|
||||
|
||||
public bool HasFluorescenceBackground(FluorescenceCalibrationKey key)
|
||||
=> File.Exists(Path.Combine(GetKeyDirectory("fluorescence", key), "background.csv"));
|
||||
|
||||
public void SaveStitchedAbsorbanceCalibration(
|
||||
AbsorbanceCalibrationKey key, SpectrumData reference, SpectrumData dark)
|
||||
{
|
||||
var directory = GetKeyDirectory("stitched-absorbance", key);
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(Path.Combine(directory, "parameters.json"), JsonSerializer.Serialize(key));
|
||||
SpectrumFileService.ExportCsv(Path.Combine(directory, "reference.csv"), reference, 0);
|
||||
SpectrumFileService.ExportCsv(Path.Combine(directory, "dark.csv"), dark, 0);
|
||||
}
|
||||
|
||||
public SpectrumData? LoadStitchedAbsorbanceCalibration(AbsorbanceCalibrationKey key, bool dark)
|
||||
{
|
||||
var path = Path.Combine(GetKeyDirectory("stitched-absorbance", key), dark ? "dark.csv" : "reference.csv");
|
||||
return File.Exists(path) ? SpectrumFileService.ImportCsv(path) : null;
|
||||
}
|
||||
|
||||
private string GetKeyDirectory<T>(string category, T key)
|
||||
{
|
||||
object? matchingKey = key;
|
||||
if (key is AbsorbanceCalibrationKey absorbanceKey)
|
||||
matchingKey = absorbanceKey with
|
||||
{
|
||||
VisibleSettings = absorbanceKey.VisibleSettings with { AverageCount = 1 },
|
||||
NearInfraredSettings = absorbanceKey.NearInfraredSettings with { AverageCount = 1 },
|
||||
};
|
||||
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(matchingKey))));
|
||||
return Path.Combine(_baseDir, category, hash);
|
||||
}
|
||||
|
||||
public void SaveDark(
|
||||
int integrationTimeMs,
|
||||
int averageCount,
|
||||
@@ -30,7 +79,6 @@ public class CalibrationStore
|
||||
public void SaveModuleDark(
|
||||
string moduleId,
|
||||
int integrationTimeMs,
|
||||
int averageCount,
|
||||
string deviceId,
|
||||
SpectrumData spectrum,
|
||||
int internalAnalogGainLevel = 0)
|
||||
@@ -38,7 +86,6 @@ public class CalibrationStore
|
||||
var dir = GetModuleDeviceDir(
|
||||
moduleId,
|
||||
integrationTimeMs,
|
||||
averageCount,
|
||||
internalAnalogGainLevel,
|
||||
deviceId);
|
||||
Directory.CreateDirectory(dir);
|
||||
@@ -60,7 +107,6 @@ public class CalibrationStore
|
||||
public void SaveModuleReference(
|
||||
string moduleId,
|
||||
int integrationTimeMs,
|
||||
int averageCount,
|
||||
string deviceId,
|
||||
SpectrumData spectrum,
|
||||
int internalAnalogGainLevel = 0)
|
||||
@@ -68,7 +114,6 @@ public class CalibrationStore
|
||||
var dir = GetModuleDeviceDir(
|
||||
moduleId,
|
||||
integrationTimeMs,
|
||||
averageCount,
|
||||
internalAnalogGainLevel,
|
||||
deviceId);
|
||||
Directory.CreateDirectory(dir);
|
||||
@@ -91,14 +136,11 @@ public class CalibrationStore
|
||||
public SpectrumData? LoadModuleDark(
|
||||
string moduleId,
|
||||
int integrationTimeMs,
|
||||
int averageCount,
|
||||
string deviceId,
|
||||
int internalAnalogGainLevel = 0)
|
||||
{
|
||||
var path = Path.Combine(
|
||||
GetModuleDeviceDir(moduleId, integrationTimeMs, averageCount,
|
||||
internalAnalogGainLevel, deviceId),
|
||||
"dark.csv");
|
||||
var path = GetModuleCalibrationPath(moduleId, integrationTimeMs,
|
||||
internalAnalogGainLevel, deviceId, "dark.csv");
|
||||
return File.Exists(path) ? SpectrumFileService.ImportCsv(path) : null;
|
||||
}
|
||||
|
||||
@@ -118,14 +160,11 @@ public class CalibrationStore
|
||||
public SpectrumData? LoadModuleReference(
|
||||
string moduleId,
|
||||
int integrationTimeMs,
|
||||
int averageCount,
|
||||
string deviceId,
|
||||
int internalAnalogGainLevel = 0)
|
||||
{
|
||||
var path = Path.Combine(
|
||||
GetModuleDeviceDir(moduleId, integrationTimeMs, averageCount,
|
||||
internalAnalogGainLevel, deviceId),
|
||||
"reference.csv");
|
||||
var path = GetModuleCalibrationPath(moduleId, integrationTimeMs,
|
||||
internalAnalogGainLevel, deviceId, "reference.csv");
|
||||
return File.Exists(path) ? SpectrumFileService.ImportCsv(path) : null;
|
||||
}
|
||||
|
||||
@@ -156,13 +195,10 @@ public class CalibrationStore
|
||||
public bool HasModuleDark(
|
||||
string moduleId,
|
||||
int integrationTimeMs,
|
||||
int averageCount,
|
||||
string deviceId,
|
||||
int internalAnalogGainLevel = 0)
|
||||
=> File.Exists(Path.Combine(
|
||||
GetModuleDeviceDir(moduleId, integrationTimeMs, averageCount,
|
||||
internalAnalogGainLevel, deviceId),
|
||||
"dark.csv"));
|
||||
=> File.Exists(GetModuleCalibrationPath(moduleId, integrationTimeMs,
|
||||
internalAnalogGainLevel, deviceId, "dark.csv"));
|
||||
|
||||
public bool HasReference(
|
||||
int integrationTimeMs,
|
||||
@@ -178,13 +214,10 @@ public class CalibrationStore
|
||||
public bool HasModuleReference(
|
||||
string moduleId,
|
||||
int integrationTimeMs,
|
||||
int averageCount,
|
||||
string deviceId,
|
||||
int internalAnalogGainLevel = 0)
|
||||
=> File.Exists(Path.Combine(
|
||||
GetModuleDeviceDir(moduleId, integrationTimeMs, averageCount,
|
||||
internalAnalogGainLevel, deviceId),
|
||||
"reference.csv"));
|
||||
=> File.Exists(GetModuleCalibrationPath(moduleId, integrationTimeMs,
|
||||
internalAnalogGainLevel, deviceId, "reference.csv"));
|
||||
|
||||
public List<int> GetAvailableIntegrationTimes()
|
||||
{
|
||||
@@ -266,7 +299,6 @@ public class CalibrationStore
|
||||
private string GetModuleDeviceDir(
|
||||
string moduleId,
|
||||
int integrationTimeMs,
|
||||
int averageCount,
|
||||
int internalAnalogGainLevel,
|
||||
string deviceId)
|
||||
{
|
||||
@@ -278,11 +310,22 @@ public class CalibrationStore
|
||||
"modules",
|
||||
safeModule,
|
||||
$"{integrationTimeUs}us",
|
||||
$"{averageCount}avg",
|
||||
GainDir(internalAnalogGainLevel),
|
||||
safeDevice);
|
||||
}
|
||||
|
||||
private string GetModuleCalibrationPath(
|
||||
string moduleId,
|
||||
int integrationTimeMs,
|
||||
int internalAnalogGainLevel,
|
||||
string deviceId,
|
||||
string fileName)
|
||||
{
|
||||
var directory = GetModuleDeviceDir(moduleId, integrationTimeMs,
|
||||
internalAnalogGainLevel, deviceId);
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
|
||||
private string GetLegacyDeviceDir(int integrationTimeMs, int averageCount, string deviceId)
|
||||
{
|
||||
var safeName = string.Join("_", deviceId.Split(Path.GetInvalidFileNameChars()));
|
||||
|
||||
@@ -94,6 +94,10 @@ public sealed class DataProcessingSettingsFile
|
||||
|
||||
public bool AbsorbanceAutoSmooth { get; set; }
|
||||
|
||||
public bool SmoothVisibleAbsorbance { get; set; } = true;
|
||||
|
||||
public bool SmoothNearInfraredAbsorbance { get; set; } = true;
|
||||
|
||||
public bool AbsorbanceStitchingEnabled { get; set; } = true;
|
||||
|
||||
public double AbsorbanceStitchingWavelengthNm { get; set; } = 850;
|
||||
|
||||
@@ -11,6 +11,7 @@ internal enum AcquisitionTarget
|
||||
Sample,
|
||||
Dark,
|
||||
Reference,
|
||||
FluorescenceBackground,
|
||||
}
|
||||
|
||||
public class FakeSpectrometerDevice : ISpectrometerDevice
|
||||
|
||||
@@ -43,6 +43,7 @@ public sealed class SeaSpectrometerDiscoveryAdapter : ISpectrometerDiscovery, ID
|
||||
public sealed class SeaSpectrometerDeviceAdapter : ISpectrometerDevice, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private const int MaxIntegrationTimeMicroseconds = 10_000_000;
|
||||
private static readonly bool InternalAnalogGainWritesEnabled = false;
|
||||
|
||||
private readonly SeaSpectrometerDiscovery _discovery;
|
||||
private readonly SpectrometerDescriptor _descriptor;
|
||||
@@ -184,6 +185,15 @@ public sealed class SeaSpectrometerDeviceAdapter : ISpectrometerDevice, IDisposa
|
||||
string target,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (!InternalAnalogGainWritesEnabled)
|
||||
{
|
||||
Log.Warning(
|
||||
"Sea internal analog gain write disabled for diagnostics target={Target} device={DeviceId} nativeIndex={NativeIndex} requestedLevel={Level}; hardware gain unchanged",
|
||||
target, _descriptor.DeviceId, NativeIndex, level);
|
||||
return;
|
||||
}
|
||||
|
||||
level = Math.Clamp(level, 0, 1);
|
||||
if (_appliedInternalAnalogGainLevel == level)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,26 @@ namespace RamanApp.Services;
|
||||
|
||||
public static class SpectrumProcessor
|
||||
{
|
||||
public static SpectrumData SubtractFluorescenceBackground(SpectrumData sample, SpectrumData background)
|
||||
{
|
||||
var normalizedSample = NormalizeByWavelength(sample);
|
||||
var normalizedBackground = NormalizeByWavelength(background);
|
||||
if (normalizedSample.Wavelengths.Length == 0 || normalizedBackground.Wavelengths.Length == 0)
|
||||
throw new InvalidOperationException("荧光光谱或纯水背景为空,请重新自动校准");
|
||||
if (Math.Round(normalizedSample.Wavelengths[0], 1, MidpointRounding.AwayFromZero)
|
||||
< Math.Round(normalizedBackground.Wavelengths[0], 1, MidpointRounding.AwayFromZero)
|
||||
|| Math.Round(normalizedSample.Wavelengths[^1], 1, MidpointRounding.AwayFromZero)
|
||||
> Math.Round(normalizedBackground.Wavelengths[^1], 1, MidpointRounding.AwayFromZero))
|
||||
throw new InvalidOperationException("纯水背景未覆盖当前荧光波长范围,请重新自动校准");
|
||||
|
||||
var intensities = normalizedSample.Intensities.Select((value, index) =>
|
||||
value - LinearInterp(normalizedBackground.Wavelengths, normalizedBackground.Intensities,
|
||||
normalizedSample.Wavelengths[index])).ToArray();
|
||||
if (intensities.Any(value => !double.IsFinite(value)))
|
||||
throw new InvalidOperationException("荧光背景扣除产生无效数据,请重新自动校准");
|
||||
return new SpectrumData(normalizedSample.Wavelengths, intensities, "fluorescence-background-corrected");
|
||||
}
|
||||
|
||||
public static SpectrumData CalculateAbsorbance(SpectrumData sample, SpectrumData dark, SpectrumData reference)
|
||||
{
|
||||
var normalizedSample = NormalizeByWavelength(sample);
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using RamanApp.Services;
|
||||
@@ -25,6 +24,9 @@ public partial class AcquisitionViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
public partial double Progress { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsProgressIndeterminate { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string ProgressText { get; set; }
|
||||
|
||||
@@ -77,12 +79,9 @@ public partial class AcquisitionViewModel : ObservableObject
|
||||
|
||||
private CancellationTokenSource? _activeCts;
|
||||
private CancellationTokenSource? _continuousCts;
|
||||
private readonly Stopwatch _progressWatch = new();
|
||||
private readonly Stopwatch _continuousWatch = new();
|
||||
private readonly DispatcherTimer _progressTimer;
|
||||
private double _expectedMs;
|
||||
private double _overallExpectedMs;
|
||||
private double _completedExpectedMs;
|
||||
private int _totalProgressSteps;
|
||||
private int _completedProgressSteps;
|
||||
private string _currentAction = "采集";
|
||||
private readonly AcquisitionSettingsStore _settingsStore;
|
||||
private bool _loadingSettings;
|
||||
@@ -92,6 +91,7 @@ public partial class AcquisitionViewModel : ObservableObject
|
||||
public Func<CancellationToken, Task<AcquisitionCycleResult>>? OnSingleAcquire { get; set; }
|
||||
public Func<CancellationToken, Task<AcquisitionCycleResult>>? OnContinuousAcquire { get; set; }
|
||||
public Func<CancellationToken, Task>? OnAutoCalibration { get; set; }
|
||||
public Func<CancellationToken, Task<bool>>? ConfirmAutoCalibration { get; set; }
|
||||
public Func<CancellationToken, Task>? OnSaveLightSpectrum { get; set; }
|
||||
public Func<CancellationToken, Task>? OnSaveDarkSpectrum { get; set; }
|
||||
public Func<double>? GetEstimatedAcquisitionMs { get; set; }
|
||||
@@ -112,8 +112,6 @@ public partial class AcquisitionViewModel : ObservableObject
|
||||
IsSingleAcquire = false;
|
||||
AcquisitionMode = "普通模式";
|
||||
LoadSaveSettings();
|
||||
_progressTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
|
||||
_progressTimer.Tick += (_, _) => UpdateProgress();
|
||||
}
|
||||
|
||||
public void SetSaveDirectory(string path)
|
||||
@@ -244,7 +242,7 @@ public partial class AcquisitionViewModel : ObservableObject
|
||||
_continuousCts = _activeCts;
|
||||
var token = _activeCts.Token;
|
||||
IsContinuousSessionActive = true;
|
||||
BeginContinuousProgress(loopTotal, totalMs);
|
||||
BeginContinuousProgress(loopTotal);
|
||||
OnContinuousStarted?.Invoke(loopTotal, stepsPerLoop);
|
||||
bool completed = false;
|
||||
int saveFailureCount = 0;
|
||||
@@ -259,7 +257,10 @@ public partial class AcquisitionViewModel : ObservableObject
|
||||
{
|
||||
double waitMs = nextStartMs - _continuousWatch.Elapsed.TotalMilliseconds;
|
||||
if (waitMs > 0)
|
||||
{
|
||||
StartProgress("等待下一轮");
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(waitMs), token);
|
||||
}
|
||||
|
||||
CurrentLoopIndex = loopCount + 1;
|
||||
var outcome = AcquisitionCycleResult.NoAutoSave;
|
||||
@@ -359,6 +360,14 @@ public partial class AcquisitionViewModel : ObservableObject
|
||||
_activeCts = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
if (ConfirmAutoCalibration == null)
|
||||
throw new InvalidOperationException("未配置纯水确认窗口,无法开始自动校准");
|
||||
if (!await ConfirmAutoCalibration(_activeCts.Token))
|
||||
{
|
||||
StopProgress("取消");
|
||||
return;
|
||||
}
|
||||
_activeCts.Token.ThrowIfCancellationRequested();
|
||||
if (OnAutoCalibration != null)
|
||||
await OnAutoCalibration(_activeCts.Token);
|
||||
CompleteProgress("完成");
|
||||
@@ -448,64 +457,69 @@ public partial class AcquisitionViewModel : ObservableObject
|
||||
|
||||
private void StartProgress(string action)
|
||||
{
|
||||
_currentAction = action;
|
||||
_expectedMs = Math.Max(100, GetEstimatedAcquisitionMs?.Invoke() ?? 0);
|
||||
_overallExpectedMs = _expectedMs;
|
||||
_completedExpectedMs = 0;
|
||||
_progressWatch.Restart();
|
||||
_totalProgressSteps = 0;
|
||||
_completedProgressSteps = 0;
|
||||
Progress = 0;
|
||||
UpdateProgress();
|
||||
_progressTimer.Start();
|
||||
IsProgressIndeterminate = true;
|
||||
ReportProgressStage(action);
|
||||
}
|
||||
|
||||
private void BeginContinuousProgress(int loopTotal, double totalMs)
|
||||
public void BeginProgressSteps(int totalSteps)
|
||||
{
|
||||
if (totalSteps <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(totalSteps));
|
||||
_totalProgressSteps = totalSteps;
|
||||
_completedProgressSteps = 0;
|
||||
Progress = 0;
|
||||
IsProgressIndeterminate = false;
|
||||
ReportProgressStage("准备采集");
|
||||
}
|
||||
|
||||
public void ReportProgressStage(string stage)
|
||||
{
|
||||
_currentAction = stage;
|
||||
string cycle = IsContinuousSessionActive
|
||||
? CurrentLoopTotal > 0 && IsContinuousRunActive
|
||||
? $"第 {CurrentLoopIndex}/{CurrentLoopTotal} 轮 · "
|
||||
: $"第 {CurrentLoopIndex} 轮 · "
|
||||
: string.Empty;
|
||||
ProgressText = _totalProgressSteps > 0
|
||||
? $"{cycle}{stage} · 已完成 {_completedProgressSteps}/{_totalProgressSteps} 步"
|
||||
: $"{cycle}{stage}";
|
||||
}
|
||||
|
||||
public void CompleteProgressStep()
|
||||
{
|
||||
if (_totalProgressSteps == 0)
|
||||
return;
|
||||
_completedProgressSteps = Math.Min(_completedProgressSteps + 1, _totalProgressSteps - 1);
|
||||
Progress = 100.0 * _completedProgressSteps / _totalProgressSteps;
|
||||
ReportProgressStage(_currentAction);
|
||||
}
|
||||
|
||||
private void BeginContinuousProgress(int loopTotal)
|
||||
{
|
||||
_progressTimer.Stop();
|
||||
IsContinuousRunActive = true;
|
||||
CurrentLoopIndex = 0;
|
||||
CurrentLoopTotal = loopTotal;
|
||||
_overallExpectedMs = Math.Max(1, totalMs);
|
||||
_completedExpectedMs = 0;
|
||||
_continuousWatch.Restart();
|
||||
Progress = 0;
|
||||
ProgressText = "0%";
|
||||
_progressTimer.Start();
|
||||
}
|
||||
|
||||
private void UpdateProgress()
|
||||
{
|
||||
if (IsContinuousRunActive)
|
||||
{
|
||||
Progress = Math.Min(99,
|
||||
_continuousWatch.Elapsed.TotalMilliseconds / Math.Max(1, _overallExpectedMs) * 100);
|
||||
ProgressText = CurrentLoopTotal > 0
|
||||
? $"{Progress:0}% · {Math.Max(1, CurrentLoopIndex)}/{CurrentLoopTotal}"
|
||||
: $"{Progress:0}%";
|
||||
return;
|
||||
}
|
||||
|
||||
var elapsedMs = _progressWatch.Elapsed.TotalMilliseconds;
|
||||
double currentPhase = Math.Min(_expectedMs * 0.98, elapsedMs);
|
||||
double total = Math.Max(1, _overallExpectedMs);
|
||||
Progress = Math.Min(99, (_completedExpectedMs + currentPhase) / total * 100);
|
||||
ProgressText = IsContinuousRunActive && CurrentLoopTotal > 0
|
||||
? $"{Progress:0}% · {Math.Max(1, CurrentLoopIndex)}/{CurrentLoopTotal}"
|
||||
: $"{Progress:0}%";
|
||||
StartProgress("等待采集");
|
||||
}
|
||||
|
||||
private void CompleteProgress(string label)
|
||||
{
|
||||
_progressTimer.Stop();
|
||||
IsProgressIndeterminate = false;
|
||||
Progress = 100;
|
||||
ProgressText = "100%";
|
||||
}
|
||||
|
||||
private void StopProgress(string label)
|
||||
{
|
||||
_progressTimer.Stop();
|
||||
ProgressText = $"{Progress:0}%";
|
||||
IsProgressIndeterminate = false;
|
||||
ProgressText = label;
|
||||
}
|
||||
|
||||
|
||||
private void LoadSaveSettings()
|
||||
{
|
||||
_loadingSettings = true;
|
||||
|
||||
@@ -18,6 +18,12 @@ public partial class DataProcessingViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
public partial bool AbsorbanceAutoSmooth { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool SmoothVisibleAbsorbance { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool SmoothNearInfraredAbsorbance { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool AbsorbanceStitchingEnabled { get; set; }
|
||||
|
||||
@@ -90,6 +96,13 @@ public partial class DataProcessingViewModel : ObservableObject
|
||||
? SpectrumProcessor.RemoveOutliers(input, OutlierWindowRadius, OutlierMadThreshold)
|
||||
: input;
|
||||
|
||||
public bool IsAbsorbanceSmoothingSelected(SpectroscopyModuleKind kind) => kind switch
|
||||
{
|
||||
SpectroscopyModuleKind.VisibleAbsorbance => SmoothVisibleAbsorbance,
|
||||
SpectroscopyModuleKind.NearInfraredAbsorbance => SmoothNearInfraredAbsorbance,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
[RelayCommand]
|
||||
private void PreviewAcquisitionSmoothing() => OnPreviewAcquisitionSmoothing?.Invoke();
|
||||
|
||||
@@ -126,6 +139,8 @@ public partial class DataProcessingViewModel : ObservableObject
|
||||
var settings = _settingsStore.Load();
|
||||
AutoProcessAfterAcquire = settings.AutoProcessAfterAcquire;
|
||||
AbsorbanceAutoSmooth = settings.AbsorbanceAutoSmooth;
|
||||
SmoothVisibleAbsorbance = settings.SmoothVisibleAbsorbance;
|
||||
SmoothNearInfraredAbsorbance = settings.SmoothNearInfraredAbsorbance;
|
||||
AbsorbanceStitchingEnabled = settings.AbsorbanceStitchingEnabled;
|
||||
AbsorbanceStitchingWavelengthNm = ClampStitchingWavelength(settings.AbsorbanceStitchingWavelengthNm);
|
||||
AbsorbanceInterpolationEnabled = settings.AbsorbanceInterpolationEnabled;
|
||||
@@ -153,6 +168,8 @@ public partial class DataProcessingViewModel : ObservableObject
|
||||
{
|
||||
AutoProcessAfterAcquire = AutoProcessAfterAcquire,
|
||||
AbsorbanceAutoSmooth = AbsorbanceAutoSmooth,
|
||||
SmoothVisibleAbsorbance = SmoothVisibleAbsorbance,
|
||||
SmoothNearInfraredAbsorbance = SmoothNearInfraredAbsorbance,
|
||||
AbsorbanceStitchingEnabled = AbsorbanceStitchingEnabled,
|
||||
AbsorbanceStitchingWavelengthNm = ClampStitchingWavelength(AbsorbanceStitchingWavelengthNm),
|
||||
AbsorbanceInterpolationEnabled = AbsorbanceInterpolationEnabled,
|
||||
@@ -186,6 +203,10 @@ public partial class DataProcessingViewModel : ObservableObject
|
||||
|
||||
partial void OnAbsorbanceAutoSmoothChanged(bool value) => SaveSettings();
|
||||
|
||||
partial void OnSmoothVisibleAbsorbanceChanged(bool value) => SaveSettings();
|
||||
|
||||
partial void OnSmoothNearInfraredAbsorbanceChanged(bool value) => SaveSettings();
|
||||
|
||||
partial void OnAbsorbanceStitchingEnabledChanged(bool value) => SaveSettings();
|
||||
|
||||
partial void OnAbsorbanceInterpolationEnabledChanged(bool value) => SaveSettings();
|
||||
|
||||
@@ -301,7 +301,7 @@ public partial class SpectroscopyModuleViewModel : ObservableObject
|
||||
|
||||
public SpectrometerSettings ToSettings() => new(
|
||||
Math.Clamp(IntegrationTimeMs, MinIntegrationTimeMs, MaxIntegrationTimeMs),
|
||||
Math.Clamp(AverageCount, 1, 1000),
|
||||
AverageCount,
|
||||
Math.Clamp(BoxcarWidth, 0, 100),
|
||||
DarkNoiseEnabled,
|
||||
Math.Clamp(InternalAnalogGainLevel, 0, 1));
|
||||
@@ -314,7 +314,7 @@ public partial class SpectroscopyModuleViewModel : ObservableObject
|
||||
: settings.DeviceSerialNumber.Trim();
|
||||
AcquireEnabled = settings.AcquireEnabled;
|
||||
IntegrationTimeMs = settings.IntegrationTimeMs;
|
||||
AverageCount = Math.Clamp(settings.AverageCount, 1, 1000);
|
||||
AverageCount = settings.AverageCount;
|
||||
BoxcarWidth = Math.Clamp(settings.BoxcarWidth, 0, 100);
|
||||
DarkNoiseEnabled = settings.DarkNoiseEnabled;
|
||||
InternalAnalogGainLevel = settings.InternalAnalogGainLevel;
|
||||
@@ -358,6 +358,13 @@ public partial class SpectroscopyModuleViewModel : ObservableObject
|
||||
if (clamped != value)
|
||||
InternalAnalogGainLevel = clamped;
|
||||
}
|
||||
|
||||
partial void OnAverageCountChanged(int value)
|
||||
{
|
||||
var clamped = Math.Clamp(value, 1, ushort.MaxValue);
|
||||
if (clamped != value)
|
||||
AverageCount = clamped;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record InternalAnalogGainOption(int Level, string Label)
|
||||
|
||||
@@ -452,6 +452,48 @@ public partial class EasyTtlViewModel : ObservableObject, IDisposable
|
||||
selectedChannel.Channel, selected.DisplayName, _simulate);
|
||||
}
|
||||
|
||||
public void ValidateShutterChannel()
|
||||
{
|
||||
if (Lasers.Any(laser => laser.SelectedChannel.Channel == Shutter.SelectedChannel.Channel))
|
||||
throw new InvalidOperationException("Shutter 通道不能与激光通道共用,请检查外部硬件配置");
|
||||
if (!_simulate && (!IsConnected || _device == null))
|
||||
throw new InvalidOperationException("EasyTTL 未连接,无法控制 shutter");
|
||||
}
|
||||
|
||||
public async Task OpenShutterForAbsorbanceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateShutterChannel();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await TurnOffAcquisitionLasersAsync(cancellationToken);
|
||||
var channel = Shutter.SelectedChannel;
|
||||
Log.Information("Absorbance shutter open start channel={Channel} simulate={Simulate}", channel.Channel, _simulate);
|
||||
if (!_simulate)
|
||||
{
|
||||
_device!.SetChannel(channel.Channel, FixedFrequencyHz, (float)FixedDutyPercent);
|
||||
await SettleAcquisitionCommandAsync(cancellationToken);
|
||||
_device.ChannelOn(channel.Channel);
|
||||
await SettleAcquisitionCommandAsync(cancellationToken);
|
||||
var status = _device.GetStatus();
|
||||
ConfirmSelectedChannel(status, channel.Channel);
|
||||
SyncHardwareUiFromStatus(status);
|
||||
}
|
||||
else
|
||||
{
|
||||
_suppressHardwareUpdates = true;
|
||||
try { channel.HardwareEnabled = true; }
|
||||
finally { _suppressHardwareUpdates = false; }
|
||||
}
|
||||
await Task.Delay(channel.OpenDelayMs, cancellationToken);
|
||||
Log.Information("Absorbance shutter opened channel={Channel}", channel.Channel);
|
||||
}
|
||||
|
||||
public async Task CloseShutterAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await TurnOffAcquisitionLasersAsync(cancellationToken);
|
||||
await Task.Delay(Shutter.SelectedChannel.CloseDelayMs, cancellationToken);
|
||||
Log.Information("Absorbance shutter closed channel={Channel}", Shutter.SelectedChannel.Channel);
|
||||
}
|
||||
|
||||
public async Task TurnOffAcquisitionLasersAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Log.Information("EasyTTL acquisition laser off start simulate={Simulate}", _simulate);
|
||||
|
||||
@@ -191,6 +191,27 @@ public partial class ElloFilterViewModel : ObservableObject, IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public ElloDeviceViewModel GetAbsorbanceCalibrationWheel()
|
||||
{
|
||||
var candidates = Devices.Where(device => device.SlotCount == 6).ToArray();
|
||||
if (candidates.Length != 1)
|
||||
throw new InvalidOperationException("吸收校准需要唯一一台六槽滤光轮,请检查配置");
|
||||
if (!candidates[0].IsConnected && !_simulate)
|
||||
throw new InvalidOperationException($"六槽滤光轮 {candidates[0].Name} 未连接");
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
public async Task MoveAbsorbanceWheelAsync(ElloDeviceViewModel wheel, int slot, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!wheel.IsConnected && _simulate)
|
||||
await wheel.ConnectAsync(cancellationToken);
|
||||
if (!wheel.IsConnected || wheel.SlotCount != 6)
|
||||
throw new InvalidOperationException("六槽滤光轮未连接或型号不匹配");
|
||||
await wheel.MoveToSlotAsync(slot, cancellationToken);
|
||||
if (wheel.CurrentSlot != slot)
|
||||
throw new InvalidOperationException($"六槽滤光轮未到达第 {slot} 档:{wheel.StatusText}");
|
||||
}
|
||||
|
||||
public async Task MoveAllToAutoCalibrationPositionAsync(
|
||||
bool darkPosition,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using RamanApp.Models;
|
||||
using RamanApp.Services;
|
||||
using Serilog;
|
||||
|
||||
namespace RamanApp.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel
|
||||
{
|
||||
private static FluorescenceCalibrationKey CreateFluorescenceKey(
|
||||
SpectroscopyModuleViewModel module, int channel, string wavelength,
|
||||
SpectrometerSettings? settings = null)
|
||||
=> new(module.SelectedDevice!.SerialNumber, channel, wavelength, settings ?? module.ToSettings(),
|
||||
Math.Min(module.MinWavelengthNm, module.MaxWavelengthNm),
|
||||
Math.Max(module.MinWavelengthNm, module.MaxWavelengthNm));
|
||||
|
||||
private async Task AutoCalibrateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var fluorescence = DeviceSettings.Fluorescence;
|
||||
bool calibrateFluorescence = fluorescence.AcquireEnabled;
|
||||
bool calibrateAbsorbance = DeviceSettings.AbsorbanceModules.Any(module => module.AcquireEnabled);
|
||||
if (!calibrateFluorescence && !calibrateAbsorbance)
|
||||
throw new InvalidOperationException("请至少启用一个光谱采集模块");
|
||||
if (calibrateFluorescence && fluorescence.SelectedDevice == null)
|
||||
throw new InvalidOperationException("荧光模块未绑定光谱仪");
|
||||
|
||||
var lasers = calibrateFluorescence ? EasyTtl.GetAcquisitionLaserChannels() : [];
|
||||
if (calibrateFluorescence && lasers.Count == 0)
|
||||
throw new InvalidOperationException("请至少启用一个荧光激光通道");
|
||||
EasyTtl.ValidateShutterChannel();
|
||||
|
||||
SpectroscopyModuleViewModel[] absorptionModules =
|
||||
[DeviceSettings.VisibleAbsorbance, DeviceSettings.NearInfraredAbsorbance];
|
||||
ElloDeviceViewModel? wheel = null;
|
||||
if (calibrateAbsorbance)
|
||||
{
|
||||
if (absorptionModules.Any(module => module.SelectedDevice == null))
|
||||
throw new InvalidOperationException("吸收自动校准需要同时绑定可见光和近红外两台光谱仪");
|
||||
if (string.Equals(absorptionModules[0].SelectedDevice!.SerialNumber,
|
||||
absorptionModules[1].SelectedDevice!.SerialNumber, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("吸收自动校准需要两台不同的光谱仪,请检查可见光和近红外模块的绑定");
|
||||
wheel = Ello.GetAbsorbanceCalibrationWheel();
|
||||
}
|
||||
|
||||
Acquisition.BeginProgressSteps(lasers.Count + (calibrateAbsorbance ? 6 : 0) + 2);
|
||||
int restoreSlot = wheel?.CurrentSlot ?? 6;
|
||||
bool wheelMoved = false;
|
||||
var backgrounds = new List<(FluorescenceCalibrationKey Key, SpectrumData Spectrum)>();
|
||||
IReadOnlyList<ModuleAcquisitionResult> references = [];
|
||||
IReadOnlyList<ModuleAcquisitionResult> darks = [];
|
||||
try
|
||||
{
|
||||
await TurnOffAllLasersAsync(CancellationToken.None);
|
||||
foreach (var laser in lasers)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ReportCalibrationStage($"采集 {laser.DisplayName} nm 纯水荧光背景");
|
||||
bool acquired = false;
|
||||
try
|
||||
{
|
||||
await TurnOnAcquisitionLaserWithOpenDelayAsync(laser, cancellationToken);
|
||||
var result = await AcquireModuleAsync(fluorescence, AcquisitionTarget.FluorescenceBackground,
|
||||
laser.SelectedChannel.Channel, laser.DisplayName, cancellationToken)
|
||||
?? throw new InvalidOperationException("纯水荧光背景没有有效波长数据");
|
||||
backgrounds.Add((CreateFluorescenceKey(fluorescence, laser.SelectedChannel.Channel,
|
||||
laser.DisplayName, result.Settings), result.Spectrum));
|
||||
acquired = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReportCalibrationStage($"关闭 {laser.DisplayName} nm 激光并等待延时");
|
||||
await TurnOffAcquisitionLasersWithCloseDelayAsync(laser, acquired, CancellationToken.None);
|
||||
}
|
||||
Acquisition.CompleteProgressStep();
|
||||
}
|
||||
|
||||
if (wheel != null)
|
||||
{
|
||||
wheelMoved = true;
|
||||
ReportCalibrationStage("采集双光谱仪明光谱:shutter 打开、激光关闭、第6档");
|
||||
references = await AcquireAbsorbanceCalibrationPhaseAsync(
|
||||
absorptionModules, wheel, AcquisitionTarget.Reference, 6, cancellationToken);
|
||||
ReportCalibrationStage("采集双光谱仪暗光谱:shutter 打开、激光关闭、第5档");
|
||||
darks = await AcquireAbsorbanceCalibrationPhaseAsync(
|
||||
absorptionModules, wheel, AcquisitionTarget.Dark, 5, cancellationToken);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReportCalibrationStage("关闭光源、恢复滤光轮");
|
||||
await EasyTtl.CloseShutterAsync(CancellationToken.None);
|
||||
if (wheel != null && wheelMoved)
|
||||
await Ello.MoveAbsorbanceWheelAsync(wheel, restoreSlot, CancellationToken.None);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Acquisition.CompleteProgressStep();
|
||||
ReportCalibrationStage("保存背景与明暗谱、刷新图表");
|
||||
if (calibrateAbsorbance)
|
||||
{
|
||||
var visibleReference = references.Single(result => result.ModuleKind == SpectroscopyModuleKind.VisibleAbsorbance);
|
||||
var nearReference = references.Single(result => result.ModuleKind == SpectroscopyModuleKind.NearInfraredAbsorbance);
|
||||
var visibleDark = darks.Single(result => result.ModuleKind == SpectroscopyModuleKind.VisibleAbsorbance);
|
||||
var nearDark = darks.Single(result => result.ModuleKind == SpectroscopyModuleKind.NearInfraredAbsorbance);
|
||||
var key = new AbsorbanceCalibrationKey(visibleReference.DeviceSerialNumber, visibleReference.Settings,
|
||||
nearReference.DeviceSerialNumber, nearReference.Settings,
|
||||
visibleReference.MinWavelengthNm, visibleReference.MaxWavelengthNm,
|
||||
nearReference.MinWavelengthNm, nearReference.MaxWavelengthNm);
|
||||
_calibrationStore.SaveStitchedAbsorbanceCalibration(key,
|
||||
SpectrumData.Merge(visibleReference.Spectrum, nearReference.Spectrum, LaserSettings.LaserWavelengthNm),
|
||||
SpectrumData.Merge(visibleDark.Spectrum, nearDark.Spectrum, LaserSettings.LaserWavelengthNm));
|
||||
foreach (var result in references)
|
||||
_calibrationStore.SaveModuleReference(result.ModuleKind.ToString(), result.Settings.IntegrationTimeMs,
|
||||
result.DeviceSerialNumber, result.Spectrum, result.Settings.InternalAnalogGainLevel);
|
||||
foreach (var result in darks)
|
||||
_calibrationStore.SaveModuleDark(result.ModuleKind.ToString(), result.Settings.IntegrationTimeMs,
|
||||
result.DeviceSerialNumber, result.Spectrum, result.Settings.InternalAnalogGainLevel);
|
||||
}
|
||||
|
||||
foreach (var background in backgrounds)
|
||||
_calibrationStore.SaveFluorescenceBackground(background.Key, background.Spectrum);
|
||||
foreach (var result in references)
|
||||
_lightByModule[result.ModuleKind] = result.Spectrum;
|
||||
foreach (var result in darks)
|
||||
_darkByModule[result.ModuleKind] = result.Spectrum;
|
||||
UpdateLightDarkChart();
|
||||
UpdateAbsorbanceChart();
|
||||
Log.Information("Pure water calibration completed fluorescenceBackgrounds={Backgrounds} absorbance={Absorbance}",
|
||||
backgrounds.Count, calibrateAbsorbance);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ModuleAcquisitionResult>> AcquireAbsorbanceCalibrationPhaseAsync(
|
||||
IReadOnlyList<SpectroscopyModuleViewModel> modules, ElloDeviceViewModel wheel,
|
||||
AcquisitionTarget target, int slot, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<ModuleAcquisitionResult>();
|
||||
try
|
||||
{
|
||||
await EasyTtl.OpenShutterForAbsorbanceAsync(cancellationToken);
|
||||
await Ello.MoveAbsorbanceWheelAsync(wheel, slot, cancellationToken);
|
||||
foreach (var module in modules)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ReportCalibrationStage($"读取 {module.DisplayName} {(target == AcquisitionTarget.Reference ? "明光谱" : "暗光谱")}");
|
||||
var result = await AcquireModuleAsync(module, target, null, "校准", cancellationToken)
|
||||
?? throw new InvalidOperationException($"{module.DisplayName} 校准没有有效波长数据");
|
||||
results.Add(result);
|
||||
Acquisition.CompleteProgressStep();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReportCalibrationStage("关闭 shutter 并等待延时");
|
||||
await EasyTtl.CloseShutterAsync(CancellationToken.None);
|
||||
}
|
||||
Acquisition.CompleteProgressStep();
|
||||
return results;
|
||||
}
|
||||
|
||||
private void ReportCalibrationStage(string stage)
|
||||
{
|
||||
Acquisition.ReportProgressStage(stage);
|
||||
Log.Information("Pure water calibration stage={Stage}", stage);
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,6 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
[ObservableProperty]
|
||||
public partial SpectrumChartViewModel AbsorbanceChart { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string CalibrationStatus { get; set; }
|
||||
|
||||
private readonly ISpectrometerDiscovery _discovery;
|
||||
private readonly CalibrationStore _calibrationStore;
|
||||
@@ -142,7 +140,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
: new SeaSpectrometerDiscoveryAdapter(),
|
||||
simulateIndex) { }
|
||||
|
||||
public MainWindowViewModel(ISpectrometerDiscovery discovery, int simulateIndex = -1)
|
||||
public MainWindowViewModel(ISpectrometerDiscovery discovery, int simulateIndex = -1, CalibrationStore? calibrationStore = null)
|
||||
{
|
||||
var savedRole = _userRoleSettingsStore.LoadRole();
|
||||
if (Enum.TryParse(savedRole, ignoreCase: true, out AppUserRole role))
|
||||
@@ -150,8 +148,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
|
||||
_discovery = discovery;
|
||||
_simulate = simulateIndex >= 1;
|
||||
_calibrationStore = new CalibrationStore();
|
||||
CalibrationStatus = string.Empty;
|
||||
_calibrationStore = calibrationStore ?? new CalibrationStore();
|
||||
|
||||
DeviceSettings = new DeviceSettingsViewModel(simulate: _simulate);
|
||||
LaserSettings = new LaserSettingsViewModel();
|
||||
@@ -179,7 +176,6 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
|
||||
WireUp();
|
||||
DiscoverDevices();
|
||||
UpdateCalibrationStatus();
|
||||
|
||||
}
|
||||
|
||||
@@ -223,7 +219,6 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
private void OnModulePropertyChanged(SpectroscopyModuleViewModel module, string? propertyName)
|
||||
{
|
||||
if (propertyName is nameof(SpectroscopyModuleViewModel.IntegrationTimeMs)
|
||||
or nameof(SpectroscopyModuleViewModel.AverageCount)
|
||||
or nameof(SpectroscopyModuleViewModel.InternalAnalogGainLevel)
|
||||
or nameof(SpectroscopyModuleViewModel.SelectedDeviceName))
|
||||
{
|
||||
@@ -237,13 +232,11 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
or nameof(SpectroscopyModuleViewModel.MaxWavelengthNm))
|
||||
{
|
||||
UpdateCharts();
|
||||
UpdateCalibrationStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (propertyName == nameof(SpectroscopyModuleViewModel.AcquireEnabled))
|
||||
{
|
||||
UpdateCalibrationStatus();
|
||||
UpdateAbsorbanceChart();
|
||||
}
|
||||
}
|
||||
@@ -254,7 +247,9 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
UpdateCharts();
|
||||
}
|
||||
else if (propertyName == nameof(DataProcessingViewModel.AbsorbanceAutoSmooth))
|
||||
else if (propertyName is nameof(DataProcessingViewModel.AbsorbanceAutoSmooth)
|
||||
or nameof(DataProcessingViewModel.SmoothVisibleAbsorbance)
|
||||
or nameof(DataProcessingViewModel.SmoothNearInfraredAbsorbance))
|
||||
{
|
||||
UpdateAbsorbanceChart();
|
||||
}
|
||||
@@ -472,7 +467,6 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
Log.Warning(ex, "Spectrometer discovery failed during startup");
|
||||
DeviceSettings.PopulateDevices([]);
|
||||
AppToastService.Error(ex.Message, "光谱仪初始化失败");
|
||||
UpdateCalibrationStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -528,11 +522,24 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
if (spectrum == null)
|
||||
return null;
|
||||
|
||||
if (_simulate && target == AcquisitionTarget.Sample)
|
||||
if (_simulate && target is AcquisitionTarget.Sample or AcquisitionTarget.FluorescenceBackground)
|
||||
{
|
||||
spectrum = ApplySimulatedModuleProfile(spectrum, module.Kind);
|
||||
if (laserChannel is int channel)
|
||||
spectrum = ApplySimulatedLaserResponse(spectrum, channel, (int)module.Kind + 1);
|
||||
if (target == AcquisitionTarget.FluorescenceBackground)
|
||||
spectrum = new SpectrumData(spectrum.Wavelengths,
|
||||
spectrum.Intensities.Select(value => value * 0.1).ToArray(), "simulated-pure-water");
|
||||
}
|
||||
|
||||
if (target == AcquisitionTarget.Sample && module.Kind == SpectroscopyModuleKind.Fluorescence)
|
||||
{
|
||||
var key = CreateFluorescenceKey(module, laserChannel!.Value, laserName, settings);
|
||||
var background = _calibrationStore.LoadFluorescenceBackground(key)
|
||||
?? throw new InvalidOperationException($"{laserName} nm 缺少当前参数的纯水背景,请先执行自动校准");
|
||||
spectrum = SpectrumProcessor.SubtractFluorescenceBackground(spectrum, background);
|
||||
Log.Information("Fluorescence background subtracted device={Device} channel={Channel} integrationMs={IntegrationMs}",
|
||||
device.SerialNumber, laserChannel, settings.IntegrationTimeMs);
|
||||
}
|
||||
|
||||
Log.Information(
|
||||
@@ -597,6 +604,18 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
|
||||
var selectedLasers = EasyTtl.GetAcquisitionLaserChannels();
|
||||
var results = new List<ModuleAcquisitionResult>();
|
||||
Acquisition.BeginProgressSteps((acquireFluorescence ? selectedLasers.Count : 0)
|
||||
+ (absorptionModules.Count > 0 ? absorptionModules.Count + 2 : 0) + 2);
|
||||
|
||||
if (acquireFluorescence)
|
||||
{
|
||||
foreach (var laser in selectedLasers)
|
||||
{
|
||||
if (!_calibrationStore.HasFluorescenceBackground(CreateFluorescenceKey(
|
||||
fluorescence, laser.SelectedChannel.Channel, laser.DisplayName)))
|
||||
throw new InvalidOperationException($"{laser.DisplayName} nm 缺少当前参数的纯水背景,请先执行自动校准");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -610,6 +629,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
bool acquisitionCompleted = false;
|
||||
try
|
||||
{
|
||||
Acquisition.ReportProgressStage($"打开 {laser.DisplayName} nm 激光并等待稳定");
|
||||
await TurnOnAcquisitionLaserWithOpenDelayAsync(laser, cancellationToken);
|
||||
|
||||
await AcquireModulesAsync(
|
||||
@@ -623,11 +643,13 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
Acquisition.ReportProgressStage($"关闭 {laser.DisplayName} nm 激光并等待延时");
|
||||
await TurnOffAcquisitionLasersWithCloseDelayAsync(
|
||||
laser,
|
||||
acquisitionCompleted,
|
||||
CancellationToken.None);
|
||||
}
|
||||
Acquisition.CompleteProgressStep();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,20 +657,34 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
await TurnOffAllLasersAsync(CancellationToken.None);
|
||||
if (absorptionModules.Count > 0)
|
||||
{
|
||||
await AcquireModulesAsync(
|
||||
absorptionModules,
|
||||
null,
|
||||
"无激光",
|
||||
results,
|
||||
cancellationToken,
|
||||
saveContext.SampleName);
|
||||
var wheel = Ello.GetAbsorbanceCalibrationWheel();
|
||||
int restoreSlot = wheel.CurrentSlot ?? 6;
|
||||
try
|
||||
{
|
||||
Acquisition.ReportProgressStage("打开 shutter、滤光轮切换第6档");
|
||||
await EasyTtl.OpenShutterForAbsorbanceAsync(cancellationToken);
|
||||
await Ello.MoveAbsorbanceWheelAsync(wheel, 6, cancellationToken);
|
||||
Acquisition.CompleteProgressStep();
|
||||
await AcquireModulesAsync(absorptionModules, null, "无激光", results,
|
||||
cancellationToken, saveContext.SampleName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Acquisition.ReportProgressStage("关闭 shutter、恢复滤光轮");
|
||||
await EasyTtl.CloseShutterAsync(CancellationToken.None);
|
||||
await Ello.MoveAbsorbanceWheelAsync(wheel, restoreSlot, CancellationToken.None);
|
||||
}
|
||||
Acquisition.CompleteProgressStep();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Acquisition.ReportProgressStage("关闭光源");
|
||||
await TurnOffAllLasersAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
Acquisition.CompleteProgressStep();
|
||||
Acquisition.ReportProgressStage("处理光谱、刷新图表并保存");
|
||||
if (results.Count == 0)
|
||||
{
|
||||
if (acquireFluorescence && selectedLasers.Count == 0)
|
||||
@@ -685,6 +721,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
foreach (var module in modules)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Acquisition.ReportProgressStage($"读取 {module.DisplayName} · {laserName}");
|
||||
var result = await AcquireModuleAsync(
|
||||
module,
|
||||
AcquisitionTarget.Sample,
|
||||
@@ -694,7 +731,8 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
sampleName);
|
||||
if (result != null)
|
||||
results.Add(result);
|
||||
|
||||
if (laserChannel == null)
|
||||
Acquisition.CompleteProgressStep();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,6 +833,9 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
var series = new List<ChartSeries>();
|
||||
SpectrumData? active = null;
|
||||
int colorIndex = 0;
|
||||
bool hasFluorescence = _loopFrames.Count > 0
|
||||
? _loopFrames.Any(frame => frame.Results.Any(result => result.ModuleKind == SpectroscopyModuleKind.Fluorescence))
|
||||
: _moduleResults.Any(result => result.ModuleKind == SpectroscopyModuleKind.Fluorescence);
|
||||
|
||||
if (_loopFrames.Count > 0)
|
||||
{
|
||||
@@ -802,6 +843,9 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
foreach (var result in frame.Results)
|
||||
{
|
||||
if (hasFluorescence && result.ModuleKind != SpectroscopyModuleKind.Fluorescence)
|
||||
continue;
|
||||
|
||||
string name = $"#{frame.LoopIndex}-{CreateResultName(result)}";
|
||||
series.Add(CreateAcquisitionSeries(name, result.Spectrum, GetSeriesColor(colorIndex++)));
|
||||
active = result.Spectrum;
|
||||
@@ -812,6 +856,9 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
foreach (var result in _moduleResults)
|
||||
{
|
||||
if (hasFluorescence && result.ModuleKind != SpectroscopyModuleKind.Fluorescence)
|
||||
continue;
|
||||
|
||||
series.Add(CreateAcquisitionSeries(
|
||||
CreateResultName(result),
|
||||
result.Spectrum,
|
||||
@@ -857,7 +904,6 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
LightDarkChart.Clear();
|
||||
MergedChart.Clear();
|
||||
AbsorbanceChart.Clear();
|
||||
UpdateCalibrationStatus();
|
||||
}
|
||||
|
||||
private async Task AcquireAndSaveLightAsync(CancellationToken cancellationToken)
|
||||
@@ -866,44 +912,6 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
private async Task AcquireAndSaveDarkAsync(CancellationToken cancellationToken)
|
||||
=> await AcquireAndSaveCalibrationAsync(AcquisitionTarget.Dark, cancellationToken);
|
||||
|
||||
private async Task AutoCalibrateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
bool lightPositionReached = false;
|
||||
await TurnOffAllLasersAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await Ello.MoveAllToAutoCalibrationPositionAsync(
|
||||
darkPosition: true,
|
||||
cancellationToken);
|
||||
|
||||
await AcquireAndSaveCalibrationAsync(AcquisitionTarget.Dark, cancellationToken);
|
||||
|
||||
await Ello.MoveAllToAutoCalibrationPositionAsync(
|
||||
darkPosition: false,
|
||||
cancellationToken);
|
||||
lightPositionReached = true;
|
||||
|
||||
await AcquireAndSaveCalibrationAsync(AcquisitionTarget.Reference, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await TurnOffAllLasersAsync(CancellationToken.None);
|
||||
if (!lightPositionReached)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Ello.MoveAllToAutoCalibrationPositionAsync(
|
||||
darkPosition: false,
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to restore ELLO devices to auto-calibration light positions");
|
||||
AppToastService.Error(ex.Message, "滤光轮恢复失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AcquireAndSaveCalibrationAsync(
|
||||
AcquisitionTarget target,
|
||||
@@ -913,11 +921,13 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
if (modules.Count == 0)
|
||||
throw new InvalidOperationException("没有已启用并绑定设备的吸收光谱模块");
|
||||
|
||||
Acquisition.BeginProgressSteps(modules.Count + 2);
|
||||
await TurnOffAllLasersAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
foreach (var module in modules)
|
||||
{
|
||||
Acquisition.ReportProgressStage($"采集并保存 {module.DisplayName} {(target == AcquisitionTarget.Reference ? "明光谱" : "暗光谱")}");
|
||||
var result = await AcquireModuleAsync(module, target, null, "校准", cancellationToken);
|
||||
if (result == null)
|
||||
continue;
|
||||
@@ -928,7 +938,6 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
_calibrationStore.SaveModuleDark(
|
||||
module.ModuleId,
|
||||
module.IntegrationTimeMs,
|
||||
module.AverageCount,
|
||||
result.DeviceSerialNumber,
|
||||
result.Spectrum,
|
||||
module.InternalAnalogGainLevel);
|
||||
@@ -939,11 +948,11 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
_calibrationStore.SaveModuleReference(
|
||||
module.ModuleId,
|
||||
module.IntegrationTimeMs,
|
||||
module.AverageCount,
|
||||
result.DeviceSerialNumber,
|
||||
result.Spectrum,
|
||||
module.InternalAnalogGainLevel);
|
||||
}
|
||||
Acquisition.CompleteProgressStep();
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -951,7 +960,8 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
await TurnOffAllLasersAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
UpdateCalibrationStatus();
|
||||
Acquisition.CompleteProgressStep();
|
||||
Acquisition.ReportProgressStage("刷新图表");
|
||||
UpdateLightDarkChart();
|
||||
UpdateAbsorbanceChart();
|
||||
}
|
||||
@@ -1036,40 +1046,53 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
continue;
|
||||
|
||||
calculated[result.ModuleKind] = absorbance.Value;
|
||||
string laserPrefix = result.Channel == null ? string.Empty : $"{result.LaserName}-";
|
||||
series.Add(CreateAbsorbanceSeries(
|
||||
$"{prefix}{laserPrefix}{result.ModuleName}-吸光度",
|
||||
absorbance.Value.Raw,
|
||||
absorbance.Value.Smoothed,
|
||||
GetSeriesColor(colorIndex++)));
|
||||
}
|
||||
|
||||
if (!DataProcessing.AbsorbanceStitchingEnabled
|
||||
|| !calculated.TryGetValue(SpectroscopyModuleKind.VisibleAbsorbance, out var visible)
|
||||
|| !calculated.TryGetValue(SpectroscopyModuleKind.NearInfraredAbsorbance, out var nearInfrared))
|
||||
{
|
||||
foreach (var result in results)
|
||||
{
|
||||
if (!calculated.TryGetValue(result.ModuleKind, out var absorbance))
|
||||
continue;
|
||||
string laserPrefix = result.Channel == null ? string.Empty : $"{result.LaserName}-";
|
||||
series.Add(CreateAbsorbanceSeries(
|
||||
$"{prefix}{laserPrefix}{result.ModuleName}-吸光度",
|
||||
absorbance.Raw,
|
||||
absorbance.Smoothed,
|
||||
GetSeriesColor(colorIndex++)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var stitched = SpectrumProcessor.StitchAbsorbance(
|
||||
visible.Raw,
|
||||
nearInfrared.Raw,
|
||||
DataProcessing.AbsorbanceStitchingWavelengthNm,
|
||||
LaserSettings.LaserWavelengthNm);
|
||||
SpectrumData? smoothed = null;
|
||||
if (DataProcessing.AbsorbanceAutoSmooth || forceSmooth)
|
||||
smoothed = DataProcessing.ApplySmoothing(stitched);
|
||||
var stitched = CreateStitchedAbsorbance(visible, nearInfrared);
|
||||
|
||||
string laserName = results.FirstOrDefault()?.Channel == null
|
||||
? string.Empty
|
||||
: $"{results[0].LaserName}-";
|
||||
series.Add(CreateAbsorbanceSeries(
|
||||
$"{prefix}{laserName}可见/近红外拼接吸光度",
|
||||
stitched,
|
||||
smoothed,
|
||||
stitched.Raw,
|
||||
stitched.Smoothed,
|
||||
GetSeriesColor(colorIndex++)));
|
||||
}
|
||||
|
||||
private (SpectrumData Raw, SpectrumData? Smoothed) CreateStitchedAbsorbance(
|
||||
(SpectrumData Raw, SpectrumData? Smoothed) visible,
|
||||
(SpectrumData Raw, SpectrumData? Smoothed) nearInfrared)
|
||||
{
|
||||
var raw = SpectrumProcessor.StitchAbsorbance(
|
||||
visible.Raw, nearInfrared.Raw,
|
||||
DataProcessing.AbsorbanceStitchingWavelengthNm, LaserSettings.LaserWavelengthNm);
|
||||
var processed = visible.Smoothed != null || nearInfrared.Smoothed != null
|
||||
? SpectrumProcessor.StitchAbsorbance(
|
||||
visible.Smoothed ?? visible.Raw, nearInfrared.Smoothed ?? nearInfrared.Raw,
|
||||
DataProcessing.AbsorbanceStitchingWavelengthNm, LaserSettings.LaserWavelengthNm)
|
||||
: null;
|
||||
return (raw, processed);
|
||||
}
|
||||
|
||||
private static ChartSeries CreateAbsorbanceSeries(
|
||||
string name,
|
||||
SpectrumData raw,
|
||||
@@ -1107,7 +1130,8 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
prepared.Dark,
|
||||
prepared.Reference);
|
||||
SpectrumData? smoothed = null;
|
||||
if (DataProcessing.AbsorbanceAutoSmooth || forceSmooth)
|
||||
if ((DataProcessing.AbsorbanceAutoSmooth || forceSmooth)
|
||||
&& DataProcessing.IsAbsorbanceSmoothingSelected(result.ModuleKind))
|
||||
smoothed = DataProcessing.ApplySmoothing(absorbance);
|
||||
return (absorbance, smoothed);
|
||||
}
|
||||
@@ -1147,7 +1171,6 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
SpectrometerSettings settings,
|
||||
SpectroscopyModuleViewModel module)
|
||||
=> settings.IntegrationTimeMs == module.IntegrationTimeMs
|
||||
&& settings.AverageCount == module.AverageCount
|
||||
&& settings.InternalAnalogGainLevel == module.InternalAnalogGainLevel;
|
||||
|
||||
private SpectrumData? GetCalibrationSpectrum(
|
||||
@@ -1166,53 +1189,16 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
? _calibrationStore.LoadModuleDark(
|
||||
module.ModuleId,
|
||||
module.IntegrationTimeMs,
|
||||
module.AverageCount,
|
||||
device.SerialNumber,
|
||||
module.InternalAnalogGainLevel)
|
||||
: _calibrationStore.LoadModuleReference(
|
||||
module.ModuleId,
|
||||
module.IntegrationTimeMs,
|
||||
module.AverageCount,
|
||||
device.SerialNumber,
|
||||
module.InternalAnalogGainLevel);
|
||||
return spectrum == null ? null : NullIfEmpty(ApplyConfiguredWavelengthRange(spectrum, module));
|
||||
}
|
||||
|
||||
private void UpdateCalibrationStatus()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var module in DeviceSettings.AbsorbanceModules)
|
||||
{
|
||||
var device = module.SelectedDevice;
|
||||
if (device == null || !module.AcquireEnabled)
|
||||
continue;
|
||||
|
||||
bool hasDark = _darkByModule.ContainsKey(module.Kind)
|
||||
|| _calibrationStore.HasModuleDark(
|
||||
module.ModuleId,
|
||||
module.IntegrationTimeMs,
|
||||
module.AverageCount,
|
||||
device.SerialNumber,
|
||||
module.InternalAnalogGainLevel);
|
||||
bool hasReference = _lightByModule.ContainsKey(module.Kind)
|
||||
|| _calibrationStore.HasModuleReference(
|
||||
module.ModuleId,
|
||||
module.IntegrationTimeMs,
|
||||
module.AverageCount,
|
||||
device.SerialNumber,
|
||||
module.InternalAnalogGainLevel);
|
||||
string state = (hasDark, hasReference) switch
|
||||
{
|
||||
(true, true) => "已校准",
|
||||
(true, false) => "缺明光谱",
|
||||
(false, true) => "缺暗光谱",
|
||||
_ => "未校准",
|
||||
};
|
||||
parts.Add($"{module.DisplayName}@{module.IntegrationTimeMs}ms/{module.AverageCount}次: {state}");
|
||||
}
|
||||
|
||||
CalibrationStatus = string.Join(" | ", parts);
|
||||
}
|
||||
|
||||
public void LoadCalibrationForCurrentSettings()
|
||||
{
|
||||
@@ -1235,7 +1221,6 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
UpdateCalibrationStatus();
|
||||
UpdateLightDarkChart();
|
||||
UpdateAbsorbanceChart();
|
||||
}
|
||||
@@ -1423,20 +1408,13 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
if (visible == null || nearInfrared == null)
|
||||
continue;
|
||||
|
||||
var stitched = SpectrumProcessor.StitchAbsorbance(
|
||||
visible.Value.Raw,
|
||||
nearInfrared.Value.Raw,
|
||||
DataProcessing.AbsorbanceStitchingWavelengthNm,
|
||||
LaserSettings.LaserWavelengthNm);
|
||||
var smoothed = DataProcessing.AbsorbanceAutoSmooth
|
||||
? DataProcessing.ApplySmoothing(stitched)
|
||||
: null;
|
||||
var stitched = CreateStitchedAbsorbance(visible.Value, nearInfrared.Value);
|
||||
series.Add(new SpectrumExportSeries(
|
||||
group[0].LaserName,
|
||||
group[0].Channel,
|
||||
"可见/近红外拼接",
|
||||
stitched,
|
||||
smoothed,
|
||||
stitched.Raw,
|
||||
stitched.Smoothed,
|
||||
loopIndex,
|
||||
loopTimestamp,
|
||||
"Absorbance",
|
||||
@@ -1483,8 +1461,10 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
first?.LoopTotal,
|
||||
first?.ContinuousIntervalMs,
|
||||
DescribePerModule(results, result => result.FilterWheelPositions),
|
||||
DescribeSmoothing(absorbance),
|
||||
DescribeProcessing(absorbance, stitched));
|
||||
DescribeSmoothing(absorbance, results),
|
||||
!absorbance && results.Any(result => result.ModuleKind == SpectroscopyModuleKind.Fluorescence)
|
||||
? "已扣除对应激光通道及采集参数的纯水荧光背景"
|
||||
: DescribeProcessing(absorbance, stitched));
|
||||
}
|
||||
|
||||
private static string DescribePerModule(
|
||||
@@ -1501,13 +1481,19 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
$"{result.ModuleName}={values[index]}"));
|
||||
}
|
||||
|
||||
private string DescribeSmoothing(bool absorbance)
|
||||
private string DescribeSmoothing(bool absorbance, IReadOnlyList<ModuleAcquisitionResult>? results = null)
|
||||
{
|
||||
var stages = new List<string>();
|
||||
if (DataProcessing.AutoProcessAfterAcquire)
|
||||
stages.Add("采集后");
|
||||
if (absorbance && DataProcessing.AbsorbanceAutoSmooth)
|
||||
stages.Add("吸光度结果");
|
||||
{
|
||||
var targets = (results ?? [])
|
||||
.Where(result => DataProcessing.IsAbsorbanceSmoothingSelected(result.ModuleKind))
|
||||
.Select(result => result.ModuleName).Distinct().ToArray();
|
||||
if (targets.Length > 0)
|
||||
stages.Add($"吸光度结果({string.Join("、", targets)})");
|
||||
}
|
||||
if (stages.Count == 0)
|
||||
return "关闭";
|
||||
|
||||
@@ -1542,6 +1528,9 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
if (stitched)
|
||||
{
|
||||
if (DataProcessing.AbsorbanceAutoSmooth
|
||||
&& (DataProcessing.SmoothVisibleAbsorbance || DataProcessing.SmoothNearInfraredAbsorbance))
|
||||
processing.Add("所选吸光度段先平滑,再拼接;拼接后不做整条平滑");
|
||||
processing.Add("可见/近红外拼接(波长=" +
|
||||
DataProcessing.AbsorbanceStitchingWavelengthNm.ToString(
|
||||
"0.####", CultureInfo.InvariantCulture) + "nm)");
|
||||
|
||||
@@ -139,6 +139,32 @@ public partial class SpectrumChartViewModel : ObservableObject
|
||||
};
|
||||
}
|
||||
|
||||
public static (double Min, double Max)? GetCombinedXRange(params SpectrumChartViewModel[] charts)
|
||||
{
|
||||
double minimum = double.PositiveInfinity;
|
||||
double maximum = double.NegativeInfinity;
|
||||
foreach (var chart in charts)
|
||||
{
|
||||
foreach (var series in chart.Series.Where(series => chart.IsSeriesVisible(series.Name)))
|
||||
{
|
||||
var coordinates = chart.ConvertX(series.X);
|
||||
var intensities = series.SmoothedY ?? series.Y;
|
||||
for (int index = 0; index < Math.Min(coordinates.Length, intensities.Length); index++)
|
||||
{
|
||||
if (!double.IsFinite(coordinates[index]) || !double.IsFinite(intensities[index]))
|
||||
continue;
|
||||
minimum = Math.Min(minimum, coordinates[index]);
|
||||
maximum = Math.Max(maximum, coordinates[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!double.IsFinite(minimum) || !double.IsFinite(maximum))
|
||||
return null;
|
||||
double padding = maximum > minimum ? (maximum - minimum) * 0.05 : 1;
|
||||
return (minimum - padding, maximum + padding);
|
||||
}
|
||||
|
||||
private static double[] CreatePixelArray(int length)
|
||||
{
|
||||
var pixels = new double[length];
|
||||
@@ -157,10 +183,7 @@ public partial class SpectrumChartViewModel : ObservableObject
|
||||
|
||||
public void SetData(string name, double[] x, double[] y, string color = "#1E90FF")
|
||||
{
|
||||
_series.Clear();
|
||||
_series.Add(new ChartSeries(name, x, y, color));
|
||||
SynchronizeSeriesVisibility();
|
||||
DataVersion++;
|
||||
SetData(new ChartSeries(name, x, y, color));
|
||||
}
|
||||
|
||||
public void SetData(params ChartSeries[] series)
|
||||
@@ -168,6 +191,7 @@ public partial class SpectrumChartViewModel : ObservableObject
|
||||
_series.Clear();
|
||||
_series.AddRange(series);
|
||||
SynchronizeSeriesVisibility();
|
||||
AutoScaleRequested = !UseCustomRange && !UseFixedXRange && !UseFixedYRange;
|
||||
DataVersion++;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,14 @@
|
||||
|
||||
<CheckBox Content="采集光谱预处理"
|
||||
IsChecked="{Binding AutoProcessAfterAcquire}" />
|
||||
<CheckBox Content="吸光度结果平滑"
|
||||
<CheckBox Content="自动平滑所选吸光度"
|
||||
IsChecked="{Binding AbsorbanceAutoSmooth}" />
|
||||
<StackPanel Spacing="4" Margin="16,0,0,0">
|
||||
<CheckBox Content="可见光吸光度"
|
||||
IsChecked="{Binding SmoothVisibleAbsorbance}" />
|
||||
<CheckBox Content="近红外吸光度"
|
||||
IsChecked="{Binding SmoothNearInfraredAbsorbance}" />
|
||||
</StackPanel>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -176,7 +182,7 @@
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding ResetAcquisitionSmoothingCommand}" />
|
||||
<Button Content="预览吸光度平滑"
|
||||
<Button Content="预览所选吸光度平滑"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding PreviewAbsorbanceSmoothingCommand}" />
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<TextBlock Grid.Row="1" Classes="field-label" Text="积分次数" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1"
|
||||
Value="{Binding DeviceSettings.Fluorescence.AverageCount}"
|
||||
Minimum="1" Maximum="1000" Increment="1" FormatString="0" />
|
||||
Minimum="1" Maximum="65535" Increment="1" FormatString="0" />
|
||||
</Grid>
|
||||
|
||||
<ItemsControl Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2"
|
||||
@@ -98,7 +98,7 @@
|
||||
<TextBlock Grid.Row="1" Classes="field-label" Text="积分次数" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1"
|
||||
Value="{Binding DeviceSettings.NearInfraredAbsorbance.AverageCount}"
|
||||
Minimum="1" Maximum="1000" Increment="1" FormatString="0" />
|
||||
Minimum="1" Maximum="65535" Increment="1" FormatString="0" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -128,7 +128,7 @@
|
||||
<TextBlock Grid.Row="1" Classes="field-label" Text="积分次数" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1"
|
||||
Value="{Binding DeviceSettings.VisibleAbsorbance.AverageCount}"
|
||||
Minimum="1" Maximum="1000" Increment="1" FormatString="0" />
|
||||
Minimum="1" Maximum="65535" Increment="1" FormatString="0" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="RamanApp.Views.PureWaterCalibrationDialog"
|
||||
Title="自动校准 · 纯水确认" Width="470" SizeToContent="Height"
|
||||
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||
<StackPanel Margin="24" Spacing="16">
|
||||
<TextBlock Text="是否已放置装有纯水的比色皿?" FontSize="18" FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Text="荧光:按已启用的激光通道采集纯水背景。 吸收:任一吸收模块启用时,采集两台光谱仪的明谱(第6档)和暗谱(第5档)。 确认后将自动控制激光、shutter 和滤光轮。请确认光路及安全防护已就绪,采集期间不要更换比色皿。" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="12">
|
||||
<Button Content="取消" Click="OnCancelClick" />
|
||||
<Button Content="已放置,开始校准" Click="OnConfirmClick" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,13 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace RamanApp.Views;
|
||||
|
||||
public partial class PureWaterCalibrationDialog : Window
|
||||
{
|
||||
public PureWaterCalibrationDialog() => InitializeComponent();
|
||||
|
||||
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
|
||||
|
||||
private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using RamanApp.ViewModels;
|
||||
using ScottPlot;
|
||||
using ScottPlot.Plottables;
|
||||
@@ -24,6 +25,8 @@ public partial class SpectrumChartView : UserControl
|
||||
private readonly List<ProbeSeriesState> _probeSeries = [];
|
||||
private VerticalLine? _probeLine;
|
||||
private bool _hasRenderedData;
|
||||
private SpectrumChartView? _linkedChart;
|
||||
private bool _linkedAutoScalePending;
|
||||
|
||||
public bool ShowXAxisSelector
|
||||
{
|
||||
@@ -53,6 +56,7 @@ public partial class SpectrumChartView : UserControl
|
||||
_vm.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
|
||||
_vm = DataContext as SpectrumChartViewModel;
|
||||
_hasRenderedData = false;
|
||||
if (_vm != null)
|
||||
{
|
||||
_vm.PropertyChanged += OnViewModelPropertyChanged;
|
||||
@@ -81,7 +85,6 @@ public partial class SpectrumChartView : UserControl
|
||||
_probeSeries.Clear();
|
||||
ProbeRowsPanel.Children.Clear();
|
||||
plot.Clear();
|
||||
var ds = vm.DisplaySettings;
|
||||
var visibleSeries = vm.Series
|
||||
.Where(series => series.X.Length > 0
|
||||
&& series.Y.Length > 0
|
||||
@@ -133,7 +136,7 @@ public partial class SpectrumChartView : UserControl
|
||||
plot.Axes.Bottom.Label.Text = string.Empty;
|
||||
plot.Axes.Bottom.Collapse();
|
||||
}
|
||||
plot.Axes.Left.Label.Text = ds?.YAxisUnit ?? vm.YAxisLabel;
|
||||
plot.Axes.Left.Label.Text = string.Empty;
|
||||
|
||||
plot.HideLegend();
|
||||
|
||||
@@ -164,6 +167,7 @@ public partial class SpectrumChartView : UserControl
|
||||
else if (vm.ConsumeAutoScaleRequest() || !canKeepPreviousLimits)
|
||||
{
|
||||
plot.Axes.AutoScale();
|
||||
ScheduleLinkedAutoScale();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -172,6 +176,7 @@ public partial class SpectrumChartView : UserControl
|
||||
}
|
||||
|
||||
_hasRenderedData = vm.Series.Count > 0;
|
||||
AvaPlot.Refresh();
|
||||
}
|
||||
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
@@ -254,8 +259,34 @@ public partial class SpectrumChartView : UserControl
|
||||
|
||||
public void LinkXAxis(SpectrumChartView other)
|
||||
{
|
||||
_linkedChart = other;
|
||||
other._linkedChart = this;
|
||||
AvaPlot.Plot.Axes.Link(other.AvaPlot, true, false);
|
||||
other.AvaPlot.Plot.Axes.Link(AvaPlot, true, false);
|
||||
ScheduleLinkedAutoScale();
|
||||
}
|
||||
|
||||
private void ScheduleLinkedAutoScale()
|
||||
{
|
||||
if (_linkedChart == null || _linkedAutoScalePending || _linkedChart._linkedAutoScalePending)
|
||||
return;
|
||||
_linkedAutoScalePending = true;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
_linkedAutoScalePending = false;
|
||||
var other = _linkedChart;
|
||||
if (_vm == null || other?._vm == null
|
||||
|| _vm.UseCustomRange || _vm.UseFixedXRange
|
||||
|| other._vm.UseCustomRange || other._vm.UseFixedXRange)
|
||||
return;
|
||||
var range = SpectrumChartViewModel.GetCombinedXRange(_vm, other._vm);
|
||||
if (range == null)
|
||||
return;
|
||||
AvaPlot.Plot.Axes.SetLimitsX(range.Value.Min, range.Value.Max);
|
||||
other.AvaPlot.Plot.Axes.SetLimitsX(range.Value.Min, range.Value.Max);
|
||||
AvaPlot.Refresh();
|
||||
other.AvaPlot.Refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public void HideSharedProbe() => HideProbe();
|
||||
|
||||
@@ -4,6 +4,100 @@ namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class AcquisitionViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Progress_advances_only_after_steps_and_finishes_after_callback_returns()
|
||||
{
|
||||
var vm = new AcquisitionViewModel();
|
||||
vm.GetEstimatedAcquisitionMs = () => 1;
|
||||
vm.OnSingleAcquire = async _ =>
|
||||
{
|
||||
vm.BeginProgressSteps(4);
|
||||
vm.ReportProgressStage("等待设备");
|
||||
await Task.Delay(150);
|
||||
Assert.Equal(0, vm.Progress);
|
||||
vm.CompleteProgressStep();
|
||||
Assert.Equal(25, vm.Progress);
|
||||
vm.ReportProgressStage("保存数据");
|
||||
Assert.Equal(25, vm.Progress);
|
||||
vm.CompleteProgressStep();
|
||||
vm.CompleteProgressStep();
|
||||
vm.CompleteProgressStep();
|
||||
Assert.Equal(75, vm.Progress);
|
||||
Assert.Contains("3/4", vm.ProgressText);
|
||||
Assert.False(vm.IsProgressIndeterminate);
|
||||
return RamanApp.ViewModels.AcquisitionCycleResult.NoAutoSave;
|
||||
};
|
||||
|
||||
await vm.SingleAcquireCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(100, vm.Progress);
|
||||
Assert.False(vm.IsAcquiring);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_does_not_complete_progress_and_next_run_resets_steps()
|
||||
{
|
||||
var vm = new AcquisitionViewModel();
|
||||
vm.OnSingleAcquire = token =>
|
||||
{
|
||||
vm.BeginProgressSteps(4);
|
||||
Assert.Equal(0, vm.Progress);
|
||||
vm.CompleteProgressStep();
|
||||
vm.StopAcquireCommand.Execute(null);
|
||||
token.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(AcquisitionCycleResult.NoAutoSave);
|
||||
};
|
||||
|
||||
await vm.SingleAcquireCommand.ExecuteAsync(null);
|
||||
Assert.Equal(25, vm.Progress);
|
||||
Assert.False(vm.IsProgressIndeterminate);
|
||||
await vm.SingleAcquireCommand.ExecuteAsync(null);
|
||||
Assert.Equal(25, vm.Progress);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Auto_calibration_requires_pure_water_confirmation(bool confirmed)
|
||||
{
|
||||
var vm = new AcquisitionViewModel();
|
||||
var calls = new List<string>();
|
||||
vm.ConfirmAutoCalibration = _ =>
|
||||
{
|
||||
Assert.True(vm.IsAcquiring);
|
||||
Assert.True(vm.IsProgressIndeterminate);
|
||||
Assert.Equal(0, vm.Progress);
|
||||
calls.Add("confirmation");
|
||||
return Task.FromResult(confirmed);
|
||||
};
|
||||
vm.OnAutoCalibration = _ =>
|
||||
{
|
||||
calls.Add("hardware");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
await vm.AutoCalibrationCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(confirmed ? new[] { "confirmation", "hardware" } : new[] { "confirmation" }, calls);
|
||||
Assert.False(vm.IsAcquiring);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_during_confirmation_does_not_start_hardware()
|
||||
{
|
||||
var vm = new AcquisitionViewModel();
|
||||
bool acquired = false;
|
||||
vm.ConfirmAutoCalibration = _ =>
|
||||
{
|
||||
vm.StopAcquireCommand.Execute(null);
|
||||
return Task.FromResult(true);
|
||||
};
|
||||
vm.OnAutoCalibration = _ => { acquired = true; return Task.CompletedTask; };
|
||||
await vm.AutoCalibrationCommand.ExecuteAsync(null);
|
||||
Assert.False(acquired);
|
||||
Assert.False(vm.IsAcquiring);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Continuous_acquire_marks_session_active_and_runs_cleanup_when_stopped()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
using RamanApp.Models;
|
||||
using RamanApp.Services;
|
||||
using RamanApp.ViewModels;
|
||||
|
||||
namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class AutoCalibrationWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Combined_calibration_collects_each_laser_then_light_and_dark_with_shutter()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
foreach (var laser in fixture.Vm.EasyTtl.Lasers)
|
||||
laser.IsEnabled = true;
|
||||
|
||||
await fixture.Vm.Acquisition.OnAutoCalibration!(CancellationToken.None);
|
||||
|
||||
Assert.Equal(100.0 * 10 / 11, fixture.Vm.Acquisition.Progress, 8);
|
||||
Assert.Equal(new[] { "sample", "sample", "sample", "reference", "reference", "dark", "dark" },
|
||||
fixture.Readings.Select(reading => reading.Target));
|
||||
Assert.All(fixture.Readings.Take(3), reading =>
|
||||
{
|
||||
Assert.True(reading.LaserOn);
|
||||
Assert.False(reading.ShutterOpen);
|
||||
});
|
||||
Assert.All(fixture.Readings.Skip(3), reading =>
|
||||
{
|
||||
Assert.False(reading.LaserOn);
|
||||
Assert.True(reading.ShutterOpen);
|
||||
Assert.Equal(reading.Target == "reference" ? 6 : 5, reading.Slot);
|
||||
});
|
||||
Assert.All(fixture.Vm.EasyTtl.Channels, channel => Assert.False(channel.HardwareEnabled));
|
||||
Assert.Equal(6, fixture.Wheel.CurrentSlot);
|
||||
foreach (var laser in fixture.Vm.EasyTtl.Lasers)
|
||||
Assert.NotNull(fixture.Store.LoadFluorescenceBackground(fixture.BackgroundKey(laser)));
|
||||
|
||||
var stitchedReference = fixture.Store.LoadStitchedAbsorbanceCalibration(fixture.AbsorbanceKey(), dark: false);
|
||||
var stitchedDark = fixture.Store.LoadStitchedAbsorbanceCalibration(fixture.AbsorbanceKey(), dark: true);
|
||||
Assert.NotNull(stitchedReference);
|
||||
Assert.NotNull(stitchedDark);
|
||||
Assert.Equal(350, stitchedReference.Wavelengths[0]);
|
||||
Assert.Equal(1400, stitchedReference.Wavelengths[^1]);
|
||||
|
||||
fixture.Readings.Clear();
|
||||
await fixture.Vm.Acquisition.OnSingleAcquire!(CancellationToken.None);
|
||||
Assert.Equal(100.0 * 8 / 9, fixture.Vm.Acquisition.Progress, 8);
|
||||
Assert.Equal(3, fixture.Vm.RawChart.Series.Count);
|
||||
Assert.Equal("可见/近红外拼接吸光度", Assert.Single(fixture.Vm.AbsorbanceChart.Series).Name);
|
||||
Assert.Equal(3, fixture.Vm.GetExportSeries().Count(series => series.DataType == "Absorbance"));
|
||||
var exportedFluorescence = fixture.Vm.GetExportSeries().Where(series => series.Metadata?.ModuleName == "荧光光谱").ToArray();
|
||||
Assert.Equal(3, exportedFluorescence.Length);
|
||||
Assert.All(exportedFluorescence,
|
||||
series => Assert.Equal("fluorescence-background-corrected", series.Spectrum.Source));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task One_enabled_absorbance_module_still_calibrates_both_devices(bool nearInfraredEnabled)
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
fixture.Vm.DeviceSettings.Fluorescence.AcquireEnabled = false;
|
||||
fixture.Vm.DeviceSettings.NearInfraredAbsorbance.AcquireEnabled = nearInfraredEnabled;
|
||||
fixture.Vm.DeviceSettings.VisibleAbsorbance.AcquireEnabled = !nearInfraredEnabled;
|
||||
|
||||
await fixture.Vm.Acquisition.OnAutoCalibration!(CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { "visible", "nir", "visible", "nir" }, fixture.Readings.Select(reading => reading.Device));
|
||||
Assert.All(fixture.Readings, reading => Assert.Equal(reading.Device == "visible" ? 50 : 100, reading.IntegrationMs));
|
||||
Assert.True(fixture.Store.HasModuleDark("VisibleAbsorbance", 50, "visible"));
|
||||
Assert.True(fixture.Store.HasModuleReference("NearInfraredAbsorbance", 100, "nir"));
|
||||
|
||||
await fixture.Vm.Acquisition.OnSingleAcquire!(CancellationToken.None);
|
||||
var expectedModule = nearInfraredEnabled
|
||||
? fixture.Vm.DeviceSettings.NearInfraredAbsorbance
|
||||
: fixture.Vm.DeviceSettings.VisibleAbsorbance;
|
||||
Assert.Equal($"{expectedModule.DisplayName}-吸光度", Assert.Single(fixture.Vm.AbsorbanceChart.Series).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabling_stitching_keeps_individual_absorbance_curves()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
fixture.Vm.DeviceSettings.Fluorescence.AcquireEnabled = false;
|
||||
fixture.Vm.DataProcessing.AbsorbanceStitchingEnabled = false;
|
||||
await fixture.Vm.Acquisition.OnAutoCalibration!(CancellationToken.None);
|
||||
|
||||
await fixture.Vm.Acquisition.OnSingleAcquire!(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, fixture.Vm.AbsorbanceChart.Series.Count);
|
||||
Assert.DoesNotContain(fixture.Vm.AbsorbanceChart.Series, series => series.Name.Contains("拼接"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fluorescence_only_does_not_move_filter_wheel_or_collect_absorbance()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
fixture.Vm.DeviceSettings.NearInfraredAbsorbance.AcquireEnabled = false;
|
||||
fixture.Vm.DeviceSettings.VisibleAbsorbance.AcquireEnabled = false;
|
||||
await fixture.Vm.Acquisition.OnAutoCalibration!(CancellationToken.None);
|
||||
Assert.Equal("sample", Assert.Single(fixture.Readings).Target);
|
||||
Assert.Null(fixture.Wheel.CurrentSlot);
|
||||
Assert.NotNull(fixture.Store.LoadFluorescenceBackground(fixture.BackgroundKey(fixture.Vm.EasyTtl.Lasers[0])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failure_closes_light_sources_and_does_not_save_partial_calibration()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
fixture.FailureTarget = "dark";
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => fixture.Vm.Acquisition.OnAutoCalibration!(CancellationToken.None));
|
||||
Assert.All(fixture.Vm.EasyTtl.Channels, channel => Assert.False(channel.HardwareEnabled));
|
||||
Assert.False(fixture.Store.HasFluorescenceBackground(fixture.BackgroundKey(fixture.Vm.EasyTtl.Lasers[0])));
|
||||
Assert.False(fixture.Store.HasModuleReference("VisibleAbsorbance", 50, "visible"));
|
||||
Assert.False(fixture.Store.HasModuleDark("NearInfraredAbsorbance", 100, "nir"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_during_calibration_closes_shutter_and_discards_staged_results()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
fixture.OnRead = target => { if (target == "reference") cancellation.Cancel(); };
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => fixture.Vm.Acquisition.OnAutoCalibration!(cancellation.Token));
|
||||
Assert.All(fixture.Vm.EasyTtl.Channels, channel => Assert.False(channel.HardwareEnabled));
|
||||
Assert.False(fixture.Store.HasModuleReference("VisibleAbsorbance", 50, "visible"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Changing_fluorescence_parameters_requires_new_background_before_laser_on()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
fixture.Vm.DeviceSettings.NearInfraredAbsorbance.AcquireEnabled = false;
|
||||
fixture.Vm.DeviceSettings.VisibleAbsorbance.AcquireEnabled = false;
|
||||
await fixture.Vm.Acquisition.OnAutoCalibration!(CancellationToken.None);
|
||||
fixture.Readings.Clear();
|
||||
fixture.Vm.DeviceSettings.Fluorescence.IntegrationTimeMs = 200;
|
||||
|
||||
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => fixture.Vm.Acquisition.OnSingleAcquire!(CancellationToken.None));
|
||||
Assert.Contains("纯水背景", error.Message);
|
||||
Assert.Empty(fixture.Readings);
|
||||
Assert.All(fixture.Vm.EasyTtl.Channels, channel => Assert.False(channel.HardwareEnabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Same_device_mapping_is_rejected_before_any_calibration_acquisition()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
fixture.Vm.DeviceSettings.VisibleAbsorbance.SelectDevice(fixture.Vm.DeviceSettings.NearInfraredAbsorbance.SelectedDevice, false);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => fixture.Vm.Acquisition.OnAutoCalibration!(CancellationToken.None));
|
||||
Assert.Empty(fixture.Readings);
|
||||
}
|
||||
|
||||
private sealed record Reading(string Target, string Device, bool LaserOn, bool ShutterOpen, int? Slot, int IntegrationMs);
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public async Task Display_and_export_stitch_smoothed_segments_without_smoothing_across_seam(bool smoothVisible, bool smoothNear)
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
var vm = fixture.Vm;
|
||||
vm.DeviceSettings.Fluorescence.AcquireEnabled = false;
|
||||
vm.DataProcessing.AbsorbanceAutoSmooth = true;
|
||||
vm.DataProcessing.SmoothVisibleAbsorbance = smoothVisible;
|
||||
vm.DataProcessing.SmoothNearInfraredAbsorbance = smoothNear;
|
||||
vm.DataProcessing.SmoothingMethod = "移动平均";
|
||||
vm.DataProcessing.SmoothingWindowSize = 3;
|
||||
fixture.SampleIntensity = (device, index) => device == "visible"
|
||||
? new double[] { 50, 130, 70, 170 }[index]
|
||||
: new double[] { 170, 60, 140, 90 }[index];
|
||||
await vm.Acquisition.OnAutoCalibration!(CancellationToken.None);
|
||||
await vm.Acquisition.OnSingleAcquire!(CancellationToken.None);
|
||||
|
||||
var exported = vm.GetExportSeries().Where(series => series.DataType == "Absorbance").ToArray();
|
||||
var visible = exported.Single(series => series.Metadata?.ModuleName == vm.DeviceSettings.VisibleAbsorbance.DisplayName);
|
||||
var near = exported.Single(series => series.Metadata?.ModuleName == vm.DeviceSettings.NearInfraredAbsorbance.DisplayName);
|
||||
var stitched = exported.Single(series => series.DeviceName == "可见/近红外拼接");
|
||||
Assert.Equal(smoothVisible, visible.SmoothedSpectrum != null);
|
||||
Assert.Equal(smoothNear, near.SmoothedSpectrum != null);
|
||||
Assert.Equal(smoothVisible || smoothNear, stitched.SmoothedSpectrum != null);
|
||||
var expected = SpectrumProcessor.StitchAbsorbance(
|
||||
visible.SmoothedSpectrum ?? visible.Spectrum, near.SmoothedSpectrum ?? near.Spectrum,
|
||||
vm.DataProcessing.AbsorbanceStitchingWavelengthNm, vm.LaserSettings.LaserWavelengthNm);
|
||||
var output = stitched.SmoothedSpectrum ?? stitched.Spectrum;
|
||||
Assert.Equal(expected.Wavelengths, output.Wavelengths);
|
||||
Assert.Equal(expected.Intensities, output.Intensities);
|
||||
var chart = Assert.Single(vm.AbsorbanceChart.Series);
|
||||
Assert.Equal(expected.Intensities, chart.SmoothedY ?? chart.Y);
|
||||
Assert.False(vm.DataProcessing.ApplySmoothing(stitched.Spectrum)!.Intensities.SequenceEqual(expected.Intensities));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_smooths_only_selected_segment_without_enabling_automatic_smoothing()
|
||||
{
|
||||
using var fixture = new CalibrationFixture();
|
||||
var vm = fixture.Vm;
|
||||
vm.DeviceSettings.Fluorescence.AcquireEnabled = false;
|
||||
vm.DataProcessing.SmoothVisibleAbsorbance = false;
|
||||
vm.DataProcessing.SmoothNearInfraredAbsorbance = true;
|
||||
vm.DataProcessing.SmoothingMethod = "移动平均";
|
||||
vm.DataProcessing.SmoothingWindowSize = 3;
|
||||
fixture.SampleIntensity = (_, index) => new double[] { 50, 130, 70, 170 }[index];
|
||||
await vm.Acquisition.OnAutoCalibration!(CancellationToken.None);
|
||||
await vm.Acquisition.OnSingleAcquire!(CancellationToken.None);
|
||||
var exported = vm.GetExportSeries().Where(series => series.DataType == "Absorbance").ToArray();
|
||||
var visible = exported.Single(series => series.Metadata?.ModuleName == vm.DeviceSettings.VisibleAbsorbance.DisplayName);
|
||||
var near = exported.Single(series => series.Metadata?.ModuleName == vm.DeviceSettings.NearInfraredAbsorbance.DisplayName);
|
||||
|
||||
vm.DataProcessing.PreviewAbsorbanceSmoothingCommand.Execute(null);
|
||||
|
||||
var expected = SpectrumProcessor.StitchAbsorbance(visible.Spectrum,
|
||||
vm.DataProcessing.ApplySmoothing(near.Spectrum)!,
|
||||
vm.DataProcessing.AbsorbanceStitchingWavelengthNm, vm.LaserSettings.LaserWavelengthNm);
|
||||
Assert.Equal(expected.Intensities, Assert.Single(vm.AbsorbanceChart.Series).SmoothedY);
|
||||
Assert.False(vm.DataProcessing.AbsorbanceAutoSmooth);
|
||||
}
|
||||
|
||||
private sealed class CalibrationFixture : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "raman-calibration-tests", Guid.NewGuid().ToString("N"));
|
||||
public MainWindowViewModel Vm { get; }
|
||||
public CalibrationStore Store { get; }
|
||||
public ElloDeviceViewModel Wheel { get; }
|
||||
public List<Reading> Readings { get; } = [];
|
||||
public string? FailureTarget { get; set; }
|
||||
public Action<string>? OnRead { get; set; }
|
||||
public Func<string, int, double>? SampleIntensity { get; set; }
|
||||
|
||||
public CalibrationFixture()
|
||||
{
|
||||
Store = new CalibrationStore(_directory);
|
||||
var nir = new Device(FakeSpectrometerDiscovery.NipproDeviceName, "nir", [900, 1000, 1200, 1400], this);
|
||||
var visible = new Device(FakeSpectrometerDiscovery.Xs11639DeviceName, "visible", [350, 500, 700, 850], this);
|
||||
Vm = new MainWindowViewModel(new Discovery([nir, visible]), 1, Store);
|
||||
Vm.DeviceSettings.Fluorescence.SelectDevice(nir, false);
|
||||
Vm.DeviceSettings.NearInfraredAbsorbance.SelectDevice(nir, false);
|
||||
Vm.DeviceSettings.VisibleAbsorbance.SelectDevice(visible, false);
|
||||
foreach (var module in Vm.DeviceSettings.Modules)
|
||||
{
|
||||
module.AcquireEnabled = true;
|
||||
module.IntegrationTimeMs = module.Kind == SpectroscopyModuleKind.VisibleAbsorbance ? 50 : 100;
|
||||
module.InternalAnalogGainLevel = 0;
|
||||
module.MinWavelengthNm = module.Kind == SpectroscopyModuleKind.VisibleAbsorbance ? 350 : 900;
|
||||
module.MaxWavelengthNm = module.Kind == SpectroscopyModuleKind.VisibleAbsorbance ? 850 : 1400;
|
||||
}
|
||||
Vm.Acquisition.AutoSaveEnabled = false;
|
||||
Vm.DataProcessing.AutoProcessAfterAcquire = false;
|
||||
Vm.DataProcessing.AbsorbanceAutoSmooth = false;
|
||||
Vm.DataProcessing.SmoothVisibleAbsorbance = true;
|
||||
Vm.DataProcessing.SmoothNearInfraredAbsorbance = true;
|
||||
Vm.DataProcessing.OutlierRemovalEnabled = false;
|
||||
Vm.DataProcessing.AbsorbanceInterpolationEnabled = false;
|
||||
Vm.DataProcessing.AbsorbanceStitchingEnabled = true;
|
||||
foreach (var channel in Vm.EasyTtl.Channels)
|
||||
{
|
||||
channel.OpenDelayMs = 0;
|
||||
channel.CloseDelayMs = 0;
|
||||
}
|
||||
for (int index = 0; index < Vm.EasyTtl.Lasers.Count; index++)
|
||||
{
|
||||
Vm.EasyTtl.Lasers[index].SelectedChannel = Vm.EasyTtl.Channels[index];
|
||||
Vm.EasyTtl.Lasers[index].IsEnabled = index == 0;
|
||||
}
|
||||
Vm.EasyTtl.Shutter.SelectedChannel = Vm.EasyTtl.Channels[3];
|
||||
Vm.Ello.Devices.Clear();
|
||||
Wheel = new ElloDeviceViewModel(true, () => { }, new ElloDeviceSettings
|
||||
{
|
||||
Name = "六槽轮", PortName = "SIM-ELLO", Address = "0", SlotCount = 6,
|
||||
});
|
||||
Vm.Ello.Devices.Add(Wheel);
|
||||
}
|
||||
|
||||
public FluorescenceCalibrationKey BackgroundKey(EasyTtlLaserViewModel laser)
|
||||
{
|
||||
var module = Vm.DeviceSettings.Fluorescence;
|
||||
return new("nir", laser.SelectedChannel.Channel, laser.DisplayName, module.ToSettings(), module.MinWavelengthNm, module.MaxWavelengthNm);
|
||||
}
|
||||
|
||||
public AbsorbanceCalibrationKey AbsorbanceKey()
|
||||
{
|
||||
var visible = Vm.DeviceSettings.VisibleAbsorbance;
|
||||
var nir = Vm.DeviceSettings.NearInfraredAbsorbance;
|
||||
return new("visible", visible.ToSettings(), "nir", nir.ToSettings(),
|
||||
visible.MinWavelengthNm, visible.MaxWavelengthNm, nir.MinWavelengthNm, nir.MaxWavelengthNm);
|
||||
}
|
||||
|
||||
public SpectrumData Read(string target, string device, double[] wavelengths, SpectrometerSettings settings)
|
||||
{
|
||||
Readings.Add(new(target, device, Vm.EasyTtl.Lasers.Any(laser => laser.SelectedChannel.HardwareEnabled),
|
||||
Vm.EasyTtl.Shutter.SelectedChannel.HardwareEnabled, Wheel.CurrentSlot, settings.IntegrationTimeMs));
|
||||
if (FailureTarget == target)
|
||||
throw new InvalidOperationException("Injected acquisition failure");
|
||||
OnRead?.Invoke(target);
|
||||
double value = target == "dark" ? 10 : target == "reference" ? 200 : 100;
|
||||
return new SpectrumData(wavelengths, wavelengths.Select((_, index) =>
|
||||
target == "sample" && SampleIntensity != null ? SampleIntensity(device, index) : value).ToArray(), device);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Vm.Dispose();
|
||||
if (Directory.Exists(_directory))
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Discovery(IReadOnlyList<ISpectrometerDevice> devices) : ISpectrometerDiscovery
|
||||
{
|
||||
public IReadOnlyList<ISpectrometerDevice> DiscoverDevices() => devices;
|
||||
}
|
||||
|
||||
private sealed class Device(string name, string serial, double[] wavelengths, CalibrationFixture fixture) : ISpectrometerDevice
|
||||
{
|
||||
public string Name => name;
|
||||
public string SerialNumber => serial;
|
||||
public int PixelCount => wavelengths.Length;
|
||||
public double[] GetWavelengths() => wavelengths;
|
||||
public Task<SpectrumData> AcquireAsync(SpectrometerSettings settings, CancellationToken ct = default)
|
||||
=> Task.FromResult(fixture.Read("sample", serial, wavelengths, settings));
|
||||
public Task<SpectrumData> AcquireDarkAsync(SpectrometerSettings settings, CancellationToken ct = default)
|
||||
=> Task.FromResult(fixture.Read("dark", serial, wavelengths, settings));
|
||||
public Task<SpectrumData> AcquireReferenceAsync(SpectrometerSettings settings, CancellationToken ct = default)
|
||||
=> Task.FromResult(fixture.Read("reference", serial, wavelengths, settings));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,70 @@ namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class CalibrationStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Stitched_calibration_ignores_average_count_but_preserves_integration_time_matching()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
var store = new CalibrationStore(dir);
|
||||
var key = new AbsorbanceCalibrationKey(
|
||||
"visible", new SpectrometerSettings(100, 3, 0, false, 0),
|
||||
"nir", new SpectrometerSettings(200, 5, 0, false, 0),
|
||||
350, 1000, 900, 1686);
|
||||
store.SaveStitchedAbsorbanceCalibration(key,
|
||||
new SpectrumData([950], [100]), new SpectrumData([950], [10]));
|
||||
var changedCount = key with
|
||||
{
|
||||
VisibleSettings = key.VisibleSettings with { AverageCount = 7 },
|
||||
NearInfraredSettings = key.NearInfraredSettings with { AverageCount = 9 },
|
||||
};
|
||||
|
||||
Assert.Equal(100, store.LoadStitchedAbsorbanceCalibration(changedCount, false)!.Intensities[0]);
|
||||
Assert.Equal(10, store.LoadStitchedAbsorbanceCalibration(changedCount, true)!.Intensities[0]);
|
||||
var changedTime = changedCount with
|
||||
{
|
||||
VisibleSettings = changedCount.VisibleSettings with { IntegrationTimeMs = 300 },
|
||||
};
|
||||
Assert.Null(store.LoadStitchedAbsorbanceCalibration(changedTime, false));
|
||||
Assert.Null(store.LoadStitchedAbsorbanceCalibration(changedTime, true));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteIfExists(dir);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Module_calibration_uses_integration_time_without_average_count()
|
||||
{
|
||||
var dir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
var store = new CalibrationStore(dir);
|
||||
var dark = new SpectrumData([500], [10]);
|
||||
var reference = new SpectrumData([500], [100]);
|
||||
store.SaveModuleDark("VisibleAbsorbance", 100, "device-1", dark);
|
||||
store.SaveModuleReference("VisibleAbsorbance", 100, "device-1", reference);
|
||||
|
||||
Assert.Equal(10, store.LoadModuleDark("VisibleAbsorbance", 100, "device-1")!.Intensities[0]);
|
||||
Assert.Equal(100, store.LoadModuleReference("VisibleAbsorbance", 100, "device-1")!.Intensities[0]);
|
||||
Assert.True(store.HasModuleDark("VisibleAbsorbance", 100, "device-1"));
|
||||
Assert.True(store.HasModuleReference("VisibleAbsorbance", 100, "device-1"));
|
||||
Assert.Null(store.LoadModuleDark("VisibleAbsorbance", 200, "device-1"));
|
||||
Assert.Null(store.LoadModuleReference("VisibleAbsorbance", 200, "device-1"));
|
||||
Assert.False(store.HasModuleDark("VisibleAbsorbance", 100, "device-2"));
|
||||
Assert.False(store.HasModuleReference("VisibleAbsorbance", 100, "device-1", 1));
|
||||
Assert.True(File.Exists(Path.Combine(dir, "modules", "VisibleAbsorbance", "100000us", "gain0", "device-1", "dark.csv")));
|
||||
Assert.DoesNotContain(Directory.GetDirectories(dir, "*", SearchOption.AllDirectories),
|
||||
path => Path.GetFileName(path).EndsWith("avg"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteIfExists(dir);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Module_calibrations_are_isolated_for_the_same_device_and_settings()
|
||||
{
|
||||
@@ -15,13 +79,13 @@ public sealed class CalibrationStoreTests
|
||||
var fluorescence = new SpectrumData([900, 901], [10, 11]);
|
||||
var nearInfrared = new SpectrumData([900, 901], [20, 21]);
|
||||
|
||||
store.SaveModuleDark("Fluorescence", 100, 1, "NIPPRO", fluorescence);
|
||||
store.SaveModuleDark("NearInfraredAbsorbance", 100, 1, "NIPPRO", nearInfrared);
|
||||
store.SaveModuleDark("Fluorescence", 100, "NIPPRO", fluorescence);
|
||||
store.SaveModuleDark("NearInfraredAbsorbance", 100, "NIPPRO", nearInfrared);
|
||||
|
||||
Assert.Equal(10, store.LoadModuleDark(
|
||||
"Fluorescence", 100, 1, "NIPPRO")!.Intensities[0]);
|
||||
"Fluorescence", 100, "NIPPRO")!.Intensities[0]);
|
||||
Assert.Equal(20, store.LoadModuleDark(
|
||||
"NearInfraredAbsorbance", 100, 1, "NIPPRO")!.Intensities[0]);
|
||||
"NearInfraredAbsorbance", 100, "NIPPRO")!.Intensities[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -5,6 +5,30 @@ namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class DataProcessingViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Selected_smoothing_segments_are_saved_independently()
|
||||
{
|
||||
var path = CreateTempSettingsPath();
|
||||
try
|
||||
{
|
||||
var vm = new DataProcessingViewModel(new DataProcessingSettingsStore(path));
|
||||
vm.SmoothVisibleAbsorbance = false;
|
||||
vm.SmoothNearInfraredAbsorbance = true;
|
||||
var loaded = new DataProcessingViewModel(new DataProcessingSettingsStore(path));
|
||||
Assert.False(loaded.SmoothVisibleAbsorbance);
|
||||
Assert.True(loaded.SmoothNearInfraredAbsorbance);
|
||||
loaded.SmoothVisibleAbsorbance = true;
|
||||
loaded.SmoothNearInfraredAbsorbance = false;
|
||||
var reloaded = new DataProcessingViewModel(new DataProcessingSettingsStore(path));
|
||||
Assert.True(reloaded.SmoothVisibleAbsorbance);
|
||||
Assert.False(reloaded.SmoothNearInfraredAbsorbance);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteIfExists(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Absorbance_processing_defaults_match_verified_pipeline()
|
||||
{
|
||||
|
||||
@@ -5,6 +5,26 @@ namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class DeviceSettingsViewModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(3, 3)]
|
||||
[InlineData(1000, 1000)]
|
||||
[InlineData(70000, 65535)]
|
||||
public void Average_count_is_loaded_clamped_and_used_for_acquisition(int savedAverageCount, int expected)
|
||||
{
|
||||
var module = CreateModule();
|
||||
module.ApplySettings(new SpectroscopyModuleSettingsFile
|
||||
{
|
||||
IntegrationTimeMs = 250,
|
||||
AverageCount = savedAverageCount,
|
||||
});
|
||||
|
||||
Assert.Equal(expected, module.AverageCount);
|
||||
Assert.Equal(expected, module.ToSettings().AverageCount);
|
||||
Assert.Equal(expected, module.ToSettingsFile().AverageCount);
|
||||
Assert.Equal(250, module.ToSettings().IntegrationTimeMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fake_discovery_exposes_the_two_realistic_device_identities_and_ranges()
|
||||
{
|
||||
@@ -144,14 +164,18 @@ public sealed class DeviceSettingsViewModelTests
|
||||
first.Fluorescence.AverageCount = 3;
|
||||
first.NearInfraredAbsorbance.SelectedDeviceName = FakeSpectrometerDiscovery.NipproDeviceName;
|
||||
first.NearInfraredAbsorbance.IntegrationTimeMs = 800;
|
||||
first.NearInfraredAbsorbance.AverageCount = 5;
|
||||
first.VisibleAbsorbance.SelectedDeviceName = FakeSpectrometerDiscovery.Xs11639DeviceName;
|
||||
first.VisibleAbsorbance.AcquireEnabled = false;
|
||||
first.VisibleAbsorbance.AverageCount = 7;
|
||||
|
||||
var second = new DeviceSettingsViewModel(new SpectrometerSettingsStore(path));
|
||||
second.PopulateDevices(devices);
|
||||
|
||||
Assert.Equal(250, second.Fluorescence.IntegrationTimeMs);
|
||||
Assert.Equal(3, second.Fluorescence.AverageCount);
|
||||
Assert.Equal(5, second.NearInfraredAbsorbance.AverageCount);
|
||||
Assert.Equal(7, second.VisibleAbsorbance.AverageCount);
|
||||
Assert.Equal(800, second.NearInfraredAbsorbance.IntegrationTimeMs);
|
||||
Assert.False(second.VisibleAbsorbance.AcquireEnabled);
|
||||
Assert.Equal(FakeSpectrometerDiscovery.Xs11639DeviceName,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using RamanApp.Models;
|
||||
using RamanApp.Services;
|
||||
|
||||
namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class FluorescenceBackgroundTests
|
||||
{
|
||||
[Fact]
|
||||
public void Saved_background_covers_original_sample_at_one_decimal_precision()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "raman-background-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var store = new CalibrationStore(directory);
|
||||
var key = new FluorescenceCalibrationKey("nir", 1, "532", new(100, 1, 0, false, 1), 850, 1400);
|
||||
var background = new SpectrumData([850.123456789, 1398.53654321], [12, 20]);
|
||||
store.SaveFluorescenceBackground(key, background);
|
||||
var loaded = new CalibrationStore(directory).LoadFluorescenceBackground(key)!;
|
||||
var corrected = SpectrumProcessor.SubtractFluorescenceBackground(background, loaded);
|
||||
|
||||
Assert.Equal(background.Wavelengths, corrected.Wavelengths);
|
||||
Assert.All(corrected.Intensities, value => Assert.Equal(0, value));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Four_decimal_background_rounding_does_not_reject_matching_endpoints()
|
||||
{
|
||||
var sample = new SpectrumData([850.123456789, 1398.53654321], [12, 20]);
|
||||
var background = new SpectrumData([850.1235, 1398.5365], [12, 20]);
|
||||
|
||||
var corrected = SpectrumProcessor.SubtractFluorescenceBackground(sample, background);
|
||||
|
||||
Assert.Equal(sample.Wavelengths, corrected.Wavelengths);
|
||||
Assert.All(corrected.Intensities, value => Assert.Equal(0, value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(850.16, 1398.5365)]
|
||||
[InlineData(850.1235, 1398.44)]
|
||||
public void Missing_coverage_at_one_decimal_precision_is_rejected(double start, double end)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => SpectrumProcessor.SubtractFluorescenceBackground(
|
||||
new SpectrumData([850.123456789, 1398.53654321], [12, 20]),
|
||||
new SpectrumData([start, end], [12, 20])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coverage_uses_one_decimal_without_rounding_subtraction_data()
|
||||
{
|
||||
var sample = new SpectrumData([850.11, 900.125, 1398.54], [12.123, 20.456, 30.789]);
|
||||
var background = new SpectrumData([850.14, 900.125, 1398.51], [2.123, 3.456, 4.789]);
|
||||
|
||||
var corrected = SpectrumProcessor.SubtractFluorescenceBackground(sample, background);
|
||||
|
||||
Assert.Equal(sample.Wavelengths, corrected.Wavelengths);
|
||||
Assert.Equal(10, corrected.Intensities[0], 10);
|
||||
Assert.Equal(17, corrected.Intensities[1], 10);
|
||||
Assert.Equal(26, corrected.Intensities[2], 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Background_subtraction_interpolates_and_preserves_negative_values()
|
||||
{
|
||||
var sample = new SpectrumData([500, 501, 502], [10, 15, 30]);
|
||||
var background = new SpectrumData([500, 502], [12, 20]);
|
||||
var corrected = SpectrumProcessor.SubtractFluorescenceBackground(sample, background);
|
||||
|
||||
Assert.Equal(new double[] { -2, -1, 10 }, corrected.Intensities);
|
||||
Assert.Equal(new double[] { 10, 15, 30 }, sample.Intensities);
|
||||
Assert.Equal(sample.Wavelengths, corrected.Wavelengths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Background_must_cover_entire_sample_wavelength_range()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => SpectrumProcessor.SubtractFluorescenceBackground(
|
||||
new SpectrumData([500, 502], [10, 20]), new SpectrumData([501, 502], [1, 2])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Background_is_isolated_by_device_laser_and_acquisition_settings()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "raman-background-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var store = new CalibrationStore(directory);
|
||||
var key = new FluorescenceCalibrationKey("nir", 1, "532", new(100, 1, 0, false, 1), 900, 1400);
|
||||
store.SaveFluorescenceBackground(key, new SpectrumData([900, 1400], [10, 20]));
|
||||
|
||||
Assert.Equal(10, new CalibrationStore(directory).LoadFluorescenceBackground(key)!.Intensities[0]);
|
||||
Assert.False(store.HasFluorescenceBackground(key with { DeviceSerialNumber = "other" }));
|
||||
Assert.False(store.HasFluorescenceBackground(key with { LaserChannel = 2 }));
|
||||
Assert.False(store.HasFluorescenceBackground(key with { LaserWavelength = "577" }));
|
||||
Assert.False(store.HasFluorescenceBackground(key with { Settings = key.Settings with { IntegrationTimeMs = 200 } }));
|
||||
Assert.False(store.HasFluorescenceBackground(key with { Settings = key.Settings with { InternalAnalogGainLevel = 0 } }));
|
||||
Assert.False(store.HasFluorescenceBackground(key with { Settings = key.Settings with { BoxcarWidth = 3 } }));
|
||||
Assert.False(store.HasFluorescenceBackground(key with { MaxWavelengthNm = 1500 }));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,16 @@ namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class ModuleAcquisitionFlowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Fluorescence_uses_selected_laser_then_absorbance_modules_run_once()
|
||||
[Theory]
|
||||
[InlineData(true, true, true)]
|
||||
[InlineData(true, true, false)]
|
||||
[InlineData(true, false, true)]
|
||||
[InlineData(true, false, false)]
|
||||
[InlineData(false, true, true)]
|
||||
[InlineData(false, true, false)]
|
||||
[InlineData(false, false, true)]
|
||||
public async Task Upper_chart_prefers_fluorescence_without_skipping_absorbance_acquisition(
|
||||
bool fluorescence, bool nearInfrared, bool visible)
|
||||
{
|
||||
var calls = new List<(string Device, bool LaserOn)>();
|
||||
var probe = new LaserProbe();
|
||||
@@ -17,7 +25,16 @@ public sealed class ModuleAcquisitionFlowTests
|
||||
new TrackingDevice(FakeSpectrometerDiscovery.Xs11639DeviceName, "XS-1", 350, 1000, calls, probe),
|
||||
]);
|
||||
using var vm = new MainWindowViewModel(discovery, simulateIndex: 1);
|
||||
probe.IsAnyLaserOn = () => vm.EasyTtl.Channels.Any(channel => channel.HardwareEnabled);
|
||||
vm.DeviceSettings.Fluorescence.AcquireEnabled = fluorescence;
|
||||
vm.DeviceSettings.NearInfraredAbsorbance.AcquireEnabled = nearInfrared;
|
||||
vm.DeviceSettings.VisibleAbsorbance.AcquireEnabled = visible;
|
||||
vm.Acquisition.AutoSaveEnabled = false;
|
||||
probe.IsAnyLaserOn = () => vm.EasyTtl.Lasers.Any(laser => laser.SelectedChannel.HardwareEnabled);
|
||||
foreach (var channel in vm.EasyTtl.Channels)
|
||||
{
|
||||
channel.OpenDelayMs = 0;
|
||||
channel.CloseDelayMs = 0;
|
||||
}
|
||||
|
||||
foreach (var item in vm.EasyTtl.Lasers)
|
||||
item.IsEnabled = false;
|
||||
@@ -26,19 +43,32 @@ public sealed class ModuleAcquisitionFlowTests
|
||||
laser.SelectedChannel.CloseDelayMs = 0;
|
||||
laser.IsEnabled = true;
|
||||
|
||||
await vm.Acquisition.OnAutoCalibration!(CancellationToken.None);
|
||||
calls.Clear();
|
||||
await vm.Acquisition.SingleAcquireCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
(FakeSpectrometerDiscovery.NipproDeviceName, true),
|
||||
(FakeSpectrometerDiscovery.NipproDeviceName, false),
|
||||
(FakeSpectrometerDiscovery.Xs11639DeviceName, false),
|
||||
], calls);
|
||||
var expectedCalls = new List<(string Device, bool LaserOn)>();
|
||||
if (fluorescence)
|
||||
expectedCalls.Add((FakeSpectrometerDiscovery.NipproDeviceName, true));
|
||||
if (nearInfrared)
|
||||
expectedCalls.Add((FakeSpectrometerDiscovery.NipproDeviceName, false));
|
||||
if (visible)
|
||||
expectedCalls.Add((FakeSpectrometerDiscovery.Xs11639DeviceName, false));
|
||||
Assert.Equal(expectedCalls, calls);
|
||||
Assert.All(vm.EasyTtl.Channels, channel => Assert.False(channel.HardwareEnabled));
|
||||
Assert.Collection(vm.RawChart.Series,
|
||||
item => Assert.Contains("532-荧光光谱", item.Name),
|
||||
item => Assert.Equal("近红外吸收光谱", item.Name),
|
||||
item => Assert.Equal("可见光吸收光谱", item.Name));
|
||||
if (fluorescence)
|
||||
{
|
||||
Assert.Contains("532-荧光光谱", Assert.Single(vm.RawChart.Series).Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
var expectedNames = new List<string>();
|
||||
if (nearInfrared)
|
||||
expectedNames.Add("近红外吸收光谱");
|
||||
if (visible)
|
||||
expectedNames.Add("可见光吸收光谱");
|
||||
Assert.Equal(expectedNames, vm.RawChart.Series.Select(series => series.Name));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackingDiscovery(IReadOnlyList<ISpectrometerDevice> devices)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using RamanApp.Models;
|
||||
using RamanApp.ViewModels;
|
||||
|
||||
namespace Raman.TUI.Tests.App;
|
||||
|
||||
public sealed class SpectrumChartViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Combined_x_range_covers_both_charts_with_independent_intensities()
|
||||
{
|
||||
var upper = new SpectrumChartViewModel();
|
||||
var lower = new SpectrumChartViewModel();
|
||||
upper.SetData("fluorescence", [850, 1400], [1000, 60000]);
|
||||
lower.SetData("absorbance", [350, 1100], [0.1, 0.5]);
|
||||
|
||||
Assert.Equal((297.5, 1452.5), SpectrumChartViewModel.GetCombinedXRange(upper, lower)!.Value);
|
||||
Assert.Equal((297.5, 1452.5), SpectrumChartViewModel.GetCombinedXRange(lower, upper)!.Value);
|
||||
|
||||
lower.Clear();
|
||||
|
||||
Assert.Equal((822.5, 1427.5), SpectrumChartViewModel.GetCombinedXRange(upper, lower)!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combined_x_range_uses_lower_chart_when_upper_is_empty()
|
||||
{
|
||||
var upper = new SpectrumChartViewModel();
|
||||
var lower = new SpectrumChartViewModel();
|
||||
lower.SetData("absorbance", [400, 800], [0.1, 0.5]);
|
||||
Assert.Equal((380d, 820d), SpectrumChartViewModel.GetCombinedXRange(upper, lower)!.Value);
|
||||
lower.Clear();
|
||||
Assert.Null(SpectrumChartViewModel.GetCombinedXRange(upper, lower));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combined_x_range_ignores_hidden_and_invalid_data()
|
||||
{
|
||||
var chart = new SpectrumChartViewModel();
|
||||
chart.SetData(
|
||||
new ChartSeries("visible", [500, 600, 2000, double.NaN], [1, 2, double.NaN, 1], "#1E90FF"),
|
||||
new ChartSeries("hidden", [100, 3000], [1, 2], "#1E90FF"));
|
||||
chart.SeriesVisibility.Single(item => item.Name == "hidden").IsVisible = false;
|
||||
Assert.Equal((495d, 605d), SpectrumChartViewModel.GetCombinedXRange(chart)!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Combined_x_range_uses_display_coordinates_and_handles_single_point()
|
||||
{
|
||||
var chart = new SpectrumChartViewModel { XAxisMode = XAxisMode.Pixel };
|
||||
chart.SetData("sample", [500, 600, 700], [1, 2, 3]);
|
||||
Assert.Equal((-0.1, 2.1), SpectrumChartViewModel.GetCombinedXRange(chart)!.Value);
|
||||
chart.SetData("sample", [500], [1]);
|
||||
Assert.Equal((-1d, 1d), SpectrumChartViewModel.GetCombinedXRange(chart)!.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Each_new_acquisition_requests_auto_scale_before_notifying_view(bool multipleSeries)
|
||||
{
|
||||
var chart = new SpectrumChartViewModel();
|
||||
chart.ConsumeAutoScaleRequest();
|
||||
int notifications = 0;
|
||||
chart.PropertyChanged += (_, change) =>
|
||||
{
|
||||
if (change.PropertyName == nameof(SpectrumChartViewModel.DataVersion))
|
||||
{
|
||||
Assert.True(chart.AutoScaleRequested);
|
||||
notifications++;
|
||||
}
|
||||
};
|
||||
|
||||
foreach (double intensity in new[] { 60000d, 10d })
|
||||
{
|
||||
if (multipleSeries)
|
||||
chart.SetData(new ChartSeries("sample", [500, 501], [intensity, intensity / 2], "#1E90FF"));
|
||||
else
|
||||
chart.SetData("sample", [500, 501], [intensity, intensity / 2]);
|
||||
|
||||
Assert.True(chart.ConsumeAutoScaleRequest());
|
||||
Assert.False(chart.ConsumeAutoScaleRequest());
|
||||
}
|
||||
|
||||
Assert.Equal(2, notifications);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("custom")]
|
||||
[InlineData("fixed")]
|
||||
[InlineData("fixedY")]
|
||||
public void New_data_preserves_explicit_axis_ranges(string rangeMode)
|
||||
{
|
||||
var chart = new SpectrumChartViewModel();
|
||||
if (rangeMode == "custom")
|
||||
{
|
||||
chart.UseCustomRange = true;
|
||||
chart.CustomXMin = 500;
|
||||
chart.CustomXMax = 600;
|
||||
chart.CustomYMin = 0;
|
||||
chart.CustomYMax = 100;
|
||||
}
|
||||
else if (rangeMode == "fixed")
|
||||
{
|
||||
chart.RequestFixedRange(500, 600, 0, 100);
|
||||
}
|
||||
else
|
||||
{
|
||||
chart.RequestFixedYRange(0, 100);
|
||||
}
|
||||
|
||||
chart.SetData("sample", [500, 501], [10, 20]);
|
||||
|
||||
Assert.False(chart.ConsumeAutoScaleRequest());
|
||||
Assert.Equal(rangeMode == "custom", chart.UseCustomRange);
|
||||
Assert.Equal(rangeMode == "fixed", chart.UseFixedXRange);
|
||||
Assert.Equal(rangeMode != "custom", chart.UseFixedYRange);
|
||||
Assert.Equal(100, rangeMode == "custom" ? chart.CustomYMax : chart.FixedYMax);
|
||||
|
||||
chart.RequestAutoScale();
|
||||
Assert.False(chart.UseCustomRange);
|
||||
Assert.False(chart.UseFixedXRange);
|
||||
Assert.False(chart.UseFixedYRange);
|
||||
Assert.True(chart.ConsumeAutoScaleRequest());
|
||||
|
||||
chart.SetData("sample", [500, 501], [1, 2]);
|
||||
Assert.True(chart.ConsumeAutoScaleRequest());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user