diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardJsonFileParserTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardJsonFileParserTests.cs
index a752128..a3b3adb 100644
--- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardJsonFileParserTests.cs
+++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardJsonFileParserTests.cs
@@ -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 }, "")
);
@@ -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]
diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardTimestampFrequencyTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardTimestampFrequencyTests.cs
new file mode 100644
index 0000000..523a9f9
--- /dev/null
+++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardTimestampFrequencyTests.cs
@@ -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;
+
+///
+/// Tests for how an SD card log's timestamp clock frequency is chosen and reported (issue #426).
+///
+///
+/// Firmware v3.7.2 and earlier write no TimestampFreq 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.
+///
+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> ToListAsync(IAsyncEnumerable source)
+ {
+ var list = new List();
+ await foreach (var item in source)
+ {
+ list.Add(item);
+ }
+
+ return list;
+ }
+}
diff --git a/src/Daqifi.Core/Device/SdCard/SdCardCsvFileParser.cs b/src/Daqifi.Core/Device/SdCard/SdCardCsvFileParser.cs
index 13a7263..b54621b 100644
--- a/src/Daqifi.Core/Device/SdCard/SdCardCsvFileParser.cs
+++ b/src/Daqifi.Core/Device/SdCard/SdCardCsvFileParser.cs
@@ -73,6 +73,13 @@ public async Task 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);
@@ -83,7 +90,11 @@ public async Task ParseAsync(
fileName,
fileCreatedDate,
config,
- EmptySamples());
+ EmptySamples())
+ {
+ TimestampFrequency = timestampFrequency,
+ TimestampFrequencySource = timestampSource
+ };
}
var samples = ParseCsvLines(
@@ -94,7 +105,11 @@ public async Task ParseAsync(
fileCreatedDate,
options);
- return new SdCardLogSession(fileName, fileCreatedDate, config, samples);
+ return new SdCardLogSession(fileName, fileCreatedDate, config, samples)
+ {
+ TimestampFrequency = timestampFrequency,
+ TimestampFrequencySource = timestampSource
+ };
}
///
@@ -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;
diff --git a/src/Daqifi.Core/Device/SdCard/SdCardDeviceConfiguration.cs b/src/Daqifi.Core/Device/SdCard/SdCardDeviceConfiguration.cs
index 887f2b5..16ff44f 100644
--- a/src/Daqifi.Core/Device/SdCard/SdCardDeviceConfiguration.cs
+++ b/src/Daqifi.Core/Device/SdCard/SdCardDeviceConfiguration.cs
@@ -32,8 +32,17 @@ public sealed record SdCardDeviceConfiguration(
///
/// Creates an 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.
///
+ ///
+ /// The device's own 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
+ /// , 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 0 when the device has not reported a frequency, which
+ /// leaves the parser's fallback in charge exactly as before.
+ ///
/// A connected and initialized device.
/// A configuration snapshot, or null if the device has no analog channels.
public static SdCardDeviceConfiguration? FromDevice(DaqifiDevice device)
@@ -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,
diff --git a/src/Daqifi.Core/Device/SdCard/SdCardFileParser.cs b/src/Daqifi.Core/Device/SdCard/SdCardFileParser.cs
index a4ea0d5..b3fee28 100644
--- a/src/Daqifi.Core/Device/SdCard/SdCardFileParser.cs
+++ b/src/Daqifi.Core/Device/SdCard/SdCardFileParser.cs
@@ -79,20 +79,23 @@ public async Task ParseAsync(
}
}
+ // Whatever the file itself stated, captured before the override is merged in so the
+ // two sources stay distinguishable when reporting which one was used.
+ var fileTimestampFrequency = config?.TimestampFrequency ?? 0u;
+
// Apply ConfigurationOverride as a fallback for any fields not found in the file.
// This is useful when the device is connected during download — the device's live
- // status provides calibration, resolution, and port range values that may not be
- // embedded in the SD card log file.
+ // status provides calibration, resolution, port range, and timestamp clock values
+ // that may not be embedded in the SD card log file.
if (options.ConfigurationOverride != null)
{
config = MergeConfigurations(config, options.ConfigurationOverride);
}
- var timestampFrequency = config?.TimestampFrequency ?? 0u;
- if (timestampFrequency == 0 && options.FallbackTimestampFrequency > 0)
- {
- timestampFrequency = options.FallbackTimestampFrequency;
- }
+ var (timestampFrequency, timestampSource) = SdCardTimestampFrequencyResolver.Resolve(
+ fileTimestampFrequency,
+ options.ConfigurationOverride?.TimestampFrequency ?? 0u,
+ options.FallbackTimestampFrequency);
var tickPeriod = timestampFrequency > 0
? 1.0 / timestampFrequency
@@ -106,7 +109,11 @@ public async Task ParseAsync(
config,
ct);
- return new SdCardLogSession(fileName, fileCreatedDate, config, samples);
+ return new SdCardLogSession(fileName, fileCreatedDate, config, samples)
+ {
+ TimestampFrequency = timestampFrequency,
+ TimestampFrequencySource = timestampSource
+ };
}
///
diff --git a/src/Daqifi.Core/Device/SdCard/SdCardJsonFileParser.cs b/src/Daqifi.Core/Device/SdCard/SdCardJsonFileParser.cs
index c013fb9..3c35a24 100644
--- a/src/Daqifi.Core/Device/SdCard/SdCardJsonFileParser.cs
+++ b/src/Daqifi.Core/Device/SdCard/SdCardJsonFileParser.cs
@@ -64,13 +64,27 @@ public async Task ParseAsync(
var config = InferConfiguration(lines[0], options);
+ // JSON log lines carry raw tick counts and no frequency of their own, so the file can
+ // only ever contribute 0 here; a connected device's frequency is what stands between
+ // the caller's fallback guess and the data.
+ var (timestampFrequency, timestampSource) = SdCardTimestampFrequencyResolver.Resolve(
+ fileFrequencyHz: 0u,
+ options.ConfigurationOverride?.TimestampFrequency ?? 0u,
+ options.FallbackTimestampFrequency);
+
+ config = config with { TimestampFrequency = timestampFrequency };
+
var samples = ParseJsonLines(
lines,
config,
fileCreatedDate,
options);
- return new SdCardLogSession(fileName, fileCreatedDate, config, samples);
+ return new SdCardLogSession(fileName, fileCreatedDate, config, samples)
+ {
+ TimestampFrequency = timestampFrequency,
+ TimestampFrequencySource = timestampSource
+ };
}
///
@@ -248,12 +262,12 @@ private static SdCardDeviceConfiguration InferConfiguration(string firstLine, Sd
var parsed = TryParseJsonLine(firstLine);
var analogCount = parsed?.analog.Count ?? 0;
- var timestampFreq = options.FallbackTimestampFrequency;
-
var inferred = new SdCardDeviceConfiguration(
AnalogPortCount: analogCount,
DigitalPortCount: 0, // Cannot infer from data
- TimestampFrequency: timestampFreq,
+ // The frequency is resolved by the caller, after the override merge, so that a
+ // connected device's real clock beats a fallback guess rather than losing to it.
+ TimestampFrequency: 0u,
DeviceSerialNumber: null,
DevicePartNumber: null,
FirmwareRevision: null,
diff --git a/src/Daqifi.Core/Device/SdCard/SdCardLogSession.cs b/src/Daqifi.Core/Device/SdCard/SdCardLogSession.cs
index 254048e..57e72ec 100644
--- a/src/Daqifi.Core/Device/SdCard/SdCardLogSession.cs
+++ b/src/Daqifi.Core/Device/SdCard/SdCardLogSession.cs
@@ -29,6 +29,27 @@ public sealed class SdCardLogSession
///
public IAsyncEnumerable Samples { get; }
+ ///
+ /// Gets the timestamp clock frequency in Hz that was actually used to convert this log's
+ /// raw tick counts into times, or 0 when no conversion was possible.
+ ///
+ ///
+ /// Read this together with . A frequency that does
+ /// not match the recording device rescales every timestamp in the session by a constant
+ /// factor, and nothing in the sample data reveals it.
+ ///
+ public uint TimestampFrequency { get; init; }
+
+ ///
+ /// Gets where came from.
+ ///
+ ///
+ /// means the frequency was a guess supplied by
+ /// the caller rather than a figure reported by the file or the device, so the timestamps
+ /// should not be trusted as absolute elapsed time.
+ ///
+ public SdCardTimestampSource TimestampFrequencySource { get; init; }
+
///
/// Initializes a new instance of the class.
///
diff --git a/src/Daqifi.Core/Device/SdCard/SdCardParseOptions.cs b/src/Daqifi.Core/Device/SdCard/SdCardParseOptions.cs
index fe27f8d..f48ca87 100644
--- a/src/Daqifi.Core/Device/SdCard/SdCardParseOptions.cs
+++ b/src/Daqifi.Core/Device/SdCard/SdCardParseOptions.cs
@@ -32,12 +32,19 @@ public sealed class SdCardParseOptions
/// it will be used to convert raw tick deltas to elapsed time.
///
///
- /// This value is only used as a fallback — if the file contains a valid
- /// TimestampFreq, it takes precedence.
+ /// This value is the last resort. A frequency stated by the file wins, and a frequency
+ /// reported by a connected device through comes next;
+ /// this fallback applies only when neither is available.
///
///
- /// Defaults to 50 MHz (the Nyquist device clock frequency). Set to 0 to
- /// disable the fallback entirely.
+ /// Defaults to 50 MHz. That figure is a historical default and does not match
+ /// shipped Nyquist firmware, which reports a 42 MHz timestamp clock — converting 42 MHz
+ /// ticks as though they were 50 MHz makes every reconstructed timestamp roughly 19% fast.
+ /// Prefer supplying from the connected device so this
+ /// guess is never needed, and check
+ /// on the parsed session to see
+ /// whether it was. Set to 0 to disable the fallback entirely, which leaves tick counts
+ /// unconverted rather than converted with a guess.
///
///
public uint FallbackTimestampFrequency { get; set; } = 50_000_000;
@@ -47,8 +54,9 @@ public sealed class SdCardParseOptions
/// config fill in any gaps not found in the file itself.
///
/// This is useful when the device is connected during download — the device's
- /// live status provides calibration, resolution, and port range values that
- /// may not be embedded in the SD card log file.
+ /// live status provides calibration, resolution, port range, and timestamp clock
+ /// values that may not be embedded in the SD card log file. Build one with
+ /// .
///
///
public SdCardDeviceConfiguration? ConfigurationOverride { get; set; }
diff --git a/src/Daqifi.Core/Device/SdCard/SdCardTimestampFrequencyResolver.cs b/src/Daqifi.Core/Device/SdCard/SdCardTimestampFrequencyResolver.cs
new file mode 100644
index 0000000..f641622
--- /dev/null
+++ b/src/Daqifi.Core/Device/SdCard/SdCardTimestampFrequencyResolver.cs
@@ -0,0 +1,49 @@
+namespace Daqifi.Core.Device.SdCard;
+
+///
+/// Picks the timestamp clock frequency an SD card log parser converts tick counts with, and
+/// records where it came from.
+///
+///
+/// The precedence is the same for every log format: the file's own frequency, then a live
+/// device's, then the caller's fallback guess. The device only ever fills a gap the file left,
+/// so a connected device can never override a self-describing log — but it does beat the
+/// fallback, which is the whole point of supplying one.
+///
+internal static class SdCardTimestampFrequencyResolver
+{
+ ///
+ /// Resolves the frequency to use, in Hz, together with its source. A zero argument means
+ /// "not available" for each of the three inputs.
+ ///
+ /// Frequency embedded in the log file, or zero.
+ ///
+ /// Frequency from , or zero.
+ ///
+ ///
+ /// , or zero to disable it.
+ ///
+ /// The frequency to convert with, and the source it came from.
+ public static (uint FrequencyHz, SdCardTimestampSource Source) Resolve(
+ uint fileFrequencyHz,
+ uint deviceFrequencyHz,
+ uint fallbackFrequencyHz)
+ {
+ if (fileFrequencyHz > 0)
+ {
+ return (fileFrequencyHz, SdCardTimestampSource.LogFile);
+ }
+
+ if (deviceFrequencyHz > 0)
+ {
+ return (deviceFrequencyHz, SdCardTimestampSource.Device);
+ }
+
+ if (fallbackFrequencyHz > 0)
+ {
+ return (fallbackFrequencyHz, SdCardTimestampSource.Fallback);
+ }
+
+ return (0u, SdCardTimestampSource.None);
+ }
+}
diff --git a/src/Daqifi.Core/Device/SdCard/SdCardTimestampSource.cs b/src/Daqifi.Core/Device/SdCard/SdCardTimestampSource.cs
new file mode 100644
index 0000000..457eabf
--- /dev/null
+++ b/src/Daqifi.Core/Device/SdCard/SdCardTimestampSource.cs
@@ -0,0 +1,52 @@
+namespace Daqifi.Core.Device.SdCard;
+
+///
+/// Where the timestamp clock frequency used to convert an SD card log's raw tick counts
+/// into wall-clock times came from.
+///
+///
+///
+/// Every timestamp in a parsed session is a tick count divided by this frequency, so a
+/// frequency that does not match the recording device rescales the entire session by a
+/// constant factor. That failure is invisible in the data itself — the samples still look
+/// evenly spaced, just at the wrong rate — which is why the source is reported alongside
+/// rather than left implicit.
+///
+///
+/// is the value to watch for: it means nothing in the file and no
+/// connected device supplied a frequency, so
+/// was used as a guess.
+///
+///
+public enum SdCardTimestampSource
+{
+ ///
+ /// No frequency was available and the fallback was disabled
+ /// ( set to zero), so tick
+ /// counts were not converted to elapsed time at all.
+ ///
+ None = 0,
+
+ ///
+ /// The frequency was read from the log file itself. This is the most trustworthy source:
+ /// it is what the device recorded at the time the log was written.
+ ///
+ LogFile = 1,
+
+ ///
+ /// The file carried no frequency, so the one reported by a live device via
+ /// was used. Firmware v3.7.2 and
+ /// earlier embed no frequency in SD card logs but do report one in their live status
+ /// message, which makes this the normal source when a log is parsed straight after
+ /// download from the device that wrote it.
+ ///
+ Device = 2,
+
+ ///
+ /// Neither the file nor a connected device supplied a frequency, so
+ /// was used. The reconstructed
+ /// timestamps are only as accurate as that guess — if it does not match the recording
+ /// device's clock, every timestamp in the session is scaled by a constant factor.
+ ///
+ Fallback = 3
+}
diff --git a/src/Daqifi.Core/Device/TimestampProcessor.cs b/src/Daqifi.Core/Device/TimestampProcessor.cs
index 1281543..9266149 100644
--- a/src/Daqifi.Core/Device/TimestampProcessor.cs
+++ b/src/Daqifi.Core/Device/TimestampProcessor.cs
@@ -40,14 +40,22 @@ namespace Daqifi.Core.Device;
public sealed class TimestampProcessor : ITimestampProcessor
{
///
- /// Default tick period in seconds (20 nanoseconds = 20E-9 seconds).
- /// This corresponds to a 50MHz clock.
+ /// Default tick period in seconds (20 nanoseconds = 20E-9 seconds), corresponding to a
+ /// 50 MHz clock.
///
+ ///
+ /// This is a historical default, not a measurement of any shipped board: current firmware
+ /// reports a 42 MHz timestamp clock, so relying on this value instead of the
+ /// device-reported frequency scales every reconstructed timestamp by roughly 1.19. Always
+ /// prefer with the device's own figure, and use
+ /// to find out whether that has happened.
+ ///
public const double DefaultTickPeriod = 20E-9;
///
- /// Default timestamp clock frequency in Hz (50MHz), corresponding to
- /// .
+ /// Default timestamp clock frequency in Hz (50 MHz), corresponding to
+ /// . See that field for why it should not be relied on in
+ /// place of the device-reported frequency.
///
public const uint DefaultTimestampFrequency = 50_000_000;