Inconsistent fan rpm reading vs pwm duty

Hi everyone,

I'm setting up a Home Assistant control center in a RAM Promaster converted as an RV.
One of the system to control is a set of 5 PWM fans dedicated to cool the back of a fridge cabinet. The fans are all connected to a fan hub, meaning all fans get the same PWM signal and only one fan reports its speed.
I'm using an Arduino Uno R4 WiFi to read the temperature of the cabinet, to send the PWM signal and to read the RPM.

I used mariuste code to determine the PWM duty value based on temperature (Fan_Temp_Control/code/Fan_Temp_Control/Fan_Temp_Control.ino at main · mariuste/Fan_Temp_Control · GitHub).
I used Federico Dossena code to read fan RPM (How to properly control PWM fans with Arduino - Federico Dossena).
Since I'm using an Arduino Uno R4 WiFi, I had to change the PWM commands from timer control to PwmOut as described by pschatzmann in https://www.pschatzmann.ch/home/2023/07/01/under-the-hood-arduino-uno-r4-pwm/.

After a few days of testing, I found an inconsistency between the PWM duty commanded value and the corresponding fan RPM.
Based on the temperature reading, which is within 0.1 degree of the actual value, the PWM duty varies between 10% and 100%. According to the manufacturer of the fans (Arctic P12 Max), the fans have a range of 400 to 3300 RPM. So, according to fan data sheet, a duty of 100% should equate 3300 RPM, 50 % = 1650 RPM, etc.

I did lots of tests and confirmed the following:
When the PWM signal is disconnected from the fan PWM input, the fan turns at full speed and the RPM reading indicates 3300.
When the PWM duty commanded value is 100%, the RPM reading also indicates 3300.
But when the PWM duty commanded value is lower than 100%, the RPM reading is higher than expected value (ex. at 50%, the RPM reading is around 2000 instead of 1650.)

So, what am I doing wrong?
Please help

Here is my code:

/*
This code allows:
1. iPad mini on/off charging cycle between ~25% and ~80%.
2. Fridge cabinet fans speed control based on cabinet temperature.
3. RAM Promaster VSIM sensor data monitoring.
*/

/*
Fan speed control based on fridge cabinet temperature constants/variables:
tempLow (default 35)    - Below this temperature (minus half hysteresis) the fan shuts off.
                          It shuts on again at this temperature plus half hysteresis.
tempHigh (default 50)   - At and above this temperature the fan is at maximum speed.
hysteresis (constant 5) - Hysteresis to prevent frequent on/off switching at the threshold.
minDuty (constant 10)   - Minimum fan speed to prevent stalling
maxDuty (constant 100)  - Maximum fan speed to limit noise

PIN02 = Temperature sensor input pin
PIN10 = PWM output pin

Fan speed RPM reading:
debounce (0)      - Used to filter out noise
fanstuckThreshold - Timer used to detect if no interrupts are received to consider the fan as stuck and report 0 RPM

PIN03 = RPM sensor input pin
*/

#include <Arduino.h>
#include <WiFiS3.h>
#include <ArduinoHA.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <pwm.h>

#define BROKER_ADDR       IPAddress(x,x,x,x)
#define WIFI_SSID         "ssid"
#define WIFI_PASSWORD     "password"

WiFiClient client;
HADevice device;
HAMqtt mqtt(client, device);

// Temperature sensor
#define ONE_WIRE_BUS 2
OneWire oneWire(ONE_WIRE_BUS);  
DallasTemperature sensors(&oneWire);
const int tempSetInterval = 5000;
unsigned long tempLastReadAt = millis();
float tempLow = 35;
float tempHigh = 50;
const float hysteresis = 5;
const int minDuty = 10;
const int maxDuty = 100;

// PWM
#define PIN10 10
PwmOut pwm(PIN10);
const float pin10Freq = 25000;
bool fanState = HIGH;
float duty = 100;
float newDuty = 100;

// RPM
#define PIN03 3
#define debounce 0
#define fanstuckThreshold 500
unsigned long volatile ts1=0,ts2=0;
unsigned long rpmLastReadAt = millis();

// Arduino
#define PIN09 9
#define PIN13 13
unsigned long lastReadAt = millis();
bool Pin09lastInputState = false;
HABinarySensor Pin09("Pin09");
HASwitch Pin13("Pin13");
HASensorNumber fanTemperature("fanTemperature", HASensorNumber::PrecisionP1);
HASensorNumber fanRPM("fanRPM");
HANumber fanTempLow("fanTempLow", HANumber::PrecisionP1);
HANumber fanTempHigh("fanTempHigh", HANumber::PrecisionP1);

