Skip to content

Instantly share code, notes, and snippets.

@birkir
Created November 12, 2023 23:26
Show Gist options
  • Save birkir/12fb20f5d5e0dc911c6fac6ef280295d to your computer and use it in GitHub Desktop.
Save birkir/12fb20f5d5e0dc911c6fac6ef280295d to your computer and use it in GitHub Desktop.
Spa Temperature Controller for Shelly
// Constants
const TARGET_TEMPERATURE = 40;
const PUMP_INTERVAL = 10 * 1000;
const START_STOP_THRESHOLD = 1.0;
const WEATHER_STATION_ID = "1395";
const HEATLOSS_RATE = 0.0158;
const HEATLOSS_RATE_COVER = 0.001206;
const INLET_FLOW_RATE = 2.2334;
const INLET_TEMPERATURE = 43;
const SPA_VOLUME = 1000;
const SURFACE_AREA = 2 * Math.PI * 0.9 * 1.5 + Math.PI * Math.pow(0.9, 2);
// State
var outsideTemperature = 15;
var spaTemperature = 40;
var spaWaterVolumeInLiters = 0;
var pumping = false;
var pumpingStartedAt = null;
// Get outside temperature from vedur.is
function updateOutsideTemperature() {
return Shelly.call(
"HTTP.GET",
{
url:
"https://apis.is/weather/observations/is?stations=" +
WEATHER_STATION_ID,
},
function (result, error_code, error_message) {
try {
var res = JSON.parse(result.body);
outsideTemperature = parseFloat(res.results[0].T, 10);
} catch (err) {}
}
);
}
// Retrieve temperature from addon module
function getSpaTemperature(cb) {
return cb(37.6);
// return Shelly.call("temperature.getStatus", { id: 100 }, cb, null);
}
// Calculate time it takes to fill the hot tub
function estimateFillingTime(volumeLiters, inletFlowRateLitersPerSecond) {
const total = volumeLiters / inletFlowRateLitersPerSecond;
return total;
}
// Calculate the time it takes to reach a target temperature
function estimateHeatingTime(
spaVolumeLiters,
spaTemperatureCelsius,
spaRadiusMeters,
spaHeightMeters,
spaInsulationThicknessMeters,
outsideTemperatureCelsius,
inletFlowRateLitersPerSecond,
inletTemperatureCelsius,
targetSpaTemperatureCelsius
) {
// Constants
const specificHeatCapacity = 4.186; // J/g°C, specific heat capacity of water in J/g°C
const densityWater = 1; // Density of water in g/mL
// Adjusted heat transfer coefficient per meter of insulation per degree Celsius difference per second for demonstration
// This value would need to be determined through empirical data for real-world application
const heatTransferCoefficient = 1.4186; // Arbitrary coefficient for significant impact
// Calculate the total energy required to heat the spa to the target temperature (in joules)
const energyRequired =
spaVolumeLiters *
densityWater *
specificHeatCapacity *
(targetSpaTemperatureCelsius - spaTemperatureCelsius); // E = mcΔT
// Calculate the energy provided by the inlet water per second (in joules)
const energyInletPerSecond =
inletFlowRateLitersPerSecond *
densityWater *
specificHeatCapacity *
(inletTemperatureCelsius - spaTemperatureCelsius);
// Calculate heat loss or gain due to insulation and outside temperature
const surfaceArea =
2 * Math.PI * spaRadiusMeters * (spaHeightMeters + spaRadiusMeters); // Surface area of the cylinder
const temperatureDifference =
spaTemperatureCelsius - outsideTemperatureCelsius;
const heatTransferPerSecond =
surfaceArea *
heatTransferCoefficient *
spaInsulationThicknessMeters *
Math.abs(temperatureDifference);
// Net energy per second is the energy input minus the heat loss or plus the heat gain
let netEnergyPerSecond =
energyInletPerSecond -
(temperatureDifference > 0
? heatTransferPerSecond
: -heatTransferPerSecond);
// Calculate the time required to reach the target temperature
if (netEnergyPerSecond <= 0) {
return Infinity;
}
return energyRequired / netEnergyPerSecond;
}
// format has HH:MM:SS
function formatSeconds(seconds) {
if (seconds === Infinity) return "∞";
var hours = Math.floor(seconds / 3600);
var minutes = Math.floor((seconds - hours * 3600) / 60);
var seconds = Math.floor(seconds - hours * 3600 - minutes * 60);
var short =
(minutes < 10 ? "0" + minutes : minutes) +
":" +
(seconds < 10 ? "0" + seconds : seconds);
if (hours === 0) return short;
return (hours < 10 ? "0" + hours : hours) + ":" + short;
}
// Every minute worker
function pumpWorker() {
return getSpaTemperature(function (value) {
// Update spa temperature from reading
spaTemperature = typeof value === "number" ? value : 0;
// Adjust for outside temperature
var targetSpaTemperature = TARGET_TEMPERATURE;
// Range -10 to 10, should raise target temperature by up to 2 degrees
targetSpaTemperature += Math.min(
Math.max(Math.abs(outsideTemperature - 10) / 5, 0),
2
);
// Detect if hot tub is empty
if (Math.abs(spaTemperature - outsideTemperature) > 10 && !pumping) {
// Reset volume state
spaWaterVolumeInLiters = 0;
}
// Calculate spa volume
const pumpingTimeSeconds =
(pumpingStartedAt ? Date.now() - pumpingStartedAt.getTime() : 0) / 1000;
if (pumping) {
spaWaterVolumeInLiters = Math.max(
0,
Math.min(SPA_VOLUME, pumpingTimeSeconds * INLET_FLOW_RATE)
);
}
const isFilling = spaWaterVolumeInLiters < SPA_VOLUME;
const fillingTime = estimateFillingTime(SPA_VOLUME, INLET_FLOW_RATE);
const heatingTime = estimateHeatingTime(
SPA_VOLUME,
spaTemperature,
0.9,
0.9,
0.1,
outsideTemperature,
INLET_FLOW_RATE,
INLET_TEMPERATURE,
targetSpaTemperature
);
// Nice output
print("-----------------------------");
print(
"Outside " +
outsideTemperature.toFixed(2) +
"°C" +
" / " +
"Spa " +
spaTemperature.toFixed(2) +
"°C" +
" / " +
"Target " +
targetSpaTemperature.toFixed(2) +
"°C" +
" / " +
"Inlet " +
INLET_TEMPERATURE.toFixed(2) +
"°C"
);
print(
"Water level " +
spaWaterVolumeInLiters.toFixed(1) +
"/" +
SPA_VOLUME.toFixed(0) +
" liters" +
" / " +
(pumpingStartedAt
? "Pumping for " + formatSeconds(pumpingTimeSeconds)
: "Not pumping") +
" / " +
"Heat left: " +
formatSeconds(heatingTime) +
" / " +
"Filling left: " +
formatSeconds(fillingTime - pumpingTimeSeconds)
);
if (
spaTemperature <= TARGET_TEMPERATURE - START_STOP_THRESHOLD ||
isFilling
) {
onPumpChange(true);
} else if (spaTemperature >= TARGET_TEMPERATURE) {
onPumpChange(false);
}
});
}
// Handle pump state changes
function onPumpChange(shouldPump) {
if (shouldPump === true) {
if (!pumping) {
pumpingStartedAt = new Date();
}
pumping = true;
// Shelly.call("Switch.Set", { id: 0, on: true });
} else {
pumping = false;
// Shelly.call("Switch.Set", { id: 0, on: false });
}
}
// // Update internal state
// Shelly.call("Switch.GetStatus", { id: 0 }, onSwitchStatus, null);
// Start timers
// var outsideTimer = Timer.set(3600 * 1000, true, updateOutsideTemperature);
var pumpTimer = Timer.set(PUMP_INTERVAL, true, pumpWorker);
pumpWorker();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment