Multi-zone heating control

A few years ago, my parents bought a new house. It was 20 years old and in a very good shape, but there were a few things that were not quite to our taste.

The previous owners had an easy solution for most problems : just throw more money at it until it goes away. Whilst that may have worked for them, it resulted in some strange solutions and an expensive house to live in…

Here is a write-up about how the heating is now setup.

The end result

First of all, it works ! That was far from guaranteed when I started that project two years ago…

This is the day to day interface (basic ui and openhab mobile app.) :


The system runs on a schedule, which can be modified by the “Présences” switches (these are manual) or by simply setting a new setpoint “consigne” (it will keep it until the next schedule change).

There is no other way to control the heating; you must use a smartphone or computer. This has not been a problem for us.

Last winter, we have consumed 20-25% less oil than what the previous owners told us they were using. I guess we also save 1 to 1,5kWh of electricity each day by not running a 80W pump 24h a day. (Granted, the system was really badly configured before.)

The house is now more comfortable; no more cold rooms, and only one room remains too hot (we never heat the kitchen but it is still the hottest room in the house.)

The schedule is made in Nodered… because I couldn’t find a better solution to have complex timers that my father could also program.

All the intelligence is also in Nodered.

For actuators, I use 4 Raspberry Pis and with a simple relay board. They communicate to the central nodered via MQTT.

Details

Physical devices

Most rooms have a water-based under-floor heating solution. All bedrooms (except mine) have radiators instead of under-floor heating.

It was not cost-effective to individually control radiators, so all radiators are in one zone. Each radiator has a manual thermostatic valve.

There are 3 distribution manifolds for all floor heating. Each one has a wifi-connected RPi and relays.

In the boiler room, I have control over the temperature of the oil-fuelled boiler (~30°C, ~45°C, ~55°C, ~65°C and ~75°C). There is a pump for the radiators and an other one for the floor heating. It is possible to have different temperature for the floor heating and radiators via a 3-way valve allowing to mix cold, return water into the floor heating water. All this is also controlled by a RPi.

The previous owners just left the radiator’s pump always on; all the floor heating was controlled by one thermostat, permanently set to 22°C…

I use Oregon Scientific thermometers (20-25€/piece) and the RFXCOM usb receiver (~100€) to mesure temperatures as they are cheap, wireless and innocuous.

My backup solution is to have set up all the relays so that when disconnected (no power to relays), it works just like the old system. We have left the old thermostat set to 19°C in the living room. I have explained to my family how to disconnect the 4 Rpis if things ever go wrong. If MQTT communication is lost, they will automatically default to that state too. We thankfully have not yet needed to use that feature !

Software

I use Openhab as a UI and a logger. All the intelligence is in Nodered. All communication (except thermometers) is via MQTT.

For the actual regulation, we have a simple on/off with hysteresis. I would like to do PID, but the valves are too slow and have asymmetric times (something like 3m to open, 5m to close) so it’s not possible to do PWM. We do have overshoot, something like 0.6 °C in the worst case. As it only happens once per heating period, we just arrange the schedule so that that ‘bump’ happens when we’re most likely to use the room.

Lastly, we have saved a lot of troubleshooting time by having grafana show us graphs about all temperatures (including departing and returning water temp, measured at the boiler).

Lessons learned

  • RF is weird. Sometimes some of the thermometers would drop and only come back hours later. All solved after one year by… moving the RF receiver 6m.
  • Data, data, data. (It has to be well presented too.) Without Grafana, I would still not even have noticed some of the issues I have had.
  • Be careful when working remotely. I have once turned a relay on instead of off. When I came back home (thankfully, only half a day later), I was quite surprised to see how hot the living-room was ! :-p
  • Use modular components (including software). That enabled me to completely change the software architecture with minimal fuss and no noticeable downtime.
  • When I bought the RPis, I didn’t know ESP8266 or MySensors existed. (Yeah, I have learned a lot.) Looking back, it was actually the right choice for something as critical as the heating system. I do not seem to be able to make Arduino’s work reliably, whilst the RPis have been rock solid.
  • Our boiler is oversized :-p

Things I would like to do better

  • Find a way to do PWM on the valves, and put a PWM regulation on it.

  • Find a user-friendly way to create custom, conditional* schedules.

  • by conditional schedules I mean having the schedule change according to, for example, a presence switch in Openhab.

If you have read me up to this point, thanks ! :slight_smile:

Extra screenshots

Advanced section, showing the state of every actuator


One of grafana’s graphs

The actual regulation intelligence (nodered)

A part of my (not ideal) solution for the schedule

12 Likes

My first thought was simply to PWM the feed into the valve and work out how much ( if at all ) you could control the position of the actuator. Having thought about it I think that’s over thinking it.
1 - Initially establish the open and close time of the valve.
2 - Use a long time constant for the ‘PWM’ and simply open and close the valve entirely in such a way as to get the right percentage on time called for.
The inertia of a heating system, especially UFH is such that controlling it, even in one second slices, is pointless. a minute or so may be more appropriate.

Yes, I thought about doing that, but I couldn’t find a solution that suited me. First, I don’t know the exact time the valves take to open or close (can’t seem to find the datasheet); measuring is difficult because they are so slow, and do not ‘click’ at either end of the travel.

How long is too long for the time constant ? 30min ? 1h ? 1h seems too long for me and 30min will have big imprecisions (which may or may not be problematic; I haven’t tested.)

Also, it complicates the code. You either set once per period which percentage you need (have to be careful not to change the setpoint in the middle of a period) or have some way to adjust in the middle of the period (not impossible, but adds complexity and imprecision.)

It might have worked, but I was not convinced enough to give it a shot.

I do have an idea, which is to have variable time periods. Always have the valve in motion, and arrange the timing such that the “area under the graph” is what you want. Also, be sure to come back to one end of the travel at each cycle.

