feat: 优化若干问题

This commit is contained in:
chuan
2026-07-04 17:34:47 +08:00 Unverified
parent 227697f35a
commit d20b36b15b
21 changed files with 1243 additions and 176 deletions
@@ -79,6 +79,12 @@ public sealed class DataProcessingSettingsFile
public bool AbsorbanceAutoSmooth { get; set; }
public bool AbsorbanceStitchingEnabled { get; set; } = true;
public double AbsorbanceStitchingWavelengthNm { get; set; } = 850;
public string AbsorbanceStitchingReference { get; set; } = "光谱仪2";
public string SmoothingMethod { get; set; } = "移动平均";
public int SmoothingWindowSize { get; set; } = 15;
@@ -83,6 +83,7 @@ public sealed class EasyTtlSettingsFile
public string? PortName { get; set; }
// Kept for compatibility with older settings files; new code uses per-channel delays.
public int? LaserStabilizationDelayMs { get; set; }
public List<EasyTtlChannelSettings> Channels { get; set; } = [];
@@ -95,6 +96,10 @@ public sealed class EasyTtlChannelSettings
public string? Alias { get; set; }
public bool? ShowInLaserPanel { get; set; }
public int? OpenDelayMs { get; set; }
public int? CloseDelayMs { get; set; }
}
[JsonSourceGenerationOptions(
@@ -42,7 +42,7 @@ public sealed class SeaSpectrometerDiscoveryAdapter : ISpectrometerDiscovery, ID
public sealed class SeaSpectrometerDeviceAdapter : ISpectrometerDevice, IDisposable, IAsyncDisposable
{
private const int MaxIntegrationTimeMicroseconds = 1_000_000;
private const int MaxIntegrationTimeMicroseconds = 10_000_000;
private readonly SeaSpectrometerDiscovery _discovery;
private readonly SpectrometerDescriptor _descriptor;
@@ -80,6 +80,14 @@ public sealed class SpectrometerSettingsFile
public string? Spectrometer2Name { get; set; }
public string? Spectrometer1DeviceName { get; set; }
public string? Spectrometer2DeviceName { get; set; }
public string? Spectrometer1DeviceSerialNumber { get; set; }
public string? Spectrometer2DeviceSerialNumber { get; set; }
public bool? Spectrometer1ShowInPanel { get; set; }
public bool? Spectrometer2ShowInPanel { get; set; }
+100 -97
View File
@@ -55,57 +55,8 @@ public static class SpectrumFileService
sb.AppendLine($"# Date: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine($"# Laser Wavelength: {laserWavelengthNm} nm");
sb.AppendLine($"# Series Count: {series.Count}");
bool hasSmoothed = series.Any(item => item.SmoothedSpectrum != null);
bool hasLoop = series.Any(item => item.LoopIndex != null);
string prefix = hasLoop ? "LoopIndex,Timestamp," : "";
sb.AppendLine(hasSmoothed
? $"{prefix}Type,Laser,Channel,Device,Wavelength(nm),Value,SmoothedValue"
: $"{prefix}Type,Laser,Channel,Device,Wavelength(nm),Value");
foreach (var item in series)
{
var spectrum = item.Spectrum;
var smoothed = item.SmoothedSpectrum;
string channel = item.LaserChannel?.ToString(CultureInfo.InvariantCulture) ?? "";
string loopPrefix = hasLoop
? string.Format(CultureInfo.InvariantCulture,
"{0},{1},",
item.LoopIndex?.ToString(CultureInfo.InvariantCulture) ?? "",
item.LoopTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) ?? "")
: "";
int length = spectrum.Wavelengths.Length;
for (int i = 0; i < length; i++)
{
if (hasSmoothed)
{
string smoothedValue = smoothed != null && i < smoothed.Intensities.Length
? smoothed.Intensities[i].ToString("F4", CultureInfo.InvariantCulture)
: "";
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"{0}{1},{2},{3},{4},{5:F4},{6:F4},{7}",
loopPrefix,
EscapeCsv(item.DataType),
EscapeCsv(item.LaserName),
channel,
EscapeCsv(item.DeviceName),
spectrum.Wavelengths[i],
spectrum.Intensities[i],
smoothedValue));
}
else
{
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"{0}{1},{2},{3},{4},{5:F4},{6:F4}",
loopPrefix,
EscapeCsv(item.DataType),
EscapeCsv(item.LaserName),
channel,
EscapeCsv(item.DeviceName),
spectrum.Wavelengths[i],
spectrum.Intensities[i]));
}
}
}
sb.AppendLine("# Layout: Wide");
AppendWideTable(sb, series, ",", EscapeCsv);
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
}
@@ -166,58 +117,108 @@ public static class SpectrumFileService
public static string ToClipboardText(IReadOnlyList<SpectrumExportSeries> series)
{
var sb = new StringBuilder();
bool hasSmoothed = series.Any(item => item.SmoothedSpectrum != null);
bool hasLoop = series.Any(item => item.LoopIndex != null);
string prefix = hasLoop ? "LoopIndex\tTimestamp\t" : "";
sb.AppendLine(hasSmoothed
? $"{prefix}Type\tLaser\tChannel\tDevice\tWavelength(nm)\tValue\tSmoothedValue"
: $"{prefix}Type\tLaser\tChannel\tDevice\tWavelength(nm)\tValue");
AppendWideTable(sb, series, "\t", value => value);
return sb.ToString();
}
foreach (var item in series)
private static void AppendWideTable(StringBuilder sb, IReadOnlyList<SpectrumExportSeries> series,
string delimiter, Func<string, string> escape)
{
var columns = BuildWideColumns(series);
var header = new List<string> { "Wavelength(nm)" };
header.AddRange(columns.Select(column => column.Header));
sb.AppendLine(string.Join(delimiter, header.Select(escape)));
var wavelengthKeys = new Dictionary<string, double>();
foreach (var column in columns)
{
var spectrum = item.Spectrum;
var smoothed = item.SmoothedSpectrum;
string channel = item.LaserChannel?.ToString(CultureInfo.InvariantCulture) ?? "";
string loopPrefix = hasLoop
? string.Format(CultureInfo.InvariantCulture,
"{0}\t{1}\t",
item.LoopIndex?.ToString(CultureInfo.InvariantCulture) ?? "",
item.LoopTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) ?? "")
: "";
for (int i = 0; i < spectrum.Wavelengths.Length; i++)
foreach (var key in column.Values.Keys)
{
if (hasSmoothed)
{
string smoothedValue = smoothed != null && i < smoothed.Intensities.Length
? smoothed.Intensities[i].ToString("F4", CultureInfo.InvariantCulture)
: "";
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"{0}{1}\t{2}\t{3}\t{4}\t{5:F4}\t{6:F4}\t{7}",
loopPrefix,
item.DataType,
item.LaserName,
channel,
item.DeviceName,
spectrum.Wavelengths[i],
spectrum.Intensities[i],
smoothedValue));
}
else
{
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
"{0}{1}\t{2}\t{3}\t{4}\t{5:F4}\t{6:F4}",
loopPrefix,
item.DataType,
item.LaserName,
channel,
item.DeviceName,
spectrum.Wavelengths[i],
spectrum.Intensities[i]));
}
if (!wavelengthKeys.ContainsKey(key))
wavelengthKeys[key] = double.Parse(key, CultureInfo.InvariantCulture);
}
}
return sb.ToString();
foreach (var wavelengthKey in wavelengthKeys.OrderBy(item => item.Value).Select(item => item.Key))
{
var row = new List<string> { wavelengthKey };
row.AddRange(columns.Select(column =>
column.Values.TryGetValue(wavelengthKey, out var value) ? value : ""));
sb.AppendLine(string.Join(delimiter, row.Select(escape)));
}
}
private static List<WideColumn> BuildWideColumns(IReadOnlyList<SpectrumExportSeries> series)
{
var columns = new List<WideColumn>();
var headerCounts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var item in series)
{
string baseHeader = BuildColumnHeader(item);
columns.Add(new WideColumn(MakeUniqueHeader(baseHeader, headerCounts),
BuildValueMap(item.Spectrum.Wavelengths, item.Spectrum.Intensities)));
if (item.SmoothedSpectrum != null)
{
columns.Add(new WideColumn(MakeUniqueHeader($"{baseHeader}|Smoothed", headerCounts),
BuildValueMap(item.Spectrum.Wavelengths, item.SmoothedSpectrum.Intensities)));
}
}
return columns;
}
private static Dictionary<string, string> BuildValueMap(double[] wavelengths, double[] values)
{
var map = new Dictionary<string, string>(StringComparer.Ordinal);
int length = Math.Min(wavelengths.Length, values.Length);
for (int i = 0; i < length; i++)
{
string wavelength = wavelengths[i].ToString("F4", CultureInfo.InvariantCulture);
map.TryAdd(wavelength, values[i].ToString("F4", CultureInfo.InvariantCulture));
}
return map;
}
private static string BuildColumnHeader(SpectrumExportSeries item)
{
var parts = new List<string>();
if (item.LoopIndex != null)
{
parts.Add($"Loop{item.LoopIndex.Value.ToString(CultureInfo.InvariantCulture)}");
if (item.LoopTimestamp != null)
parts.Add(item.LoopTimestamp.Value.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture));
}
AddHeaderPart(parts, item.DataType);
AddHeaderPart(parts, item.LaserName);
if (item.LaserChannel != null)
parts.Add($"Ch{item.LaserChannel.Value.ToString(CultureInfo.InvariantCulture)}");
AddHeaderPart(parts, item.DeviceName);
return parts.Count == 0 ? "Series" : string.Join("|", parts);
}
private static void AddHeaderPart(List<string> parts, string value)
{
if (!string.IsNullOrWhiteSpace(value))
parts.Add(value.Trim());
}
private static string MakeUniqueHeader(string header, Dictionary<string, int> headerCounts)
{
if (!headerCounts.TryGetValue(header, out int count))
{
headerCounts[header] = 1;
return header;
}
count++;
headerCounts[header] = count;
return $"{header} ({count.ToString(CultureInfo.InvariantCulture)})";
}
private static string EscapeCsv(string value)
@@ -227,4 +228,6 @@ public static class SpectrumFileService
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
private sealed record WideColumn(string Header, Dictionary<string, string> Values);
}
+108
View File
@@ -1,6 +1,7 @@
using System;
using System.Linq;
using RamanApp.Models;
using Serilog;
namespace RamanApp.Services;
@@ -33,6 +34,94 @@ public static class SpectrumProcessor
return new SpectrumData(wavelengths, absorbance, "absorbance");
}
public static SpectrumData StitchAbsorbance(
SpectrumData spectrometer1,
SpectrumData spectrometer2,
double stitchingWavelengthNm,
bool referenceSpectrometer2,
double laserWavelengthNm)
{
var s1 = NormalizeByWavelength(spectrometer1);
var s2 = NormalizeByWavelength(spectrometer2);
if (s1.Wavelengths.Length == 0) return s2;
if (s2.Wavelengths.Length == 0) return s1;
double overlapStart = Math.Max(s1.Wavelengths[0], s2.Wavelengths[0]);
double overlapEnd = Math.Min(s1.Wavelengths[^1], s2.Wavelengths[^1]);
if (overlapEnd < overlapStart)
{
Log.Information(
"Absorbance stitching skipped because spectra do not overlap requestedNm={RequestedNm:0.###} s1Range={S1Min:0.###}..{S1Max:0.###} s2Range={S2Min:0.###}..{S2Max:0.###}",
stitchingWavelengthNm, s1.Wavelengths[0], s1.Wavelengths[^1], s2.Wavelengths[0], s2.Wavelengths[^1]);
return SpectrumData.Merge(s1, s2, laserWavelengthNm);
}
double seam = Math.Clamp(stitchingWavelengthNm, overlapStart, overlapEnd);
if (Math.Abs(seam - stitchingWavelengthNm) > double.Epsilon)
{
Log.Information(
"Absorbance stitching wavelength clamped requestedNm={RequestedNm:0.###} seamNm={SeamNm:0.###} overlap={OverlapStart:0.###}..{OverlapEnd:0.###}",
stitchingWavelengthNm, seam, overlapStart, overlapEnd);
}
double y1 = LinearInterp(s1.Wavelengths, s1.Intensities, seam);
double y2 = LinearInterp(s2.Wavelengths, s2.Intensities, seam);
return referenceSpectrometer2
? StitchAtSeam(OffsetIntensity(s1, y2 - y1), s2, seam)
: StitchAtSeam(s1, OffsetIntensity(s2, y1 - y2), seam);
}
private static SpectrumData StitchAtSeam(SpectrumData left, SpectrumData right, double seam)
{
var wavelengths = new System.Collections.Generic.List<double>();
var intensities = new System.Collections.Generic.List<double>();
AddPointsBefore(wavelengths, intensities, left, seam);
wavelengths.Add(seam);
intensities.Add(LinearInterp(right.Wavelengths, right.Intensities, seam));
AddPointsAfter(wavelengths, intensities, right, seam);
return NormalizeByWavelength(new SpectrumData([.. wavelengths], [.. intensities], "stitched-absorbance"));
}
private static void AddPointsBefore(System.Collections.Generic.List<double> wavelengths,
System.Collections.Generic.List<double> intensities, SpectrumData spectrum, double seam)
{
int length = Math.Min(spectrum.Wavelengths.Length, spectrum.Intensities.Length);
for (int i = 0; i < length; i++)
{
if (spectrum.Wavelengths[i] < seam)
{
wavelengths.Add(spectrum.Wavelengths[i]);
intensities.Add(spectrum.Intensities[i]);
}
}
}
private static void AddPointsAfter(System.Collections.Generic.List<double> wavelengths,
System.Collections.Generic.List<double> intensities, SpectrumData spectrum, double seam)
{
int length = Math.Min(spectrum.Wavelengths.Length, spectrum.Intensities.Length);
for (int i = 0; i < length; i++)
{
if (spectrum.Wavelengths[i] > seam)
{
wavelengths.Add(spectrum.Wavelengths[i]);
intensities.Add(spectrum.Intensities[i]);
}
}
}
private static SpectrumData OffsetIntensity(SpectrumData spectrum, double offset)
{
var intensities = new double[spectrum.Intensities.Length];
for (int i = 0; i < intensities.Length; i++)
intensities[i] = spectrum.Intensities[i] + offset;
return new SpectrumData((double[])spectrum.Wavelengths.Clone(), intensities, spectrum.Source);
}
public static SpectrumData Interpolate(SpectrumData input, double startWn, double endWn,
double interval, double laserWavelengthNm)
{
@@ -176,6 +265,8 @@ public static class SpectrumProcessor
private static double LinearInterp(double[] xs, double[] ys, double x)
{
if (xs.Length == 0 || ys.Length == 0) return 0;
if (xs.Length == 1 || ys.Length == 1) return ys[0];
if (x <= xs[0]) return ys[0];
if (x >= xs[^1]) return ys[^1];
@@ -189,6 +280,23 @@ public static class SpectrumProcessor
return ys[idx - 1] + t * (ys[idx] - ys[idx - 1]);
}
private static SpectrumData NormalizeByWavelength(SpectrumData spectrum)
{
var points = spectrum.Wavelengths
.Zip(spectrum.Intensities, (w, y) => new { Wavelength = w, Intensity = y })
.Where(p => !double.IsNaN(p.Wavelength) && !double.IsInfinity(p.Wavelength)
&& !double.IsNaN(p.Intensity) && !double.IsInfinity(p.Intensity))
.GroupBy(p => p.Wavelength)
.OrderBy(g => g.Key)
.Select(g => new { Wavelength = g.Key, Intensity = g.Average(p => p.Intensity) })
.ToArray();
return new SpectrumData(
points.Select(p => p.Wavelength).ToArray(),
points.Select(p => p.Intensity).ToArray(),
spectrum.Source);
}
private static double FitPolynomialAtCenter(double[] y, int start, int end, int center, int order)
{
int size = order + 1;
@@ -46,6 +46,9 @@ public partial class AcquisitionViewModel : ObservableObject
[ObservableProperty]
public partial bool IsContinuousRunActive { get; private set; }
[ObservableProperty]
public partial bool IsContinuousSessionActive { get; private set; }
private CancellationTokenSource? _activeCts;
private CancellationTokenSource? _continuousCts;
private readonly Stopwatch _progressWatch = new();
@@ -64,6 +67,7 @@ public partial class AcquisitionViewModel : ObservableObject
public Func<int>? GetContinuousStepCount { get; set; }
public Action<int, int>? OnContinuousStarted { get; set; }
public Action<int, bool>? OnContinuousFinished { get; set; }
public Func<Task>? OnContinuousSessionFinished { get; set; }
public AcquisitionViewModel()
{
@@ -126,6 +130,7 @@ public partial class AcquisitionViewModel : ObservableObject
_continuousCts = _activeCts;
var token = _activeCts.Token;
StatusText = "连续采集中...";
IsContinuousSessionActive = true;
StartProgress("连续");
try
@@ -155,9 +160,12 @@ public partial class AcquisitionViewModel : ObservableObject
finally
{
IsAcquiring = false;
IsContinuousSessionActive = false;
_activeCts?.Dispose();
_activeCts = null;
_continuousCts = null;
if (OnContinuousSessionFinished != null)
await OnContinuousSessionFinished();
}
}
@@ -173,6 +181,7 @@ public partial class AcquisitionViewModel : ObservableObject
int loopTotal = Math.Max(1, ContinuousCount);
int stepsPerLoop = Math.Max(1, GetContinuousStepCount?.Invoke() ?? 1);
StatusText = "定时连续采集中...";
IsContinuousSessionActive = true;
BeginContinuousProgress(loopTotal, stepsPerLoop);
OnContinuousStarted?.Invoke(loopTotal, stepsPerLoop);
bool completed = false;
@@ -211,12 +220,15 @@ public partial class AcquisitionViewModel : ObservableObject
finally
{
IsAcquiring = false;
IsContinuousSessionActive = false;
IsContinuousRunActive = false;
CurrentLoopIndex = 0;
CurrentLoopTotal = 0;
_activeCts?.Dispose();
_activeCts = null;
_continuousCts = null;
if (OnContinuousSessionFinished != null)
await OnContinuousSessionFinished();
OnContinuousFinished?.Invoke(loopTotal, completed);
}
}
@@ -18,6 +18,15 @@ public partial class DataProcessingViewModel : ObservableObject
[ObservableProperty]
public partial bool AbsorbanceAutoSmooth { get; set; }
[ObservableProperty]
public partial bool AbsorbanceStitchingEnabled { get; set; }
[ObservableProperty]
public partial double AbsorbanceStitchingWavelengthNm { get; set; }
[ObservableProperty]
public partial string AbsorbanceStitchingReference { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsWindowSmoothing))]
[NotifyPropertyChangedFor(nameof(IsOrderSmoothing))]
@@ -33,6 +42,9 @@ public partial class DataProcessingViewModel : ObservableObject
public IReadOnlyList<string> SmoothingMethods { get; } =
["移动平均", "Savitzky-Golay"];
public IReadOnlyList<string> AbsorbanceStitchingReferences { get; } =
["光谱仪2", "光谱仪1"];
public bool IsWindowSmoothing => true;
public bool IsOrderSmoothing => SmoothingMethod != "移动平均";
public string SmoothingOrderLabel => "多项式阶数";
@@ -89,6 +101,9 @@ public partial class DataProcessingViewModel : ObservableObject
var settings = _settingsStore.Load();
AutoProcessAfterAcquire = settings.AutoProcessAfterAcquire;
AbsorbanceAutoSmooth = settings.AbsorbanceAutoSmooth;
AbsorbanceStitchingEnabled = settings.AbsorbanceStitchingEnabled;
AbsorbanceStitchingWavelengthNm = ClampStitchingWavelength(settings.AbsorbanceStitchingWavelengthNm);
AbsorbanceStitchingReference = NormalizeStitchingReference(settings.AbsorbanceStitchingReference);
SmoothingMethod = NormalizeMethod(settings.SmoothingMethod);
SmoothingWindowSize = Math.Clamp(settings.SmoothingWindowSize, 3, 501);
SmoothingOrder = Math.Clamp(settings.SmoothingOrder, 1, 4);
@@ -108,6 +123,9 @@ public partial class DataProcessingViewModel : ObservableObject
{
AutoProcessAfterAcquire = AutoProcessAfterAcquire,
AbsorbanceAutoSmooth = AbsorbanceAutoSmooth,
AbsorbanceStitchingEnabled = AbsorbanceStitchingEnabled,
AbsorbanceStitchingWavelengthNm = ClampStitchingWavelength(AbsorbanceStitchingWavelengthNm),
AbsorbanceStitchingReference = NormalizeStitchingReference(AbsorbanceStitchingReference),
SmoothingMethod = NormalizeMethod(SmoothingMethod),
SmoothingWindowSize = Math.Clamp(SmoothingWindowSize, 3, 501),
SmoothingOrder = Math.Clamp(SmoothingOrder, 1, 4),
@@ -117,10 +135,32 @@ public partial class DataProcessingViewModel : ObservableObject
private static string NormalizeMethod(string? method)
=> method == "Savitzky-Golay" ? "Savitzky-Golay" : "移动平均";
private static string NormalizeStitchingReference(string? reference)
=> reference == "光谱仪1" ? "光谱仪1" : "光谱仪2";
private static double ClampStitchingWavelength(double value)
=> double.IsNaN(value) || double.IsInfinity(value) ? 850 : Math.Clamp(value, 1, 10000);
partial void OnAutoProcessAfterAcquireChanged(bool value) => SaveSettings();
partial void OnAbsorbanceAutoSmoothChanged(bool value) => SaveSettings();
partial void OnAbsorbanceStitchingEnabledChanged(bool value) => SaveSettings();
partial void OnAbsorbanceStitchingWavelengthNmChanged(double value)
{
var clamped = ClampStitchingWavelength(value);
if (Math.Abs(clamped - value) > double.Epsilon)
{
AbsorbanceStitchingWavelengthNm = clamped;
return;
}
SaveSettings();
}
partial void OnAbsorbanceStitchingReferenceChanged(string value) => SaveSettings();
partial void OnSmoothingMethodChanged(string value) => SaveSettings();
partial void OnSmoothingWindowSizeChanged(int value) => SaveSettings();
@@ -38,36 +38,40 @@ public partial class DeviceSettingsViewModel : ObservableObject
public void PopulateDevices(IReadOnlyList<ISpectrometerDevice> devices)
{
Spectrometer1.AvailableDevices.Clear();
Spectrometer2.AvailableDevices.Clear();
foreach (var d in devices)
PopulateDeviceList(Spectrometer1, devices);
PopulateDeviceList(Spectrometer2, devices);
var selected1 = FindSavedDevice(Spectrometer1, devices);
var selected2 = FindSavedDevice(Spectrometer2, devices);
var usedDevices = new List<ISpectrometerDevice>();
if (selected1 != null)
usedDevices.Add(selected1);
if (selected2 != null)
{
Spectrometer1.AvailableDevices.Add(d);
Spectrometer2.AvailableDevices.Add(d);
if (usedDevices.Contains(selected2))
selected2 = null;
else
usedDevices.Add(selected2);
}
if (devices.Count >= 1)
if (selected1 == null && !Spectrometer1.HasSavedDevicePreference)
{
Spectrometer1.SelectedDevice = devices[0];
Spectrometer1.DeviceNames.Clear();
Spectrometer1.DeviceNames.Add(devices[0].Name);
Spectrometer1.SelectedDeviceName = devices[0].Name;
}
if (devices.Count >= 2)
{
Spectrometer2.SelectedDevice = devices[1];
Spectrometer2.DeviceNames.Clear();
Spectrometer2.DeviceNames.Add(devices[1].Name);
Spectrometer2.SelectedDeviceName = devices[1].Name;
selected1 = FirstUnusedDevice(devices, usedDevices);
if (selected1 != null)
usedDevices.Add(selected1);
}
foreach (var d in devices)
if (selected2 == null && !Spectrometer2.HasSavedDevicePreference)
{
if (!Spectrometer1.DeviceNames.Contains(d.Name))
Spectrometer1.DeviceNames.Add(d.Name);
if (!Spectrometer2.DeviceNames.Contains(d.Name))
Spectrometer2.DeviceNames.Add(d.Name);
selected2 = FirstUnusedDevice(devices, usedDevices);
if (selected2 != null)
usedDevices.Add(selected2);
}
Spectrometer1.SelectDevice(selected1, rememberPreference: selected1 != null);
Spectrometer2.SelectDevice(selected2, rememberPreference: selected2 != null);
}
public SpectrometerSettings GetSettings1() => Spectrometer1.ToSettings();
@@ -80,6 +84,10 @@ public partial class DeviceSettingsViewModel : ObservableObject
Spectrometer1.GroupName = settings.Spectrometer1Name.Trim();
if (!string.IsNullOrWhiteSpace(settings.Spectrometer2Name))
Spectrometer2.GroupName = settings.Spectrometer2Name.Trim();
Spectrometer1.LoadDevicePreference(settings.Spectrometer1DeviceName,
settings.Spectrometer1DeviceSerialNumber);
Spectrometer2.LoadDevicePreference(settings.Spectrometer2DeviceName,
settings.Spectrometer2DeviceSerialNumber);
if (settings.Spectrometer1ShowInPanel.HasValue)
Spectrometer1.ShowInSpectrometerPanel = settings.Spectrometer1ShowInPanel.Value;
if (settings.Spectrometer2ShowInPanel.HasValue)
@@ -108,6 +116,10 @@ public partial class DeviceSettingsViewModel : ObservableObject
{
Spectrometer1Name = Spectrometer1.GroupName,
Spectrometer2Name = Spectrometer2.GroupName,
Spectrometer1DeviceName = Spectrometer1.SavedDeviceName,
Spectrometer2DeviceName = Spectrometer2.SavedDeviceName,
Spectrometer1DeviceSerialNumber = Spectrometer1.SavedDeviceSerialNumber,
Spectrometer2DeviceSerialNumber = Spectrometer2.SavedDeviceSerialNumber,
Spectrometer1ShowInPanel = Spectrometer1.ShowInSpectrometerPanel,
Spectrometer2ShowInPanel = Spectrometer2.ShowInSpectrometerPanel,
Spectrometer1DarkNoiseEnabled = Spectrometer1.DarkNoiseEnabled,
@@ -124,6 +136,7 @@ public partial class DeviceSettingsViewModel : ObservableObject
private void OnSpectrometerPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(SpectrometerGroupViewModel.GroupName)
|| e.PropertyName == nameof(SpectrometerGroupViewModel.SelectedDeviceName)
|| e.PropertyName == nameof(SpectrometerGroupViewModel.ShowInSpectrometerPanel)
|| e.PropertyName == nameof(SpectrometerGroupViewModel.DarkNoiseEnabled)
|| e.PropertyName == nameof(SpectrometerGroupViewModel.TriggerTimeoutMs)
@@ -131,12 +144,51 @@ public partial class DeviceSettingsViewModel : ObservableObject
|| e.PropertyName == nameof(SpectrometerGroupViewModel.MaxWavelengthNm))
SaveSettings();
}
private static void PopulateDeviceList(SpectrometerGroupViewModel group,
IReadOnlyList<ISpectrometerDevice> devices)
{
group.AvailableDevices.Clear();
foreach (var device in devices)
group.AvailableDevices.Add(device);
group.DeviceNames.Clear();
group.DeviceNames.Add(SpectrometerGroupViewModel.NotConnectedDeviceName);
foreach (var deviceName in devices.Select(d => d.Name).Distinct())
group.DeviceNames.Add(deviceName);
}
private static ISpectrometerDevice? FindSavedDevice(SpectrometerGroupViewModel group,
IReadOnlyList<ISpectrometerDevice> devices)
{
if (!string.IsNullOrWhiteSpace(group.SavedDeviceName))
{
var byName = devices.FirstOrDefault(device =>
string.Equals(device.Name, group.SavedDeviceName, StringComparison.OrdinalIgnoreCase));
if (byName != null)
return byName;
}
if (!string.IsNullOrWhiteSpace(group.SavedDeviceSerialNumber))
{
return devices.FirstOrDefault(device =>
string.Equals(device.SerialNumber, group.SavedDeviceSerialNumber, StringComparison.OrdinalIgnoreCase));
}
return null;
}
private static ISpectrometerDevice? FirstUnusedDevice(IReadOnlyList<ISpectrometerDevice> devices,
IReadOnlyCollection<ISpectrometerDevice> usedDevices)
=> devices.FirstOrDefault(device => !usedDevices.Contains(device));
}
public partial class SpectrometerGroupViewModel : ObservableObject
{
public const string NotConnectedDeviceName = "(未连接)";
public const int MinIntegrationTimeMs = 1;
public const int MaxIntegrationTimeMs = 1000;
public const int MaxIntegrationTimeMs = 10000;
private bool _suppressDevicePreferenceUpdate;
[ObservableProperty]
public partial string GroupName { get; set; }
@@ -178,10 +230,18 @@ public partial class SpectrometerGroupViewModel : ObservableObject
public ISpectrometerDevice? SelectedDevice { get; set; }
public string? SavedDeviceName { get; private set; }
public string? SavedDeviceSerialNumber { get; private set; }
public bool HasSavedDevicePreference =>
!string.IsNullOrWhiteSpace(SavedDeviceName)
|| !string.IsNullOrWhiteSpace(SavedDeviceSerialNumber);
public SpectrometerGroupViewModel(string groupName)
{
GroupName = groupName;
DeviceNames = ["(未连接)"];
DeviceNames = [NotConnectedDeviceName];
AcquireEnabled = true;
ShowInSpectrometerPanel = true;
IntegrationTimeMs = 100;
@@ -194,6 +254,47 @@ public partial class SpectrometerGroupViewModel : ObservableObject
partial void OnSelectedDeviceNameChanged(string? value)
{
SelectedDevice = AvailableDevices.FirstOrDefault(d => d.Name == value);
if (_suppressDevicePreferenceUpdate)
return;
if (SelectedDevice != null)
{
SaveDevicePreference(SelectedDevice);
}
else if (string.Equals(value, NotConnectedDeviceName, StringComparison.Ordinal))
{
SavedDeviceName = null;
SavedDeviceSerialNumber = null;
}
}
public void LoadDevicePreference(string? deviceName, string? serialNumber)
{
SavedDeviceName = string.IsNullOrWhiteSpace(deviceName) ? null : deviceName.Trim();
SavedDeviceSerialNumber = string.IsNullOrWhiteSpace(serialNumber) ? null : serialNumber.Trim();
}
public void SelectDevice(ISpectrometerDevice? device, bool rememberPreference)
{
_suppressDevicePreferenceUpdate = !rememberPreference;
try
{
SelectedDevice = device;
SelectedDeviceName = device?.Name ?? NotConnectedDeviceName;
}
finally
{
_suppressDevicePreferenceUpdate = false;
}
if (rememberPreference && device != null)
SaveDevicePreference(device);
}
private void SaveDevicePreference(ISpectrometerDevice device)
{
SavedDeviceName = device.Name;
SavedDeviceSerialNumber = device.SerialNumber;
}
partial void OnIntegrationTimeMsChanged(int value)
+47 -21
View File
@@ -42,9 +42,6 @@ public partial class EasyTtlViewModel : ObservableObject, IDisposable
[ObservableProperty]
public partial bool OutputEnabled { get; set; }
[ObservableProperty]
public partial int LaserStabilizationDelayMs { get; set; } = 2500;
[ObservableProperty]
public partial ObservableCollection<EasyTtlChannelViewModel> Channels { get; set; } =
[
@@ -89,6 +86,18 @@ public partial class EasyTtlViewModel : ObservableObject, IDisposable
Log.Information("EasyTTL channel laser panel visibility changed channel={Channel} show={Show}",
channel.Channel, channel.ShowInLaserPanel);
}
else if (e.PropertyName == nameof(EasyTtlChannelViewModel.OpenDelayMs))
{
SaveSettings();
Log.Information("EasyTTL channel open delay changed channel={Channel} delayMs={DelayMs}",
channel.Channel, channel.OpenDelayMs);
}
else if (e.PropertyName == nameof(EasyTtlChannelViewModel.CloseDelayMs))
{
SaveSettings();
Log.Information("EasyTTL channel close delay changed channel={Channel} delayMs={DelayMs}",
channel.Channel, channel.CloseDelayMs);
}
};
if (_simulate)
@@ -112,7 +121,6 @@ public partial class EasyTtlViewModel : ObservableObject, IDisposable
_loadingSettings = true;
try
{
LaserStabilizationDelayMs = Math.Clamp(settings.LaserStabilizationDelayMs ?? 2500, 0, 60000);
if (!string.IsNullOrWhiteSpace(settings.PortName))
{
_savedPortName = settings.PortName.Trim();
@@ -130,6 +138,14 @@ public partial class EasyTtlViewModel : ObservableObject, IDisposable
? $"CH{channel.Channel}"
: saved.Alias.Trim();
channel.ShowInLaserPanel = saved.ShowInLaserPanel ?? true;
channel.OpenDelayMs = Math.Clamp(
saved.OpenDelayMs ?? EasyTtlChannelViewModel.DefaultDelayMs,
EasyTtlChannelViewModel.MinDelayMs,
EasyTtlChannelViewModel.MaxDelayMs);
channel.CloseDelayMs = Math.Clamp(
saved.CloseDelayMs ?? EasyTtlChannelViewModel.DefaultDelayMs,
EasyTtlChannelViewModel.MinDelayMs,
EasyTtlChannelViewModel.MaxDelayMs);
}
}
finally
@@ -143,13 +159,14 @@ public partial class EasyTtlViewModel : ObservableObject, IDisposable
var settings = new EasyTtlSettingsFile
{
PortName = SelectedPortName,
LaserStabilizationDelayMs = LaserStabilizationDelayMs,
Channels = Channels
.Select(channel => new EasyTtlChannelSettings
{
Channel = channel.Channel,
Alias = channel.Alias,
ShowInLaserPanel = channel.ShowInLaserPanel
ShowInLaserPanel = channel.ShowInLaserPanel,
OpenDelayMs = channel.OpenDelayMs,
CloseDelayMs = channel.CloseDelayMs
})
.ToList()
};
@@ -157,21 +174,6 @@ public partial class EasyTtlViewModel : ObservableObject, IDisposable
_settingsStore.Save(settings);
}
partial void OnLaserStabilizationDelayMsChanged(int value)
{
if (_loadingSettings)
return;
if (value < 0 || value > 60000)
{
LaserStabilizationDelayMs = Math.Clamp(value, 0, 60000);
return;
}
SaveSettings();
Log.Information("EasyTTL laser stabilization delay changed delayMs={DelayMs}", value);
}
partial void OnSelectedPortNameChanged(string? value)
{
if (_loadingSettings || _suppressSettingsSave)
@@ -594,6 +596,10 @@ public partial class EasyTtlViewModel : ObservableObject, IDisposable
public partial class EasyTtlChannelViewModel(int channel, string alias) : ObservableObject
{
public const int MinDelayMs = 0;
public const int MaxDelayMs = 60000;
public const int DefaultDelayMs = 2000;
public int Channel { get; } = channel;
[ObservableProperty]
@@ -608,6 +614,12 @@ public partial class EasyTtlChannelViewModel(int channel, string alias) : Observ
[ObservableProperty]
public partial bool HardwareEnabled { get; set; }
[ObservableProperty]
public partial int OpenDelayMs { get; set; } = DefaultDelayMs;
[ObservableProperty]
public partial int CloseDelayMs { get; set; } = DefaultDelayMs;
public string DisplayName => string.IsNullOrWhiteSpace(Alias)
? $"CH{Channel}"
: Alias;
@@ -616,4 +628,18 @@ public partial class EasyTtlChannelViewModel(int channel, string alias) : Observ
{
OnPropertyChanged(nameof(DisplayName));
}
partial void OnOpenDelayMsChanged(int value)
{
var clamped = Math.Clamp(value, MinDelayMs, MaxDelayMs);
if (clamped != value)
OpenDelayMs = clamped;
}
partial void OnCloseDelayMsChanged(int value)
{
var clamped = Math.Clamp(value, MinDelayMs, MaxDelayMs);
if (clamped != value)
CloseDelayMs = clamped;
}
}
+139 -17
View File
@@ -71,6 +71,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
private SpectrumData? _activeSpectrum;
private readonly List<LaserAcquisitionResult> _laserResults = [];
private readonly List<LoopAcquisitionFrame> _loopFrames = [];
private int? _heldContinuousLaserChannel;
private static readonly string[] LaserColors =
[
@@ -148,6 +149,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
Acquisition.GetEstimatedAcquisitionMs = EstimateAcquisitionDurationMs;
Acquisition.GetContinuousStepCount = () => Math.Max(1, EasyTtl.GetAcquisitionLaserChannels().Count);
Acquisition.OnContinuousStarted = (_, _) => _loopFrames.Clear();
Acquisition.OnContinuousSessionFinished = ReleaseHeldContinuousLaserAsync;
Acquisition.GetCalibrationInfo = () =>
{
var parts = new List<string>();
@@ -201,6 +203,10 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
UpdateCharts();
else if (e.PropertyName == nameof(DataProcessingViewModel.AbsorbanceAutoSmooth))
UpdateAbsorbanceChart();
else if (e.PropertyName == nameof(DataProcessingViewModel.AbsorbanceStitchingEnabled)
|| e.PropertyName == nameof(DataProcessingViewModel.AbsorbanceStitchingWavelengthNm)
|| e.PropertyName == nameof(DataProcessingViewModel.AbsorbanceStitchingReference))
UpdateAbsorbanceChart();
else if (e.PropertyName == nameof(DataProcessingViewModel.SmoothingMethod)
|| e.PropertyName == nameof(DataProcessingViewModel.SmoothingWindowSize)
|| e.PropertyName == nameof(DataProcessingViewModel.SmoothingOrder))
@@ -225,6 +231,10 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
UpdateCalibrationStatus();
UpdateAbsorbanceChart();
}
else if (e.PropertyName == nameof(SpectrometerGroupViewModel.SelectedDeviceName))
{
OnSelectedSpectrometerChanged();
}
};
DeviceSettings.Spectrometer2.PropertyChanged += (_, e) =>
{
@@ -239,6 +249,10 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
UpdateCalibrationStatus();
UpdateAbsorbanceChart();
}
else if (e.PropertyName == nameof(SpectrometerGroupViewModel.SelectedDeviceName))
{
OnSelectedSpectrometerChanged();
}
};
}
@@ -272,6 +286,15 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
LoadCalibrationForCurrentSettings();
}
private void OnSelectedSpectrometerChanged()
{
_darkSpectrum1 = null;
_darkSpectrum2 = null;
_lightSpectrum1 = null;
_lightSpectrum2 = null;
LoadCalibrationForCurrentSettings();
}
private void OnEffectiveWavelengthRangeChanged()
{
UpdateCharts();
@@ -450,9 +473,13 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
var frameStartedAt = DateTime.Now;
var selectedLasers = EasyTtl.GetAcquisitionLaserChannels();
var results = new List<LaserAcquisitionResult>();
bool keepSingleLaserOn = Acquisition.IsContinuousSessionActive && selectedLasers.Count == 1;
try
{
if (!keepSingleLaserOn && _heldContinuousLaserChannel != null)
await TurnOffAcquisitionLasersAndClearHeldAsync(CancellationToken.None);
if (selectedLasers.Count == 0)
{
var (s1, s2) = await AcquirePairAsync(AcquisitionTarget.Sample, ct);
@@ -475,21 +502,22 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
Log.Information("Acquire laser sequence start channel={Channel} alias={Alias}",
laser.Channel, laser.DisplayName);
bool acquisitionCompleted = false;
bool resultAdded = false;
try
{
await EasyTtl.TurnOnAcquisitionLaserAsync(laser, ct);
if (EasyTtl.LaserStabilizationDelayMs > 0)
if (keepSingleLaserOn)
{
Acquisition.StatusText = $"等待 {laser.DisplayName} 稳定...";
Log.Information("Acquire laser stabilization wait start channel={Channel} alias={Alias} delayMs={DelayMs}",
laser.Channel, laser.DisplayName, EasyTtl.LaserStabilizationDelayMs);
await Task.Delay(EasyTtl.LaserStabilizationDelayMs, ct);
Log.Information("Acquire laser stabilization wait completed channel={Channel} alias={Alias} delayMs={DelayMs}",
laser.Channel, laser.DisplayName, EasyTtl.LaserStabilizationDelayMs);
await EnsureHeldContinuousLaserAsync(laser, ct);
}
else
{
await TurnOnAcquisitionLaserWithOpenDelayAsync(laser, ct);
}
Acquisition.StatusText = $"采集 {laser.DisplayName}...";
var (s1, s2) = await AcquirePairAsync(AcquisitionTarget.Sample, ct);
acquisitionCompleted = true;
if (_simulate)
{
s1 = s1 == null ? null : ApplySimulatedLaserResponse(s1, laser.Channel, 1);
@@ -505,21 +533,28 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
}
results.Add(CreateLaserAcquisitionResult(laser.Channel, laser.DisplayName, s1, s2));
resultAdded = true;
}
finally
{
if (!keepSingleLaserOn)
await TurnOffAcquisitionLasersWithCloseDelayAsync(laser, acquisitionCompleted, CancellationToken.None);
}
if (resultAdded)
{
Acquisition.ReportContinuousStepCompleted(
$"连续采集中 ({Acquisition.CurrentLoopIndex}/{Acquisition.CurrentLoopTotal})");
Log.Information("Acquire laser sequence completed channel={Channel} alias={Alias}",
laser.Channel, laser.DisplayName);
}
finally
{
await EasyTtl.TurnOffAcquisitionLasersAsync(CancellationToken.None);
}
}
}
}
finally
{
await EasyTtl.TurnOffAcquisitionLasersAsync(CancellationToken.None);
if (!keepSingleLaserOn)
await TurnOffAcquisitionLasersAndClearHeldAsync(CancellationToken.None);
}
if (results.Count == 0)
@@ -549,6 +584,87 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
PeakDetection.RunDetection();
}
private async Task EnsureHeldContinuousLaserAsync(EasyTtlChannelViewModel laser, CancellationToken ct)
{
if (_heldContinuousLaserChannel == laser.Channel)
{
Log.Information("Acquire single-laser continuous reuse channel={Channel} alias={Alias}",
laser.Channel, laser.DisplayName);
return;
}
if (_heldContinuousLaserChannel != null)
await TurnOffHeldContinuousLaserWithCloseDelayAsync(CancellationToken.None);
_heldContinuousLaserChannel = laser.Channel;
await TurnOnAcquisitionLaserWithOpenDelayAsync(laser, ct);
Log.Information("Acquire single-laser continuous hold channel={Channel} alias={Alias}",
laser.Channel, laser.DisplayName);
}
private async Task TurnOnAcquisitionLaserWithOpenDelayAsync(EasyTtlChannelViewModel laser, CancellationToken ct)
{
await EasyTtl.TurnOnAcquisitionLaserAsync(laser, ct);
if (laser.OpenDelayMs <= 0)
return;
Acquisition.StatusText = $"等待 {laser.DisplayName} 开光延迟...";
Log.Information("Acquire laser open delay wait start channel={Channel} alias={Alias} delayMs={DelayMs}",
laser.Channel, laser.DisplayName, laser.OpenDelayMs);
await Task.Delay(laser.OpenDelayMs, ct);
Log.Information("Acquire laser open delay wait completed channel={Channel} alias={Alias} delayMs={DelayMs}",
laser.Channel, laser.DisplayName, laser.OpenDelayMs);
}
private Task ReleaseHeldContinuousLaserAsync()
=> _heldContinuousLaserChannel == null
? Task.CompletedTask
: TurnOffHeldContinuousLaserWithCloseDelayAsync(CancellationToken.None);
private async Task TurnOffHeldContinuousLaserWithCloseDelayAsync(CancellationToken cancellationToken)
{
var laser = EasyTtl.Channels.FirstOrDefault(channel => channel.Channel == _heldContinuousLaserChannel);
if (laser != null)
{
await TurnOffAcquisitionLasersWithCloseDelayAsync(
laser,
waitCloseDelay: true,
cancellationToken);
return;
}
await TurnOffAcquisitionLasersAndClearHeldAsync(cancellationToken);
}
private async Task TurnOffAcquisitionLasersWithCloseDelayAsync(EasyTtlChannelViewModel laser,
bool waitCloseDelay,
CancellationToken cancellationToken)
{
if (waitCloseDelay && laser.CloseDelayMs > 0)
{
Acquisition.StatusText = $"等待 {laser.DisplayName} 关光延迟...";
Log.Information("Acquire laser close delay wait start channel={Channel} alias={Alias} delayMs={DelayMs}",
laser.Channel, laser.DisplayName, laser.CloseDelayMs);
await Task.Delay(laser.CloseDelayMs, cancellationToken);
Log.Information("Acquire laser close delay wait completed channel={Channel} alias={Alias} delayMs={DelayMs}",
laser.Channel, laser.DisplayName, laser.CloseDelayMs);
}
await TurnOffAcquisitionLasersAndClearHeldAsync(cancellationToken);
}
private async Task TurnOffAcquisitionLasersAndClearHeldAsync(CancellationToken cancellationToken)
{
try
{
await EasyTtl.TurnOffAcquisitionLasersAsync(cancellationToken);
}
finally
{
_heldContinuousLaserChannel = null;
}
}
private static SpectrumData ApplySimulatedLaserResponse(SpectrumData spectrum, int laserChannel, int deviceIndex)
{
var intensities = new double[spectrum.Intensities.Length];
@@ -893,10 +1009,16 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
dark2 = PrepareAbsorbanceInput(dark2);
ref2 = PrepareAbsorbanceInput(ref2);
var sample = SpectrumData.Merge(sample1, sample2, LaserSettings.LaserWavelengthNm);
var darkData = SpectrumData.Merge(dark1, dark2, LaserSettings.LaserWavelengthNm);
var refData = SpectrumData.Merge(ref1, ref2, LaserSettings.LaserWavelengthNm);
var absorbance = SpectrumProcessor.CalculateAbsorbance(sample, darkData, refData);
var absorbance1 = SpectrumProcessor.CalculateAbsorbance(sample1, dark1, ref1);
var absorbance2 = SpectrumProcessor.CalculateAbsorbance(sample2, dark2, ref2);
var absorbance = DataProcessing.AbsorbanceStitchingEnabled
? SpectrumProcessor.StitchAbsorbance(
absorbance1,
absorbance2,
DataProcessing.AbsorbanceStitchingWavelengthNm,
DataProcessing.AbsorbanceStitchingReference != "光谱仪1",
LaserSettings.LaserWavelengthNm)
: SpectrumData.Merge(absorbance1, absorbance2, LaserSettings.LaserWavelengthNm);
SpectrumData? smoothed = null;
if (DataProcessing.AbsorbanceAutoSmooth || forceSmooth)
@@ -11,6 +11,42 @@
IsChecked="{Binding AutoProcessAfterAcquire}" />
<CheckBox Content="吸光度自动平滑"
IsChecked="{Binding AbsorbanceAutoSmooth}" />
<CheckBox Content="启用吸光度拼接修正"
IsChecked="{Binding AbsorbanceStitchingEnabled}" />
<Grid ColumnDefinitions="96,*"
RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0"
Text="拼接波长"
VerticalAlignment="Center"
Margin="0,0,8,4" />
<NumericUpDown Grid.Row="0"
Grid.Column="1"
Value="{Binding AbsorbanceStitchingWavelengthNm}"
Minimum="1"
Maximum="10000"
Increment="1"
FormatString="0.###"
HorizontalAlignment="Stretch"
Margin="0,0,0,4">
<NumericUpDown.InnerRightContent>
<TextBlock Text="nm"
Foreground="Gray"
VerticalAlignment="Center"
Margin="0,0,4,0" />
</NumericUpDown.InnerRightContent>
</NumericUpDown>
<TextBlock Grid.Row="1"
Text="拼接基准"
VerticalAlignment="Center"
Margin="0,0,8,0" />
<ComboBox Grid.Row="1"
Grid.Column="1"
ItemsSource="{Binding AbsorbanceStitchingReferences}"
SelectedItem="{Binding AbsorbanceStitchingReference}"
HorizontalAlignment="Stretch" />
</Grid>
<Separator />
+2 -2
View File
@@ -25,7 +25,7 @@
<TextBlock Grid.Row="1" Text="积分时间" VerticalAlignment="Center" Margin="0,0,8,0" />
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding Spectrometer1.IntegrationTimeMs}"
Minimum="1" Maximum="1000" Increment="1" FormatString="0" Margin="0,0,0,4">
Minimum="1" Maximum="10000" Increment="1" FormatString="0" Margin="0,0,0,4">
<NumericUpDown.InnerRightContent>
<TextBlock Text="ms" Foreground="Gray" VerticalAlignment="Center" Margin="0,0,4,0" />
</NumericUpDown.InnerRightContent>
@@ -68,7 +68,7 @@
<TextBlock Grid.Row="1" Text="积分时间" VerticalAlignment="Center" Margin="0,0,8,0" />
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding Spectrometer2.IntegrationTimeMs}"
Minimum="1" Maximum="1000" Increment="1" FormatString="0" Margin="0,0,0,4">
Minimum="1" Maximum="10000" Increment="1" FormatString="0" Margin="0,0,0,4">
<NumericUpDown.InnerRightContent>
<TextBlock Text="ms" Foreground="Gray" VerticalAlignment="Center" Margin="0,0,4,0" />
</NumericUpDown.InnerRightContent>
+26 -14
View File
@@ -157,19 +157,6 @@
IsEnabled="{Binding EasyTtl.IsConnected}" OffContent="OFF" OnContent="ON"/>
</Grid>
<Grid ColumnDefinitions="*,220">
<TextBlock VerticalAlignment="Center" Text="激光稳定等待"/>
<NumericUpDown Grid.Column="1"
Value="{Binding EasyTtl.LaserStabilizationDelayMs}"
Minimum="0" Maximum="60000"
Increment="100"
FormatString="F0">
<NumericUpDown.InnerRightContent>
<TextBlock Margin="6,0" VerticalAlignment="Center" Foreground="Gray" Text="ms"/>
</NumericUpDown.InnerRightContent>
</NumericUpDown>
</Grid>
<Separator/>
<TextBlock FontWeight="SemiBold" Text="通道配置"/>
@@ -178,7 +165,7 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:EasyTtlChannelViewModel">
<Border Padding="0,5" BorderBrush="{DynamicResource SemiColorBorder}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="36,*,Auto,Auto,Auto,Auto">
<Grid ColumnDefinitions="36,*,Auto,Auto,Auto,Auto" RowDefinitions="Auto,Auto" RowSpacing="6">
<TextBlock Grid.Column="0" VerticalAlignment="Center" FontWeight="SemiBold"
Text="{Binding Channel, StringFormat=CH{0}}"/>
@@ -190,6 +177,31 @@
<ToggleSwitch Grid.Column="5" IsChecked="{Binding HardwareEnabled}"
IsEnabled="{Binding $parent[Window].DataContext.EasyTtl.IsConnected}"
OffContent="OFF" OnContent="ON"/>
<Grid Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="5"
ColumnDefinitions="Auto,110,Auto,110,*">
<TextBlock Grid.Column="0" Margin="0,0,6,0" VerticalAlignment="Center" Foreground="Gray" Text="开光延迟"/>
<NumericUpDown Grid.Column="1"
Value="{Binding OpenDelayMs}"
Minimum="0" Maximum="60000"
Increment="100"
FormatString="F0">
<NumericUpDown.InnerRightContent>
<TextBlock Margin="4,0" VerticalAlignment="Center" Foreground="Gray" Text="ms"/>
</NumericUpDown.InnerRightContent>
</NumericUpDown>
<TextBlock Grid.Column="2" Margin="12,0,6,0" VerticalAlignment="Center" Foreground="Gray" Text="关光延迟"/>
<NumericUpDown Grid.Column="3"
Value="{Binding CloseDelayMs}"
Minimum="0" Maximum="60000"
Increment="100"
FormatString="F0">
<NumericUpDown.InnerRightContent>
<TextBlock Margin="4,0" VerticalAlignment="Center" Foreground="Gray" Text="ms"/>
</NumericUpDown.InnerRightContent>
</NumericUpDown>
</Grid>
</Grid>
</Border>
</DataTemplate>
@@ -0,0 +1,63 @@
using RamanApp.ViewModels;
namespace Raman.TUI.Tests.App;
public sealed class AcquisitionViewModelTests
{
[Fact]
public async Task Continuous_acquire_marks_session_active_and_runs_cleanup_when_stopped()
{
var vm = new AcquisitionViewModel();
var activeDuringAcquire = false;
var cleanupCount = 0;
vm.OnContinuousAcquire = _ =>
{
activeDuringAcquire = vm.IsContinuousSessionActive;
vm.StopAcquireCommand.Execute(null);
return Task.CompletedTask;
};
vm.OnContinuousSessionFinished = () =>
{
cleanupCount++;
Assert.False(vm.IsContinuousSessionActive);
return Task.CompletedTask;
};
await vm.ContinuousAcquireCommand.ExecuteAsync(null);
Assert.True(activeDuringAcquire);
Assert.False(vm.IsContinuousSessionActive);
Assert.Equal(1, cleanupCount);
}
[Fact]
public async Task Timed_loop_marks_session_active_and_runs_cleanup_after_completion()
{
var vm = new AcquisitionViewModel
{
ContinuousCount = 2,
};
var activeAcquireCount = 0;
var cleanupCount = 0;
vm.OnContinuousAcquire = _ =>
{
if (vm.IsContinuousSessionActive)
activeAcquireCount++;
return Task.CompletedTask;
};
vm.OnContinuousSessionFinished = () =>
{
cleanupCount++;
Assert.False(vm.IsContinuousSessionActive);
return Task.CompletedTask;
};
await vm.TimedLoopAcquireCommand.ExecuteAsync(null);
Assert.Equal(2, activeAcquireCount);
Assert.False(vm.IsContinuousSessionActive);
Assert.Equal(1, cleanupCount);
}
}
@@ -0,0 +1,62 @@
using RamanApp.Services;
using RamanApp.ViewModels;
namespace Raman.TUI.Tests.App;
public sealed class DataProcessingViewModelTests
{
[Fact]
public void Absorbance_stitching_defaults_to_enabled_at_850nm_with_spectrometer2_reference()
{
var path = CreateTempSettingsPath();
try
{
var vm = new DataProcessingViewModel(new DataProcessingSettingsStore(path));
Assert.True(vm.AbsorbanceStitchingEnabled);
Assert.Equal(850, vm.AbsorbanceStitchingWavelengthNm);
Assert.Equal("光谱仪2", vm.AbsorbanceStitchingReference);
}
finally
{
DeleteIfExists(path);
}
}
[Fact]
public void Absorbance_stitching_settings_are_saved_and_loaded()
{
var path = CreateTempSettingsPath();
try
{
var vm = new DataProcessingViewModel(new DataProcessingSettingsStore(path));
vm.AbsorbanceStitchingEnabled = false;
vm.AbsorbanceStitchingWavelengthNm = 875.5;
vm.AbsorbanceStitchingReference = "光谱仪1";
var loaded = new DataProcessingViewModel(new DataProcessingSettingsStore(path));
Assert.False(loaded.AbsorbanceStitchingEnabled);
Assert.Equal(875.5, loaded.AbsorbanceStitchingWavelengthNm);
Assert.Equal("光谱仪1", loaded.AbsorbanceStitchingReference);
}
finally
{
DeleteIfExists(path);
}
}
private static string CreateTempSettingsPath()
{
var dir = Path.Combine(Path.GetTempPath(), "raman-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return Path.Combine(dir, "data-processing.json");
}
private static void DeleteIfExists(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
@@ -0,0 +1,194 @@
using RamanApp.ViewModels;
using RamanApp.Services;
namespace Raman.TUI.Tests.App;
public sealed class DeviceSettingsViewModelTests
{
[Fact]
public void Integration_time_allows_ten_seconds_and_clamps_above_it()
{
var group = new SpectrometerGroupViewModel("test");
group.IntegrationTimeMs = 10000;
Assert.Equal(10000, group.IntegrationTimeMs);
Assert.Equal(10000, group.ToSettings().IntegrationTimeMs);
group.IntegrationTimeMs = 10001;
Assert.Equal(10000, group.IntegrationTimeMs);
Assert.Equal(10000, group.ToSettings().IntegrationTimeMs);
}
[Fact]
public void Populate_devices_restores_saved_spectrometer_by_name_when_order_changes()
{
var path = CreateTempSettingsPath();
try
{
new SpectrometerSettingsStore(path).Save(new SpectrometerSettingsFile
{
Spectrometer1DeviceName = "UV",
Spectrometer2DeviceName = "NIR",
});
var vm = new DeviceSettingsViewModel(new SpectrometerSettingsStore(path));
var uv = CreateDevice("UV", "S1");
var nir = CreateDevice("NIR", "S2");
vm.PopulateDevices([nir, uv]);
Assert.Same(uv, vm.Spectrometer1.SelectedDevice);
Assert.Same(nir, vm.Spectrometer2.SelectedDevice);
Assert.Equal("UV", vm.Spectrometer1.SelectedDeviceName);
Assert.Equal("NIR", vm.Spectrometer2.SelectedDeviceName);
}
finally
{
DeleteIfExists(path);
}
}
[Fact]
public void Populate_devices_uses_serial_number_when_saved_name_is_not_found()
{
var path = CreateTempSettingsPath();
try
{
new SpectrometerSettingsStore(path).Save(new SpectrometerSettingsFile
{
Spectrometer1DeviceName = "Old UV",
Spectrometer1DeviceSerialNumber = "S1",
});
var vm = new DeviceSettingsViewModel(new SpectrometerSettingsStore(path));
var uv = CreateDevice("New UV", "S1");
var nir = CreateDevice("NIR", "S2");
vm.PopulateDevices([nir, uv]);
Assert.Same(uv, vm.Spectrometer1.SelectedDevice);
Assert.Equal("New UV", vm.Spectrometer1.SelectedDeviceName);
}
finally
{
DeleteIfExists(path);
}
}
[Fact]
public void Populate_devices_does_not_assign_saved_device_to_unsaved_second_slot()
{
var path = CreateTempSettingsPath();
try
{
new SpectrometerSettingsStore(path).Save(new SpectrometerSettingsFile
{
Spectrometer1DeviceName = "NIR",
});
var vm = new DeviceSettingsViewModel(new SpectrometerSettingsStore(path));
var uv = CreateDevice("UV", "S1");
var nir = CreateDevice("NIR", "S2");
vm.PopulateDevices([uv, nir]);
Assert.Same(nir, vm.Spectrometer1.SelectedDevice);
Assert.Same(uv, vm.Spectrometer2.SelectedDevice);
}
finally
{
DeleteIfExists(path);
}
}
[Fact]
public void Populate_devices_does_not_assign_saved_second_device_to_unsaved_first_slot()
{
var path = CreateTempSettingsPath();
try
{
new SpectrometerSettingsStore(path).Save(new SpectrometerSettingsFile
{
Spectrometer2DeviceName = "UV",
});
var vm = new DeviceSettingsViewModel(new SpectrometerSettingsStore(path));
var uv = CreateDevice("UV", "S1");
var nir = CreateDevice("NIR", "S2");
vm.PopulateDevices([uv, nir]);
Assert.Same(nir, vm.Spectrometer1.SelectedDevice);
Assert.Same(uv, vm.Spectrometer2.SelectedDevice);
}
finally
{
DeleteIfExists(path);
}
}
[Fact]
public void Populate_devices_keeps_saved_missing_device_unassigned()
{
var path = CreateTempSettingsPath();
try
{
new SpectrometerSettingsStore(path).Save(new SpectrometerSettingsFile
{
Spectrometer1DeviceName = "Missing UV",
});
var vm = new DeviceSettingsViewModel(new SpectrometerSettingsStore(path));
vm.PopulateDevices([CreateDevice("NIR", "S2")]);
Assert.Null(vm.Spectrometer1.SelectedDevice);
Assert.Equal(SpectrometerGroupViewModel.NotConnectedDeviceName,
vm.Spectrometer1.SelectedDeviceName);
}
finally
{
DeleteIfExists(path);
}
}
[Fact]
public void Selected_device_change_is_saved_with_name_and_serial_number()
{
var path = CreateTempSettingsPath();
try
{
var vm = new DeviceSettingsViewModel(new SpectrometerSettingsStore(path));
var uv = CreateDevice("UV", "S1");
var nir = CreateDevice("NIR", "S2");
vm.PopulateDevices([uv, nir]);
vm.Spectrometer1.SelectedDeviceName = "NIR";
var saved = new SpectrometerSettingsStore(path).Load();
Assert.Equal("NIR", saved.Spectrometer1DeviceName);
Assert.Equal("S2", saved.Spectrometer1DeviceSerialNumber);
}
finally
{
DeleteIfExists(path);
}
}
private static FakeSpectrometerDevice CreateDevice(string name, string serialNumber)
=> new(name, serialNumber, 350, 1000);
private static string CreateTempSettingsPath()
{
var dir = Path.Combine(Path.GetTempPath(), "raman-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return Path.Combine(dir, "spectrometer.json");
}
private static void DeleteIfExists(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
@@ -0,0 +1,81 @@
using RamanApp.Services;
using RamanApp.ViewModels;
namespace Raman.TUI.Tests.App;
public sealed class EasyTtlViewModelTests
{
[Fact]
public void Channels_default_open_and_close_delays_to_two_seconds()
{
var path = CreateTempSettingsPath();
try
{
var vm = new EasyTtlViewModel(true, new EasyTtlSettingsStore(path));
Assert.All(vm.Channels, channel =>
{
Assert.Equal(2000, channel.OpenDelayMs);
Assert.Equal(2000, channel.CloseDelayMs);
});
}
finally
{
DeleteIfExists(path);
}
}
[Fact]
public void Channels_load_and_save_per_channel_open_and_close_delays()
{
var path = CreateTempSettingsPath();
try
{
File.WriteAllText(path, """
{
"channels": [
{
"channel": 1,
"alias": "laser-a",
"openDelayMs": 1200,
"closeDelayMs": 3400
}
]
}
""");
var vm = new EasyTtlViewModel(true, new EasyTtlSettingsStore(path));
var channel = Assert.Single(vm.Channels, item => item.Channel == 1);
Assert.Equal(1200, channel.OpenDelayMs);
Assert.Equal(3400, channel.CloseDelayMs);
channel.OpenDelayMs = 2100;
channel.CloseDelayMs = 4300;
var saved = new EasyTtlSettingsStore(path).Load();
var savedChannel = Assert.Single(saved.Channels, item => item.Channel == 1);
Assert.Equal(2100, savedChannel.OpenDelayMs);
Assert.Equal(4300, savedChannel.CloseDelayMs);
Assert.Null(saved.LaserStabilizationDelayMs);
}
finally
{
DeleteIfExists(path);
}
}
private static string CreateTempSettingsPath()
{
var dir = Path.Combine(Path.GetTempPath(), "raman-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return Path.Combine(dir, "easyttl.json");
}
private static void DeleteIfExists(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
@@ -0,0 +1,129 @@
using RamanApp.Models;
using RamanApp.Services;
namespace Raman.TUI.Tests.App;
public sealed class SpectrumFileServiceTests
{
[Fact]
public void ExportCsv_writes_multiple_spectra_as_wide_columns()
{
var series = new[]
{
CreateSeries("Laser1", 1, "光谱仪1", [500, 501], [10, 11]),
CreateSeries("Laser2", 2, "光谱仪2", [500, 501], [20, 21]),
};
var lines = ExportLines(series);
Assert.Contains("# Layout: Wide", lines);
Assert.Contains("Wavelength(nm),Raw|Laser1|Ch1|光谱仪1,Raw|Laser2|Ch2|光谱仪2", lines);
Assert.Contains("500.0000,10.0000,20.0000", lines);
Assert.Contains("501.0000,11.0000,21.0000", lines);
Assert.DoesNotContain(lines, line => line.StartsWith("Raw,Laser1", StringComparison.Ordinal));
}
[Fact]
public void ExportCsv_places_smoothed_column_next_to_source_column()
{
var series = new[]
{
CreateSeries("Laser1", 1, "光谱仪1", [500, 501], [10, 11], [9.5, 10.5]),
};
var lines = ExportLines(series);
Assert.Contains("Wavelength(nm),Raw|Laser1|Ch1|光谱仪1,Raw|Laser1|Ch1|光谱仪1|Smoothed", lines);
Assert.Contains("500.0000,10.0000,9.5000", lines);
}
[Fact]
public void ExportCsv_aligns_by_wavelength_union_and_leaves_missing_cells_empty()
{
var series = new[]
{
CreateSeries("Laser1", 1, "光谱仪1", [500, 501], [10, 11]),
CreateSeries("Laser2", 2, "光谱仪2", [500.5, 501], [20, 21]),
};
var lines = ExportLines(series);
Assert.Contains("500.0000,10.0000,", lines);
Assert.Contains("500.5000,,20.0000", lines);
Assert.Contains("501.0000,11.0000,21.0000", lines);
}
[Fact]
public void ExportCsv_includes_absorbance_and_loop_metadata_in_wide_headers()
{
var timestamp = new DateTime(2026, 7, 4, 16, 20, 0, 123);
var series = new[]
{
new SpectrumExportSeries(
"Laser1",
1,
"A+B",
new SpectrumData([500], [0.1234], "abs"),
LoopIndex: 2,
LoopTimestamp: timestamp,
DataType: "Absorbance"),
};
var lines = ExportLines(series);
Assert.Contains("Wavelength(nm),Loop2|2026-07-04 16:20:00.123|Absorbance|Laser1|Ch1|A+B", lines);
Assert.Contains("500.0000,0.1234", lines);
}
[Fact]
public void ExportCsv_escapes_wide_headers()
{
var series = new[]
{
CreateSeries("Laser, \"A\"", 1, "Spec,1", [500], [10]),
};
var lines = ExportLines(series);
Assert.Contains("\"Raw|Laser, \"\"A\"\"|Ch1|Spec,1\"", lines[5]);
}
[Fact]
public void ToClipboardText_uses_same_wide_shape_with_tabs()
{
var series = new[]
{
CreateSeries("Laser1", null, "光谱仪1", [500, 501], [10, 11]),
CreateSeries("Laser2", null, "光谱仪2", [500, 501], [20, 21]),
};
var text = SpectrumFileService.ToClipboardText(series);
Assert.StartsWith("Wavelength(nm)\tRaw|Laser1|光谱仪1\tRaw|Laser2|光谱仪2", text);
Assert.Contains("500.0000\t10.0000\t20.0000", text);
}
private static SpectrumExportSeries CreateSeries(string laser, int? channel, string device,
double[] wavelengths, double[] intensities, double[]? smoothed = null)
=> new(
laser,
channel,
device,
new SpectrumData(wavelengths, intensities, "raw"),
smoothed == null ? null : new SpectrumData(wavelengths, smoothed, "smoothed"));
private static string[] ExportLines(IReadOnlyList<SpectrumExportSeries> series)
{
var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.csv");
try
{
SpectrumFileService.ExportCsv(path, series, 785);
return File.ReadAllLines(path);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
}
@@ -0,0 +1,58 @@
using RamanApp.Models;
using RamanApp.Services;
namespace Raman.TUI.Tests.App;
public sealed class SpectrumProcessorTests
{
[Fact]
public void Stitch_absorbance_offsets_spectrometer1_to_spectrometer2_at_seam()
{
var spectrometer1 = new SpectrumData([800, 850, 900], [1, 1, 1]);
var spectrometer2 = new SpectrumData([850, 900, 950], [2, 2, 2]);
var stitched = SpectrumProcessor.StitchAbsorbance(
spectrometer1,
spectrometer2,
stitchingWavelengthNm: 850,
referenceSpectrometer2: true,
laserWavelengthNm: 785);
Assert.Equal([800, 850, 900, 950], stitched.Wavelengths);
Assert.All(stitched.Intensities, value => Assert.Equal(2, value, precision: 6));
}
[Fact]
public void Stitch_absorbance_clamps_seam_to_overlap_when_configured_wavelength_is_outside_overlap()
{
var spectrometer1 = new SpectrumData([800, 850, 900], [1, 1, 1]);
var spectrometer2 = new SpectrumData([850, 900, 950], [3, 3, 3]);
var stitched = SpectrumProcessor.StitchAbsorbance(
spectrometer1,
spectrometer2,
stitchingWavelengthNm: 840,
referenceSpectrometer2: true,
laserWavelengthNm: 785);
Assert.Contains(850, stitched.Wavelengths);
Assert.All(stitched.Intensities, value => Assert.Equal(3, value, precision: 6));
}
[Fact]
public void Stitch_absorbance_offsets_when_spectrometers_touch_at_single_wavelength()
{
var spectrometer1 = new SpectrumData([800, 850], [1, 1]);
var spectrometer2 = new SpectrumData([850, 900], [2, 2]);
var stitched = SpectrumProcessor.StitchAbsorbance(
spectrometer1,
spectrometer2,
stitchingWavelengthNm: 850,
referenceSpectrometer2: true,
laserWavelengthNm: 785);
Assert.Equal([800, 850, 900], stitched.Wavelengths);
Assert.All(stitched.Intensities, value => Assert.Equal(2, value, precision: 6));
}
}
@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Raman.Simulator\Raman.Simulator.csproj" />
<ProjectReference Include="..\..\src\Raman.TUI\Raman.TUI.csproj" />
<ProjectReference Include="..\..\src\RamanApp\RamanApp.csproj" />
<ProjectReference Include="..\..\src\Roman.Host\Roman.Host.csproj" />
</ItemGroup>