diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/VerisenseDevice.java b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/VerisenseDevice.java index 94cb1b33e..126d79174 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/VerisenseDevice.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/VerisenseDevice.java @@ -255,6 +255,25 @@ public static class FW_CHANGES { * "1.04.024" is stale). The 36-byte total header = 4 (index+length) + 32 config. */ public static final ShimmerVerObject CCF_GEN2 = new ShimmerVerObject(FW_ID.UNKNOWN, 2, 0, 4); + /** + * The VD6283 effective sample-rate index is mirrored into payload header + * byte 30 bits 6:3. Header LENGTH is unchanged (still 32 bytes), so unlike + * the other entries here this one does not affect where anything is read + * from. + *

+ * DIAGNOSTIC USE ONLY - no parsing decision keys on it. The rate field is + * self-describing: earlier firmware always wrote zero into those bits, and + * this firmware writes the EFFECTIVE index, which is never zero while light + * blocks exist. So a parser can simply ask whether the field is set. This + * constant exists to tell "old recording" apart from "new recording, but + * the firmware failed to populate the field", which is a bug worth + * reporting rather than silently tolerating. + *

+ * PLACEHOLDER: confirm against the firmware release that ships the change + * (expected v2.02.000, DEV-1011). Because nothing parses on it, a wrong + * value here can only mis-word a warning. + */ + public static final ShimmerVerObject CCF_GEN2_LIGHT_RATE = new ShimmerVerObject(FW_ID.UNKNOWN, 2, 2, 0); } public static class FW_SPECIAL_VERSIONS { @@ -723,6 +742,14 @@ public boolean isPayloadDesignV13orAbove() { return PayloadContentsDetails.isPayloadDesignV13orAbove(getShimmerVerObject()); } + /** + * See {@link FW_CHANGES#CCF_GEN2_LIGHT_RATE} - diagnostic only, no parsing + * decision keys on this. + */ + public boolean isPayloadDesignV14orAbove() { + return PayloadContentsDetails.isPayloadDesignV14orAbove(getShimmerVerObject()); + } + /** * Whether a given Verisense hardware revision is second-generation * (SR68-9/10, SR61-5/6). Mirrors the TypeScript SDK @@ -1062,21 +1089,33 @@ && isAnyLsm6dsvChannelEnabled()) { sb.append("}"); } else if(sensorClassKey==AbstractSensor.SENSORS.VD6283 && isSensorEnabled(Configuration.Verisense.SENSOR_ID.VD6283)) { - // Second-generation ambient light. The configured sample rate isn't stored - // in the payload header, so only the calculated (achieved) rate is reported. + // Second-generation ambient light. From FW v2.02.000 the configured rate + // is in the payload header (byte 30 bits 6:3) and is reported like every + // other sensor; for earlier recordings it was stored nowhere, so only the + // calculated (achieved) rate can be given. SensorVD6283 sensorVd6283 = getSensorVD6283(); - sb.append(sensorClassKey.toString()); - sb.append(" {Sampling Rate ["); - sb.append(SENSOR_CONFIG_STRINGS.SAMPLING_RATE_CALCULATED); - if(!Double.isNaN(calculatedSamplingRate)) { - sb.append(UtilVerisenseDriver.formatDoubleToNdecimalPlaces(calculatedSamplingRate, 3)); - sb.append(" "); - sb.append(CHANNEL_UNITS.FREQUENCY); + if(sensorVd6283.isConfiguredRateKnown()) { + // The CONFIGURED rate, not getRateFreq()'s exposure-clamped one. This is + // the field a user reads to check what they asked the device to do, so a + // 20 Hz configuration must not print as 10 Hz merely because the exposure + // cannot sustain it. The clamp still governs block timing and the gap + // window; Exposure is on this same line for anyone deriving the bound. + sb.append(generateCalcSamplingRateConfigStr(sensorClassKey, sensorVd6283.getRate().freqHz, calculatedSamplingRate)); } else { - sb.append(UtilVerisenseDriver.UNAVAILABLE); + sb.append(sensorClassKey.toString()); + sb.append(" {Sampling Rate ["); + sb.append(SENSOR_CONFIG_STRINGS.SAMPLING_RATE_CALCULATED); + if(!Double.isNaN(calculatedSamplingRate)) { + sb.append(UtilVerisenseDriver.formatDoubleToNdecimalPlaces(calculatedSamplingRate, 3)); + sb.append(" "); + sb.append(CHANNEL_UNITS.FREQUENCY); + } else { + sb.append(UtilVerisenseDriver.UNAVAILABLE); + } + sb.append("]; "); } - sb.append("]; Gain = "); + sb.append("Gain = "); sb.append(UtilVerisenseDriver.formatDoubleToNdecimalPlaces(sensorVd6283.getGain(), 2)); sb.append("x; Exposure = "); sb.append(UtilVerisenseDriver.formatDoubleToNdecimalPlaces(sensorVd6283.getExposureUs()/1000.0, 1)); diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/AsmBinaryFileConstants.java b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/AsmBinaryFileConstants.java index 778cc7e24..3407f490f 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/AsmBinaryFileConstants.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/AsmBinaryFileConstants.java @@ -42,8 +42,10 @@ public class PAYLOAD_CONFIG_BYTE_INDEX { // Second-generation (payload design v13) only: GEN_CFG_3 at abs byte 29 (rel 25) // carries the mag/light/skin-temp/algo-hub enables + LED mode. public static final int GEN_CFG_3 = 25; - /** Second-generation only: ambient-light gain index in bits 2:0 and the - * dark-channel enable in bit 7 (abs byte 30). */ + /** Second-generation only (abs byte 30): ambient-light gain index in bits + * 2:0, the effective sample-rate index in bits 6:3 from FW v2.02.000 where + * 0 means not recorded, and the dark-channel enable in bit 7. See + * {@code SensorVD6283.VD6283_RATE}. */ public static final int LIGHT_GAIN_AND_DARK = 26; /** Second-generation only: ambient-light exposure index (abs byte 31). */ public static final int LIGHT_EXPOSURE = 27; diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetails.java b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetails.java index 8495799a2..2e869149b 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetails.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetails.java @@ -277,6 +277,17 @@ public static boolean isPayloadDesignV13orAbove(ShimmerVerObject svo) { return VerisenseDevice.compareFwVersions(svo, VerisenseDevice.FW_CHANGES.CCF_GEN2); } + /** + * VD6283 effective sample-rate index present in payload header byte 30 bits + * 6:3. See {@link VerisenseDevice.FW_CHANGES#CCF_GEN2_LIGHT_RATE}: this is a + * DIAGNOSTIC marker only. The header length is unchanged at this version, so + * nothing about where fields are read from depends on it, and the rate field + * itself says whether it is populated. + */ + public static boolean isPayloadDesignV14orAbove(ShimmerVerObject svo) { + return VerisenseDevice.compareFwVersions(svo, VerisenseDevice.FW_CHANGES.CCF_GEN2_LIGHT_RATE); + } + /** * This is a similar method to "!isPayloadDesignGen7OrAbove" but has been * created to make it easy to distinguish in the code sections that control the 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..254b95ae6 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetailsV8orAbove.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/PayloadContentsDetailsV8orAbove.java @@ -2,7 +2,6 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.ListIterator; import java.util.Map.Entry; @@ -93,13 +92,30 @@ 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 carry their configured sample rate in the payload header + // (MLX90632 refresh code in byte 32; VD6283 rate index in byte 30 bits 6:3 + // from FW v2.02.000), so where the rate is known the blocks are already + // correctly timed and only the CSV gap window has to be derived from it - + // see the method javadoc for why this is no longer measured from the data. + // + // Where it is NOT known - an ambient light recording from firmware earlier + // than v2.02.000 - the blocks are timed from the exposure bound, which only + // bounds the rate from above and is ten times too fast at the 1 Hz default. + // That is a real loss against measuring the spacing, and it is deliberate: + // measuring cost the ability to report data loss at all. + // + // Be precise about what it costs, because it is more than the CSV header. + // The light and skin-temp CSVs carry no timestamp column, so the FILE PARSER + // output moves only by its header start time, up to 8.1 s at 1 Hz. But the + // per-sample ObjectCluster stream is stamped from each block's start time + // stepping by its timestampDiffInS, so for those recordings every consumer + // of that stream - the Android API, live streaming, algorithm modules - now + // sees a 10-sample block laid over 0.9 s of the 10 s it really spans, and a + // 9.1 s jump to the next block. Second-generation firmware never shipped to + // a customer, so this reaches internal recordings only, and a recording from + // v2.02.000 onwards is unaffected either way. + seedSlowSensorGapWindow(DATABLOCK_SENSOR_ID.LIGHT); + seedSlowSensorGapWindow(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 @@ -316,95 +332,55 @@ 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. + * Set a slow sensor CSV gap-splitting window from the rate the payload header + * states, before the block timings are back-filled below. + *

+ * Both slow sensors now carry their configured rate in the header - the + * MLX90632 refresh code always did (byte 32), and the VD6283 rate index does + * from FW v2.02.000 (byte 30 bits 6:3) - so the blocks keep the rate + * {@code parseDataBlockMetaData} already seeded them with from + * {@code getSamplingRateForSensor}, and nothing here re-times them. That is + * what makes the FIRST block of a CSV set correctly timed rather than laid + * out over a guessed span. + *

+ * This deliberately replaces the older approach of measuring the period from + * the spacing of consecutive same-sensor block end ticks. Measuring works + * only on healthy data: a payload holds two or three slow-sensor blocks, so + * the window was built from one or two gaps, and if one of those was itself a + * dropped block it became the median and the gap was judged continuous while + * the healthy boundary beside it was flagged instead (DEV-974). A window + * derived from the configured rate cannot be pulled onto a wrong cadence by + * the very loss it exists to detect. + * + * @param slowSensorId the slow sensor data block id */ - private void refineSlowSensorSamplingRateFromBlockTicks(DATABLOCK_SENSOR_ID slowSensorId) { - List slowSensorBlocks = new ArrayList(); - for(DataBlockDetails dataBlockDetails:listOfDataBlocksInOrder) { - if(dataBlockDetails.datablockSensorId==slowSensorId) { - slowSensorBlocks.add(dataBlockDetails); - } - } - if(slowSensorBlocks.size()<2) { - return; - } - - // v11+ payloads store microcontroller-clock ticks per block, earlier designs - // store real-world-clock ticks; either works as only deltas are used. - boolean useUcClockTicks = verisenseDevice.isPayloadDesignV11orAbove(); - List perSamplePeriodsS = new ArrayList(); - for(int i=1;i0 && sampleCount>0) { - perSamplePeriodsS.add((deltaTicks/32768.0)/sampleCount); - } - } - if(perSamplePeriodsS.isEmpty()) { - return; - } - // Median so a dropped block (a 2x gap) can't skew the period. - Collections.sort(perSamplePeriodsS); - double medianPeriodS = perSamplePeriodsS.get(perSamplePeriodsS.size()/2); - if(!(medianPeriodS>0)) { + // Package-private so API_00011 can drive this layer directly. It is the layer + // that decides WHETHER the seeder runs, and the guard that keeps first- + // generation payloads away from the sensor-class lookup, so testing only + // UtilCsvSplitting leaves it uncovered. + void seedSlowSensorGapWindow(DATABLOCK_SENSOR_ID slowSensorId) { + if(!containsDataBlockForSensor(slowSensorId)) { + // No blocks of this sensor in this payload, so there is no boundary to + // judge and nothing to seed. Seeding regardless would ask the device for + // the sensor-class keys behind this data block id, and that lookup CREATES + // and caches them - so parsing a first-generation file, which has neither + // slow sensor, would quietly populate mappings and rate limits for + // hardware the recording does not have. return; } - - double achievedRateHz = 1.0/medianPeriodS; - for(DataBlockDetails dataBlockDetails:slowSensorBlocks) { - dataBlockDetails.setSamplingRate(achievedRateHz); - dataBlockDetails.calculateTimestampDiffInS(); + if(slowSensorId==DATABLOCK_SENSOR_ID.LIGHT) { + UtilCsvSplitting.warnIfLightRateFieldUnusable(verisenseDevice); } + UtilCsvSplitting.seedSlowSensorGapWindow(verisenseDevice, slowSensorId); + } - // Seed the CSV gap-splitting window from the OBSERVED cadence rather than a - // single-rate +/-10% band. The header-derived estimate can sit within ~1% of - // the band edge (VD6283: 10 Hz estimated vs ~9.09 Hz achieved), and the slow - // sensors' cadence is inherently jittery: the light's is bimodal (exposure vs - // exposure + dead time: ~100 vs ~110 ms at the default exposure) and the - // MLX90632's conversions can slip by several refresh periods and then catch - // up (observed +12.5% block spacing with no samples lost - DEV-927 - // validation data). A single payload carries only 2-3 slow-sensor blocks, - // i.e. one or two inter-block gaps - no spread information - so the gap - // side of the window cannot rely on observed spread at all: it is set to - // tolerate anything up to SLOW_SENSOR_MAX_INTER_BLOCK_GAP_RATIO x the - // achieved median spacing, which keeps healthy jitter continuous while a - // genuinely dropped block (2x spacing) still splits. The fast side keeps - // the observed-minimum-period basis with the standard tolerance. - // The put is deliberately UNCONDITIONAL: the limits map is global across - // payloads, and a payload with fewer than two blocks of this sensor (early - // return above - e.g. the very first payload of a recording) leaves - // populateExpectedPayloadTsDiffLimitMapIfNeeded to seed a configured-rate - // +/-10% band first. A containsKey guard here would then lock that too-tight - // estimate in for the whole file (observed: 25-min DEV-927 skin-temp - // recording fragmented into 7 CSVs); the measured window must win as soon as - // it exists, and re-measuring on every payload keeps it tracking the sensor. - double minPeriodS = perSamplePeriodsS.get(0); - double[] samplingRateLimits = new double[] { - achievedRateHz/UtilCsvSplitting.FILE_GAP_TOLERANCE_MULTIPLIER.SLOW_SENSOR_MAX_INTER_BLOCK_GAP_RATIO, - (1.0/minPeriodS)*UtilCsvSplitting.FILE_GAP_TOLERANCE_MULTIPLIER.UPPER}; - for(SENSORS sensorClassKey:verisenseDevice.getOrCreateListOfSensorClassKeysForDataBlockId(slowSensorId)) { - if(sensorClassKey!=SENSORS.CLOCK) { - UtilCsvSplitting.SAMPLING_RATE_LIMITS_PER_SENSOR.put(sensorClassKey, samplingRateLimits); + boolean containsDataBlockForSensor(DATABLOCK_SENSOR_ID slowSensorId) { + for(DataBlockDetails dataBlockDetails:listOfDataBlocksInOrder) { + if(dataBlockDetails.datablockSensorId==slowSensorId) { + return true; } } + return false; } private void backfillDataBlockRwcTimestamps() { diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/UtilCsvSplitting.java b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/UtilCsvSplitting.java index f6c23ee08..0835e5e53 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/UtilCsvSplitting.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/payloaddesign/UtilCsvSplitting.java @@ -8,6 +8,8 @@ import com.shimmerresearch.verisense.UtilVerisenseDriver; 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; public class UtilCsvSplitting { @@ -17,17 +19,39 @@ public class FILE_GAP_TOLERANCE_MULTIPLIER { public static final double LOWER = 0.9; /** * Slow sensors only (VD6283 light / MLX90632 skin temp): the largest - * inter-block gap, as a multiple of the achieved median block spacing, that - * is still treated as continuous. The MLX90632's conversions can slip by - * several refresh periods and then catch up (observed up to +12.5% block - * spacing on the DEV-927 validation recording with no samples lost), and the - * window is seeded from the first payload that carries >= 2 blocks - often a - * single inter-block gap, i.e. no spread information - so the standard - * LOWER (-10%) band is routinely violated by healthy data. A genuinely - * dropped block doubles the spacing (2x), so 1.5x keeps comfortable margin - * on both sides. + * inter-block gap, as a multiple of the CONFIGURED block spacing taken from + * the payload header, that is still treated as continuous. A genuinely + * dropped block doubles the spacing, so 1.5x leaves 25% of margin against + * it while absorbing the jitter that the standard LOWER (-10%) band is far + * too tight for. + *

+ * This also sets the MLX90632 gap edge, and that edge is pinned against + * real data. For skin temp the window is widened again by + * {@link #SLOW_SENSOR_CONVERSION_SLIP_TOLERANCE}, so the edge lands at + * {@code cfg/1.725} - and Test_065 contains a HEALTHY skin-temp boundary at + * 1.63x nominal spacing during start-up settling. Retuning this constant for + * the light sensor alone moves the skin-temp edge with it: 1.4 would put it + * at 1.61x and re-split that recording. See + * {@link UtilCsvSplitting#getSlowSensorPlausibleRateRangeHz} and change it + * only against a measurement on both sensors. */ public static final double SLOW_SENSOR_MAX_INTER_BLOCK_GAP_RATIO = 1.5; + /** + * MLX90632 only: how far a SINGLE block boundary apparent rate may sit + * either side of the configured output rate and still be plausible. + *

+ * The chip 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 one boundary reads ~12.5% slow and the next + * correspondingly fast. Only the AVERAGE rate is bounded by the configured + * one, 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 sampling is a plain periodic timer with no + * slip-and-catch-up behaviour: a failed read there costs a whole period, it + * never shortens one. + */ + public static final double SLOW_SENSOR_CONVERSION_SLIP_TOLERANCE = 1.15; } protected static HashMap SAMPLING_RATE_LIMITS_PER_SENSOR = new HashMap(); @@ -99,8 +123,236 @@ public static double[] calculateSamplingRateLimits(double configuredSamplingRate return new double[] {configuredSamplingRate*FILE_GAP_TOLERANCE_MULTIPLIER.LOWER, configuredSamplingRate*FILE_GAP_TOLERANCE_MULTIPLIER.UPPER}; } + /** + * Whether the payload header itself carries this slow sensor configured + * sample rate, so that nothing has to be inferred from the data. + *

+ * VD6283: true once the firmware stores the rate index in header byte 30 + * bits 6:3 (FW v2.02.000+). MLX90632: always true for an enabled gen-2 + * device, because the refresh code has always been in header byte 32. + * + * @param verisenseDevice the device being parsed + * @param slowSensorId the slow sensor data block id + * @return true when the configured rate is known from the header alone + */ + public static boolean isSlowSensorConfiguredRateKnown(VerisenseDevice verisenseDevice, DATABLOCK_SENSOR_ID slowSensorId) { + if(slowSensorId==DATABLOCK_SENSOR_ID.LIGHT) { + SensorVD6283 sensorVd6283 = verisenseDevice.getSensorVD6283(); + return sensorVd6283!=null && sensorVd6283.isConfiguredRateKnown(); + } + if(slowSensorId==DATABLOCK_SENSOR_ID.SKIN_TEMP) { + return isRateUsable(verisenseDevice.getSamplingRateForSensor(SENSORS.MLX90632)); + } + return false; + } + + private static boolean isRateUsable(double rateHz) { + return rateHz>0 && !Double.isNaN(rateHz) && !Double.isInfinite(rateHz); + } + + /** + * The {min, max} per-sample rate a slow sensor could legitimately be running + * at, which the CSV gap window is then built from. + *

+ * When the configured rate is known from the header the range collapses onto + * it - exactly for the VD6283, whose timer has no slip behaviour, and widened + * by {@link FILE_GAP_TOLERANCE_MULTIPLIER#SLOW_SENSOR_CONVERSION_SLIP_TOLERANCE} + * for the MLX90632, whose conversions slip and catch up. + *

+ * When it is not known - a light recording from FW earlier than v2.02.000 - + * the range is the whole firmware rate table. That is deliberately wide: + * [0.5, 20] Hz becomes a [0.33, 22] Hz window, so a 10-sample light block + * boundary is accepted anywhere up to 30 s and genuine loss goes unreported. + * It is the best that can be done without the rate, it keeps such recordings + * in ONE CSV rather than one per block, and it is stateless: nothing is + * learned from the data, so no amount of unhealthy data can move it. + *

+ * What comes back is a plausible RATE range. The gap window built from it is + * wider again on both sides - see {@link #seedSlowSensorGapWindow}, which also + * explains why the MLX90632 edge emerging from + * {@link FILE_GAP_TOLERANCE_MULTIPLIER#SLOW_SENSOR_MAX_INTER_BLOCK_GAP_RATIO} + * cannot be retuned for the light sensor alone. + * + * @param verisenseDevice the device being parsed + * @param slowSensorId the slow sensor 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) { + if(isSlowSensorConfiguredRateKnown(verisenseDevice, slowSensorId)) { + double configuredRateHz = verisenseDevice.getSensorVD6283().getRateFreq(); + if(isRateUsable(configuredRateHz)) { + return new double[] {configuredRateHz, configuredRateHz}; + } + } + return new double[] {SensorVD6283.MIN_SAMPLE_RATE_HZ, SensorVD6283.MAX_SAMPLE_RATE_HZ}; + } + if(slowSensorId==DATABLOCK_SENSOR_ID.SKIN_TEMP) { + double configuredRateHz = verisenseDevice.getSamplingRateForSensor(SENSORS.MLX90632); + if(isSlowSensorConfiguredRateKnown(verisenseDevice, slowSensorId)) { + // Both sides are widened, and the slow side is the uncomfortable one. + // It puts the gap edge at cfg/1.725, while a dropped block landing on a + // 12.5% catch-up presents cfg/1.75 - so that case is reported, but by + // 1.4%, and it stops being reported at all once a catch-up reaches + // 13.75%. + // + // Narrowing the slow side to cfg/1.5, as the VD6283 uses, was tried and + // reverted: the Test_065 recording contains a healthy skin-temp boundary + // at 1.63x nominal spacing during start-up settling, which then split. So + // this sensor's healthy behaviour genuinely overlaps the region a dropped + // block would land in, and no single threshold separates them cleanly. + // 1.725 is the midpoint that was chosen with that data in hand: 6% above + // the worst healthy boundary observed, 14% below a clean dropped block. + // Moving it in either direction trades false splits against missed loss, + // so change it only against a measurement, not an intuition. + return new double[] { + configuredRateHz/FILE_GAP_TOLERANCE_MULTIPLIER.SLOW_SENSOR_CONVERSION_SLIP_TOLERANCE, + configuredRateHz*FILE_GAP_TOLERANCE_MULTIPLIER.SLOW_SENSOR_CONVERSION_SLIP_TOLERANCE}; + } + // Unreachable today: getRateFreq() is refresh/sub-measurements, always + // within [0.167, 32], and the sensor class exists whenever skin-temp + // blocks do. Kept because the caller no longer guarantees either. + return new double[] {SensorMLX90632.MIN_OUTPUT_RATE_HZ, SensorMLX90632.MAX_OUTPUT_RATE_HZ}; + } + return null; + } + + /** + * Sets a slow sensor CSV gap-splitting window from the rate the payload + * header states, replacing the configured-rate +/-10% band that + * {@link #populateExpectedPayloadTsDiffLimitMapIfNeeded} would otherwise + * install. + *

+ * The gap side is {@code min / SLOW_SENSOR_MAX_INTER_BLOCK_GAP_RATIO} so + * healthy jitter stays continuous while a dropped block, which doubles the + * spacing and so halves the apparent rate, is reported. The fast side is + * {@code max * UPPER}, which catches an overlapping block or a backwards + * clock step. Worked through at 1 Hz the window is [0.667, 1.1] Hz: a dropped + * block presents 0.5 Hz and splits, a block that took one extra period after + * a failed I2C read presents 0.909 Hz and does not. + *

+ * The VD6283 gets a clean 25% margin on a dropped block. The MLX90632 does + * not, and cannot: its window is {@code [f/1.725, f*1.265]}, so a dropped + * block presents {@code 0.5f} and splits comfortably, but a dropped block + * landing on the chip's documented 12.5% catch-up presents {@code 0.571f} + * against an edge of {@code 0.580f} and splits by 1.4%. That is not slack + * left lying around - see {@link #getSlowSensorPlausibleRateRangeHz} for the + * measurement that pins the edge where it is. + *

+ * Nothing here depends on previously seen data, so unlike a measured window + * this cannot be pulled onto a wrong cadence by the very loss it is meant to + * detect, and it is correct from the FIRST boundary of a CSV set rather than + * after several. The put is unconditional because + * populateExpectedPayloadTsDiffLimitMapIfNeeded is containsKey-guarded and + * runs later in the parse: this window must win. + *

+ * The MLX90632 gap edge is coupled to + * {@link FILE_GAP_TOLERANCE_MULTIPLIER#SLOW_SENSOR_MAX_INTER_BLOCK_GAP_RATIO} + * and it is pinned against real data. That edge is + * {@code cfg / (1.15 x 1.5) = cfg/1.725}, an emergent product of the slip + * tolerance and that ratio, and Test_065 contains a healthy skin-temp boundary + * at 1.63x nominal spacing. Retuning the ratio for the light sensor alone + * moves the skin-temp edge with it: 1.4 would put it at 1.61x and re-split + * that recording. Check both sensors, against a measurement. + * + * @param verisenseDevice the device being parsed + * @param slowSensorId the slow sensor data block id + */ + public static void seedSlowSensorGapWindow(VerisenseDevice verisenseDevice, DATABLOCK_SENSOR_ID slowSensorId) { + double[] plausibleRateRangeHz = getSlowSensorPlausibleRateRangeHz(verisenseDevice, slowSensorId); + if(plausibleRateRangeHz==null) { + return; + } + double[] samplingRateLimits = new double[] { + plausibleRateRangeHz[0]/FILE_GAP_TOLERANCE_MULTIPLIER.SLOW_SENSOR_MAX_INTER_BLOCK_GAP_RATIO, + plausibleRateRangeHz[1]*FILE_GAP_TOLERANCE_MULTIPLIER.UPPER}; + for(SENSORS sensorClassKey:verisenseDevice.getOrCreateListOfSensorClassKeysForDataBlockId(slowSensorId)) { + if(sensorClassKey!=SENSORS.CLOCK) { + SAMPLING_RATE_LIMITS_PER_SENSOR.put(sensorClassKey, samplingRateLimits); + } + } + } + + /** + * Set once an unusable ambient-light rate field has been reported, so it is + * one line rather than one per payload. + *

+ * Once per CSV SET, not once per recording: it is cleared with the limits map, + * and {@code AsmBinaryFileParse.writeAllStringBuildersToFiles()} clears that at + * every CSV split - config change and time-based alike - as well as at the + * start and end of a file. A recording split into four CSV sets can therefore + * print this four times, which is the right granularity anyway: each set is a + * separate CSV that somebody may open on its own. + */ + private static boolean hasWarnedLightRateFieldUnusable = false; + public static void clearMapOfSamplingRateLimitsPerSensor() { SAMPLING_RATE_LIMITS_PER_SENSOR.clear(); + hasWarnedLightRateFieldUnusable = false; + } + + /** + * Reports, once per CSV set, an ambient-light rate field this parser cannot + * use: either a payload claiming firmware new enough to store the index yet + * leaving it clear, or any payload holding a reserved index 7..15. + *

+ * This is the one failure the header-driven design cannot otherwise see. A + * zero field is indistinguishable from an old recording, so the parser falls + * back to the wide rate-table window, the light data still comes out, and + * nothing anywhere says that the gap detection for this file is nearly blind + * - see {@link #getSlowSensorPlausibleRateRangeHz} for how wide that fallback + * is. If the firmware ever ships with the field broken, this line is the only + * thing that will say so. + *

+ * Nothing about parsing keys on the firmware version: the field is + * self-describing and {@link SensorVD6283#isConfiguredRateKnown()} alone + * decides behaviour. The version is read HERE and nowhere else, purely to tell + * "old recording, as expected" apart from "new recording, firmware bug". A + * wrong value in {@link VerisenseDevice.FW_CHANGES#CCF_GEN2_LIGHT_RATE} can + * therefore only make this warning fire at the wrong boundary; it cannot + * change a parse. + * + * @param verisenseDevice the device being parsed + */ + public static void warnIfLightRateFieldUnusable(VerisenseDevice verisenseDevice) { + if(hasWarnedLightRateFieldUnusable) { + return; + } + SensorVD6283 sensorVd6283 = verisenseDevice.getSensorVD6283(); + if(sensorVd6283==null || sensorVd6283.isConfiguredRateKnown()) { + return; + } + + // Two different faults, and they must not be reported as one. + String fault; + if(sensorVd6283.isRateIndexReserved()) { + // A value no firmware rate table defines. Report this whatever the + // payload design claims: on an old recording those bits should be clear, + // so a non-zero one is an anomaly in its own right and the version tells + // us nothing useful about it. + // + // Deliberately says nothing about the recording carrying light blocks. The + // caller in PayloadContentsDetailsV8orAbove only reaches here for a payload + // that does, but this method is public and does not check, so stating it + // would be a claim made on somebody else's behalf. + fault = "wrote the reserved index " + sensorVd6283.getRateIndexRaw() + + " into the ambient-light rate field (payload header byte 30 bits 6:3)," + + " which no firmware rate table defines"; + } else if(verisenseDevice.isPayloadDesignV14orAbove()) { + fault = "stores the VD6283 sample rate in payload header byte 30 bits 6:3, but this" + + " recording carries ambient light blocks with that field clear. This is a" + + " firmware fault, not an old recording"; + } else { + // Old recording, field clear: exactly as expected, and not worth a line. + return; + } + + hasWarnedLightRateFieldUnusable = true; + double[] fallbackRangeHz = getSlowSensorPlausibleRateRangeHz(verisenseDevice, DATABLOCK_SENSOR_ID.LIGHT); + System.out.println("WARNING!!! Firmware " + verisenseDevice.getFirmwareVersionParsed() + " " + + fault + ". Falling back to the whole rate table, " + fallbackRangeHz[0] + " to " + + fallbackRangeHz[1] + " Hz, so light blocks are timed from the exposure bound" + + " and gap detection for this file is close to blind."); } public static String isDataBlockContinuous(SENSORS sensorClassKey, DataSegmentDetails dataSegmentDetailsPrevious, DataBlockDetails nextDataBlockDetails) { diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/sensors/SensorMLX90632.java b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/sensors/SensorMLX90632.java index edcf0cd56..bed0d4ee2 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/sensors/SensorMLX90632.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/sensors/SensorMLX90632.java @@ -36,8 +36,10 @@ * refresh-rate code (0..7 = 0.5/1/2/4/8/16/32/64 Hz). The OUTPUT sample rate * the firmware delivers is refresh / sub-measurements (medical = 2, * extended = 3) - mirrors the web SDK's SensorMLX90632.ts. As with the - * ambient light, the parser refines the achieved rate per payload from - * temp-block tick spacing; the header-derived value seeds the timing. + * ambient light, the header-derived value IS the timing: nothing is measured + * back out of the data. Refining the achieved rate from temp-block tick spacing + * was removed with DEV-974, because it could only work on healthy data and so + * went blind exactly when a recording had lost samples. */ public class SensorMLX90632 extends AbstractSensor { @@ -55,6 +57,19 @@ public class SensorMLX90632 extends AbstractSensor { public static final int SUB_MEASUREMENTS_MEDICAL = 2; public static final int SUB_MEASUREMENTS_EXTENDED = 3; + /** + * Output-rate bounds implied by the refresh table across BOTH modes: the + * slowest configuration is REFRESH_HZ_TABLE[0] (0.5 Hz) divided by + * {@link #SUB_MEASUREMENTS_EXTENDED} (3) = 0.167 Hz, and the fastest is + * REFRESH_HZ_TABLE[7] (64 Hz) divided by {@link #SUB_MEASUREMENTS_MEDICAL} + * (2) = 32 Hz. Unlike the VD6283 before FW v2.02.000, this sensor refresh + * code has always been stored in the payload header, so {@link + * #getRateFreq()} is the real configured output rate and these bounds are + * only needed as a fallback when that rate cannot be read. + */ + public static final double MIN_OUTPUT_RATE_HZ = 0.5/3; + public static final double MAX_OUTPUT_RATE_HZ = 32.0; + private int refreshRateCode = 5; // 16 Hz chip refresh (firmware default) private boolean extendedMode = false; @@ -174,9 +189,14 @@ public String getMeasTypeString() { /** * Header-derived output sample rate (Hz): the chip refresh rate divided by - * the sub-measurements per output (medical = 2, extended = 3). Seeds - * data-block timing; the parser refines the achieved rate per payload from - * temp-block tick spacing. + * the sub-measurements per output (medical = 2, extended = 3). This times the + * data blocks directly; nothing refines it from the data afterwards. + *

+ * It is the CONFIGURED rate. Conversions slip by several refresh periods and + * then catch up, so an individual boundary can read up to about 12.5% either + * side of it while losing no samples at all - which is why the CSV gap window + * built from this widens BOTH sides by + * {@code SLOW_SENSOR_CONVERSION_SLIP_TOLERANCE}. */ public double getRateFreq() { return getRefreshHz() / (extendedMode ? SUB_MEASUREMENTS_EXTENDED : SUB_MEASUREMENTS_MEDICAL); diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/sensors/SensorVD6283.java b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/sensors/SensorVD6283.java index 37ec8437b..4bf431bd0 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/sensors/SensorVD6283.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/verisense/sensors/SensorVD6283.java @@ -43,14 +43,21 @@ * and BLUE are unaffected, so lux/CCT remain valid in either mode. No * dark-enabled recording exists yet - revisit the naming when one does. *

- * The sample rate is configured in the op config (LIGHT_SAMPLE_RATE_INDEX) but - * is NOT mirrored into the stored payload header, and the achieved rate also - * differs from both the configured rate and 1/exposure (the chip adds dead - * time per measurement, e.g. ~110 ms/sample for the 100 ms exposure). The - * getRateFreq() value here is therefore only the exposure-limited ESTIMATE - * used to seed data-block timing; the parser refines it per payload from the - * spacing of consecutive light-block timestamps - * (PayloadContentsDetailsV8orAbove.refineLightSamplingRateFromBlockTicks). + * The sample rate is configured in the op config (LIGHT_SAMPLE_RATE_INDEX). + * From FW v2.02.000 the firmware mirrors the EFFECTIVE index (post-default) + * into payload header byte 30 bits 6:3, so {@link #getRateFreq()} returns the + * real configured rate - see {@link VD6283_RATE}. Earlier firmware stored it + * nowhere and left 0 in those bits, in which case all that can be said is that + * 1/exposure bounds the rate from ABOVE; at the firmware default of 1 Hz with + * the default 100 ms exposure that bound is ten times the truth, which is what + * fragmented light data into one CSV per block (DEV-979). + *

+ * Even the configured rate is not always the ACHIEVED rate: the chip measures + * every max(inter-measurement, exposure) plus dead time, so 10 Hz configured + * with a 100 ms exposure achieves ~9.09 Hz. getRateFreq() therefore clamps the + * configured rate with the exposure bound, and the CSV gap window built from it + * (UtilCsvSplitting.seedSlowSensorGapWindow) is tolerant enough to absorb the + * remainder. */ public class SensorVD6283 extends AbstractSensor { @@ -81,7 +88,113 @@ public class SensorVD6283 extends AbstractSensor { {-0.028752, 0.506372, -0.120614}, {-0.552625, 0.335866, 0.494781}}; - /** Poll ceiling in continuous mode (firmware slow-sensor sampler). */ + /** + * The rates the firmware can be configured to, from the slow-sensor sampler + * index table (hal_slowSensorSampler.c {@code slowSensorRateMs[] = {0, 2000, + * 1000, 500, 200, 100, 50}} ms). The index lives in operational-config byte + * 75 (LIGHT_SAMPLE_RATE_INDEX) and, from FW v2.02.000, is mirrored into + * payload header byte 30 bits 6:3 as the EFFECTIVE index - the value the + * sampler was actually started with, after the firmware fallback from 0 + * (Off) to 1 Hz. It is therefore never {@code NOT_STORED} while light data + * blocks exist, which is what lets a parser read 0 as "recorded by earlier + * firmware" without consulting the firmware version at all. + *

+ * The indices are a stored-data contract shared with the firmware: they may + * be appended to but never renumbered, and cannot exceed the 4-bit header + * field (the firmware carries a compile-time guard for that). + */ + public static enum VD6283_RATE { + NOT_STORED("Not stored", 0, 0.0), + /** + * Bits 6:3 held 7..15, which the firmware rate table does not define. + *

+ * Distinct from {@link #NOT_STORED} on purpose. Both end up with the wide + * fallback window, but they mean opposite things: NOT_STORED is an old + * recording behaving exactly as expected, while this is a payload that + * wrote a rate nobody can decode. Collapsing the two made the firmware-fault + * warning report a field that was "clear" when it was not, which sends a + * reader looking for the wrong bug. + *

+ * Not in {@code BY_CONFIG_VALUE}: -1 is not a storable index. + */ + RESERVED_INDEX("Reserved", -1, 0.0), + RATE_0_5_HZ("0.5Hz", 1, 0.5), + RATE_1_HZ("1.0Hz", 2, 1.0), + RATE_2_HZ("2.0Hz", 3, 2.0), + RATE_5_HZ("5.0Hz", 4, 5.0), + RATE_10_HZ("10.0Hz", 5, 10.0), + RATE_20_HZ("20.0Hz", 6, 20.0); + + public String label; + public Integer configValue; + public double freqHz; + + static Map BY_CONFIG_VALUE = new LinkedHashMap(); + static { + for (VD6283_RATE e : values()) { + // RESERVED_INDEX carries -1, which is not a storable index and must not + // become a lookup key - getForConfigValue returns it as a verdict, never + // finds it. + if(e.configValue>=0) { BY_CONFIG_VALUE.put(e.configValue, e); } + } + } + + private VD6283_RATE(String label, Integer configValue, double freqHz) { + this.label = label; this.configValue = configValue; this.freqHz = freqHz; + } + + /** + * Deliberately does NOT clamp the way the other sensors rate enums do + * (e.g. SensorLSM6DSV.LSM6DSV_RATE, which nudges into range): an index the + * table does not define - 7..15, reserved - must read as unknown, never as + * the nearest rate. Guessing 20 Hz for a reserved code would silently + * mis-time every sample in the block. + */ + public static VD6283_RATE getForConfigValue(int configValue) { + VD6283_RATE rate = BY_CONFIG_VALUE.get(configValue); + if(rate!=null) { + return rate; + } + // 0 means the firmware never wrote the field. 7..15 mean it wrote + // something this driver does not know, which is a different problem and + // has to be reportable as one - see RESERVED_INDEX. + return configValue==0? NOT_STORED:RESERVED_INDEX; + } + + /** + * Whether this is a rate the firmware could have been configured to, as + * opposed to one of the two verdicts {@link #getForConfigValue} returns for + * a field it could not decode. + *

+ * The question is answered here rather than at each call site because it was + * being asked in two places with two different answers: the driver tested + * both sentinels, while the ASM_PC header patcher tested only NOT_STORED, so + * adding RESERVED_INDEX let an undecodable index through the patcher and into + * a regression dataset. One predicate, one place to update when a member is + * added. + *

+ * Both properties are tested because a real rate has both - a storable index + * 1..6 and a non-zero frequency - and each verdict has neither. A member + * carrying one without the other would be a mistake in this enum, and + * unusable is the safe way to read a mistake. + */ + public boolean isStorable() { + return configValue>0 && freqHz>0; + } + } + + /** Raw bits 6:3 of header byte 30, kept so diagnostics can name what was there. */ + private int rateIndexRaw = 0; + + /** Payload header byte 30 field layout (firmware PAYLOAD_HDR_LIGHT_*). */ + public static final int LIGHT_GAIN_MASK = 0x07; + public static final int LIGHT_RATE_INDEX_BIT_SHIFT = 3; + public static final int LIGHT_RATE_INDEX_MASK = 0x0F; + public static final int LIGHT_DARK_ENABLE_MASK = 0x80; + + /** Slowest rate the firmware can be configured to (see VD6283_RATE). */ + public static final double MIN_SAMPLE_RATE_HZ = 0.5; + /** Fastest, and also the poll ceiling in continuous mode. */ public static final double MAX_SAMPLE_RATE_HZ = 20.0; public static final String UNITS_LUX = "lux"; @@ -90,6 +203,7 @@ public class SensorVD6283 extends AbstractSensor { private int gainIndex = 0; private int exposureIndex = 0; private boolean darkChannelEnabled = false; + private VD6283_RATE rate = VD6283_RATE.NOT_STORED; public class GuiLabelSensors { public static final String LIGHT = "Light"; @@ -283,9 +397,43 @@ public double[] computeLuxCct(double red, double green, double blue) { public void configBytesParse(ShimmerDevice shimmerDevice, byte[] configBytes, COMMUNICATION_TYPE commType) { if(commType == COMMUNICATION_TYPE.SD && isSensorEnabled(Configuration.Verisense.SENSOR_ID.VD6283)) { int gainAndDark = configBytes[PAYLOAD_CONFIG_BYTE_INDEX.LIGHT_GAIN_AND_DARK] & 0xFF; - gainIndex = gainAndDark & 0x07; - darkChannelEnabled = (gainAndDark & 0x80) != 0; + gainIndex = gainAndDark & LIGHT_GAIN_MASK; + darkChannelEnabled = (gainAndDark & LIGHT_DARK_ENABLE_MASK) != 0; + // Bits 6:3 were spare before FW v2.02.000 and were written as zero, so a + // zero here means the rate was not recorded rather than index 0. No + // firmware-version check is needed: the firmware stores the EFFECTIVE + // index, which is never zero while light blocks exist. + // + // "Earlier firmware always left these clear" is load-bearing, so here is + // the proof rather than the assertion. On firmware before 2.02.000, + // backupConfigSettings() built this byte as + // cfg15Bkup = (lightGainIndex & 0x07) | (lightDarkEnable ? 0x80 : 0) + // which cannot set bits 6:3, and resetPayloadBackupConfig() zeroed the + // whole byte. There is no third writer. + // + // Gating this on the firmware version as well would look safer and is + // not: the header-patched regression datasets deliberately keep their + // true firmware version, so a version gate would stop the parser reading + // the very field those datasets exist to exercise. A reserved index is + // the residual risk and it is reported rather than silently accepted - + // see VD6283_RATE.RESERVED_INDEX. + rateIndexRaw = (gainAndDark >> LIGHT_RATE_INDEX_BIT_SHIFT) & LIGHT_RATE_INDEX_MASK; + rate = VD6283_RATE.getForConfigValue(rateIndexRaw); exposureIndex = configBytes[PAYLOAD_CONFIG_BYTE_INDEX.LIGHT_EXPOSURE] & 0xFF; + } else if(commType == COMMUNICATION_TYPE.SD) { + // The light sensor is not enabled in THIS payload, so there is no rate to + // read. Defence in depth only: today this cannot find a stale value to + // clear, because VerisenseDevice.configBytesParse calls + // sensorAndConfigMapsCreate() before any sensor config is parsed, and that + // replaces this object with a fresh one. Sensor state does NOT carry from + // payload to payload - do not reason as though it does. + // + // Kept rather than deleted because the invariant it leans on belongs to + // another class. Were instances ever reused across payloads, a stale rate + // would be worse than a stale gain or exposure: those only affect + // calibration, while the rate drives block timing and CSV splitting. + rateIndexRaw = 0; + rate = VD6283_RATE.NOT_STORED; } } @@ -307,13 +455,50 @@ public boolean isDarkChannelEnabled() { } /** - * 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. + * The sample rate to time this sensor data blocks with (Hz). + *

+ * When the payload header carries the configured rate (FW v2.02.000+, see + * {@link VD6283_RATE}) that rate is returned, clamped by the exposure bound + * because the chip cannot measure faster than it integrates. Otherwise only + * the exposure bound itself is available, and that is an UPPER BOUND rather + * than an estimate of the rate: it is ten times too fast at the firmware + * default of 1 Hz. Callers that need to know which of the two they got + * should ask {@link #isConfiguredRateKnown()}. */ public double getRateFreq() { - return Math.min(MAX_SAMPLE_RATE_HZ, 1e6 / getExposureUs()); + double exposureLimitHz = Math.min(MAX_SAMPLE_RATE_HZ, 1e6 / getExposureUs()); + if(isConfiguredRateKnown()) { + return Math.min(rate.freqHz, exposureLimitHz); + } + return exposureLimitHz; + } + + /** + * Whether the payload header told us the configured sample rate. False for + * recordings from FW earlier than v2.02.000, where {@link #getRateFreq()} + * can only return the exposure bound. + */ + public boolean isConfiguredRateKnown() { + return rate.isStorable(); + } + + /** + * True when bits 6:3 held a value the firmware rate table does not define. + *

+ * Worth separating from "not stored" because it should never happen and says + * something different when it does - see {@link VD6283_RATE#RESERVED_INDEX}. + */ + public boolean isRateIndexReserved() { + return rate==VD6283_RATE.RESERVED_INDEX; + } + + /** The raw 4-bit value from header byte 30 bits 6:3, for diagnostics. */ + public int getRateIndexRaw() { + return rateIndexRaw; + } + + public VD6283_RATE getRate() { + return rate; } @Override diff --git a/ShimmerDriver/src/test/java/com/shimmerresearch/verisense/payloaddesign/API_00011_VerisenseSlowSensorGapWindow.java b/ShimmerDriver/src/test/java/com/shimmerresearch/verisense/payloaddesign/API_00011_VerisenseSlowSensorGapWindow.java new file mode 100644 index 000000000..3af103536 --- /dev/null +++ b/ShimmerDriver/src/test/java/com/shimmerresearch/verisense/payloaddesign/API_00011_VerisenseSlowSensorGapWindow.java @@ -0,0 +1,811 @@ +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.io.ByteArrayOutputStream; +import java.io.PrintStream; +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; +import com.shimmerresearch.verisense.sensors.SensorVD6283.VD6283_RATE; + +/** + * Tests for deriving the slow sensors' CSV gap-splitting window from the rate + * the PAYLOAD HEADER states, rather than measuring it from the data. + *

+ * The window is built by + * {@link UtilCsvSplitting#seedSlowSensorGapWindow(VerisenseDevice, DATABLOCK_SENSOR_ID)} + * and every split decision below is taken through the real + * {@link UtilCsvSplitting#isDataBlockContinuous(SENSORS, DataSegmentDetails, DataBlockDetails)}, + * on synthetic payloads built the way the metadata parse leaves them. No binary + * test files, no hardware data and no reflection. + *

+ * Background. The VD6283 is sampled 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 10 samples are buffered + * per block. That rate lived only in operational-config byte 75 and was not + * stored in the payload, so the parser fell back to the exposure-derived value, + * which bounds the rate from ABOVE and is ten times too fast at the default. A + * 10-sample block covering 10 s was laid out over 0.9 s and the remaining 9.1 s + * read as a gap: 129 one-block CSVs from one recording (DEV-979). From FW + * v2.02.000 the firmware stores the effective rate index in header byte 30 bits + * 6:3 (DEV-1011) and this suite pins the parser side of that. + *

+ * Several cases below are physical scenarios carried over from the earlier + * measurement-based work so the coverage is not lost: a dropped block, a failed + * I2C read costing one period, the VD6283's bimodal cadence, and an overlapping + * boundary. They are re-pointed at the header-derived window. The reason a + * measured window was abandoned is asserted directly by + * {@link #test006_droppedBlockSplitsOnTheVeryFirstBoundary()}: a window built + * from one or two observed gaps could not report a dropped block that was + * itself one of those gaps (DEV-974 Bug A), whereas a configured-rate window + * reports it from the first boundary onwards. + */ +public class API_00011_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; + + /** 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; + /** Refresh code 0 = 0.5 Hz refresh -> 0.25 Hz medical output (slowest). */ + private static final int SKIN_TEMP_CONFIG_0HZ5_REFRESH = 0<<1; + + @Before + public void clearSplittingState() { + UtilCsvSplitting.clearMapOfSamplingRateLimitsPerSensor(); + } + + /** + * A gen-2 device as the payload-header parse leaves it. The config array is + * the 32-byte payload header, so writing a byte here is exactly what the + * firmware writing that byte would produce. + * + * @param lightGainAndDarkByte header byte 30: gain 2:0, rate index 6:3, dark 7 + * @param skinTempConfigByte header byte 32: measType bit 0, refresh code 3:1 + */ + private VerisenseDevice setupGen2Device(int lightGainAndDarkByte, int skinTempConfigByte) { + return setupGen2Device(lightGainAndDarkByte, skinTempConfigByte, 2, 0, 9); + } + + private VerisenseDevice setupGen2Device(int lightGainAndDarkByte, int skinTempConfigByte, + int fwMajor, int fwMinor, int fwInternal) { + VerisenseDevice device = new VerisenseDevice(COMMUNICATION_TYPE.SD); + + byte[] configBytes = new byte[32]; + configBytes[0] = (byte) 0x10; // extended-config flag + configBytes[2] = (byte) fwMajor; + configBytes[3] = (byte) fwMinor; + configBytes[4] = (byte) (fwInternal & 0xFF); + configBytes[5] = (byte) ((fwInternal>>8) & 0xFF); + 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[26] = (byte) lightGainAndDarkByte; + configBytes[28] = (byte) skinTempConfigByte; + 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; + } + + /** Header byte 30 as the firmware writes it: gain 2.5x, given rate index, no dark channel. */ + private static int lightHeaderByte(int rateIndex) { + return 0x02 | (rateIndex << SensorVD6283.LIGHT_RATE_INDEX_BIT_SHIFT); + } + + /** A light device at the given configured rate index (0 = as earlier firmware left it). */ + private VerisenseDevice setupLightDevice(int rateIndex) { + return setupGen2Device(lightHeaderByte(rateIndex), SKIN_TEMP_CONFIG_32HZ_REFRESH); + } + + 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 rate, 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 a SUB-MINUTE microcontroller tick counter per block... + dataBlockDetails.getTimeDetailsUcClock().setEndTimeTicks(endTicks%TICKS_PER_MINUTE); + // ...while the continuity check works on absolute real-world-clock ms. + 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 the parse-flow step under test: derive the window from the header. */ + private void seedWindow(VerisenseDevice device, DATABLOCK_SENSOR_ID slowSensorId) { + UtilCsvSplitting.seedSlowSensorGapWindow(device, slowSensorId); + } + + 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 continuity check, + * asserting every boundary is judged continuous. + * + * @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) { + seedWindow(device, slowSensorId); + double endS = firstBlockEndS; + DataSegmentDetails dataSegmentDetails = dataSegmentOf(newBlock(device, slowSensorId, ticks(endS))); + for (int i = 0; i < spacingsS.length; i++) { + endS += spacingsS[i]; + DataBlockDetails next = newBlock(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; + } + + private double[] windowFor(DATABLOCK_SENSOR_ID slowSensorId) { + return UtilCsvSplitting.SAMPLING_RATE_LIMITS_PER_SENSOR.get(sensorClassKeyOf(slowSensorId)); + } + + // ------------------------------------------------- header rate decoding + + /** Every index in the firmware rate table must decode to its rate. */ + @Test + public void test001_everyConfiguredRateIndexIsDecoded() { + // hal_slowSensorSampler.c: slowSensorRateMs[] = {0, 2000, 1000, 500, 200, 100, 50}. + // The table is a STORED-DATA CONTRACT shared with the firmware across two + // repositories with no build-time link between them, so pin the conversion + // here: an index renumbered on either side silently re-times recordings. + double[] firmwarePeriodMs = {2000, 1000, 500, 200, 100, 50}; + double[] expectedHz = {0.5, 1.0, 2.0, 5.0, 10.0, 20.0}; + for(int i=0;i + * It must also stay DISTINCT from "not stored". Both get the wide fallback + * window, but they mean opposite things - one is an old recording behaving as + * designed, the other is a payload holding a rate nobody can decode - and the + * diagnostic has to be able to say which. + */ + @Test + public void test003_reservedRateIndicesReadAsReservedNotAsNotStored() { + for (int rateIndex = 7; rateIndex <= 15; rateIndex++) { + VerisenseDevice device = setupLightDevice(rateIndex); + SensorVD6283 sensor = device.getSensorVD6283(); + assertEquals("reserved index " + rateIndex, VD6283_RATE.RESERVED_INDEX, sensor.getRate()); + assertTrue("reserved index " + rateIndex, sensor.isRateIndexReserved()); + assertFalse(sensor.isConfiguredRateKnown()); + assertEquals("the raw nibble must survive for the diagnostic", + rateIndex, sensor.getRateIndexRaw()); + } + // A clear field is the other state, and must not be confused with it. + SensorVD6283 notStored = setupLightDevice(0).getSensorVD6283(); + assertEquals(VD6283_RATE.NOT_STORED, notStored.getRate()); + assertFalse(notStored.isRateIndexReserved()); + // ...and the field cannot spill into gain or the dark bit. + VerisenseDevice device = setupLightDevice(15); + assertEquals(2.5, device.getSensorVD6283().getGain(), 1e-9); + assertFalse(device.getSensorVD6283().isDarkChannelEnabled()); + } + + /** All three fields of header byte 30 decode independently. */ + @Test + public void test004_gainRateAndDarkChannelShareByte30Cleanly() { + // gain index 3 (5.0x), rate index 2 (1 Hz), dark channel on = 0x93 + VerisenseDevice device = setupGen2Device(0x93, SKIN_TEMP_CONFIG_32HZ_REFRESH); + SensorVD6283 sensor = device.getSensorVD6283(); + assertEquals(VD6283_RATE.RATE_1_HZ, sensor.getRate()); + assertEquals(5.0, sensor.getGain(), 1e-9); + assertTrue(sensor.isDarkChannelEnabled()); + } + + /** + * The configured rate is clamped by the exposure bound, because the chip + * cannot measure faster than it integrates. 20 Hz configured with the + * fixture's 100 ms exposure is unattainable, so 10 Hz is used. + */ + @Test + public void test005_configuredRateIsClampedByTheExposureBound() { + VerisenseDevice device = setupLightDevice(6); // 20 Hz configured + assertEquals(20.0, device.getSensorVD6283().getRate().freqHz, 1e-9); + assertEquals("exposure-limited to 10 Hz", 10.0, device.getSensorVD6283().getRateFreq(), 1e-9); + // A rate the exposure can sustain is returned unchanged. + assertEquals(1.0, setupLightDevice(2).getSensorVD6283().getRateFreq(), 1e-9); + } + + // ------------------------------------------------------- light: splitting + + /** + * DEV-974 Bug A, the reason a measured window was abandoned. A window built + * from the one or two inter-block gaps a payload carries could not report a + * dropped block that was itself one of those gaps. Derived from the + * configured rate, the very FIRST boundary of a CSV set reports it. + */ + @Test + public void test006_droppedBlockSplitsOnTheVeryFirstBoundary() { + VerisenseDevice device = setupLightDevice(2); // 1 Hz -> a block every 10 s + seedWindow(device, DATABLOCK_SENSOR_ID.LIGHT); + + DataSegmentDetails dataSegmentDetails = dataSegmentOf(newBlock(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(10))); + DataBlockDetails afterDropout = newBlock(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(30)); // 2x spacing + + assertFalse("a dropped block must split, with no history needed", + continuityResult(DATABLOCK_SENSOR_ID.LIGHT, dataSegmentDetails, afterDropout).isEmpty()); + } + + /** The reported symptom: 1 Hz light, a 10-sample block every 10 s, one CSV. */ + @Test + public void test007_lightAt1HzDoesNotSplit() { + VerisenseDevice device = setupLightDevice(2); + DataSegmentDetails dataSegmentDetails = walkStream(device, DATABLOCK_SENSOR_ID.LIGHT, 100, uniformSpacings(40, 10.0)); + assertEquals(41, dataSegmentDetails.getDataBlockCount()); + } + + /** The window is exactly the documented [cfg/1.5, cfg*1.1] band. */ + @Test + public void test008_windowIsDerivedFromTheConfiguredRate() { + VerisenseDevice device = setupLightDevice(2); // 1 Hz + seedWindow(device, DATABLOCK_SENSOR_ID.LIGHT); + + double[] window = windowFor(DATABLOCK_SENSOR_ID.LIGHT); + assertNotNull(window); + assertEquals(1.0/UtilCsvSplitting.FILE_GAP_TOLERANCE_MULTIPLIER.SLOW_SENSOR_MAX_INTER_BLOCK_GAP_RATIO, window[0], 1e-9); + assertEquals(1.0*UtilCsvSplitting.FILE_GAP_TOLERANCE_MULTIPLIER.UPPER, window[1], 1e-9); + // A dropped block halves the apparent rate and a healthy boundary is at 1.0 + assertTrue(UtilCsvSplitting.isSamplingRateOutsideOfLimits(window, 0.5)); + assertFalse(UtilCsvSplitting.isSamplingRateOutsideOfLimits(window, 1.0)); + } + + /** + * A failed I2C read does not consume a sample slot, so one block takes an + * extra period to fill and that boundary measures 11 s. It must stay + * continuous: no samples were lost. + */ + @Test + public void test009_failedI2cReadStaysContinuous() { + VerisenseDevice device = setupLightDevice(2); + double[] spacingsS = uniformSpacings(20, 10.0); + spacingsS[10] = 11.0; + walkStream(device, DATABLOCK_SENSOR_ID.LIGHT, 100, spacingsS); + } + + /** + * The VD6283's cadence is bimodal - exposure versus exposure plus dead time, + * about 100 and 110 ms at the default exposure - so at 10 Hz configured the + * boundaries alternate around 1.0 and 1.1 s. Both must stay continuous. This + * is the case that made a median-based fast edge split healthy files + * (DEV-974 Bug B); a fixed multiple of the configured rate cannot. + */ + @Test + public void test010_bimodalCadenceAt10HzDoesNotSplit() { + VerisenseDevice device = setupLightDevice(5); // 10 Hz + double[] spacingsS = new double[20]; + for (int i = 0; i < spacingsS.length; i++) { + spacingsS[i] = (i%2==0)? 1.0:1.1; + } + walkStream(device, DATABLOCK_SENSOR_ID.LIGHT, 100, spacingsS); + } + + /** The slowest configurable rate, 0.5 Hz, gives a 20 s block spacing. */ + @Test + public void test011_slowestConfiguredRateDoesNotSplit() { + VerisenseDevice device = setupLightDevice(1); // 0.5 Hz + walkStream(device, DATABLOCK_SENSOR_ID.LIGHT, 100, uniformSpacings(10, 20.0)); + } + + /** An overlapping block, or a backwards clock step, must be reported. */ + @Test + public void test012_overlappingBoundarySplits() { + VerisenseDevice device = setupLightDevice(2); + seedWindow(device, DATABLOCK_SENSOR_ID.LIGHT); + + DataSegmentDetails dataSegmentDetails = dataSegmentOf(newBlock(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(100))); + DataBlockDetails overlapping = newBlock(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(100.1)); + + assertFalse("an overlapping boundary must split", + continuityResult(DATABLOCK_SENSOR_ID.LIGHT, dataSegmentDetails, overlapping).isEmpty()); + } + + /** + * A boundary that crosses a minute is a non-event: the continuity check works + * on absolute real-world-clock milliseconds, not the sub-minute tick counter + * that the block also carries. + */ + @Test + public void test013_minuteCrossingBoundaryIsANonEvent() { + VerisenseDevice device = setupLightDevice(2); + seedWindow(device, DATABLOCK_SENSOR_ID.LIGHT); + + DataBlockDetails first = newBlock(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(55)); + DataBlockDetails second = newBlock(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(65)); + assertTrue("the fixture must actually wrap the tick counter", + second.getTimeDetailsUcClock().getEndTimeTicks() + * A dropped block normally doubles the spacing. Landing on a boundary that had + * already caught up by the chip's documented 12.5% makes the spacing + * {@code 2 x 0.875} nominal periods instead, so the block presents itself as + * {@code cfg/1.75} rather than {@code cfg/2} - much healthier than it is. The + * gap edge is {@code cfg/1.725}, so it is still reported, by 1.4%. + *

+ * The obvious repair is to narrow the slow side to {@code cfg/1.5}, as the + * VD6283 uses. That was tried and reverted: Test_065 contains a HEALTHY + * skin-temp boundary at 1.63x nominal spacing during start-up settling, and + * narrowing split it. This sensor's healthy behaviour genuinely reaches into + * the region a dropped block occupies, so the two assertions below are the + * real constraint from both sides, 1.63x must not split and 1.75x must. + */ + @Test + public void test017_skinTempGapEdgeSeparatesRealLossFromStartUpSettling() { + VerisenseDevice device = setupGen2Device(lightHeaderByte(2), SKIN_TEMP_CONFIG_32HZ_REFRESH); + seedWindow(device, DATABLOCK_SENSOR_ID.SKIN_TEMP); + double[] window = windowFor(DATABLOCK_SENSOR_ID.SKIN_TEMP); + + double configuredHz = 16.0; + + // Measured on Test_065: a 16-sample block boundary 1531 ms after the + // previous block ended, against a nominal 937.5 ms. No samples were lost. + assertFalse("the 1.63x start-up boundary observed on Test_065 must NOT split", + UtilCsvSplitting.isSamplingRateOutsideOfLimits(window, configuredHz/1.633)); + + // A dropped block, even hidden by a 12.5% catch-up, must still split. + assertTrue("a dropped block landing on a catch-up must split", + UtilCsvSplitting.isSamplingRateOutsideOfLimits(window, configuredHz/(2.0*0.875))); + + // A clean dropped block has proper margin; it is only the catch-up case + // that is close. + assertTrue(UtilCsvSplitting.isSamplingRateOutsideOfLimits(window, configuredHz/2.0)); + assertTrue("the catch-up case is deliberately close - if this margin ever grows" + + " past 10% somebody has widened the window without saying so", + (window[0]-configuredHz/(2.0*0.875))/window[0] < 0.10); + } + + /** + * The slowest skin-temp configuration, 0.25 Hz output, where a 16-sample + * block spans about 64 s. Two such blocks can land in one payload, and + * differencing their SUB-MINUTE end ticks re-bases a real 64 s gap to about + * 4 s, i.e. an apparent 4 Hz. That is what fragmented a 3-day recording into + * thousands of CSVs and, through the algorithm-buffer reset on every split, + * stopped non-wear detection producing any output. Taking the rate from the + * header removes the tick arithmetic entirely. + */ + @Test + public void test018_skinTempAtSlowestRateUsesTheHeaderRateNotWrappedTicks() { + VerisenseDevice device = setupGen2Device(lightHeaderByte(2), SKIN_TEMP_CONFIG_0HZ5_REFRESH); + assertEquals("slowest configuration is 0.25 Hz output", 0.25, device.getSamplingRateForSensor(SENSORS.MLX90632), 1e-9); + seedWindow(device, DATABLOCK_SENSOR_ID.SKIN_TEMP); + + double[] window = windowFor(DATABLOCK_SENSOR_ID.SKIN_TEMP); + assertNotNull(window); + assertTrue("the window must sit around 0.25 Hz, not the wrapped ~4 Hz", window[1] < 1.0); + + // A genuine 64 s boundary between 0.25 Hz blocks must NOT split. + walkStream(device, DATABLOCK_SENSOR_ID.SKIN_TEMP, 10, 64.0, 64.0, 64.0); + // The aliased ~4 Hz reading that the tick path produced would split. + assertTrue(UtilCsvSplitting.isSamplingRateOutsideOfLimits(window, 4.0)); + } + + // ------------------------------------------------------------- other + + // ------------------------------------------- the firmware-fault diagnostic + + /** + * Captures whatever the parser prints while {@code runnable} runs. + *

+ * Worth the awkwardness: the warning below is the ONLY signal that would + * exist if the firmware shipped with the rate field broken, so "does it + * actually print" is the whole point of it. + */ + private String captureConsole(Runnable runnable) { + PrintStream original = System.out; + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + try { + System.setOut(new PrintStream(captured)); + runnable.run(); + } finally { + System.setOut(original); + } + return captured.toString(); + } + + /** + * A recording from firmware that predates the field is the ordinary case and + * must stay silent. Warning on every old recording would train people to + * ignore the line that matters. + */ + @Test + public void test023_oldFirmwareWithNoStoredRateIsNotReported() { + final VerisenseDevice device = setupGen2Device(lightHeaderByte(0), SKIN_TEMP_CONFIG_32HZ_REFRESH, 2, 0, 9); + assertFalse("fixture must have no stored rate", device.getSensorVD6283().isConfiguredRateKnown()); + assertFalse("and must predate the field", device.isPayloadDesignV14orAbove()); + + String printed = captureConsole(new Runnable() { + @Override + public void run() { + UtilCsvSplitting.warnIfLightRateFieldUnusable(device); + } + }); + assertEquals("an old recording must print nothing", "", printed.trim()); + } + + /** + * The case this exists for: firmware new enough to store the rate, carrying + * light blocks, with the field clear. That is a firmware fault, and without + * this line it would be indistinguishable from an old recording - the file + * would parse, the light data would come out, and the near-blind fallback + * window would never be mentioned. + */ + @Test + public void test024_newFirmwareWithNoStoredRateIsReportedOncePerFile() { + final VerisenseDevice device = setupGen2Device(lightHeaderByte(0), SKIN_TEMP_CONFIG_32HZ_REFRESH, 2, 2, 0); + assertTrue("fixture must claim firmware that stores the field", device.isPayloadDesignV14orAbove()); + assertFalse("but carry no rate", device.getSensorVD6283().isConfiguredRateKnown()); + + String printed = captureConsole(new Runnable() { + @Override + public void run() { + // Three payloads of the same file: the warning is per recording. + UtilCsvSplitting.warnIfLightRateFieldUnusable(device); + UtilCsvSplitting.warnIfLightRateFieldUnusable(device); + UtilCsvSplitting.warnIfLightRateFieldUnusable(device); + } + }); + assertTrue("it must say the firmware is at fault: " + printed, printed.contains("firmware fault")); + assertEquals("exactly one line per file", 1, printed.split("WARNING", -1).length-1); + + // A new file clears the state, so the next recording is reported too. + UtilCsvSplitting.clearMapOfSamplingRateLimitsPerSensor(); + String nextFile = captureConsole(new Runnable() { + @Override + public void run() { + UtilCsvSplitting.warnIfLightRateFieldUnusable(device); + } + }); + assertTrue("the next file must warn again", nextFile.contains("WARNING")); + } + + /** New firmware that DID store the rate is the healthy case: silent. */ + @Test + public void test025_newFirmwareWithAStoredRateIsNotReported() { + final VerisenseDevice device = setupGen2Device(lightHeaderByte(2), SKIN_TEMP_CONFIG_32HZ_REFRESH, 2, 2, 0); + assertTrue(device.getSensorVD6283().isConfiguredRateKnown()); + + String printed = captureConsole(new Runnable() { + @Override + public void run() { + UtilCsvSplitting.warnIfLightRateFieldUnusable(device); + } + }); + assertEquals("", printed.trim()); + } + + /** + * A reserved index is reported whatever the payload design says. + *

+ * Two reasons it cannot be folded into the previous case. It is a different + * fault, so saying the field is "clear" would send a reader after the wrong + * firmware bug. And on an OLD recording those bits should be clear by + * construction, so a non-zero one is an anomaly in its own right - gating the + * report on the firmware version would have said nothing at all. + */ + @Test + public void test026_reservedRateIndexIsReportedWhateverTheFirmwareVersion() { + for(final int[] fw:new int[][] {{2, 0, 9}, {2, 2, 0}}) { + UtilCsvSplitting.clearMapOfSamplingRateLimitsPerSensor(); + final VerisenseDevice device = setupGen2Device( + lightHeaderByte(9), SKIN_TEMP_CONFIG_32HZ_REFRESH, fw[0], fw[1], fw[2]); + assertTrue(device.getSensorVD6283().isRateIndexReserved()); + + String printed = captureConsole(new Runnable() { + @Override + public void run() { + UtilCsvSplitting.warnIfLightRateFieldUnusable(device); + } + }); + String where = "firmware " + fw[0] + "." + fw[1] + "." + fw[2] + ": " + printed; + assertTrue("a reserved index must be reported, " + where, printed.contains("WARNING")); + assertTrue("and named, " + where, printed.contains("reserved index 9")); + assertFalse("it must not be described as clear, " + where, printed.contains("field clear")); + } + } + + // ------------------------------------- the layer above the window seeder + + /** + * Everything else here drives {@link UtilCsvSplitting} directly. This drives + * the layer that decides WHETHER the seeder runs during a real payload parse. + *

+ * That layer matters for a reason that is invisible from the seeder itself: + * asking the device for the sensor-class keys behind a data block id CREATES + * and caches them, and the lookup returns the second-generation slow sensors + * unconditionally. Seeding for a sensor the payload has no blocks for would + * therefore populate mappings and rate limits for hardware the recording does + * not have - visible on first-generation files, which have neither slow sensor. + */ + @Test + public void test027_windowIsSeededOnlyForSensorsThePayloadActuallyCarries() { + VerisenseDevice device = setupLightDevice(2); + PayloadContentsDetailsV8orAbove payload = new PayloadContentsDetailsV8orAbove(device); + + assertFalse("an empty payload carries neither slow sensor", + payload.containsDataBlockForSensor(DATABLOCK_SENSOR_ID.LIGHT)); + payload.seedSlowSensorGapWindow(DATABLOCK_SENSOR_ID.LIGHT); + assertNull("no blocks means no window", windowFor(DATABLOCK_SENSOR_ID.LIGHT)); + assertNull(windowFor(DATABLOCK_SENSOR_ID.SKIN_TEMP)); + + // Give it a light block and the light window appears - and only that one. + payload.listOfDataBlocksInOrder.add(newBlock(device, DATABLOCK_SENSOR_ID.LIGHT, ticks(10))); + assertTrue(payload.containsDataBlockForSensor(DATABLOCK_SENSOR_ID.LIGHT)); + assertFalse(payload.containsDataBlockForSensor(DATABLOCK_SENSOR_ID.SKIN_TEMP)); + + payload.seedSlowSensorGapWindow(DATABLOCK_SENSOR_ID.LIGHT); + payload.seedSlowSensorGapWindow(DATABLOCK_SENSOR_ID.SKIN_TEMP); + + double[] lightWindow = windowFor(DATABLOCK_SENSOR_ID.LIGHT); + assertNotNull("a payload carrying light blocks must get its window", lightWindow); + assertEquals(1.0/UtilCsvSplitting.FILE_GAP_TOLERANCE_MULTIPLIER.SLOW_SENSOR_MAX_INTER_BLOCK_GAP_RATIO, + lightWindow[0], 1e-9); + assertNull("and the sensor with no blocks must get nothing", + windowFor(DATABLOCK_SENSOR_ID.SKIN_TEMP)); + } + + /** Nothing here may disturb a fast sensor's own band. */ + @Test + public void test019_fastSensorLimitsAreUntouched() { + VerisenseDevice device = setupLightDevice(2); + double[] fastSensorLimits = UtilCsvSplitting.calculateSamplingRateLimits(960); + UtilCsvSplitting.SAMPLING_RATE_LIMITS_PER_SENSOR.put(SENSORS.LSM6DSV, fastSensorLimits); + + seedWindow(device, DATABLOCK_SENSOR_ID.LIGHT); + + assertTrue("the fast sensor's band must be the same array, unmodified", + fastSensorLimits==UtilCsvSplitting.SAMPLING_RATE_LIMITS_PER_SENSOR.get(SENSORS.LSM6DSV)); + assertEquals(960*UtilCsvSplitting.FILE_GAP_TOLERANCE_MULTIPLIER.LOWER, fastSensorLimits[0], 1e-9); + } + + /** + * The window is a pure function of the header, so re-seeding it - which + * happens on every payload - always yields the same band, and clearing the + * state at a CSV-set boundary loses nothing that cannot be recomputed. + */ + @Test + public void test020_windowIsStatelessAndReproducible() { + VerisenseDevice device = setupLightDevice(2); + seedWindow(device, DATABLOCK_SENSOR_ID.LIGHT); + double[] first = windowFor(DATABLOCK_SENSOR_ID.LIGHT).clone(); + + for (int i = 0; i < 50; i++) { + seedWindow(device, DATABLOCK_SENSOR_ID.LIGHT); + } + assertArrayEqualsExact(first, windowFor(DATABLOCK_SENSOR_ID.LIGHT)); + + UtilCsvSplitting.clearMapOfSamplingRateLimitsPerSensor(); + seedWindow(device, DATABLOCK_SENSOR_ID.LIGHT); + assertArrayEqualsExact(first, windowFor(DATABLOCK_SENSOR_ID.LIGHT)); + } + + private static void assertArrayEqualsExact(double[] expected, double[] actual) { + assertNotNull(actual); + assertEquals(expected.length, actual.length); + for (int i = 0; i < expected.length; i++) { + assertEquals(expected[i], actual[i], 0.0); + } + } + + /** + * Pins the regression this change exists to fix, without needing the old + * implementation to hand. + *

+ * Before the rate was stored, the light window could only come from + * {@code getRateFreq()}, which was the exposure bound - 10 Hz at the default + * 100 ms exposure - widened by the standard +/-10%. A 1 Hz recording presents + * 10 samples per 10 s, i.e. 1 Hz, which sits far outside that band, so EVERY + * boundary was reported as a gap and every block became its own CSV: 129 of + * them from one 22-minute recording (DEV-979). The header-derived window + * accepts the same boundary. + */ + @Test + public void test021_theExposureDerivedBandIsWhatUsedToSplitEveryBlock() { + double exposureBoundHz = setupLightDevice(0).getSensorVD6283().getRateFreq(); + assertEquals("the old band was built from this", 10.0, exposureBoundHz, 1e-9); + double[] oldBand = UtilCsvSplitting.calculateSamplingRateLimits(exposureBoundHz); + + double apparentRateOf1HzBoundaryHz = LIGHT_SAMPLES_PER_BLOCK/10.0; + assertTrue("a healthy 1 Hz boundary was OUTSIDE the exposure-derived band", + UtilCsvSplitting.isSamplingRateOutsideOfLimits(oldBand, apparentRateOf1HzBoundaryHz)); + + VerisenseDevice device = setupLightDevice(2); + seedWindow(device, DATABLOCK_SENSOR_ID.LIGHT); + assertFalse("and INSIDE the header-derived window", + UtilCsvSplitting.isSamplingRateOutsideOfLimits(windowFor(DATABLOCK_SENSOR_ID.LIGHT), apparentRateOf1HzBoundaryHz)); + } + + /** + * The CSV sensor-config line reports {@code Configured} only when the header + * actually carried the rate, so a reader can tell a known rate from an + * exposure-derived guess. + */ + @Test + public void test022_csvConfigLineReportsConfiguredOnlyWhenKnown() { + String known = setupLightDevice(2).generateSensorConfigStrSingleSensor(SENSORS.VD6283, 0.993); + assertTrue("known rate must report Configured: " + known, known.contains("Configured = 1.0 Hz")); + assertTrue(known.contains("Calculated = 0.993 Hz")); + assertTrue("the rest of the line must be preserved: " + known, + known.contains("Gain = 2.50x") && known.contains("Slot1 = Visible")); + + String unknown = setupLightDevice(0).generateSensorConfigStrSingleSensor(SENSORS.VD6283, 0.993); + assertFalse("an unknown rate must NOT be reported as configured: " + unknown, + unknown.contains("Configured")); + assertTrue(unknown.contains("Calculated = 0.993 Hz")); + assertTrue(unknown.contains("Gain = 2.50x") && unknown.contains("Slot1 = Visible")); + } + + /** + * {@code Configured} is the rate the device was asked for, NOT the + * exposure-clamped one the parser times blocks with. + *

+ * test022 cannot see the difference, and neither can any other test here: at + * 1 Hz against the fixture exposure of 100 ms the clamped and unclamped values + * are the same number. 20 Hz is where they part, since the chip cannot measure + * faster than it integrates and the bound is 10 Hz. Someone reading the CSV to + * check what the device was configured to do has to see 20, while + * {@link SensorVD6283#getRateFreq()} goes on returning 10 for the block timing + * and the gap window - test005 pins that side. + */ + @Test + public void test028_csvConfiguredValueIsTheRateAskedForNotTheClampedOne() { + VerisenseDevice device = setupLightDevice(6); // 20 Hz configured + assertEquals("the fixture must actually clamp, or this proves nothing", + 10.0, device.getSensorVD6283().getRateFreq(), 1e-9); + + String line = device.generateSensorConfigStrSingleSensor(SENSORS.VD6283, 9.912); + assertTrue("Configured must report the configured rate: " + line, + line.contains("Configured = 20.0 Hz")); + assertFalse("and must not report the exposure clamp instead: " + line, + line.contains("Configured = 10.0 Hz")); + assertTrue(line.contains("Calculated = 9.912 Hz")); + } +}