venerdì 1 luglio 2022

Termostato con arduino (2° puntata)

Neanche fosse un telefilm, ma in realtà è corretto poiché di articoli sull'argomento ne servirebbero davvero tanti, e ognuno sarebbe come le ciliegine, una tira l'altra.

Un termostato digitale può essere davvero complesso, se pensiamo alla gestione di una cella frigorifera negativa, abbiamo da controllare:

  • Un compressore da circa 2kW di potenza.
  • Un elettro-ventilatore da circa 20W.
  • Una elettrovalvola inversione ciclo.
  • Un interruttore o sensore porta.
  • Un lampada per illuminazione interna.

Come minimo ci servono almeno 4 relay (quindi 4 digital pin), un ingresso digitale per il sensore porta, 2 sonde di temperatura: una sonda montata nell'evaporatore e l'altra come sonda ambiente. Non è finita qui ovviamente è obbligatorio avere la possibilità di interagire con il controllore, quindi ci servono: display e pulsanti. Direte voi è finita qui, potrebbe finire qui ma anche no. Considerate che i prodotti conservati hanno un costo molto elevato, vogliamo garanzie che il prodotto non si deteriori a causa di un guasto, ci serve qualcuno che monitori la cella in remoto 24 ore su 24. Allora il controllore deve potersi connettere ad una rete per poterlo interrogare da remoto, ecco che da un semplice termostato siamo arrivati ad una moderna centralina per la gestione di una cella frigorifera. 

Bene ora torniamo con i piedi per terra e rileggiamo l'articolo precedente, dove ho scritto che un termostato elettromeccanico è un interruttore controllato dalla temperatura. Invece nel caso del termostato digitale, esso di base è un relay controllato dalla temperatura. Non è un caso che qui io abbia scritto una classe Relay. 

Dimenticavo alla fine dell'articolo precedente ho lasciato un abbozzo di funzione e adesso come promesso devo descriverla. La riporto qui di seguito per comodità:

bool checkThermostat(bool state) {
switch (state) {
case LOW:
if (g_temperature <= g_setpoint - g_isteresi) {
state = HIGH;
}
break;
case HIGH:
if (g_temperature > g_setpoint) {
state = LOW;
}
break;
} // end switch (state)
return state;
} // end checkThermostat()

Coloro i quali hanno letto altri miei articoli sanno già che per i nomi delle variabili globali uso il prefisso g_ e da ciò dovrebbe essere facile dedurre che la funzione necessità di 3 variabili globali di seguito descritte:

  • g_temperature - questa variabile contiene il valore di temperatura rilevato dalla sonda, poco importa il tipo di sonda, ciò che conta è che è di tipo float. Quasi certamente desideriamo che il suo valore sia visibile su un display.

  • g_setpoint - questa variabile contiene il valore di temperatura desiderata e il tipo è sempre float. Anche questo lo visualizziamo su un display e inoltre dobbiamo poterne modificare il valore tramite dei tasti o altro. Ancora un desiderio quasi una necessità è quella di salvare il valore in eeprom in modo che spegnendo e riaccendendo il controller usi il valore plausibile usato in precedenza.

  • g_isteresi - come sopra, cioè modificabile e salvato in eeprom, sempre di tipo  float.

Rimane da capire a cosa serve l'argomento state usato sia in ingresso che in uscita.Se il relay è connesso al pin 13 la chiamata a funzione sarà qualcosa di simile:
 
void loop() {
     bool state = checkThermostat(digitalRead(13));
     digitalWrite(13, state); 
} 

Oppure semplificando al massimo si elimina la variabile state così:
 
void loop() {
    digitalWrite(13, checkThermostat(digitalRead(13))); 
} 

