Preview build — page text is a draft awaiting human review. Code badges come from real compiles. The flasher is real but not yet hardware-tested; the Playground is still a preview.
Cooling Curve Logger: the finished build
Pico WStudent projects

Newton’s law of cooling experiment with a live graph (Pico W + 2 DS18B20 probes)

Hot water cools on a live phone graph — and the Pico W fits Newton’s law for you.

Difficulty
Easy
Build time
1.5 h
Parts cost
₹1,131 · $13
Verified — micropython (mpy-cross v1.29.0), 3 Nov 2026Compile-only

Community draft — report a mistake

Parts list

PartQtyApprox.Buy
Raspberry Pi Pico W (MicroPython installed)1₹620Buy Raspberry Pi Pico W (MicroPython installed)
DS18B20 waterproof probe (1 m cable)2₹240Buy DS18B20 waterproof probe (1 m cable)
4.7 kΩ resistor1₹1Buy 4.7 kΩ resistor
Breadboard + jumper wires1₹90Buy Breadboard + jumper wires
Steel tumbler or paper cup + thermocol lid (for a second run)1₹30Buy Steel tumbler or paper cup + thermocol lid (for a second run)
Micro-USB cable + 5 V adapter or power bank1₹150Buy Micro-USB cable + 5 V adapter or power bank
Total₹1,131

Affiliate disclosure: “Buy” links may earn FlashReady a small commission at no extra cost to you. Prices are indicative.

Wiring

Raspberry Pi Pico WOnly the pins used are shown · 3.3 V logicDS18B20 (1-Wire)DS18B20 (1-Wire) #24.7k → 3V3GND3V3GP22GNDDQVDDGNDDQVDD
Every connection
Raspberry Pi Pico WPart · pinWireNotes
GP22DS18B20 (1-Wire) · DQyellowHot-water probe (yellow or white wire) — physical pin 29
3V3DS18B20 (1-Wire) · DQredVia 4.7 kΩ — One pull-up for the whole 1-Wire bus
3V3DS18B20 (1-Wire) · VDDred
GNDDS18B20 (1-Wire) · GNDblack
GP22DS18B20 (1-Wire) #2 · DQyellowRoom-air probe on the same bus
3V3DS18B20 (1-Wire) #2 · VDDred
GNDDS18B20 (1-Wire) #2 · GNDblack

Board quirks: Use hot water from a kettle or geyser, around 70–80 °C — not boiling, and an adult pours it. The DS18B20 probe’s steel tube is fine in water up to about 100 °C, but the cable’s PVC sleeve can soften above that. The probe that is warmer at power-up is taken as the hot-water probe. Put the hot probe in the cup before switching on (or open /reset after it is in). One 4.7 kΩ pull-up from DQ to 3V3 serves both probes — they share the 1-Wire bus and are told apart by their unique 64-bit ROM codes. Fanless still air gives the cleanest curve. A ceiling fan speeds cooling (a bigger k) — which makes a good second experiment. Without Wi-Fi settings the Pico still logs: the CSV lines appear in Thonny’s shell and can be copied into a spreadsheet.

Code

main.pyMicroPythonOpen in PlaygroundOpen in PlaygroundFlash
# Newton's law of cooling experiment - Raspberry Pi Pico W (MicroPython) + 2 waterproof DS18B20 probes.
# One probe sits in a cup of hot water, the other in the room air. Every 5 s the Pico W logs both
# temperatures. It fits T(t) = Ta + (T0 - Ta) * e^(-k t) by a straight-line fit of ln(T - Ta) against t,
# and serves a live graph at http://<pico-ip>/ (CSV at /data.csv, new run at /reset).
# Without Wi-Fi it still logs: the CSV lines appear in Thonny's shell.
# Both probes: DQ -> GP22 (one 4.7 kOhm pull-up to 3V3 for the bus), VDD -> 3V3, GND -> GND.
from machine import Pin
import onewire
import ds18x20
import network
import socket
import time
import math
import json

SAMPLE_S = 5
MAX_POINTS = 720  # 1 hour at 5 s
MIN_DIFF_C = 2.0  # points closer than this to room temperature are left out of the fit (noise)

WIFI_SSID = "your-wifi"
WIFI_PASS = "your-password"

# The FlashReady flasher saves your settings to /settings.json; they win over the lines above.
try:
    with open("/settings.json") as f:
        _s = json.load(f)
except (OSError, ValueError):
    _s = {}
WIFI_SSID = _s.get("wifi_ssid") or WIFI_SSID
WIFI_PASS = _s.get("wifi_pass") or WIFI_PASS

led = Pin("LED", Pin.OUT)
bus = ds18x20.DS18X20(onewire.OneWire(Pin(22)))
roms = bus.scan()
print("DS18B20 probes found:", len(roms))
hot_rom = room_rom = None

t0 = time.ticks_ms()
times, hot, room = [], [], []


def read_both():
    """Returns (hot, room) in deg C; room is None with only one probe."""
    global hot_rom, room_rom
    bus.convert_temp()
    time.sleep_ms(750)
    vals = [bus.read_temp(r) for r in roms]
    if hot_rom is None and vals:
        # The warmer probe at the start is the one in the hot water.
        i = vals.index(max(vals))
        hot_rom = roms[i]
        room_rom = roms[1 - i] if len(roms) > 1 else None
        print("Hot-water probe:", hot_rom.hex(), " room probe:", room_rom.hex() if room_rom else "none")
    h = vals[roms.index(hot_rom)]
    r = vals[roms.index(room_rom)] if room_rom else None
    return h, r


def fit():
    """(k per minute, T0 fitted, Ta used, points used) or None."""
    if not hot:
        return None
    ta = sum(room) / len(room) if room and room[0] is not None else min(hot) - 1
    xs, ys = [], []
    for t, h in zip(times, hot):
        if h - ta >= MIN_DIFF_C:
            xs.append(t / 60)
            ys.append(math.log(h - ta))
    n = len(xs)
    if n < 5:
        return None
    sx, sy = sum(xs), sum(ys)
    sxx = sum(x * x for x in xs)
    sxy = sum(x * y for x, y in zip(xs, ys))
    den = n * sxx - sx * sx
    if den == 0:
        return None
    slope = (n * sxy - sx * sy) / den
    icpt = (sy - slope * sx) / n
    return -slope, ta + math.exp(icpt), ta, n


def csv():
    yield "t_s,hot_c,room_c\n"
    for t, h, r in zip(times, hot, room):
        yield "%d,%.2f,%s\n" % (t, h, "" if r is None else "%.2f" % r)


PAGE = """<!doctype html><meta name=viewport content="width=device-width"><title>Cooling curve</title>
<style>body{font-family:sans-serif;margin:16px}canvas{width:100%;max-width:720px;border:1px solid #ccc}</style>
<h2>Newton's law of cooling</h2><p id=s>Loading...</p><canvas id=c width=720 height=360></canvas>
<p><a href=/data.csv>Download CSV</a> &middot; <a href=/reset>Start a new run</a></p>
<script>
async function u(){const d=await (await fetch('/data.json')).json();const c=document.getElementById('c'),g=c.getContext('2d');
g.clearRect(0,0,720,360);if(!d.t.length){return}const all=d.hot.concat(d.room.filter(x=>x!==null));
const lo=Math.floor(Math.min(...all))-2,hi=Math.ceil(Math.max(...all))+2,tm=Math.max(60,d.t[d.t.length-1]);
const X=t=>40+t/tm*670,Y=v=>340-(v-lo)/(hi-lo)*330;g.fillStyle='#888';g.fillText(hi+' C',2,14);g.fillText(lo+' C',2,340);
g.fillText((tm/60).toFixed(1)+' min',660,355);
const line=(a,col)=>{g.strokeStyle=col;g.beginPath();a.forEach((v,i)=>{if(v===null)return;i?g.lineTo(X(d.t[i]),Y(v)):g.moveTo(X(d.t[i]),Y(v))});g.stroke()};
line(d.hot,'#e11d48');line(d.room,'#2563eb');
if(d.k){g.strokeStyle='#16a34a';g.setLineDash([5,4]);g.beginPath();for(let t=0;t<=tm;t+=tm/60){const v=d.ta+(d.t0-d.ta)*Math.exp(-d.k*t/60);t?g.lineTo(X(t),Y(v)):g.moveTo(X(t),Y(v))}g.stroke();g.setLineDash([])}
document.getElementById('s').textContent='Hot '+d.hot[d.hot.length-1].toFixed(1)+' C, room '+(d.room[d.room.length-1]??'-')+' C, '+d.t.length+' points'+(d.k?', k = '+d.k.toFixed(4)+' per min (fit on '+d.n+' points)':'');}
u();setInterval(u,5000);
</script>"""


def data_json():
    f = fit()
    d = {"t": times, "hot": hot, "room": room}
    if f:
        d.update(k=f[0], t0=f[1], ta=f[2], n=f[3])
    return json.dumps(d)


def serve(server):
    global t0, full
    try:
        conn, _ = server.accept()
    except OSError:
        return
    try:
        conn.settimeout(2)
        req = conn.recv(512).decode()
        path = req.split(" ")[1] if " " in req else "/"
        if path.startswith("/data.json"):
            conn.send("HTTP/1.0 200 OK\r\nContent-Type: application/json\r\n\r\n")
            conn.send(data_json())
        elif path.startswith("/data.csv"):
            conn.send("HTTP/1.0 200 OK\r\nContent-Type: text/csv\r\nContent-Disposition: attachment; filename=cooling.csv\r\n\r\n")
            for line in csv():
                conn.send(line)
        elif path.startswith("/reset"):
            times.clear()
            hot.clear()
            room.clear()
            t0 = time.ticks_ms()
            full = False
            print("New run started")
            conn.send("HTTP/1.0 303 See Other\r\nLocation: /\r\n\r\n")
        else:
            conn.send("HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\n")
            conn.send(PAGE)
    except OSError:
        pass
    finally:
        conn.close()


server = None
if WIFI_SSID == "your-wifi":
    print("Web page off - no Wi-Fi name set. Flash from flashready.in or type it into main.py. Logging to the shell only.")
else:
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect(WIFI_SSID, WIFI_PASS)
    for _ in range(40):
        if wlan.isconnected():
            break
        time.sleep(0.5)
    if wlan.isconnected():
        print("Open http://%s/ on a phone or laptop on the same Wi-Fi" % wlan.ifconfig()[0])
        server = socket.socket()
        server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        server.bind(socket.getaddrinfo("0.0.0.0", 80)[0][-1])
        server.listen(2)
        server.settimeout(0.2)
    else:
        print("Wi-Fi not found - logging to the shell only")

if not roms:
    print("No DS18B20 found - check DQ on GP22, the 4.7 kOhm pull-up to 3V3, VDD and GND")
    while True:
        led.toggle()
        time.sleep(0.2)

print("t_s,hot_c,room_c")
next_sample = time.ticks_ms()
full = False
while True:
    if server:
        serve(server)
    if time.ticks_diff(time.ticks_ms(), next_sample) < 0:
        continue
    next_sample = time.ticks_add(next_sample, SAMPLE_S * 1000)
    led.on()
    h, r = read_both()
    led.off()
    if len(times) < MAX_POINTS:
        times.append(time.ticks_diff(time.ticks_ms(), t0) // 1000)
        hot.append(round(h, 2))
        room.append(None if r is None else round(r, 2))
        print("%d,%.2f,%s" % (times[-1], h, "" if r is None else "%.2f" % r))
        f = fit()
        if f and len(times) % 12 == 0:  # once a minute
            print("fit: k = %.4f /min, T0 = %.1f C, Ta = %.1f C, %d points" % f)
    elif not full:
        full = True
        print("Run full (1 hour) - open /reset to start again")

Compiled for picow on micropython (mpy-cross v1.29.0) · 3 Nov 2026

How it works

Newton’s law of cooling says that a hot object cools at a rate proportional to how much hotter it is than its surroundings. That gives an exponential curve, T(t) = Tₐ + (T₀ − Tₐ)·e^(−kt). In the school lab it is usually a thermometer, a stopwatch and a lot of writing; here two digital probes record every 5 seconds and the Pico W draws the curve live on any phone on the same Wi-Fi.

How it works

Two DS18B20 probes share one data wire (the 1-Wire bus on GP22). Each DS18B20 has a unique 64-bit address, so the Pico W can read them separately. The one that is warmer at power-up is taken as the hot-water probe; the other measures the room air Tₐ.

Every 5 seconds the Pico W reads both probes and stores the values (up to one hour). It serves a small web page that redraws the graph every 5 seconds: the red line is the water, the blue line the room, and the dashed green line the fitted law.

The fit. Take the log of the law: ln(T − Tₐ) = ln(T₀ − Tₐ) − k·t. Plotting ln(T − Tₐ) against time gives a straight line with slope −k. The Pico W fits that line by least squares, leaving out points less than 2 °C above room temperature (there, sensor noise dominates). It shows k per minute; the shell prints it once a minute too.

/data.csv downloads every point for your lab file; /reset starts a new run.

What to show at the exhibition

Run two cups side by side on two Pico Ws (or one after the other): one open, one with a thermocol lid. Visitors watch the curves separate within a few minutes. Show the fitted k for each and ask: “Which cup keeps tea hot longer, and by how much?”

Questions the examiner may ask

  • Why is the curve exponential? The rate of heat loss is proportional to the temperature difference. As the water cools, the difference shrinks, so it cools more and more slowly.
  • Why take the logarithm? It turns the exponential into a straight line, and the slope of a straight line is easy to fit and to understand.
  • Why measure room temperature too? Tₐ is in the law. A wrong Tₐ bends the log plot and changes k.
  • Does the law hold exactly? Roughly, for small temperature differences. At high temperatures, evaporation and radiation make the early cooling faster than the simple law predicts — you can often see the first few minutes stray from the fitted line.

Cost in India

About ₹1,100. Waterproof DS18B20 probes cost ₹100–150 each; the Pico W ₹600–650. Without Wi-Fi, the same build works as a USB data logger with Thonny.

Troubleshooting

“No DS18B20 found”

Check the pull-up between DQ and 3V3, and the probe colours: usually red = VDD, black = GND, yellow (or white) = DQ. Some cheap probes use other colours — check the seller’s sheet.

A probe reads 85.0 °C at the start

85 °C is the DS18B20’s power-on value, returned if a reading is taken before a conversion finishes. The sketch waits 750 ms per conversion; if you still see it, the supply is weak — use short wires.

The page doesn’t open

The phone or laptop must be on the same Wi-Fi as the Pico W. Use the address printed in Thonny’s shell, with http:// (not https://).

k changes a lot between runs

It depends on the cup, the amount of water, a lid, draughts and stirring. Keep everything the same and change one thing per run — that is the experiment.

Upgrades

  • Compare cups: steel, glass, paper and a flask — which has the smallest k?
  • Add a third probe and cool two cups at once (one with a lid).
  • Save each run to the Pico’s flash as a CSV so you can download it later.
  • Show the live temperature on an SSD1306 OLED for use without a phone.

↑↓ to move · Enter to open