Handboek · hoofdstuk 30Datalogging en communicatie
Een logboek dat zichzelf schrijft: van bits op een draad tot een grafiek in Python
De klimaatmodule meet om de twee seconden temperatuur, vochtigheid, licht en energie. Zonder geheugen is elke meting na twee seconden vergeten. In dit hoofdstuk bouw je de datalogger: de Mega praat met de computer over een seriële verbinding, leest sensoren over de I²C-bus, kent de datum dankzij een klokchip en schrijft elke minuut een regel op een SD-kaart, in het CSV-formaat van hoofdstuk 28. Daarna lees je een hele week in met Python, schoon je ze op en maak je er grafieken van die vertellen wat de module deed.

- Je kan uitleggen hoe een teken over een seriële verbinding gaat (startbit, databits, stopbit) en rekenen met de baudrate.
- Je kan een eenvoudig protocol ontwerpen (één CSV-regel per meting, commentaarregels, een kop) en de regels in Python inlezen met pyserial.
- Je kan I²C-sensoren aansluiten en uitlezen: adressen, SDA en SCL, pull-ups, en je kan de stijgtijd bij een lange kabel narekenen.
- Je kan de bytes van een sensor omrekenen met de datasheet en controleren met een CRC.
- Je kan uitleggen waarom millis() geen klok is, en een RTC (DS3231) lezen en zetten.
- Je kan metingen met een tijdstempel op een SD-kaart bewaren, weet waarom flush() of close() nodig is, en kan uitrekenen hoe lang een kaart volstaat.
- Je kan logbestanden in Python inlezen, opschonen (lege cellen, onmogelijke waarden, ontbrekende minuten), samenvatten per dag en in een grafiek zetten.
- Je kan het geheugengebruik en het eigen verbruik van een datalogger schatten.
Zestig jaar meten op een vulkaan
In maart 1958 begon de Amerikaanse scheikundige Charles David Keeling met metingen van koolstofdioxide in de lucht, op de flank van de vulkaan Mauna Loa op Hawaï: ver van steden en bossen, zodat hij de lucht van de hele planeet meet en niet die van één fabriek. Na een paar jaar zag hij twee dingen. Elk jaar zakt het CO₂-gehalte in de lente en de zomer, als de planten op het noordelijk halfrond groeien, en stijgt het weer in de herfst. En van jaar tot jaar gaat het omhoog: van ongeveer 315 ppm in 1958 naar meer dan 420 ppm vandaag. Die grafiek heet de Keeling-curve.
Het geheim van de curve is niet één slimme meting, maar een logboek dat nooit stopt: elke meting met een datum en een uur, elke ijking genoteerd, elke onderbreking verklaard. Op 27 november 2022 barstte de Mauna Loa zelf uit; de lava sneed de weg en de stroomkabel naar het observatorium af. Vanaf 8 december werd er tijdelijk gemeten op de naburige Maunakea, en in de gegevens staat precies wanneer en waar. Zonder die notities zou niemand de meetreeksen van voor en na de uitbarsting kunnen vergelijken.
De datalogger van de klimaatmodule is veel kleiner, maar hij moet hetzelfde kunnen: meten op vaste tijden, elke meting een tijdstempel geven, alles bewaren in een vorm die een programma kan lezen, en eerlijk tonen wat er ontbreekt.
In hoofdstuk 28 kreeg je een CSV-bestand en analyseerde je het. Hier maak je het bestand zelf, van de sensor tot de kaart, en zie je waarom lege cellen, tijdstempels en een vaste vorm zo belangrijk zijn.