void setup()
{
    pinMode(PIN09, INPUT_PULLUP);
    pinMode(PIN13, OUTPUT);
    digitalWrite(PIN13, LOW); // sets the digital pin 13 off
    Pin09lastInputState = digitalRead(PIN09);

    byte mac[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
    WiFi.macAddress(mac);
    device.setUniqueId(mac, sizeof(mac));

    device.setName("Arduino Uno R4 WiFi");
    device.setSoftwareVersion("1.0.0");
    Pin09.setName("Pin09");
    Pin09.setDeviceClass("door");
    Pin09.setIcon("mdi:fire");
    Pin09.setCurrentState(Pin09lastInputState);
    Pin13.setName("Pin13");
    Pin13.setIcon("mdi:battery-charging");
    fanTemperature.setIcon("mdi:temperature-celsius");
    fanTemperature.setName("Fan Temperature");
    fanTemperature.setUnitOfMeasurement("°C");
    fanTempLow.setIcon("mdi:temperature-celsius");
    fanTempLow.setName("Fan Temperature Low");
    fanTempLow.setUnitOfMeasurement("°C");
    fanTempLow.setStep(0.5f);
    fanTempLow.setMin(20);
    fanTempLow.setMax(60);
    fanTempLow.setCurrentState(tempLow);
    fanTempHigh.setIcon("mdi:temperature-celsius");
    fanTempHigh.setName("Fan Temperature High");
    fanTempHigh.setUnitOfMeasurement("°C");
    fanTempHigh.setStep(0.5f);
    fanTempHigh.setMin(20);
    fanTempHigh.setMax(60);
    fanTempHigh.setCurrentState(tempHigh);
    fanRPM.setIcon("mdi:rotate-3d-variant");
    fanRPM.setName("Fan RPM");
    fanRPM.setUnitOfMeasurement("RPM");

    Pin13.onCommand(onSwitchCommand);
    fanTempLow.onCommand(onFanTempLowCommand);
    fanTempHigh.onCommand(onFanTempHighCommand);

    Serial.begin(9600);
    Serial.println();

    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

    while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
    }
    Serial.println("");
    Serial.println("WiFi connected");

    device.enableSharedAvailability();
    device.enableLastWill();

    // PWM
    pwm.begin(pin10Freq, duty);
	
    // Temperature sensor - start up the temperature library 
    sensors.begin();
    sensors.requestTemperatures();

    // Temperature reporting
    Serial.println("# Main Loop");
    Serial.println("(temperature, state, Duty Cycle, Fan On/Off)");
    Serial.println();

    // RPM setup
    pinMode(PIN03,INPUT_PULLUP);
    attachInterrupt(digitalPinToInterrupt(PIN03),tachISR,FALLING);

    // MQTT broker connection
    mqtt.begin(BROKER_ADDR, "username", "password");
}

void loop()
{
    mqtt.loop();

    if ((millis() - lastReadAt) > 30) { // read in 30ms interval
        // library produces MQTT message if a new state is different than the previous one
        Pin09.setState(digitalRead(PIN09));
        Pin09lastInputState = Pin09.getCurrentState();
        lastReadAt = millis();
    }

    if ((millis() - tempLastReadAt) > tempSetInterval) { // read in 5000ms interval
        // measure temperature, calculate Duty cycle, set PWM
	tempToPwmDuty();
        tempLastReadAt = millis();
    }

    if ((millis() - rpmLastReadAt) > 1000) { // read in 1000ms interval
        // measure RPM
        fanRPM.setValue(calcRPM());
        rpmLastReadAt = millis();
    }
}

void onSwitchCommand(bool state, HASwitch* sender)
{
    digitalWrite(PIN13, (state ? HIGH : LOW));
    sender->setState(state); // report state back to the Home Assistant
}

void onFanTempLowCommand(HANumeric number, HANumber* sender)
{
    tempLow = number.toFloat();
    sender->setState(number); // report the selected option back to the HA panel
}

void onFanTempHighCommand(HANumeric number, HANumber* sender)
{
    tempHigh = number.toFloat();
    sender->setState(number); // report the selected option back to the HA panel
}

void tachISR()
{
    unsigned long m=millis();
    if ((m-ts2) > debounce) {
        ts1=ts2;
        ts2=m;
    }
}

unsigned long calcRPM()
{
    if (millis()-ts2<fanstuckThreshold && ts2!=0) {
        return (60000/(ts2-ts1))/2;
    } else return 0;
}

