Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ public async Task ParseAsync_EmptyFile_ReturnsEmptySamples()
public async Task ParseAsync_ConfigurationOverride_UsesProvidedConfig()
{
// Arrange — JSON has no metadata headers, so override fills in device info gaps.
// TimestampFrequency is inferred from FallbackTimestampFrequency (50MHz default),
// so the inferred value takes precedence over the override's value.
// JSON lines carry no frequency of their own, so the connected device's frequency is
// the best information available and must beat the FallbackTimestampFrequency guess.
await using var stream = SdCardTestJsonFileBuilder.BuildJsonFile(
(100u, new[] { 1.0, 2.0 }, "")
);
Expand Down Expand Up @@ -217,8 +217,12 @@ public async Task ParseAsync_ConfigurationOverride_UsesProvidedConfig()
Assert.Equal("NQ1", session.DeviceConfig.DevicePartNumber);
Assert.Equal("1.0.0", session.DeviceConfig.FirmwareRevision);
Assert.Equal(1, session.DeviceConfig.DigitalPortCount);
// Inferred frequency (from FallbackTimestampFrequency) takes precedence
Assert.Equal(50_000_000u, session.DeviceConfig.TimestampFrequency);
// The device's reported frequency beats the fallback guess, and says so.
Assert.Equal(1000u, session.DeviceConfig.TimestampFrequency);
Assert.Equal(1000u, session.TimestampFrequency);
Assert.Equal(
global::Daqifi.Core.Device.SdCard.SdCardTimestampSource.Device,
session.TimestampFrequencySource);
}

[Fact]
Expand Down
275 changes: 275 additions & 0 deletions src/Daqifi.Core.Tests/Device/SdCard/SdCardTimestampFrequencyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Daqifi.Core.Device;
using Daqifi.Core.Device.SdCard;
using Xunit;

namespace Daqifi.Core.Tests.Device.SdCard;