This allows for short periods (maximum = time to go fully open + time to go fully closed), and relatively precise adjustments. However, everything above 50% will get skewed towards 100%, everything below 50% will get skewed towards 0%, to be sure to return to one end of the travel at each cycle.

This drawing might make it clearer :


Vertical axis = position of the valve
Horizontal axis = time

Times B-C, D-E and G-H are there to ensure we do get back to one end of the travel at each cycle.

I am planning to test that as soon as I got back from my semester abroad, which should be in a month or so.

Interesting feedback. An idea just jumped in to my head though.

My actuators have a position indicator rod that protrudes up from the top of the body. I could fit a position sensor to that and sense the position of the valve. … The feedback would allow me to set a position by PWM’ing the control signal … Oh dear, something else to play with :slight_smile:

1 Like

Huh, I also have an indicator rod but never thought about using it…
I can however foresee “mechanical” problems with that solution. (How to fix you measuring device ?)

But it would be really great to be able to read the actual position !

Since someone has asked for my code, here it is.
Use it as inspiration source only, it was not designed to be reused for other systems. :slight_smile:

Missing are all the MQTT nodes, at the begin and/or the end of the flows. It should be relatively obvious where they should go. :slight_smile:

Node-red : regulation

[
    {
        "id": "8b5865a5.a04078",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Manage defaulted zones",
        "func": "//When a zone has no new temperature readings for a long time, it will \"copy\" what a defaultZone does\n\nvar MAX_INTERVAL_READINGS = 3600; //Max sec. between two readings before defaulting\n//Idéalement, doit être plus long que le plus long temps d'hysteresis\n\nvar defaultZone = \"Salon\" //Zone to default to\n\n\n// Code\n\n//Désactivate automatic regulation\nif (global.get(\"Regulation\") !== undefined && global.get(\"Regulation\") < 1) {\n    return null;\n}\n\ncurrentTime = Math.round((new Date()).getTime() / 1000);\n\nvar zonesArray = flow.get(\"zonesArray\");\nvar defaultedZones = [];\n\nmsg.zonesArray = zonesArray;\n\nif (zonesArray[defaultZone] === undefined || zonesArray[defaultZone][\"lastUpdate\"] === undefined || currentTime - zonesArray[defaultZone][\"lastUpdate\"] > MAX_INTERVAL_READINGS) {\n    //All hope is lost…\n    node.status({fill:\"red\",shape:\"ring\",text:defaultZone + \" has defaulted… :'(\"});\n    return null;\n}\n\nfor (var zone in zonesArray) {\n    if (zonesArray[zone][\"lastUpdate\"] === undefined || currentTime - zonesArray[zone][\"lastUpdate\"] > MAX_INTERVAL_READINGS && zone != defaultZone) {\n        defaultedZones.push(zone);\n    }\n}\n\n//debug\nmsg.defaultedZones = defaultedZones;\n\nif (defaultedZones.length > 0) {\n    node.status({fill:\"red\",shape:\"dot\",text:\"Some zone(s) have defaulted.\"});\n    var newMessages = [[]];\n    var defaultedState = zonesArray[defaultZone][\"state\"];\n    \n    for (var zone in defaultedZones) {\n        newMessages[0].push({payload:defaultedState, zone:defaultedZones[zone]});\n    }\n    msg.newMessages = newMessages;\n    //return msg;\n    return newMessages;\n}\nelse {\n    node.status({fill:\"green\",shape:\"dot\",text:\"No zone has defaulted.\"});\n    return null;\n}",
        "outputs": 1,
        "noerr": 0,
        "x": 330,
        "y": 740,
        "wires": [
            [
                "2c7399f5.10bde6",
                "d25d775d.365da"
            ]
        ]
    },
    {
        "id": "2b2b317a.fd6fe6",
        "type": "switch",
        "z": "49319db8.e1b464",
        "name": "Thermometres",
        "property": "topic",
        "propertyType": "msg",
        "rules": [
            {
                "t": "cont",
                "v": "/Salon_Temperature",
                "vt": "str"
            },
            {
                "t": "cont",
                "v": "/Cuisine_Temperature",
                "vt": "str"
            },
            {
                "t": "cont",
                "v": "/Bureau_Temperature",
                "vt": "str"
            },
            {
                "t": "cont",
                "v": "/Mezzanine_Temperature",
                "vt": "str"
            },
            {
                "t": "cont",
                "v": "/PetitSalon_Temperature",
                "vt": "str"
            },
            {
                "t": "cont",
                "v": "/ChParents_Temperature",
                "vt": "str"
            },
            {
                "t": "cont",
                "v": "/ChQuentin_Temperature",
                "vt": "str"
            },
            {
                "t": "cont",
                "v": "/Hall_Temperature",
                "vt": "str"
            },
            {
                "t": "cont",
                "v": "/SdBParents_Temperature",
                "vt": "str"
            }
        ],
        "checkall": "false",
        "outputs": 9,
        "x": 380,
        "y": 440,
        "wires": [
            [
                "6fa2e03b.54e1f8"
            ],
            [
                "a9d2a359.bf2da8"
            ],
            [
                "22474adb.5340ae"
            ],
            [
                "11a3ebcf.d9097c"
            ],
            [
                "2bb881ea.7a103e"
            ],
            [
                "9c5ebee1.fd5d8"
            ],
            [
                "253014.5fbda7ec"
            ],
            [
                "9b653647.b7e868"
            ],
            [
                "9af1ae3d.55b258"
            ]
        ]
    },
    {
        "id": "6fa2e03b.54e1f8",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Salon",
        "func": "//Only change this variable\nmsg.zone = \"Salon\";\n\n\n//Code\nmsg.newTemp = parseFloat(msg.payload);\nif (isNaN(msg.newTemp)) {\n    node.error(\"Invalid temp received\", msg);\n    return null;\n}\nmsg.consigne = global.get(msg.zone + \"_Setpoint\");\n\nif (msg.consigne === undefined){\n    node.error(\"No valid schedule found !\", msg);\n    return null;\n}\n\nif (msg.consigne > msg.newTemp + msg.hysteresis) {\n    msg.payload = \"ON\";\n    node.status({fill:\"green\",shape:\"dot\",text:\"Heating\"});\n}\nelse if (msg.consigne < msg.newTemp - msg.hysteresis) {\n    msg.payload = \"OFF\";\n    node.status({fill:\"red\",shape:\"dot\",text:\"Not heating\"});\n}\nelse {\n    msg.payload = \"HYSTERESIS\";\n    node.status({fill:\"grey\",shape:\"dot\",text:\"Hysteresis…\"});\n}\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 550,
        "y": 180,
        "wires": [
            [
                "6b5ce50f.17177c",
                "fb60fd21.038b"
            ]
        ]
    },
    {
        "id": "72deaffd.89ea3",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Set variables",
        "func": "//Remove unused vars\ndelete msg.qos;\ndelete msg.retain;\n\n//Set vars\nmsg.hysteresis = 0.1;\n\n//Desactivate regulation if we want to do thing manually\nif (global.get(\"Regulation\") !== undefined && global.get(\"Regulation\") < 1) {\n    return null;\n}\nelse {\n    return msg;\n}",
        "outputs": 1,
        "noerr": 0,
        "x": 210,
        "y": 440,
        "wires": [
            [
                "2b2b317a.fd6fe6"
            ]
        ]
    },
    {
        "id": "6b5ce50f.17177c",
        "type": "switch",
        "z": "49319db8.e1b464",
        "name": "Radiateurs?",
        "property": "zone",
        "propertyType": "msg",
        "rules": [
            {
                "t": "eq",
                "v": "ChParents",
                "vt": "str"
            },
            {
                "t": "else"
            }
        ],
        "checkall": "false",
        "outputs": 2,
        "x": 790,
        "y": 440,
        "wires": [
            [
                "81a4ab2d.169738"
            ],
            [
                "372c7ceb.061e4c"
            ]
        ]
    },
    {
        "id": "372c7ceb.061e4c",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Contrôle pompe sol",
        "func": "/*\nPasses msg on output 1 and create a second message for the pump on output 2.\n */\n\nvar zonesArray = flow.get(\"zonesArray\");\n//We need to create the object\nif (!(zonesArray instanceof Object)) {\n    zonesArray = {};\n}\n\nif (msg.payload == \"HYSTERESIS\" && zonesArray[msg.zone] !== undefined) {\n    //Just update 'lastUpdate', do not change 'state'\n    zonesArray[msg.zone][\"lastUpdate\"] = Math.round((new Date()).getTime() / 1000);\n}\nelse {\n    zonesArray[msg.zone] = {state:msg.payload, lastUpdate:Math.round((new Date()).getTime() / 1000)};\n}\n\nflow.set(\"zonesArray\", zonesArray); //Save the array for later use\nif (msg.payload == \"HYSTERESIS\") {\n    return null;\n}\n\nvar pump = \"OFF\";\nnode.status({fill:\"red\",shape:\"dot\",text:\"Off\"});\nfor (var zone in zonesArray) {\n    if (zonesArray[zone][\"state\"] == \"ON\" || zonesArray[zone][\"state\"] >= 5) {\n        pump = \"ON\";\n        node.status({fill:\"green\",shape:\"dot\",text:\"On\"});\n    }\n}\n\nvar pumpMsg = {payload: pump, topic: \"/openhab/in/command/Sol_Pump\"};\nreturn [msg, pumpMsg];",
        "outputs": "2",
        "noerr": 0,
        "x": 1000,
        "y": 460,
        "wires": [
            [
                "d25d775d.365da"
            ],
            [
                "a36df686.1f096",
                "6fb853ab.9165fc"
            ]
        ]
    },
    {
        "id": "d25d775d.365da",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Change Topic",
        "func": "//Will *delete* msg and create new message(s) to control individual 'chapeaux'.\n\n\n\nif (msg.zone == \"Salon\") {\n    return {payload: msg.payload, topic:\"/openhab/in/command/Salon_Chapeau\"};\n}\nelse if (msg.zone == \"Cuisine\") {\n    return {payload: msg.payload, topic:\"/openhab/in/command/Cuisine_Chapeau\"};\n}\nelse if (msg.zone == \"Bureau\") {\n    return {payload: msg.payload, topic:\"/openhab/in/command/Bureau_Chapeau\"};\n}\nelse if (msg.zone == \"Mezzanine\") {\n    return {payload: msg.payload, topic:\"/openhab/in/command/Mezzanine_Chapeau\"};\n}\nelse if (msg.zone == \"PetitSalon\") {\n    return {payload: msg.payload, topic:\"/openhab/in/command/PetitSalon_Chapeau\"};\n}\nelse if (msg.zone == \"ChParents\") {\n    return null; //Separate circuit with only pump, no chapeau\n}\nelse if (msg.zone == \"ChQuentin\") {\n    return {payload: msg.payload, topic:\"/openhab/in/command/ChQuentin_Chapeau\"};\n}\nelse if (msg.zone == \"Hall\") {\n    return [[{payload: msg.payload, topic:\"/openhab/in/command/HallBas_Chapeau\"}, {payload: msg.payload, topic:\"/openhab/in/command/HallHaut_Chapeau\"}]];\n}\nelse if (msg.zone == \"SdBParents\") {\n    return {payload: msg.payload, topic:\"/openhab/in/command/SdBParents_Chapeau\"};\n}\nelse{\n    node.error(\"Unknown zone\", msg);\n    return null;\n}",
        "outputs": 1,
        "noerr": 0,
        "x": 1340,
        "y": 600,
        "wires": [
            [
                "e09d60f9.577178"
            ]
        ]
    },
    {
        "id": "81a4ab2d.169738",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Contrôle pompe radiateur",
        "func": "/*\nPasses msg on output 1 and create a second message for the pump on output 2.\n */\n\nvar pump;\n\nif (msg.payload == \"HYSTERESIS\") {\n    return null; //Just stay the way we are\n}\n\nif (msg.payload == \"ON\" || msg.payload >= 5) {\n    pump = \"ON\";\n    node.status({fill:\"green\",shape:\"dot\",text:\"On\"});\n}\nelse {\n    pump = \"OFF\";\n    node.status({fill:\"red\",shape:\"dot\",text:\"Off\"});\n}\n\nvar pumpMsg = {payload: pump, topic: \"/openhab/in/command/Radiateurs_Pump\"};\nreturn [msg, pumpMsg];",
        "outputs": "2",
        "noerr": 0,
        "x": 1010,
        "y": 400,
        "wires": [
            [
                "d25d775d.365da"
            ],
            [
                "a36df686.1f096",
                "7ca6cbf.9713734"
            ]
        ]
    },
    {
        "id": "9c5ebee1.fd5d8",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "ChParents",
        "func": "//Only change this variable\nmsg.zone = \"ChParents\";\n\n\n//Code\nmsg.newTemp = parseFloat(msg.payload);\nif (isNaN(msg.newTemp)) {\n    node.error(\"Invalid temp received\", msg);\n    return null;\n}\nmsg.consigne = global.get(msg.zone + \"_Setpoint\");\n\nif (msg.consigne === undefined){\n    node.error(\"No valid schedule found !\", msg);\n    return null;\n}\n\nif (msg.consigne > msg.newTemp + msg.hysteresis) {\n    msg.payload = \"ON\";\n    node.status({fill:\"green\",shape:\"dot\",text:\"Heating\"});\n}\nelse if (msg.consigne < msg.newTemp - msg.hysteresis) {\n    msg.payload = \"OFF\";\n    node.status({fill:\"red\",shape:\"dot\",text:\"Not heating\"});\n}\nelse {\n    msg.payload = \"HYSTERESIS\";\n    node.status({fill:\"grey\",shape:\"dot\",text:\"Hysteresis…\"});\n}\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 570,
        "y": 480,
        "wires": [
            [
                "6b5ce50f.17177c",
                "fb60fd21.038b"
            ]
        ]
    },
    {
        "id": "a9d2a359.bf2da8",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Cuisine",
        "func": "//Only change this variable\nmsg.zone = \"Cuisine\";\n\n\n//Code\nmsg.newTemp = parseFloat(msg.payload);\nif (isNaN(msg.newTemp)) {\n    node.error(\"Invalid temp received\", msg);\n    return null;\n}\nmsg.consigne = global.get(msg.zone + \"_Setpoint\");\n\nif (msg.consigne === undefined){\n    node.error(\"No valid schedule found !\", msg);\n    return null;\n}\n\nif (msg.consigne > msg.newTemp + msg.hysteresis) {\n    msg.payload = \"ON\";\n    node.status({fill:\"green\",shape:\"dot\",text:\"Heating\"});\n}\nelse if (msg.consigne < msg.newTemp - msg.hysteresis) {\n    msg.payload = \"OFF\";\n    node.status({fill:\"red\",shape:\"dot\",text:\"Not heating\"});\n}\nelse {\n    msg.payload = \"HYSTERESIS\";\n    node.status({fill:\"grey\",shape:\"dot\",text:\"Hysteresis…\"});\n}\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 560,
        "y": 240,
        "wires": [
            [
                "6b5ce50f.17177c",
                "fb60fd21.038b"
            ]
        ]
    },
    {
        "id": "22474adb.5340ae",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Bureau",
        "func": "//Only change this variable\nmsg.zone = \"Bureau\";\n\n\n//Code\nmsg.newTemp = parseFloat(msg.payload);\nif (isNaN(msg.newTemp)) {\n    node.error(\"Invalid temp received\", msg);\n    return null;\n}\nmsg.consigne = global.get(msg.zone + \"_Setpoint\");\n\nif (msg.consigne === undefined){\n    node.error(\"No valid schedule found !\", msg);\n    return null;\n}\n\nif (msg.consigne > msg.newTemp + msg.hysteresis) {\n    msg.payload = \"ON\";\n    node.status({fill:\"green\",shape:\"dot\",text:\"Heating\"});\n}\nelse if (msg.consigne < msg.newTemp - msg.hysteresis) {\n    msg.payload = \"OFF\";\n    node.status({fill:\"red\",shape:\"dot\",text:\"Not heating\"});\n}\nelse {\n    msg.payload = \"HYSTERESIS\";\n    node.status({fill:\"grey\",shape:\"dot\",text:\"Hysteresis…\"});\n}\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 560,
        "y": 300,
        "wires": [
            [
                "6b5ce50f.17177c",
                "fb60fd21.038b"
            ]
        ]
    },
    {
        "id": "11a3ebcf.d9097c",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Mezzanine",
        "func": "//Only change this variable\nmsg.zone = \"Mezzanine\";\n\n\n//Code\nmsg.newTemp = parseFloat(msg.payload);\nif (isNaN(msg.newTemp)) {\n    node.error(\"Invalid temp received\", msg);\n    return null;\n}\nmsg.consigne = global.get(msg.zone + \"_Setpoint\");\n\nif (msg.consigne === undefined){\n    node.error(\"No valid schedule found !\", msg);\n    return null;\n}\n\nif (msg.consigne > msg.newTemp + msg.hysteresis) {\n    msg.payload = \"ON\";\n    node.status({fill:\"green\",shape:\"dot\",text:\"Heating\"});\n}\nelse if (msg.consigne < msg.newTemp - msg.hysteresis) {\n    msg.payload = \"OFF\";\n    node.status({fill:\"red\",shape:\"dot\",text:\"Not heating\"});\n}\nelse {\n    msg.payload = \"HYSTERESIS\";\n    node.status({fill:\"grey\",shape:\"dot\",text:\"Hysteresis…\"});\n}\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 570,
        "y": 360,
        "wires": [
            [
                "6b5ce50f.17177c",
                "fb60fd21.038b"
            ]
        ]
    },
    {
        "id": "253014.5fbda7ec",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "ChQuentin",
        "func": "//Only change this variable\nmsg.zone = \"ChQuentin\";\n\n\n//Code\nmsg.newTemp = parseFloat(msg.payload);\nif (isNaN(msg.newTemp)) {\n    node.error(\"Invalid temp received\", msg);\n    return null;\n}\nmsg.consigne = global.get(msg.zone + \"_Setpoint\");\n\nif (msg.consigne === undefined){\n    node.error(\"No valid schedule found !\", msg);\n    return null;\n}\n\nif (msg.consigne > msg.newTemp + msg.hysteresis) {\n    msg.payload = \"ON\";\n    node.status({fill:\"green\",shape:\"dot\",text:\"Heating\"});\n}\nelse if (msg.consigne < msg.newTemp - msg.hysteresis) {\n    msg.payload = \"OFF\";\n    node.status({fill:\"red\",shape:\"dot\",text:\"Not heating\"});\n}\nelse {\n    msg.payload = \"HYSTERESIS\";\n    node.status({fill:\"grey\",shape:\"dot\",text:\"Hysteresis…\"});\n}\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 570,
        "y": 540,
        "wires": [
            [
                "6b5ce50f.17177c",
                "fb60fd21.038b"
            ]
        ]
    },
    {
        "id": "9b653647.b7e868",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Hall",
        "func": "//Only change this variable\nmsg.zone = \"Hall\";\n\n\n//Code\nmsg.newTemp = parseFloat(msg.payload);\nif (isNaN(msg.newTemp)) {\n    node.error(\"Invalid temp received\", msg);\n    return null;\n}\nmsg.consigne = global.get(msg.zone + \"_Setpoint\");\n\nif (msg.consigne === undefined){\n    node.error(\"No valid schedule found !\", msg);\n    return null;\n}\n\nif (msg.consigne > msg.newTemp + msg.hysteresis) {\n    msg.payload = \"ON\";\n    node.status({fill:\"green\",shape:\"dot\",text:\"Heating\"});\n}\nelse if (msg.consigne < msg.newTemp - msg.hysteresis) {\n    msg.payload = \"OFF\";\n    node.status({fill:\"red\",shape:\"dot\",text:\"Not heating\"});\n}\nelse {\n    msg.payload = \"HYSTERESIS\";\n    node.status({fill:\"grey\",shape:\"dot\",text:\"Hysteresis…\"});\n}\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 550,
        "y": 600,
        "wires": [
            [
                "6b5ce50f.17177c",
                "fb60fd21.038b"
            ]
        ]
    },
    {
        "id": "9af1ae3d.55b258",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "SdBParents",
        "func": "//Only change this variable\nmsg.zone = \"SdBParents\";\n\n\n//Code\nmsg.newTemp = parseFloat(msg.payload);\nif (isNaN(msg.newTemp)) {\n    node.error(\"Invalid temp received\", msg);\n    return null;\n}\nmsg.consigne = global.get(msg.zone + \"_Setpoint\");\n\nif (msg.consigne === undefined){\n    node.error(\"No valid schedule found !\", msg);\n    return null;\n}\n\nif (msg.consigne > msg.newTemp + msg.hysteresis) {\n    msg.payload = \"ON\";\n    node.status({fill:\"green\",shape:\"dot\",text:\"Heating\"});\n}\nelse if (msg.consigne < msg.newTemp - msg.hysteresis) {\n    msg.payload = \"OFF\";\n    node.status({fill:\"red\",shape:\"dot\",text:\"Not heating\"});\n}\nelse {\n    msg.payload = \"HYSTERESIS\";\n    node.status({fill:\"grey\",shape:\"dot\",text:\"Hysteresis…\"});\n}\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 570,
        "y": 660,
        "wires": [
            [
                "6b5ce50f.17177c",
                "fb60fd21.038b"
            ]
        ]
    },
    {
        "id": "a2faa2b1.9f65f",
        "type": "inject",
        "z": "49319db8.e1b464",
        "name": "Every hour",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "repeat": "3600",
        "crontab": "",
        "once": false,
        "x": 90,
        "y": 740,
        "wires": [
            [
                "8b5865a5.a04078"
            ]
        ]
    },
    {
        "id": "2c7399f5.10bde6",
        "type": "debug",
        "z": "49319db8.e1b464",
        "name": "",
        "active": false,
        "console": "false",
        "complete": "true",
        "x": 610,
        "y": 740,
        "wires": []
    },
    {
        "id": "a36df686.1f096",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Contrôle temperature eau",
        "func": "//To see which setpoints to use, check nodered on rpi-chaudiere\nsolTemp = 40;\nradiateursTemp = 55;\n\nsolTempOff = 20;\nradiateursTempOff = 20;\n\nradTopicIn = \"/openhab/in/command/Radiateurs_Pump\";\nsolTopicIn = \"/openhab/in/command/Sol_Pump\"\nchaudiereTopicOut = \"/openhab/in/command/Chaudiere_Setpoint\";\nsolTopicOut = \"/openhab/in/command/DepartSol_Setpoint\";\n\n\n//Code\nif (msg.payload != \"ON\" && msg.payload != \"OFF\") {\n    node.error(\"Received msg with wrong payload.\", msg);\n    return null;\n}\n\nif (msg.topic == radTopicIn) {\n    context.set(\"pumpRadiateurs\", msg.payload);\n}\nelse if (msg.topic == solTopicIn) {\n    context.set(\"pumpSol\", msg.payload);\n}\nelse {\n    node.error(\"Received msg with wrong topic.\", msg);\n    return null;\n}\n\nvar startSol;\nvar startRadiateurs;\nif (context.get(\"pumpSol\") === undefined || context.get(\"pumpSol\") == \"OFF\") {\n    startSol = false;\n}\nelse {\n    startSol = true;\n}\nif (context.get(\"pumpRadiateurs\") === undefined || context.get(\"pumpRadiateurs\") == \"OFF\") {\n    startRadiateurs = false;\n}\nelse {\n    startRadiateurs = true;\n}\n\nvar msgTempSol = {topic:solTopicOut};\nvar msgTempChaudiere = {topic:chaudiereTopicOut};\nif (!startSol && !startRadiateurs) {\n    msgTempSol.payload = solTempOff;\n    msgTempChaudiere.payload = radiateursTempOff;\n}\nelse if (!startSol && startRadiateurs) {\n    msgTempSol.payload = solTempOff;\n    msgTempChaudiere.payload = radiateursTemp;\n}\nelse if (startSol && !startRadiateurs) {\n    msgTempSol.payload = solTemp;\n    msgTempChaudiere.payload = solTemp;\n}\nelse if (startSol && startRadiateurs) {\n    msgTempSol.payload = solTemp;\n    msgTempChaudiere.payload = radiateursTemp;\n}\n\nreturn [msgTempSol, msgTempChaudiere];",
        "outputs": "2",
        "noerr": 0,
        "x": 1370,
        "y": 420,
        "wires": [
            [
                "2921d9ac.576896"
            ],
            [
                "c8408e29.9dea3"
            ]
        ]
    },
    {
        "id": "3f926e04.fb64da",
        "type": "comment",
        "z": "49319db8.e1b464",
        "name": "Parametres température eau",
        "info": "",
        "x": 1380,
        "y": 380,
        "wires": []
    },
    {
        "id": "9d8f375.5e4a848",
        "type": "comment",
        "z": "49319db8.e1b464",
        "name": "Paramètre hystéresis",
        "info": "",
        "x": 220,
        "y": 400,
        "wires": []
    },
    {
        "id": "b1522e8f.67e41",
        "type": "comment",
        "z": "49319db8.e1b464",
        "name": "Paramètre zones sans mesure récente",
        "info": "",
        "x": 350,
        "y": 700,
        "wires": []
    },
    {
        "id": "fb60fd21.038b",
        "type": "debug",
        "z": "49319db8.e1b464",
        "name": "",
        "active": false,
        "console": "false",
        "complete": "true",
        "x": 770,
        "y": 400,
        "wires": []
    },
    {
        "id": "2bb881ea.7a103e",
        "type": "function",
        "z": "49319db8.e1b464",
        "name": "Petit Salon",
        "func": "//Only change this variable\nmsg.zone = \"PetitSalon\";\n\n\n//Code\nmsg.newTemp = parseFloat(msg.payload);\nif (isNaN(msg.newTemp)) {\n    node.error(\"Invalid temp received\", msg);\n    return null;\n}\nmsg.consigne = global.get(msg.zone + \"_Setpoint\");\n\nif (msg.consigne === undefined){\n    node.error(\"No valid schedule found !\", msg);\n    return null;\n}\n\nif (msg.consigne > msg.newTemp + msg.hysteresis) {\n    msg.payload = \"ON\";\n    node.status({fill:\"green\",shape:\"dot\",text:\"Heating\"});\n}\nelse if (msg.consigne < msg.newTemp - msg.hysteresis) {\n    msg.payload = \"OFF\";\n    node.status({fill:\"red\",shape:\"dot\",text:\"Not heating\"});\n}\nelse {\n    msg.payload = \"HYSTERESIS\";\n    node.status({fill:\"grey\",shape:\"dot\",text:\"Hysteresis…\"});\n}\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 570,
        "y": 420,
        "wires": [
            [
                "6b5ce50f.17177c",
                "fb60fd21.038b"
            ]
        ]
    },
    {
        "id": "e09d60f9.577178",
        "type": "delay",
        "z": "49319db8.e1b464",
        "name": "",
        "pauseType": "rate",
        "timeout": "50",
        "timeoutUnits": "milliseconds",
        "rate": "1",
        "nbRateUnits": "1",
        "rateUnits": "second",
        "randomFirst": "1",
        "randomLast": "5",
        "randomUnits": "seconds",
        "drop": false,
        "x": 1750,
        "y": 500,
        "wires": [
            [
                "acccff1a.facd4"
            ]
        ]
    },
    {
        "id": "c8408e29.9dea3",
        "type": "rbe",
        "z": "49319db8.e1b464",
        "name": "",
        "func": "rbe",
        "gap": "",
        "start": "",
        "inout": "out",
        "x": 1570,
        "y": 440,
        "wires": [
            [
                "e09d60f9.577178"
            ]
        ]
    },
    {
        "id": "2921d9ac.576896",
        "type": "rbe",
        "z": "49319db8.e1b464",
        "name": "",
        "func": "rbe",
        "gap": "",
        "start": "",
        "inout": "out",
        "x": 1570,
        "y": 400,
        "wires": [
            [
                "e09d60f9.577178"
            ]
        ]
    },
    {
        "id": "6fb853ab.9165fc",
        "type": "rbe",
        "z": "49319db8.e1b464",
        "name": "",
        "func": "rbe",
        "gap": "",
        "start": "",
        "inout": "out",
        "x": 1310,
        "y": 520,
        "wires": [
            [
                "e09d60f9.577178"
            ]
        ]
    },
    {
        "id": "7ca6cbf.9713734",
        "type": "rbe",
        "z": "49319db8.e1b464",
        "name": "",
        "func": "rbe",
        "gap": "",
        "start": "",
        "inout": "out",
        "x": 1310,
        "y": 480,
        "wires": [
            [
                "e09d60f9.577178"
            ]
        ]
    }
]