void tempToPwmDuty()
{
    sensors.requestTemperatures();
    float temp = sensors.getTempCByIndex(0);
    Serial.print(temp);
    Serial.print("°C, ");
    fanTemperature.setValue(temp);
    if (temp < tempLow) {
        // distinguish two cases to consider hysteresis
        if (fanState == HIGH) {
            if (temp < tempLow - (hysteresis / 2) ) {
                // fan is on, temp below threshold minus hysteresis -> switch off
                Serial.print("a, ");
                newDuty = 0;
            } else {
                // fan is on, temp not below threshold minus hysteresis -> keep minimum speed
                Serial.print("b, ");
                newDuty = minDuty;
            }
        } else if (fanState == LOW) {
            // fan is off, temp below threshold -> keep off
            Serial.print("c, ");
            newDuty = 0;
        }
    } else if (temp < tempHigh) {
        // distinguish two cases to consider hysteresis
        if (fanState == HIGH) {
            // fan is on, temp above threshold > control fan speed
            Serial.print("d, ");
            newDuty = map(temp, tempLow, tempHigh, minDuty, maxDuty);
        } else if (fanState == LOW) {
            if (temp > tempLow + (hysteresis / 2) ) {
                // fan is off, temp above threshold plus hysteresis -> switch on
                Serial.print("e, ");
                newDuty = minDuty;
            } else {
                // fan is on, temp not above threshold plus hysteresis -> keep off
                Serial.print("f, ");
                newDuty = 0;
            }
        }
    } else if (temp >= tempHigh) {
        // fan is on, temp above maximum temperature -> maximum speed
        Serial.print("g, ");
        newDuty = maxDuty;
    } else {
        // any other temperature -> maximum speed (this case should never occur
        Serial.print("h, ");
        newDuty = maxDuty;
    }
    //set new duty
    duty = newDuty;
    Serial.print(duty);
    Serial.print("%, ");
    if (fanState==0) {Serial.println("OFF");} else {Serial.println("ON");}
    Serial.print(tempLow);
    Serial.print(", ");
    Serial.println(tempHigh);
    setPwmDuty();
}

void setPwmDuty()
{
    if (duty == 0) {
        fanState = LOW;
    } else if (duty > 0) {
        fanState = HIGH;
    }
    pwm.pulse_perc(duty);
}

Please link the source on which you are basing the above calculation. The technical information in the Arctic P12 Max User Manual from arctic.de indicates that the relationship between PWM duty cycle and fan speed is actually non-linear, and that for a supply voltage of 12 V, one can expect approximately 2200 RPM at 50% duty cycle:

You get the RPM signal from fan for a purpose - to adjust PWM according to RPM feedback, not according to expectations.
PWM -RPM ratio is not linear, neither is air flow m3/h - RPM relationship.

grb, thank you very much for your reply.
Honestly, I feel like a rookie.
Somehow, I missed this diagram.
It explains exactly the readouts I'm having.

I initially started the project with an Arduino Uno R3 and got lots of problems reading the fan RPM. Values were completely off.
So I reached out to Arctic and, instead of pointing me to the P12 Max diagram, they pointed me to this specification (https://wiki.kobol.io/helios4/files/fan/4_Wire_PWM_Spec.pdf) where on page 14 there is a diagram showing a one to one relationship between % of full speed and % duty. So I assumed the fan would operate the same and never really searched for a P12 Max fan speed vs PWM.
Having no results with the Uno R3, I left the project rest for a few months.
In the meantime I purchased an Arduino Uno R4 WiFi and got better results with basically the same code and electronic setup.
So, I guess my Uno R3 is probably defective.

Anyhow, thanks again for your reply.
Now my next step is to package all the parts in a nice box.
FBO

kmin, thanks for your reply.

I read the RPM only to confirm the fan is turning.
As I mentionned to grb, the spec given to me by Arctic support indicates a one to one relationship between % of full speed and % duty.
With the diagram pointed out by grb, It's clear the PWM-RPM ratio is not linear.
The RPM readings are normal, so case closed.

Thanks again

Ok.
Usually it's used as feedback to adjust PWM.

Although it is unclear why they thought a specification document would be relevant to your query, this is an interesting document, because it shows that the Arctic P12 Max does not meet the published specifications:

Fan operating voltage shall be within the range 12 V ±5% V. [p. 9]
...
The fan RPM (as a percentage of maximum RPM) should match the PWM duty cycle within ±10%. [p. 14]

In contrast, their own performance data show that at 12.0 V, a 50% duty cycle yields a rotational speed that is 65% of the maximum RPM — while the spec requires that such a high speed should only be attained when the PWM duty cycle is in the range 55–75% (so that the fan RPM will "match the PWM duty cycle within ±10%").

Perhaps you can ask them about that.

As explained above, it only has to be "one to one" within ±10% to meet the specs.

This would be the case if using feedback control to regulate fan speed. In OP's case, they are using feedback control to regulate temperature (so the PWM duty cycle is adjusted according to the readings of a temperature sensor):

newDuty = map(temp, tempLow, tempHigh, minDuty, maxDuty);

Concept is same, you adjust duty cycle based on feedback, not based on expectations.
Duty cycle - air flow relationship is far from linear.

Thanks grb for your analysis.
Even if the fan doesn’t meet the specs, I’m still satisfied with the performance of the fan.
I chose it for its quietness.
I raised a concern about the RPM reading vs PWM duty because I thought I had something to adjust in my programming/electronic setup. The P12 Max PWM vs RPM diagram confirms the readings are OK, thus my setup is OK.

Thanks Kmin,
I don’t adjust based on expectations.
I use expected values to confirm the values provided/calculated by my programming.
In my case, I had the wrong expected values since I didn’t refer to the published P12 Max PWM vs RPM curves.

1 Like

This topic was automatically closed 180 days after the last reply. New replies are no longer allowed.