完善自动校准、光谱处理和采集参数设置

This commit is contained in:
2026-10-20 20:36:05 +08:00 Unverified
parent fd43f07063
commit 090ffae89b
33 changed files with 1651 additions and 238 deletions
+6
View File
@@ -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.
+55
View File
@@ -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 重新编译启动;未经验证不要直接恢复用于正式测量。
+55
View File
@@ -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
View File
@@ -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
+5 -3
View File
@@ -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}" />
+16
View File
@@ -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
+19
View File
@@ -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);
+69 -26
View File
@@ -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);
+61 -47
View File
@@ -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);
}
}
+121 -132
View File
@@ -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++;
}
+8 -2
View File
@@ -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}" />
+3 -3
View File
@@ -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="荧光:按已启用的激光通道采集纯水背景。&#10;吸收:任一吸收模块启用时,采集两台光谱仪的明谱(第6档)和暗谱(第5档)。&#10;&#10;确认后将自动控制激光、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);
}
+33 -2
View File
@@ -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());
}
}