feat: 基础版本

This commit is contained in:
chuan
2026-06-28 15:34:31 +08:00 Unverified
parent d4f0dd8685
commit febeda21c7
16 changed files with 1992 additions and 134 deletions
+2
View File
@@ -1,3 +1,5 @@
tmp/
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
+36 -1
View File
@@ -1,3 +1,5 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -16,9 +18,42 @@ public partial class App : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
int simulateIndex = ParseSimulateArg(desktop.Args);
var noiseRange = ParseRangeArg(desktop.Args);
desktop.MainWindow = new MainWindow(simulateIndex, noiseRange);
}
base.OnFrameworkInitializationCompleted();
}
private static int ParseSimulateArg(string[]? args)
{
if (args == null) return -1;
for (int i = 0; i < args.Length; i++)
{
if (string.Equals(args[i], "--simulate", StringComparison.OrdinalIgnoreCase))
{
if (i + 1 < args.Length && int.TryParse(args[i + 1], out int idx) && idx >= 1 && idx <= 4)
return idx;
return 1;
}
}
return -1;
}
private static (double Lo, double Hi)? ParseRangeArg(string[]? args)
{
if (args == null) return null;
for (int i = 0; i < args.Length; i++)
{
if (string.Equals(args[i], "--range", StringComparison.OrdinalIgnoreCase)
&& i + 2 < args.Length
&& double.TryParse(args[i + 1], System.Globalization.CultureInfo.InvariantCulture, out double lo)
&& double.TryParse(args[i + 2], System.Globalization.CultureInfo.InvariantCulture, out double hi))
{
return (lo, hi);
}
}
return null;
}
}
+9 -3
View File
@@ -206,7 +206,7 @@
<Border Width="1" Background="{DynamicResource SemiColorBorder}" Margin="4,4" />
<!-- 快速保存 -->
<Button Classes="tool-btn">
<Button Classes="tool-btn" Click="OnQuickSaveClick">
<StackPanel Spacing="2">
<PathIcon Foreground="#2196F3"
Data="M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z" />
@@ -215,7 +215,7 @@
</Button>
<!-- 导出光谱数据 -->
<Button Classes="tool-btn">
<Button Classes="tool-btn" Click="OnExportClick">
<StackPanel Spacing="2">
<PathIcon Foreground="#FF5722"
Data="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20M12,19L8,15H10.5V12H13.5V15H16L12,19Z" />
@@ -224,7 +224,7 @@
</Button>
<!-- 导入光谱数据 -->
<Button Classes="tool-btn">
<Button Classes="tool-btn" Click="OnImportClick">
<StackPanel Spacing="2">
<PathIcon Foreground="#3F51B5"
Data="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20M12,12L16,16H13.5V19H10.5V16H8L12,12Z" />
@@ -253,6 +253,9 @@
<Border Width="1" Background="{DynamicResource SemiColorBorder}" Margin="2,1" />
<TextBlock FontSize="10" Foreground="Gray" VerticalAlignment="Center"
Text="{Binding DeviceSettings.Spectrometer1.AverageCount, StringFormat=平均: {0}次}" />
<Border Width="1" Background="{DynamicResource SemiColorBorder}" Margin="2,1" />
<TextBlock FontSize="10" Foreground="#FF9800" VerticalAlignment="Center"
Text="{Binding CalibrationStatus}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock FontSize="10" Foreground="Gray" VerticalAlignment="Center"
@@ -305,6 +308,9 @@
<TabItem Header="合并光谱">
<views:SpectrumChartView DataContext="{Binding MergedChart}" />
</TabItem>
<TabItem Header="吸光度">
<views:SpectrumChartView DataContext="{Binding AbsorbanceChart}" />
</TabItem>
</TabControl>
<!-- GridSplitter between center and right panel -->
+9 -2
View File
@@ -12,10 +12,12 @@ namespace RamanApp;
public partial class MainWindow : Window
{
public MainWindow()
public MainWindow() : this(-1, null) { }
public MainWindow(int simulateIndex, (double Lo, double Hi)? noiseRange = null)
{
InitializeComponent();
DataContext = new MainWindowViewModel();
DataContext = new MainWindowViewModel(simulateIndex, noiseRange);
}
private MainWindowViewModel Vm => (MainWindowViewModel)DataContext!;
@@ -181,6 +183,11 @@ public partial class MainWindow : Window
}
}
private void OnQuickSaveClick(object? sender, RoutedEventArgs e)
{
Vm.QuickSave();
}
private void OnAutoScaleClick(object? sender, RoutedEventArgs e)
{
var avaPlot = FindActiveAvaPlot();
+4
View File
@@ -8,6 +8,10 @@
<!--<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>-->
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="SampleData\sample_spectra.csv" LogicalName="RamanApp.SampleData.sample_spectra.csv" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.17" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.17" />
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using RamanApp.Models;
namespace RamanApp.Services;
public class CalibrationStore
{
private readonly string _baseDir;
public CalibrationStore(string? baseDir = null)
{
_baseDir = baseDir ?? Path.Combine(AppContext.BaseDirectory, "calibration");
}
public void SaveDark(int integrationTimeMs, string deviceId, SpectrumData spectrum)
{
var dir = GetDeviceDir(integrationTimeMs, deviceId);
Directory.CreateDirectory(dir);
SpectrumFileService.ExportCsv(Path.Combine(dir, "dark.csv"), spectrum, 0);
}
public void SaveReference(int integrationTimeMs, string deviceId, SpectrumData spectrum)
{
var dir = GetDeviceDir(integrationTimeMs, deviceId);
Directory.CreateDirectory(dir);
SpectrumFileService.ExportCsv(Path.Combine(dir, "reference.csv"), spectrum, 0);
}
public SpectrumData? LoadDark(int integrationTimeMs, string deviceId)
{
var path = Path.Combine(GetDeviceDir(integrationTimeMs, deviceId), "dark.csv");
if (!File.Exists(path)) return null;
return SpectrumFileService.ImportCsv(path);
}
public SpectrumData? LoadReference(int integrationTimeMs, string deviceId)
{
var path = Path.Combine(GetDeviceDir(integrationTimeMs, deviceId), "reference.csv");
if (!File.Exists(path)) return null;
return SpectrumFileService.ImportCsv(path);
}
public bool HasCalibration(int integrationTimeMs, string deviceId)
{
var dir = GetDeviceDir(integrationTimeMs, deviceId);
return File.Exists(Path.Combine(dir, "dark.csv"))
&& File.Exists(Path.Combine(dir, "reference.csv"));
}
public bool HasDark(int integrationTimeMs, string deviceId)
{
return File.Exists(Path.Combine(GetDeviceDir(integrationTimeMs, deviceId), "dark.csv"));
}
public bool HasReference(int integrationTimeMs, string deviceId)
{
return File.Exists(Path.Combine(GetDeviceDir(integrationTimeMs, deviceId), "reference.csv"));
}
public List<int> GetAvailableIntegrationTimes()
{
if (!Directory.Exists(_baseDir))
return [];
return Directory.GetDirectories(_baseDir)
.Select(d => Path.GetFileName(d))
.Where(name => name.EndsWith("ms"))
.Select(name => name[..^2])
.Where(n => int.TryParse(n, out _))
.Select(int.Parse)
.OrderBy(t => t)
.ToList();
}
public List<string> GetCalibratedDevices(int integrationTimeMs)
{
var itDir = Path.Combine(_baseDir, $"{integrationTimeMs}ms");
if (!Directory.Exists(itDir))
return [];
return Directory.GetDirectories(itDir)
.Where(d => File.Exists(Path.Combine(d, "dark.csv"))
&& File.Exists(Path.Combine(d, "reference.csv")))
.Select(Path.GetFileName)
.Where(n => n != null)
.Cast<string>()
.ToList();
}
private string GetDeviceDir(int integrationTimeMs, string deviceId)
{
var safeName = string.Join("_", deviceId.Split(Path.GetInvalidFileNameChars()));
return Path.Combine(_baseDir, $"{integrationTimeMs}ms", safeName);
}
}
+91 -90
View File
@@ -6,6 +6,13 @@ using RamanApp.Models;
namespace RamanApp.Services;
internal enum AcquisitionTarget
{
Sample,
Dark,
Reference,
}
public class FakeSpectrometerDevice : ISpectrometerDevice
{
public string Name { get; }
@@ -14,27 +21,22 @@ public class FakeSpectrometerDevice : ISpectrometerDevice
private readonly double _startWavelength;
private readonly double _endWavelength;
private readonly double _laserWavelength;
private readonly Random _rng = new();
private readonly (double Wavenumber, double Intensity, double Width)[] _peaks;
private static readonly (double Wavenumber, double Intensity, double Width)[] SubstratePeaks =
[
(430, 2000, 40), // SiO2 broad band
(800, 1500, 30), // SiO2 ring breathing
(1060, 1000, 35), // SiO2 asymmetric stretch
];
private readonly int _sampleIndex;
private readonly double _noiseLo;
private readonly double _noiseHi;
public FakeSpectrometerDevice(string name, string serial,
double startNm, double endNm, double laserNm,
(double Wavenumber, double Intensity, double Width)[] peaks)
double startNm, double endNm, int sampleIndex = 0,
double noiseLo = 0, double noiseHi = 0)
{
Name = name;
SerialNumber = serial;
_startWavelength = startNm;
_endWavelength = endNm;
_laserWavelength = laserNm;
_peaks = peaks;
_sampleIndex = sampleIndex;
_noiseLo = noiseLo;
_noiseHi = noiseHi;
}
public double[] GetWavelengths()
@@ -46,68 +48,81 @@ public class FakeSpectrometerDevice : ISpectrometerDevice
return w;
}
public async Task<SpectrumData> AcquireAsync(SpectrometerSettings settings, CancellationToken ct = default)
public Task<SpectrumData> AcquireAsync(SpectrometerSettings settings, CancellationToken ct = default)
=> AcquireCoreAsync(settings, AcquisitionTarget.Sample, ct);
public Task<SpectrumData> AcquireDarkAsync(SpectrometerSettings settings, CancellationToken ct = default)
=> AcquireCoreAsync(settings, AcquisitionTarget.Dark, ct);
public Task<SpectrumData> AcquireReferenceAsync(SpectrometerSettings settings, CancellationToken ct = default)
=> AcquireCoreAsync(settings, AcquisitionTarget.Reference, ct);
private async Task<SpectrumData> AcquireCoreAsync(SpectrometerSettings settings,
AcquisitionTarget target, CancellationToken ct)
{
int delayMs = Math.Max(settings.IntegrationTimeMs * settings.AverageCount / 10, 100);
int delayMs = Math.Max(settings.IntegrationTimeMs * settings.AverageCount / 10, 50);
await Task.Delay(delayMs, ct);
var wavelengths = GetWavelengths();
var intensities = GenerateSpectrum(wavelengths, settings);
return new SpectrumData(wavelengths, intensities, Name);
}
var provider = SampleDataProvider.Instance;
double itScale = settings.IntegrationTimeMs / 100.0;
double[] source = target switch
{
AcquisitionTarget.Dark => provider.Dark,
AcquisitionTarget.Reference => provider.Reference,
_ => GetSampleArray(provider, _sampleIndex),
};
private double[] GenerateSpectrum(double[] wavelengths, SpectrometerSettings settings)
{
double scale = settings.IntegrationTimeMs / 100.0;
double noiseReduction = Math.Sqrt(Math.Max(1, settings.AverageCount));
var intensities = new double[PixelCount];
for (int i = 0; i < PixelCount; i++)
{
double signal;
if (settings.DarkAcquisition)
{
double darkCurrent = 50 * scale;
double readNoise = 8.0 / noiseReduction;
signal = darkCurrent + (_rng.NextDouble() * 2 - 1) * readNoise;
}
else if (settings.LightAcquisition)
{
double wn = WavelengthToWavenumber(wavelengths[i]);
double bg = (250 + 0.12 * wn - 2e-5 * wn * wn + 1.2e-9 * wn * wn * wn) * scale;
double substratePeak = 0;
foreach (var (center, intensity, width) in SubstratePeaks)
{
double delta = wn - center;
substratePeak += intensity * scale * (width * width) / (delta * delta + width * width);
}
signal = bg + substratePeak;
double noise = Math.Sqrt(Math.Max(1, signal)) / noiseReduction;
signal += (_rng.NextDouble() * 2 - 1) * noise;
}
else
{
double wn = WavelengthToWavenumber(wavelengths[i]);
double bg = (300 + 0.08 * wn - 1.5e-5 * wn * wn + 1e-9 * wn * wn * wn) * scale;
double baseVal = Interpolate(provider.Wavelengths, source, wavelengths[i]) * itScale;
double peakSignal = 0;
foreach (var (center, intensity, width) in _peaks)
{
double delta = wn - center;
peakSignal += intensity * scale * (width * width) / (delta * delta + width * width);
}
signal = bg + peakSignal;
double noise = Math.Sqrt(Math.Max(1, signal)) / noiseReduction;
signal += (_rng.NextDouble() * 2 - 1) * noise;
if (_noiseHi > 0)
{
double sigma = _noiseLo + (_noiseHi - _noiseLo) * _rng.NextDouble();
baseVal += NextGaussian() * sigma;
}
intensities[i] = Math.Max(0, signal);
intensities[i] = Math.Max(0, baseVal);
}
if (settings.BoxcarWidth > 0)
ApplyBoxcar(intensities, settings.BoxcarWidth);
return intensities;
return new SpectrumData(wavelengths, intensities, Name);
}
private double NextGaussian()
{
double u1 = 1.0 - _rng.NextDouble();
double u2 = _rng.NextDouble();
return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
}
private static double[] GetSampleArray(SampleDataProvider p, int index) => index switch
{
0 => p.SampleOD04,
1 => p.SampleOD02,
2 => p.SampleOD01,
3 => p.SampleOD0025,
_ => p.SampleOD04,
};
private static double Interpolate(double[] xs, double[] ys, double x)
{
if (x <= xs[0]) return ys[0];
if (x >= xs[^1]) return ys[^1];
int idx = Array.BinarySearch(xs, x);
if (idx >= 0) return ys[idx];
idx = ~idx;
if (idx == 0) return ys[0];
if (idx >= xs.Length) return ys[^1];
double t = (x - xs[idx - 1]) / (xs[idx] - xs[idx - 1]);
return ys[idx - 1] + t * (ys[idx] - ys[idx - 1]);
}
private static void ApplyBoxcar(double[] data, int width)
@@ -123,44 +138,30 @@ public class FakeSpectrometerDevice : ISpectrometerDevice
data[i] = sum / (2 * half + 1);
}
}
private double WavelengthToWavenumber(double wavelength)
{
return (1.0 / _laserWavelength - 1.0 / wavelength) * 1e7;
}
}
public class FakeSpectrometerDiscovery : ISpectrometerDiscovery
{
private readonly int _sampleIndex;
private readonly double _noiseLo;
private readonly double _noiseHi;
public FakeSpectrometerDiscovery(int sampleIndex = 0,
double noiseLo = 0, double noiseHi = 0)
{
_sampleIndex = sampleIndex;
_noiseLo = noiseLo;
_noiseHi = noiseHi;
}
public IReadOnlyList<ISpectrometerDevice> DiscoverDevices()
{
var lowRangePeaks = new (double, double, double)[]
{
(621, 8000, 12), // polystyrene ring deformation
(795, 3000, 10), // weak
(1002, 25000, 8), // ring breathing (strongest)
(1031, 10000, 10), // C-H in-plane bend
(1155, 5000, 15), // C-C stretch
(1450, 6000, 20), // CH2 deformation
(1583, 12000, 10), // ring C=C stretch
(1602, 14000, 10), // ring C=C stretch
};
var highRangePeaks = new (double, double, double)[]
{
(1583, 12000, 10),
(1602, 14000, 10),
(2852, 5000, 25), // C-H symmetric stretch
(2904, 7000, 20), // C-H stretch
(3054, 9000, 15), // aromatic C-H stretch
};
return
[
new FakeSpectrometerDevice("USB2000+ (低波数)", "FLMT01234",
786, 940, 785, lowRangePeaks),
new FakeSpectrometerDevice("USB2000+ (高波数)", "FLMT05678",
920, 1080, 785, highRangePeaks),
new FakeSpectrometerDevice("UV-Vis (350-1000nm)", "UVVIS01234",
350, 1000, _sampleIndex, _noiseLo, _noiseHi),
new FakeSpectrometerDevice("NIR (900-1686nm)", "NIR05678",
900, 1686, _sampleIndex, _noiseLo, _noiseHi),
];
}
}
+9 -3
View File
@@ -11,7 +11,15 @@ public interface ISpectrometerDevice
string SerialNumber { get; }
int PixelCount { get; }
double[] GetWavelengths();
/// <summary>采集样品光谱(加样品溶液后)。</summary>
Task<SpectrumData> AcquireAsync(SpectrometerSettings settings, CancellationToken ct = default);
/// <summary>采集暗光谱(无光源 / 滤光轮全黑位置)。</summary>
Task<SpectrumData> AcquireDarkAsync(SpectrometerSettings settings, CancellationToken ct = default);
/// <summary>采集明光谱(参比,纯水 / 滤光轮参比位置)。</summary>
Task<SpectrumData> AcquireReferenceAsync(SpectrometerSettings settings, CancellationToken ct = default);
}
public record SpectrometerSettings(
@@ -19,9 +27,7 @@ public record SpectrometerSettings(
int AverageCount,
int BoxcarWidth,
bool DarkNoiseCorrection,
bool NonlinearityCorrection,
bool DarkAcquisition = false,
bool LightAcquisition = false);
bool NonlinearityCorrection);
public interface ISpectrometerDiscovery
{
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Reflection;
using RamanApp.Models;
namespace RamanApp.Services;
public class SampleDataProvider
{
private static SampleDataProvider? _instance;
public static SampleDataProvider Instance => _instance ??= new SampleDataProvider();
public double[] Wavelengths { get; }
public double[] Dark { get; }
public double[] Reference { get; }
public double[] SampleOD04 { get; }
public double[] SampleOD02 { get; }
public double[] SampleOD01 { get; }
public double[] SampleOD0025 { get; }
private SampleDataProvider()
{
var wavelengths = new List<double>();
var dark = new List<double>();
var reference = new List<double>();
var od04 = new List<double>();
var od02 = new List<double>();
var od01 = new List<double>();
var od0025 = new List<double>();
using var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("RamanApp.SampleData.sample_spectra.csv");
if (stream == null)
throw new InvalidOperationException("Embedded sample data not found");
using var reader = new StreamReader(stream);
reader.ReadLine(); // skip header
string? line;
while ((line = reader.ReadLine()) != null)
{
var parts = line.Split(',');
if (parts.Length < 7) continue;
wavelengths.Add(double.Parse(parts[0], CultureInfo.InvariantCulture));
dark.Add(double.Parse(parts[1], CultureInfo.InvariantCulture));
reference.Add(double.Parse(parts[2], CultureInfo.InvariantCulture));
od04.Add(double.Parse(parts[3], CultureInfo.InvariantCulture));
od02.Add(double.Parse(parts[4], CultureInfo.InvariantCulture));
od01.Add(double.Parse(parts[5], CultureInfo.InvariantCulture));
od0025.Add(double.Parse(parts[6], CultureInfo.InvariantCulture));
}
Wavelengths = [.. wavelengths];
Dark = [.. dark];
Reference = [.. reference];
SampleOD04 = [.. od04];
SampleOD02 = [.. od02];
SampleOD01 = [.. od01];
SampleOD0025 = [.. od0025];
}
public SpectrumData GetDark() => new(Copy(Wavelengths), Copy(Dark), "sample-dark");
public SpectrumData GetReference() => new(Copy(Wavelengths), Copy(Reference), "sample-reference");
public SpectrumData GetSample(int index = 0) => index switch
{
0 => new(Copy(Wavelengths), Copy(SampleOD04), "sample-OD0.4"),
1 => new(Copy(Wavelengths), Copy(SampleOD02), "sample-OD0.2"),
2 => new(Copy(Wavelengths), Copy(SampleOD01), "sample-OD0.1"),
3 => new(Copy(Wavelengths), Copy(SampleOD0025), "sample-OD0.025"),
_ => new(Copy(Wavelengths), Copy(SampleOD04), "sample-OD0.4"),
};
public int SampleCount => 4;
private static double[] Copy(double[] src)
{
var dst = new double[src.Length];
Array.Copy(src, dst, src.Length);
return dst;
}
}
@@ -6,6 +6,33 @@ namespace RamanApp.Services;
public static class SpectrumProcessor
{
public static SpectrumData CalculateAbsorbance(SpectrumData sample, SpectrumData dark, SpectrumData reference)
{
int len = Math.Min(sample.Intensities.Length,
Math.Min(dark.Intensities.Length, reference.Intensities.Length));
var absorbance = new double[len];
var wavelengths = new double[len];
Array.Copy(sample.Wavelengths, wavelengths, len);
for (int i = 0; i < len; i++)
{
double sampleMinusDark = sample.Intensities[i] - dark.Intensities[i];
double refMinusDark = reference.Intensities[i] - dark.Intensities[i];
if (refMinusDark <= 0 || sampleMinusDark <= 0)
{
absorbance[i] = 0;
}
else
{
double transmittance = sampleMinusDark / refMinusDark;
absorbance[i] = transmittance > 0 ? -Math.Log10(transmittance) : 0;
}
}
return new SpectrumData(wavelengths, absorbance, "absorbance");
}
public static SpectrumData Interpolate(SpectrumData input, double startWn, double endWn,
double interval, double laserWavelengthNm)
{
@@ -26,6 +26,7 @@ public partial class AcquisitionViewModel : ObservableObject
public Func<CancellationToken, Task>? OnContinuousAcquire { get; set; }
public Func<CancellationToken, Task>? OnSaveLightSpectrum { get; set; }
public Func<CancellationToken, Task>? OnSaveDarkSpectrum { get; set; }
public Func<string>? GetCalibrationInfo { get; set; }
public AcquisitionViewModel()
{
@@ -118,7 +119,8 @@ public partial class AcquisitionViewModel : ObservableObject
if (OnSaveLightSpectrum != null)
await OnSaveLightSpectrum(cts.Token);
Progress = 100;
StatusText = "明光谱已保存";
var info = GetCalibrationInfo?.Invoke() ?? "";
StatusText = $"明光谱已保存{(info.Length > 0 ? $" ({info})" : "")}";
}
catch (Exception ex)
{
@@ -143,7 +145,8 @@ public partial class AcquisitionViewModel : ObservableObject
if (OnSaveDarkSpectrum != null)
await OnSaveDarkSpectrum(cts.Token);
Progress = 100;
StatusText = "暗光谱已保存";
var info = GetCalibrationInfo?.Invoke() ?? "";
StatusText = $"暗光谱已保存{(info.Length > 0 ? $" ({info})" : "")}";
}
catch (Exception ex)
{
+253 -31
View File
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -42,7 +44,14 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty]
public partial SpectrumChartViewModel MergedChart { get; set; }
[ObservableProperty]
public partial SpectrumChartViewModel AbsorbanceChart { get; set; }
[ObservableProperty]
public partial string CalibrationStatus { get; set; }
private readonly ISpectrometerDiscovery _discovery;
private readonly CalibrationStore _calibrationStore;
private SpectrumData? _lastRaw1;
private SpectrumData? _lastRaw2;
@@ -52,11 +61,20 @@ public partial class MainWindowViewModel : ObservableObject
private SpectrumData? _darkSpectrum2;
private SpectrumData? _activeSpectrum;
public MainWindowViewModel() : this(new FakeSpectrometerDiscovery()) { }
private static readonly string[] SampleNames = ["OD0.4", "OD0.2", "OD0.1", "OD0.025"];
public MainWindowViewModel(ISpectrometerDiscovery discovery)
public MainWindowViewModel() : this(-1, null) { }
public MainWindowViewModel(int simulateIndex, (double Lo, double Hi)? noiseRange = null)
: this(new FakeSpectrometerDiscovery(
simulateIndex >= 1 ? Math.Clamp(simulateIndex - 1, 0, 3) : 0,
noiseRange?.Lo ?? 0, noiseRange?.Hi ?? 0), simulateIndex) { }
public MainWindowViewModel(ISpectrometerDiscovery discovery, int simulateIndex = -1)
{
_discovery = discovery;
_calibrationStore = new CalibrationStore();
CalibrationStatus = "";
DeviceSettings = new DeviceSettingsViewModel();
LaserSettings = new LaserSettingsViewModel();
@@ -70,9 +88,17 @@ public partial class MainWindowViewModel : ObservableObject
LightDarkChart = new SpectrumChartViewModel { DisplaySettings = DisplaySettings };
BackgroundChart = new SpectrumChartViewModel { DisplaySettings = DisplaySettings };
MergedChart = new SpectrumChartViewModel { DisplaySettings = DisplaySettings };
AbsorbanceChart = new SpectrumChartViewModel { DisplaySettings = DisplaySettings };
WireUp();
DiscoverDevices();
UpdateCalibrationStatus();
if (simulateIndex >= 1)
{
int idx = Math.Clamp(simulateIndex - 1, 0, 3);
Acquisition.StatusText = $"模拟模式 — 样品: {SampleNames[idx]},请依次: 保存暗光谱 → 保存明光谱 → 单次采集";
}
}
private void WireUp()
@@ -81,6 +107,15 @@ public partial class MainWindowViewModel : ObservableObject
Acquisition.OnContinuousAcquire = AcquireOnceAsync;
Acquisition.OnSaveLightSpectrum = AcquireAndSaveLightAsync;
Acquisition.OnSaveDarkSpectrum = AcquireAndSaveDarkAsync;
Acquisition.GetCalibrationInfo = () =>
{
var parts = new List<string>();
if (DeviceSettings.Spectrometer1.AcquireEnabled)
parts.Add($"仪1@{DeviceSettings.Spectrometer1.IntegrationTimeMs}ms");
if (DeviceSettings.Spectrometer2.AcquireEnabled)
parts.Add($"仪2@{DeviceSettings.Spectrometer2.IntegrationTimeMs}ms");
return string.Join(", ", parts);
};
DeviceSettings.OnClearAll = ClearAllData;
@@ -117,6 +152,26 @@ public partial class MainWindowViewModel : ObservableObject
};
DisplaySettings.PropertyChanged += (_, _) => RefreshAllChartVersions();
DeviceSettings.Spectrometer1.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(SpectrometerGroupViewModel.IntegrationTimeMs))
OnIntegrationTimeChanged();
};
DeviceSettings.Spectrometer2.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(SpectrometerGroupViewModel.IntegrationTimeMs))
OnIntegrationTimeChanged();
};
}
private void OnIntegrationTimeChanged()
{
_darkSpectrum1 = null;
_darkSpectrum2 = null;
_lightSpectrum1 = null;
_lightSpectrum2 = null;
LoadCalibrationForCurrentSettings();
}
private void RefreshAllChartVersions()
@@ -125,6 +180,7 @@ public partial class MainWindowViewModel : ObservableObject
LightDarkChart.DataVersion++;
BackgroundChart.DataVersion++;
MergedChart.DataVersion++;
AbsorbanceChart.DataVersion++;
}
private void OnSelectedPeakChanged()
@@ -154,45 +210,53 @@ public partial class MainWindowViewModel : ObservableObject
LightDarkChart.LaserWavelengthNm = wl;
BackgroundChart.LaserWavelengthNm = wl;
MergedChart.LaserWavelengthNm = wl;
AbsorbanceChart.LaserWavelengthNm = wl;
}
private void DiscoverDevices()
{
var devices = _discovery.DiscoverDevices();
DeviceSettings.PopulateDevices(devices);
LoadCalibrationForCurrentSettings();
}
private Task AcquireOnceAsync(CancellationToken ct) =>
AcquireAsync(ct, darkAcquisition: false, lightAcquisition: false);
private static Task<SpectrumData> AcquireTargetAsync(ISpectrometerDevice dev,
SpectrometerSettings settings, AcquisitionTarget target, CancellationToken ct) => target switch
{
AcquisitionTarget.Dark => dev.AcquireDarkAsync(settings, ct),
AcquisitionTarget.Reference => dev.AcquireReferenceAsync(settings, ct),
_ => dev.AcquireAsync(settings, ct),
};
private async Task AcquireAsync(CancellationToken ct, bool darkAcquisition, bool lightAcquisition)
/// <summary>串行采集两台已启用的光谱仪,仅返回数据,不修改任何状态或图表。</summary>
private async Task<(SpectrumData? S1, SpectrumData? S2)> AcquirePairAsync(
AcquisitionTarget target, CancellationToken ct)
{
var dev1 = DeviceSettings.Spectrometer1.SelectedDevice;
var dev2 = DeviceSettings.Spectrometer2.SelectedDevice;
var settings1 = DeviceSettings.GetSettings1();
var settings2 = DeviceSettings.GetSettings2();
if (darkAcquisition)
{
settings1 = settings1 with { DarkAcquisition = true };
settings2 = settings2 with { DarkAcquisition = true };
}
else if (lightAcquisition)
{
settings1 = settings1 with { LightAcquisition = true };
settings2 = settings2 with { LightAcquisition = true };
}
bool acquire1 = dev1 != null && DeviceSettings.Spectrometer1.AcquireEnabled;
bool acquire2 = dev2 != null && DeviceSettings.Spectrometer2.AcquireEnabled;
if (!acquire1 && !acquire2)
SpectrumData? s1 = null, s2 = null;
if (acquire1)
s1 = await AcquireTargetAsync(dev1!, settings1, target, ct);
if (acquire2)
s2 = await AcquireTargetAsync(dev2!, settings2, target, ct);
return (s1, s2);
}
private async Task AcquireOnceAsync(CancellationToken ct)
{
var (s1, s2) = await AcquirePairAsync(AcquisitionTarget.Sample, ct);
if (s1 == null && s2 == null)
return;
if (acquire1)
_lastRaw1 = await dev1!.AcquireAsync(settings1, ct);
if (acquire2)
_lastRaw2 = await dev2!.AcquireAsync(settings2, ct);
_lastRaw1 = s1;
_lastRaw2 = s2;
UpdateCharts();
@@ -239,6 +303,7 @@ public partial class MainWindowViewModel : ObservableObject
UpdateLightDarkChart();
UpdateBackgroundChart();
UpdateAbsorbanceChart();
}
private void ClearAllData()
@@ -255,28 +320,58 @@ public partial class MainWindowViewModel : ObservableObject
LightDarkChart.Clear();
BackgroundChart.Clear();
MergedChart.Clear();
AbsorbanceChart.Clear();
UpdateCalibrationStatus();
}
private async Task AcquireAndSaveLightAsync(CancellationToken ct)
{
await AcquireAsync(ct, darkAcquisition: false, lightAcquisition: true);
if (DeviceSettings.Spectrometer1.AcquireEnabled && _lastRaw1 != null)
_lightSpectrum1 = _lastRaw1;
if (DeviceSettings.Spectrometer2.AcquireEnabled && _lastRaw2 != null)
_lightSpectrum2 = _lastRaw2;
var (s1, s2) = await AcquirePairAsync(AcquisitionTarget.Reference, ct);
if (s1 != null)
{
_lightSpectrum1 = s1;
var dev = DeviceSettings.Spectrometer1.SelectedDevice;
if (dev != null)
_calibrationStore.SaveReference(DeviceSettings.Spectrometer1.IntegrationTimeMs,
dev.SerialNumber, s1);
}
if (s2 != null)
{
_lightSpectrum2 = s2;
var dev = DeviceSettings.Spectrometer2.SelectedDevice;
if (dev != null)
_calibrationStore.SaveReference(DeviceSettings.Spectrometer2.IntegrationTimeMs,
dev.SerialNumber, s2);
}
UpdateCalibrationStatus();
UpdateLightDarkChart();
UpdateBackgroundChart();
UpdateAbsorbanceChart();
}
private async Task AcquireAndSaveDarkAsync(CancellationToken ct)
{
await AcquireAsync(ct, darkAcquisition: true, lightAcquisition: false);
if (DeviceSettings.Spectrometer1.AcquireEnabled && _lastRaw1 != null)
_darkSpectrum1 = _lastRaw1;
if (DeviceSettings.Spectrometer2.AcquireEnabled && _lastRaw2 != null)
_darkSpectrum2 = _lastRaw2;
var (s1, s2) = await AcquirePairAsync(AcquisitionTarget.Dark, ct);
if (s1 != null)
{
_darkSpectrum1 = s1;
var dev = DeviceSettings.Spectrometer1.SelectedDevice;
if (dev != null)
_calibrationStore.SaveDark(DeviceSettings.Spectrometer1.IntegrationTimeMs,
dev.SerialNumber, s1);
}
if (s2 != null)
{
_darkSpectrum2 = s2;
var dev = DeviceSettings.Spectrometer2.SelectedDevice;
if (dev != null)
_calibrationStore.SaveDark(DeviceSettings.Spectrometer2.IntegrationTimeMs,
dev.SerialNumber, s2);
}
UpdateCalibrationStatus();
UpdateLightDarkChart();
UpdateBackgroundChart();
UpdateAbsorbanceChart();
}
private void UpdateLightDarkChart()
@@ -317,6 +412,133 @@ public partial class MainWindowViewModel : ObservableObject
BackgroundChart.Clear();
}
private void UpdateAbsorbanceChart()
{
var series = new List<ChartSeries>();
var abs1 = TryComputeAbsorbance(_lastRaw1, _darkSpectrum1, _lightSpectrum1,
DeviceSettings.Spectrometer1);
if (abs1 != null)
series.Add(new ChartSeries("吸光度-仪1", abs1.Wavelengths, abs1.Intensities, "#E91E63"));
var abs2 = TryComputeAbsorbance(_lastRaw2, _darkSpectrum2, _lightSpectrum2,
DeviceSettings.Spectrometer2);
if (abs2 != null)
series.Add(new ChartSeries("吸光度-仪2", abs2.Wavelengths, abs2.Intensities, "#9C27B0"));
if (series.Count > 0)
AbsorbanceChart.SetData([.. series]);
else
AbsorbanceChart.Clear();
}
private SpectrumData? TryComputeAbsorbance(SpectrumData? sample,
SpectrumData? dark, SpectrumData? reference,
SpectrometerGroupViewModel specGroup)
{
if (sample == null) return null;
var darkData = dark;
var refData = reference;
if (darkData == null || refData == null)
{
var dev = specGroup.SelectedDevice;
if (dev == null) return null;
int it = specGroup.IntegrationTimeMs;
darkData ??= _calibrationStore.LoadDark(it, dev.SerialNumber);
refData ??= _calibrationStore.LoadReference(it, dev.SerialNumber);
}
if (darkData == null || refData == null) return null;
var absorbance = SpectrumProcessor.CalculateAbsorbance(sample, darkData, refData);
if (DataProcessing.SmoothingEnabled)
absorbance = SpectrumProcessor.Smooth(absorbance, (int)DataProcessing.SmoothingLambda);
return absorbance;
}
private void UpdateCalibrationStatus()
{
var parts = new List<string>();
var dev1 = DeviceSettings.Spectrometer1.SelectedDevice;
var dev2 = DeviceSettings.Spectrometer2.SelectedDevice;
int it1 = DeviceSettings.Spectrometer1.IntegrationTimeMs;
int it2 = DeviceSettings.Spectrometer2.IntegrationTimeMs;
if (dev1 != null)
{
bool hasDark = _darkSpectrum1 != null || _calibrationStore.HasDark(it1, dev1.SerialNumber);
bool hasRef = _lightSpectrum1 != null || _calibrationStore.HasReference(it1, dev1.SerialNumber);
string state = (hasDark, hasRef) switch
{
(true, true) => "已校准",
(true, false) => "缺明光谱",
(false, true) => "缺暗光谱",
_ => "未校准",
};
parts.Add($"仪1@{it1}ms: {state}");
}
if (dev2 != null && DeviceSettings.Spectrometer2.AcquireEnabled)
{
bool hasDark = _darkSpectrum2 != null || _calibrationStore.HasDark(it2, dev2.SerialNumber);
bool hasRef = _lightSpectrum2 != null || _calibrationStore.HasReference(it2, dev2.SerialNumber);
string state = (hasDark, hasRef) switch
{
(true, true) => "已校准",
(true, false) => "缺明光谱",
(false, true) => "缺暗光谱",
_ => "未校准",
};
parts.Add($"仪2@{it2}ms: {state}");
}
var savedTimes = _calibrationStore.GetAvailableIntegrationTimes();
if (savedTimes.Count > 0)
parts.Add($"已存: {string.Join("/", savedTimes.ConvertAll(t => $"{t}ms"))}");
CalibrationStatus = parts.Count > 0 ? string.Join(" | ", parts) : "";
}
public void LoadCalibrationForCurrentSettings()
{
var dev1 = DeviceSettings.Spectrometer1.SelectedDevice;
if (dev1 != null && _darkSpectrum1 == null)
_darkSpectrum1 = _calibrationStore.LoadDark(DeviceSettings.Spectrometer1.IntegrationTimeMs, dev1.SerialNumber);
if (dev1 != null && _lightSpectrum1 == null)
_lightSpectrum1 = _calibrationStore.LoadReference(DeviceSettings.Spectrometer1.IntegrationTimeMs, dev1.SerialNumber);
var dev2 = DeviceSettings.Spectrometer2.SelectedDevice;
if (dev2 != null && _darkSpectrum2 == null)
_darkSpectrum2 = _calibrationStore.LoadDark(DeviceSettings.Spectrometer2.IntegrationTimeMs, dev2.SerialNumber);
if (dev2 != null && _lightSpectrum2 == null)
_lightSpectrum2 = _calibrationStore.LoadReference(DeviceSettings.Spectrometer2.IntegrationTimeMs, dev2.SerialNumber);
UpdateCalibrationStatus();
UpdateLightDarkChart();
UpdateBackgroundChart();
}
public void QuickSave()
{
var spectrum = GetActiveSpectrum();
if (spectrum == null)
{
Acquisition.StatusText = "无可保存的光谱数据";
return;
}
var dir = Path.Combine(AppContext.BaseDirectory, "spectra");
Directory.CreateDirectory(dir);
var fileName = $"spectrum_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
var path = Path.Combine(dir, fileName);
SpectrumFileService.ExportCsv(path, spectrum, LaserSettings.LaserWavelengthNm);
Acquisition.StatusText = $"已快速保存: {fileName}";
}
public void ImportSpectrum(SpectrumData spectrum)
{
_lastRaw1 = spectrum;
+3
View File
@@ -1,6 +1,8 @@
using Raman.Core.Spectroscopy;
using Spec.Sea;
const int HostSafetyMaxIntegrationTimeMicroseconds = 1_000_000;
var cancellationToken = CancellationToken.None;
var discovery = new SeaSpectrometerDiscovery();
@@ -67,6 +69,7 @@ static async Task RunDeviceAsync(
var maxIntegration = capabilities.MaxIntegrationTimeMicroseconds > 0
? capabilities.MaxIntegrationTimeMicroseconds
: int.MaxValue;
maxIntegration = Math.Min(maxIntegration, HostSafetyMaxIntegrationTimeMicroseconds);
var integration = Math.Clamp(requestedIntegration, capabilities.MinIntegrationTimeMicroseconds, maxIntegration);
var average = checked((ushort)Math.Clamp(ReadInt("RAMAN_TEST_AVERAGE", originalAcquisition?.Average ?? 1), 1, ushort.MaxValue));
var boxcar = checked((ushort)Math.Clamp(ReadInt("RAMAN_TEST_BOXCAR", originalAcquisition?.Boxcar ?? 0), 0, ushort.MaxValue));
@@ -197,7 +197,7 @@ public sealed class SpectrometerRuntimeWorkspace : IAsyncDisposable
return SpectrometerResponse.Ok();
}
private const int MaxIntegrationTimeMicroseconds = 5_000_000;
private const int MaxIntegrationTimeMicroseconds = 1_000_000;
private async Task<SpectrometerResponse> ConfigureAcquisitionAsync(SpectrometerRequest request, CancellationToken cancellationToken)
{
@@ -210,7 +210,7 @@ public sealed class SpectrometerRuntimeWorkspace : IAsyncDisposable
{
return SpectrometerResponse.Fail(
SpectrometerErrorCodes.InvalidValue,
$"Integration time {request.AcquisitionSettings.IntegrationTimeMicroseconds} us exceeds maximum allowed {MaxIntegrationTimeMicroseconds} us (5s).");
$"Integration time {request.AcquisitionSettings.IntegrationTimeMicroseconds} us exceeds maximum allowed {MaxIntegrationTimeMicroseconds} us (1s).");
}
var device = FindConnectedDevice(request.Target);
@@ -73,6 +73,28 @@ public sealed class SpectrometerRuntimeWorkspaceTests
Assert.Equal(validSettings, valid.AcquisitionSettings);
}
[Fact]
public async Task ConfigureAcquisitionAsync_rejects_host_integration_time_above_one_second()
{
var workspace = CreateWorkspaceWithDescriptor();
await workspace.HandleAsync(new SpectrometerRequest("sim-spec-001", SpectrometerCommandKind.Connect));
var atLimit = await workspace.HandleAsync(new SpectrometerRequest(
"sim-spec-001",
SpectrometerCommandKind.ConfigureAcquisition,
new SpectrometerAcquisitionSettings(1_000_000, 1, 0)));
var aboveLimit = await workspace.HandleAsync(new SpectrometerRequest(
"sim-spec-001",
SpectrometerCommandKind.ConfigureAcquisition,
new SpectrometerAcquisitionSettings(1_000_001, 1, 0)));
Assert.True(atLimit.Succeeded);
Assert.False(aboveLimit.Succeeded);
Assert.Equal(SpectrometerErrorCodes.InvalidValue, aboveLimit.ErrorCode);
Assert.Contains("1000000", aboveLimit.ErrorMessage);
Assert.Contains("1s", aboveLimit.ErrorMessage);
}
[Fact]
public async Task AcquireSpectrumAsync_returns_wavelengths_and_intensities()
{