Senza la variabile state però non possiamo visualizzare lo stato del pin 13 sul display. La funzione svolge correttamente il proprio compito, ma non è mai buona idea fare > o < (== ancora peggio) tra variabili di tipo float. Il perché è legato al modo in cui un valore in virgola mobile è conservato in memoria nel formato binario. Possiamo farci una idea seguendo questo link dove nel campo You entered digitiamo 49.02 e nel campo sotto vediamo che il valore effettivo vale 49.020000457763671875, ci sono ben 18 numeri a destra del punto decimale, decisamente troppi. La CPU deve eseguire molte istruzioni elementari e il tempo CPU è prezioso e non va sprecato. C'è anche da dire che la temperatura rilevata da una sonda anche precisa non va oltre i 2 decimali e questo significa prendere in considerazione i centesimi di grado centigrado. 

La nostra funzione allora potrebbe lavorare meglio con variabili di tipo intere grandi 16 bit ciascuna. Possiamo realizzare ciò usando il tipo int16_t al posto di float per le 3 variabili globali. 

Il tipo int16_t può rappresentare un valore decimale nel seguente intervallo: -32768÷32767. Questo vuole dire che se la temperatura rilevata è di 327.67 °C posso ancora convertirlo in intero a 16 bit moltiplicando per 100 che appunto vale 32767. Per avere margine se il termostato deve lavorare con temperature pari o superiori a 327.67 °C al posto di int16_t userò il tipo int32_t. Se ci accontentiamo dei decimi di grado possiamo moltiplicare per 10 e useremo sempre int16_t così la nostra funzione lavorerà fino a poco meno di 3276.0 °C.

Test al simulatore

-

Figura 1: Collegamenti del termostato simulato con wokwi

La Figura 1, mostra i componenti e il loro collegamento, li elenco di seguito per commentarli: 

  • Display - lo impieghiamo per mostrare la temperatura letta dalla sonda NTC (23.99) e la temperatura del setpoint (49.02) impostata tramite il potenziometro. La scritta ON visibile si trasforma in OFF e indica lo stato del relay.

  • Arduino -  il pin 13 è direttamente connesso alla bobina del relay, possiamo fare ciò solo con il simulatore, nella pratica si guasta arduino. Il pin A0 è connesso alla sonda di temperatura NTC. A1 è connesso al pin centrale del potenziometro che ci serve per regolare il setpoint.

  • Potenziometro - Nella pratica è sufficiente un potenziometro lineare da 10k. Aggiungere un condensatore da 100nF tra A1 e GND rende più stabile il valore acquisito dal convertitore ADC.

  • Led - si accende quando i contatti NO del relay si chiudono.

  • Relay - Abbiamo già detto che nella pratica il relay non si può collegare direttamente ad un pin di arduino. Nella pratica i contatti NO ci servono per alimentare la resistenza di riscaldamento, noi per la simulazione ci abbiamo messo il led.

  • NTC - Negative Temperature Coefficient. Si tratta di un resistore il cui valore in ohm varia in base alla sua (del resistore) temperatura. In commercio si trovano schedine simili a quelle in Figura 1, le quali possono essere collegate direttamente ad arduino. Per ricavare il valore di temperatura espresso in gradi centigradi servono un poco di calcoli, io ho usato il codice descritto qui e ne ho ricavato una funzione. 

La funzione per leggere la temperatura

Come anticipato, la funzione per calcolare la temperatura rilevata dalla sonda NTC l'ho ricavata dal tutorial di adafruit, la riporto di seguito:

float getTemperature() {
float average = analogRead(A0);
average = 1023 / average - 1;
average = 10000.0 / average;
//float steinhart;
average = average / 10000.0; // (R/Ro)
average = log(average); // ln(R/Ro)
average /= 3950; // 1/B * ln(R/Ro)
average += 1.0 / (25 + 273.15); // + (1/To)
average = 1.0 / average; // Invert
average -= 273.15;
return average;
}

Si tratta di una semplificazione, in quanto normalmente si acquisiscono 8, 16 (o più) campioni in un secondo (cioè uno ogni 1/16 = 62.5ms) e di questi si fa la media aritmetica.

Cioè i 16 campioni si sommano e poi si divide per il numero di campioni, 16 in questo caso. 

Al simulatore non c'è necessità di effettuare la media aritmetica per cui ho semplificato il codice.

