Pico · Water flow and data validity

Liquid Flow: A Bubble Is Not Zero Flow

Watch signed liquid flow without filling every gap with zero. An SLF3S-1300F reports flow, sensor temperature and flags that describe its preceding read interval. A small gravity-fed water experiment makes those distinctions visible, while a finite Pico reader preserves unknown intervals and calculates only a conditional subtotal.

1. What the numbers can establish

The exact sensor is Sensirion SLF3S-1300F, article 3.000.091. Its selected water calibration produces signed flow in millilitres per minute (mL/min). Positive and negative refer to the sensor's marked flow direction, not to good and bad water. The proposed controller is an original RP2040 Raspberry Pi Pico with official RPI_PICO MicroPython 1.26.0. No physical build, target firmware compilation, Pico execution or flow-accuracy verification is claimed here.

The Inter-Integrated Circuit (I2C) bus has serial data (SDA) and serial clock (SCL) lines. They are open-drain: devices pull a line LOW, while a resistor returns it HIGH. A cyclic redundancy check (CRC) detects many transmission errors; it does not certify flow accuracy or the physical presence of liquid. Each returned pair of data bytes has its own CRC byte, including the flags word. The active-low interrupt-request output, IRQn, is unused, and pin 6 is not connected (NC).

General-purpose input/output (GPIO) names such as GP0 are different from Pico physical pin numbers. Ground (GND) is the common reference; VDD denotes the sensor's positive supply. Universal Serial Bus (USB) powers the Pico. Units used below are volts (V), milliamperes (mA), kilohms (kΩ), nanofarads (nF), microfarads (µF), milliseconds (ms), microseconds (µs) and degrees Celsius (°C). Keep these definitions in mind when reading the functional drawing.

The useful result is a record of which intervals were accepted, omitted or left uncertain. It is not a dosing controller, a complete run-volume meter or a demonstration of a pressure rating. A separate two-switch tank-level model answers a different question: a level state does not supply a signed flow rate.

2. Four active conductors on one qualified rail

Original Pico 3V3_OUT physical pin 36 supplies SLF3S VDD contact 3; Pico ground 38 returns to contact 4. GP0 pin 1 reaches SDA contact 2 and GP1 pin 2 reaches SCL contact 5, with separate 2.2 kilohm pull-ups and a non-junction crossing bridge. Two parallel bypass capacitors return to the same ground. IRQn contact 1 and NC contact 6 remain insulated. A separate gravity water path joins two open vessels through the sensor.
Logical numbered contacts, not a plug-face view or a cable-colour instruction. The clock pull-up crosses data without a junction. The lower panel is fluid tubing, not electrical wiring. Open full-size diagram · Download editable Scalable Vector Graphics (SVG).

The sensor header is Molex 53261-0671, a six-circuit 1.25 mm PicoBlade connector. Use the kit-supplied 30 cm six-pin-to-pigtail ribbon cable from EK-SLF3S-1300F, article 3.000.120, with its 51021-0600 mating housing. The article number identifies the complete kit, not a separately numbered cable product. The sensor alone does not include that cable. The kit's SCC1-USB sensor adapter is not part of this direct-Pico circuit.

With all power disconnected, identify connector orientation against the manufacturer's pin-assignment drawing and verify continuity from every numbered contact to its pigtail end. Do not infer a contact number from a photograph, wire colour or a similar six-pin lead. Insulate the two unused conductors separately.

Sensor contactPico / support connectionRole
1 IRQnUnused, individually insulatedNo address-change or data-interrupt function is enabled.
2 SDAGP0, physical 1; R1 2.2 kΩ ±1% to 3V3_OUTBidirectional data; no pull-up to 5 V.
3 VDD3V3_OUT, physical 36Sensor power from the Pico's regulated rail, subject to the qualification below.
4 GNDPico GND, physical 38Power and signal return.
5 SCLGP1, physical 2; R2 2.2 kΩ ±1% to 3V3_OUTClock at a requested 100 kilohertz (kHz).
6 NCUnused, individually insulatedNo signal or supply.
C1 / C2100 nF / 10 µF ceramic, parallel between contacts 3 and 4Local bypass across the actual sensor supply and return. Effective capacitance can change with bias.