Wat je al weet, en het plan voor de logger
| wat je al kan | waar | wat er nu bij komt |
|---|---|---|
| Serial.print voor debuggen, de seriële plotter | hoofdstuk 29 | wat er op de draad gebeurt, en een protocol waar een programma mee overweg kan |
| de SHT31 met een bibliotheek | hoofdstuk 29 | I²C zelf: adressen, pull-ups, bytes en een controlegetal |
| millis() en taken | hoofdstuk 29 | een echte klok met datum |
| CSV inlezen met csv.DictReader, gemiddelden, matplotlib | hoofdstuk 28 | het bestand zelf maken, en duizenden regels opschonen |
| binair en hexadecimaal, bits en bytes | hoofdstuk 25 | BCD, de bits van een teken, twee bytes tot één getal |
| RC-tijd τ = R · C | hoofdstuk 24 | waarom een lange I²C-kabel sterkere pull-ups vraagt |
De logger van de klimaatmodule heeft vier soorten ingangen en drie uitgangen. De sensoren voor temperatuur, vocht, energie en tijd hangen aan de I²C-bus: twee draden voor allemaal samen. De lichtsensor blijft op A1. De Mega meet om de 2 s, telt de metingen van één minuut samen en schrijft dan één regel: op de SD-kaart, en tegelijk over de seriële poort naar de computer, waar Python ze kan lezen.
De regel heeft het formaat van hoofdstuk 28, met twee kolommen extra achteraan:
tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V
Omdat je programma's uit hoofdstuk 28 de kolommen bij hun naam opvragen (rij["zon_Wh"]), werken ze zonder aanpassing op de nieuwe bestanden. Alleen staat er nu één regel per minuut, in plaats van één per drie uur.
Seriële communicatie: bits op één draad
Een seriële verbinding stuurt de bits na elkaar over één draad: de uitgang TX (transmit) van de ene kant hangt aan de ingang RX (receive) van de andere, en omgekeerd; de massa (GND) is gedeeld. Er is geen klokdraad. Beide kanten spreken vooraf een snelheid af, de baudrate: bij 9600 baud duurt één bit . Het blok in de Mega dat dit doet, heet een UART. De Mega heeft er vier: Serial (pin 0 en 1, via de USB-chip naar de computer) en Serial1 tot Serial3 (pin 14 tot 19).
In rust is de lijn hoog (5 V). Een teken begint met een startbit: de lijn gaat één bittijd laag, zodat de ontvanger weet dat er iets komt en zijn klok gelijkzet. Dan volgen de acht databits, het minst belangrijke bit eerst (b0), en ten slotte een stopbit (hoog). Dat heet 8N1: 8 databits, geen pariteitsbit, 1 stopbit. Eén teken kost dus 10 bittijden.
Het teken '2' is in ASCII 50, of 0x32 = 0011 0010. Van b0 naar b7 is dat 0, 1, 0, 0, 1, 1, 0, 0: zo zie je het in het tijdsdiagram.
Staat de seriële monitor op 9600 en de Mega op 115 200, dan meet de monitor elf keer te trage bits: je ziet onzin. Er is geen melding, alleen rare tekens. Kijk dan eerst naar de baudrate.
9600 of 115 200 baud: hoeveel tijd kost een regel?
De logger stuurt elke minuut een regel van 48 tekens, plus \r\n. klimaat.ino stuurt elke minuut ook een statusregel van ongeveer 110 tekens. Hoe lang duurt het versturen bij 9600 en bij 115 200 baud? Wat doet Serial.print ondertussen?
- 8N1: 10 bits per teken
- zendbuffer van Serial: 64 tekens
- tijd per regel
- hoe lang print() wacht
- 1Tekens per seconde. 9600 baud / 10 bits = 960 tekens per seconde; bij 115 200 baud 11 520.
- 2De logregel. 50 tekens: bij 9600, bij 115 200.
- 3De buffer.
Serial.printzet tekens in een buffer van 64 en keert meteen terug; de UART stuurt ze op de achtergrond. Past een regel in de buffer, dan wacht print() niet. Van de statusregel van 110 tekens moeten er 46 wachten tot er plaats is: bij 9600 baud , bij 115 200 . - 4Overzicht:
baudrate tijd per bit tekens per seconde regel van 48 tekens + \r\n 9600 104,2 µs 960 52,08 ms 19 200 52,1 µs 1920 26,04 ms 57 600 17,4 µs 5760 8,68 ms 115 200 8,7 µs 11 520 4,34 ms
Een eigen protocol: één regel per meting
Een protocol is een afspraak over de vorm van de berichten. Voor de logger kiezen we het eenvoudigste dat een programma aan de andere kant zeker begrijpt:
- elke meting is één regel, afgesloten met
\r\n(println); de regelovergang is het einde van het bericht; - de velden staan gescheiden door komma's, met een decimale punt;
- de eerste gewone regel is de kop met de namen van de kolommen;
- een regel die met # begint, is commentaar of een melding: een programma slaat hem over;
- geen geldige meting: de cel blijft leeg, nooit 0 of −999.
// serieel_csv.ino - om de 2 s een meting als CSV-regel naar de computer#include <Wire.h>#include <Adafruit_SHT31.h>#include "pinnen.h" Adafruit_SHT31 sht = Adafruit_SHT31();unsigned long vorige = 0; void setup() { Serial.begin(115200); sht.begin(0x44); Serial.println("# klimaatmodule: een meting om de 2 s"); // regels met # zijn commentaar Serial.println("tijd_s,temperatuur_C,vocht_pct,licht_ruw"); // de kop: namen van de kolommen} void loop() { unsigned long nu = millis(); if (nu - vorige >= 2000) { vorige += 2000; float t, rv; bool ok = sht.readBoth(&t, &rv); // false als de sensor niet (goed) antwoordde Serial.print(nu / 1000); Serial.print(','); if (ok) Serial.print(t, 2); // geen geldige meting: de cel blijft leeg Serial.print(','); if (ok) Serial.print(rv, 1); Serial.print(','); Serial.println(analogRead(LDR)); // println: de regel eindigt op \r\n }}Wat de computer ontvangt (in de simulatie zit het stekkertje van de SHT31 tussen 7 en 9 s even los):
# klimaatmodule: een meting om de 2 s tijd_s,temperatuur_C,vocht_pct,licht_ruw 2,23.50,64.0,477 4,23.61,63.8,478 6,23.70,63.6,479 8,,,480 10,23.90,63.2,481 12,24.01,63.0,482
De regel van 8 s heeft twee lege cellen: de meting mislukte, en dat staat er eerlijk. Een 0 zou een programma als een echte temperatuur van 0 °C lezen.
De regels inlezen in Python
Aan de kant van de computer leest Python de regels met de bibliotheek pyserial (installeren met pip install pyserial; je importeert ze als serial). Welke poort de Mega heeft, zie je in de Arduino-IDE (Tools, Port) of met python -m serial.tools.list_ports. Sluit eerst de seriële monitor: een poort kan maar door één programma tegelijk open zijn.
# lees_serieel.py - metingen van de Mega over de seriële poort inlezen en bewarenimport csvfrom datetime import datetime import serial # eenmalig installeren: pip install pyserial poort = serial.Serial("COM3", 115200, timeout=5) # Linux: "/dev/ttyACM0", macOS: "/dev/cu.usbmodem…"kop = Nonewith open("metingen.csv", "w", newline="", encoding="utf-8") as bestand: schrijver = csv.writer(bestand) for _ in range(9): # voor de test negen regels; echt meten: while True regel = poort.readline().decode("utf-8", errors="replace").strip() velden = regel.split(",") if regel.startswith("#"): print("commentaar:", regel) elif velden[0] == "tijd_s": # de kop: de namen van de kolommen kop = velden schrijver.writerow(["pc_tijd"] + kop) elif kop is None or len(velden) != len(kop): print("overgeslagen:", regel) else: nu = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # de computer kent de datum wel schrijver.writerow([nu] + velden) print(nu, velden)poort.close()Uitvoer:
overgeslagen: 63.8,478 commentaar: # klimaatmodule: een meting om de 2 s 2026-10-12 10:00:00 ['2', '23.50', '64.0', '477'] 2026-10-12 10:00:02 ['4', '23.61', '63.8', '478'] 2026-10-12 10:00:04 ['6', '23.70', '63.6', '479'] 2026-10-12 10:00:06 ['8', '', '', '480'] 2026-10-12 10:00:08 ['10', '23.90', '63.2', '481'] 2026-10-12 10:00:10 ['12', '24.01', '63.0', '482']
readline() wacht tot er een hele regel binnen is (tot \n) en geeft bytes; decode maakt er tekst van, strip haalt \r\n weg. De eerste regel is een restje van vóór het openen van de poort: hij heeft te weinig velden en er is nog geen kop, dus hij wordt overgeslagen. De computer zet er zijn eigen datum en tijd voor, want serieel_csv.ino kent alleen de seconden sinds de start.
In dit boek praat het programma met een nagebootste poort die de regels teruggeeft van de simulatie op het vorige blad, en een klok die om de 2 s verspringt. Op je computer gebruik je de echte poort, precies zoals in de code. Let op: het openen van de poort herstart de Mega (de USB-chip trekt de reset even laag); de eerste 2 s komt er niets.
I²C: twee draden, veel sensoren
De Mega heeft maar een handvol seriële poorten, en elke sensor een eigen poort geven lukt niet. I²C (spreek uit: i-kwadraat-c) is een bus: tot ongeveer honderd chips hangen aan dezelfde twee draden, SDA (data) en SCL (klok). De Mega is de master: hij maakt de klok en begint elk gesprek. De sensoren zijn slaves: ze antwoorden alleen als ze aangesproken worden.
Elke chip heeft een adres van 7 bits. Een gesprek gaat zo: een START (SDA zakt terwijl SCL hoog is), dan het adres met één bit erachter (0 = schrijven, 1 = lezen), dan geeft de aangesproken chip een ACK (hij trekt SDA laag bij de negende klokpuls). Daarna volgen de databytes, elk met een ACK, en een STOP (SDA stijgt terwijl SCL hoog is). Antwoordt er niemand op een adres, dan blijft SDA hoog: een NACK, en Wire.endTransmission() geeft 2 terug in plaats van 0.
De chips maken een lijn nooit actief hoog: ze kunnen ze alleen laag trekken of loslaten (open drain). Een pull-upweerstand naar 5 V trekt de losgelaten lijn weer hoog. Zo kunnen twee chips die tegelijk iets zeggen, nooit kortsluiting maken. De Mega heeft 10 kΩ pull-ups op pin 20 en 21.
| adres | chip | wat | hoe |
|---|---|---|---|
| 0x40 | INA226 | stroom en spanning van de zonnepanelen | A0 en A1 aan GND |
| 0x41 | INA226 | stroom en spanning naar de verbruikers | A0 aan VS |
| 0x44 | SHT31 | temperatuur en vochtigheid van de lucht | ADDR aan GND (0x45 aan VDD) |
| 0x57 | AT24C32 | geheugenchipje op het RTC-moduletje (niet gebruikt) | vast op het moduletje |
| 0x68 | DS3231 | klok met datum | vast |
De I²C-bus van de klimaatmodule
Vier chips op twee draden, en de SD-kaart apart op de SPI-bus. De INA226's en de klok zitten in de elektronicakast naast de Mega; de SHT31 zit in de sensorkast in de kweekruimte, 3 m verder.
- 1De Mega: master
Maakt de klok op SCL (100 kHz) en begint elk gesprek. De bibliotheek Wire doet het werk; SDA is pin 20, SCL pin 21.
- 2Pull-ups
Houden SDA en SCL hoog als niemand ze laag trekt. Door de lange kabel naar de sensorkast komen er 2,2 kΩ bij de 10 kΩ van de Mega (volgend voorbeeld).
- 3INA226 zon (0x40)
Meet de spanning over een shunt in de leiding van de laadregelaar: de zonnestroom.
- 4INA226 verbruik (0x41)
Dezelfde chip, een ander adres: pin A0 aan de voeding in plaats van aan GND.
- 5SHT31 (0x44)
Temperatuur en vocht van de kaslucht, in de sensorkast op 3 m kabel.
- 6DS3231 (0x68)
De klok. Op het moduletje zit ook een geheugenchipje op 0x57, dat de scanner ook vindt.
- 7SD-kaart op SPI
Niet op I²C: de kaart praat SPI, via pin 50 tot 53. CS (53) kiest de kaart.
- 8Eén bus, vier chips
Wat er op de bus gebeurt, horen alle chips; alleen de chip met het juiste adres antwoordt.
Wie zit er op de bus? De I²C-scanner
Het eerste programma dat je op een nieuwe I²C-bus draait: het probeert elk adres van 1 tot 126 en meldt waar er een ACK komt. Zo zie je meteen of een sensor goed aangesloten is, en welk adres hij echt heeft (dat staat niet altijd juist op het bordje).
// i2c_scanner.ino - welke adressen antwoorden op de I2C-bus?#include <Wire.h> void setup() { Serial.begin(115200); Wire.begin(); int gevonden = 0; for (byte adres = 1; adres < 127; adres++) { Wire.beginTransmission(adres); // alleen het adres sturen, geen data if (Wire.endTransmission() == 0) { // 0 = er kwam een ACK: hier zit een apparaat Serial.print("apparaat op 0x"); if (adres < 16) Serial.print('0'); Serial.println(adres, HEX); gevonden++; } } Serial.print(gevonden); Serial.println(" apparaten gevonden");} void loop() {}Uitvoer met de bus van de klimaatmodule:
apparaat op 0x40 apparaat op 0x41 apparaat op 0x44 apparaat op 0x57 apparaat op 0x68 5 apparaten gevonden
Vijf antwoorden voor vier moduletjes: het klokmoduletje heeft ook een geheugenchip (0x57). Vindt de scanner niets, dan zijn het bijna altijd de draden: SDA en SCL verwisseld, geen gemeenschappelijke GND, of geen voeding op het bordje. Vindt hij een chip op een onverwacht adres, pas dan je code aan of verander de adrespin.
Pull-ups voor een kabel van 3 m
De SHT31 zit in de sensorkast, 3 m van de Mega. De kabel heeft tussen twee aders een capaciteit van ongeveer 100 pF per meter; de vier chips en de Mega voegen elk ongeveer 10 pF toe. Werkt de bus op 100 kHz met alleen de 10 kΩ van de Mega? Zo niet: welke extra pull-up kies je?
- I²C-norm bij 100 kHz: stijgtijd (van 30 % tot 70 % van 5 V) hoogstens 1000 ns
- een chip moet 3 mA kunnen wegtrekken bij 0,4 V
- 10 kΩ op de Mega
- de stijgtijd met 10 kΩ
- een geschikte extra weerstand
- 1Capaciteit. ; reken met 340 pF.
- 2Stijgen is een RC-laadcurve (hoofdstuk 24): . Van 30 % naar 70 % duurt .
- 3Met 10 kΩ: , : bijna drie keer te traag. De bus haalt dan geen 100 kHz; een bit wordt verkeerd gelezen of de chip antwoordt niet.
- 4Grenzen voor R. Hoogstens . Minstens , anders kan een chip de lijn niet laag genoeg trekken.
- 5Keuze. 2,2 kΩ extra, parallel met de 10 kΩ: . Dan en .
De SHT31 zonder bibliotheek
In hoofdstuk 29 las je de SHT31 met readBoth. Zo doet de bibliotheek het, stap voor stap volgens de datasheet:
| uit de datasheet (SHT3x-DIS) | waarde |
|---|---|
| adres | 0x44 (ADDR aan GND) of 0x45 (ADDR aan VDD) |
| commando één meting, hoge herhaalbaarheid, zonder clock stretching | 0x24 0x00 |
| duur van die meting | typisch 12,5 ms, hoogstens 15 ms |
| antwoord | 6 bytes: T (2), CRC, RV (2), CRC; eerst de hoge byte |
| omrekening | (°C), (%) |
| CRC | CRC-8, veelterm 0x31, beginwaarde 0xFF; CRC(0xBE 0xEF) = 0x92 |
| nauwkeurigheid SHT31 (typisch) | ±0,2 °C, ±2 % RV |
| voeding, stroom | 2,15 tot 5,5 V; 0,6 mA tijdens een meting, 0,2 µA in rust |
Het programma stuurt het commando, wacht 20 ms, leest zes bytes, controleert beide CRC's en rekent om. Een CRC is een controlegetal dat de sensor uit de twee databytes berekent; de Mega rekent het na. Valt er onderweg een bit om (storing op de lange kabel), dan klopt het niet meer en gooit het programma de meting weg.
// sht31_zelf.ino - de SHT31 uitlezen zonder bibliotheek, stap voor stap volgens de datasheet#include <Wire.h> const byte ADRES = 0x44; // pin ADDR aan GND (aan VDD: 0x45)float luchtT = NAN, luchtRV = NAN; byte crc8(byte a, byte b) { // CRC-8 over twee bytes: veelterm 0x31, beginwaarde 0xFF byte crc = 0xFF; byte data[2] = {a, b}; for (int i = 0; i < 2; i++) { crc ^= data[i]; for (int bit = 0; bit < 8; bit++) { if (crc & 0x80) crc = (crc << 1) ^ 0x31; else crc = crc << 1; } } return crc;} void printHex(byte b) { if (b < 16) Serial.print('0'); Serial.print(b, HEX); Serial.print(' ');} bool leesSht31() { Wire.beginTransmission(ADRES); Wire.write(0x24); // commando 0x2400: één meting, hoge herhaalbaarheid, Wire.write(0x00); // zonder 'clock stretching' if (Wire.endTransmission() != 0) { // geen ACK: sensor los of verkeerd adres Serial.println("geen antwoord op 0x44"); return false; } delay(20); // de meting duurt tot 15 ms (datasheet) if (Wire.requestFrom(ADRES, (byte)6) != 6) { Serial.println("minder dan 6 bytes ontvangen"); return false; } byte b[6]; for (int i = 0; i < 6; i++) b[i] = Wire.read(); Serial.print("bytes: "); for (int i = 0; i < 6; i++) printHex(b[i]); if (crc8(b[0], b[1]) != b[2] || crc8(b[3], b[4]) != b[5]) { Serial.println(" CRC fout: meting weggegooid"); return false; } unsigned int ruwT = ((unsigned int)b[0] << 8) | b[1]; // twee bytes tot één getal van 16 bit unsigned int ruwRV = ((unsigned int)b[3] << 8) | b[4]; luchtT = -45.0 + 175.0 * ruwT / 65535.0; // formules uit de datasheet luchtRV = 100.0 * ruwRV / 65535.0; Serial.print(" -> "); Serial.print(ruwT); Serial.print(" en "); Serial.print(ruwRV); Serial.print(" -> "); Serial.print(luchtT, 2); Serial.print(" C, "); Serial.print(luchtRV, 1); Serial.println(" %"); return true;} void setup() { Serial.begin(115200); Wire.begin(); Serial.print("test CRC(BE EF) = "); // het voorbeeld uit de datasheet: 92 Serial.println(crc8(0xBE, 0xEF), HEX);} void loop() { leesSht31(); delay(2000); // testschets: hier mag het}Uitvoer: eerst de test met het voorbeeld uit de datasheet, dan drie metingen; bij de derde valt een bit om in de tweede byte, bij de vierde is de sensor losgekoppeld:
test CRC(BE EF) = 92 bytes: 64 29 59 9C ED F5 -> 25641 en 40173 -> 23.47 C, 61.3 % bytes: 64 31 A3 9C AB 6E -> 25649 en 40107 -> 23.49 C, 61.2 % bytes: 64 3C 2B 9C 6A 18 CRC fout: meting weggegooid geen antwoord op 0x44
Narekenen: 0x6429 = 25 641, en °C. Twee bytes worden één getal met (hoog << 8) | laag: de hoge byte schuift acht plaatsen naar links, zoals 0x64 · 256. Wie de fout negeert, krijgt bij een losse sensor 0xFF 0xFF (Wire.read() geeft −1): dat is precies 130,0 °C en 100,0 %. Je vindt die waarden verderop terug in echte logbestanden.
Tijd: waarom millis() geen klok is
Een meting zonder tijdstip is weinig waard. millis() lijkt een klok, maar volstaat niet:
- ze kent geen datum, alleen de tijd sinds de start;
- na een stroomonderbreking of reset begint ze weer bij 0;
- ze is onnauwkeurig: de 16 MHz van de Mega komt van een keramische resonator die tot ±0,5 % mag afwijken, in het slechtste geval ruim 7 minuten per dag, en ze verloopt met de temperatuur;
- ze loopt na 49,7 dagen over.
Een RTC (real-time clock) lost dat op. De DS3231 heeft een eigen kwartskristal met temperatuurcorrectie: ±2 ppm tussen 0 en 40 °C, hoogstens ongeveer een minuut per jaar. Een knoopcel houdt hem jaren aan de gang als de module geen stroom heeft (hij verbruikt dan ongeveer een microampère). De Mega leest hem via I²C op adres 0x68. De tijd staat in registers 0x00 tot 0x06 in BCD (binair gecodeerd decimaal): elk cijfer apart in 4 bits, zodat 34 minuten als 0x34 = 0011 0100 staat.
Welke tijd? In België springt de klok twee keer per jaar een uur. In het najaar komt 2:30 dan twee keer voor, en in de lente ontbreekt een uur: een logboek met gewone kloktijd heeft dubbele en ontbrekende regels. De logger loopt daarom het hele jaar op wintertijd (UTC+1). In de zomer loopt hij dus een uur achter op je horloge: 12:00 in het logboek is 13:00 zomertijd.
RTClib kan de klok zetten op het moment van compileren: rtc.adjust(DateTime(F(__DATE__), F(__TIME__))). Dat is de tijd van je computer, met zomertijd en een paar seconden te laat. logger.ino laat je de klok zetten met een opdracht vanaf de computer; opdracht 30.14 werkt dat uit.
De klok lezen: DS3231 en BCD
Twee manieren: met de bibliotheek RTClib (Adafruit, via de bibliotheekbeheerder), en zelf, met de registers. Het patroon 'registerwijzer zetten, dan bytes lezen' gebruiken ook de INA226 en veel andere I²C-chips.
// klok.ino - de DS3231 lezen: met RTClib, en daarna de registers zelf (BCD)#include <Wire.h>#include <RTClib.h> RTC_DS3231 rtc; byte bcdNaarGetal(byte b) { // 0x34 -> 3 * 10 + 4 = 34 return (b >> 4) * 10 + (b & 0x0F);} void printTwee(int x) { if (x < 10) Serial.print('0'); Serial.print(x);} void setup() { Serial.begin(115200); Wire.begin(); if (!rtc.begin()) { Serial.println("# geen DS3231 op adres 0x68"); return; } if (rtc.lostPower()) Serial.println("# de klok heeft stilgestaan: stel ze opnieuw in"); DateTime nu = rtc.now(); // 1. met de bibliotheek Serial.print("RTClib: "); Serial.print(nu.year()); Serial.print('-'); printTwee(nu.month()); Serial.print('-'); printTwee(nu.day()); Serial.print(' '); printTwee(nu.hour()); Serial.print(':'); printTwee(nu.minute()); Serial.print(':'); printTwee(nu.second()); Serial.println(); Wire.beginTransmission(0x68); // 2. zelf: registerwijzer op 0x00 ... Wire.write(0x00); Wire.endTransmission(); Wire.requestFrom(0x68, 7); // ... en zeven registers lezen byte r[7]; Serial.print("registers:"); for (int i = 0; i < 7; i++) { r[i] = Wire.read(); Serial.print(" "); if (r[i] < 16) Serial.print('0'); Serial.print(r[i], HEX); } Serial.println(); Serial.print("zelf: 20"); printTwee(bcdNaarGetal(r[6])); // jaar Serial.print('-'); printTwee(bcdNaarGetal(r[5] & 0x1F)); // maand (bit 7 is de eeuw) Serial.print('-'); printTwee(bcdNaarGetal(r[4])); // dag Serial.print(' '); printTwee(bcdNaarGetal(r[2] & 0x3F)); // uur (24-uursmodus) Serial.print(':'); printTwee(bcdNaarGetal(r[1])); Serial.print(':'); printTwee(bcdNaarGetal(r[0] & 0x7F)); Serial.println();} void loop() {}Uitvoer:
RTClib: 2026-10-12 12:34:56 registers: 56 34 12 01 12 10 26 zelf: 2026-10-12 12:34:56
bcdNaarGetal neemt de hoge helft (b >> 4, de tientallen) en de lage helft (b & 0x0F, de eenheden). In twee registers staan extra bits die je eerst wegmaskert: bit 7 van de maand is de eeuw, bit 6 van de uren kiest de 12-uursmodus. Register 3 is de dag van de week (RTClib: maandag = 1). RTClib doet dit allemaal voor je en geeft een DateTime.
De SD-kaart: SPI, bestanden en flush
Een SD-kaart praat SPI: een snelle bus met een klokdraad (SCK), een draad van de Mega naar de kaart (MOSI), een terug (MISO) en een selectiedraad (CS) per apparaat. Op de Mega zijn dat pin 52, 51, 50 en 53. Een kaart werkt op 3,3 V: gebruik een moduletje met een spanningsregelaar en een niveauomzetter. SPI werk je in Expert (hoofdstuk E37) verder uit; de bibliotheek SD regelt het.
De bibliotheek leest kaarten met een FAT16- of FAT32-bestandssysteem (formatteer ze met de SD Card Formatter van de SD Association) en kent alleen korte 8.3-namen: hoogstens acht tekens, een punt en drie. 2026-10-12.CSV mislukt, 20261012.CSV lukt. De logger maakt één bestand per dag, in een map LOG: een map omdat de hoofdmap van een FAT16-kaart maar 512 namen kan bevatten, en per dag omdat een klein bestand makkelijk te kopiëren is en bij een fout niet alles meesleurt.
Een kaart schrijft in blokken van 512 bytes. println vult eerst een buffer in het SRAM; pas bij flush() of close() gaat alles echt naar de kaart en komt de nieuwe bestandslengte in de map. De logger opent het bestand elke minuut, schrijft één regel en sluit het: zo verlies je bij een stroomonderbreking hoogstens de lopende minuut.
Trek de kaart er alleen uit als de logger niet schrijft (vlak na een volle minuut is veilig), of schakel eerst de voeding uit. Hoofdstuk 24 berekende dat een supercondensator de Mega 3,1 s in leven houdt: genoeg om bij een stroomonderbreking het bestand netjes te sluiten.
Hoeveel dagen past er op 1 GB?
De logger schrijft elke minuut een regel. Hoe groot wordt een dagbestand, hoe lang doet een kaart van 1 GB ermee, en wat gebeurt er als ze toch vol is?
- een regel is 44 tot 49 tekens plus \r\n (één byte per teken)
- de kop: 71 tekens plus \r\n
- 1440 minuten per dag
- bytes per dag
- aantal dagen op 1 GB
- 1Per dag, geschat. Met gemiddeld 47,5 bytes per regel: bytes.
- 2Nagemeten op de zeven dagbestanden van de testweek (verderop in dit hoofdstuk): gemiddeld 68 514 bytes per dag.
- 31 GB. dagen, of ongeveer 40 jaar. Een bestand neemt op de kaart wel een geheel aantal clusters in (enkele kB tot 32 kB); per bestand gaat hoogstens één cluster verloren, dat verandert weinig.
- 4Kaart vol. Dan schrijft
printlnminder tekens dan gevraagd. logger.ino vergelijkt het aantal met dat van dezelfde regel naar Serial, meldt het en zet de statusled aan. De simulatie met een 'kaart' van 200 bytes:# SD-kaart klaar tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V 2026-10-12 06:00,21.0,55.0,0.09,0.00,4004,13.12 2026-10-12 06:01,21.0,55.0,0.09,0.00,4004,13.12 2026-10-12 06:02,21.0,55.0,0.09,0.00,4004,13.12 # SD: kaart vol, regel niet bewaard
Energie meten met de INA226
Voor verbruik_Wh en zon_Wh moet de logger stroom meten. De ACS712-20A op A4 (hoofdstuk 16) meet tot 20 A, maar op een heldere middag laden de panelen de batterij met tot : daar loopt hij tegen zijn grens. De logger gebruikt daarom twee INA226-chips, elk met een shunt van 100 A / 75 mV (0,75 mΩ): één in de leiding van de laadregelaar, één in de leiding naar de verbruikers. De batterijstroom volgt uit de knooppuntwet (hoofdstuk 21).
De INA226 meet de kleine spanning over de shunt (tot 81,92 mV, in stappen van 2,5 µV) en de spanning van de rail (in stappen van 1,25 mV), en geeft ze via I²C. Twee dezelfde chips op één bus kan, omdat je met de pinnen A0 en A1 zestien adressen kan kiezen.
| register | naam | per stap | in logger.ino |
|---|---|---|---|
| 0x00 | configuratie | 0x4927: 128 metingen middelen, 1,1 ms per conversie, continu | |
| 0x01 | shuntspanning (met teken) | 2,5 µV | stroom = stappen · 2,5 µV / 0,75 mΩ = stappen · 3,33 mA |
| 0x02 | busspanning | 1,25 mV | de spanning van de 12,8 V-rail |
| 0xFE | fabrikant | 0x5449 ('TI'): handig om te testen of je de juiste chip leest |
De groeilampen worden met PWM gedimd (490 Hz, hoofdstuk 25): de stroom springt 490 keer per seconde tussen 0 en 15 A. Eén meting van 1,1 ms zou op een willekeurig moment vallen (aliasing). Daarom middelt de INA226 128 metingen van spanning en stroom: , bijna 140 PWM-perioden. De logger rekent zo: om de 2 s, het gemiddelde over een minuut, maal 1/60 h = Wh.
logger.ino: de datalogger van de klimaatmodule
Alles samen: om de 2 s meten, per minuut optellen, bij een nieuwe minuut één regel naar de computer en naar de kaart. De regel wordt geschreven door één functie, schrijfRegel(Print &uit), die zowel Serial als een File aankan: beide zijn een Print, iets waar je print() op kan doen. Zo zijn de twee kopieën gegarandeerd gelijk. Zet pinnen.h uit hoofdstuk 29 in dezelfde map, en installeer RTClib en Adafruit SHT31 met de bibliotheekbeheerder.
// logger.ino - de datalogger van de klimaatmodule (Arduino Mega 2560, hoofdstuk 30)// Om de 2 s meten; elke minuut één CSV-regel op de SD-kaart (map LOG, één bestand per dag) en naar de computer.// I2C (SDA 20, SCL 21): INA226 zon 0x40, INA226 verbruik 0x41, SHT31 0x44, DS3231 0x68.// SD-kaart op SPI: 50 MISO, 51 MOSI, 52 SCK, 53 CS. De klok loopt het hele jaar op wintertijd (UTC+1).// De klok zetten vanaf de computer: stuur T2026-06-01 06:00:00 en Enter.#include <Wire.h>#include <SPI.h>#include <SD.h>#include <RTClib.h>#include <Adafruit_SHT31.h>#include "pinnen.h" const unsigned long MEET_MS = 2000;const int SD_CS = 53;const byte INA_ZON = 0x40; // INA226 in de leiding van de laadregelaar (A0 en A1 aan GND)const byte INA_VERBRUIK = 0x41; // INA226 in de leiding naar de verbruikers (A0 aan VS)const float R_SHUNT = 0.00075; // ohm: shunt van 100 A / 75 mVconst char KOP[] = "tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V"; RTC_DS3231 rtc;Adafruit_SHT31 sht = Adafruit_SHT31();bool kaartOk = false;unsigned long vorigeMeting = 0;DateTime minuut; // de minuut waarvan we nu metingen verzamelenbool bezig = false; float somT = 0, somRV = 0, somLux = 0, somZon = 0, somVerbruik = 0, somU = 0; // sommen over één minuutint nT = 0, nMeting = 0, nVermogen = 0; char opdracht[32]; // wat de computer stuurtint lengte = 0; // ---------------------------------------------------------------- sensorenfloat daglichtLux() { // LDR onder een kapje (hoofdstuk 22 en 25) long som = 0; for (int i = 0; i < 16; i++) som += analogRead(LDR); int n = som / 16; if (n < 1) n = 1; float r = 1000.0 * (1024.0 - n) / n; return 10.0 * pow(r / 15000.0, -1.0 / 0.7) / 0.10;} void inaSchrijf(byte adres, byte reg, unsigned int waarde) { Wire.beginTransmission(adres); Wire.write(reg); // eerst het register ... Wire.write(waarde >> 8); // ... dan de hoge byte ... Wire.write(waarde & 0xFF); // ... en de lage byte Wire.endTransmission();} long inaLees(byte adres, byte reg) { // 16 bits uit een register, of -1 bij een fout Wire.beginTransmission(adres); Wire.write(reg); // de registerwijzer zetten if (Wire.endTransmission() != 0) return -1; if (Wire.requestFrom(adres, (byte)2) != 2) return -1; byte hoog = Wire.read(); // eerst de hoge byte; twee aparte regels, byte laag = Wire.read(); // zodat de volgorde vastligt return ((long)hoog << 8) | laag;} float inaSpanning(byte adres) { // busspanning: 1,25 mV per stap long bus = inaLees(adres, 0x02); if (bus < 0) return NAN; return bus * 0.00125;} float inaVermogen(byte adres) { // W, of NAN bij een fout long shunt = inaLees(adres, 0x01); // shuntspanning: 2,5 uV per stap, met teken float u = inaSpanning(adres); if (shunt < 0 || isnan(u)) return NAN; float stroom = (int16_t)shunt * 0.0000025 / R_SHUNT; // A return u * stroom;} // ---------------------------------------------------------------- meten en schrijvenvoid meten() { float t, rv; if (sht.readBoth(&t, &rv)) { // alleen geldige metingen tellen mee somT += t; somRV += rv; nT++; } somLux += daglichtLux(); float pZon = inaVermogen(INA_ZON); float pVerbruik = inaVermogen(INA_VERBRUIK); float u = inaSpanning(INA_VERBRUIK); if (!isnan(pZon) && !isnan(pVerbruik) && !isnan(u)) { somZon += pZon; somVerbruik += pVerbruik; somU += u; nVermogen++; } nMeting++;} void wisSommen() { somT = somRV = somLux = somZon = somVerbruik = somU = 0; nT = nMeting = nVermogen = 0;} size_t schrijfRegel(Print &uit) { // één CSV-regel, naar de kaart of naar de computer size_t n = 0; // hoeveel tekens er echt geschreven zijn char tijd[17]; snprintf(tijd, sizeof(tijd), "%04d-%02d-%02d %02d:%02d", minuut.year(), minuut.month(), minuut.day(), minuut.hour(), minuut.minute()); n += uit.print(tijd); n += uit.print(','); if (nT > 0) n += uit.print(somT / nT, 1); // geen geldige meting: lege cel n += uit.print(','); if (nT > 0) n += uit.print(somRV / nT, 1); n += uit.print(','); if (nVermogen > 0) n += uit.print(somVerbruik / nVermogen / 60.0, 2); // gemiddeld vermogen x 1/60 h = Wh n += uit.print(','); if (nVermogen > 0) n += uit.print(somZon / nVermogen / 60.0, 2); n += uit.print(','); if (nMeting > 0) n += uit.print(somLux / nMeting, 0); n += uit.print(','); if (nVermogen > 0) n += uit.print(somU / nVermogen, 2); n += uit.println(); return n;} void opslaan() { size_t lang = schrijfRegel(Serial); // altijd naar de computer if (!kaartOk) return; char naam[20]; snprintf(naam, sizeof(naam), "LOG/%04d%02d%02d.CSV", minuut.year(), minuut.month(), minuut.day()); bool nieuw = !SD.exists(naam); File f = SD.open(naam, FILE_WRITE); // FILE_WRITE schrijft achteraan bij if (!f) { Serial.println("# SD: kan het bestand niet openen"); digitalWrite(STATUSLED, HIGH); return; } if (nieuw) f.println(KOP); // een nieuwe dag: eerst de kop if (schrijfRegel(f) < lang) { // niet alles paste nog op de kaart Serial.println("# SD: kaart vol, regel niet bewaard"); digitalWrite(STATUSLED, HIGH); } f.close(); // close() schrijft alles echt weg} void leesOpdracht() { // bv. "T2026-06-01 06:00:00": de klok zetten while (Serial.available() > 0) { char c = Serial.read(); if (c == '\n' || c == '\r') { opdracht[lengte] = '\0'; int j, mo, d, u, mi, s; if (lengte > 0 && sscanf(opdracht, "T%d-%d-%d %d:%d:%d", &j, &mo, &d, &u, &mi, &s) == 6) { rtc.adjust(DateTime(j, mo, d, u, mi, s)); Serial.println("# klok gezet"); wisSommen(); // de lopende minuut hoort bij de oude tijd: bezig = false; // niet bewaren, opnieuw beginnen } else if (lengte > 0) { Serial.println("# onbekende opdracht"); } lengte = 0; } else if (lengte < (int)sizeof(opdracht) - 1) { opdracht[lengte] = c; lengte++; } }} // ---------------------------------------------------------------- setup en loopvoid setup() { Serial.begin(115200); pinMode(STATUSLED, OUTPUT); Wire.begin(); if (!rtc.begin()) Serial.println("# ALARM: geen klok (DS3231) op 0x68"); if (rtc.lostPower()) Serial.println("# de klok heeft stilgestaan: stuur T jjjj-mm-dd uu:mm:ss"); if (!sht.begin(0x44)) Serial.println("# ALARM: geen SHT31 op 0x44"); inaSchrijf(INA_ZON, 0x00, 0x4927); // 128 metingen middelen: het gemiddelde over de PWM inaSchrijf(INA_VERBRUIK, 0x00, 0x4927); kaartOk = SD.begin(SD_CS); if (kaartOk && !SD.exists("LOG")) SD.mkdir("LOG"); Serial.println(kaartOk ? "# SD-kaart klaar" : "# ALARM: geen SD-kaart, alleen naar de computer"); digitalWrite(STATUSLED, kaartOk ? LOW : HIGH); // led aan = probleem met de kaart Serial.println(KOP);} void loop() { unsigned long nu = millis(); leesOpdracht(); if (nu - vorigeMeting < MEET_MS) return; vorigeMeting += MEET_MS; DateTime t = rtc.now(); DateTime begin(t.year(), t.month(), t.day(), t.hour(), t.minute(), 0); // het begin van deze minuut if (!bezig) { minuut = begin; bezig = true; } else if (begin.unixtime() != minuut.unixtime()) { // een nieuwe minuut: de vorige is af opslaan(); wisSommen(); minuut = begin; } meten();}Een test in de simulatie: zondag 11 oktober 2026 van 23:58 tot 00:02, met in de minuut 23:59 een losse SHT31 en om 00:00 de pomp die aanslaat. De seriële monitor:
# SD-kaart klaar tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V 2026-10-11 23:58,18.1,83.8,0.09,0.00,0,13.25 2026-10-11 23:59,,,0.09,0.00,0,13.25 2026-10-12 00:00,18.0,83.9,0.29,0.00,0,13.24 2026-10-12 00:01,18.0,84.0,0.29,0.00,0,13.24
En wat er daarna op de kaart staat, twee dagbestanden met elk een kop:
== LOG/20261011.CSV (157 bytes) tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V 2026-10-11 23:58,18.1,83.8,0.09,0.00,0,13.25 2026-10-11 23:59,,,0.09,0.00,0,13.25 == LOG/20261012.CSV (165 bytes) tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V 2026-10-12 00:00,18.0,83.9,0.29,0.00,0,13.24 2026-10-12 00:01,18.0,84.0,0.29,0.00,0,13.24
Het verbruik springt om 00:00 van 0,09 naar 0,29 Wh per minuut: 5,5 W wordt 17,5 W als de pomp (12 W) aanslaat. De minuut 23:59 heeft lege cellen voor temperatuur en vocht, maar wel energie: die komt van andere chips. De eerste regel komt bij de eerste minuutwissel; start de logger midden in een minuut, dan is die eerste regel een gemiddelde over minder metingen.
Stuur in de seriële monitor T2026-10-11 20:00:00 (met Enter). logger.ino leest de tekens met Serial.read() tot de regelovergang en ontleedt ze met sscanf. Zonder knoopcel, of na een lege knoopcel, meldt de logger dat de klok stilstond:
# de klok heeft stilgestaan: stuur T jjjj-mm-dd uu:mm:ss # SD-kaart klaar tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V # klok gezet # onbekende opdracht 2026-10-11 20:00,21.0,55.0,0.09,0.00,4004,13.12
Het CSV-formaat van de klimaatmodule
Het formaat van hoofdstuk 28, met twee kolommen achteraan. Deze afspraken maken het bestand bruikbaar voor elk programma, ook over tien jaar:
| afspraak | waarom |
|---|---|
| de eerste regel is de kop, met de eenheid in de naam (temperatuur_C) | een programma vraagt kolommen op bij naam; de eenheid gaat nooit verloren |
| tijdstip als jjjj-mm-dd uu:mm, in wintertijd (UTC+1) | sorteert vanzelf juist; geen dubbele of ontbrekende uren bij de overgang naar zomertijd |
| het tijdstip is het begin van de minuut | zoals in hoofdstuk 28 (een blok heet naar zijn begin) |
| komma tussen de velden, punt als decimaalteken | Python leest het zonder omzetten (float("23.5")) |
| lege cel = geen geldige meting | een 0 of −999 zou meetellen in een gemiddelde |
| nieuwe kolommen alleen achteraan | oude programma's met csv.reader en een kolomnummer blijven werken |
| energie als Wh per minuut, niet als vermogen | optellen geeft meteen Wh per uur, per dag, per week |
| één bestand per dag: LOG/JJJJMMDD.CSV | 8.3-namen; kleine bestanden, makkelijk te kopiëren |
Testdata: een week in het formaat van de logger
Om de analyse te schrijven, hoef je niet een week te wachten. Ingenieurs maken eerst testdata: een model dat bestanden maakt in precies het formaat van het echte toestel, met de fouten die je verwacht. Dit programma maakt zeven dagbestanden voor 12 tot 18 oktober 2026. Het model gebruikt wat je al weet: een zonneprofiel per dag (van helder tot grijs), de pomp 10 minuten per uur, lampen die dimmen met het daglicht en het energiebeheer van hoofdstuk 20 (onder 40 % alleen op zon, onder 20 % uit). En het zet er drie soorten fouten in, zoals ze in echte logbestanden voorkomen.
# maak_testdata.py - een week testdata in het formaat van de logger (een model, geen echte metingen)import mathimport osimport random rng = random.Random(2026) # vast zaadje: telkens dezelfde 'metingen'os.makedirs("LOG", exist_ok=True)KOP = "tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V"WEER = [0.95, 0.55, 0.2, 0.4, 0.8, 0.65, 0.3] # 1 = helder; maandag 12 tot zondag 18 oktober 2026soc = 0.65 # laadtoestand van de batterij (0 tot 1)ruis = 0.0 for dag in range(7): w = WEER[dag] with open(f"LOG/202610{dag + 12}.CSV", "w", newline="") as f: f.write(KOP + "\r\n") for m in range(1440): u = m / 60 # uur, wintertijd (UTC+1) hoogte = max(0.0, math.sin(math.pi * (u - 7.1) / 10.7)) # zon op 7:06, onder 17:48 lux = 40000 * w * hoogte ** 1.3 * (1 + (1 - w) * (rng.random() - 0.5)) paneel = math.cos(math.pi * (u - 12.45) / 10.8) ** 1.3 if abs(u - 12.45) < 5.4 else 0.0 zon = 1000 * w * paneel * (1 + 0.6 * (1 - w) * (rng.random() - 0.5)) # W die de panelen kunnen geven basis = 3.5 + (12 if m % 60 < 10 else 0) + (5 if 8 <= u < 20 else 2) # sensoren, pomp, ventilatoren wens = 190 * min(max((200 - lux / 54) / 200, 0), 1) if 6 <= u < 22 else 0.0 if soc >= 0.40: lampen = wens # energiebeheer (hoofdstuk 20) elif soc >= 0.20: lampen = min(wens, max(zon - basis, 0)) # alleen op zon else: lampen = 0.0 verbruik = basis + lampen if soc >= 1.0 and zon > verbruik: zon = verbruik # batterij vol: de regelaar knijpt af stroom = (zon - verbruik) / 13.2 # A, positief = laden soc = min(1.0, soc + stroom * 13.2 * (0.95 if stroom > 0 else 1) / 60 / 3072) bat = 13.0 + 0.4 * soc + 0.015 * stroom ruis = 0.9 * ruis + rng.gauss(0, 0.05) temp = 18.0 + (6.0 * w + 0.8) * max(0.0, math.sin(math.pi * (u - 9) / 9.5)) ** 1.2 + 1.5 * lampen / 190 + ruis vocht = 84 - 1.6 * (temp - 18) + rng.gauss(0, 0.4) tijd = f"2026-10-{dag + 12} {m // 60:02d}:{m % 60:02d}" t_tekst, v_tekst = f"{temp:.1f}", f"{vocht:.1f}" # fouten die in echte logbestanden voorkomen: if (dag, m) in ((0, 552), (0, 553), (1, 605)): t_tekst, v_tekst = "130.0", "100.0" # versie 1 las 0xFF 0xFF: sensor antwoordde niet if dag == 1 and m == 840: continue # 14:00: nieuwe software opgeladen if dag == 2 and 707 <= m <= 718: t_tekst, v_tekst = "", "" # stekkertje los: versie 2 laat de cel leeg f.write(f"{tijd},{t_tekst},{v_tekst},{verbruik / 60:.2f},{zon / 60:.2f},{lux:.0f},{bat:.2f}\r\n")print("zeven bestanden in de map LOG")Uitvoer: zeven bestanden in de map LOG. Het begin van het eerste bestand:
tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V 2026-10-12 00:00,17.9,84.1,0.29,0.00,0,13.24 2026-10-12 00:01,17.9,83.9,0.29,0.00,0,13.24 2026-10-12 00:02,17.9,83.5,0.29,0.00,0,13.24 2026-10-12 00:03,17.9,84.7,0.29,0.00,0,13.24 2026-10-12 00:04,17.8,84.5,0.29,0.00,0,13.24 …
Het zijn geen metingen, maar een model met toevalsruis. Met dezelfde startwaarde van de toevalsgenerator (2026) krijg je precies dezelfde bestanden: download het programma en je werkt met dezelfde getallen als dit boek. De fouten: op maandag om 9:12 en 9:13 en dinsdag om 10:05 staat 130,0 °C en 100,0 % (de eerste versie van de logger controleerde niet of de SHT31 antwoordde); dinsdag om 14:00 ontbreekt een regel (toen werd versie 2 opgeladen); woensdag van 11:47 tot 11:58 zijn temperatuur en vocht leeg (een losse stekker, en versie 2 schrijft dan een lege cel).
Inlezen en opschonen: logboek.py
Een analyse begint altijd met opschonen: wat is er binnengekomen, wat ontbreekt, wat kan niet? De functies staan in een eigen module logboek.py, zodat elk volgend programma ze met from logboek import lees_week kan gebruiken, zoals pinnen.h in C++. Het blok onderaan, na if __name__ == "__main__":, draait alleen als je logboek.py zelf start: dan maakt het een verslag.
# logboek.py - de logbestanden van de klimaatmodule inlezen en opschonenimport csvfrom datetime import datetime, timedelta GRENZEN = {"T": (-10, 50), "RV": (0, 100)} # wat in de module kan; daarbuiten is het een meetfout def getal(tekst): """Tekst naar kommagetal; een lege cel (of een ontbrekende bij een halve regel) wordt None.""" if tekst is None or tekst == "": return None return float(tekst) def lees_dag(naam): """Alle regels van één dagbestand, als woordenboeken met getallen en een echte tijd.""" rijen = [] with open(naam, newline="", encoding="utf-8") as bestand: for r in csv.DictReader(bestand): rijen.append({"tijd": datetime.strptime(r["tijdstip"], "%Y-%m-%d %H:%M"), "T": getal(r["temperatuur_C"]), "RV": getal(r["vocht_pct"]), "verbruik": getal(r["verbruik_Wh"]), "zon": getal(r["zon_Wh"]), "licht": getal(r["licht_lx"]), "bat": getal(r["batterij_V"])}) return rijen def lees_week(eerste=12, laatste=18): """De dagbestanden van 12 tot 18 oktober 2026 achter elkaar.""" rijen = [] for dag in range(eerste, laatste + 1): rijen += lees_dag(f"LOG/202610{dag}.CSV") return rijen def buiten(waarde, grootheid): laag, hoog = GRENZEN[grootheid] return waarde is not None and not (laag <= waarde <= hoog) def opschonen(rijen): """Onmogelijke waarden worden None (T en RV samen: ze komen van dezelfde sensor). Geeft hun aantal terug.""" fout = 0 for r in rijen: if buiten(r["T"], "T") or buiten(r["RV"], "RV"): r["T"] = r["RV"] = None fout += 1 return fout def gaten(rijen): """Paren van opeenvolgende regels die niet precies één minuut uit elkaar liggen.""" return [(a["tijd"], b["tijd"]) for a, b in zip(rijen, rijen[1:]) if b["tijd"] - a["tijd"] != timedelta(minutes=1)] if __name__ == "__main__": rijen = lees_week() print("regels ingelezen:", len(rijen), "- verwacht:", 7 * 24 * 60) print("lege temperatuur:", sum(1 for r in rijen if r["T"] is None)) for r in rijen: if buiten(r["T"], "T") or buiten(r["RV"], "RV"): print(" onmogelijk:", r["tijd"], r["T"], r["RV"]) print("opgeschoond:", opschonen(rijen), "regels") for a, b in gaten(rijen): print("gat in de tijd:", a, "->", b)Verslag bij de testweek:
regels ingelezen: 10079 - verwacht: 10080 lege temperatuur: 12 onmogelijk: 2026-10-12 09:12:00 130.0 100.0 onmogelijk: 2026-10-12 09:13:00 130.0 100.0 onmogelijk: 2026-10-13 10:05:00 130.0 100.0 opgeschoond: 3 regels gat in de tijd: 2026-10-13 13:59:00 -> 2026-10-13 14:01:00
Drie soorten problemen, drie behandelingen. Lege cellen worden None en tellen nergens mee. Onmogelijke waarden (130 °C in een kas) worden None: ze zijn geen extreem weer maar een meetfout, en dat kan je bewijzen met de sensor (0xFF 0xFF). Ontbrekende regels vind je alleen door de tijden te vergelijken: 10 079 regels in plaats van 10 080. datetime.strptime maakt van de tekst een echte tijd, waarmee je kan rekenen.
Een fout of een uitschieter?
Opschonen is niet 'alles weggooien wat vreemd is'. Een waarde die sterk afwijkt, heet een uitschieter, en er zijn twee soorten:
- Meetfouten: fysisch onmogelijk (130 °C in een kas, 100,0 % en 130,0 °C samen), of met een bekende oorzaak (een losse draad). Die mag je vervangen door 'geen waarde', met een regel die je in je verslag vermeldt.
- Echte gebeurtenissen: een piek in de vochtigheid als iemand de plantjes besproeit, een daling van de temperatuur als het dak opengaat. Die hoor je niet weg te halen: juist die wil je zien.
Daarom gebruikt logboek.py vaste grenzen die uit de fysica van de module komen (−10 tot 50 °C, 0 tot 100 %), niet 'alles wat meer dan x van het gemiddelde afwijkt'. En het past de ruwe bestanden nooit aan: het opschonen gebeurt in het programma, zodat iemand anders het kan nagaan en herhalen (hoofdstuk 28, het verhaal van de vijf ontbrekende landen).
Een 0 is een meting: 0 °C trekt het daggemiddelde omlaag. Een lege waarde laat je leeg, of je vult ze in met een methode die je vermeldt (bv. het gemiddelde van de minuut ervoor en erna). Voor een daggemiddelde over 1428 in plaats van 1440 minuten is leeglaten prima.
De week per dag
Nu de gegevens schoon zijn, is een samenvatting per dag een paar regels: de rijen verzamelen per datum in een woordenboek (hoofdstuk 28), dan per dag gemiddelde, minimum, maximum en de sommen van de energie.
# per_dag.py - de week per dag samenvattenfrom logboek import lees_week, opschonen rijen = lees_week()opschonen(rijen)dagen = {} # datum -> de regels van die dagfor r in rijen: datum = r["tijd"].date() if datum not in dagen: dagen[datum] = [] dagen[datum].append(r) print("datum T gem T min T max geldig verbruik zon saldo (kWh)")for datum, rs in dagen.items(): t = [r["T"] for r in rs if r["T"] is not None] verbruik = sum(r["verbruik"] for r in rs) / 1000 zon = sum(r["zon"] for r in rs) / 1000 print(f"{datum} {sum(t) / len(t):6.1f} {min(t):6.1f} {max(t):6.1f} {len(t):7d} {verbruik:9.2f} {zon:5.2f} {zon - verbruik:+6.2f}")Uitvoer:
datum T gem T min T max geldig verbruik zon saldo (kWh) 2026-10-12 20.0 17.7 24.7 1438 1.50 1.74 +0.23 2026-10-13 19.4 17.6 22.4 1438 1.67 1.71 +0.03 2026-10-14 19.1 17.6 21.0 1428 2.15 1.27 -0.88 2026-10-15 19.2 17.7 21.4 1440 1.46 2.43 +0.98 2026-10-16 19.7 17.7 23.7 1440 1.54 1.69 +0.14 2026-10-17 19.6 17.8 22.9 1440 1.61 1.66 +0.05 2026-10-18 19.2 17.7 21.2 1440 2.02 1.89 -0.13
Woensdag (grijs) is de enige dag met een groot tekort: de lampen branden langer en de zon levert weinig. Donderdag laadt de batterij dat weer bij. De warmste minuut van de week was 24,7 °C, op de heldere maandag. De kolom 'geldig' telt de minuten met een temperatuur: 1428 op woensdag door de losse stekker.
Grafieken van de week
matplotlib (hoofdstuk 28) tekent de uurgemiddelden en de energie per dag. Met plt.subplots(2, 1) komen twee grafieken onder elkaar in één figuur. Omdat de tijd een echte datetime is, zet matplotlib zelf datums op de as.
# grafiek_week.py - de temperatuur per uur en de energie per dag, in twee grafiekenimport matplotlib.pyplot as pltfrom logboek import lees_week, opschonen rijen = lees_week()opschonen(rijen) per_uur = {} # begin van het uur -> temperaturenper_dag = {} # datum -> [verbruik, zon] in Whfor r in rijen: if r["T"] is not None: uur = r["tijd"].replace(minute=0) if uur not in per_uur: per_uur[uur] = [] per_uur[uur].append(r["T"]) dag = r["tijd"].date() if dag not in per_dag: per_dag[dag] = [0.0, 0.0] per_dag[dag][0] += r["verbruik"] per_dag[dag][1] += r["zon"] uren = sorted(per_uur)gemiddeld = [sum(per_uur[u]) / len(per_uur[u]) for u in uren]dagen = sorted(per_dag) figuur, (boven, onder) = plt.subplots(2, 1, figsize=(9, 7))boven.plot(uren, gemiddeld, color="tab:red")boven.set_ylabel("temperatuur (°C)")boven.set_title("Klimaatmodule, 12 tot 18 oktober 2026")boven.grid(True)x = range(len(dagen))onder.bar([i - 0.2 for i in x], [per_dag[d][0] / 1000 for d in dagen], width=0.4, label="verbruik")onder.bar([i + 0.2 for i in x], [per_dag[d][1] / 1000 for d in dagen], width=0.4, label="zonnestroom")onder.set_xticks(list(x), [d.strftime("%a %d") for d in dagen])onder.set_ylabel("energie per dag (kWh)")onder.legend()figuur.tight_layout()figuur.savefig("week_oktober.png", dpi=150)plt.show()De code maakt de twee grafieken van dit blad en het vorige. Het uurgemiddelde van 60 minuten verbergt de korte schommelingen maar toont het dagritme: 's nachts houdt de warmte van het station de module op ongeveer 18 °C, overdag stijgt ze met de zon, en 's avonds geven de lampen nog wat warmte.
Wat vertellen de gegevens over de module?
Bekijk het verbruik op woensdagavond en de zonnestroom op maandagvoormiddag, minuut per minuut (de energie per minuut maal 60 is het gemiddelde vermogen in W). Wat gebeurde er om 20:51 op woensdag en om 10:55 op maandag?
- de testweek (logboek.py)
- energiebeheer: onder 40 % lampen alleen op zon (hoofdstuk 20)
- batterij 2 × 120 Ah LiFePO4
- een verklaring voor beide knikken
- 1Woensdag 20:51. Het verbruik valt van ongeveer 200 W naar 5,5 W, ruim een uur vóór de lichtperiode eindigt (22 uur). Op een grijze dag daalt de laadtoestand; op dat moment zakt ze onder 40 %, en na zonsondergang betekent 'alleen op zon' gewoon 'uit'. Ook donderdagochtend blijven de lampen uit tot de zon genoeg levert.
- 2Maandag 10:55. De zonnestroom valt van ruim 800 W naar het verbruik van de module. De zon schijnt gewoon door, maar de batterij is vol: de laadregelaar knijpt de panelen af, en de INA226 meet alleen wat er echt door de leiding gaat. De batterijspanning springt op dat moment van 14,3 naar 13,4 V.
- 3Gevolg voor de analyse. zon_Wh is wat er geleverd werd, niet wat de panelen konden leveren. In hoofdstuk 28 was zon_Wh een model van wat de panelen gaven; een echte logger ziet de afgeknepen zon niet. Wie wil weten hoeveel zon er verloren ging, schat het uit licht_lx.
Een datalogger voor het klaslokaal
Hoe veranderen de temperatuur en de luchtvochtigheid in het klaslokaal tijdens een schooldag?
- Arduino Mega of Uno, USB-kabel
- SHT31-breakout, DS3231-moduletje met knoopcel
- microSD-moduletje en een kaart (FAT32)
- breadboard en draadjes
- logger.ino, i2c_scanner.ino en klok_zetten.py (werkboek) of de seriële monitor

