Pico · Humidity, contact temperature and uncertainty
A room can have moderate humidity while one cold patch of glass approaches the dew point. Combine an air measurement with a separately contacted temperature sensor, then calculate a margin interval. The useful result is knowing when the assumptions leave the sign unresolved—not turning one positive number into a promise that the whole window will stay dry.
Relative humidity (RH) compares water-vapour pressure with saturation at the same air temperature. Dew point is the temperature at which that vapour state reaches saturation over liquid water. A condensation margin here means contact-sensor temperature minus estimated air dew point. Positive and negative describe that numerical comparison; they are not building-control commands or proof of visible droplets.
The exact proposed hardware is an original RP2040 Raspberry Pi Pico with official RPI_PICO MicroPython 1.26.0, an Adafruit product identifier (PID) 5665 SHT45 carrier without a membrane, and an Analog Devices DS18B20+ in its three-lead TO-92 through-hole package. Sensirion SHT45-AD1B-R2 is the bare-sensor reference. The carrier's public schematic names the SHT4x family and does not prove a tape-and-reel suffix on an individual board; confirm the supplied carrier and sensor version rather than inferring one from its serial number.
Inter-Integrated Circuit (I2C) uses serial data (SDA) and serial clock (SCL). The DS18B20 uses a separate 1-Wire data line, DQ. Both are open-drain interfaces: a device pulls LOW and a resistor returns the line HIGH. General-purpose input/output (GPIO) names differ from physical Pico pin numbers. A cyclic redundancy check (CRC) detects many transfer errors; it does not establish thermal contact or calibrated accuracy.
Universal Serial Bus (USB) supplies the Pico. Ground (GND) is the common reference; VIN is the carrier's positive input, its 3V pad is an internal regulated output, and VDD/VSS label chip supply and return. JP2 is the carrier's header reference. Units below are volts (V), milliamperes (mA), microamperes (µA), kilohms (kΩ), nanofarads (nF), milliseconds (ms) and degrees Celsius (°C). No physical build, target firmware compilation, Pico execution or condensation performance is claimed.