This is hack-ish :
Nodered : save openhab state

[
    {
        "id": "feb9c50a.21df2",
        "type": "function",
        "z": "40c562ef.ffacf4",
        "name": "Set ALL Openhab items to global variables",
        "func": "\nlastSlashIndex = msg.topic.lastIndexOf('/');\n\nitem = msg.topic.slice(lastSlashIndex+1);\nglobal.set(item, msg.payload);\n\n\nreturn null;",
        "outputs": "0",
        "noerr": 0,
        "x": 370,
        "y": 40,
        "wires": []
    }
]

…and here’s the schedule.

Nodered : part of schedule

[
    {
        "id": "8494ec94.28e3a",
        "type": "function",
        "z": "87b37a8c.842998",
        "name": "Set Quentin",
        "func": "flow.set(\"quentin\", msg.payload);\nmsg.quentin = msg.payload;\nreturn msg;",
        "outputs": "1",
        "noerr": 0,
        "x": 313.01953125,
        "y": 158.00392150878906,
        "wires": [
            [
                "d12c47b9.de5668"
            ]
        ]
    },
    {
        "id": "bb998031.2eadd",
        "type": "function",
        "z": "87b37a8c.842998",
        "name": "Ch Quentin",
        "func": "flow.set(\"schedChQuentin\",msg.payload);\nmsg.quentin = flow.get(\"quentin\");\nreturn msg;",
        "outputs": "1",
        "noerr": 0,
        "x": 310,
        "y": 100,
        "wires": [
            [
                "d12c47b9.de5668"
            ]
        ]
    },
    {
        "id": "d12c47b9.de5668",
        "type": "switch",
        "z": "87b37a8c.842998",
        "name": "Quentin?",
        "property": "quentin",
        "propertyType": "flow",
        "rules": [
            {
                "t": "eq",
                "v": "ON",
                "vt": "str"
            },
            {
                "t": "eq",
                "v": "OFF",
                "vt": "str"
            }
        ],
        "checkall": "false",
        "outputs": 2,
        "x": 493.00002670288086,
        "y": 123.00000667572021,
        "wires": [
            [
                "b492556c.876398"
            ],
            [
                "d74badb0.6de46"
            ]
        ]
    },
    {
        "id": "b492556c.876398",
        "type": "function",
        "z": "87b37a8c.842998",
        "name": "Get Ch Quentin",
        "func": "msg.payload = flow.get(\"schedChQuentin\");\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 674.0000076293945,
        "y": 101.00000095367432,
        "wires": [
            [
                "4eafa8e1.f0aab"
            ]
        ]
    },
    {
        "id": "d74badb0.6de46",
        "type": "change",
        "z": "87b37a8c.842998",
        "name": "10°C",
        "rules": [
            {
                "t": "set",
                "p": "payload",
                "pt": "msg",
                "to": "10",
                "tot": "str"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 674.0000305175781,
        "y": 145.00000095367432,
        "wires": [
            [
                "4eafa8e1.f0aab"
            ]
        ]
    },
    {
        "id": "4eafa8e1.f0aab",
        "type": "change",
        "z": "87b37a8c.842998",
        "name": "Consigne Ch Quentin",
        "rules": [
            {
                "t": "set",
                "p": "topic",
                "pt": "msg",
                "to": "/openhab/in/command/ChQuentin_Setpoint",
                "tot": "str"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 912.0000610351562,
        "y": 120.00000095367432,
        "wires": [
            [
                "399b654c.e345fa"
            ]
        ]
    },
    {
        "id": "399b654c.e345fa",
        "type": "function",
        "z": "87b37a8c.842998",
        "name": "Get Mode Vacances",
        "func": "msg.modeVacances = flow.get(\"modeVacances\");\n\nif (msg.modeVacances == \"ON\"){\n    return [msg, null];\n}\nelse {\n    return [null, msg];\n}",
        "outputs": "2",
        "noerr": 0,
        "x": 1189.0000610351562,
        "y": 1069.0000276565552,
        "wires": [
            [],
            [
                "96de886f.974a18",
                "4436a9f1.c67948"
            ]
        ]
    },
    {
        "id": "cc227feb.11509",
        "type": "inject",
        "z": "87b37a8c.842998",
        "name": "15:00 Daily 19°C",
        "topic": "",
        "payload": "19",
        "payloadType": "num",
        "repeat": "",
        "crontab": "00 15 * * *",
        "once": false,
        "x": 113,
        "y": 598.0000400543213,
        "wires": [
            [
                "d69b4f6b.c8055"
            ]
        ]
    },
    {
        "id": "f60ff73d.3b8dd8",
        "type": "inject",
        "z": "87b37a8c.842998",
        "name": "21:00 Daily 18°C",
        "topic": "",
        "payload": "18",
        "payloadType": "num",
        "repeat": "",
        "crontab": "00 21 * * *",
        "once": false,
        "x": 113,
        "y": 629.0000400543213,
        "wires": [
            [
                "d69b4f6b.c8055"
            ]
        ]
    },
    {
        "id": "d69b4f6b.c8055",
        "type": "change",
        "z": "87b37a8c.842998",
        "name": "Consigne Mezzanine",
        "rules": [
            {
                "t": "set",
                "p": "topic",
                "pt": "msg",
                "to": "/openhab/in/command/Mezzanine_Setpoint",
                "tot": "str"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 848.0000762939453,
        "y": 617.0000877380371,
        "wires": [
            [
                "399b654c.e345fa"
            ]
        ]
    },
    {
        "id": "4436a9f1.c67948",
        "type": "delay",
        "z": "87b37a8c.842998",
        "name": "",
        "pauseType": "rate",
        "timeout": "50",
        "timeoutUnits": "milliseconds",
        "rate": "30",
        "nbRateUnits": "1",
        "rateUnits": "minute",
        "randomFirst": "1",
        "randomLast": "5",
        "randomUnits": "seconds",
        "drop": false,
        "x": 1148.8946685791016,
        "y": 2013.6371364593506,
        "wires": [
            [
                "67117656.bfe96"
            ]
        ]
    },
    {
        "id": "fb1fffff.f8756",
        "type": "openhab2-in",
        "z": "87b37a8c.842998",
        "name": "Mode Vacances",
        "controller": "73896086.0707b",
        "itemname": "Mode_vacances",
        "x": 85,
        "y": 2102.00008392334,
        "wires": [
            [
                "1e244bee.98c084"
            ],
            []
        ]
    },
    {
        "id": "1e244bee.98c084",
        "type": "function",
        "z": "87b37a8c.842998",
        "name": "Set Mode Vacances",
        "func": "flow.set(\"modeVacances\",msg.payload);\nmsg.modeVacances = msg.payload;\nif (msg.modeVacances == \"ON\") {\n    return [msg, null];\n    }\n    else {\n        return[null, msg];\n    }\n\n",
        "outputs": "2",
        "noerr": 0,
        "x": 306,
        "y": 2100.00008392334,
        "wires": [
            [
                "98f27d6.f5a738",
                "58c53b6b.561764",
                "f4343316.d17cc"
            ],
            [
                "4524825f.dab63c",
                "bcfb82af.57c188",
                "da2e3616.78cbb8"
            ]
        ]
    },
    {
        "id": "24c4ef23.03aac",
        "type": "change",
        "z": "87b37a8c.842998",
        "name": "Consigne Ch Quentin",
        "rules": [
            {
                "t": "set",
                "p": "topic",
                "pt": "msg",
                "to": "/openhab/in/command/ChQuentin_Setpoint",
                "tot": "str"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 780,
        "y": 1960,
        "wires": [
            [
                "4436a9f1.c67948"
            ]
        ]
    },
    {
        "id": "98f27d6.f5a738",
        "type": "change",
        "z": "87b37a8c.842998",
        "name": "5°C",
        "rules": [
            {
                "t": "set",
                "p": "payload",
                "pt": "msg",
                "to": "5",
                "tot": "num"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 570,
        "y": 1980,
        "wires": [
            [
                "24c4ef23.03aac",
                "875cde37.05f03",
                "ede063f0.db032",
                "69227c9f.6d6614",
                "3f9adf45.5b4fe",
                "1c977a39.0e9c96",
                "4e339d1c.fb22e4",
                "4c1aec0e.1e2814",
                "da90bc22.68d89"
            ]
        ]
    },
    {
        "id": "3f9adf45.5b4fe",
        "type": "change",
        "z": "87b37a8c.842998",
        "name": "Consigne Mezzanine",
        "rules": [
            {
                "t": "set",
                "p": "topic",
                "pt": "msg",
                "to": "/openhab/in/command/Mezzanine_Setpoint",
                "tot": "str"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 780,
        "y": 2040,
        "wires": [
            [
                "4436a9f1.c67948"
            ]
        ]
    },
    {
        "id": "4524825f.dab63c",
        "type": "change",
        "z": "87b37a8c.842998",
        "name": "17°C",
        "rules": [
            {
                "t": "set",
                "p": "payload",
                "pt": "msg",
                "to": "17",
                "tot": "num"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 570,
        "y": 2020,
        "wires": [
            [
                "875cde37.05f03",
                "ede063f0.db032",
                "69227c9f.6d6614",
                "3f9adf45.5b4fe",
                "1c977a39.0e9c96",
                "4e339d1c.fb22e4",
                "24c4ef23.03aac",
                "da90bc22.68d89"
            ]
        ]
    },
    {
        "id": "73896086.0707b",
        "type": "openhab2-controller",
        "z": "",
        "name": "Openhab fileServer ittre",
        "protocol": "http",
        "host": "192.168.1.150",
        "port": "8081",
        "path": "",
        "username": "",
        "password": ""
    }
]

Impressive work. Thanks for sharing!

This is how many commercial regulators work.
For example, with a cycle time of 10 minutes and normally closed valves:

loop{
X = 30 * <required heating power (0.0-1.0)>
at minute 0: apply power to valve
at minute X: shutoff power to valve
at minute 30: restart loop
}

You never need to know how long it takes for the valve to close or open since it is a dynamic PI controller running in a separate loop.
If the temperature is too low: increase required power
If the temperature is too high: decrease required power

This is very well described in the manual of the MDT KNX-controlled Heating actuator

Nice document, thank you !

Hum… it may work better than how I think it would.
I thought PI controllers needed a linear response (as in, when they ask 5% more, they always expect x extra Watts of power, with x a fixed number.) That is not the case with your algorithm when the valves take a significant portion of the cycle time to open. It probably is a smaller problem than what I think it is.

Also, I’d have to time my schedules to avoid having to wait (at most) a whole cycle before the valves started moving.

Well… as of right now, I doubt I will implement a PWM solution as the system is actually working fine (even though we have tweaked the schedules so that the peak temperature is at just the right time, that work is already done.)

If I ever implement something new for the heating system it would be a pre-heating system. Say I get back home every day at 18h, the system should determine itself based on actual inside/outside temperature and known heating curve when to start heating so that the home is warm at exactly 18h. And even that won’t be anytime soon, I have other projects on the horizon for now !