A carbon-dioxide sensor can accept a pressure value without knowing whether that value is current, correctly scaled or from the same room. This experiment makes that separate input visible: acquire pressure from a BMP390, convert pascals into the SCD41 command word, verify the stored word, then keep its age beside a later carbon-dioxide reading. A successful pressure write is not a carbon-dioxide calibration.
The exact fixture is an original RP2040 Raspberry Pi Pico with stock RPI_PICO MicroPython 1.26.0, an Adafruit SCD-41 carrier, product ID 5190, and an Adafruit BMP390 carrier, product ID 4816. The current Sensirion data sheet lists the SCD41-D-R2 orderable; a carrier product number alone does not establish a bare sensor's lot or revision. Use the documented carriers and verify their identity before substituting anything. No target firmware build, Pico execution or physical sensor result is claimed here.
Inter-Integrated Circuit (I²C) is the two-wire addressed bus. Serial data (SDA) and serial clock (SCL) are pulled HIGH by fitted resistors rather than driven HIGH by every participant. General-purpose input/output (GPIO) pins GP0 and GP1 provide the Pico bus. Universal Serial Bus (USB) supplies the whole fixture. Carbon dioxide is written CO₂; its reported concentration uses parts per million (ppm). Pressure uses pascals (Pa), with one hectopascal (hPa) equal to 100 Pa. A cyclic redundancy check (CRC) checks a transmitted word for detectable corruption; it does not establish measurement accuracy.
SCD41 measures carbon dioxide using a photoacoustic optical method. Pressure influences the result, so the sensor accepts an external ambient-pressure input. The data sheet permits this command during periodic measurement and states that it overrides compensation based on a previously configured altitude. That is different from forced recalibration, which needs a known carbon-dioxide reference and a defined conditioning procedure.
The supplied experiment does not perform forced recalibration, change automatic self-calibration (ASC), change temperature offset, change altitude or persist settings. It reads those initial values for context, writes only ambient-pressure updates and starts a finite periodic run. The pressure setting is not restored automatically at shutdown: the last acknowledged word can remain stored until another permitted write or sensor reset. The report calls the saved metadata initial settings, not unchanged settings.
Three questions must therefore remain distinct. Did the barometer return a plausible fresh conversion? Did the gas sensor acknowledge and return the intended pressure word? What can we say about the timing and accuracy of a later buffered CO₂ result? The first two are checked by the code. The third remains limited: the protocol does not provide an exact timestamp identifying when an internal CO₂ conversion applied that word.
Pico physical pin 36, 3V3_OUT, powers both carrier VIN pins. Physical pin 38 is their common ground. GP0, physical pin 1, connects to both SDA terminals; GP1, physical pin 2, connects to both SCL terminals. The bus runs at 100 kilohertz (kHz). Neither carrier's 3Vo regulator output is connected to the other output or to Pico 3V3_OUT. They are not connected (NC). There is no second supply and no connection to Pico VBUS.
This supply choice is deliberate. The BMP390 carrier's host-side pull-ups follow VIN, so supplying its VIN with 5 V would pull the shared Pico bus toward 5 V. Its bare sensor is not a 5 V part either: the carrier supplies the necessary regulator and level shifting. The SCD41 carrier uses its factory jumpers, regulator, level shifter and fitted pull-ups. Do not change a jumper based only on old silkscreen labels; Adafruit documents a revision-A labeling reversal. Confirm the actual copper connections against the current board drawing.
Units used in the circuit are volts (V), milliamperes (mA), kilohms (kΩ, thousands of ohms) and microfarads (µF). The two host pull-ups on each bus line are nominally 10 kΩ paths in parallel, giving about 5 kΩ at the shared 3.3 V level before the internal level-shifter branches are considered. Approximate external sink demand is 3.3/5000 = 0.66 mA per LOW line. Actual LOW voltage, internal branches, capacitance and rise time still require qualification; this simple sum is not an interface certificate.
Unused BMP390 header names mean serial data output (SDO), chip select (CS) and interrupt (INT); those terminals remain unconnected in this I²C fixture.