Avviando la simulazione e cliccando sul sensore NTC compare un cursore con il quale variare la temperatura rilevata dalla sonda. Con la rotellina del mouse posso incrementare/decrementare a passi di 0.1°C. Questo ci da la possibilità di verificare il funzionamento del termostato, isteresi inclusa.

La funzione per la media

La lettura dal canale analogico è sempre soggetta ad inquinamento elettromagnetico, per cui leggiamo si il valore della sonda di temperatura ma oltre a questo leggiamo le emissioni elettromagnetiche che qualunque elettrodomestico emette sia nella rete elettrica che attorno ad esso. Tutti gli elettrodomestici per normativa devono emettere meno inquinamento elettromagnetico possibile, ma tecnicamente è impossibile azzerarlo. Per cui commutazioni di carichi importanti, come condizionatori (ON/OFF) ecc rendono la lettura della sonda instabile, avremo un valore che oscilla attorno ad un valore medio. Dall'altro canto, dispositivi elettronici sensibili ai disturbi elettromagnetici si difendono attraverso filtri passivi e algoritmi di acquisizione, con l'obbiettivo di ricavare il segnale utile meno inquinato possibile. Ma anche qui è impossibile renderlo totalmente immune.

L'algoritmo di seguito descritto è il più semplice tra gli algoritmi usabili per filtrare i disturbi, tuttavia abbinato a filtri hardware risulta fornire letture sufficiente stabili.

uint16_t analog16Samples() {
static uint16_t somma;
static uint32_t timer62ms;
static byte nSamples;

if (millis() - timer62ms >= 62) {
timer62ms = millis();
somma += analogRead(A0); // accumula i campioni
nSamples++;
if (nSamples == 16) {
nSamples = 0;
uint16_t media = somma / 16; // calcola la media
somma = 0;
return media; // restituisce la media
}
}
return 0;
}

La funzione restituisce zero per tutte le chiamate da 0÷15, alla 16° chiamata restituisce la media aritmetica dei 16 campioni acquisiti dal pin A0, la cosa si ripete ogni 16 campioni. In breve avremo la media ogni 992ms (quasi 1 secondo). Sfrutteremo questo comportamento per eseguire in loop codice temporizzato.  

Il nome della funzione non dice da dove acquisisce i campioni, da lettore del programma per capire da dove provengono devo leggermi la funzione per scoprire che sono acquisiti dal pin A0. Per migliorare la leggibilità potrei usare il nome read16SamplesOnA0 o altro che sia più comprensibile e magari pure più sintetico, in sostanza serve tempo per trovare il giusto nome, ma quanto tempo dedicare dipende anche da altri fattori, ad esempio la funzione in questione non permette di scegliere da dove acquisire i campioni, e se avessi più sensori, come dovrei fare? 

Inoltre la funzione non fa qualcosa che potrebbe fare e anche per questo motivo non serve dedicargli altro tempo. In particolare la funzione potrebbe anche valutare la bontà della sonda NTC, sonde che hanno il brutto vizio di incamerare umidità e dopo un onorato servizio si devono sostituire preventivamente. Un termostato digitale oggi può permettersi di eseguire un algoritmo che valuti la bontà della sonda e ne segnali il possibile malfunzionamento. Basterebbe già un algoritmo che consideri il delta tra due campionamenti. Cioè, 1° campione 475, 2° campione 257, c'è un delta di  200 in 62ms, il ripetersi frequente di ciò può solo dipendere da un guasto alla sonda. Una sonda in corto o totalmente aperta è facile da verificare, ma al momento questo termostato digitale non ha questa capacità.

Quindi per adesso la funzione resta così com'è con i suoi pregi e difetti, ma attenzione a creare decine di funzioni con questi difetti. Il pregio è che non è "bloccante" nei confronti delle altre istruzioni presenti nella funzione loop() e non è un pregio da poco.

Se usiamo questa funzione dobbiamo modificare la funzione getTemperature() in modo che operi con il valore restituito da analogi16Samples(), la modifica si limita ad inserire nella lista degli argomenti una variabile di tipo uint16_t. Vediamo solo le prime due righe:

