VEX V5 PYTHON | 09.3 SENSOR CONDITIONS

Guide objective

Turn raw sensor readings into clear conditions using debouncing, filtering and hysteresis, while keeping immediate stop checks responsive.

01 | FROM A READING TO A DECISION

A Distance Sensor reports 301, 298, 303 and 299 mm. A single threshold at 300 mm would keep changing its answer. A switch can also briefly alternate between pressed and released when its contacts bounce.

A sensor condition is a named Boolean decision based on evidence. For example: object visible, wall nearby, lift at its lower stop, or target centred.

Keep three ideas separate: whether the reading is valid, what it measures, and whether it satisfies the condition needed by the current action.

02 | CHOOSE THE RIGHT TOOL

These tools solve different problems
ToolPurposeExample
DebouncingAccept a Boolean change after it persistsA switch stays pressed for 60 ms
Median filterReduce isolated numeric spikesMiddle of the last five ranges
Exponential smoothingReduce rapid numeric fluctuationMove partway toward each new measurement
HysteresisUse different enter and exit thresholdsNear at 300 mm; leave at 340 mm
Dwell / qualificationRequire a condition for a durationTarget window remains valid for 200 ms
Edge detectionAct once on a transitionCount a new object once when absent becomes present

Do not automatically stack every filter. Each extra stage adds state and can add delay. Start with recorded raw data, then choose the simplest rule that fixes the observed problem.

03 | DEBOUNCE A SWITCH WITHOUT BLOCKING

Track a candidate state and the time it first appeared. Restart that timer whenever the raw state changes. Accept the candidate only after it lasts long enough.

The duration begins at the first observed candidate sample
Time (ms)Raw pressedResult with 60 ms debounce
0FalseStable False
20TrueCandidate True starts
40FalseCandidate resets
60TrueNew candidate True
100TrueStill only 40 ms
120TrueStable True; one rising edge

The DebouncedBool class in the complete program returns the stable state plus rising and falling edges. Holding the switch pressed does not produce another rising edge.

A 60 ms setting is illustrative. Measure the actual switch and task. At a 20 ms poll interval, acceptance is quantised to observed samples; long gaps do not prove the signal stayed unchanged.

04 | DO NOT DELAY A LIMIT STOP

For a mechanism moving toward a physical stop, the first raw pressed reading should inhibit further movement in that direction. Debounced confirmation can identify a stable home position afterwards.

Do not continue pushing into a stop while waiting for a debounce timer. After a stop, use a deliberate rearm or homing policy rather than automatically restarting when contacts bounce.

The complete approach example treats its raw contact input as a fault immediately. Its debounced switch value is recorded only for observation. This is not a complete lift-homing controller.

05 | MEDIAN FILTER: REMOVE AN ISOLATED SPIKE

Five samples preserve a sustained change after enough new readings arrive
Raw window (mm)Sorted windowMedian
500, 502, 90, 501, 49990, 499, 500, 501, 502500
500, 500, 250, 250, 250250, 250, 250, 500, 500250

A median selects the middle value after sorting. It can reject one unusual sample without averaging that spike into the result. It cannot reliably reject a sustained bad reading or several wrong values.

Here, the filter waits for five valid samples before producing a value. Invalid data empties the window. Old values must not act as current evidence after a dropout.

A short raw reading may represent a real obstacle. The starter checks the raw close-range guard before filtering, even when the median would reject that reading.

06 | EXPONENTIAL SMOOTHING AND ITS DELAY

An exponential moving average (EMA) updates a stored value using:

filtered = filtered + alpha * (new_value - filtered)

If filtered is 500 mm, the new value is 450 mm and alpha is 0.4, the result is 480 mm. A smaller alpha smooths more but follows real changes more slowly. Alpha 1 uses the new value directly.

The starter feeds a five-sample median into an EMA with alpha 0.4. This combination is a teaching example, not a default for every robot. Compare median only, EMA only and the combined result on the same logged data.

Alpha assumes a reasonably regular update period. Changing the poll interval changes the effective response. For variable intervals, a time-based coefficient such as 1 - exp(-dt / tau) can preserve a chosen time constant.

07 | HYSTERESIS PREVENTS THRESHOLD CHATTER

Use one threshold to enter a state and another to leave it. In the gap between the two, keep the previous state.

