292 lines
13 KiB
C#
292 lines
13 KiB
C#
|
|
using Microsoft.Extensions.Logging;
|
|||
|
|
using Microsoft.Extensions.Options;
|
|||
|
|
using Pcie8586Probe.Hardware;
|
|||
|
|
using Pcie8586Probe.Models;
|
|||
|
|
using SSPCTester.Devices.Config;
|
|||
|
|
using SSPCTester.Devices.Interfaces;
|
|||
|
|
using DriverInputRange = Pcie8586Probe.Models.InputRange;
|
|||
|
|
|
|||
|
|
namespace SSPCTester.Devices.Drivers.Real;
|
|||
|
|
|
|||
|
|
/// <summary>PCIe8586 高速采集卡适配器。原生句柄仅由 IDigitizer 持有。</summary>
|
|||
|
|
public sealed class Pcie8586Daq : IDaq, IDisposable
|
|||
|
|
{
|
|||
|
|
private readonly DaqOptions _options;
|
|||
|
|
private readonly ILogger<Pcie8586Daq> _logger;
|
|||
|
|
private readonly IDigitizer _digitizer;
|
|||
|
|
private readonly SemaphoreSlim _operationLock = new(1, 1);
|
|||
|
|
private bool _connected;
|
|||
|
|
|
|||
|
|
public Pcie8586Daq(IOptions<DeviceOptions> options, ILogger<Pcie8586Daq> logger)
|
|||
|
|
: this(options.Value.Daq, logger, new Pcie8586Digitizer())
|
|||
|
|
{
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal Pcie8586Daq(DaqOptions options, ILogger<Pcie8586Daq> logger, IDigitizer digitizer)
|
|||
|
|
{
|
|||
|
|
_options = options;
|
|||
|
|
_logger = logger;
|
|||
|
|
_digitizer = digitizer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public bool IsConnected => _connected && _digitizer.IsOpen;
|
|||
|
|
public string DisplayName => "PCIe8586 高速采集卡";
|
|||
|
|
public event EventHandler<bool>? ConnectionChanged;
|
|||
|
|
public event EventHandler<CurveSample>? SampleReceived;
|
|||
|
|
|
|||
|
|
public async Task<IReadOnlyList<DaqDeviceDescriptor>> EnumerateDevicesAsync(CancellationToken ct = default)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
var devices = await _digitizer.EnumerateDevicesAsync(ct);
|
|||
|
|
return devices.Select(x => new DaqDeviceDescriptor(
|
|||
|
|
x.LogicalId, x.PhysicalId, x.Description, x.IsSimulated)).ToArray();
|
|||
|
|
}
|
|||
|
|
catch (DllNotFoundException ex)
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException("找不到 ACTS1000_64.dll。请安装厂商驱动并将 x64 运行库放到程序目录。", ex);
|
|||
|
|
}
|
|||
|
|
catch (BadImageFormatException ex)
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException("ACTS1000_64.dll 位数不匹配,程序和运行库必须都是 x64。", ex);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public async Task<bool> ConnectAsync(CancellationToken ct = default)
|
|||
|
|
{
|
|||
|
|
await _operationLock.WaitAsync(ct);
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (IsConnected) return true;
|
|||
|
|
var devices = await EnumerateDevicesAsync(ct);
|
|||
|
|
var selected = devices.SingleOrDefault(x => x.LogicalId == _options.LogicalDeviceId)
|
|||
|
|
?? throw new InvalidOperationException($"未发现逻辑 ID {_options.LogicalDeviceId} 的 PCIe8586。");
|
|||
|
|
await _digitizer.OpenAsync(
|
|||
|
|
new DeviceInfo(selected.LogicalId, selected.PhysicalId, selected.Description, selected.IsSimulated), ct);
|
|||
|
|
_connected = true;
|
|||
|
|
ConnectionChanged?.Invoke(this, true);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
finally
|
|||
|
|
{
|
|||
|
|
_operationLock.Release();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public async Task DisconnectAsync()
|
|||
|
|
{
|
|||
|
|
await _operationLock.WaitAsync();
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
await _digitizer.StopAsync();
|
|||
|
|
await _digitizer.CloseAsync();
|
|||
|
|
if (_connected)
|
|||
|
|
{
|
|||
|
|
_connected = false;
|
|||
|
|
ConnectionChanged?.Invoke(this, false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
finally
|
|||
|
|
{
|
|||
|
|
_operationLock.Release();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public async Task<CurveData> CaptureAroundActionAsync(
|
|||
|
|
DaqActionCaptureRequest request,
|
|||
|
|
Func<CancellationToken, Task> action,
|
|||
|
|
CancellationToken ct = default)
|
|||
|
|
{
|
|||
|
|
ValidateOptions(requireOutputVoltage: true);
|
|||
|
|
await _operationLock.WaitAsync(ct);
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (!IsConnected) await ConnectWithoutLockAsync(ct);
|
|||
|
|
var config = CreateDriverConfig();
|
|||
|
|
await _digitizer.ConfigureAsync(config, ct);
|
|||
|
|
|
|||
|
|
int preSamples = Math.Max(1, (int)Math.Round(config.SampleRateHz * request.PreTriggerMs / 1_000.0));
|
|||
|
|
int postSamples = Math.Max(1, (int)Math.Round(config.SampleRateHz * request.PostTriggerMs / 1_000.0));
|
|||
|
|
int targetSamples = checked(preSamples + postSamples);
|
|||
|
|
var collected = Enumerable.Range(0, config.ChannelCount)
|
|||
|
|
.Select(_ => new List<double>(targetSamples)).ToArray();
|
|||
|
|
bool actionInvoked = false;
|
|||
|
|
int triggerIndex = 0;
|
|||
|
|
var preTriggerTimeout = TimeSpan.FromMilliseconds(Math.Max(3_000, request.PreTriggerMs * 5));
|
|||
|
|
using var acquisitionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|||
|
|
acquisitionCts.CancelAfter(preTriggerTimeout);
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
await foreach (var block in _digitizer.StartAcquisitionAsync(acquisitionCts.Token))
|
|||
|
|
{
|
|||
|
|
if (!actionInvoked)
|
|||
|
|
{
|
|||
|
|
int take = Math.Min(preSamples - collected[0].Count, block.SamplesPerChannel);
|
|||
|
|
for (int channel = 0; channel < config.ChannelCount; channel++)
|
|||
|
|
collected[channel].AddRange(block.Channels[channel].AsSpan(0, take).ToArray());
|
|||
|
|
if (collected[0].Count >= preSamples)
|
|||
|
|
{
|
|||
|
|
triggerIndex = collected[0].Count;
|
|||
|
|
actionInvoked = true;
|
|||
|
|
acquisitionCts.CancelAfter(Timeout.InfiniteTimeSpan);
|
|||
|
|
await action(ct);
|
|||
|
|
}
|
|||
|
|
// 动作发生在本次 DMA 读取完成之后;当前块未使用余量仍属于动作前数据,必须丢弃。
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int remaining = targetSamples - collected[0].Count;
|
|||
|
|
int postTake = Math.Min(remaining, block.SamplesPerChannel);
|
|||
|
|
for (int channel = 0; channel < config.ChannelCount; channel++)
|
|||
|
|
collected[channel].AddRange(block.Channels[channel].AsSpan(0, postTake).ToArray());
|
|||
|
|
if (collected[0].Count >= targetSamples) break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested && !actionInvoked)
|
|||
|
|
{
|
|||
|
|
string message =
|
|||
|
|
$"PCIe8586 did not provide enough pre-trigger samples within {preTriggerTimeout.TotalMilliseconds:F0}ms; control action was not executed. " +
|
|||
|
|
$"sampleRate={config.SampleRateHz:F0}Hz, channels={config.ChannelCount}, " +
|
|||
|
|
$"preTrigger={request.PreTriggerMs:F1}ms, postTrigger={request.PostTriggerMs:F1}ms, " +
|
|||
|
|
$"samples={collected[0].Count}/{preSamples}.";
|
|||
|
|
_logger.LogWarning(message);
|
|||
|
|
throw new InvalidOperationException(message, ex);
|
|||
|
|
}
|
|||
|
|
finally
|
|||
|
|
{
|
|||
|
|
await _digitizer.StopAsync();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!actionInvoked)
|
|||
|
|
throw new InvalidOperationException(
|
|||
|
|
$"PCIe8586 acquisition ended before enough pre-trigger samples were collected; control action was not executed. " +
|
|||
|
|
$"sampleRate={config.SampleRateHz:F0}Hz, channels={config.ChannelCount}, " +
|
|||
|
|
$"preTrigger={request.PreTriggerMs:F1}ms, postTrigger={request.PostTriggerMs:F1}ms, " +
|
|||
|
|
$"samples={collected[0].Count}/{preSamples}.");
|
|||
|
|
if (collected[0].Count < targetSamples)
|
|||
|
|
throw new InvalidOperationException("PCIe8586 采集提前结束,后置波形不完整。");
|
|||
|
|
|
|||
|
|
return BuildCurve(collected.Select(x => x.ToArray()).ToArray(), config.SampleRateHz, triggerIndex);
|
|||
|
|
}
|
|||
|
|
finally
|
|||
|
|
{
|
|||
|
|
_operationLock.Release();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public async Task<CurveData> CaptureAsync(
|
|||
|
|
int[] channels, int sampleRateHz, double durationSec, CancellationToken ct = default)
|
|||
|
|
{
|
|||
|
|
double preMs = Math.Max(1, durationSec * 1_000 * 0.2);
|
|||
|
|
double postMs = Math.Max(1, durationSec * 1_000 - preMs);
|
|||
|
|
return await CaptureAroundActionAsync(
|
|||
|
|
new DaqActionCaptureRequest(preMs, postMs, true), _ => Task.CompletedTask, ct);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public async Task<double[]> ReadInstantAsync(int[] channels, CancellationToken ct = default)
|
|||
|
|
{
|
|||
|
|
var curve = await CaptureAroundActionAsync(
|
|||
|
|
new DaqActionCaptureRequest(1, 2, true), _ => Task.CompletedTask, ct);
|
|||
|
|
return channels.Select(channel =>
|
|||
|
|
{
|
|||
|
|
var data = curve.EngineeringChannels.SingleOrDefault(x => x.PhysicalChannel == channel);
|
|||
|
|
return data?.Samples.Average() ?? double.NaN;
|
|||
|
|
}).ToArray();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void ValidateOptions(bool requireOutputVoltage)
|
|||
|
|
{
|
|||
|
|
if (_options.ChannelCount is not (1 or 2 or 4 or 8))
|
|||
|
|
throw new InvalidOperationException("PCIe8586 启用通道数只能为 1、2、4 或 8。");
|
|||
|
|
if (_options.ClockDivider < 1)
|
|||
|
|
throw new InvalidOperationException("PCIe8586 时钟分频必须大于 0。");
|
|||
|
|
if (_options.PreTriggerMs <= 0 || _options.PostTriggerMs <= 0)
|
|||
|
|
throw new InvalidOperationException("前置和后置采集窗口必须大于 0。");
|
|||
|
|
|
|||
|
|
var enabled = _options.Channels.Where(x => x.PhysicalChannel < _options.ChannelCount).ToArray();
|
|||
|
|
foreach (var channel in enabled.Where(x => ParseRole(x.Role) != DaqChannelRole.Unconfigured))
|
|||
|
|
{
|
|||
|
|
if (!channel.IsCalibrated)
|
|||
|
|
throw new InvalidOperationException($"PCIe8586 CH{channel.PhysicalChannel}({channel.Name})尚未标定。");
|
|||
|
|
if (!double.IsFinite(channel.Scale) || channel.Scale == 0 || !double.IsFinite(channel.Offset))
|
|||
|
|
throw new InvalidOperationException($"PCIe8586 CH{channel.PhysicalChannel} 标定比例或偏置无效。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (requireOutputVoltage &&
|
|||
|
|
enabled.Count(x => ParseRole(x.Role) == DaqChannelRole.OutputVoltage && x.IsCalibrated) != 1)
|
|||
|
|
throw new InvalidOperationException("曲线测试必须配置且只能配置一个已标定的“输出电压”通道。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async Task ConnectWithoutLockAsync(CancellationToken ct)
|
|||
|
|
{
|
|||
|
|
var devices = await EnumerateDevicesAsync(ct);
|
|||
|
|
var selected = devices.SingleOrDefault(x => x.LogicalId == _options.LogicalDeviceId)
|
|||
|
|
?? throw new InvalidOperationException($"未发现逻辑 ID {_options.LogicalDeviceId} 的 PCIe8586。");
|
|||
|
|
await _digitizer.OpenAsync(
|
|||
|
|
new DeviceInfo(selected.LogicalId, selected.PhysicalId, selected.Description, selected.IsSimulated), ct);
|
|||
|
|
_connected = true;
|
|||
|
|
ConnectionChanged?.Invoke(this, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private AcquisitionConfig CreateDriverConfig() => new(
|
|||
|
|
_options.ChannelCount,
|
|||
|
|
string.Equals(_options.InputRange, "PlusMinus1V", StringComparison.OrdinalIgnoreCase)
|
|||
|
|
? DriverInputRange.PlusMinus1V
|
|||
|
|
: DriverInputRange.PlusMinus5V,
|
|||
|
|
_options.ClockDivider,
|
|||
|
|
AcquisitionMode.Continuous,
|
|||
|
|
10_000);
|
|||
|
|
|
|||
|
|
private CurveData BuildCurve(double[][] cardVolts, double sampleRateHz, int triggerIndex)
|
|||
|
|
{
|
|||
|
|
var engineering = new List<CurveChannelData>();
|
|||
|
|
for (int channel = 0; channel < cardVolts.Length; channel++)
|
|||
|
|
{
|
|||
|
|
var cfg = _options.Channels.FirstOrDefault(x => x.PhysicalChannel == channel)
|
|||
|
|
?? new DaqChannelOptions { PhysicalChannel = channel, Name = $"CH{channel}" };
|
|||
|
|
var role = ParseRole(cfg.Role);
|
|||
|
|
var values = cardVolts[channel].Select(x => x * cfg.Scale + cfg.Offset).ToArray();
|
|||
|
|
engineering.Add(new CurveChannelData(
|
|||
|
|
channel, role, cfg.Name, UnitFor(role), cfg.Scale, cfg.Offset, values));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var voltage = engineering.Single(x => x.Role == DaqChannelRole.OutputVoltage).Samples;
|
|||
|
|
var current = engineering.SingleOrDefault(x => x.Role == DaqChannelRole.OutputCurrent)?.Samples;
|
|||
|
|
if (voltage.Length > 0)
|
|||
|
|
{
|
|||
|
|
int sample = Math.Clamp(triggerIndex, 0, voltage.Length - 1);
|
|||
|
|
SampleReceived?.Invoke(this, new CurveSample(
|
|||
|
|
sample / sampleRateHz,
|
|||
|
|
voltage[sample],
|
|||
|
|
current is { Length: > 0 } ? current[Math.Min(sample, current.Length - 1)] : 0));
|
|||
|
|
}
|
|||
|
|
return new CurveData
|
|||
|
|
{
|
|||
|
|
SampleRateHz = (int)Math.Round(sampleRateHz),
|
|||
|
|
TriggerSampleIndex = triggerIndex,
|
|||
|
|
TriggerTimeSec = triggerIndex / sampleRateHz,
|
|||
|
|
Voltage = voltage,
|
|||
|
|
Current = current,
|
|||
|
|
EngineeringChannels = engineering
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
internal static DaqChannelRole ParseRole(string? role) =>
|
|||
|
|
Enum.TryParse<DaqChannelRole>(role, true, out var value) ? value : DaqChannelRole.Unconfigured;
|
|||
|
|
|
|||
|
|
internal static string UnitFor(DaqChannelRole role) => role switch
|
|||
|
|
{
|
|||
|
|
DaqChannelRole.OutputVoltage or DaqChannelRole.InputVoltage => "V",
|
|||
|
|
DaqChannelRole.OutputCurrent or DaqChannelRole.InputCurrent => "A",
|
|||
|
|
_ => "V"
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
public void Dispose()
|
|||
|
|
{
|
|||
|
|
try { DisconnectAsync().GetAwaiter().GetResult(); } catch { }
|
|||
|
|
_digitizer.Dispose();
|
|||
|
|
_operationLock.Dispose();
|
|||
|
|
}
|
|||
|
|
}
|