DEV Community

Cover image for IoT Architectures Under Pressure: Smart Thermostat, Scheduling (Part 6)
Adriano Repetti
Adriano Repetti

Posted on • Edited on

IoT Architectures Under Pressure: Smart Thermostat, Scheduling (Part 6)

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: hnet=hinhouthheaterU×A×(ToutsideTinside)h_\mathrm{net} = h_\mathrm{in} - h_\mathrm{out} \approx h_\mathrm{heater} - U \times A \times (T_\mathrm{outside} - T_\mathrm{inside}) . If you want a rough adjustment, adding 50% to the hsh_s 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 °C/hour\mathrm{°C/hour} our system can achieve, accounting for seasonal variations and outside temperature.

In short, we're simply going to use:

hs=cp×ρ×q×Δt h_s = c_p \times \rho \times q \times \varDelta t

Consequently:

T=hsP T = \frac{h_s}{P}

Where:

hsh_s = Sensible Heat in kW\mathrm{kW} .
cpc_p = Specific Heat of Air in kJ/kg°C\mathrm{kJ/kg °C} .
ρ\rho = Density of Air in kg/m3\mathrm{kg/m^3} .
qq = Air Volume Flow in m3/s\mathrm{m^3/s} .
Δt\varDelta t = ttargettcurrentt_\mathrm{target} - t_\mathrm{current} in °C\mathrm{°C} .
PP = Heating element power in kW/h\mathrm{kW/h} .
TT = Time required to reach the required temperature ttargett_\mathrm{target} in hours.

We know that cp=1.006kJ/kg°Cc_p = 1.006 \enspace \mathrm{kJ/kg °C} and ρ=1.202kg/m3\rho = 1.202 \enspace \mathrm{kg/m^3} .

Let's calculate how long does it take to heat a room from 10 °C to 20 °C, assuming q=1m3/sq = 1 \enspace \mathrm{m^3/s} and P=10kWP = 10 \enspace \mathrm{kW} .

T=(1.006kJ/kg°C)×(1.202kg/m3)×(1m3/s)×(10°C)10kW=12.09212kW10kW/h1.2hours \begin{split}T &= \frac {(1.006 \enspace \mathrm{kJ/kg °C}) \times (1.202 \enspace \mathrm{kg/m^3}) \times (1 \enspace \mathrm{m^3/s}) \times (10 \enspace \mathrm{°C})}{10 \enspace \mathrm{kW}}\newline &= \frac{12.09212 \enspace \mathrm{kW}}{10 \enspace \mathrm{kW/h}} \approx 1.2 \enspace \mathrm{hours}\end{split}

Let's add a new configuration entry:

  "heatingPower": "10 kW"
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 hsh_s 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;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
chen-feng-cn profile image
Chen Feng ( 陳冯) • Edited

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.

Collapse
 
adriano-repetti profile image
Adriano Repetti • Edited

We surely could set a predetermined value in the configuration file, something like
"degreesPerHourCapability": 5 then simply calculate the required time as i8 hours = delta / degreesPerHourCapability.

However, I had four reasons to make it slightly more complicate than it could have been:

  • It's easier to setup using P (you should know your furnace's power).
  • It takes a considerable time for a device to learn, we want to provide a somewhat sensible starting point.
  • I am not planning a post to talk about ML (or some traditional statistical method) to calculate this value knowing the outside temperature, the day and the past performance.
  • It's easier to include heatLossesCompensation(in the reviewed) code when working with hsh_s .

TL;DR: could we simplify calculateHoursToTemperature()? We sure can!

Collapse
 
chen-feng-cn profile image
Chen Feng ( 陳冯)

Is getCurrentWeathersynchronous?

Collapse
 
adriano-repetti profile image
Adriano Repetti

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.