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 6313f0374..9943a8481 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetailsV8orAbove.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetailsV8orAbove.java @@ -16,8 +16,6 @@ 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; @@ -95,14 +93,13 @@ public void parsePayloadContentsMetaData(int binFileByteIndex) throws IOExceptio // --------- End of parsing ------------------ - // 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); + // 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); // 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 @@ -151,16 +148,6 @@ 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(); } @@ -329,247 +316,17 @@ private boolean isParserAtEndOfBuffer(int bufferLength, int currentByteIndex) { } /** - * 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
+ * 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.
*/
- 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) {
+ private void refineSlowSensorSamplingRateFromBlockTicks(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
+ * Exposure-limited sample-rate ESTIMATE (Hz). The configured rate is not in
+ * the stored payload header and the chip adds per-measurement dead time, so
+ * this only seeds data-block timing - the parser refines the rate per payload
+ * from consecutive light-block timestamps.
*/
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
deleted file mode 100644
index 84cc727ec..000000000
--- a/ShimmerDriver/src/test/java/com/shimmerresearch/verisense/payloaddesign/API_00009_VerisenseSlowSensorGapWindow.java
+++ /dev/null
@@ -1,634 +0,0 @@
-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.