Created
November 12, 2023 23:26
-
-
Save birkir/12fb20f5d5e0dc911c6fac6ef280295d to your computer and use it in GitHub Desktop.
Spa Temperature Controller for Shelly
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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