| Terminal | Exact connection | Purpose |
|---|---|---|
| Pico 3V3_OUT, physical 36 | Carrier JP2 pin 1 VIN; DS18B20 pin 3 VDD; R1 top; C1 top | One nominal 3.3 V domain, powered by Pico USB only. |
| Pico GND, physical 38 | Carrier JP2 pin 3 GND; DS18B20 pin 1 GND; C1 bottom | Power and signal return. |
| Pico GP0, physical 1 | Carrier JP2 pin 5 SDA | I2C0 data; requested clock rate is 100 kilohertz (kHz). |
| Pico GP1, physical 2 | Carrier JP2 pin 4 SCL | I2C0 clock. |
| Pico GP16, physical 21 | DS18B20 pin 2 DQ; R1 bottom | Externally powered 1-Wire, with R1 = 4.7 kΩ ±1% to 3V3_OUT. |
| C1 | 100 nF ceramic directly between DS pins 3 and 1 | Local nonpolar power bypass. |
| Carrier JP2 pin 2, 3V | Leave unused | Do not join the regulator output to a second supply. |
Adafruit's current carrier includes a regulator, level shifting, local bypass and 10 kΩ pull-ups on each side of each shifted bus line. Add no extra I2C pull-ups here. At assumed 3.3 V on both sides, two 10 kΩ branches draw about 0.66 mA per LOW line; actual voltage drops depend on the board. The separate 4.7 kΩ DQ resistor draws about 0.70 mA at nominal 3.3 V, within the thermometer's stated 4 mA sink test condition. These are loading estimates, not measured edge quality.
The bare SHT45 operates at 1.08–3.6 V, while the carrier accepts the microcontroller supply through VIN. The externally powered DS18B20 requires 3.0–5.5 V and its DQ pull-up must not exceed its local supply. Its local-power input-HIGH minimum is 2.2 V and specified LOW maximum is 0.8 V. Keep every external signal in the Pico's 3.3 V domain; the thermometer's 5.5 V capability does not permit a 5 V Pico input. Check actual startup, loaded rail, HIGH/LOW and rise times at the terminals. Short wiring and the stated resistor do not automatically qualify any cable length.
Use the manufacturer's TO-92 package view to identify pins before soldering. This lesson uses a bare DS18B20+, not a waterproof probe with undocumented wire colours. Cover each lead separately and strain-relieve the short cable. Only the plastic sensor body contacts the glass. Keep the Pico, USB connection and exposed conductors away from wet surfaces.
Attach the thermometer's flat body to one small interior glass point with a repeatable, electrically insulating, removable attachment. Record its position and attachment method. A package partly exposed to warm room air can read warmer than the glass; the cable also transports heat. Thermal lag is the delay while the package approaches a changing surface temperature. The DS18B20's conversion time is not its contact-settling time.
Place the humidity carrier in representative nearby room air, apart from Pico heat, direct sun, exhaled breath, radiators and the wet boundary immediately against the pane. Air in a room is not perfectly mixed. A sensor at one location cannot establish vapour conditions behind a curtain or at every cold frame corner. Allow the mounted sensors to settle under the intended conditions and check stability; no universal waiting time establishes that contact error is bounded.
The supplied program never sends a SHT45 heater command. It performs a soft reset, but reset does not remove residual heat, contamination or previous exposure. A cold, unheated, settled sensor and known handling history are physical prerequisites. Sensirion's typical humidity response is on a seconds scale, so a new digital conversion can still describe a lagging sensor. Keep the humidity element open to air; do not tape over, touch or deliberately wet it. Follow current handling guidance for contamination and cleaning.
The National Physical Laboratory (NPL) gives this Magnus approximation for saturation over liquid water. The natural logarithm, written ln, reverses an exponential relation. Its vapour-pressure approximation is described for −45 to +60 °C; this experiment deliberately accepts a narrower room domain and excludes dew-point intervals extending below 0 °C. It does not substitute a frost-point calculation. The application accepts indicated air temperature from 5 to 40 °C, RH from 20 to 90%, and contact temperature from 0 to 40 °C; it also requires the expanded dew-point interval to remain at or above 0 °C.
g = ln(RH / 100) + 17.62 × T_air / (243.12 + T_air)
T_dew = 243.12 × g / (17.62 - g)
central margin = T_contact - T_dew
Example: T_air = 22 °C; RH = 60%; T_contact = 15 °C
T_dew ≈ 13.875 °C; central margin ≈ +1.125 °CA central estimate hides uncertainty. The code uses explicitly assumed bounds of ±1 °C for air temperature, ±5 percentage points for RH, ±1 °C for contact indication and ±0.3 °C for the dew-point model. Five percentage points means 60% becomes 55–65%, not a five-percent multiplier. These values are a teaching policy, not copied sensor tolerances, a validated error budget or a confidence interval. In particular, contact error and lag may exceed them. Replace them only with justified bounds and recheck the domain.
Dew point increases with air temperature and RH within this domain. Evaluate its low and high input corners, then subtract the opposite extremes from the contact interval:
dew_low = dewpoint(T_air - 1, RH - 5) - 0.3
dew_high = dewpoint(T_air + 1, RH + 5) + 0.3
margin_low = T_contact - 1 - dew_high
margin_high = T_contact + 1 - dew_low
At 22 °C / 60% RH / contact 15 °C:
dew interval ≈ [11.311, 16.364] °C
margin interval ≈ [-2.364, +4.689] °C → INDETERMINATE
POSITIVE_MARGIN_ASSUMED means the whole chosen interval is above zero. NEGATIVE_MARGIN_ASSUMED means it is below zero. An interval including zero is INDETERMINATE. None is an all-window safety state: the classification is conditional on the assumed bounds, placement, timing and model. Droplet formation also depends on local conditions and surface behavior. The program supplies no heating, ventilation, mould-prevention or building-control action.
Datasheet accuracy and resolution are separate. The DS18B20 specifies ±0.5 °C error from −10 to +85 °C under its conditions, but a 12-bit step of 0.0625 °C does not make the contacted glass that accurate. SHT45 headline ±1% RH and ±0.1 °C are typical values in specified regions; consult the current maximum/range curves and handling conditions. NPL's vapour-pressure approximation uncertainty is not automatically a certified ±0.3 °C dew-point uncertainty. This lesson's extra allowance remains an explicit assumption.
During preparation, the reader requires exactly one DS18B20 family address and checks its read-only memory (ROM) code with CRC. It rejects parasite power. It preserves the alarm bytes while setting 12-bit resolution in volatile scratchpad memory, then reads that configuration back. It does not copy settings to electrically erasable programmable read-only memory (EEPROM).
Each pair issues a new thermometer Convert T command, requires an observed busy LOW, waits at least 760 ms after the command returns, then requires completion HIGH and a valid nine-byte scratchpad. The datasheet's maximum 12-bit conversion time is 750 ms. A valid scratchpad alone could contain the +85 °C power-on value or an earlier result; the sequence checks address, resolution and conversion behavior instead of treating CRC alone as freshness. Returned delays and a stuck busy signal cause rejection. This is still protocol evidence, not proof of the physical die or thermal equilibrium.
About 600 ms into that conversion, the reader commands a fresh high-precision SHT45 measurement with byte 0xFD, waits 10 ms and reads temperature plus humidity with both CRCs. The current datasheet gives an 8.3 ms maximum under its characterization conditions. Raw RH can mathematically fall below 0 or above 100%; it is rejected by the application domain rather than cropped into a usable value.
The Pico records command/read brackets, not hardware measurement timestamps. The air bracket must be at most 40 ms, the contact bracket at most 850 ms, and their total span and oldest-command age at the decision must be at most 1000 ms. Conversion periods can overlap; the exact sensor sampling instants are unknown. These guards limit returned software observations, not physical response age or unobserved delays. No time-skew compensation or derivative estimate is applied.
The clock uses wrapping millisecond ticks. It rejects an observed backward step or gap above 2000 ms and a frozen clock has a finite wait budget. An entirely unobserved whole tick turn can alias a small difference; a hung low-level call is not interrupted by this logic. The short continuously serviced run is a required assumption. A completed row describes its historical decision snapshot, not the connection, temperature or margin at print time.
Save the entire program as pico_condensation_margin.py on the selected Pico firmware. The firmware's bundled 1-Wire module is used; no external cloud or network client is involved. Load the file once in the read-evaluate-print loop (REPL) and call main explicitly:
scope = {}
exec(open("pico_condensation_margin.py").read(), scope)
scope["main"]()"""Finite heater-uncommanded SHT45 + externally powered DS18B20 experiment.
Host observation brackets and assumed error intervals are not physical guarantees.
Load once, then explicitly call main(); there is no autostart block.
"""
import math
POLICY = {'air_C': 1.0, 'rh_points': 5.0, 'surface_C': 1.0, 'model_C': 0.3}
def number(x):
if isinstance(x, bool) or not isinstance(x, (int, float)) or not math.isfinite(x):
raise ValueError('NONFINITE_NUMBER')
return x
def crc_sht(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 crc_dallas(data):
c = 0
for b in data:
for _ in range(8):
mix = (c ^ b) & 1
c >>= 1
if mix:
c ^= 0x8C
b >>= 1
return c
def frame(data, n):
if not isinstance(data, (bytes, bytearray)) or len(data) != n:
raise ValueError('FRAME_LENGTH')
return bytes(data)
def sht_words(data):
b = frame(data, 6)
if crc_sht(b[:2]) != b[2] or crc_sht(b[3:5]) != b[5]:
raise ValueError('SHT_CRC')
return (b[0] << 8 | b[1], b[3] << 8 | b[4])
def sht_values(data):
t, r = sht_words(data)
# Do not crop a nonphysical raw humidity into a usable endpoint.
return -45 + 175 * t / 65535, -6 + 125 * r / 65535
def surface_value(data):
b = frame(data, 9)
if crc_dallas(b[:8]) != b[8]:
raise ValueError('DS_CRC')
if b[4] != 0x7F:
raise ValueError('DS_RESOLUTION')
raw = b[0] | b[1] << 8
if raw & 0x8000:
raw -= 65536
return raw / 16
def dewpoint(t, rh):
t, rh = number(t), number(rh)
if not -45 <= t <= 60 or not 0 < rh <= 100:
raise ValueError('MAGNUS_DOMAIN')
g = math.log(rh / 100) + 17.62 * t / (243.12 + t)
return 243.12 * g / (17.62 - g)
def margin(t, rh, surface, policy=None):
t, rh, surface = number(t), number(rh), number(surface)
p = dict(POLICY if policy is None else policy)
if set(p) != set(POLICY):
raise ValueError('POLICY_FIELDS')
for k in p:
if not 0 <= number(p[k]) <= (10 if k == 'rh_points' else 5):
raise ValueError('POLICY_RANGE')
if not 5 <= t <= 40 or not 20 <= rh <= 90 or not 0 <= surface <= 40:
raise ValueError('ROOM_DOMAIN')
lo = dewpoint(t - p['air_C'], rh - p['rh_points']) - p['model_C']
hi = dewpoint(t + p['air_C'], rh + p['rh_points']) + p['model_C']
if lo < 0:
raise ValueError('LIQUID_WATER_SCOPE')
low = surface - p['surface_C'] - hi
high = surface + p['surface_C'] - lo
label = 'INDETERMINATE'
if low > 0:
label = 'POSITIVE_MARGIN_ASSUMED'
elif high < 0:
label = 'NEGATIVE_MARGIN_ASSUMED'
return {'dewpoint_C': dewpoint(t, rh), 'dewpoint_interval_C': [lo, hi],
'margin_C': surface - dewpoint(t, rh), 'margin_interval_C': [low, high],
'classification': label, 'assumed_policy': p,
'accuracy_or_condensation_guaranteed': False}
class Clock:
def __init__(self, ticks, diff):
self.ticks, self.diff = ticks, diff
self.raw = ticks()
if type(self.raw) is not int:
raise ValueError('CLOCK')
self.elapsed, self.bad = 0, 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.elapsed = raw, self.elapsed + d
return self.elapsed
def wait(clock, sleep_ms, duration):
start = clock.now()
for _ in range(duration // 25 + 3):
if clock.now() - start >= duration:
return
sleep_ms(min(25, duration - (clock.now() - start)))
clock.now()
raise ValueError('FROZEN_CLOCK')
def evaluate(air, contact, now):
t, rh, ab, ae = air
surface, sb, se = contact
if any(type(v) is not int or v < 0 for v in (ab, ae, sb, se, now)):
raise ValueError('TIME_TYPE')
if not ab <= ae <= now or not sb <= se <= now:
raise ValueError('TIME_ORDER')
if ae - ab > 40 or se - sb > 850:
raise ValueError('ACQUISITION_SPAN')
oldest = min(ab, sb)
offset = max(ae, se) - oldest
if now - oldest > 1000 or offset > 1000:
raise ValueError('PAIR_AGE')
result = margin(t, rh, surface)
result.update({'air_C': t, 'rh_percent': rh, 'surface_C': surface,
'air_host_window_ms': [ab, ae], 'surface_host_window_ms': [sb, se],
'decision_elapsed_ms': now, 'oldest_command_age_ms': now - oldest,
'pair_window_span_ms': offset, 'sensor_timestamp': None})
return result
class Hardware:
def __init__(self):
self.i2c, self.ow, self.rom = None, None, None
self.pins = []
def open(self):
from machine import Pin, I2C
from onewire import OneWire
self.Pin = Pin
# Retain pin handles before changing modes, for partial-open cleanup.
for n in (0, 1, 16):
self.pins.append(Pin(n))
sda, scl, dq = self.pins
self.i2c = I2C(0, sda=sda, scl=scl, freq=100000, timeout=20000)
sda.init(pull=None)
scl.init(pull=None)
self.ow = OneWire(dq)
dq.init(pull=None) # retain open-drain mode; external 4.7k is the pull-up
def command(self, value):
if self.i2c.writeto(0x44, bytes([value])) != 1:
raise OSError('SHORT_WRITE')
def select(self):
self.ow.reset(True)
self.ow.writebyte(0x55)
self.ow.write(self.rom)
def scratch(self):
self.select()
self.ow.writebyte(0xBE)
b = bytearray(9)
self.ow.readinto(b)
# Checks CRC and volatile 12-bit configuration, not a fresh conversion.
surface_value(b)
return bytes(b)
def prepare(self, clock, sleep_ms):
wait(clock, sleep_ms, 10)
self.command(0x94)
wait(clock, sleep_ms, 2)
self.command(0x89)
wait(clock, sleep_ms, 2)
a, b = sht_words(self.i2c.readfrom(0x44, 6))
serial = a << 16 | b
roms = self.ow.scan()
if len(roms) != 1:
raise ValueError('ONE_DS_REQUIRED')
rom = frame(roms[0], 8)
if rom[0] != 0x28 or crc_dallas(rom[:7]) != rom[7]:
raise ValueError('DS_ROM')
self.rom = rom
self.select()
self.ow.writebyte(0xB4)
if self.ow.readbit() != 1:
raise ValueError('NO_PARASITE_POWER')
self.select()
self.ow.writebyte(0xBE)
raw = bytearray(9)
self.ow.readinto(raw)
if crc_dallas(raw[:8]) != raw[8]:
raise ValueError('DS_CRC')
# Preserve alarm bytes; set volatile resolution. Never COPY SCRATCHPAD.
self.select()
self.ow.writebyte(0x4E)
self.ow.write(bytes([raw[2], raw[3], 0x7F]))
self.scratch()
return {'sht_serial': serial, 'ds_rom_hex': rom.hex()}
def start_surface(self):
self.select()
self.ow.writebyte(0x44)
if self.ow.readbit() != 0:
raise ValueError('DS_BUSY_NOT_OBSERVED')
def read_surface(self):
if self.ow.readbit() != 1:
raise ValueError('DS_NOT_COMPLETE')
return surface_value(self.scratch())
def air(self):
return sht_values(self.i2c.readfrom(0x44, 6))
def close(self):
errors = []
if self.i2c is not None:
try:
self.i2c.deinit()
except BaseException as e:
errors.append(e)
for pin in self.pins:
try:
pin.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=8):
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)
io.open()
identity = io.prepare(clock, sleep_ms)
clock.now()
for index in range(count):
if index:
wait(clock, sleep_ms, 1000)
sb = clock.now()
io.start_surface()
issued = clock.now()
if issued - sb > 10:
raise ValueError('DS_START_LATENCY')
wait(clock, sleep_ms, 600)
ab = clock.now()
io.command(0xFD) # high precision, never a heater command
wait(clock, sleep_ms, 10)
t, rh = io.air()
ae = clock.now()
wait(clock, sleep_ms, max(0, 760 - (clock.now() - issued)))
surface = io.read_surface()
se = clock.now()
row = evaluate((t, rh, ab, ae), (surface, sb, se), clock.now())
row['index'] = index
rows.append(row)
finally:
io.close() # no report if required cleanup raises; sensors remain powered
return {'state': 'CLOSED_HISTORY', 'identity': identity, 'rows': rows,
'current_margin_C': None, 'hardware_qualified': False,
'scope': 'Assumed interval at each completed host snapshot; no current safety state'}
def main():
import json
report = run()
print(json.dumps(report))
return report
The default is eight pairs, with a one-second serviced pause between pairs—roughly thirteen seconds plus initialization and transfer overhead. It does not continually monitor the window. The program prints one CLOSED_HISTORY object after interface cleanup succeeds, with current_margin_C=null. No previous good reading substitutes for a failed pair. A transfer, CRC, range, clock, conversion or cleanup error prevents a successful report; KeyboardInterrupt and process-exit exceptions propagate through the same cleanup attempt.
Cleanup independently attempts to disable I2C and release each owned pin to an input without pulls. It does not switch off sensor power or undo a conversion already started. Partial construction and cleanup failures are exercised with host adapters; no claim is made that those tests execute Pico registers. Printing can also fail after a completed historical acquisition.
The blank worksheet has no observations filled in. Keep original historical rows and write separate notes for contact changes, drift and rejected runs. A classification without its assumptions and position is incomplete.
The single experiment and test archive includes the complete program, host tests, blank log, instructions and original graphics. Run python test_margin.py on a host Python interpreter in that extracted directory. The suite exercises the actual supplied functions with deterministic bus/clock adapters, CRC vectors, signed temperatures, interval corners, timing boundaries, faults and cleanup. It is not a target firmware build, sensor calibration or physical thermal experiment.
Inspect a generated interval together with its assumptions. For instance, an apparently useful thermometer resolution can coexist with large contact error. Repeating the same indicated value reduces neither an unknown offset nor an unqualified thermal lag. A wider bound is honest only if its underlying physical limits are justified; merely choosing a large number is not qualification.
The air is indicated as 22 °C and 60% RH, the contacted sensor reads 15 °C, and the central margin is +1.125 °C. The interval under the stated assumptions is −2.364 to +4.689 °C. May the report say that the whole window cannot condense?
No. The interval crosses zero, so the sign is indeterminate even under those assumptions. The physical contact and air placement may introduce errors beyond the selected bounds; two conversion windows are not an exact simultaneous field measurement. One small contact point is not the whole pane, and a historical row is not a current guarantee.
Complete program · Blank worksheet · Experiment and tests ZIP. Both figures offer full-size Portable Network Graphics (PNG) and editable SVG files. Continue with the electronics course.