The same middle-band reading can mean different states because of history
ReadingPrevious NEARNew NEAR
299 mmFalseTrue: enter at or below 300
315 mmTrueTrue: remain near
315 mmFalseFalse: not yet near
340 mmTrueFalse: exit at or above 340

Hysteresis adds no fixed waiting period, but it does add memory. Reset that memory when the evidence becomes unavailable. This rule classifies proximity; it does not prove an exact position.

08 | BUILD CONDITIONS WITH AND, OR AND NOT

# All required evidence must be valid before declaring success.
can_finish = range_valid and target_near and mechanism_ready
# Any urgent guard can stop the action.
must_stop = raw_contact or raw_too_close or cancelled
# Missing evidence is a separate outcome, not a far-away object.
if not range_valid:
    stop_drive()

Use names that explain the meaning and units: range_valid, target_near, heading_in_tolerance. Keep unavailable data distinct from False. Otherwise, an unplugged or non-detecting sensor can accidentally look like a clear path.

For object counting, use a qualified absent-to-present edge, then require a qualified absence before rearming. Sensor placement and object spacing determine whether the edge represents one real object.

09 | COMPLETE NON-BLOCKING SENSOR APPROACH

This forward-only practice routine starts stationary, fills a valid range window, approaches slowly, then brakes when filtered proximity enters the near state. It confirms the observed window while keeping motors stopped.

Paste below # Begin project code without editing the generated configuration region. Keep its VEX imports and Brain declaration. Manual configuration: left motors 3/4, right motors 1/2, Distance Sensor 16 and Limit Switch on Brain three-wire A. Do not duplicate these in Devices.

MOTION_CONFIGURED starts False. Verify motor signs, sensor alignment, bumper setback and a clear test lane. Thresholds are measured from the sensor face. The 120 mm raw guard and 8% request are illustrative, not a validated stopping margin for your robot.

# =============================================================================
# TEAM [MY TEAM NAME / NUMBER] | SENSOR CONDITIONS AND FILTERING
# =============================================================================
# Keep generated imports and Brain setup above '# Begin project code'.
# Manual declarations below: do not duplicate the same devices in Devices.
import math
left_1 = Motor(Ports.PORT3, GearSetting.RATIO_6_1, False)
left_2 = Motor(Ports.PORT4, GearSetting.RATIO_6_1, False)
right_1 = Motor(Ports.PORT1, GearSetting.RATIO_6_1, True)
right_2 = Motor(Ports.PORT2, GearSetting.RATIO_6_1, True)
left_drive = MotorGroup(left_1, left_2)
right_drive = MotorGroup(right_1, right_2)
front_distance = Distance(Ports.PORT16)
contact = Limit(brain.three_wire_port.a)
controller = Controller(PRIMARY)

MOTION_CONFIGURED = False  # Verify direction, sensor mounting and stopping distance.
POLL_MS = 20
MAX_GAP_MS = 100
ROUTE_TIMEOUT_MS = 8000
ACQUIRE_TIMEOUT_MS = 1000
MEDIAN_SAMPLES = 5
EMA_ALPHA = 0.4
NEAR_ENTER_MM = 300.0      # From the sensor face, not the front bumper.
NEAR_EXIT_MM = 340.0
CONFIRM_MAX_MM = 320.0
RAW_STOP_MM = 120.0       # Illustrative guard: choose from actual geometry/speed.
CONFIRM_MS = 200
SWITCH_DEBOUNCE_MS = 60
APPROACH_PERCENT = 8.0

def stop_drive():
    left_drive.stop(BRAKE)
    right_drive.stop(BRAKE)

def apply_forward(value):
    if value == 0:
        stop_drive()
    else:
        left_drive.spin(FORWARD, value, PERCENT)
        right_drive.spin(FORWARD, value, PERCENT)

class DebouncedBool:
    def __init__(self, hold_ms, initial=False):
        self.hold_ms = hold_ms
        self.value = initial
        self.candidate = initial
        self.since = None
    def update(self, now, raw):
        raw = bool(raw)
        if self.since is None or raw != self.candidate:
            self.candidate, self.since = raw, now
        rising = falling = False
        if raw != self.value and now-self.since >= self.hold_ms:
            old = self.value
            self.value = raw
            rising = self.value and not old
            falling = old and not self.value
        return self.value, rising, falling