- 1Sluit de SHT31 en de DS3231 aan op 5V, GND, SDA en SCL (Mega: 20 en 21; Uno: A4 en A5), en het SD-moduletje op 5V, GND en de SPI-pinnen (Mega: 50 tot 53; Uno: 12, 11, 13 en 10; zet dan SD_CS op 10).
- 2Draai de I²C-scanner. Je verwacht 0x44, 0x57 en 0x68.
- 3Laad logger.ino op. Zonder INA226 blijven de energiekolommen leeg: dat mag. Zet de klok met de opdracht T… in de seriële monitor.
- 4Leg de logger op een kast op ooghoogte, uit de zon en weg van de verwarming. Laat hem een hele schooldag loggen; noteer wanneer er ramen opengaan en wanneer er leerlingen zijn.
- 5Kopieer het bestand van de kaart en pas logboek.py aan (de naam van het bestand en de dagen). Maak een grafiek van temperatuur en vocht.
Zo begint het bestand (uit de simulatie van logger.ino; bij jou staan er echte waarden):
tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V 2026-10-12 00:00,18.0,83.9,0.29,0.00,0,13.24 2026-10-12 00:01,18.0,84.0,0.29,0.00,0,13.24
Typisch: 's morgens stijgen temperatuur en vocht zodra de klas binnenkomt (mensen geven warmte en waterdamp af); bij een open raam dalen ze in enkele minuten; tijdens de middagpauze vlakken ze af. Een regel per minuut is fijn genoeg om een open raam te zien.
Een klas van twintig leerlingen geeft ongeveer 2 kW warmte af (hoofdstuk 28) en elke persoon ademt waterdamp uit: daarom stijgt de relatieve vochtigheid, tenzij de temperatuur sneller stijgt (warme lucht kan meer waterdamp bevatten, dus de relatieve vochtigheid kan dan zelfs dalen). Een logger met een klok en een kaart laat zo'n verloop zien zonder dat iemand ernaast moet zitten, en de lege cellen en tijdstempels maken duidelijk wat er ontbreekt.
Habitat: geheugen en eigen verbruik van de logger
De logger komt in klimaat.ino. Past alles in het werkgeheugen van de Mega? En hoeveel energie kost de logger zelf, vergeleken met de module?
- SRAM van de Mega: 8 kB
- 5 V-omvormer met een rendement van 83 % (hoofdstuk 21)
- basisverbruik van de module 230 Wh per dag (hoofdstuk 20); in de testweek 1,5 tot 2,2 kWh per dag
- het SRAM-gebruik
- het eigen verbruik per dag
- 1Werkgeheugen. Een schatting per onderdeel:
Samen ongeveer een vijfde van de 8 kB. De IDE toont na het compileren het exacte getal voor de globale variabelen; laat minstens een paar honderd bytes vrij voor de stack. Op een Uno (2 kB) zou het krap worden: een van de redenen voor de Mega.wat SRAM (orde van grootte) SD-bibliotheek (waarvan 512 bytes blokbuffer) ±0,6 kB Wire (buffers van 32 bytes) ±0,2 kB Serial (zend- en ontvangstbuffer van 64 bytes) ±0,2 kB variabelen van klimaat.ino en de logger, KOP, opdracht ±0,4 kB stack: lokale variabelen, snprintf-buffers ±0,3 kB samen ±1,7 kB van de 8 kB - 2Stroom. Per onderdeel:
onderdeel van de logger stroom op 5 V (gemiddeld) waarop gebaseerd SD-kaartmodule ±10 mA (pieken ±100 mA bij het schrijven) orde van grootte, hoofdstuk 21 DS3231 ≤ 0,3 mA datasheet (en een ledje op het moduletje: vaak enkele mA extra) 2 × INA226 2 × 0,33 mA datasheet, continu meten SHT31, om de 2 s 12,5 ms meten bij 0,6 mA ±0,004 mA datasheet samen ±11 mA = ±55 mW de Mega zelf telt niet: die draait toch - 3Energie per dag. aan de 12 V-kant; maal 24 h is dat per dag. Met een ledje op het klokmoduletje (±3 mA) wordt het ±2,0 Wh.
- 4Vergelijken. 1,6 Wh is 0,7 % van het basisverbruik van 230 Wh, en ongeveer 0,1 % van wat de module in de testweek per dag verbruikte. De SD-kaart is de grootste post; de sensoren tellen nauwelijks.
klimaat.ino versie 2: de logger als taak
De logger wordt geen apart programma maar een deel van de sturing: de meettaak van hoofdstuk 29 meet al om de 2 s, dus telt ze nu ook op voor het logboek (loggen()). Drie dingen veranderen verder: de tijd van de dag komt uit de DS3231 in plaats van uit millis() met een startuur, de pomp draait in de eerste tien minuten van elk kloksuur (ook na een herstart), en alle meldingen beginnen met #, zodat een Python-programma op de computer ze van de logregels kan scheiden.
// klimaat.ino - de sturing van de klimaatmodule, versie 2 (Arduino Mega 2560, hoofdstuk 30)// Nieuw tegenover versie 1: de klok komt van de DS3231, en de meettaak houdt een logboek bij: elke minuut een// CSV-regel op de SD-kaart (map LOG, één bestand per dag) en naar de computer. Meldingen beginnen nu met #.// I2C: INA226 zon 0x40, INA226 verbruik 0x41, SHT31 0x44, DS3231 0x68. SD op SPI (CS = 53). Klok: wintertijd.#include <Wire.h>#include <SPI.h>#include <SD.h>#include <RTClib.h>#include <EEPROM.h>#include <Adafruit_SHT31.h>#include "pinnen.h" // ---------------------------------------------------------------- instellingenconst unsigned long MEET_MS = 2000;const unsigned long LAMP_MS = 200;const unsigned long MELD_MS = 60000UL;const unsigned long BEWAAR_MS = 3600000UL; // de laadtoestand elk uur in het EEPROMconst unsigned long DENDER_MS = 20;const float CAPACITEIT_AH = 240.0; // twee LiFePO4-batterijen van 120 Ah (hoofdstuk 20)const int ADRES_SOC = 0; // waar de laadtoestand in het EEPROM staatconst float DOEL = 200.0; // umol/(m2 s) op het kweekbed (hoofdstuk 25)const float LAMPEN_VOL = 200.0; // wat de lampen op 100 % gevenconst float LUX_PER_UMOL = 54.0;const float U_NODIG = 11.63, U_START = 2.21; // dakmotor (hoofdstuk 26)const unsigned long AANLOOP_MS = 1000, REMMEN_VANAF_MS = 6000, TIMEOUT_MS = 12000;const int UITGANGEN[] = {LAMPEN, PWM_DICHT, PWM_OPEN, DAK_ENABLE, POMP, CO2_KLEP, VENTILATOREN, STATUSLED};const int AANTAL_UIT = sizeof(UITGANGEN) / sizeof(UITGANGEN[0]);const int SD_CS = 53; // logger (hoofdstuk 30)const byte INA_ZON = 0x40, INA_VERBRUIK = 0x41;const float R_SHUNT = 0.00075; // shunt 100 A / 75 mVconst char KOP[] = "tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V"; // ---------------------------------------------------------------- toestand (globaal: alle taken gebruiken ze)Adafruit_SHT31 sht = Adafruit_SHT31();RTC_DS3231 rtc;DateTime klok; // de tijd van de laatste meting (om de 2 s bijgewerkt)bool kaartOk = false;DateTime minuut; // logger: de minuut die we verzamelenbool bezig = false;float somT = 0, somRV = 0, somLux = 0, somZon = 0, somVerbruik = 0, somU = 0;int nT = 0, nMeting = 0, nVermogen = 0;char opdracht[32];int lengte = 0;float luchtT = NAN, luchtRV = NAN; // kaslucht, van de SHT31 op I2Cfloat lux = 0, uBat = 0, iBat = 0, soc = 50.0;int grens = 0; // energiebeheer: de hoogste PWM die de lampen mogenint stand = 0; // de PWM die de lampen nu krijgenbool pompAan = false, genoegWater = true;enum Dak { STIL, SLUITEN, OPENEN };Dak dak = STIL;bool dakOpen = false;unsigned long vorigeMeting = 0, vorigeLamp = 0, vorigeMelding = 0, vorigeBewaring = 0;unsigned long startDak = 0, laatsteWissel = 0, vorigeKnipper = 0;int vorigeLezing = LOW, stabiel = LOW;bool ledAan = false; // ---------------------------------------------------------------- sensorenint gemiddeld(int pin) { // 16 metingen middelen tegen ruis (hoofdstuk 22) long som = 0; for (int i = 0; i < 16; i++) som += analogRead(pin); return som / 16;} float daglichtLux() { // LDR onder een kapje (hoofdstuk 22 en 25) int n = gemiddeld(LDR); if (n < 1) n = 1; float r = 1000.0 * (1024.0 - n) / n; return 10.0 * pow(r / 15000.0, -1.0 / 0.7) / 0.10;} float batterijspanning() { // deler 100 kOhm / 33 kOhm (hoofdstuk 22) return gemiddeld(BATTERIJ) * 5.0 / 1024.0 * (133.0 / 33.0);} float batterijstroom() { // ACS712-20A: 100 metingen in 20,4 ms (hoofdstuk 16 en 25) long som = 0; unsigned long t0 = micros(); for (int i = 0; i < 100; i++) { while (micros() - t0 < i * 204UL) { } som += analogRead(STROOM); } return (som / 100.0 * 5.0 / 1024.0 - 2.50) / 0.100; // A, positief = ontladen} long inaLees(byte adres, byte reg) { // INA226: 16 bits uit een register, of -1 (hoofdstuk 30) Wire.beginTransmission(adres); Wire.write(reg); if (Wire.endTransmission() != 0) return -1; if (Wire.requestFrom(adres, (byte)2) != 2) return -1; byte hoog = Wire.read(); byte laag = Wire.read(); return ((long)hoog << 8) | laag;} float inaSpanning(byte adres) { long bus = inaLees(adres, 0x02); if (bus < 0) return NAN; return bus * 0.00125;} float inaVermogen(byte adres) { long shunt = inaLees(adres, 0x01); float u = inaSpanning(adres); if (shunt < 0 || isnan(u)) return NAN; return u * (int16_t)shunt * 0.0000025 / R_SHUNT;} // ---------------------------------------------------------------- tijdunsigned long secondenVanDeDag() { // uit de klok, niet meer uit millis() return klok.hour() * 3600UL + klok.minute() * 60UL + klok.second();} void printTwee(unsigned long x) { // altijd twee cijfers: 7 -> 07 if (x < 10) Serial.print('0'); Serial.print(x);} void printTijd() { // meldingen beginnen met #: de logger-regels niet Serial.print("# "); unsigned long s = secondenVanDeDag(); printTwee(s / 3600); Serial.print(':'); printTwee(s / 60 % 60); Serial.print(':'); printTwee(s % 60); Serial.print(" ");} // ---------------------------------------------------------------- logger (hoofdstuk 30)void wisSommen() { somT = somRV = somLux = somZon = somVerbruik = somU = 0; nT = nMeting = nVermogen = 0;} size_t schrijfRegel(Print &uit) { size_t n = 0; char tijd[17]; snprintf(tijd, sizeof(tijd), "%04d-%02d-%02d %02d:%02d", minuut.year(), minuut.month(), minuut.day(), minuut.hour(), minuut.minute()); n += uit.print(tijd); n += uit.print(','); if (nT > 0) n += uit.print(somT / nT, 1); n += uit.print(','); if (nT > 0) n += uit.print(somRV / nT, 1); n += uit.print(','); if (nVermogen > 0) n += uit.print(somVerbruik / nVermogen / 60.0, 2); n += uit.print(','); if (nVermogen > 0) n += uit.print(somZon / nVermogen / 60.0, 2); n += uit.print(','); if (nMeting > 0) n += uit.print(somLux / nMeting, 0); n += uit.print(','); if (nVermogen > 0) n += uit.print(somU / nVermogen, 2); n += uit.println(); return n;} void opslaan() { size_t lang = schrijfRegel(Serial); if (!kaartOk) return; char naam[20]; snprintf(naam, sizeof(naam), "LOG/%04d%02d%02d.CSV", minuut.year(), minuut.month(), minuut.day()); bool nieuw = !SD.exists(naam); File f = SD.open(naam, FILE_WRITE); if (!f) { Serial.println("# SD: kan het bestand niet openen"); kaartOk = false; // de statusled knippert snel return; } if (nieuw) f.println(KOP); if (schrijfRegel(f) < lang) { Serial.println("# SD: kaart vol"); kaartOk = false; } f.close();} void loggen() { // om de 2 s, na de metingen DateTime begin(klok.year(), klok.month(), klok.day(), klok.hour(), klok.minute(), 0); if (!bezig) { minuut = begin; bezig = true; } else if (begin.unixtime() != minuut.unixtime()) { opslaan(); wisSommen(); minuut = begin; } if (!isnan(luchtT)) { somT += luchtT; somRV += luchtRV; nT++; } somLux += lux; float pZon = inaVermogen(INA_ZON); float pVerbruik = inaVermogen(INA_VERBRUIK); float u = inaSpanning(INA_VERBRUIK); if (!isnan(pZon) && !isnan(pVerbruik) && !isnan(u)) { somZon += pZon; somVerbruik += pVerbruik; somU += u; nVermogen++; } nMeting++;} void leesOpdracht() { // T2026-10-11 20:00:00 + Enter: de klok zetten while (Serial.available() > 0) { char c = Serial.read(); if (c == '\n' || c == '\r') { opdracht[lengte] = '\0'; int j, mo, d, u, mi, s; if (lengte > 0 && sscanf(opdracht, "T%d-%d-%d %d:%d:%d", &j, &mo, &d, &u, &mi, &s) == 6) { rtc.adjust(DateTime(j, mo, d, u, mi, s)); Serial.println("# klok gezet"); wisSommen(); bezig = false; } else if (lengte > 0) { Serial.println("# onbekende opdracht"); } lengte = 0; } else if (lengte < (int)sizeof(opdracht) - 1) { opdracht[lengte] = c; lengte++; } }} // ---------------------------------------------------------------- takenvoid taakMeten(unsigned long nu) { if (nu - vorigeMeting < MEET_MS) return; vorigeMeting += MEET_MS; klok = rtc.now(); sht.readBoth(&luchtT, &luchtRV); // nan als de sensor niet antwoordt lux = daglichtLux(); uBat = batterijspanning(); iBat = batterijstroom(); soc -= iBat * (MEET_MS / 3600000.0) / CAPACITEIT_AH * 100.0; // lading tellen (hoofdstuk 16) if (uBat >= 14.2 && iBat < 0.0 && iBat > -2.0) soc = 100.0; // vol: opnieuw ijken soc = constrain(soc, 0.0, 100.0); // energiebeheer (hoofdstuk 20): hoeveel licht mag er hoogstens branden? if (soc >= 40.0) grens = 255; else if (soc < 20.0) grens = 0; else if (iBat < 0.0) grens = min(grens + 5, 255); // de batterij laadt: er is zon over else grens = max(grens - 5, 0); // de batterij levert: minder licht loggen();} void taakLampen(unsigned long nu) { // dimmen met het daglicht (hoofdstuk 25) if (nu - vorigeLamp < LAMP_MS) return; vorigeLamp += LAMP_MS; int wens = 0; unsigned long uur = secondenVanDeDag() / 3600; if (uur >= 6 && uur < 22) { // lichtperiode van 16 uur float f = constrain((DOEL - lux / LUX_PER_UMOL) / LAMPEN_VOL, 0.0, 1.0); wens = (int)(f * 255 + 0.5); } int doelStand = min(wens, grens); if (doelStand > stand) stand++; // zacht: hoogstens 1 stap per 200 ms else if (doelStand < stand) stand--; analogWrite(LAMPEN, stand);} void taakPomp(unsigned long nu) { // 10 min per uur, nooit zonder water (hoofdstuk 23) bool tijd = klok.minute() < 10; // de eerste 10 minuten van elk uur, volgens de klok bool water = (digitalRead(VLOTTER) == LOW); bool aan = tijd && water; if (water != genoegWater) { // alleen melden als er iets verandert printTijd(); Serial.println(water ? "vlotter: weer genoeg water" : "ALARM vlotter: te weinig water"); genoegWater = water; } if (aan != pompAan) { printTijd(); Serial.println(aan ? "pomp aan" : "pomp uit"); pompAan = aan; } digitalWrite(POMP, aan ? HIGH : LOW);} bool knopGedrukt(unsigned long nu) { // true bij de flank 'net ingedrukt' (hoofdstuk 24) int lezing = digitalRead(KNOP); if (lezing != vorigeLezing) { laatsteWissel = nu; vorigeLezing = lezing; } if (nu - laatsteWissel >= DENDER_MS && lezing != stabiel) { stabiel = lezing; return stabiel == HIGH; } return false;} void motor(int richting, int waarde) { // +1 sluiten, -1 openen, 0 stil (hoofdstuk 26) analogWrite(PWM_DICHT, richting > 0 ? waarde : 0); analogWrite(PWM_OPEN, richting < 0 ? waarde : 0); // nooit beide tegelijk} void stopDak(unsigned long nu, const char* reden) { motor(0, 0); dak = STIL; printTijd(); Serial.println(reden);} void taakDak(unsigned long nu) { if (knopGedrukt(nu)) { if (dak != STIL) { stopDak(nu, "dak gestopt met de knop"); // een druk tijdens de beweging = stoppen } else { dak = dakOpen ? SLUITEN : OPENEN; startDak = nu; printTijd(); Serial.println(dak == SLUITEN ? "dak sluiten" : "dak openen"); } } if (dak == STIL) return; unsigned long t = nu - startDak; if (dak == SLUITEN && digitalRead(EIND_DICHT) == HIGH) { dakOpen = false; stopDak(nu, "dak dicht"); } else if (dak == OPENEN && digitalRead(EIND_OPEN) == HIGH) { dakOpen = true; stopDak(nu, "dak open"); } else if (t > TIMEOUT_MS) { stopDak(nu, "ALARM dak: eindpunt niet bereikt"); } else { // aanlopen, constant, afremmen tot 30 % float s = 1.0; if (t < AANLOOP_MS) s = t / (float)AANLOOP_MS; else if (t > REMMEN_VANAF_MS) s = max(0.3, 1.0 - (t - REMMEN_VANAF_MS) / (float)AANLOOP_MS); float u = U_START + (U_NODIG - U_START) * s; motor(dak == SLUITEN ? +1 : -1, constrain((int)(u / uBat * 255 + 0.5), 0, 255)); }} void taakStatusled(unsigned long nu) { // rustig knipperen = alles goed, snel = alarm bool alarm = !genoegWater || soc < 10.0 || !kaartOk; unsigned long periode = alarm ? 100 : 500; if (nu - vorigeKnipper < periode) return; vorigeKnipper = nu; // hier mag het: een led mag wat verlopen ledAan = !ledAan; digitalWrite(STATUSLED, ledAan ? HIGH : LOW);} void taakMelden(unsigned long nu) { // elke minuut één regel voor de seriële monitor if (nu - vorigeMelding < MELD_MS) return; vorigeMelding += MELD_MS; printTijd(); Serial.print("T "); Serial.print(luchtT, 1); Serial.print(" C RV "); Serial.print(luchtRV, 0); Serial.print(" % licht "); Serial.print(lux, 0); Serial.print(" lx bat "); Serial.print(uBat, 2); Serial.print(" V "); Serial.print(iBat, 1); Serial.print(" A SoC "); Serial.print(soc, 1); Serial.print(" % lampen "); Serial.print(stand); Serial.print("/"); Serial.println(grens);} void taakBewaren(unsigned long nu) { // de laadtoestand overleeft een stroomonderbreking if (nu - vorigeBewaring < BEWAAR_MS) return; vorigeBewaring += BEWAAR_MS; EEPROM.put(ADRES_SOC, soc); // schrijft alleen de bytes die veranderen} // ---------------------------------------------------------------- setup en loopvoid setup() { for (int i = 0; i < AANTAL_UIT; i++) { pinMode(UITGANGEN[i], OUTPUT); digitalWrite(UITGANGEN[i], LOW); // veilige begintoestand: alles uit } digitalWrite(DAK_ENABLE, HIGH); pinMode(VLOTTER, INPUT_PULLUP); pinMode(KNOP, INPUT); pinMode(EIND_OPEN, INPUT_PULLUP); pinMode(EIND_DICHT, INPUT_PULLUP); Serial.begin(115200); Serial.println("# klimaatmodule v2 (hoofdstuk 30)"); Wire.begin(); if (!sht.begin(0x44)) Serial.println("# ALARM: geen SHT31 op adres 0x44"); if (!rtc.begin()) Serial.println("# ALARM: geen klok (DS3231) op 0x68"); if (rtc.lostPower()) Serial.println("# de klok heeft stilgestaan: stuur T jjjj-mm-dd uu:mm:ss"); klok = rtc.now(); byte ina[] = {INA_ZON, INA_VERBRUIK}; for (int i = 0; i < 2; i++) { // INA226: 128 metingen middelen (282 ms) Wire.beginTransmission(ina[i]); Wire.write(0x00); Wire.write(0x49); Wire.write(0x27); Wire.endTransmission(); } kaartOk = SD.begin(SD_CS); if (kaartOk && !SD.exists("LOG")) SD.mkdir("LOG"); if (!kaartOk) Serial.println("# ALARM: geen SD-kaart"); EEPROM.get(ADRES_SOC, soc); if (isnan(soc) || soc < 0.0 || soc > 100.0) soc = 50.0; // leeg EEPROM: een ruwe schatting Serial.print("# laadtoestand uit het EEPROM: "); Serial.print(soc, 1); Serial.println(" %"); uBat = batterijspanning(); dakOpen = (digitalRead(EIND_OPEN) == HIGH); Serial.println(KOP);} void loop() { unsigned long nu = millis(); // millis() blijft de klok voor de intervallen leesOpdracht(); taakMeten(nu); taakLampen(nu); taakPomp(nu); taakDak(nu); taakStatusled(nu); taakMelden(nu); taakBewaren(nu);}Een test in de simulatie op maandag 12 oktober vanaf 07:59, met de laadtoestand op 35 % en een druk op de knop van het dak om 08:00:40:
# klimaatmodule v2 (hoofdstuk 30) # laadtoestand uit het EEPROM: 35.0 % tijdstip,temperatuur_C,vocht_pct,verbruik_Wh,zon_Wh,licht_lx,batterij_V 2026-10-12 07:59,17.9,84.0,0.85,1.11,1578,13.07 # 08:00:00 pomp aan # 08:00:00 T 18.0 C RV 84 % licht 1666 lx bat 13.05 V -0.1 A SoC 35.0 % lampen 86/90 # 08:00:40 dak openen # 08:00:44 dak open 2026-10-12 08:00,18.0,83.9,1.33,1.31,1742,13.05 # 08:01:00 T 18.0 C RV 84 % licht 1831 lx bat 13.05 V -0.1 A SoC 35.0 % lampen 86/90 2026-10-12 08:01,18.0,83.8,1.52,1.52,1908,13.05 # 08:02:00 T 18.1 C RV 84 % licht 1997 lx bat 13.05 V 0.0 A SoC 35.0 % lampen 104/100 2026-10-12 08:02,18.1,83.7,1.73,1.74,2075,13.05 # 08:03:00 T 18.1 C RV 84 % licht 2161 lx bat 13.03 V 0.2 A SoC 35.0 % lampen 124/120
De logregels staan tussen de meldingen; lees_serieel.py kan de #-regels overslaan. millis() is niet weg: ze blijft de klok voor de intervallen, want die moeten ook doorlopen als iemand de RTC verzet. De RTC geeft de datum en het uur.
UART, I²C en SPI naast elkaar
| symbool | betekenis | eenheid |
|---|---|---|
| pull-upweerstand | Ω | |
| capaciteit van de bus | F |
| UART (Serial) | I²C (Wire) | SPI (SD-kaart) | |
|---|---|---|---|
| draden | TX, RX (+ GND) | SDA, SCL (+ GND) | MOSI, MISO, SCK, CS per apparaat (+ GND) |
| klok | geen: beide kanten kennen de baudrate | SCL, van de master | SCK, van de master |
| apparaten | twee | tot ±100, elk een eigen adres | meerdere, elk een eigen CS-draad |
| snelheid (Mega) | tot 115 200 baud en meer | 100 of 400 kHz | enkele MHz |
| afstand | meters (tot tientallen met RS-485) | centimeters tot een paar meter | centimeters |
| in de module | Mega ↔ computer | SHT31, DS3231, 2 × INA226 | SD-kaart |
| pyserial | wat het doet |
|---|---|
pip install pyserial | eenmalig installeren (je importeert het als serial) |
python -m serial.tools.list_ports | welke poorten er zijn (COM3, /dev/ttyACM0 …) |
poort = serial.Serial("COM3", 115200, timeout=5) | openen; de Mega herstart, wacht 2 s |
poort.readline() | bytes tot en met \n, of b"" na de timeout |
.decode("utf-8").strip() | bytes naar tekst, zonder \r\n |
poort.write(b"T2026-10-11 20:00:00\n") | tekst naar de Mega sturen (als bytes) |
poort.close() | sluiten: pas dan kan de seriële monitor de poort weer openen |
Waar dit hoofdstuk in het geheel past
Datalogging is waar de leerlijnen elkaar ontmoeten: elektronica (pull-ups, shunts), programmeren in twee talen, onderzoeken (tijdstempels, lege cellen, eerlijk rapporteren) en data (opschonen, samenvatten, grafieken). De logger is ook het begin van de digitale tweeling van het station in Expert: een model dat je alleen kan bouwen en controleren met goede metingen.
Het CSV-formaat, DictReader, woordenboeken per dag en matplotlib: hier op echte logbestanden.
Taken met millis(), Serial, bibliotheken en klimaat.ino, waar de logger een taak in wordt.
De RC-tijd achter de stijgtijd van een I²C-lijn, en de supercondensator die het bestand laat sluiten.
Bits, bytes, hexadecimaal en aliasing met de PWM van de lampen.
De knooppuntwet: batterijstroom = zonnestroom − verbruik.
De laadtoestand: met twee INA226's nauwkeuriger dan met de ACS712 alleen.
Het energiebeheer dat je in de gegevens van woensdagavond terugziet.
Een regeling beoordelen met een meting: daar is het logboek voor.
Grafieken kiezen en een beslisboom trainen op de logbestanden.
Meetonzekerheid en standaardfout op minuutgemiddelden.
SPI en I²C in detail, interrupts, slaapstanden en de gegevens draadloos naar de centrale computer (MQTT).
Het verbruik van morgen voorspellen uit een logboek van maanden.
In het kort
- Een seriële verbinding (UART) stuurt elk teken als startbit, acht databits (b0 eerst) en stopbit; beide kanten gebruiken dezelfde baudrate. Tekens per seconde = baudrate / 10.
- Een eenvoudig protocol: één CSV-regel per meting, een kop, # voor meldingen, een lege cel als er geen geldige meting is.
- Python leest de regels met pyserial: readline() geeft bytes, decode() en strip() maken er tekst van; controleer het aantal velden.
- I²C: twee draden (SDA, SCL), open drain met pull-ups, elke chip een eigen adres van 7 bits; START, adres + R/W, ACK, data, STOP.
- Een lange kabel verhoogt de capaciteit: de stijgtijd 0,85 · R · C moet onder 1 µs blijven (100 kHz), dus sterkere pull-ups.
- De bytes van een sensor reken je om met de datasheet; een CRC toont of er onderweg een bit omviel.
- millis() is geen klok: geen datum, weg na een reset, onnauwkeurig. Een DS3231 wel: BCD in registers, ±2 ppm, knoopcel. Log het hele jaar in wintertijd.
- De SD-bibliotheek kent alleen 8.3-namen; pas flush() of close() legt de gegevens echt vast. Een dag logboek is ±68 kB: 1 GB is ±40 jaar.
- Opschonen: lege cellen worden None, onmogelijke waarden ook (met een vaste regel), ontbrekende regels vind je met de tijdstempels. Pas de ruwe bestanden nooit aan.
- Een logger kost weinig: ±1,7 kB SRAM en ±1,6 Wh per dag in de klimaatmodule.
Wat je nu kent
- datalogger
- Toestel of programma dat metingen automatisch met een tijdstempel bewaart.
- UART
- Seriële verbinding zonder klokdraad: elk teken gaat als startbit, databits en stopbit over één draad (TX naar RX).
- baudrate
- Aantal bits per seconde op een seriële verbinding; beide kanten moeten dezelfde gebruiken.
- protocol
- Afspraak over hoe twee toestellen gegevens uitwisselen: vorm, volgorde en betekenis.
- I²C
- Bus met twee draden (SDA voor data, SCL voor de klok) waarop veel chips hangen, elk met een eigen adres.
- pull-upweerstand
- Weerstand naar de voedingsspanning die een lijn hoog houdt als niemand ze laag trekt.
- CRC
- Controlegetal dat uit de data berekend wordt: klopt het niet, dan is er onderweg iets misgegaan.
- RTC (real-time clock)
- Klokchip die datum en tijd bijhoudt, ook zonder voeding van de microcontroller (knoopcel).
- BCD
- Binair gecodeerd decimaal: elk decimaal cijfer apart in 4 bits, zoals 0x34 voor 34.
- SPI
- Snelle bus met een klokdraad, twee datadraden en een selectiedraad per apparaat; hier voor de SD-kaart.
- tijdstempel
- Datum en tijd die bij een meting horen.
- opschonen (data)
- Fouten in data opsporen en behandelen: lege cellen, onmogelijke waarden, dubbele of ontbrekende regels.
- shunt
- Weerstand van enkele milliohm in een stroomleiding; de spanning erover is evenredig met de stroom.