The sensor's operating supply is 3.2–3.8 V. Its 5.5 V absolute maximum is a stress rating, not a supply option. Nominal 3.3 V leaves only 0.1 V above the sensor's lower operating limit. The Pico output label is not a guarantee of every board, cable drop or startup transient. Before accepting a physical run, qualify both the Pico rail and sensor-terminal voltage, including loaded startup, ripple and return drop, with appropriate instruments. A conservative fixture acceptance window is 3.20–3.45 V at both nodes, inside the sensor range and the Pico domain. If it is not maintained, stop and revise the power design separately; do not substitute a guessed regulator tolerance.

The datasheet gives up to 6 mA measurement current under its stated conditions. At an assumed 3.3 V, each minimum 2.178 kΩ pull-up draws about 1.52 mA when held LOW. Those are loading calculations, not guaranteed sensor LOW levels. Sensirion's engineering guidance recommends 2.2–10 kΩ pull-ups, and its reference wiring uses 3.3 V. The reviewed sensor documents do not provide a numerical input-HIGH/output-LOW guarantee to invent here: inspect the actual bus HIGH, LOW and edges. The normal input voltage stays in the common 3.3 V domain.

For an assumed 200 picofarad (pF) bus, the usual resistance-capacitance (RC) rise estimate is 0.8473 × 2200 Ω × 200 pF ≈ 0.373 µs. That is below the 1 µs standard-mode rise-time limit at 100 kHz, but cable capacitance and edges must be checked rather than assumed. The driver removes internal Pico pulls after selecting I2C, leaving the two external resistors in control. Its 20,000 µs timeout is an interface setting, not a guaranteed whole-experiment deadline.

3. Keep the wet experiment small and separate

Use two small open plastic vessels, a mounted sensor, the kit's matched 1/4-28 flat-bottom fluidic fittings and clean water-compatible tubing. Check the fitting and tubing material against the manufacturer's connection guidance; a matching thread alone does not qualify a seal. The sensor's wetted materials include polyphenylene sulfide (PPS), 316L stainless steel and epoxy. The clamp is a support, not a pressure enclosure.

Keep electronics and connector above a drip tray. The sensor is not electrically isolated from the liquid. Do not connect the water to another energized device, conductive plumbing or a separately powered probe. This project uses clean deionized water only, not alcohol, cleaning solvent, salt solution or a consumable product. Select the water command explicitly; factory water calibration is not a calibration of your vessel, tubing or conditional volume estimate.

Raise one open vessel slightly to establish gentle gravity flow through the sensor in its marked positive direction. Equalizing levels may produce a near-zero indication; exchanging the height relationship can produce reverse flow. Do not add a pump, valve, syringe jet or forced air purge. If flow is outside the selected range, lower the head rather than pressurizing the system. Pressure figures in the datasheet apply under stated conditions to the sensor, not automatically to fittings; no pressure-rating test is proposed.

Observe bubbles that occur during gentle filling or when an upstream tube mouth reaches the water surface. Do not assume that a visible bubble must trigger at an exact time or that an unflagged read proves a bubble-free whole system. Flush residual water before deposits dry, following Sensirion's clean-handling guidance. Do not push wires, brushes or abrasive tools into the channel. For other media, cleaning agents or storage conditions, consult the applicable manufacturer guidance instead of reusing this water-only procedure.

4. An interval mean, latest temperature and latched flags

The flow word is a signed 16-bit value. For this exact model, flow = raw / 500 gives mL/min. Temperature is also signed, with sensor_temperature = raw / 200 in °C. It is the sensor's latest temperature result, influenced by its own operation, ambient conditions and liquid; it is not automatically an exact water thermometer. Keep the physical fixture within the specified +5 to +50 °C operating conditions. The code rejects sensor-temperature results outside 5–50 °C as a sanity gate; passing that gate does not certify the ambient or liquid temperature.

Flow is not an instantaneous endpoint. Below a 100 ms read interval, the sensor reports an arithmetic average of its internal measurements since the preceding read. At longer intervals it switches to exponential smoothing, reported by bit 5. Therefore this lesson uses the current interval mean rather than averaging adjacent readings as trapezoid endpoints.