/// <summary>
/// Tests for how an SD card log's timestamp clock frequency is chosen and reported (issue #426).
/// </summary>
/// <remarks>
/// Firmware v3.7.2 and earlier write no <c>TimestampFreq</c> into SD card logs but do report one
/// (42 MHz on the bench Nq1) in their live status message. Before the fix, the live figure was
/// discarded and parsing fell back to 50 MHz, silently stretching every reconstructed timestamp
/// by a factor of 50/42 ≈ 1.19.
/// </remarks>
public class SdCardTimestampFrequencyTests
{
private const uint DeviceFrequencyHz = 42_000_000;

// 20 Hz at a 42 MHz counter: 42e6 / 20 = 2,100,000 ticks per sample. Measured on the bench.
private const uint TicksPerSampleAt20Hz = 2_100_000;

private readonly SdCardFileParser _parser = new();

#region SdCardDeviceConfiguration.FromDevice

[Fact]
public void FromDevice_WithDeviceReportedFrequency_PropagatesIt()
{
// Arrange — a device whose status message reported a real timestamp clock.
var device = new DaqifiDevice("TestDevice");
device.PopulateChannelsFromStatus(new DaqifiOutMessage
{
AnalogInPortNum = 4,
DigitalPortNum = 2,
TimestampFreq = DeviceFrequencyHz
});

// Act
var config = SdCardDeviceConfiguration.FromDevice(device);

// Assert — the one field the live device is uniquely able to supply is carried across.
Assert.NotNull(config);
Assert.Equal(DeviceFrequencyHz, config.TimestampFrequency);
}

[Fact]
public void FromDevice_WhenDeviceReportedNoFrequency_KeepsZero()
{
// Arrange — status message with channel counts but no TimestampFreq.
var device = new DaqifiDevice("TestDevice");
device.PopulateChannelsFromStatus(new DaqifiOutMessage
{
AnalogInPortNum = 4,
DigitalPortNum = 2
});

// Act
var config = SdCardDeviceConfiguration.FromDevice(device);

// Assert — zero means "unknown", which leaves the parser's fallback in charge.
Assert.NotNull(config);
Assert.Equal(0u, config.TimestampFrequency);
}

[Fact]
public void FromDevice_WithNoAnalogChannels_ReturnsNull()
{
// Arrange
var device = new DaqifiDevice("TestDevice");
device.PopulateChannelsFromStatus(new DaqifiOutMessage
{
DigitalPortNum = 2,
TimestampFreq = DeviceFrequencyHz
});

// Act & Assert
Assert.Null(SdCardDeviceConfiguration.FromDevice(device));
}

#endregion

#region Resolver precedence

[Theory]
// File wins outright, even against a device and a fallback.
[InlineData(80_000_000u, 42_000_000u, 50_000_000u, 80_000_000u, SdCardTimestampSource.LogFile)]
// File silent: the device's real clock beats the fallback guess.
[InlineData(0u, 42_000_000u, 50_000_000u, 42_000_000u, SdCardTimestampSource.Device)]
// Nothing but the guess.
[InlineData(0u, 0u, 50_000_000u, 50_000_000u, SdCardTimestampSource.Fallback)]
// Guess disabled: no conversion at all rather than a wrong one.
[InlineData(0u, 0u, 0u, 0u, SdCardTimestampSource.None)]
// A device that reports nothing does not shadow the fallback.
[InlineData(0u, 0u, 1_000u, 1_000u, SdCardTimestampSource.Fallback)]
public void Resolve_FollowsFileThenDeviceThenFallback(
uint fileHz,
uint deviceHz,
uint fallbackHz,
uint expectedHz,
SdCardTimestampSource expectedSource)
{
var (frequencyHz, source) = SdCardTimestampFrequencyResolver.Resolve(fileHz, deviceHz, fallbackHz);

Assert.Equal(expectedHz, frequencyHz);
Assert.Equal(expectedSource, source);
}

#endregion

#region Parser reports which frequency it used

[Fact]
public async Task ParseAsync_WithFileEmbeddedFrequency_PrefersFileOverDevice()
{
// Arrange — the file states 80 MHz while a connected device claims 42 MHz.
var builder = new SdCardTestFileBuilder()
.AddMessage(SdCardTestFileBuilder.CreateStatusMessage(
analogPortNum: 2,
digitalPortNum: 1,
timestampFreq: 80_000_000))
.AddMessage(SdCardTestFileBuilder.CreateStreamMessage(
timestamp: 1_000,
analogFloatValues: new[] { 1.0f, 2.0f }));

using var stream = builder.Build();

// Act
var session = await _parser.ParseAsync(stream, "log_20240115_103000.bin", new SdCardParseOptions
{
ConfigurationOverride = DeviceOverride(DeviceFrequencyHz)
});

// Assert — a self-describing log is never overridden by a live device.
Assert.Equal(80_000_000u, session.TimestampFrequency);
Assert.Equal(SdCardTimestampSource.LogFile, session.TimestampFrequencySource);
}

[Fact]
public async Task ParseAsync_WhenFileHasNoFrequency_UsesDeviceAndReportsIt()
{
// Arrange — a FW 3.7.2-shaped log: stream messages only, no TimestampFreq anywhere.
using var stream = BuildTwoSampleLogWithoutFrequency();

// Act
var session = await _parser.ParseAsync(stream, "log_20240115_103000.bin", new SdCardParseOptions
{
ConfigurationOverride = DeviceOverride(DeviceFrequencyHz)
});

// Assert
Assert.Equal(DeviceFrequencyHz, session.TimestampFrequency);
Assert.Equal(SdCardTimestampSource.Device, session.TimestampFrequencySource);
}

[Fact]
public async Task ParseAsync_WhenNothingSuppliesFrequency_SurfacesTheFallbackRatherThanHidingIt()
{
// Arrange — no file frequency, no connected device.
using var stream = BuildTwoSampleLogWithoutFrequency();

// Act
var session = await _parser.ParseAsync(stream, "log_20240115_103000.bin", new SdCardParseOptions
{
FallbackTimestampFrequency = 50_000_000
});

// Assert — the guess still happens, but the caller can now see that it did.
Assert.Equal(50_000_000u, session.TimestampFrequency);
Assert.Equal(SdCardTimestampSource.Fallback, session.TimestampFrequencySource);
}

[Fact]
public async Task ParseAsync_WithFallbackDisabled_ReportsNoFrequency()
{
// Arrange
using var stream = BuildTwoSampleLogWithoutFrequency();

// Act
var session = await _parser.ParseAsync(stream, "log_20240115_103000.bin", new SdCardParseOptions
{
FallbackTimestampFrequency = 0
});

// Assert
Assert.Equal(0u, session.TimestampFrequency);
Assert.Equal(SdCardTimestampSource.None, session.TimestampFrequencySource);
}

#endregion

#region Regression: the ~19% scaling error itself

[Fact]
public async Task ParseAsync_WithConnectedDevice_SpacesSamplesAtTheRecordedRate()
{
// Arrange — two samples one 20 Hz period apart on a 42 MHz counter, exactly as the
// bench Nq1 writes them.
using var stream = BuildTwoSampleLogWithoutFrequency();

// Act
var session = await _parser.ParseAsync(stream, "log_20240115_103000.bin", new SdCardParseOptions
{
ConfigurationOverride = DeviceOverride(DeviceFrequencyHz)
});

var samples = await ToListAsync(session.Samples);

// Assert — 50.0 ms apart, the rate the data was actually logged at. Falling back to
// 50 MHz would report 42.0 ms, an 8 ms (19%) error on every interval in the file.
Assert.Equal(2, samples.Count);
var spacing = (samples[1].Timestamp - samples[0].Timestamp).TotalMilliseconds;
Assert.Equal(50.0, spacing, precision: 3);
}

[Fact]
public async Task ParseAsync_WithoutConnectedDevice_StillMisreportsButSaysSo()
{
// Arrange — the same file parsed offline, where the 50 MHz guess is all there is.
using var stream = BuildTwoSampleLogWithoutFrequency();

// Act
var session = await _parser.ParseAsync(stream, "log_20240115_103000.bin", new SdCardParseOptions
{
FallbackTimestampFrequency = 50_000_000
});

var samples = await ToListAsync(session.Samples);

// Assert — this documents the residual limitation: with no device to ask, the spacing
// is still wrong. What changed is that TimestampFrequencySource now says the figure was
// a guess, so a caller can warn instead of silently trusting it.
var spacing = (samples[1].Timestamp - samples[0].Timestamp).TotalMilliseconds;
Assert.Equal(42.0, spacing, precision: 3);
Assert.Equal(SdCardTimestampSource.Fallback, session.TimestampFrequencySource);
}

#endregion

private static SdCardDeviceConfiguration DeviceOverride(uint timestampFrequencyHz) =>
new(
AnalogPortCount: 2,
DigitalPortCount: 1,
TimestampFrequency: timestampFrequencyHz,
DeviceSerialNumber: "TEST123",
DevicePartNumber: "Nq1",
FirmwareRevision: "3.7.2",
CalibrationValues: null);

private static System.IO.Stream BuildTwoSampleLogWithoutFrequency()
{
return new SdCardTestFileBuilder()
.AddMessage(SdCardTestFileBuilder.CreateStreamMessage(
timestamp: 1_000_000,
analogFloatValues: new[] { 1.0f, 2.0f }))
.AddMessage(SdCardTestFileBuilder.CreateStreamMessage(
timestamp: 1_000_000 + TicksPerSampleAt20Hz,
analogFloatValues: new[] { 3.0f, 4.0f }))
.Build();
}

private static async Task<List<T>> ToListAsync<T>(IAsyncEnumerable<T> source)
{
var list = new List<T>();
await foreach (var item in source)
{
list.Add(item);
}

return list;
}
}
25 changes: 22 additions & 3 deletions src/Daqifi.Core/Device/SdCard/SdCardCsvFileParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ public async Task<SdCardLogSession> ParseAsync(
var (headerConfig, columnLayout) = ParseHeader(lines, options);
var config = MergeConfiguration(headerConfig, options.ConfigurationOverride);

var (timestampFrequency, timestampSource) = SdCardTimestampFrequencyResolver.Resolve(
headerConfig.TimestampFrequency,
options.ConfigurationOverride?.TimestampFrequency ?? 0u,
options.FallbackTimestampFrequency);

config = config with { TimestampFrequency = timestampFrequency };

// Find the index of the first data row (after comments and column header)
var dataStartIndex = FindDataStartIndex(lines);

Expand All @@ -83,7 +90,11 @@ public async Task<SdCardLogSession> ParseAsync(
fileName,
fileCreatedDate,
config,
EmptySamples());
EmptySamples())
{
TimestampFrequency = timestampFrequency,
TimestampFrequencySource = timestampSource
};
}