float getTemperature(uint16_t a0) {
float average = a0;
... 

Adesso però il nome della funzione non è calzante, poiché la funzione effettua solo dei calcoli in modo che dal valore nell'intervallo 0÷1023 ne risulti un valore di temperatura in virgola mobile, il nome potrebbe essere calcTemperature(), nome che è più coerente con il compito svolto dalla funzione.

Siamo quindi arrivati a mettere il tutto assieme, ciclicamente vengono eseguite queste operazioni così riassunte:

LOOP:
IS MEDIA() == READY) {
    T = CALC(MEDIA())
    LCD_PRINT(T)
    TH_STATE = CHECK_TH(T)
    LCD_PRINT(TH_STATE)
    RELAY(TH_STATE)
}
IS SETPOINT() == CHANGED {
    LCD_PRINT(setpoint)
}
GOTO LOOP

Pseudo linguaggio messo insieme in pochi istanti, impreciso e raffazzonato, non importa, qualunque cosa è buona se aiuta durante lo sviluppo. La sequenza però è corretta, per cui ha una valenza descrittiva.

È come se chiedessi ad una identità non precisata: quando hai una media pronta mi calcoli la temperatura, la stampi, esegui la funzione termostatica, stampi lo stato del termostato e comandi il relay?  Grazie.

Nel mentre che ricavi la media, se il setpoint è cambiato aggiorni il display? Grazie.

Potrei fare una richiesta simile a chiunque sia in grado di cucinare, ad esempio tortellini panna e prosciutto. Rosoli in padella il prosciutto, nel frattempo cuoci i tortellini. Scola i tortellini e unisci panna e prosciutto, mescola per 1 minuto a fuoco lento. Chi non ha mai cucinato non ha idea di cosa voglia dire "Rosolare", non sa che serve l'olio (o altro) e non ha idea di quanto tempo deve cucinare il prosciutto.

L'identità non precisata è capace di eseguire la seguente richiesta: quando hai una media pronta mi calcoli la temperatura e la stampi, perché essa conosce:

  • Da dove acquisire i dati.
  • Come calcolare la media.
  • Come dalla media calcolare la temperatura.
  • Come e dove stampare la temperatura.

Ha queste capacità perché abbiamo creato le funzioni di supporto. Ancora manca il cuoco (loop) che esegue la ricetta, si occuperà il loop() di eseguire la ricetta.

Bottom-up e Top-down

Sono metodologie di sviluppo ed analisi conosciute in vari campi, intraprese anche inconsciamente, esse condizionano pesantemente lo sviluppo. L'una viene descritta come l'opposto all'altra metodologia. Scrivere in merito all'argomento sarebbe per me troppo impegnativo, la cosa interessante che accade sempre con gli opposti e che si creano delle fazioni. Così è stato o così è stato descritto, cioè chi preferisce pensare dal basso verso l'alto crede sia il modo corretto e viceversa. Per fortuna si è capito che non c'è una metodologia sbagliata e una giusta, ma che entrambe sono valide e che non si deve scegliere, ma si devono usare entrambe, passando dall'una all'altra frequentemente. Per fortuna non devo scrivere altro sull'argomento poiché su wikipedia ho trovato l'argomento trattato in modo sintetico e vi invito a leggerlo.

Una citazione dal link: "Niklaus Wirth, che tra altre imprese sviluppò il linguaggio di programmazione Pascal, scrisse l'autorevole documento Lo sviluppo del software per raffinamenti successivi."