| Source terminal | SCD41 carrier PID5190 | BMP390 carrier PID4816 |
|---|---|---|
| Pico 36 3V3_OUT | JP2 pin 1 VIN | JP1 pin 1 VIN |
| Pico 38 GND | JP2 pin 3 GND | JP1 pin 3 GND |
| Pico 1 GP0 SDA | JP2 pin 5 SDA | JP1 pin 6 SDA |
| Pico 2 GP1 SCL | JP2 pin 4 SCL | JP1 pin 4 SCL |
| Unused terminals | JP2 pin 2 3Vo NC | JP1 pin 2 3Vo, pin 5 SDO, pin 7 CS and pin 8 INT NC; retain fitted address/interface resistors |
The SCD41 data sheet specifies a maximum 205 mA peak at 3.3 V, versus 18 mA maximum average in the normal periodic mode. Do not design from average current alone. The Pico guide recommends keeping external 3.3 V load below 300 mA and states that available current depends on the Pico load and its main supply. The gas sensor, both carriers, pull-ups and other loads must fit a real loaded budget. This fixture supplies no other external load.
Before running, measure VIN and the sensor-side rails under periodic load with suitable instruments. Check the data sheet's unloaded supply-ripple condition and the actual loaded voltage excursions. SCD41's operating supply is 2.4–5.5 V; BMP390's bare main supply is 1.65–3.6 V and interface supply 1.2–3.6 V. These are operating limits, not permission to run a carrier from an arbitrary absolute-maximum rail. Confirm bus HIGH/LOW levels and rise time at both boards. A static multimeter reading alone cannot qualify current pulses or brief bus transitions.
The BMP390 is configured at address hexadecimal 0x77, its carrier's default. The SCD41 is at 0x62. A successful address acknowledgement does not distinguish a BMP390 from every possible device, so the program checks its chip identifier, expected configuration, reset indication and error register. It reads the 21-byte factory trim block twice and rejects disagreement or all-zero/all-one blocks. Those coefficients belong to that particular sensor.
For every update, the driver drains old pressure/temperature data, checks that the ready flags cleared, requests one forced conversion with pressure oversampling eight and temperature oversampling two, then waits for both ready flags. It reads the six data bytes together and applies Bosch's compensation polynomial. There is no recursive infinite impulse response (IIR) filter configured; earlier readings are not recursively mixed into later forced results by that filter. The temperature is needed for pressure compensation; it is not promised as room-air temperature.
The polynomial implementation is included in full. Its coefficient parsing and numerical output were compared on a host computer with the unmodified Bosch SensorAPI 2.0.6 floating-point implementation at the recorded commit. Compiling that host oracle does not compile Pico firmware or validate this physical barometer. The stock MicroPython runtime supplies the bus interface; no additional driver installation is required on the Pico.
A returned pressure must be inside both the BMP390 operating range and the SCD41 command range used here, 70000–120000 Pa. The code rejects out-of-range values rather than clamping them into a plausible setting. A range test does not establish absolute accuracy. Do not substitute sea-level-adjusted weather pressure: the gas sensor needs the local ambient pressure at its location.
The SCD41 ambient-pressure command is 0xE000. A write carries one unsigned word followed by its CRC. The word represents ambient pressure divided by 100 Pa. A read uses the same command without an argument, waits its specified execution interval and returns that stored word with CRC. The direction of the I²C transaction distinguishes setting from reading.
word_hPa = floor(pressure_Pa / 100 + 0.5) quantized_pressure_Pa = 100 * word_hPa example: 101325 Pa → 1013 → 101300 Pa example: 101350 Pa → 1014 → 101400 Pa
Stock RP2 MicroPython 1.26 uses single-precision binary32 arithmetic, a 32-bit floating-point format. The Bosch host oracle uses double precision and does not establish bit-identical Pico results. Values near a 100 Pa halfway boundary can select an adjacent command word because of arithmetic roundoff.
Ideal nearest rounding contributes at most 50 Pa of quantization difference in this positive domain, excluding arithmetic roundoff, barometer error, location differences and timing. It is not a 50 Pa total error bound. The sensor's CRC polynomial is 0x31, initialized to 0xFF, over each two-byte word. A valid CRC plus matching readback proves a protocol observation of the stored value; it does not prove that the pressure transducer was accurate or that CO₂ calibration succeeded.
The application bounds the observed barometer call to 300 milliseconds (ms), and the pressure-read completion to write/readback completion to 100 ms. It also bounds the complete pressure acquisition and apply bracket to 400 ms. Those are chosen application policies. A delayed call is rejected after it returns; a write may already have changed the physical sensor before that delay is observed. The program cannot physically interrupt a hung sensor or interpreter. The bus timeout applies to individual low-level transfers, not an exact deadline for the entire experiment.