Bit 0 reports an air-in-line event and bit 1 a high-flow event since the previous read; these flags remain set until read. They do not say that the condition is necessarily present at the later console-print time. Flow/high-flow updates are specified at 1800–2200 updates per second, while temperature and air-in-line update at 73–111 updates per second. Clear flags mean no condition was reported by that detector in its interval, not proof that every tiny bubble was absent. A bubble can also produce a high-flow flag at its boundaries. The selected normal flow range is ±40 mL/min; the datasheet's output saturation limit is approximately ±65 mL/min. The application separately rejects values outside ±40 and values at its chosen ±65 output-limit policy. Neither policy creates an additional sensor status bit.

A flagged numeric zero becomes an unusable interval with flow_ml_min=null, not zero flow. A clear, in-range numeric zero can be accepted as a zero estimate under the same assumptions as other values. Reserved flag bits and an unqualified product revision terminate this implementation rather than being silently ignored.

5. Calculate only a conditional signed subtotal

The Pico brackets each nine-byte read with its own monotonic clock. The program requires a returned read span of at most 5 ms. For two adjacent usable reads, the upper host interval bracket must be strictly below 100 ms and the lower bracket positive. An air/high-flow/smoothing/range rejection or a timing gap breaks the anchor. The next clean read becomes BASELINE; only another contiguous clean read contributes.

read midpoint = (read_before_ms + read_after_ms) / 2
Delta t_ms = current midpoint - preceding accepted midpoint
conditional increment_mL = current mean_mL_per_min × Delta t_ms / 60000

Example: 20 mL/min × 50 ms / 60000 = 0.016667 mL
This is not (previous flow + current flow) / 2 × Delta t.

The sensor's averaging-reset instant lies somewhere within its transaction and is not supplied as a timestamp. The host midpoint interval is consequently an approximation to that sensor interval, not an exact boundary. Bus latency, internal timing, sensor accuracy and fluid behavior add limitations that a CRC cannot remove. The subtotal is a conditional estimate over selected observed intervals, not a certified physical volume.

Constructed flow rows show accepted interval-mean bars, an air-flagged raw zero, a high-flow raw 65, a long timing gap, rebaseline reads and later reverse flow. Hatched windows are omitted. The conditional signed subtotal rises, stays unchanged across omitted windows, falls with accepted negative intervals and ends at positive 0.025 millilitres; no whole-run volume is inferred.
Constructed rows from the delivered Session code, not sensor measurements. A flat subtotal across a hatched gap means no contribution was added; it does not mean the real flow or volume was zero. Open full-size graph · Download editable SVG.

Signed positive and negative contributions can cancel. Missing intervals could contain either sign, so the partial net subtotal is not even a lower bound on whole-run net volume. Startup, excluded intervals and the unobserved tail remain outside it. The final report explicitly sets whole_run_volume_known=false, current_flow_ml_min=null and sensor_timestamps=null.

6. Establish one known protocol session

Use only this sensor on the dedicated bus: the reset is a general call to address 0x00 with the single byte 0x06, which can affect other compatible devices. It is not a two-byte reset command sent to address 8. After a 30 ms reset allowance, two separate writes, 0x367C then 0xE102, prepare an eighteen-byte identity read. All six CRC words are checked. This implementation accepts product/revision 0x07030202 and records the serial number. Other revisions need explicit review rather than a guessed drop-in guarantee.

The water start command is 0x3608. The program chooses a 100 ms warm-up hold, then waits 50 ms before its first read. That first average will ordinarily already carry smoothing bit 5 and be omitted. A later clean read establishes a baseline and the following clean interval can contribute. The hold exceeds the datasheet's typical 50 ms warm-up discussion and the vendor driver's 60 ms note; it does not prove thermal equilibrium or performance under all conditions.

A complete frame is nine bytes: flow, CRC, temperature, CRC, flags, CRC. A CRC failure or an I2C read exception may occur after the sensor has already reset its interval average or flags. The code never retries and bridges that unknown window. It ends the session, attempts STOP and preserves only earlier completed historical contributions in an explicitly invalid report, when it can return one. A later manual run resets the sensor and starts a new history.