var samples = ParseCsvLines(
Expand All @@ -94,7 +105,11 @@ public async Task<SdCardLogSession> ParseAsync(
fileCreatedDate,
options);

return new SdCardLogSession(fileName, fileCreatedDate, config, samples);
return new SdCardLogSession(fileName, fileCreatedDate, config, samples)
{
TimestampFrequency = timestampFrequency,
TimestampFrequencySource = timestampSource
};
}

/// <summary>
Expand Down Expand Up @@ -138,7 +153,11 @@ private static (SdCardDeviceConfiguration Config, CsvColumnLayout Layout) ParseH
{
string? deviceName = null;
string? serialNumber = null;
var timestampFreq = options.FallbackTimestampFrequency;

// File-stated frequency only. The device override and the caller's fallback are
// applied afterwards, in that order, so that a connected device's real clock beats a
// fallback guess instead of losing to it.
var timestampFreq = 0u;
var analogChannelCount = 0;
var digitalChannelCount = 0;
var hasDigitalPair = false;
Expand Down
13 changes: 11 additions & 2 deletions src/Daqifi.Core/Device/SdCard/SdCardDeviceConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,17 @@ public sealed record SdCardDeviceConfiguration(
/// <summary>
/// Creates an <see cref="SdCardDeviceConfiguration"/> from a connected device's
/// channel configuration. This captures the calibration, resolution, port range,
/// and internal scale values needed to convert raw ADC data in SD card log files.
/// internal scale, and timestamp clock values needed to interpret an SD card log file.
/// </summary>
/// <remarks>
/// The device's own <see cref="DaqifiDevice.TimestampFrequency"/> is included because
/// firmware v3.7.2 and earlier write no timestamp frequency into SD card logs while still
/// reporting one in their live status message. Passed to
/// <see cref="SdCardParseOptions.ConfigurationOverride"/>, it fills that gap; a log that
/// does state its own frequency still wins, so this is a backstop and never an override of
/// better information. It is <c>0</c> when the device has not reported a frequency, which
/// leaves the parser's fallback in charge exactly as before.
/// </remarks>
/// <param name="device">A connected and initialized device.</param>
/// <returns>A configuration snapshot, or <c>null</c> if the device has no analog channels.</returns>
public static SdCardDeviceConfiguration? FromDevice(DaqifiDevice device)
Expand All @@ -50,7 +59,7 @@ public sealed record SdCardDeviceConfiguration(
return new SdCardDeviceConfiguration(
AnalogPortCount: analogChannels.Count,
DigitalPortCount: digitalCount,
TimestampFrequency: 0, // Let the parser use file-embedded or fallback frequency
TimestampFrequency: device.TimestampFrequency,
DeviceSerialNumber: device.Metadata.SerialNumber,
DevicePartNumber: device.Metadata.PartNumber,
FirmwareRevision: device.Metadata.FirmwareVersion,
Expand Down
Loading