diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs index dc1770e7..404abc88 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs @@ -455,8 +455,10 @@ public void Decode_MoreValuesThanChannels_MapsAvailableWithoutThrowing() } [Fact] - public void Decode_FewerValuesThanChannels_MapsAvailableWithoutThrowing() + public void Decode_MidStreamFewerValuesThanChannels_MapsAvailableWithoutThrowing() { + // The warmup guard (issue #351) only suppresses *leading* short frames. Once a full frame + // has been seen, a later short frame is still best-effort mapped rather than dropped. var device = CreateStreamingDevice(analogCount: 2); var ai0 = AnalogChannel(device, 0); var ai1 = AnalogChannel(device, 1); @@ -464,14 +466,21 @@ public void Decode_FewerValuesThanChannels_MapsAvailableWithoutThrowing() ai1.IsEnabled = true; device.StartStreaming(); - var frame = new DaqifiOutMessage { MsgTimeStamp = 1 }; - frame.AnalogInDataFloat.Add(1f); // only one value for two enabled channels + // First a full frame to clear the warmup guard. + var full = new DaqifiOutMessage { MsgTimeStamp = 1 }; + full.AnalogInDataFloat.Add(9f); + full.AnalogInDataFloat.Add(9f); + device.InvokeStreamMessage(full); + + // Then a mid-stream short frame: one value for two enabled channels. + var frame = new DaqifiOutMessage { MsgTimeStamp = 2 }; + frame.AnalogInDataFloat.Add(1f); var ex = Record.Exception(() => device.InvokeStreamMessage(frame)); Assert.Null(ex); Assert.Equal(1.0, ai0.ActiveSample!.Value); - Assert.Null(ai1.ActiveSample); + Assert.Equal(9.0, ai1.ActiveSample!.Value); // retains its last (full-frame) value } [Fact] @@ -499,6 +508,251 @@ public void Decode_CarriesDeviceTimestampVerbatimAcrossFrames() #endregion + #region Warmup-frame suppression (issue #351) + + [Fact] + public void Decode_SuppressesMalformedFirstFrame_ThenEmitsFullFrame() + { + // Reproduces the bench evidence: 2 enabled analog channels, first frame carries a single + // analog value (a firmware warmup frame). That partial first sample must not reach the + // channels; the next full frame must decode normally. + var device = CreateStreamingDevice(analogCount: 2); + var ai0 = AnalogChannel(device, 0); + var ai1 = AnalogChannel(device, 1); + ai0.IsEnabled = true; + ai1.IsEnabled = true; + device.StartStreaming(); + + var samples = new List(); + ai0.SampleReceived += (_, e) => samples.Add(e.Sample.Value); + + // Malformed first frame: one value for two enabled channels. + var warmup = new DaqifiOutMessage { MsgTimeStamp = 1000 }; + warmup.AnalogInDataFloat.Add(0.1f); + device.InvokeStreamMessage(warmup); + + Assert.Null(ai0.ActiveSample); // warmup frame suppressed + Assert.Null(ai1.ActiveSample); + Assert.Empty(samples); + + // Next full frame decodes for both channels. + var full = new DaqifiOutMessage { MsgTimeStamp = 1840 }; + full.AnalogInDataFloat.Add(4f); + full.AnalogInDataFloat.Add(8f); + device.InvokeStreamMessage(full); + + Assert.Equal(4.0, ai0.ActiveSample!.Value); + Assert.Equal(8.0, ai1.ActiveSample!.Value); + Assert.Equal(new[] { 4.0 }, samples); // AI0 saw exactly one (correct) sample + } + + [Fact] + public void Decode_WarmupFrameThenSteadyCadence_NoFalseGap() + { + // The warmup frame's timestamp is normal (one sample period before the next frame), so it + // anchors the session clock correctly — a steady cadence after it reports no false gap. + var device = CreateStreamingDevice(analogCount: 2); + AnalogChannel(device, 0).IsEnabled = true; + AnalogChannel(device, 1).IsEnabled = true; + device.StartStreaming(); + + var gaps = new List(); + device.GapDetected += (_, e) => gaps.Add(e); + + // Warmup frame (partial analog), then a steady one-period cadence. + var warmup = new DaqifiOutMessage { MsgTimeStamp = 1000 }; + warmup.AnalogInDataFloat.Add(0.1f); + device.InvokeStreamMessage(warmup); + + for (uint ts = 2000; ts <= 12000; ts += 1000) + { + var frame = new DaqifiOutMessage { MsgTimeStamp = ts }; + frame.AnalogInDataFloat.Add(1f); + frame.AnalogInDataFloat.Add(2f); + device.InvokeStreamMessage(frame); + } + + Assert.Empty(gaps); + } + + [Fact] + public void Decode_CombinedWarmupFrame_SuppressesAnalogButKeepsDigital() + { + // The firmware's fast encoder packs analog+digital into one frame, so the warmup frame + // carries a valid digital payload alongside its partial analog values (issue #351 evidence: + // "analog=[1] digital=00-04"). Only the malformed analog is dropped; digital is preserved. + var device = CreateStreamingDevice(analogCount: 2, digitalCount: 2); + var ai0 = AnalogChannel(device, 0); + var ai1 = AnalogChannel(device, 1); + var dio0 = DigitalChannel(device, 0); + var dio1 = DigitalChannel(device, 1); + ai0.IsEnabled = true; + ai1.IsEnabled = true; + dio0.IsEnabled = true; + dio1.IsEnabled = true; + device.StartStreaming(); + + var warmup = new DaqifiOutMessage { MsgTimeStamp = 1000 }; + warmup.AnalogInDataFloat.Add(0.1f); // partial analog: 1 value for 2 enabled channels + warmup.DigitalData = ByteString.CopyFrom(new byte[] { 0b10 }); // DIO0 low, DIO1 high + + device.InvokeStreamMessage(warmup); + + // Analog values suppressed... + Assert.Null(ai0.ActiveSample); + Assert.Null(ai1.ActiveSample); + // ...but the digital payload in the same frame is still decoded. + Assert.Equal(0.0, dio0.ActiveSample!.Value); + Assert.Equal(1.0, dio1.ActiveSample!.Value); + } + + [Fact] + public void Decode_WarmupFrame_StillReRaisesRawMessage() + { + // Suppression skips only the per-channel decode; raw-frame consumers still see the frame. + var device = CreateStreamingDevice(analogCount: 2); + AnalogChannel(device, 0).IsEnabled = true; + AnalogChannel(device, 1).IsEnabled = true; + device.StartStreaming(); + + var rawFrames = 0; + device.MessageReceived += (_, _) => rawFrames++; + + var warmup = new DaqifiOutMessage { MsgTimeStamp = 1 }; + warmup.AnalogInDataFloat.Add(0.1f); + device.InvokeStreamMessage(warmup); + + Assert.Equal(1, rawFrames); + } + + [Fact] + public void Decode_FullFirstFrame_NotSuppressed() + { + // A first frame that already carries the full complement decodes immediately. + var device = CreateStreamingDevice(analogCount: 2); + var ai0 = AnalogChannel(device, 0); + var ai1 = AnalogChannel(device, 1); + ai0.IsEnabled = true; + ai1.IsEnabled = true; + device.StartStreaming(); + + var frame = new DaqifiOutMessage { MsgTimeStamp = 1 }; + frame.AnalogInDataFloat.Add(1f); + frame.AnalogInDataFloat.Add(2f); + device.InvokeStreamMessage(frame); + + Assert.Equal(1.0, ai0.ActiveSample!.Value); + Assert.Equal(2.0, ai1.ActiveSample!.Value); + } + + [Fact] + public void Decode_DigitalOnlyStream_FirstFrameNotSuppressed() + { + // With no analog channels enabled the warmup guard never engages: a digital-only first + // frame is decoded normally. + var device = CreateStreamingDevice(analogCount: 0, digitalCount: 4); + var dio = Enumerable.Range(0, 4).Select(n => DigitalChannel(device, n)).ToList(); + foreach (var d in dio) d.IsEnabled = true; + device.StartStreaming(); + + var frame = new DaqifiOutMessage { MsgTimeStamp = 1 }; + frame.DigitalData = ByteString.CopyFrom(new byte[] { 0b1010 }); + device.InvokeStreamMessage(frame); + + Assert.Equal(0.0, dio[0].ActiveSample!.Value); + Assert.Equal(1.0, dio[1].ActiveSample!.Value); + } + + [Fact] + public void Decode_WarmupGuardReArmsForEachSession() + { + // The guard is re-armed at every StartStreaming, so a warmup frame is suppressed at the + // start of a *subsequent* session too. + var device = CreateStreamingDevice(analogCount: 2); + var ai0 = AnalogChannel(device, 0); + var ai1 = AnalogChannel(device, 1); + ai0.IsEnabled = true; + ai1.IsEnabled = true; + + // Session 1: warmup + a full frame. + device.StartStreaming(); + var w1 = new DaqifiOutMessage { MsgTimeStamp = 1 }; + w1.AnalogInDataFloat.Add(0.1f); + device.InvokeStreamMessage(w1); + var f1 = new DaqifiOutMessage { MsgTimeStamp = 2 }; + f1.AnalogInDataFloat.Add(1f); + f1.AnalogInDataFloat.Add(2f); + device.InvokeStreamMessage(f1); + device.StopStreaming(); + + // Session 2: a fresh warmup frame must again be suppressed. + device.StartStreaming(); + var w2 = new DaqifiOutMessage { MsgTimeStamp = 3 }; + w2.AnalogInDataFloat.Add(5f); // single value -> partial again + device.InvokeStreamMessage(w2); + + // AI1 still holds session-1's value; the session-2 warmup frame did not overwrite AI0. + Assert.Equal(1.0, ai0.ActiveSample!.Value); + Assert.Equal(2.0, ai1.ActiveSample!.Value); + } + + [Fact] + public void Decode_DigitalOnlyStart_ThenAnalogEnabledMidStream_ShortFrameNotSuppressed() + { + // The warmup guard is armed only when analog channels are enabled at StartStreaming. A + // session that starts digital-only leaves it disarmed, so a short analog frame arriving + // after analog is enabled mid-stream is best-effort mapped, not treated as a leading + // warmup frame far from session start. + var device = CreateStreamingDevice(analogCount: 2, digitalCount: 2); + var ai0 = AnalogChannel(device, 0); + var ai1 = AnalogChannel(device, 1); + var dio0 = DigitalChannel(device, 0); + dio0.IsEnabled = true; // digital-only at start + device.StartStreaming(); + + // A digital frame streams normally. + var digital = new DaqifiOutMessage { MsgTimeStamp = 1 }; + digital.DigitalData = ByteString.CopyFrom(new byte[] { 0b1 }); + device.InvokeStreamMessage(digital); + Assert.Equal(1.0, dio0.ActiveSample!.Value); + + // Enable analog mid-stream, then a short analog frame arrives (guard was never armed). + device.EnableChannels(new[] { ai0, ai1 }); + var shortAnalog = new DaqifiOutMessage { MsgTimeStamp = 2 }; + shortAnalog.AnalogInDataFloat.Add(7f); // one value for two enabled channels + + var ex = Record.Exception(() => device.InvokeStreamMessage(shortAnalog)); + + Assert.Null(ex); + Assert.Equal(7.0, ai0.ActiveSample!.Value); // not suppressed — best-effort mapped + Assert.Null(ai1.ActiveSample); + } + + [Fact] + public void Decode_PersistentShortFrames_ReleasedAfterCap() + { + // Safety bound: a stream that only ever sends short frames must not be withheld forever. + // After MaxSuppressedWarmupFrames (5) suppressed frames, the guard releases. + var device = CreateStreamingDevice(analogCount: 2); + var ai0 = AnalogChannel(device, 0); + AnalogChannel(device, 1).IsEnabled = true; + ai0.IsEnabled = true; + device.StartStreaming(); + + // 5 suppressed, the 6th is released (best-effort mapped). + for (var i = 0; i < 6; i++) + { + var frame = new DaqifiOutMessage { MsgTimeStamp = (uint)(1000 + i) }; + frame.AnalogInDataFloat.Add(i); + device.InvokeStreamMessage(frame); + } + + Assert.NotNull(ai0.ActiveSample); + Assert.Equal(5.0, ai0.ActiveSample!.Value); // the 6th frame's value + } + + #endregion + #region Helpers private static DecodableStreamingDevice CreateStreamingDevice( diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 2ad62c4a..36896f8c 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -90,6 +90,33 @@ public class DaqifiStreamingDevice : DaqifiDevice, IStreamingDevice, INetworkCon /// private readonly TimestampGapDetector _gapDetector = new(); + /// + /// The maximum number of leading short-analog frames suppressed at stream start + /// (see ). Bounds the warmup-frame guard so a + /// genuinely short stream can never be withheld indefinitely. + /// + private const int MaxSuppressedWarmupFrames = 5; + + /// + /// True from the start of a streaming session that begins with analog channels enabled, + /// until the first analog-bearing frame carrying the full enabled-channel complement has + /// been decoded (disarmed for a digital-only start). Guards the malformed warmup frame + /// the firmware emits at stream start (issue #351): its fast streaming encoder can emit a + /// leading frame with fewer analog values than the enabled channel mask, which would + /// otherwise reach every consumer as a partial (silently corrupting + /// first-value baselining, gap detection, and export). For such leading short frames only + /// the malformed analog decode is skipped — a combined frame's digital payload is still + /// decoded and the raw frame is still re-raised — until the first full frame arrives, + /// bounded by . + /// + private bool _awaitingFirstFullAnalogFrame; + + /// + /// Count of leading short-analog frames suppressed in the current session; capped by + /// . + /// + private int _suppressedWarmupFrameCount; + /// /// Gets a value indicating whether the device is currently streaming data. /// @@ -293,6 +320,15 @@ public void StartStreaming() _timestampProcessor.SetTimestampFrequency(StreamTimestampKey, TimestampFrequency); _gapDetector.Reset(); + // Arm the warmup-frame guard only when analog channels are enabled at stream start — + // the reproduced failure mode (issue #351) is the firmware's leading partial-analog + // frame at the start of an *analog* stream. A digital-only start needs no guard; leaving + // it disarmed there also avoids suppressing short analog frames that could arrive far + // from session start if analog channels are enabled mid-stream (a scenario with no + // observed warmup frame). + _awaitingFirstFullAnalogFrame = CountEnabledAnalogChannels(SnapshotChannels()) > 0; + _suppressedWarmupFrameCount = 0; + IsStreaming = true; Send(ScpiMessageProducer.StartStreaming(StreamingFrequency)); } @@ -442,6 +478,34 @@ private void DecodeStreamFrame(DaqifiOutMessage message) return; } + // Snapshot channels once: the consumer thread that repopulates channels is the same + // thread that runs this decode, so the structure is stable for the duration of the call. + var channels = SnapshotChannels(); + + // Suppress the firmware's malformed warmup frame at stream start (issue #351): its fast + // streaming encoder can emit a leading analog-bearing frame with fewer values than the + // enabled channel mask. Only the malformed *analog* values are withheld — a combined + // frame's digital payload is still decoded, and the frame's (normal one-period) + // timestamp still anchors the session clock, so digital state/edges are not lost. Only + // leading short frames are suppressed (mid-stream short frames stay best-effort mapped), + // bounded so a genuinely short stream is never withheld indefinitely. + var suppressWarmupAnalog = false; + if (_awaitingFirstFullAnalogFrame && (hasFloat || hasRawAnalog)) + { + var analogValueCount = hasFloat ? message.AnalogInDataFloat.Count : message.AnalogInData.Count; + var enabledAnalogCount = CountEnabledAnalogChannels(channels); + if (enabledAnalogCount > 0 && analogValueCount < enabledAnalogCount + && _suppressedWarmupFrameCount < MaxSuppressedWarmupFrames) + { + _suppressedWarmupFrameCount++; + suppressWarmupAnalog = true; + } + else + { + _awaitingFirstFullAnalogFrame = false; + } + } + // Reconstruct a host timestamp from the device tick counter (rollover-aware) and carry // the raw device tick value through to each decoded sample. var deviceTimestamp = message.MsgTimeStamp; @@ -458,11 +522,7 @@ private void DecodeStreamFrame(DaqifiOutMessage message) hostTimestamp, timestampResult.SecondsBetweenMessages, deviceTimestamp)); } - // Snapshot channels once: the consumer thread that repopulates channels is the same - // thread that runs this decode, so the structure is stable for the duration of the call. - var channels = SnapshotChannels(); - - if (hasFloat || hasRawAnalog) + if ((hasFloat || hasRawAnalog) && !suppressWarmupAnalog) { DecodeAnalog(message, channels, hostTimestamp, deviceTimestamp, hasFloat); } @@ -502,6 +562,19 @@ private void RaiseGapDetected(TimestampGapEventArgs args) /// USB firmware streams pre-scaled floats (used directly); WiFi firmware streams raw ADC /// counts (scaled per channel via ). /// + private static int CountEnabledAnalogChannels(IReadOnlyList channels) + { + var count = 0; + foreach (var channel in channels) + { + if (channel.IsEnabled && channel is IAnalogChannel) + { + count++; + } + } + return count; + } + private static void DecodeAnalog( DaqifiOutMessage message, IReadOnlyList channels,