In a previous post, we started designing a smart thermostat to show an alternative and cost-effective approach for IoT devices development.
In this post we are going to build on that initial MVP.
Scheduling
The first improvement is to start the furnace a bit earlier, ensuring that by the target time, the desired temperature has already been reached.
To keep things as simple as possible, we will ignore several factors:
- Heat losses: These depend on various elements such as insulation, wall volume, and outside temperature. Ignoring other heat sources, we can approximate this with: . If you want a rough adjustment, adding 50% to the calculation could help account for it.
- Time-dependent values: We assume they remain constant, even though they actually change over time.
- Additional heat sources: Sensible and latent heat contributions from people, lighting, and electronic devices.
- Environmental factors: Humidity and pressure variations.
While we are simplifying a lot, the reality is that we are building a smart thermostat, so these factors only matter up to a point. Future iterations of our device will collect data to learn precisely how many our system can achieve, accounting for seasonal variations and outside temperature.
In short, we're simply going to use:
Consequently:
Where:
= Sensible Heat in
.
= Specific Heat of Air in
.
= Density of Air in
.
= Air Volume Flow in
.
=
in
.
= Heating element power in
.
= Time required to reach the required temperature
in hours.
We know that and .
Let's calculate how long does it take to heat a room from 10 °C to 20 °C, assuming and .
Let's add a new configuration entry:
"heatingPower": "10 kW"
If you know your furnace's power in BTU/h then remember that 1 W is (more or less) 3.41 BTU/h. The code to calculate how many hours in advance we need to turn on the furnace:
const SPECIFIC_HEAT_OF_AIR = 1.006;
const DENSITY_OF_AIR = 1.202;
const AIR_VOLUME_FLOW = 1; // This might be configuration
function calculateHoursToTemperature(
context: Context,
currentTemperature: i8,
targetTemperature: i8) {
const dt = targetTemperature - currentTemperature;
if (dt < 0)
return 0;
const h = SPECIFIC_HEAT_OF_AIR * DENSITY_OF_AIR * AIR_VOLUME_FLOW * dt;
return h / context.config.get<Measure>("heatingPower").toFloat("kW/h");
}
Now we can refactor our initial code:
function applyFurnaceStatus(context: Context, stream: IoStream) {
// If we have a scheduled setting then we must honor it
const currentSchedule = getScheduledTemperature(context, "current");
if (currentSchedule.id !== null) {
setTemperatureAndFurnace(context, currentSchedule.value);
return;
}
// We do not have a target temperature for now, do we need to
// start heating to be ready for the next one?
const nextSchedule = getScheduledTemperature(context, "next");
if (nextSchedule.id === null) {
setTemperatureAndFurnace(context, currentSchedule.value);
return;
}
const hoursToReachTemperature = calculateHoursToTemperature(
context,
getCurrentTemperature(stream),
nextSchedule.value
);
if (hoursToReachTemperature >= nextSchedule.delay)
setTemperatureAndFurnace(context, nextSchedule.value);
}
function getCurrentTemperature(stream: IoStream) {
stream.writeByte(READ_TEMPERATURE_COMMAND);
stream.flush();
return F32.parseFloat(stream.readLine());
}
function getScheduledTemperature(context: Context, select: "current" | "next") {
const scheduling = Scheduling.resolve("schedule", select);
return { ...scheduling, value: toValue(scheduling) };
function toValue(id: string) {
if (id === "1")
return context.variables.get("desired_temp_1");
if (schedule.id === "2")
return context.variables.get("desired_temp_2");
return null;
}
}
function setTemperatureAndFurnace(context: Context, temperature: i8) {
context.variables.set("target_temp", temperature);
if (temperature === null) {
setFurnaceStatus(stream, false);
return;
}
const currentTemperature = getCurrentTemperature(stream);
const isFurnaceActive = currentTemperature < temperature;
setFurnaceStatus(stream, isFurnaceActive);
}
function setFurnaceStatus(stream: IoStream, active: bool) {
if (context.variables.get<bool>("furnace") != active) {
stream.writeByte(status ? FURNACE_ON_COMMAND : FURNACE_OFF_COMMAND);
context.variables.set("furnace", status);
}
}
Change Schedule According to Weather Forecast
There are a few more basic features to add, but with a working scheduler in place, we can finally introduce our first smart feature. When the temperature difference between inside and outside exceeds a configured threshold, we will increase
by 50% (based on configuration).
To achieve this, we’ll leverage a service exposed by our host, following this contract in Google protobuf (because, let’s be honest, no one wants to write or read WASM bindings):
syntax = "proto3";
import "google/protobuf/timestamp.proto";
package Weather;
service ForecastService {
rpc get_current_weather (location) returns (current_weather);
// ...
}
message location {
float latitude = 1;
float longitude = 2;
// ...
}
message current_weather {
google.protobuf.Timestamp timestamp = 1;
float temperature = 2;
float feels_like = 3;
// ...
}
If you are wondering why protobuf: because under the hood we may expose all services with gRPC (enabling load balancing among multiple hubs, secure connections and even seamlessly integration with remote cloud services). We might see more of this when we are going to discuss a reference hub implementation in a separate series (because this approach isn't limited to IoT applications!).
Now we can add a few configuration options:
"criticalDifferenceWithExternalTemperature": "10 °C",
"heatLossesCompensation": 1.5
And change calculateHoursToTemperature()
accordingly:
function calculateHoursToTemperature(
context: Context,
currentTemperature: i8,
targetTemperature: i8) {
const dt = targetTemperature - currentTemperature;
if (dt < 0)
return 0;
let h = SPECIFIC_HEAT_OF_AIR * DENSITY_OF_AIR * AIR_VOLUME_FLOW * dt;
const externalTemperature = readExternalTemperature(context);
const maxExternalDelta = context.config.get<Measure>("criticalDifferenceWithExternalTemperature").toFloat("°C");
if (currentTemperature - externalTemperature >= maxExternalDelta)
h *= context.config.get<f32>("heatLossesCompensation");
return h / context.config.get<Measure>("heatingPower").toFloat("kW/h");
}
function readExternalTemperature(context: Context) {
return context.services.get<ForecastService>("Weather.ForecastService").getCurrentWeather().temperature;
}
Just like that, we’ve introduced our first true smart feature. Notice how we didn’t have to venture beyond our domain of thermostat design, and the hardware (covered in the next post) is even more affordable than older models.
Of course, there’s much more involved in creating a fully smart thermostat, such as data collection, presets and the ability to learn the optimal base temperature when you're away. However, those enhancements lie beyond the scope of these posts.
Top comments (4)
If the device is designed to "learn", wouldn't it be simpler to hard-code a value for Hs? You already picked an arbitrary value for the air flow.
We surely could set a predetermined value in the configuration file, something like
"degreesPerHourCapability": 5
then simply calculate the required time asi8 hours = delta / degreesPerHourCapability
.However, I had four reasons to make it slightly more complicate than it could have been:
P
(you should know your furnace's power).heatLossesCompensation
(in the reviewed) code when working with hs .TL;DR: could we simplify
calculateHoursToTemperature()
? We sure can!Is
getCurrentWeather
synchronous?Unfortunately it is. :(
I'm writing these examples in AssemblyScript and it currently does not support promises. There are some workaround but...I didn't want to make things more complicate than they already are. Some other languages offer somehow that support but, as far as I can tell, it ultimately depends on proper support on WASM. Feel free to share any effective workaround you know of!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.