The default is eighty read attempts, 50 ms apart, roughly four seconds plus initialization and bus overhead. No absolute clock time is assigned. The 250 ms observed service-gap guard detects a returned excessive gap, but an unobserved complete 2^30-millisecond tick turn can alias to a small difference. A hung call or suspended host is not bounded by a software loop. Clock or final STOP failure can raise without returning any closed report; not every fault produces a printed record.

7. Complete finite MicroPython program

Save this complete file as pico_slf3s_validity.py. Use the read-evaluate-print loop (REPL), loading once and explicitly calling main. There is no automatic-start block:

scope = {}
exec(open("pico_slf3s_validity.py").read(), scope)
scope["main"]()
"""SLF3S-1300F / original Pico / MicroPython1.26.0, water-only fixture.
One dedicated I2C bus: general-call reset affects every compatible device on it.
Host read intervals give conditional volume estimates, not exact sensor timestamps.
"""
ADDRESS = 0x08
PRODUCT = 0x07030202
KNOWN_FLAGS = 0x0023


def crc8(data):
    c = 0xff
    for byte in data:
        c ^= byte
        for _ in range(8):
            c = ((c << 1) ^ 0x31) & 255 if c & 128 else (c << 1) & 255
    return c


def words(raw, count):
    if not isinstance(raw, bytes) or len(raw) != count*3:
        raise ValueError('FRAME_LENGTH')
    result = []
    for i in range(0, len(raw), 3):
        if crc8(raw[i:i+2]) != raw[i+2]:
            raise ValueError('CRC')
        result.append((raw[i] << 8) | raw[i+1])
    return result


def signed(w):
    return w-65536 if w & 0x8000 else w


def decode(raw):
    flow, temp, flags = words(raw, 3)
    if flags & ~KNOWN_FLAGS:
        raise ValueError('UNQUALIFIED_FLAGS')
    return signed(flow), signed(temp), flags


class Clock:
    def __init__(self, ticks, diff):
        self.ticks, self.diff = ticks, diff
        self.previous = ticks()
        if type(self.previous) is not int:
            raise RuntimeError('CLOCK_TYPE')
        self.elapsed = 0
    def now(self):
        raw = self.ticks()
        if type(raw) is not int:
            raise RuntimeError('CLOCK_TYPE')
        delta = self.diff(raw, self.previous)
        if type(delta) is not int or not 0 <= delta <= 250:
            raise RuntimeError('CLOCK_OR_SERVICE_GAP')
        self.previous, self.elapsed = raw, self.elapsed+delta
        return self.elapsed


class Driver:
    def __init__(self, bus=None, machine=None):
        self.bus, self.machine = bus, machine
    def open(self):
        if self.bus is not None:
            return
        if self.machine is None:
            import machine
            self.machine = machine
        m = self.machine
        sda, scl = m.Pin(0), m.Pin(1)
        self.bus = m.I2C(0, sda=sda, scl=scl, freq=100000, timeout=20000)
        # RP2 Pin.init(pull=None) preserves the selected alternate function.
        sda.init(pull=None)
        scl.init(pull=None)
    def write(self, address, data):
        n = self.bus.writeto(address, data)
        if type(n) is not int or n != len(data):
            raise OSError('SHORT_WRITE')
    def prepare(self, pause):
        pause(30)  # Power-up max25ms; also allow unknown earlier sensor state.
        self.write(0, b'\x06')  # GENERAL CALL: one command byte, not 0x0006 payload.
        pause(30)  # Reset max25ms, dedicated bus only.
        self.write(ADDRESS, b'\x36\x7c')
        pause(1)
        self.write(ADDRESS, b'\xe1\x02')
        pause(1)
        w = words(self.bus.readfrom(ADDRESS, 18), 6)
        product = (w[0] << 16) | w[1]
        serial = (w[2] << 48) | (w[3] << 32) | (w[4] << 16) | w[5]
        if product != PRODUCT:
            raise ValueError('UNQUALIFIED_PRODUCT_REVISION')
        self.write(ADDRESS, b'\x36\x08')  # Water calibration, never IPA.
        pause(100)  # Application hold, not a proof of thermal equilibrium.
        return product, serial
    def read(self):
        return self.bus.readfrom(ADDRESS, 9)
    def stop(self, sleep_ms):
        if self.bus is None:
            raise RuntimeError('NO_BUS_FOR_STOP')
        self.write(ADDRESS, b'\x3f\xf9')
        sleep_ms(1)  # Successful STOP: heater enters idle, not power-off.


