From 9efa8c3434d6e8380b082f81518808d72e20c5a7 Mon Sep 17 00:00:00 2001 From: Paul Swenson <1937248+pdscomp@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:52:09 -0400 Subject: [PATCH 1/3] hx711s: recover in-driver from torn reads and desyncs (read-on-DRDY) Make the MCU driver self-healing so recoverable acquisition faults never reach the host as fatal errors: - Torn reads (a chip latching a new conversion mid-read, ~0.4% of frames from the ~50us read window vs the 11.6ms conversion period) are no longer consumed or discarded. The driver emits nothing and re-reads on the next DRDY, bounded at 2 retries before degrading to the host's benign hold-last-value path. Every reference implementation waits for DRDY and re-reads rather than consume a raced frame: Prusa HX717 (src/common/hx717.cpp), Linux IIO (drivers/iio/adc/hx711.c), upstream Klipper (src/sensor_hx71x.c), and Elegoo's stock firmware (bounded ready-poll plus protected transfer, per the 1.6.9/1.7.0 images). - A gain-pulse desync means the serial protocol state no longer matches the chip, which only a power cycle clears (HX711F datasheet, power-down control: SCK high >60us). The driver now power-cycles in-driver and discards the 4 settling conversions the datasheet requires at 80 SPS before resuming the stream (~115ms pause). Previously the host force-restarted the whole sensor, which killed any active probe/tare session. Elegoo's stock firmware performs the same power-cycle recovery. - A chip that stops signaling DRDY for ~2.5 conversion periods (its oscillator free-runs, so silence is never legitimate) takes the same power-cycle path instead of freezing the stream. - The host is notified once per recovery via a RECOVERED marker frame carrying the torn-retry and recovery tallies, so reliability stays observable in the klippy log without escalating to a restart. The per-channel spike filter is unchanged: those glitches pass protocol framing, so no read-timing fix can catch them. --- klippy/extras/load_cell/hx711s.py | 17 ++++ src/sensor_hx711s.c | 162 +++++++++++++++++++++++++++--- 2 files changed, 164 insertions(+), 15 deletions(-) diff --git a/klippy/extras/load_cell/hx711s.py b/klippy/extras/load_cell/hx711s.py index 2f5f494cc9..f30dfab802 100644 --- a/klippy/extras/load_cell/hx711s.py +++ b/klippy/extras/load_cell/hx711s.py @@ -16,6 +16,7 @@ SAMPLE_ERROR_DESYNC = -0x80000000 SAMPLE_ERROR_READ_TOO_LONG = 0x40000000 SAMPLE_ERROR_TORN_READ = 0x20000000 +SAMPLE_ERROR_RECOVERED = 0x10000000 ADC_FACTOR = 1.0 / (1 << 23) # A corrupt chip frame can pass framing yet sit hundreds of thousands of # counts from the truth. A single chip's reading moves by well under this @@ -40,6 +41,8 @@ def __init__( self.consecutive_fails = 0 self.torn_read_count = 0 self.spike_count = 0 + self.torn_retry_total = 0 + self.recovered_count = 0 # last valid per-channel counts, held across torn reads and glitches # to keep the sample stream gap-free (see _convert_samples) self._last_channel_counts = None @@ -189,6 +192,20 @@ def _convert_samples(self, samples): self.last_error_count += 1 logging.error("%s: READ_TOO_LONG at t=%.3f", self.name, ptime) break # errors latch in the MCU, the rest are duplicates + elif val == SAMPLE_ERROR_RECOVERED: + # The MCU power-cycled the chips in-driver (datasheet + # recovery) and discarded the settling conversions; the + # stream paused ~115ms instead of faulting. Marker frame: + # ch1 = torn-read retry tally, ch2 = recovery tally. + self.torn_retry_total += channel_counts[1] + self.recovered_count = channel_counts[2] + logging.warning( + "%s: in-driver recovery at t=%.3f (recoveries=%d," + " torn retries=%d)", + self.name, ptime, self.recovered_count, + self.torn_retry_total, + ) + continue # marker, not a force sample elif self._is_glitch(channel_counts, ptime): # corrupt frame that passed framing; hold the last good # reading so the curve stays clean for tap analysis diff --git a/src/sensor_hx711s.c b/src/sensor_hx711s.c index 8ea24d223e..18e5260b4d 100644 --- a/src/sensor_hx711s.c +++ b/src/sensor_hx711s.c @@ -20,8 +20,25 @@ #define SAMPLE_ERROR_DESYNC (1L << 31) #define SAMPLE_ERROR_READ_TOO_LONG (1L << 30) #define SAMPLE_ERROR_TORN_READ (1L << 29) +#define SAMPLE_ERROR_RECOVERED (1L << 28) #define HX711S_OVERFLOW (1 << 1) +// Read-on-DRDY recovery design (see HX711F datasheet and the reference +// drivers cited in hx711s_read_adc): a frame that raced a conversion is +// re-read on the next DRDY instead of being consumed or discarded; a +// protocol desync is cleared by an in-driver power cycle with the +// datasheet-mandated settling conversions discarded. Neither condition +// reaches the host as a fatal error. +#define HX711S_MAX_TORN_RETRIES 2 +#define HX711S_SETTLING_FRAMES 4 // datasheet: settling conversions at 80SPS +#define HX711S_STUCK_US 31000 // ~2.5 conversion periods at 80 SPS + +// recovery_state values +#define RECOVERY_NONE 0 +#define RECOVERY_PD_HIGH 1 // SCK held high: power-down pulse in progress +#define RECOVERY_WAKE_WAIT 2 // chips woken, waiting out the 50ms settle +#define RECOVERY_SETTLING 3 // discarding the settling conversions + // A corrupt chip frame can pass the framing checks yet carry a value // hundreds of thousands of counts from the truth. Real contact force moves // each channel by well under this between samples (a 2mm/s approach at 80 @@ -32,11 +49,17 @@ struct hx711s_adc { struct timer timer; uint32_t rest_ticks; uint32_t last_error; + uint32_t not_ready_since; int32_t last_good_counts[MAX_SENSORS]; int32_t pending_counts[MAX_SENSORS]; + uint16_t torn_retry_total; + uint16_t recovered_total; uint8_t have_last_counts; uint8_t have_pending_counts; uint8_t pending_flag; + uint8_t recovery_state; + uint8_t settle_remaining; + uint8_t torn_retry_count; uint8_t sensor_count; uint8_t gain_channel; // extra clock pulses: chip type + gain selection uint8_t sample_bytes; // sensor_count * BYTES_PER_SAMPLE @@ -48,6 +71,8 @@ struct hx711s_adc { static struct task_wake wake_hx711s; +static void hx711s_start_recovery(struct hx711s_adc *h); + /**************************************************************** * Low-level bit-banging @@ -128,19 +153,73 @@ hx711s_event(struct timer *timer) { struct hx711s_adc *h = container_of(timer, struct hx711s_adc, timer); uint32_t rest_ticks = h->rest_ticks; + + // Recovery state machine. Power-down per HX711F datasheet ("when PD_SCK + // is high for more than 60us the chip enters power down mode"); after + // wake the output needs the datasheet settling time before data is + // trusted, so frames are discarded in the read task. + if (h->recovery_state == RECOVERY_PD_HIGH) { + for (uint8_t i = 0; i < h->sensor_count; i++) + gpio_out_write(h->clks[i], 0); // release power down + h->recovery_state = RECOVERY_WAKE_WAIT; + h->timer.waketime += timer_from_us(50000); + return SF_RESCHEDULE; + } + if (h->recovery_state == RECOVERY_WAKE_WAIT) { + h->recovery_state = RECOVERY_SETTLING; + h->timer.waketime += rest_ticks; + return SF_RESCHEDULE; + } + if (h->recovery_state == RECOVERY_SETTLING) { + if (!h->pending_flag && hx711s_is_data_ready(h)) { + h->pending_flag = 1; + sched_wake_task(&wake_hx711s); + } + h->timer.waketime += rest_ticks; + return SF_RESCHEDULE; + } + if (h->pending_flag) { h->sb.possible_overflows++; h->pending_flag |= HX711S_OVERFLOW; rest_ticks *= 4; } else if (hx711s_is_data_ready(h)) { + h->not_ready_since = 0; h->pending_flag = 1; sched_wake_task(&wake_hx711s); rest_ticks *= 8; + } else { + // No DRDY for ~2.5 conversion periods means a chip wedged (its + // oscillator free-runs, so silence is never legitimate). Recover + // in-driver rather than escalating a dead stream to the host. + uint32_t now = timer_read_time(); + if (!h->not_ready_since) + h->not_ready_since = now; + else if (now - h->not_ready_since > timer_from_us(HX711S_STUCK_US)) + hx711s_start_recovery(h); } h->timer.waketime += rest_ticks; return SF_RESCHEDULE; } +// Power-cycle all chips and re-arm acquisition. A desynced gain-selection +// state is only cleared this way (HX711F datasheet, power-down control); +// Elegoo's stock strain-gauge firmware performs the same recovery. +static void +hx711s_start_recovery(struct hx711s_adc *h) +{ + for (uint8_t i = 0; i < h->sensor_count; i++) + gpio_out_write(h->clks[i], 1); // SCK high >60us: power down + h->recovery_state = RECOVERY_PD_HIGH; + h->settle_remaining = HX711S_SETTLING_FRAMES; + h->pending_flag = 0; + h->have_last_counts = 0; + h->have_pending_counts = 0; + h->not_ready_since = 0; + h->torn_retry_count = 0; + h->timer.waketime = timer_read_time() + timer_from_us(1000); +} + static void append_sample(struct hx711s_adc *h, int32_t val) { @@ -160,6 +239,29 @@ hx711s_read_adc(struct hx711s_adc *h, uint8_t oid) uint32_t adc[MAX_SENSORS]; uint32_t sample_error = 0; + // During recovery the datasheet requires discarding the settling + // conversions after a power cycle (4 at 80 SPS); nothing is emitted + // until they are done, then the host gets one RECOVERED marker. + if (h->recovery_state == RECOVERY_SETTLING) { + uint32_t junk[MAX_SENSORS]; + hx711s_raw_read(h, junk, 24 + gain_channel); + irq_disable(); + h->pending_flag = 0; + irq_enable(); + if (--h->settle_remaining == 0) { + h->recovery_state = RECOVERY_NONE; + h->recovered_total++; + append_sample(h, SAMPLE_ERROR_RECOVERED); + append_sample(h, (int32_t)h->torn_retry_total); + append_sample(h, (int32_t)h->recovered_total); + append_sample(h, SAMPLE_ERROR_RECOVERED); + h->torn_retry_total = 0; + if (h->sb.data_count + h->sample_bytes > ARRAY_SIZE(h->sb.data)) + sensor_bulk_report(&h->sb, oid); + } + return; + } + // The chips free-run on independent oscillators, so a chip may latch a // new conversion into its output register between the data-ready poll // and this read, or during the read itself. Bits clocked out across a @@ -177,6 +279,34 @@ hx711s_read_adc(struct hx711s_adc *h, uint8_t oid) torn_read = 1; } + // Capture and clear pending flag after all reads + irq_disable(); + uint8_t flags = h->pending_flag; + h->pending_flag = 0; + irq_enable(); + + if (torn_read) { + // Every reference driver waits for DRDY and re-reads rather than + // consume a frame that raced a conversion (Prusa HX717 + // src/common/hx717.cpp; Linux IIO drivers/iio/adc/hx711.c; upstream + // Klipper src/sensor_hx71x.c; Elegoo's bounded ready-poll). Do the + // same: emit nothing now and let the next DRDY drive a re-read. + // Bounded so a persistently mistimed chip degrades to the host's + // benign hold-last-value path instead of stalling the stream. + h->torn_retry_total++; + if (h->torn_retry_count < HX711S_MAX_TORN_RETRIES) { + h->torn_retry_count++; + return; + } + h->torn_retry_count = 0; + for (uint8_t i = 0; i < h->sensor_count; i++) + append_sample(h, (int32_t)SAMPLE_ERROR_TORN_READ); + if (h->sb.data_count + h->sample_bytes > ARRAY_SIZE(h->sb.data)) + sensor_bulk_report(&h->sb, oid); + return; + } + h->torn_retry_count = 0; + for (uint8_t i = 0; i < h->sensor_count; i++) { uint32_t raw = adc[i] >> gain_channel; if (raw & 0x800000) @@ -186,24 +316,20 @@ hx711s_read_adc(struct hx711s_adc *h, uint8_t oid) sample_error = SAMPLE_ERROR_DESYNC; } - // Capture and clear pending flag after all reads - irq_disable(); - uint8_t flags = h->pending_flag; - h->pending_flag = 0; - irq_enable(); - if (flags & HX711S_OVERFLOW) sample_error = SAMPLE_ERROR_READ_TOO_LONG; - // Torn reads are a normal consequence of chip oscillator drift: discard - // just that sample, the next conversion is valid. They can also corrupt - // the extra-bit check, so they must take priority over a desync. A real - // desync corrupts the serial protocol state, making all further values - // unreliable until the chips are power cycled: latch it and keep - // reporting it so the host restarts the sensor. - if (torn_read) - sample_error = SAMPLE_ERROR_TORN_READ; - else if (sample_error) + // A desync corrupts the serial protocol state (the gain-selection pulse + // count no longer matches the chip), which only a power cycle clears + // (HX711F datasheet). Recover in-driver: the stream pauses for the + // ~115ms power-down/settle/discard sequence instead of faulting the + // host, which previously force-restarted the sensor and killed any + // active probe session. + if (sample_error == SAMPLE_ERROR_DESYNC) { + hx711s_start_recovery(h); + return; + } + if (sample_error) h->last_error = sample_error; if (h->last_error) @@ -339,6 +465,12 @@ command_query_hx711s(uint32_t *args) h->last_error = 0; h->have_last_counts = 0; h->have_pending_counts = 0; + h->recovery_state = RECOVERY_NONE; + h->settle_remaining = 0; + h->torn_retry_count = 0; + h->torn_retry_total = 0; + h->recovered_total = 0; + h->not_ready_since = 0; h->rest_ticks = args[1]; if (!h->rest_ticks) { for (uint8_t i = 0; i < h->sensor_count; i++) From b9a658cb01caf4ff889a598a24489418be190770 Mon Sep 17 00:00:00 2001 From: Paul Swenson <1937248+pdscomp@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:26:53 -0400 Subject: [PATCH 2/3] hx711s: hold single-sample force impulses from the probe trigger Move-start acceleration kicks (500 mm/s^2 Z moves) ring the bed frame and produce single-sample force impulses of ~200-250g on the channel sum that fully revert on the next sample (measured from recorded tap curves on the CC1 test printer: the same ~220g one-sample blip appears at probing-move start and at pullback-move start). They pass the per-channel glitch filter (100k-count threshold) because they are too small, and they pass the tare because they are brief. When one lands in the arming window of a probing move it fires a first-crossing trigger up to 1.2mm above the real contact point: the resulting tap window contains no contact ramp and the tap validator aborts with TAP_CHRONOLOGY. Real contact at 2mm/s rises by at most ~80g/sample sustained (also from the recorded taps), so any single-sample jump over ~190g on the sum is now held for one sample and only forwarded to the trigger if the next sample confirms it as a genuine step (the same hold-confirm pattern as the per-channel glitch filter). Real contact ramps never engage the hold, so trigger latency is unchanged; genuine steps are delayed by at most one sample. The raw per-channel stream still carries the impulse to the host for diagnostics. --- src/sensor_hx711s.c | 73 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/src/sensor_hx711s.c b/src/sensor_hx711s.c index 18e5260b4d..47313fb089 100644 --- a/src/sensor_hx711s.c +++ b/src/sensor_hx711s.c @@ -45,6 +45,18 @@ // SPS), so a larger single-sample jump is a glitch, not a load change. #define SPIKE_CHANNEL_THRESHOLD 100000 +// Move-start acceleration kicks (500 mm/s^2 Z moves) ring the bed frame and +// appear as single-sample force impulses of ~200-250g (~20-30k counts on +// the channel sum) that fully revert on the next sample; measured on the +// CC1 test printer from recorded tap curves (2026-07-26, 40+ taps). Real +// contact at 2mm/s rises by at most ~80g/sample sustained, so any larger +// single-sample sum jump is held back for one sample and only forwarded to +// the probe trigger if the next sample confirms it (a genuine step). A +// one-sample impulse can otherwise fire a first-crossing trigger 1mm+ +// above the real contact point (phantom trigger -> flat tap window -> +// TAP_CHRONOLOGY aborts). +#define SPIKE_SUM_THRESHOLD 20000 + struct hx711s_adc { struct timer timer; uint32_t rest_ticks; @@ -52,10 +64,14 @@ struct hx711s_adc { uint32_t not_ready_since; int32_t last_good_counts[MAX_SENSORS]; int32_t pending_counts[MAX_SENSORS]; + int32_t last_good_sum; + int32_t pending_sum; uint16_t torn_retry_total; uint16_t recovered_total; uint8_t have_last_counts; uint8_t have_pending_counts; + uint8_t have_last_sum; + uint8_t have_pending_sum; uint8_t pending_flag; uint8_t recovery_state; uint8_t settle_remaining; @@ -230,6 +246,52 @@ append_sample(struct hx711s_adc *h, int32_t val) h->sb.data_count += BYTES_PER_SAMPLE; } +// Forward a summed sample to the probe trigger unless it is a single-sample +// impulse (see SPIKE_SUM_THRESHOLD): the first jump is held for one sample +// and only released if the next sample confirms it. The raw per-channel +// stream above is unaffected, so the host still sees the impulse for +// diagnostics. Same hold-confirm pattern as the per-channel glitch filter. +static void +report_sum(struct hx711s_adc *h, int32_t sum) +{ + if (!h->have_last_sum) { + h->last_good_sum = sum; + h->have_last_sum = 1; + if (h->lce) + load_cell_probe_report_sample(h->lce, sum); + return; + } + int32_t delta = sum - h->last_good_sum; + if (delta > SPIKE_SUM_THRESHOLD || delta < -SPIKE_SUM_THRESHOLD) { + if (h->have_pending_sum) { + int32_t pdelta = sum - h->pending_sum; + if (pdelta <= SPIKE_SUM_THRESHOLD + && pdelta >= -SPIKE_SUM_THRESHOLD) { + // two consecutive samples agree on the new value: genuine + // sustained step, release the held sample and this one + if (h->lce) + load_cell_probe_report_sample(h->lce, h->pending_sum); + h->last_good_sum = h->pending_sum; + h->have_pending_sum = 0; + } else { + // still jumping: track the latest suspect value + h->pending_sum = sum; + return; + } + } else { + h->pending_sum = sum; + h->have_pending_sum = 1; + return; + } + } else if (h->have_pending_sum) { + // the held sample reverted: it was an impulse, drop it + h->have_pending_sum = 0; + } + h->last_good_sum = sum; + if (h->lce) + load_cell_probe_report_sample(h->lce, sum); +} + static void hx711s_read_adc(struct hx711s_adc *h, uint8_t oid) { @@ -356,8 +418,7 @@ hx711s_read_adc(struct hx711s_adc *h, uint8_t oid) for (uint8_t i = 0; i < h->sensor_count; i++) h->last_good_counts[i] = counts_buf[i]; h->have_last_counts = 1; - if (h->lce) - load_cell_probe_report_sample(h->lce, sum); + report_sum(h, sum); } else { for (uint8_t i = 0; i < h->sensor_count; i++) { int32_t delta = counts_buf[i] - h->last_good_counts[i]; @@ -372,8 +433,7 @@ hx711s_read_adc(struct hx711s_adc *h, uint8_t oid) h->have_pending_counts = 0; for (uint8_t i = 0; i < h->sensor_count; i++) h->last_good_counts[i] = counts_buf[i]; - if (h->lce) - load_cell_probe_report_sample(h->lce, sum); + report_sum(h, sum); } else if (h->have_pending_counts) { uint8_t pending_match = 1; for (uint8_t i = 0; i < h->sensor_count; i++) { @@ -391,8 +451,7 @@ hx711s_read_adc(struct hx711s_adc *h, uint8_t oid) h->have_pending_counts = 0; for (uint8_t i = 0; i < h->sensor_count; i++) h->last_good_counts[i] = counts_buf[i]; - if (h->lce) - load_cell_probe_report_sample(h->lce, sum); + report_sum(h, sum); } else { // Replace a stale pending glitch with the latest sample. for (uint8_t i = 0; i < h->sensor_count; i++) @@ -465,6 +524,8 @@ command_query_hx711s(uint32_t *args) h->last_error = 0; h->have_last_counts = 0; h->have_pending_counts = 0; + h->have_last_sum = 0; + h->have_pending_sum = 0; h->recovery_state = RECOVERY_NONE; h->settle_remaining = 0; h->torn_retry_count = 0; From dd612373aa3e84d99e45efc80e6df151c1e21858 Mon Sep 17 00:00:00 2001 From: Paul Swenson <1937248+pdscomp@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:36:50 -0400 Subject: [PATCH 3/3] hx711s: lower impulse hold below trigger_force; sign-aware confirm The previous 190g threshold missed the small end of the impulse class: a captured raw force stream (182k samples, full mesh macro runs on the CC1 test printer) shows the killer event was an isolated +87g single-sample blip 0.5s after travel vibration settled -- 87g is under the 190g hold threshold but over the 75g trigger force, so it fired 'probe triggered prior to movement' in the arming window. The impulse class spans at least 87-220g, all single-sample and instantly reverting, so the hold threshold now sits just under trigger_force (67g): any blip big enough to trigger is held, and only released if the next sample confirms it. The confirm check is now sign-aware: an up-jump confirms when the next sample stays above (pending - threshold), which steep real contact ramps (+80g/sample sustained) satisfy, while phantoms revert and are dropped. The old symmetric |current - pending| <= threshold check would never confirm a monotonic ramp (each sample is more than threshold above the previous), needlessly stalling the trigger feed. Validated by replaying the full 182k-sample capture through a model of the filter: all 72 phantom crossings (1-sample blips, 60-220g) are removed; all 433 real contact ramps still trigger; the fatal +87g blip at the failure timestamp is held and dropped. --- src/sensor_hx711s.c | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/sensor_hx711s.c b/src/sensor_hx711s.c index 47313fb089..819702e4b1 100644 --- a/src/sensor_hx711s.c +++ b/src/sensor_hx711s.c @@ -45,17 +45,18 @@ // SPS), so a larger single-sample jump is a glitch, not a load change. #define SPIKE_CHANNEL_THRESHOLD 100000 -// Move-start acceleration kicks (500 mm/s^2 Z moves) ring the bed frame and -// appear as single-sample force impulses of ~200-250g (~20-30k counts on -// the channel sum) that fully revert on the next sample; measured on the -// CC1 test printer from recorded tap curves (2026-07-26, 40+ taps). Real -// contact at 2mm/s rises by at most ~80g/sample sustained, so any larger -// single-sample sum jump is held back for one sample and only forwarded to -// the probe trigger if the next sample confirms it (a genuine step). A -// one-sample impulse can otherwise fire a first-crossing trigger 1mm+ -// above the real contact point (phantom trigger -> flat tap window -> -// TAP_CHRONOLOGY aborts). -#define SPIKE_SUM_THRESHOLD 20000 +// Move accel kicks and travel vibration produce single-sample force +// impulses that fully revert on the next sample; captured on the CC1 test +// printer from raw force streams and tap curves (2026-07-26): ~220g blips +// at move starts and an isolated +87g blip 0.5s after travel settled, one +// sample wide. Any such impulse that crosses trigger_force fires a phantom +// first-crossing trigger ("probe triggered prior to movement" or a flat +// tap window -> TAP_CHRONOLOGY). Real contact at 2mm/s rises by at most +// ~80g/sample and is SUSTAINED. So any single-sample jump larger than the +// trigger force is held for one sample and only forwarded if the next +// sample confirms it (sign-aware: an up-jump confirms if the next sample +// stays above pending - threshold, which steep real ramps satisfy). +#define SPIKE_SUM_THRESHOLD 7000 // ~67g, just under trigger_force (75g) struct hx711s_adc { struct timer timer; @@ -264,9 +265,13 @@ report_sum(struct hx711s_adc *h, int32_t sum) int32_t delta = sum - h->last_good_sum; if (delta > SPIKE_SUM_THRESHOLD || delta < -SPIKE_SUM_THRESHOLD) { if (h->have_pending_sum) { - int32_t pdelta = sum - h->pending_sum; - if (pdelta <= SPIKE_SUM_THRESHOLD - && pdelta >= -SPIKE_SUM_THRESHOLD) { + // sign-aware confirm: the step is genuine if the level held; + // a steep real ramp keeps rising past the pending value, a + // phantom impulse reverts below it + int32_t confirm = (delta > 0) + ? (sum >= h->pending_sum - SPIKE_SUM_THRESHOLD) + : (sum <= h->pending_sum + SPIKE_SUM_THRESHOLD); + if (confirm) { // two consecutive samples agree on the new value: genuine // sustained step, release the held sample and this one if (h->lce)