class RangeFilter:
    def __init__(self, size, alpha):
        if size < 1 or size % 2 == 0 or not 0 < alpha <= 1:
            raise ValueError('Need odd positive size and 0 < alpha <= 1')
        self.size, self.alpha = size, alpha
        self.values = []
        self.filtered = None
    def reset(self):
        self.values = []
        self.filtered = None
    def update(self, raw):
        if raw is None or not math.isfinite(raw) or not 20 <= raw <= 2000:
            self.reset()
            return None
        self.values.append(raw)
        if len(self.values) > self.size:
            self.values.pop(0)
        if len(self.values) < self.size:
            return None
        median = sorted(self.values)[self.size//2]
        if self.filtered is None:
            self.filtered = median
        else:
            self.filtered += self.alpha*(median-self.filtered)
        return self.filtered

class NearCondition:
    def __init__(self, enter, leave):
        if enter >= leave:
            raise ValueError('Enter must be below leave')
        self.enter, self.leave, self.value = enter, leave, False
    def update(self, value):
        if value is None:
            self.value = False
        elif value <= self.enter:
            self.value = True
        elif value >= self.leave:
            self.value = False
        return self.value

class SensorApproach:
    def __init__(self, now):
        self.started = now
        self.state, self.reason = 'ACQUIRE', ''
        self.filter = RangeFilter(MEDIAN_SAMPLES, EMA_ALPHA)
        self.near = NearCondition(NEAR_ENTER_MM, NEAR_EXIT_MM)
        self.switch = DebouncedBool(SWITCH_DEBOUNCE_MS)
        self.confirm_since = None
        self.filtered = None
        self.switch_state = False
    def fail(self, reason):
        self.state, self.reason = 'FAULT', reason
        return 0.0
    def update(self, now, raw, raw_contact):
        if self.state in ('DONE_PROXIMITY', 'FAULT'):
            return 0.0
        self.switch_state, rising, falling = self.switch.update(now, raw_contact)
        # Debounced state is for observation, NOT the contact stop decision.
        if raw_contact:
            return self.fail('RAW_CONTACT')
        if now-self.started >= ROUTE_TIMEOUT_MS:
            return self.fail('ROUTE_TIMEOUT')
        if raw is None or not math.isfinite(raw) or not 20 <= raw <= 2000:
            self.filter.reset()
            self.near.update(None)
            self.confirm_since = None
            self.filtered = None
            return self.fail('RANGE_UNAVAILABLE')
        # A short raw reading bypasses the median and EMA.
        if raw <= RAW_STOP_MM:
            return self.fail('RAW_TOO_CLOSE')
        self.filtered = self.filter.update(raw)
        near = self.near.update(self.filtered)
        if self.state == 'ACQUIRE':
            if now-self.started >= ACQUIRE_TIMEOUT_MS:
                return self.fail('ACQUIRE_TIMEOUT')
            if self.filtered is not None:
                self.state = 'CONFIRM' if near else 'APPROACH'
            return 0.0  # Do not move until the filter has a full valid window.
        if self.state == 'APPROACH':
            if near:
                self.state = 'CONFIRM'
                self.confirm_since = None
                return 0.0
            return APPROACH_PERCENT
        if self.state == 'CONFIRM':
            # Brake throughout confirmation. No automatic restart or reverse.
            if not near or raw > CONFIRM_MAX_MM or self.filtered > CONFIRM_MAX_MM:
                return self.fail('CONFIRM_WINDOW_LOST')
            if self.confirm_since is None:
                self.confirm_since = now
            if now-self.confirm_since >= CONFIRM_MS:
                self.state = 'DONE_PROXIMITY'
            return 0.0
        return self.fail('UNKNOWN_STATE')

def cancelled():
    return (not competition.is_enabled() or not competition.is_autonomous()
            or controller.buttonB.pressing())

def read_inputs():
    pressed = contact.pressing()
    raw = None
    if front_distance.is_object_detected():
        raw = front_distance.object_distance(MM)
    return raw, pressed

def run_sensor_approach():
    machine = SensorApproach(brain.timer.time(MSEC))
    last_tick = None
    log_at = -1000
    try:
        while True:
            before = brain.timer.time(MSEC)
            if cancelled():
                return 'CANCELLED'
            raw, pressed = read_inputs()
            now = brain.timer.time(MSEC)
            if cancelled():
                return 'CANCELLED'
            if now-before > MAX_GAP_MS or (last_tick is not None
                    and now-last_tick > MAX_GAP_MS):
                return 'UPDATE_GAP'
            last_tick = now
            old = machine.state
            output = machine.update(now, raw, pressed)
            if cancelled():
                return 'CANCELLED'
            apply_forward(output)
            if old != machine.state or now-log_at >= 200:
                print(now, machine.state, 'raw', raw, 'filtered', machine.filtered,
                      'switch', machine.switch_state, 'output', output, machine.reason)
                log_at = now
            if machine.state in ('DONE_PROXIMITY', 'FAULT'):
                return machine.state+': '+machine.reason
            wait(POLL_MS, MSEC)
    except Exception:
        return 'SENSOR_OR_OUTPUT_FAULT'
    finally:
        stop_drive()

def autonomous():
    stop_drive()
    if MOTION_CONFIGURED:
        print('sensor approach result', run_sensor_approach())
    else:
        print('Set MOTION_CONFIGURED only after verifying the test setup')

def driver_control():
    stop_drive()  # Teaching project: no driver movement.

competition = Competition(driver_control, autonomous)
stop_drive()
while True:
    wait(100, MSEC)

The routine expects a fixed, sufficiently detectable surface already in view. Loss of detection faults immediately. There is no automatic search, restart or reverse. It does not hold an inertial heading; use a straight, aligned test lane before adding heading control from 05.4.

DONE_PROXIMITY means a valid proximity window persisted while zero motion was requested. It does not prove physical contact, an exact field pose or that the chassis has fully stopped. Add independent low-motion evidence if the next action requires verified settling.

10 | UNDERSTAND THE PRIORITY ORDER

Urgent observations bypass the normal filter chain
PriorityEvidenceResponse
1B, mode change, excessive update gapExit and brake
2Raw contact, unavailable range, raw too closeFAULT; zero output
3Median window and EMAUpdate normal proximity evidence
4Near classificationBrake before confirmation
5Valid confirmation window for 200 msDONE_PROXIMITY; keep output zero

Near enters at 300 mm, exits at 340 mm, and confirmation also requires both raw and filtered readings at or below 320 mm. These are deliberately different rules: classification has a wider band, while completion has a tighter observation window.

If confirmation loses its window, the example faults rather than moving again. A real controller may choose another policy, but it must be explicit.

11 | LOG RESPONSE, NOT JUST SMOOTHNESS

Record timestamp, raw value, median, EMA, condition state, action state and requested output. Plot them together or inspect a small table around every transition.

  1. Stationary surface: measure normal variation.
  2. Single injected numeric spike: compare the filters.
  3. A real step change: measure the lag to the new condition.
  4. Slow approach: measure stop range and repeatability.
  5. Missing detection: verify immediate zero output.
  6. Raw contact and short range: verify they bypass smoothing.
  7. Mode cancellation and delayed updates: verify cleanup.

At 20 ms polling, a five-sample window takes about 80 ms from first to fifth sample under ideal scheduling. Median response, EMA response, API latency, polling, braking and chassis inertia all contribute to how far the robot travels before stopping. Measure the complete chain.

12 | COMMON MISTAKES

  • Using wait() inside a condition: keep timers as stored timestamps.
  • Recreating the filter each tick: retain its history for the action.
  • Keeping old samples through dropouts: invalidate and reset.
  • Counting a held signal every cycle: use a qualified edge and rearm rule.
  • Smoothing an urgent stop: keep a raw guard path.
  • Treating no object as a clear path: distinguish missing evidence.
  • Filtering until the trace looks perfect: measure response delay.
  • Calling proximity proof of contact: name the outcome accurately.

CHALLENGES | CONDITION, FILTER AND VERIFY

  1. Trace: calculate stable states and rising edges for a bouncing switch.
  2. Compare: pass the same range sequence through median only, EMA only and both.
  3. Measure: halve alpha and compare step-response delay.
  4. Classify: explain the state at 315 mm before and after entering NEAR.
  5. Invalidate: inject None and prove that no previous filtered value is reused.
  6. Bypass: inject a raw reading of 100 mm into a window near 500 mm and verify zero output.
  7. Qualify: add a present/absent object counter that rearms after a stable absence.
  8. Test: measure repeated stop ranges at two conservative speeds and justify your thresholds.
Success evidence

Raw and processed traces, edge counts, response timings, dropout and guard tests, and repeated physical stop measurements. Explain what your completion state proves and what it cannot prove.

OFFICIAL REFERENCES

The filtering classes and state machine are independently written teaching examples. Validate thresholds, timing and physical behaviour on your own setup.