class Session:
    def __init__(self):
        self.rows = []
        self.anchor = None
        self.net_ml = 0.0
        self.covered_ms = 0.0
        self.omitted_intervals = 0
        self.fault = None
        self.last_after = None
    def fail(self, reason):
        self.fault = self.fault or reason
        self.anchor = None
    def accept(self, raw, before, after):
        if self.fault:
            raise RuntimeError('SESSION_ALREADY_INVALID')
        if (type(before) is not int or type(after) is not int or not 0 <= before <= after
                or after-before > 5 or (self.last_after is not None and before < self.last_after)):
            self.fail('READ_SPAN_OR_ORDER')
            raise RuntimeError('READ_SPAN_OR_ORDER')
        try:
            flow_raw, temp_raw, flags = decode(raw)  # All three CRC words accepted together.
        except Exception:
            self.fail('FRAME')
            raise
        self.last_after = after
        flow, temp = flow_raw/500.0, temp_raw/200.0
        reasons = []
        if flags & 1: reasons.append('AIR_IN_LINE')
        if flags & 2: reasons.append('HIGH_FLOW')
        if flags & 32: reasons.append('EXPONENTIAL_SMOOTHING')
        if abs(flow_raw) >= 32500: reasons.append('OUTPUT_LIMIT_POLICY')
        if abs(flow) > 40: reasons.append('OUTSIDE_SELECTED_FLOW_RANGE')
        if not 5 <= temp <= 50: reasons.append('OUTSIDE_SELECTED_TEMPERATURE_RANGE')
        row = dict(read_before_ms=before, read_after_ms=after,
                   flow_raw=flow_raw, temperature_raw=temp_raw, flags=flags,
                   sensor_temperature_c=temp, flow_ml_min=None,
                   interval_midpoint_delta_ms=None, estimate_increment_ml=None)
        if reasons:
            row.update(state='UNUSABLE_FLOW', reasons=reasons)
            self.anchor = None
            self.omitted_intervals += 1
        elif self.anchor is None:
            row.update(state='BASELINE', reasons=[], flow_ml_min=flow)
            self.anchor = (before, after)
            self.omitted_intervals += 1
        else:
            a, b = self.anchor
            # Upper bracket must be strictly <100ms; lower bracket must be positive.
            if after-a >= 100 or before-b <= 0:
                row.update(state='TIMING_GAP', reasons=['READ_INTERVAL_UNQUALIFIED'])
                self.anchor = None
                self.omitted_intervals += 1
            else:
                dt = ((before+after)-(a+b))/2.0
                increment = flow*dt/60000.0  # Current interval MEAN, not trapezoid endpoints.
                self.net_ml += increment
                self.covered_ms += dt
                row.update(state='INTERVAL_ESTIMATE', reasons=[], flow_ml_min=flow,
                           interval_midpoint_delta_ms=dt, estimate_increment_ml=increment)
                self.anchor = (before, after)
        self.rows.append(row)
        return row
    def report(self, identity, closed_at):
        return dict(state='CLOSED_INVALID' if self.fault else 'CLOSED',
                    identity=identity, closed_at_host_ms=closed_at,
                    terminal_fault=self.fault, historical_rows=self.rows,
                    conditional_valid_interval_net_ml=self.net_ml,
                    covered_host_interval_ms=self.covered_ms,
                    omitted_intervals=self.omitted_intervals,
                    whole_run_volume_known=False, current_flow_ml_min=None,
                    sensor_timestamps=None, stop_command_acknowledged=True)