Per descrivere il software di questo termostato evidentemente (e inconsciamente) ho usato la metodologia Bottom-Up (dal basso verso l'alto). Mentre se avessi iniziato a mostrarvi il contenuto della funzione loop() avrei usato il metodo Top-Down. 

Ma come è possibile usare una metodologia quando non ci sono funzioni di supporto e la funzione loop() è vuota?

Si procede a piccoli passi fino ad avere del codice funzionante da inserire in una funzione che funga da punto cardine. Ho iniziato con le if presenti nella funzione checkThermostat(), una if per volta, al posto delle variabili ho usato le costanti numeriche, verificato il funzionamento ho usato delle variabili, il tutto all'interno della funzione setup(). Il codice aveva delle dipendenza da tre variabili globali di tipo flot, le ho create ed inizializzate con costanti, solo in seguito ho deciso che queste variabili dovevano essere di tipo int16_t, questo metodo non è altro che la rifinitura successiva continua che permetta all'algoritmo di essere affinato.

Inconsciamente ho scelto il metodo "dal basso verso l'alto". Invece lo pseudo linguaggio usato prima usa il metodo "dall'alto verso il basso", stessa cosa quando immagino di chiedere ad una entità non precisata di compiere delle azioni.

La funzione loop()

Siamo arrivati a vedere il contenuto della funzione loop() (il cuoco), mentre per coloro che hanno premura qui al solito il progetto al simulatore wokwi.

constexpr char *onoff[] = { "OFF", "ON " };
char temperatureBuffer[7];
// struttura di supporto
struct Setpoint {
uint16_t _old;
uint16_t _new = 1024; // new != old
};

Setpoint mySetpoint;


void loop() {
// a0 contiene una lettura aggiornata ogni 62ms x 16 = 992ms
uint16_t a0 = analog16Samples();
// entra nella if (a0 != 0) ogni 992ms
if (a0 != 0) {
Serial.println(millis());
// converte a0 in tf.
float tf = calcTemperature(a0);
// converte tf nella C string temperatureBuffer
dtostrf(tf, 6, 1, temperatureBuffer);
// tronca e converte tf in int16_t
g_temperature = tf * 100;
// visualizza la C string temperatureBuffer sul display
lcd.setCursor(0, 1);
lcd.print(temperatureBuffer);
// chiama la funzione termostato
bool thState = checkThermostate(digitalRead(g_relayPin));
digitalWrite(g_relayPin, thState);
// visualizza ON/OFF sul display
lcd.setCursor(10, 0);
lcd.print(onoff[thState]);
}

// usa struttura di supporto
mySetpoint._old = analogRead(A1);
if (mySetpoint._old != mySetpoint._new) {
mySetpoint._new = mySetpoint._old;
float stpf = mySetpoint._new / 13.3;
g_setpoint = stpf * 100; // da float a uint16_t
dtostrf(stpf, 6, 1, temperatureBuffer);
lcd.setCursor(0, 0);
lcd.print(temperatureBuffer);
}
} // end void loop()

In aggiunta al loop compaiono tre dichiarazioni e una definizione (la struct Setpoint), dove mySetpoint è variabile istanza di tipo Setpoint . Mentre onoff[] è un array di C string che uso per visualizzare lo stato del relay e temperatureBuffer viene usato in dtostrf(). Come recita il commento, nella prima if (... ci entra ogni circa 992ms. Mentre nella seconda if (... ci entra solo quando regoliamo il setpoint agendo sul potenziometro.

Per coloro i quali vogliono sincerarsi che la prima if (... venga davvero eseguita ogni circa 992ms consiglio di aggiungere la stampa di millis() nel serial monitor, così:

if (a0 != 0) {
Serial.println(millis());
// converte a0 in tf.
float tf = calcTemperature(a0);

Dovrebbe stampare (o meglio stampa) quanto segue:

993
1986
2979
3972
4965

Che è la conferma che il "circa 992" è in realtà ogni 993ms, ma Serial.println() si prende il suo tempo per stampare.

La terza puntata

La terza e non ultima puntata descrive due classi che semplificano ulteriormente la creazione di un termostato. Potremo avere fino a sei sensori di temperatura NTC e 6 termostati su arduino UNO.

Termostato con DHT22  

Licenza Creative Commons
Quest'opera è distribuita con Licenza Creative Commons Attribuzione - Condividi allo stesso modo 4.0 Internazionale.

Nessun commento:

Posta un commento