diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetailsV8orAbove.java b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetailsV8orAbove.java index 9943a8481..6313f0374 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetailsV8orAbove.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetailsV8orAbove.java @@ -16,6 +16,8 @@ import com.shimmerresearch.sensors.AbstractSensor.SENSORS; import com.shimmerresearch.verisense.UtilVerisenseDriver; import com.shimmerresearch.verisense.VerisenseDevice; +import com.shimmerresearch.verisense.sensors.SensorMLX90632; +import com.shimmerresearch.verisense.sensors.SensorVD6283; import com.shimmerresearch.verisense.payloaddesign.AsmBinaryFileConstants.BYTE_COUNT; import com.shimmerresearch.verisense.payloaddesign.DataBlockDetails.DATABLOCK_SENSOR_ID; import com.shimmerresearch.verisense.sensors.SensorVerisenseClock; @@ -93,13 +95,14 @@ public void parsePayloadContentsMetaData(int binFileByteIndex) throws IOExceptio // --------- End of parsing ------------------ - // The slow sensors' achieved sample rates differ from what the payload - // header can tell us (the light rate isn't stored at all and the chip adds - // per-measurement dead time; the skin-temp output cadence is refresh-code - // derived but similarly approximate), so refine them from the data itself - // before the block timings are back-filled below. - refineSlowSensorSamplingRateFromBlockTicks(DATABLOCK_SENSOR_ID.LIGHT); - refineSlowSensorSamplingRateFromBlockTicks(DATABLOCK_SENSOR_ID.SKIN_TEMP); + // The slow sensors' achieved sample rates are not what the payload header + // can tell us: the VD6283's configured rate index is not stored at all (the + // header only yields the exposure-derived upper bound, up to 10x the truth) + // and the MLX90632's output cadence is refresh-code derived and approximate. + // Refine them from the data itself - across payloads - before the block + // timings are back-filled below, and derive the CSV gap window with them. + refineSlowSensorSamplingRateFromBlockTicks(DATABLOCK_SENSOR_ID.LIGHT, SensorVD6283.NUM_SAMPLES_PER_BLOCK); + refineSlowSensorSamplingRateFromBlockTicks(DATABLOCK_SENSOR_ID.SKIN_TEMP, SensorMLX90632.NUM_SAMPLES_PER_BLOCK); // Up to, and including, payload design v10, the real-world clock time that was // stored in the payload footer was the real-world time at the end of the @@ -148,6 +151,16 @@ public void parsePayloadContentsMetaData(int binFileByteIndex) throws IOExceptio } UtilCsvSplitting.populateExpectedPayloadTsDiffLimitMapIfNeeded(verisenseDevice, verisenseDevice.getMapOfSensorIdsPerDataBlock()); + + // Now that the block timings are back-filled, measure this payload's + // slow-sensor block spacing in ABSOLUTE milliseconds and fold it into the + // running estimate the next payload will be timed with. Deliberately here + // and not next to the rate application above: the per-block stored time is + // a sub-minute tick counter, and differencing that across a payload + // boundary cannot tell a 5 s spacing from a 65 s one. + observeSlowSensorBlockSpacing(DATABLOCK_SENSOR_ID.LIGHT, SensorVD6283.NUM_SAMPLES_PER_BLOCK); + observeSlowSensorBlockSpacing(DATABLOCK_SENSOR_ID.SKIN_TEMP, SensorMLX90632.NUM_SAMPLES_PER_BLOCK); + calculateAndSetPayloadPackagingDelayMs(); } @@ -316,17 +329,247 @@ private boolean isParserAtEndOfBuffer(int bufferLength, int currentByteIndex) { } /** - * Derive a slow sensor's (ambient light / skin temp) achieved sample period - * from the spacing of consecutive same-sensor block end ticks and apply it to - * those blocks' sampling rate before their timings are back-filled. The - * header-derived rates are only estimates (the light rate isn't stored at all - * - see SensorVD6283.getRateFreq - and the skin-temp cadence is refresh-code - * derived), and each block holds a fixed number of samples, so - * {@code inter-block ticks / samples-per-block} is the exact per-sample period. - * With fewer than two blocks in the payload the header-derived estimate the - * blocks were created with is left in place. + * Refine a slow sensor's achieved per-sample period from the data and + * (re)derive its CSV gap-splitting window, choosing between the cross-payload + * treatment DEV-979 added, the per-payload one that came before it, and - for + * the MLX90632 at a slow enough rate - a window seeded straight from the + * header. + *
+ * A block's stored end time is a counter that wraps every minute, so + * differencing two blocks' end ticks is only sound while the true spacing is + * under a minute. {@link UtilCsvSplitting#isSlowSensorSpanUnambiguousAcrossPayloads(DATABLOCK_SENSOR_ID, int)} + * asks whether that holds for the sensor's SLOWEST legitimate rate: the VD6283 + * qualifies (a 10-sample block spans at most 20 s) and needs the cross-payload + * measurement because its configured rate is not in the payload at all. + *
+ * The MLX90632 fails that gate on its slowest rates, but its refresh code IS + * in the payload, so its ACTUAL configured output rate is known + * ({@link com.shimmerresearch.verisense.sensors.SensorMLX90632#getRateFreq()}, + * the value written to the CSV "Configured" line): + *
+ * The period comes from the running estimate built by + * {@link #observeSlowSensorBlockSpacing(DATABLOCK_SENSOR_ID, int)}, which runs + * at the END of the parse - see the note on ordering there. Measuring it at + * all is the only way to recover the VD6283's rate: the configured rate index + * is operational-config byte 75 and is NOT copied into the payload header, so + * the header-derived value the blocks are created with is merely the + * exposure-limited UPPER BOUND (see + * {@link com.shimmerresearch.verisense.sensors.SensorVD6283#getRateFreq()}). + * With the firmware's default 1 Hz rate and the default 100 ms exposure that + * bound is ten times the truth, which compressed every 10-sample block into + * 0.9 s of the 10 s it actually spans and left the remaining 9.1 s looking + * like a gap - splitting the CSV on every single block (DEV-979). + *
+ * RESIDUAL, not fixed here: the first TWO blocks of each CSV set are timed + * before a boundary between them has been observed, so they keep the + * header-derived estimate. Their samples are laid out over + * {@code (N-1) x estimatedPeriod} instead of {@code (N-1) x truePeriod}, which + * for a 10-sample light block at the default exposure puts the block's - and + * therefore the CSV header's - reported start time 8.1 s late at 1 Hz. + * Re-timing them would mean revisiting a block after the next one arrives, by + * which point the file parser has deep-cloned it into the CSV dataset it is + * accumulating, so the fix does not belong in the driver. The sample VALUES + * and the block end times are unaffected. + * + * @param slowSensorId the slow sensor's data block id + * @param samplesPerBlock the sensor's fixed samples per block + */ + void refineSlowSensorSamplingRateAcrossPayloads(DATABLOCK_SENSOR_ID slowSensorId, int samplesPerBlock) { + boolean payloadCarriesThisSensor = false; + for(DataBlockDetails dataBlockDetails:listOfDataBlocksInOrder) { + if(dataBlockDetails.datablockSensorId==slowSensorId) { + payloadCarriesThisSensor = true; + break; + } + } + if(!payloadCarriesThisSensor) { + return; + } + + double medianPeriodS = UtilCsvSplitting.recordAndGetSlowSensorPeriodS(verisenseDevice, slowSensorId, Double.NaN); + if(UtilCsvSplitting.isSlowSensorPeriodPlausible(verisenseDevice, slowSensorId, medianPeriodS)) { + double achievedRateHz = 1.0/medianPeriodS; + for(DataBlockDetails dataBlockDetails:listOfDataBlocksInOrder) { + if(dataBlockDetails.datablockSensorId==slowSensorId) { + dataBlockDetails.setSamplingRate(achievedRateHz); + dataBlockDetails.calculateTimestampDiffInS(); + } + } + } + + UtilCsvSplitting.refineSlowSensorGapWindow(verisenseDevice, slowSensorId, samplesPerBlock); + } + + /** + * Measure a slow sensor's achieved per-sample period from the spacing of + * consecutive same-sensor block END TIMES, across payload boundaries, and feed + * it into the running estimate the next payload will be timed with. + *
+ * Each block holds a fixed number of samples and is stamped with the time of + * its LAST sample, so {@code inter-block time / samples-per-block} is the + * achieved per-sample period - the technique the storage-format spec + * prescribes for the LSM6DSV. + *
+ * ORDERING, and why this is separate from + * {@link #refineSlowSensorSamplingRateAcrossPayloads(DATABLOCK_SENSOR_ID, int)}: + * this runs at the END of the payload parse, once the block timings have been + * back-filled, so it can difference ABSOLUTE real-world-clock milliseconds - + * the very quantity + * {@link UtilCsvSplitting#isDataBlockContinuous(SENSORS, DataSegmentDetails, DataBlockDetails)} + * judges a boundary on. The per-block stored time is only a SUB-MINUTE tick + * counter, so differencing that across payloads cannot tell a 5 s spacing from + * a 65 s one: a real 65 s gap between 10-sample light blocks aliases to 0.5 s + * per sample, i.e. 2 Hz, which is a legitimate firmware rate and so passes any + * plausibility test. Detection was never affected (the continuity check works + * in absolute ms) but the aliased value would have been learned and applied, + * mis-timing the blocks after a real gap until the state was next cleared. + * Absolute milliseconds remove the ambiguity at the source rather than + * guarding against it downstream. + *
+ * The in-payload path used by sensors that fail the unambiguous-span gate
+ * keeps differencing ticks, which is safe there: two blocks in one payload are
+ * at most a payload duration apart.
+ *
+ * @param slowSensorId the slow sensor's data block id
+ * @param samplesPerBlock the sensor's fixed samples per block
*/
- private void refineSlowSensorSamplingRateFromBlockTicks(DATABLOCK_SENSOR_ID slowSensorId) {
+ void observeSlowSensorBlockSpacing(DATABLOCK_SENSOR_ID slowSensorId, int samplesPerBlock) {
+ if(!UtilCsvSplitting.isSlowSensorSpanUnambiguousAcrossPayloads(slowSensorId, samplesPerBlock)) {
+ return;
+ }
+
+ // Only WHOLE blocks may be measured. A block that
+ // splitDataBlocksAtMiddayMidnight cut in two would otherwise read as an
+ // extra boundary a fraction of a second wide carrying a reduced sample
+ // count, so a split block is measured on its SECOND part - which keeps the
+ // original block's end time, splitAndStartAtSampleIndex only moves the
+ // start - with the two parts' sample counts added back together.
+ List
+ * Byte-identity for the DEV-927 skin temp holds by construction: the applied
+ * period is still this payload's own median, not a whole-file one, and the
+ * window is still seeded with the old formula. The DEV-927 reference CSVs
+ * (ASM_PC Test_065) cannot be reached from this environment, so nothing about
+ * that sensor's timing is changed on trust.
+ *
+ * @param slowSensorId the slow sensor's data block id
+ */
+ void refineSlowSensorSamplingRatePerPayload(DATABLOCK_SENSOR_ID slowSensorId) {
List
+ * The chip's conversions slip by several refresh periods and then catch up
+ * (+12.5% block spacing observed on the DEV-927 validation recording, with
+ * no samples lost), so a boundary reads ~12.5% slow and the one after it
+ * correspondingly fast. Only the AVERAGE rate is bounded by the configured
+ * one; a single boundary is not, which is why the configured rate alone is
+ * too tight a bound. 1.15 covers the observed slip with a little margin.
+ *
+ * Not used for the VD6283, whose a-priori bounds come from the firmware's
+ * rate table instead - its configured rate is not in the payload at all,
+ * and its sampling is a plain periodic timer with no slip-and-catch-up
+ * behaviour (a failed read costs a whole period, it never shortens one).
+ */
+ public static final double SLOW_SENSOR_CONVERSION_SLIP_TOLERANCE = 1.15;
}
+
+ /**
+ * Slow sensors only: how many block boundaries must have been observed before
+ * the MEASURED window replaces the provisional one. Below this the median is
+ * not an estimate of anything - the boundary about to be judged is itself one
+ * of the one or two values it would be built from, so it would always be found
+ * continuous, and the first boundary of every CSV set would be unreportable no
+ * matter how large its gap.
+ */
+ public static final int SLOW_SENSOR_MIN_OBSERVATIONS_FOR_MEASURED_WINDOW = 3;
+
+ /**
+ * Slow sensors only: how many of the most recently observed per-sample periods
+ * {@link #recordAndGetSlowSensorPeriodS(VerisenseDevice, DATABLOCK_SENSOR_ID, double)} keeps
+ * per sensor. Bounded so the estimate follows genuine long-term drift instead
+ * of averaging a multi-day recording, and so the median stays cheap to re-take
+ * on every payload. Large enough that occasional dropped blocks cannot move
+ * the median once the history is full - but see the design note on
+ * {@link #refineSlowSensorGapWindow(VerisenseDevice, DATABLOCK_SENSOR_ID, int)}
+ * for what SUSTAINED loss does.
+ */
+ public static final int SLOW_SENSOR_OBSERVED_RATE_HISTORY_MAX = 256;
protected static HashMap
+ * Absolute milliseconds, not the per-block sub-minute tick counter, because a
+ * tick delta cannot tell a 5 s spacing from a 65 s one - a real 65 s gap
+ * between 10-sample light blocks aliases to a perfectly legitimate 2 Hz.
+ * Populated only once the block timings have been back-filled.
+ */
+ protected static HashMap
+ * The slow sensors (VD6283 ambient light, MLX90632 skin temp) buffer a fixed
+ * number of samples and emit the block only once it is full, stamping it with
+ * the time of its LAST sample. The spacing between two consecutive blocks'
+ * end times divided by the samples per block is therefore the achieved
+ * per-sample period - the same technique the storage-format spec prescribes for
+ * the LSM6DSV, and the only way to recover the VD6283's rate at all, because
+ * the configured rate index is not stored in the payload (see
+ * {@link com.shimmerresearch.verisense.sensors.SensorVD6283#getRateFreq()}).
+ *
+ * The MEDIAN over the accumulated history is returned rather than the latest
+ * delta. A single failed I2C read makes one block take an extra period to fill
+ * without losing a sample slot (hal_slowSensorSampler.c increments the count
+ * only on a successful read), and a dropped block doubles the delta - taking
+ * the raw delta would stretch that block's samples by 10% or 100%, whereas the
+ * median keeps every block on the true period, which is where the samples
+ * actually are. The history is bounded to
+ * {@link #SLOW_SENSOR_OBSERVED_RATE_HISTORY_MAX} so the estimate still follows
+ * genuine long-term drift.
+ *
+ * @param slowSensorId the slow sensor's data block id
+ * @param observedPeriodS the period just measured, or NaN to only read the estimate back
+ * @return the median observed period in seconds, or NaN if nothing has been observed
+ */
+ public static double recordAndGetSlowSensorPeriodS(VerisenseDevice verisenseDevice, DATABLOCK_SENSOR_ID slowSensorId, double observedPeriodS) {
+ List
+ * This catches a period no configuration could have produced - a dropped block
+ * or a clock correction stretching a boundary well past the slowest rate, for
+ * instance - so that neither the running estimate nor the timing of a block
+ * can be built from one. It does NOT and cannot catch tick aliasing: a real
+ * 65 s gap between 10-sample light blocks aliases to 0.5 s per sample, i.e.
+ * 2 Hz, which IS a legitimate firmware rate. That is why the cross-payload
+ * measurement differences absolute real-world-clock milliseconds instead of
+ * ticks (see
+ * {@code PayloadContentsDetailsV8orAbove.observeSlowSensorBlockSpacing}) - the
+ * ambiguity is removed at the source rather than filtered here.
+ *
+ * @param verisenseDevice the device being parsed
+ * @param slowSensorId the slow sensor's data block id
+ * @param observedPeriodS the candidate per-sample period in seconds
+ * @return true when the period is one the sensor could have produced
+ */
+ public static boolean isSlowSensorPeriodPlausible(VerisenseDevice verisenseDevice, DATABLOCK_SENSOR_ID slowSensorId, double observedPeriodS) {
+ if(Double.isNaN(observedPeriodS) || Double.isInfinite(observedPeriodS) || !(observedPeriodS>0)) {
+ return false;
+ }
+ double[] plausibleRateRangeHz = getSlowSensorPlausibleRateRangeHz(verisenseDevice, slowSensorId);
+ if(plausibleRateRangeHz==null) {
+ return true;
+ }
+ double observedRateHz = 1.0/observedPeriodS;
+ return observedRateHz>=plausibleRateRangeHz[0] && observedRateHz<=plausibleRateRangeHz[1];
+ }
+
+ public static int getSlowSensorObservationCount(DATABLOCK_SENSOR_ID slowSensorId) {
+ List
+ * Once the achieved per-sample period is known the window is the #285 formula
+ * over the measured history: gap side
+ * {@code median / SLOW_SENSOR_MAX_INTER_BLOCK_GAP_RATIO} so healthy jitter
+ * stays continuous while a dropped block (2x spacing) is reported, fast side
+ * the quickest boundary that is still plausible against the median, with the
+ * standard tolerance. The fast side has to be an extremum rather than the
+ * median because the MLX90632's conversions slip and then catch up (+12.5%
+ * observed on the DEV-927 recording, so the catch-up boundary reads
+ * correspondingly fast); excluding the implausible ones here rather than when
+ * they were learned stops an overlap artefact widening the fast side past
+ * itself while still letting the median recover from an early anomaly.
+ *
+ * Below {@link #SLOW_SENSOR_MIN_OBSERVATIONS_FOR_MEASURED_WINDOW}
+ * observations - which includes the first block of every CSV set, when there
+ * are none - an A-PRIORI window is used instead, because the boundary being
+ * judged is itself one of the one or two values a measured window would be
+ * built from, so that window would simply re-centre on whatever it was about
+ * to judge and the first boundary of every CSV set would be continuous no
+ * matter how large its gap. The a-priori bounds come from what the hardware
+ * can actually do, not from an arbitrary multiple: see
+ * {@code getSlowSensorPlausibleRateRangeHz}.
+ *
+ * DESIGN NOTE: the window follows the data, so SUSTAINED block loss is
+ * eventually learned as the cadence. If every other block went missing for
+ * more than {@link #SLOW_SENSOR_OBSERVED_RATE_HISTORY_MAX} boundaries the
+ * median would move onto the halved rate and the loss would stop being
+ * reported; the return to the true cadence is then reported once, as a single
+ * split. That is the price of tracking a rate the payload does not carry.
+ *
+ * @param verisenseDevice the device being parsed
+ * @param slowSensorId the slow sensor's data block id
+ * @param samplesPerBlock the sensor's fixed samples per block
+ */
+ public static void refineSlowSensorGapWindow(VerisenseDevice verisenseDevice, DATABLOCK_SENSOR_ID slowSensorId, int samplesPerBlock) {
+ List
+ * A block's stored end time is a counter that wraps every minute, so a delta
+ * between two blocks is only recoverable (by re-basing a negative delta by one
+ * minute) while the true spacing is under a minute. Inside one payload that is
+ * guaranteed by the payload's own duration, but across payloads it has to be
+ * bounded by what the sensor could be configured to do: the slowest rate the
+ * hardware offers times the samples per block.
+ *
+ * A 10-sample VD6283 block spans at most 20 s (slowest firmware rate 0.5 Hz)
+ * and is always safe. A 16-sample MLX90632 block spans 64 s in the common
+ * medical-mode worst case (0.5 Hz refresh / 2 = 0.25 Hz output) and 96 s in
+ * the extended-mode worst case
+ * ({@link com.shimmerresearch.verisense.sensors.SensorMLX90632#MIN_OUTPUT_RATE_HZ},
+ * 0.5 Hz refresh / 3 = 0.167 Hz), and this method uses that worst case - both
+ * exceed the 60 s unambiguous span, so the skin temp never qualifies. It costs
+ * nothing: the MLX90632's refresh code IS stored in the payload, so its
+ * header-derived rate is already correct and it only needs the within-payload
+ * refinement it has always had.
+ *
+ * @param slowSensorId the slow sensor's data block id
+ * @param samplesPerBlock the sensor's fixed samples per block
+ * @return true when a cross-payload tick delta is unambiguous
+ */
+ public static boolean isSlowSensorSpanUnambiguousAcrossPayloads(DATABLOCK_SENSOR_ID slowSensorId, int samplesPerBlock) {
+ double[] plausibleRateRangeHz = null;
+ if(slowSensorId==DATABLOCK_SENSOR_ID.LIGHT) {
+ plausibleRateRangeHz = new double[] {SensorVD6283.MIN_SAMPLE_RATE_HZ, SensorVD6283.MAX_SAMPLE_RATE_HZ};
+ } else if(slowSensorId==DATABLOCK_SENSOR_ID.SKIN_TEMP) {
+ plausibleRateRangeHz = new double[] {SensorMLX90632.MIN_OUTPUT_RATE_HZ, SensorMLX90632.MAX_OUTPUT_RATE_HZ};
+ }
+ if(plausibleRateRangeHz==null || !(plausibleRateRangeHz[0]>0)) {
+ return false;
+ }
+ double maximumBlockSpanS = samplesPerBlock/plausibleRateRangeHz[0];
+ return maximumBlockSpanS<(AsmBinaryFileConstants.TICKS_PER_MINUTE/AsmBinaryFileConstants.TICKS_PER_SECOND);
+ }
+
+ /**
+ * The {min, max} per-sample rate a slow sensor could legitimately be running
+ * at, used for the a-priori gap window before anything has been measured.
+ *
+ * VD6283: the firmware's whole rate table
+ * ({@link com.shimmerresearch.verisense.sensors.SensorVD6283#MIN_SAMPLE_RATE_HZ}
+ * ..{@link com.shimmerresearch.verisense.sensors.SensorVD6283#MAX_SAMPLE_RATE_HZ}
+ * = 0.5..20 Hz), because the configured index is not in the payload and the
+ * exposure only bounds the rate from above. A 10-sample block may therefore
+ * legitimately span anything from 0.5 s to 20 s.
+ *
+ * BLIND SPOT, quantified: with the standard tolerances that window is
+ * [0.33, 22] Hz, and a boundary presents {@code 10 / deltaS}, so on the first
+ * two boundaries of a CSV set any spacing up to 30 s is accepted. At the
+ * firmware's default 1 Hz that means up to 20 s of genuinely lost light data
+ * goes unreported there, permanently - those two boundaries are never
+ * re-judged. The fast side likewise accepts a backwards clock jump of up to
+ * ~9.5 s. A 60 s spacing does split. This is the price of not knowing the
+ * configured rate: the alternative, centring the window on one or two
+ * observations, cannot report anything at all (the boundary being judged is
+ * the estimate). From the third boundary on the measured window applies and
+ * the tolerance is 1.5x the achieved period.
+ *
+ * MLX90632: the refresh code IS stored in the payload, so the configured
+ * output rate is known; it is widened by
+ * {@link FILE_GAP_TOLERANCE_MULTIPLIER#SLOW_SENSOR_CONVERSION_SLIP_TOLERANCE}
+ * to cover the documented conversion slip and catch-up, and falls back to the
+ * refresh table's full span if the configured rate is unusable.
+ *
+ * @param verisenseDevice the device being parsed
+ * @param slowSensorId the slow sensor's data block id
+ * @return the {min, max} plausible rate in Hz, or null if it cannot be bounded
+ */
+ public static double[] getSlowSensorPlausibleRateRangeHz(VerisenseDevice verisenseDevice, DATABLOCK_SENSOR_ID slowSensorId) {
+ if(slowSensorId==DATABLOCK_SENSOR_ID.LIGHT) {
+ return new double[] {SensorVD6283.MIN_SAMPLE_RATE_HZ, SensorVD6283.MAX_SAMPLE_RATE_HZ};
+ }
+ if(slowSensorId==DATABLOCK_SENSOR_ID.SKIN_TEMP) {
+ double configuredSamplingRate = verisenseDevice.getSamplingRateForSensor(SENSORS.MLX90632);
+ if(configuredSamplingRate>0 && !Double.isNaN(configuredSamplingRate) && !Double.isInfinite(configuredSamplingRate)) {
+ return new double[] {
+ configuredSamplingRate/FILE_GAP_TOLERANCE_MULTIPLIER.SLOW_SENSOR_CONVERSION_SLIP_TOLERANCE,
+ configuredSamplingRate*FILE_GAP_TOLERANCE_MULTIPLIER.SLOW_SENSOR_CONVERSION_SLIP_TOLERANCE};
+ }
+ return new double[] {SensorMLX90632.MIN_OUTPUT_RATE_HZ, SensorMLX90632.MAX_OUTPUT_RATE_HZ};
+ }
+ return null;
+ }
+
+ /**
+ * Median of a list of observations - the mean of the two middle values for an
+ * even-sized input, so that neither of a pair straddling the middle can hand
+ * the estimate to an outlier on its own. Sorts a copy; the caller's list is
+ * kept in arrival order so its oldest entries can be trimmed.
+ *
+ * @param values the observations, in arrival order
+ * @return the median, or NaN when there are none
+ */
+ public static double calculateMedian(List
+ * The exposure only caps how fast the chip can measure: the firmware sets the
+ * inter-measurement time to the configured sample period and the VD6283
+ * measures every {@code max(inter-measurement, exposure)}, so the exposure
+ * bounds the rate from above and says nothing about it otherwise. The rate
+ * ITSELF is operational-config byte 75 (LIGHT_SAMPLE_RATE_INDEX) into
+ * {@link #MIN_SAMPLE_RATE_HZ}..{@link #MAX_SAMPLE_RATE_HZ}, which is NOT
+ * stored in the payload header - the firmware defaults it to 1 Hz when the
+ * sensor is enabled with index 0 (ASM_Production/main.c), and 1 Hz with the
+ * default 100 ms exposure is a factor of TEN below this bound.
+ *
+ * So this value is only good enough to seed the timing of the FIRST block of a
+ * CSV set; every block after it is re-timed from the measured inter-block
+ * spacing by
+ * {@code PayloadContentsDetailsV8orAbove.refineSlowSensorSamplingRateFromBlockTicks}.
+ * Deriving sample spacing from it alone compressed each 10-sample block into
+ * 0.9 s of a 10 s span and left the remainder looking like a 9.1 s gap, which
+ * split the CSV on every block (DEV-979).
+ *
+ * @return the exposure-limited upper bound on the sample rate in Hz
*/
public double getRateFreq() {
return Math.min(MAX_SAMPLE_RATE_HZ, 1e6 / getExposureUs());
diff --git a/ShimmerDriver/src/test/java/com/shimmerresearch/verisense/payloaddesign/API_00009_VerisenseSlowSensorGapWindow.java b/ShimmerDriver/src/test/java/com/shimmerresearch/verisense/payloaddesign/API_00009_VerisenseSlowSensorGapWindow.java
new file mode 100644
index 000000000..84cc727ec
--- /dev/null
+++ b/ShimmerDriver/src/test/java/com/shimmerresearch/verisense/payloaddesign/API_00009_VerisenseSlowSensorGapWindow.java
@@ -0,0 +1,634 @@
+package com.shimmerresearch.verisense.payloaddesign;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import com.shimmerresearch.driver.Configuration.COMMUNICATION_TYPE;
+import com.shimmerresearch.driverUtilities.ShimmerVerDetails.HW_ID;
+import com.shimmerresearch.sensors.AbstractSensor.SENSORS;
+import com.shimmerresearch.verisense.VerisenseDevice;
+import com.shimmerresearch.verisense.payloaddesign.DataBlockDetails.DATABLOCK_SENSOR_ID;
+import com.shimmerresearch.verisense.sensors.SensorMLX90632;
+import com.shimmerresearch.verisense.sensors.SensorVD6283;
+
+/**
+ * Unit tests for the DEV-979 slow-sensor timing refinement. These drive the REAL
+ * package-private methods on {@link PayloadContentsDetailsV8orAbove} - one call
+ * per synthetic payload, in sequence, with data blocks carrying end TICKS the
+ * way the metadata parse leaves them - and judge the resulting CSV split
+ * decisions through the real
+ * {@link UtilCsvSplitting#isDataBlockContinuous(SENSORS, DataSegmentDetails, DataBlockDetails)}.
+ * No binary test files, no hardware data and no reflection.
+ *
+ * The bug: the VD6283 is NOT duty-cycled. The firmware samples it on a plain
+ * repeated timer at one of {@code 0.5, 1, 2, 5, 10, 20} Hz
+ * (hal_slowSensorSampler.c {@code slowSensorRateMs[]}), defaulting to 1 Hz, and
+ * buffers 10 samples per block. That rate index lives in operational-config byte
+ * 75 and is NOT stored in the payload, so the parser fell back to the
+ * exposure-derived value - which only bounds the rate from ABOVE (10 Hz at the
+ * default 100 ms exposure). Each 10-sample block was therefore laid out over
+ * 0.9 s of the 10 s it really spans, and the 9.1 s remainder looked like a gap:
+ * 129 one-block CSVs.
+ *
+ * End-to-end coverage on the real recording is ASM_PC_00005_VerisenseFileParserPC
+ * Test_066; the DEV-927 skin-temp equivalent is Test_065.
+ */
+public class API_00009_VerisenseSlowSensorGapWindow {
+
+ private static final int LIGHT_SAMPLES_PER_BLOCK = SensorVD6283.NUM_SAMPLES_PER_BLOCK;
+ private static final int SKIN_TEMP_SAMPLES_PER_BLOCK = SensorMLX90632.NUM_SAMPLES_PER_BLOCK;
+
+ private static final double TICKS_PER_SECOND = AsmBinaryFileConstants.TICKS_PER_SECOND;
+ private static final long TICKS_PER_MINUTE = (long) AsmBinaryFileConstants.TICKS_PER_MINUTE;
+
+ /** The DEV-979 recording: 1 Hz light, so a 10-sample block every 10 s. */
+ private static final double LIGHT_1HZ_BLOCK_SPACING_S = 10;
+ /** Skin temp refresh code 6 = 32 Hz refresh -> 16 Hz medical output (DEV-927). */
+ private static final int SKIN_TEMP_CONFIG_32HZ_REFRESH = 6<<1;
+
+ @Before
+ public void clearSplittingState() {
+ UtilCsvSplitting.clearMapOfSamplingRateLimitsPerSensor();
+ }
+
+ private VerisenseDevice setupGen2Device(int skinTempConfigByte) {
+ VerisenseDevice device = new VerisenseDevice(COMMUNICATION_TYPE.SD);
+
+ byte[] configBytes = new byte[32];
+ configBytes[0] = (byte) 0x10; // extended-config flag
+ configBytes[2] = 2; // FW major
+ configBytes[4] = 9; // FW internal LSB (v2.00.009)
+ configBytes[6] = (byte) 0xFF; // reset reason
+ configBytes[11] = HW_ID.VERISENSE_PULSE_PLUS; // SR68
+ configBytes[12] = 9; // SR68-9 (second generation)
+ configBytes[25] = (byte) (0x02 | (1<<3) | (1<<4)); // GEN_CFG_3: LED + VD6283 + MLX90632
+ configBytes[28] = (byte) skinTempConfigByte; // SKIN_TEMP_CONFIG
+ device.configBytesParse(configBytes, COMMUNICATION_TYPE.SD);
+
+ device.getOrCreateListOfSensorClassKeysForDataBlockId(DATABLOCK_SENSOR_ID.LIGHT);
+ device.getOrCreateListOfSensorClassKeysForDataBlockId(DATABLOCK_SENSOR_ID.SKIN_TEMP);
+ assertTrue("this fixture must exercise the v11+ (uC ticks) path", device.isPayloadDesignV11orAbove());
+ return device;
+ }
+
+ private VerisenseDevice setupGen2Device() {
+ return setupGen2Device(0);
+ }
+
+ private static int samplesPerBlock(DATABLOCK_SENSOR_ID slowSensorId) {
+ return slowSensorId==DATABLOCK_SENSOR_ID.LIGHT? LIGHT_SAMPLES_PER_BLOCK:SKIN_TEMP_SAMPLES_PER_BLOCK;
+ }
+
+ private static SENSORS sensorClassKeyOf(DATABLOCK_SENSOR_ID slowSensorId) {
+ return slowSensorId==DATABLOCK_SENSOR_ID.LIGHT? SENSORS.VD6283:SENSORS.MLX90632;
+ }
+
+ /** A block as the metadata parse leaves it: sized, timed with the header estimate, end TICKS set. */
+ private DataBlockDetails newBlock(VerisenseDevice device, DATABLOCK_SENSOR_ID slowSensorId, long endTicks) {
+ int bytesPerSample = slowSensorId==DATABLOCK_SENSOR_ID.LIGHT? SensorVD6283.BYTES_PER_SAMPLE:SensorMLX90632.BYTES_PER_SAMPLE;
+ DataBlockDetails dataBlockDetails = new DataBlockDetails(slowSensorId, 0, 0,
+ device.getOrCreateListOfSensorClassKeysForDataBlockId(slowSensorId), 0, 0);
+ dataBlockDetails.setMetadata(samplesPerBlock(slowSensorId)*bytesPerSample, bytesPerSample,
+ device.getSamplingRateForSensor(sensorClassKeyOf(slowSensorId)));
+ // v11+ stores microcontroller-clock ticks per block; the sub-minute counter
+ // is what the refinement differences.
+ dataBlockDetails.getTimeDetailsUcClock().setEndTimeTicks(endTicks%TICKS_PER_MINUTE);
+ // The absolute RWC ms both the continuity check and the cross-payload
+ // measurement work on. Kept in step with the sub-minute ticks the
+ // in-payload path differences.
+ dataBlockDetails.getTimeDetailsRwc().setEndTimeMs(endTicks/TICKS_PER_SECOND*1000);
+ return dataBlockDetails;
+ }
+
+ private static long ticks(double seconds) {
+ return (long) Math.round(seconds*TICKS_PER_SECOND);
+ }
+
+ /**
+ * Run one payload through the real refinement: build a
+ * PayloadContentsDetailsV8orAbove, give it the blocks, and call the method the
+ * parse flow calls.
+ *
+ * @return the payload's blocks, as the refinement left them
+ */
+ private DataBlockDetails[] refinePayload(VerisenseDevice device, DATABLOCK_SENSOR_ID slowSensorId, DataBlockDetails... payloadBlocks) {
+ PayloadContentsDetailsV8orAbove payloadContentsDetails = new PayloadContentsDetailsV8orAbove(device);
+ payloadContentsDetails.listOfDataBlocksInOrder.addAll(Arrays.asList(payloadBlocks));
+ // The two phases the parse flow runs, in its order: apply the running
+ // estimate to this payload's blocks before their timings are back-filled...
+ payloadContentsDetails.refineSlowSensorSamplingRateFromBlockTicks(slowSensorId, samplesPerBlock(slowSensorId));
+ // ...then, once they are, measure this payload's boundaries in absolute ms.
+ payloadContentsDetails.observeSlowSensorBlockSpacing(slowSensorId, samplesPerBlock(slowSensorId));
+ return payloadBlocks;
+ }
+
+ /** One payload holding exactly one block of the sensor, at the given end ticks. */
+ private DataBlockDetails refineOneBlockPayload(VerisenseDevice device, DATABLOCK_SENSOR_ID slowSensorId, long endTicks) {
+ return refinePayload(device, slowSensorId, newBlock(device, slowSensorId, endTicks))[0];
+ }
+
+ private String continuityResult(DATABLOCK_SENSOR_ID slowSensorId, DataSegmentDetails previousSegment, DataBlockDetails next) {
+ return UtilCsvSplitting.isDataBlockContinuous(sensorClassKeyOf(slowSensorId), previousSegment, next);
+ }
+
+ private DataSegmentDetails dataSegmentOf(DataBlockDetails... dataBlockDetails) {
+ DataSegmentDetails dataSegmentDetails = new DataSegmentDetails();
+ for (DataBlockDetails block : dataBlockDetails) {
+ dataSegmentDetails.addDataBlock(block);
+ }
+ return dataSegmentDetails;
+ }
+
+ /**
+ * Walk a stream of one-block payloads through the real refinement, asserting
+ * each boundary's split decision.
+ *
+ * @param spacingsS the spacing from each block to the next, in seconds
+ */
+ private DataSegmentDetails walkStream(VerisenseDevice device, DATABLOCK_SENSOR_ID slowSensorId, double firstBlockEndS, double... spacingsS) {
+ double endS = firstBlockEndS;
+ DataBlockDetails previous = refineOneBlockPayload(device, slowSensorId, ticks(endS));
+ DataSegmentDetails dataSegmentDetails = dataSegmentOf(previous);
+ for (int i = 0; i < spacingsS.length; i++) {
+ endS += spacingsS[i];
+ DataBlockDetails next = refineOneBlockPayload(device, slowSensorId, ticks(endS));
+ assertEquals("boundary " + i + " (spacing " + spacingsS[i] + " s) must be continuous",
+ "", continuityResult(slowSensorId, dataSegmentDetails, next));
+ dataSegmentDetails.addDataBlock(next);
+ }
+ return dataSegmentDetails;
+ }
+
+ private static double[] uniformSpacings(int count, double spacingS) {
+ double[] spacingsS = new double[count];
+ Arrays.fill(spacingsS, spacingS);
+ return spacingsS;
+ }
+
+ // ---------------------------------------------------------------- VD6283
+
+ /**
+ * The reported symptom, in the shape the firmware actually produces: 1 Hz
+ * light, a 10-sample block every 10 s. No boundary may split, so the whole
+ * stream lands in one CSV.
+ */
+ @Test
+ public void test001_lightAt1HzDoesNotSplit() {
+ VerisenseDevice device = setupGen2Device();
+ assertEquals("the header only yields the exposure-derived upper bound",
+ 10.0, device.getSamplingRateForSensor(SENSORS.VD6283), 1e-9);
+
+ DataSegmentDetails dataSegmentDetails = walkStream(device, DATABLOCK_SENSOR_ID.LIGHT, 100, uniformSpacings(40, LIGHT_1HZ_BLOCK_SPACING_S));
+ assertEquals(41, dataSegmentDetails.getDataBlockCount());
+ }
+
+ /**
+ * The refinement recovers the true 1 s period from the block spacing and
+ * applies it, so the blocks become CONTIGUOUS: each one's 10 samples span the
+ * 9 s from its first to its last, not the 0.9 s the exposure-derived estimate
+ * implied, and the next block starts one period after the previous one ends.
+ */
+ @Test
+ public void test002_refinedPeriodIsAppliedAndMakesBlocksContiguous() {
+ VerisenseDevice device = setupGen2Device();
+
+ DataBlockDetails first = refineOneBlockPayload(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(100));
+ // The first block of a CSV set has nothing to measure against, so it keeps
+ // the header estimate - the documented residual.
+ assertEquals(10.0, first.getSamplingRate(), 1e-9);
+ assertEquals(0.1, first.getTimestampDiffInS(), 1e-9);
+
+ // The second block completes the first boundary, but its own rate was applied
+ // before that boundary existed, so it keeps the estimate too - the residual
+ // is the first TWO blocks of a set.
+ DataBlockDetails second = refineOneBlockPayload(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(100+LIGHT_1HZ_BLOCK_SPACING_S));
+ assertEquals(10.0, second.getSamplingRate(), 1e-9);
+
+ // From the third on, the measured 1 s period is applied
+ DataBlockDetails third = refineOneBlockPayload(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(100+(2*LIGHT_1HZ_BLOCK_SPACING_S)));
+ assertEquals("10 samples over 10 s = 1 Hz", 1.0, third.getSamplingRate(), 1e-6);
+ assertEquals(1.0, third.getTimestampDiffInS(), 1e-6);
+
+ // Timed from the end tick, the block's samples now span 9 x 1 s...
+ third.setUcClockEndTimeMinutesAndCalculateTimings(0);
+ double blockSpanMs = third.getTimeDetailsUcClock().getEndTimeMs()-third.getTimeDetailsUcClock().getStartTimeMs();
+ assertEquals(9000, blockSpanMs, 1);
+ }
+
+ /**
+ * A light configuration where the exposure-derived estimate happens to equal
+ * the truth (10 Hz: 10 samples 100 ms apart, a block every second) is refined
+ * to the same value, so nothing about it changes.
+ */
+ @Test
+ public void test003_lightWhereTheEstimateEqualsTheTruthIsUnchanged() {
+ VerisenseDevice device = setupGen2Device();
+
+ walkStream(device, DATABLOCK_SENSOR_ID.LIGHT, 100, uniformSpacings(20, 1.0));
+
+ DataBlockDetails latest = refineOneBlockPayload(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(121));
+ assertEquals(10.0, latest.getSamplingRate(), 1e-6);
+ }
+
+ /**
+ * A boundary that crosses a minute must still measure 1 Hz. The per-block
+ * stored time is a sub-minute counter, so the cross-payload measurement works
+ * on absolute real-world-clock ms instead and the crossing is a non-event -
+ * this pins that, since differencing the wrapped ticks would need a rebase.
+ */
+ @Test
+ public void test004_minuteCrossingBoundaryIsMeasuredCorrectly() {
+ VerisenseDevice device = setupGen2Device();
+
+ // 45 s -> 55 s -> 65 s: the last block's sub-minute tick value is SMALLER
+ DataBlockDetails first = refineOneBlockPayload(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(45));
+ refineOneBlockPayload(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(55));
+ DataBlockDetails third = refineOneBlockPayload(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(65));
+ assertTrue("the fixture must actually wrap the tick counter",
+ third.getTimeDetailsUcClock().getEndTimeTicks()
+ * The refresh code is in the header, so this configuration is instead routed
+ * to a window seeded straight from the header rate, and no tick differencing
+ * is done.
+ */
+ @Test
+ public void test020_skinTempAtSlowestRateSeedsTheWindowFromTheHeaderNotTheWrappedTickDelta() {
+ VerisenseDevice device = setupGen2Device(0); // refresh code 0 -> 0.5 Hz refresh -> 0.25 Hz output
+ assertEquals("slowest configuration is 0.25 Hz output", 0.25,
+ device.getSamplingRateForSensor(SENSORS.MLX90632), 1e-9);
+
+ // Two 16-sample blocks 64 s apart in one payload - the shape that aliased.
+ DataBlockDetails[] payload = refinePayload(device, DATABLOCK_SENSOR_ID.SKIN_TEMP,
+ newBlock(device, DATABLOCK_SENSOR_ID.SKIN_TEMP, ticks(10)),
+ newBlock(device, DATABLOCK_SENSOR_ID.SKIN_TEMP, ticks(74)));
+
+ assertEquals("the block keeps its header-derived rate, not the wrapped ~4 Hz",
+ 0.25, payload[0].getSamplingRate(), 1e-9);
+ assertEquals(0.25, payload[1].getSamplingRate(), 1e-9);
+ assertEquals("no per-payload tick differencing, no cross-payload history",
+ 0, UtilCsvSplitting.getSlowSensorObservationCount(DATABLOCK_SENSOR_ID.SKIN_TEMP));
+
+ double[] samplingRateLimits = UtilCsvSplitting.SAMPLING_RATE_LIMITS_PER_SENSOR.get(SENSORS.MLX90632);
+ assertNotNull("the gap window is seeded from the header rate", samplingRateLimits);
+ assertTrue("the window sits around 0.25 Hz, not the wrapped ~4 Hz (fast side was ~4.8 with the bug)",
+ samplingRateLimits[1] < 1.0);
+
+ // A genuine 64 s boundary between 0.25 Hz blocks must NOT split.
+ UtilCsvSplitting.clearMapOfSamplingRateLimitsPerSensor();
+ walkStream(device, DATABLOCK_SENSOR_ID.SKIN_TEMP, 10, 64, 64, 64);
+ }
+
+ /** The median helper averages the two middle values for an even-sized input. */
+ @Test
+ public void test018_medianIsTheMeanOfTheTwoMiddleValues() {
+ assertTrue(Double.isNaN(UtilCsvSplitting.calculateMedian(Arrays.