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;