After applying pressure, the code discards any already-ready CO₂ result and verifies that the ready indication cleared. It then waits for another ready indication and reads all three CRC-protected words. Even that sequence does not identify the internal pressure-application instant. Each record retains the barometer call bracket, write acknowledgement, readback time, later CO₂ read time and the host-observed age. The internal CO₂ pressure-application timestamp is explicitly null.
Save pico_scd41_pressure.py on the Pico. The following is the complete program, including both protocols and the barometer compensation. It begins by stopping any prior gas measurement, waits for the idle transition, records initial settings, initializes the barometer and starts ordinary periodic measurement. It takes six accepted updates by default, then independently attempts to stop the gas sensor, put the barometer into sleep and release the two owned bus pins as inputs without pulls.
"""Original Pico / MicroPython1.26.0. Adafruit PID5190 + PID4816.
Complete finite pressure-compensation experiment, not CO2 calibration.
BMP polynomial/driver adapted from campaign57's original source, freshly checked
against Bosch DS1.7 and SensorAPI2.0.6. No third-party runtime dependency.
"""
import math,struct
def valid_number(x):
return not isinstance(x, bool) and math.isfinite(float(x))
def coefficients(raw):
if len(raw) != 21 or raw == bytes(21) or raw == b"\xff"*21:
raise ValueError("invalid trim block")
q = struct.unpack("<HHbhhbbHHbbhbb", raw)
return (q[0]*256.0, q[1]/2.0**30, q[2]/2.0**48,
(q[3]-16384)/2.0**20, (q[4]-16384)/2.0**29,
q[5]/2.0**32, q[6]/2.0**37, q[7]*8.0, q[8]/64.0,
q[9]/256.0, q[10]/32768.0, q[11]/2.0**48,
q[12]/2.0**48, q[13]/2.0**65)
def compensate(trim, data):
if len(data) != 6:
raise ValueError("short pressure/temperature burst")
raw_p = data[0] | data[1]<<8 | data[2]<<16
raw_t = data[3] | data[4]<<8 | data[5]<<16
if raw_p in (0,0xffffff) or raw_t in (0,0xffffff):
raise ValueError("raw rail/default-like data")
t1,t2,t3,p1,p2,p3,p4,p5,p6,p7,p8,p9,p10,p11 = trim
delta = raw_t-t1
temperature = delta*t2 + delta*delta*t3
t = temperature
pressure = (p5 + t*(p6+t*(p7+t*p8))
+ raw_p*(p1+t*(p2+t*(p3+t*p4)))
+ raw_p*raw_p*(p9+t*p10+raw_p*p11))
if not valid_number(pressure) or not valid_number(t):
raise ValueError("nonfinite compensation")
if not 30000 <= pressure <= 125000 or not -40 <= t <= 85:
raise ValueError("compensated value outside operating range")
return pressure,t
class BMP390:
def __init__(self,bus,sleep_ms,address=0x77):
self.bus,self.sleep_ms,self.address=bus,sleep_ms,address
if address not in (0x76,0x77):
raise ValueError("wrong configured I2C address")
self.sleep_ms(10)
self.identity()
self.wait(0x10) # Command decoder ready before soft reset.
self.write(0x7e,0xb6)
self.sleep_ms(10)
self.identity();self.wait(0x10)
self.read(0x10,1) # Acknowledge reset event belonging to this initialization.
a,b=self.read(0x31,21),self.read(0x31,21)
if a != b:
raise ValueError("trim changed between reads")
self.trim_raw=a;self.trim=coefficients(a)
self.write(0x1b,0x03) # Sleep, pressure and temperature enabled.
self.write(0x1c,0x0b) # Pressure8x, temperature2x oversampling.
self.write(0x1f,0x00) # No recursive IIR filter.
self.guard()
def read(self,register,length):
data=self.bus.readfrom_mem(self.address,register,length)
if len(data) != length:
raise OSError("short I2C read")
return data
def write(self,register,value):
self.bus.writeto_mem(self.address,register,bytes((value,)))
def identity(self):
if self.read(0x00,1)[0] != 0x60:
raise ValueError("BMP390 CHIP_ID must be 0x60")
def error(self):
error=self.read(0x02,1)[0]
if error & 0x07:
raise OSError("BMP390 ERR_REG="+hex(error))
def wait(self,mask):
# Bounded polling count, not a strict wall-time deadline for I2C faults.
for _ in range(50):
self.error()
if self.read(0x03,1)[0]&mask == mask:
return
self.sleep_ms(5)
raise OSError("BMP390 readiness timeout")
def guard(self):
self.identity();self.error()
if self.read(0x10,1)[0]&1:
raise OSError("BMP390 reset detected; restart and recollect reference")
if (self.read(0x1b,1)[0]&0x33 != 0x03
or self.read(0x1c,1)[0] != 0x0b or self.read(0x1f,1)[0] != 0):
raise OSError("BMP390 configuration changed")
def sample(self):
self.guard()
self.read(0x04,6) # Drain old values so their ready flags cannot satisfy a new request.
if self.read(0x03,1)[0]&0x60:raise ValueError('BMP_STALE_READY')
self.write(0x1b,0x13) # One forced conversion, both channels.
self.wait(0x60)
raw=self.read(0x04,6)
self.guard()
return compensate(self.trim,raw)
def crc(data):
c=255
for b in data:
c^=b
for _ in range(8):c=((c<<1)^0x31)&255 if c&128 else(c<<1)&255
return c
def word_packet(value):
if type(value)is not int or not 0<=value<=65535:raise ValueError('WORD')
b=bytes((value>>8,value&255));return b+bytes((crc(b),))
def words(data):
if not data or len(data)%3:raise ValueError('SCD_LENGTH')
out=[]
for i in range(0,len(data),3):
if crc(data[i:i+2])!=data[i+2]:raise ValueError('SCD_CRC')
out.append(data[i]*256+data[i+1])
return out
def pressure_word(pressure_Pa):
if not valid_number(pressure_Pa)or not 70000<=pressure_Pa<=120000:raise ValueError('PRESSURE_RANGE')
return int(pressure_Pa/100+0.5) # nearest100Pa, positive domain
class Clock:
def __init__(self,ticks,diff):self.ticks,self.diff=ticks,diff;self.raw=ticks();self.total=0;self.bad=False
def now(self):
if self.bad:raise ValueError('CLOCK')
raw=self.ticks();d=self.diff(raw,self.raw)
if type(raw)is not int or type(d)is not int or not 0<=d<=2000:self.bad=True;raise ValueError('CLOCK')
self.raw,self.total=raw,self.total+d;return self.total
def pause(clock,sleep_ms,ms):
start=clock.now()
for _ in range(ms//50+4):
age=clock.now()-start
if age>=ms:return
sleep_ms(min(50,ms-age));clock.now()
raise ValueError('FROZEN_CLOCK')
class Hardware:
def __init__(self):self.bus=None;self.pins=[];self.bmp=None
def command(self,cmd,arg=None):
payload=bytes((cmd>>8,cmd&255))+(b''if arg is None else word_packet(arg))
if self.bus.writeto(0x62,payload)!=len(payload):raise OSError('SHORT_WRITE')
def query(self,cmd,n=1):
self.command(cmd);self.sleep_ms(1);raw=self.bus.readfrom(0x62,n*3)
if len(raw)!=n*3:raise OSError('SHORT_READ')
return words(raw)
def open(self,sleep_ms):
from machine import Pin,I2C
self.Pin=Pin;self.sleep_ms=sleep_ms
for n in (0,1):self.pins.append(Pin(n))
self.bus=I2C(0,sda=self.pins[0],scl=self.pins[1],freq=100000,timeout=20000)
for p in self.pins:p.init(pull=None) # keep alternate function; remove internal pulls
sleep_ms(50);self.command(0x3F86);sleep_ms(500)
self.metadata={'altitude_m':self.query(0x2322)[0],
'ASC_enabled':self.query(0x2313)[0],
'temperature_offset_word':self.query(0x2318)[0],
'initial_pressure_Pa':self.query(0xE000)[0]*100}
self.bmp=BMP390(self.bus,sleep_ms,0x77)
self.command(0x21B1);return self.metadata
def pressure(self):return self.bmp.sample()
def set_pressure(self,w):self.command(0xE000,w);self.sleep_ms(1)
def get_pressure(self):return self.query(0xE000)[0]
def ready(self):return bool(self.query(0xE4B8)[0]&0x7FF)
def measurement(self):
c,t,h=self.query(0xEC05,3)
return{'CO2_ppm':c,'sensor_temperature_C':-45+175*t/65535,'sensor_RH_percent':100*h/65535}
def close(self):
errors=[]
if self.bus is not None:
try:self.command(0x3F86);self.sleep_ms(500)
except BaseException as e:errors.append(e)
try:self.bus.writeto_mem(0x77,0x1B,b'\x03')
except BaseException as e:errors.append(e)
for p in self.pins:
try:p.init(self.Pin.IN,pull=None)
except BaseException as e:errors.append(e)
if errors:raise errors[0]
def run(io=None,ticks=None,diff=None,sleep_ms=None,count=6):
if type(count)is not int or not 1<=count<=12:raise ValueError('COUNT')
if ticks is None:
from time import ticks_ms as ticks,ticks_diff as diff,sleep_ms
io=Hardware()if io is None else io;rows=[]
try:
clock=Clock(ticks,diff);metadata=io.open(sleep_ms);clock.now()
for index in range(count):
before=clock.now();p,t=io.pressure();received=clock.now()
if received-before>300:raise ValueError('PRESSURE_RETURNED_LATE')
if not valid_number(t)or not -40<=t<=85:raise ValueError('BMP_TEMPERATURE')
w=pressure_word(p);io.set_pressure(w);written=clock.now()
echo=io.get_pressure();verified=clock.now()
if echo!=w:raise ValueError('PRESSURE_READBACK')
if verified-received>100 or verified-before>400:raise ValueError('PRESSURE_STALE_BEFORE_APPLY')
# Discard any old CO2 result. No result is labeled corrected by command timing alone.
if io.ready():
io.measurement();clock.now()
if io.ready():raise ValueError('SCD_READY_NOT_CLEARED')
start=clock.now()
for _ in range(70):
if io.ready():break
pause(clock,sleep_ms,100)
if clock.now()-start>6500:raise ValueError('SCD_READY_TIMEOUT')
else:raise ValueError('SCD_READY_TIMEOUT')
measurement=io.measurement();done=clock.now()
if done-received>7000:raise ValueError('PRESSURE_TOO_OLD')
if not(type(measurement['CO2_ppm'])is int and 1<=measurement['CO2_ppm']<=40000):raise ValueError('SCD_CO2')
if not -10<=measurement['sensor_temperature_C']<=60 or not 0<=measurement['sensor_RH_percent']<=95:raise ValueError('SCD_ENVIRONMENT')
rows.append({'index':index,'pressure_Pa':p,'BMP_temperature_C':t,
'pressure_read_bracket_ms':[before,received],
'pressure_word_hPa':w,'quantized_pressure_Pa':w*100,
'write_ack_ms':written,'readback_ms':verified,'readback_word':echo,
'CO2_read_ms':done,'pressure_host_age_ms':done-received,
'measurement':measurement,'CO2_pressure_application_timestamp':None,
'scope':'Command/readback and later buffered measurement; no CO2 accuracy or calibration proof'})
finally:io.close()
return{'state':'CLOSED_HISTORY','rows':rows,'initial_settings':metadata,
'pressure_units':'Pa measured;100Pa command word','calibration_performed':False,
'current_CO2':None,'hardware_qualified':False}
def main(count=6):
import json
r=run(count=count);print(json.dumps(r));return r
In the MicroPython read-evaluate-print loop (REPL), run this block once. No automatic startup file is installed. The application prints one completed JavaScript Object Notation (JSON) history only after cleanup succeeds. Copy it from the console to a computer file if desired; this program does not write flash.
scope = {"__name__": "experiment"}
exec(open("pico_scd41_pressure.py").read(), scope)
scope["main"]()
Expect roughly six five-second sensor intervals plus setup and protocol overhead, not six instant values. The first interval can be shorter because the periodic engine and application start are not synchronized. The report does not make a warm-up, indoor-air accuracy, health or ventilation-control claim. Read the manufacturer's conditioning and calibration requirements before using concentrations for another purpose.
CRC errors, changed barometer configuration, a reset indication, unknown trim, a missing ready indication, stale pressure, readback mismatch or a clock fault end the run. No fallback uses the previous good pressure as if it were new. Cleanup failure prevents a completed report; a print failure can occur after resources were already stopped. Neither a returned history nor a successful stop proves the current electrical or environmental state.
The clock accumulates short observed differences across the normal tick wrap. An observed service gap beyond two seconds is rejected. An entirely unobserved whole tick wrap can alias a small elapsed value, so the short, continuously serviced run is a required assumption. There is no civil-time clock, exact physical measurement timestamp or hardware watchdog in this application. Historical rows remain historical after the program ends.
The supplied host tests exercise all 65536 two-byte CRC inputs, single-bit corruptions, rounding boundaries, late calls, cleanup and finite timeout behavior. Three hundred constructed trim/raw vectors are compared against Bosch's actual compiled host C polynomial; the vectors are not factory calibration data or measured samples. Additional adapter checks exercise the actual provided hardware classes through controlled bus and pin substitutes.
Your barometer returns 101350 Pa and the SCD41 echoes 1014. A later CO₂ reading is lower than the previous one. Which conclusions are justified?
The application rounded to 1014 hPa, equivalent to 101400 Pa, and observed matching pressure readback. It cannot attribute the lower CO₂ result uniquely to that change, prove calibration, or reconstruct the internal pressure-application instant. Room concentration, buffered timing, sensor noise, conditioning and other influences remain. If the pressure source was stale or its acquisition failed, no matching echo could repair that missing evidence.
Download the compressed ZIP archive with the full experiment, checks and Bosch oracle sources, complete Pico program, blank worksheet, and editable functional circuit / quantization graph. The editable files use Scalable Vector Graphics (SVG); the linked originals above use Portable Network Graphics (PNG). The archive's instructions show the separate host C oracle build and Python commands.
The electronics course and measurement resolution lesson explain the background. Primary references: Sensirion SCD4x version 1.7, April 2025; Bosch BMP390 revision 1.7; Adafruit SCD4x pinouts and jumper warning; Adafruit BMP390 carrier pinouts; SCD41 carrier schematic; BMP390 carrier schematic; pinned Bosch SensorAPI 2.0.6 source; and the original Pico data sheet. Versioned source snapshots and their hashes accompany the author evidence; their presence is not a physical qualification result.