From 1f5d6c75027c0e9338c77c4d2b0829efd94657a2 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Sun, 2 Aug 2026 10:50:38 -0600 Subject: [PATCH] fix(sd): use the device's own timestamp clock when parsing SD logs (closes #426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SdCardDeviceConfiguration.FromDevice hardcoded TimestampFrequency: 0 even though it is handed a live device that knows its real clock. Firmware 3.7.2 and earlier embed no frequency in SD card logs, so parsing fell back to the 50 MHz default while the bench Nq1's counter runs at 42 MHz — stretching every reconstructed timestamp by 50/42 (~19%), silently and with exit code 0. FromDevice now passes device.TimestampFrequency, keeping 0 only when the device genuinely reported none. A file that states its own frequency still wins, so this is a backstop rather than an override of better data. The fallback is also no longer silent: SdCardLogSession reports the frequency it actually converted with and, via the new SdCardTimestampSource, where that figure came from — mirroring ITimestampProcessor.HasTimestampFrequency. The CSV and JSON parsers seeded the fallback before merging the device override, which meant a connected device's real clock could never win; all three parsers now share one precedence: file, then device, then fallback. Measured on the bench Nq1 (FW 3.7.2), parsing the log from the issue: 125 samples spanned 5.208 s (42.00 ms/interval) before and 6.200 s (50.00 ms/interval) after, against the 20 Hz the data was logged at. Co-Authored-By: Claude Opus 5 --- .../SdCard/SdCardJsonFileParserTests.cs | 12 +- .../SdCard/SdCardTimestampFrequencyTests.cs | 275 ++++++++++++++++++ .../Device/SdCard/SdCardCsvFileParser.cs | 25 +- .../SdCard/SdCardDeviceConfiguration.cs | 13 +- .../Device/SdCard/SdCardFileParser.cs | 23 +- .../Device/SdCard/SdCardJsonFileParser.cs | 22 +- .../Device/SdCard/SdCardLogSession.cs | 21 ++ .../Device/SdCard/SdCardParseOptions.cs | 20 +- .../SdCardTimestampFrequencyResolver.cs | 49 ++++ .../Device/SdCard/SdCardTimestampSource.cs | 52 ++++ src/Daqifi.Core/Device/TimestampProcessor.cs | 16 +- 11 files changed, 497 insertions(+), 31 deletions(-) create mode 100644 src/Daqifi.Core.Tests/Device/SdCard/SdCardTimestampFrequencyTests.cs create mode 100644 src/Daqifi.Core/Device/SdCard/SdCardTimestampFrequencyResolver.cs create mode 100644 src/Daqifi.Core/Device/SdCard/SdCardTimestampSource.cs diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardJsonFileParserTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardJsonFileParserTests.cs index a752128c..a3b3adb8 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 00000000..523a9f95 --- /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 13a7263c..b54621b5 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 887f2b50..16ff44fd 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 a4ea0d54..b3fee28a 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 c013fb96..3c35a24d 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 254048ea..57e72ecf 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 fe27f8d8..f48ca87f 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 00000000..f641622a --- /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 00000000..457eabfe --- /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 12815431..92661497 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;