feat: 优化若干问题
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user