def run(count=80, *, driver=None, ticks=None, diff=None, sleep_ms=None):
    if type(count) is not int or not 4 <= count <= 120:
        raise ValueError('COUNT')
    if ticks is None:
        import time
        ticks, diff, sleep_ms = time.ticks_ms, time.ticks_diff, time.sleep_ms
    if driver is None:
        driver = Driver()  # No I/O in constructor.
    session = Session()
    identity = None
    clock = None
    try:
        clock = Clock(ticks, diff)
        driver.open()
        def pause(ms):
            sleep_ms(ms)
            clock.now()  # Include returned startup/warmup delays.
        identity = driver.prepare(pause)
        clock.now()
        for _ in range(count):
            pause(50)
            before = clock.now()
            raw = driver.read()
            after = clock.now()
            session.accept(raw, before, after)
    except Exception as exc:
        # A partial read may already have reset averaging/flags. Never retry/rebridge.
        session.fail(type(exc).__name__+':'+str(exc))
    finally:
        driver.stop(sleep_ms)  # STOP failure raises: no successful closed report.
    return session.report(identity, clock.now() if clock is not None else None)


def main(count=80):
    import json
    result = run(count)
    print(json.dumps(result))  # Historical result; CLOSED_INVALID is not success/current health.
    return result

Printing happens after the STOP command returns successfully; STOP puts the heater into idle, not power-off. An ordinary completed session is CLOSED, while a returned terminal data fault is CLOSED_INVALID. A closed report is still historical and says nothing about current liquid flow. Keyboard interruption and process exit requests propagate after a STOP attempt. The console itself can also fail. Neither an acknowledgement nor an exception proves the present electrical or fluidic state.

The custom implementation was checked against Sensirion's actual Python driver version 1.0.0: CRC, signed unpacking, scale factors, water/STOP command bytes and flag positions. Those host checks do not run that Python package on the Pico, and they do not replace manufacturer performance qualification.

8. Four short runs, with observations left blank

  1. Check exact sensor/kit/cable identity and pin continuity without power. Inspect tubing, matched fittings and the mounting clamp. Keep the dry electrical area above a small drip tray.
  2. Before trusting data, qualify the common rail at both board and sensor during loaded startup, plus data/clock HIGH, LOW and rise time. Stop if the narrow supply window or bus levels cannot be maintained. There is no automatic voltage check in this reader.
  3. Prime gently with clean water under gravity. Establish modest forward flow, then run the four-second acquisition. Do not move wiring during a run. Record the complete returned object and its state.
  4. Equalize vessel heights for another short run, then reverse their height relationship for a separate run. Do not concatenate their subtotal fields into a complete physical volume; each reset deliberately begins a new session.
  5. During another gentle filling observation, record any air/high-flow flags that actually occur. Leave unusable intervals empty. A visible bubble and a flagged read need not have matching console timestamps.
  6. Inspect the first smoothing rejection and subsequent baseline explicitly. Compare a clear numeric zero with an air-flagged raw zero. Verify that no estimate crosses a flagged or excessive-gap window.
  7. Afterward stop acquisition, remove power and follow the water-only cleaning guidance before residue dries. Save future measurements in the blank worksheet; it presently contains only a header.

The single experiment and host-test archive includes the complete program, original editable diagrams, blank log and instructions. In an isolated host Python environment install sensirion-i2c-slf3x==1.0.0, then run python test_flow.py. The test bus and frames are synthetic. No sensor readings, calibration certificate or target binary are in the archive.

9. Challenge: may the graph bridge a bubble?

A clean interval contributes +0.016667 mL. The next read has AIR set and raw flow zero. The following read is clean at +20 mL/min. Can you add zero for the bubble, immediately integrate the clean value across the old anchor, and call the sum the amount delivered?

Show the answer

No. The flagged raw zero is an unusable interval, not a zero-flow measurement. The anchor is broken, and the following clean read establishes a new baseline. Only another contiguous clean interval can contribute. The subtotal remains a conditional signed estimate over selected windows; missing windows can contain positive or reverse flow, so it is not a whole-run amount or a lower bound.

10. Primary references and downloads

The current product page still links SLF3S-1300F datasheet v1.1 D1, October 2022. The newer SLF3S-1300D document describes a different suffix and was not substituted. Current engineering, connection, chemical-resistance and cleaning guidance were also reviewed. No separate errata file was found among the product-linked downloads; that is a bounded search result, not a guarantee that no issue exists.

Complete program · Blank worksheet · Experiment and tests ZIP. Each original figure has editable SVG and full-size Portable Network Graphics (PNG) links. Continue with the electronics course.