-
Notifications
You must be signed in to change notification settings - Fork 1
/
Heating control algorithm.txt
991 lines (884 loc) · 60.1 KB
/
Heating control algorithm.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--
-- Functionality for heating and hot water control
--
-- Uses user variables for boiler parameters, pre-heat time limits, etc and zone definitions
-- Each zone should be associated with an indoor sensor, an outdoor sensor and a zone valve
--
-- Jeff T, September 2020
--
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-- CHANGE LOG
-- 20/09/20 - Jeff - add hysteresis functionality to getHeatingTime
-- 11/10/20 - Jeff - add heat source warm-up time into the calculation (getHeatingTime)
-- 11/10/20 - Jeff - added facility for 4 hours of forecast outdoor temperatures
-- 11/10/20 - Jeff - added master + local valve capability and valve type definitions
-- 14/10/20 - Jeff - only master valves should conform to heat source minimum on-time requirement; assume that local valves aren't creating the call for heat
-- 14/10/20 - Jeff - create two semaphores for 20-second lockouts pre- and post-processing to avoid multiple setpoint schedules triggering the event in succession and to avoid setpoint
-- changes from within the event re-triggering it immediately it ends; these have to use devices as we need the 'afterSecs()' functionality of dzVents to time them
-- 15/10/20 - Jeff - allow for valve mode strings to be empty/missing so that we can leave valves in heat mode and just manipulate their setpoints
-- 19/11/20 - Jeff - if valve is defined with a % control, use that rather than time modulation to control it; avoids TRVs opening & closing fully each cycle, thus reducing noise and battery use
-- 14/01/21 - Jeff - add logging of expected unheated final temperature, useful for deciding whether incRate or decRate is wrong when the result is overshoot or undershoot
-- 22/01/21 - Jeff - change JSON URL to use FQDN rather than localhost IP, as since domoticz 2020.2, the latter now returns 401 Unauthorised even when 127.0.0.* is set as local/no auth!
-- 15/02/21 - Jeff - additional error trapping for setpoint device with no scheduled setpoints and other bad conditions; moved most of zone processing inside a 'is valid zone data' block
-- 10/03/21 - Jeff - add default value option for outdoor sensors
-- 17/04/21 - Jeff - further error-trapping of invalid device idx values in forecast temps, valve definitions
-- 31/05/21 - Jeff - v2.3 Add option for 'sw' parameter (master mode switch) for each zone - this enables us easily to switch off parts of the system (e.g. heating off, but keep hot water on)
-- 10/06/21 - Jeff - v2.4 Tidy up logic; fewer, larger 'if zone.valid' blocks
--
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--
-- TO DO:
-- Possibly have a default temperature sensor or even a default value and rather than disregard a zone with a bad/missing sensor value, substitute the default.
--
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-- Supporting functions
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
function lognote(domoticz, note)
--
-- Debug logging... set DEBUG_INFO to true to enable all the informational logging stuff in the other functions
--
local DEBUG_INFO = false
-- local DEBUG_INFO = true
if DEBUG_INFO == true then
domoticz.log(note, domoticz.LOG_INFO)
end
end
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
function getHeatingTime (domoticz, temps, params)
-- Function to calculate what proportion of its time the heating needs to be on for and at what temperature, to meet the desired temperature at the right time
--
-- temps is an array: {tRoomNow - current room temp, tRoomTarget, - target room temp, pTarget - when to hit target temp (minutes from now), tOutNow - current outdoor temp, tOutPredict - expected outdoor temp @ pTarget}
-- params is an array: {rIncrease - rate of room temp rise, degC per minute per degC of water - room difference, rDecrease, - rate of room temp decrease, degC per minute per degC of room - outdoor difference, tWaterMin - min workable water temp, tWaterMax - max workable water temp, tWRDiffMin - minimum useful water-room difference,
-- tFlow = heat source output temp, tReturn = heat source return temp, rWarmUp = heat source warm-up rate, degC/min, sName = zone name}
-- Note that rDecrease and rIncrease are both expressed as positive values!
-- returns tWater - water temperature required, pHeat - percentage of time for which heat ; both will be zero if no heat is needed
lognote(domoticz, ' getHeatingTime: tRoomTarget=' .. temps.tRoomTarget)
lognote(domoticz, ' : pTarget=' .. temps.pTarget)
-- Calculations!
local tRoomMean = (temps.tRoomNow + temps.tRoomTarget) / 2 -- mean room temp for the period
local tIBaseMin = (temps.tRoomTarget - temps.tRoomNow) / temps.pTarget -- increase required, per minute, without loss to outside
local tOutMean = (temps.tOutNow + temps.tOutPredict) / 2 -- mean outdoor temp for the period
local tDecMin = (tRoomMean - tOutMean) * params.rDecrease -- decrease per minute through loss to outside
local tIncMin = tIBaseMin + tDecMin -- net increase required, per minute
local tEndTempUnheated = temps.tRoomNow - (tDecMin * temps.pTarget) -- expected final temperature
local tRR = tIncMin / params.rIncrease -- water-to-room temp difference needed
if params.hysteresis == nil or params.hysteresis < 0 then
params.hysteresis = 0
end
local tSupply = params.tFlow -- later, might do some calculation using flow and return to represent more closely the heat in the system rather than just at the source
lognote(domoticz, ' getHeatingTime: tRoomMean=' .. tRoomMean .. ', tIBaseMin=' .. tIBaseMin .. ', tOutMean=' .. tOutMean .. ', tDecMin=' .. tDecMin .. ', tIncMin=' .. tIncMin .. ', tRR=' .. tRR .. ', end temp unheated=' .. tEndTempUnheated .. ', hysteresis=' .. params.hysteresis .. ', tFlow ' .. params.tFlow .. ', tReturn ' .. params.tReturn .. ', rWarmUp ' .. params.rWarmUp)
domoticz.log(' Expected final temperature in ' .. temps.pTarget .. ' minutes of ' .. tostring(params.sName) .. ' without adding heat is ' .. tEndTempUnheated)
-- apply hysteresis... if we are at target and will remain within bounds without heating, then don't heat; this is only helpful on current setpoints, set it to zero for future setpoints
if math.abs(tEndTempUnheated - temps.tRoomTarget) < params.hysteresis and temps.tRoomNow >= temps.tRoomTarget then
tWaterUse = 0
pHeatPC = 0
lognote(domoticz, ' getHeatingTime: predicted final temperature is within hysteresis limits. No heat needed; returning zero water temp/heat %')
return tWaterUse, pHeatPC
end
-- if the water-to-room differential required is < 0, that's 'no heat required'
if tRR < 0 then
tWaterUse = 0
pHeatPC = 0
lognote(domoticz, ' getHeatingTime: rad-to-room differential required is negative. No heat required, returning zero water temp/heat %.')
return tWaterUse, pHeatPC
end
-- calculate required water temperature to achieve the heating needed
local tWaterBase = tRR + tRoomMean -- ideal water temperature (would require heating 100% of the time at this temperature)
-- now, does the boiler/heat source need some warm-up time? (This is irrelevant and skipped if we're asking for more than max heat already)
if tSupply < tWaterBase and tWaterBase < params.tWaterMax then
-- apply the solution to a somewhat nasty quadratic equation to work out a new target water temp that allows for warm-up
lognote(domoticz, ' Boiler warm-up needed')
local hTotal = tWaterBase * temps.pTarget
local swp = tSupply + (params.rWarmUp * temps.pTarget)
local newTWB = swp - math.sqrt (swp^2 - (2 * hTotal * params.rWarmUp) - tSupply^2)
lognote(domoticz, 'hTotal=' .. hTotal .. ' swp=' .. swp .. ' rWarmUp=' .. params.rWarmUp .. ' pTarget=' .. temps.pTarget .. ' tSupply=' .. tSupply .. ' / Calc newTWB=' .. newTWB)
lognote(domoticz, 'Desired water temperature was ' .. tWaterBase .. '. Allowing for boiler warm-up, required temp is ' .. newTWB)
tWaterBase = newTWB
elseif tWaterBase < params.tWaterMax then
lognote(domoticz, 'Required supply temperature exceeds max available, so warm-up is not possible.')
else
lognote(domoticz, 'Heater supply temp currently ' .. tSupply .. ' so no warm-up required for target of ' .. tWaterBase)
end
local tWaterUse = math.floor(tWaterBase + 0.5 ,1) -- need an integer for the boiler; round up
-- if the water temperature isn't at usefully higher than target room temp, it's not going to achieve anything; how much higher differs by heating type and HW cylinder vs radiators, so is a parameter
if tWaterUse < temps.tRoomTarget + params.tWRDiffMin then
tWaterUse = temps.tRoomTarget + params.tWRDiffMin -- too small a difference; override it to minimum useful level
end
-- limit water temp requested to the available range
if tWaterUse > params.tWaterMax then
tWaterUse = params.tWaterMax
elseif tWaterUse < params.tWaterMin then
tWaterUse = params.tWaterMin
end
-- if we've varied the temperature from the ideal, then we need to vary the on-cycle percentage
-- NB, this is overly-precise; we're likely to be working with cycle periods of 10~30 minutes and boilers don't like being cycled on/off for less than about 5 minutes,
-- so whatever logic calls this function needs to take care of minimum on-times etc.
if tWaterUse ~= tWaterBase then
pHeatPC = math.floor((tWaterBase / tWaterUse * 100), 1)
else
pHeatPC = 100
end
-- if the heat-on percentage is <= 0, then we don't need to be heating at all
if pHeatPC <= 0 then
pHeatPC = 0
tWaterUse = 0
end
lognote(domoticz, ' getHeatingTime calculations: tRoomMean=' .. tRoomMean .. ', tIBaseMin=' .. tIBaseMin .. ', tOutMean=' .. tOutMean .. ', tDecMin=' .. tDecMin .. ', tIncMin=' .. tIncMin .. ', tRR=' .. tRR .. ', tWaterBase=' .. tWaterBase .. ', tWaterUse=' .. tWaterUse .. ', pHeatPC=' .. pHeatPC .. ', tWRDiffMin=' .. params.tWRDiffMin)
return tWaterUse, pHeatPC
end -- getHeating Time
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
function getSetpoints (domoticz, setpointDeviceID, DomoticzFQDN, DomoticzPort)
--
-- Function to get the current and next setpoint values and next setpoint time from a setpoint device
--
-- NB! functions can't see the domoticz object unless it's passed in as a parameter!
--
-- Jeff T, 11/09/2020
--
lognote(domoticz, ' getSetpoints: called for device idx' .. setpointDeviceID)
-- initialise variables
local spURL=""
local spTable={}
local spCount=0
local i=0
local sp
local spts = {}
local hrs
local mins
-- time and date stuff
local Time = require('Time')
local currentTime = Time()
local dayToday = currentTime.wday - 1 -- dzVents uses Sun=1 whereas the domoticz timer data uses Mon=1
if dayToday == 0 then
dayToday = 7
end
local dayTomorrow = dayToday + 1
if dayTomorrow == 8 then
dayTomorrow = 1
end
local dayYesterday = dayToday - 1
if dayTomorrow == 0 then
dayTomorrow = 7
end
local bitToday = (1 << (dayToday-1))
local bitTomorrow = (1 << (dayTomorrow-1))
local bitYesterday = (1 << (dayYesterday-1))
local dateToday = currentTime.rawDate .. ' 00:00:00'
local startToday = Time(dateToday) -- date part only, to add times to later
local startTomorrow = startToday.addMinutes(24*60)
local startYesterday = startToday.addMinutes(-24*60)
lognote(domoticz, 'bitToday=' .. bitToday .. ', bitTomorrow=' .. bitTomorrow .. ', bitYesterday=' .. bitYesterday)
-- Get a list of all timers for the specified device
local json2 = (loadfile "/home/domoticz/domoticz/scripts/lua/JSON.lua")()
local spURL = "curl 'http://" .. DomoticzFQDN .. ":" .. DomoticzPort .. "/json.htm?idx=" .. setpointDeviceID .. "&type=setpointtimers'"
lognote(domoticz, 'Setpoints:API call URL: <' .. spURL .. '>')
local handle = io.popen(spURL)
local setsAPI = handle:read('*all')
handle:close()
local spjson = json2:decode(setsAPI)
lognote(domoticz, 'API call status = ' .. spjson.status)
-- API error and nil result traps
if spjson.status ~= 'OK' then
domoticz.log('#### getSetpoints: API call failed for device idx ' .. setpointDeviceID, domoticz.LOG_WARN)
return nil, nil, nil
end
if spjson.result == nil then
domoticz.log('#### getSetpoints: No setpoints returned for device idx ' .. setpointDeviceID, domoticz.LOG_WARN)
return nil, nil, nil
end
-- Now iterate through the setpoints array and create a reduced set covering only active fixed-time setpoints applicable to yesterday, today and tomorrow
-- domoticz is unhelpful with the 'days' value - it's not a simple 7-bit mask; 1,2,...64 are Mo-Su but then 128=every day, 256=weekdays, 512=weekends! So, we have to expand the last three values into multiple records for the days we care about.
for i, sp in ipairs(spjson.result) do
if (sp.Active == "true" or sp.Active == "True") and sp.Type == 2 then -- active and a useable timer type
hrs = tonumber(string.sub(sp.Time,1,2))
mins = tonumber(string.sub(sp.Time,4,5))
if sp.Days == bitYesterday or sp.Days == 128 or (sp.Days == 256 and dayYesterday <= 5) or (sp.Days == 512 and dayYesterday >= 6) then -- yesterday matches
-- construct a datetime value for the setpoint and use that instead of the day in our set
lognote(domoticz, 'Eligible setpoint for yesterday: Days ' .. sp.Days .. ', hr ' .. hrs .. ', min ' .. mins .. ', temp ' .. sp.Temperature)
spts = startYesterday.addMinutes(hrs * 60 + mins)
spCount=spCount+1 -- increment first as LUA likes 1-indexed arrays
spTable[spCount] = {TrigTime = spts, Temperature = sp.Temperature}
end
if sp.Days == bitToday or sp.Days == 128 or (sp.Days == 256 and dayToday <= 5) or (sp.Days == 512 and dayToday >= 6) then -- today matches
-- construct a datetime value for the setpoint and use that instead of the day in our set
lognote(domoticz, 'Eligible setpoint for today: Days ' .. sp.Days .. ', hr ' .. hrs .. ', min ' .. mins .. ', temp ' .. sp.Temperature)
spts = startToday.addMinutes(hrs * 60 + mins)
spCount=spCount+1 -- increment first as LUA likes 1-indexed arrays
spTable[spCount] = {TrigTime = spts, Temperature = sp.Temperature}
end
if sp.Days == bitTomorrow or sp.Days == 128 or (sp.Days == 256 and dayTomorrow <= 5) or (sp.Days == 512 and dayTomorrow >= 6) then -- tomorrow matches
-- construct a datetime value for the setpoint and use that instead of the day in our set
lognote(domoticz, 'Eligible setpoint for tomorrow: Days ' .. sp.Days .. ', hr ' .. hrs .. ', min ' .. mins .. ', temp ' .. sp.Temperature)
spts = startTomorrow.addMinutes(hrs * 60 + mins)
spCount=spCount+1 -- increment first as LUA likes 1-indexed arrays
spTable[spCount] = {TrigTime = spts, Temperature = sp.Temperature}
end
end
end
domoticz.log(' getSetpoints: identified ' .. spCount .. ' setpoints for today and tomorrow.', domoticz.LOG_INFO)
-- Check our new array; find the earliest setpoint whose timestamp is greater than the current time and no later than the end of tomorrow
local nextTime = startTomorrow.addMinutes(24 * 60)
local nextTemp = nil
local nextMins = nil
local schedTemp = nil
local schedTime = startYesterday
lognote(domoticz, 'Start yesterday = ' .. startYesterday.dDate .. ', start today = ' .. startToday.dDate .. ', nextTime starts search = ' .. nextTime.dDate .. ', currentTime = ' .. currentTime.dDate)
for i, sp in ipairs(spTable) do
lognote(domoticz, 'Setpoint ' .. sp.TrigTime.dDate .. ' / ' .. sp.TrigTime.rawDate .. 'T' .. sp.TrigTime.rawTime .. ', ' .. sp.Temperature)
if sp.TrigTime.dDate > currentTime.dDate and sp.TrigTime.dDate < nextTime.dDate then
lognote(domoticz, '... Taken as next SP')
nextTime = sp.TrigTime
nextTemp = sp.Temperature
end
if sp.TrigTime.dDate <= currentTime.dDate and sp.TrigTime.dDate > schedTime.dDate then
lognote(domoticz, '... Taken as current scheduled SP')
schedTime = sp.TrigTime
schedTemp = sp.Temperature
end
end
if schedTemp == nil then
domoticz.log('#### getSetpoints: Did not find a valid current setpoint!', domoticz.LOG_WARN)
end
if nextTemp == nil then
domoticz.log('#### getSetpoints: Did not find any valid future setpoint today or tomorrow!', domoticz.LOG_WARN)
end
-- calculate next setpoint time as a number of minutes from now
if nextTemp ~= nil then
local compres = currentTime.compare(nextTime)
if compres.compare >= 0 then
nextMins = compres.minutes
else
nextMins = 0
end
else
nextMins = nil
end
lognote(domoticz, ' getSetpoints: Next setpoint to be used is ' .. tostring(nextTemp) .. ' degrees in ' .. tostring(nextMins) .. ' minutes at ' .. nextTime.rawDate .. 'T' .. nextTime.rawTime .. ' ; current scheduled and actual setpoint values are ' .. tostring(schedTemp) .. ' and ' .. domoticz.devices(setpointDeviceID).setPoint, domoticz.LOG_INFO)
return domoticz.devices(setpointDeviceID).setPoint, nextTemp, nextMins, schedTemp
end -- getSetpoints
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
function getZoneTemperature(domoticz, sensorIdx, maxAge)
--
-- Return current temperature of a zone, or nil if it's invalid
-- This is simple, but I've abstracted it into a function to allow for error trapping and any future complexity such as using groups of sensors
--
if sensorIdx == nil then
domoticz.log('#### getZoneTemperature was passed a nil sensorIdx!', domoticz.LOG_WARN)
return nil
end
lognote(domoticz, ' Fetching zone temperature from sensor idx ' .. tostring(sensorIdx))
if domoticz.devices(sensorIdx) == nil then
domoticz.log('#### Sensor device idx ' .. sensorIdx .. ' does not exist!', domoticz.LOG_WARN)
return nil
end
local v = nil
if domoticz.devices(sensorIdx) ~= nil then
v = domoticz.devices(sensorIdx).temperature
end
if v == nil or v > 199 or v == 0.0 or domoticz.devices(sensorIdx).lastUpdate.minutesAgo > maxAge then
-- implausible value or outdated data
domoticz.log(' Sensor idx ' .. sensorIdx .. ' is showing implausible or outdated data! [' .. tostring(v) .. ' deg, ' .. tostring(domoticz.devices(sensorIdx).lastUpdate.minutesAgo) .. ' minutes ago]')
v = nil
end
return v
end
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
function getOutdoorTemperature(domoticz, sensor, nextTime, maxAge)
--
-- Return current outdoor temperature and predicted temperature after nextTime minutes, or nil if no valid data
--
-- Jeff T, 14/09/2020
--
if sensor == nil then
domoticz.log('getOutdoorTemperature was passed a nil sensor reference!', domoticz.LOG_WARN)
return nil, nil
end
local stemp = domoticz.variables('Heating sensor ' .. sensor).value
if stemp == nil then
domoticz.log('getOutdoorTemperature: no user variable Heating sensor ' .. sensor .. ' !', domoticz.LOG_WARN)
return nil, nil
end
-- local jsonl = (loadfile "/home/domoticz/domoticz/scripts/lua/JSON.lua")()
local sensorData = json:decode(stemp)
if sensorData == nil then
domoticz.log('getOutdoorTemperature: no sensor data in uservar for reference ' .. sensor .. ' !', domoticz.LOG_WARN)
return nil, nil
end
lognote(domoticz, 'Fetching current outdoor temperature from sensor Idx ' .. sensor)
if domoticz.devices(sensorData.idxNow) == nil then
domoticz.log('Sensor device idx ' .. sensorIdx .. ' does not exist!', domoticz.LOG_WARN)
return nil
end
-- current temperature
local now = nil
if domoticz.devices(sensorData.idxNow) ~= nil then
now = domoticz.devices(sensorData.idxNow).temperature
end
if now == nil or now > 199 or domoticz.devices(sensorData.idxNow).lastUpdate.minutesAgo > maxAge then
-- implausible value or outdated data
domoticz.log('Sensor idx ' .. sensorData.idxNow .. ' is showing implausible or outdated data! [' .. tostring(now) .. ' deg, ' .. tostring(domoticz.devices(sensorData.idxNow).lastUpdate.minutesAgo) .. ' minutes ago]')
if sensorData.default ~= nil then
domoticz.log('>> default value of ' .. sensorData.default .. ' will be used instead.')
now = sensorData.default
else
now = nil
end
end
-- temperature at nextTime
local next
if nextTime == nil or nextTime <= 30 then
next = now
elseif nextTime <= 90 and domoticz.devices(sensorData.idx1) ~= nil then
next = domoticz.devices(sensorData.idx1).temperature
elseif nextTime <= 150 and domoticz.devices(sensorData.idx2) ~= nil then
next = domoticz.devices(sensorData.idx2).temperature
elseif nextTime <= 210 and domoticz.devices(sensorData.idx3) ~= nil then
next = domoticz.devices(sensorData.idx3).temperature
elseif domoticz.devices(sensorData.idx4) ~= nil then
next = domoticz.devices(sensorData.idx4).temperature
else
next = nil
end
if next == nil then
domoticz.log('Forecast temperature for sensor ' .. sensor .. ' for ' .. tostring(nextTime) .. ' minutes ahead is nil!', domoticz.LOG_WARN)
end
lognote(domoticz, 'Sensor ' .. sensor .. ' returning now=' .. tostring(now) .. ' and next for ' .. tostring(nextTime) .. ' mins ahead=' .. tostring(next))
return now, next
end
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
function heatingControl (domoticz, trig)
--
-- Main heating control function, called by the dzVents event block
--
domoticz.log('#### Heating control', domoticz.LOG_INFO)
json = (loadfile "/home/domoticz/domoticz/scripts/lua/JSON.lua")() -- let this be global so that the called functions can use it (although getSetpoints still has to have its own for when it's called by other scripts)
-- constants
-- local variables
local globalParams = {}
local params = {}
local temps = {}
local x = 0
local tWaterMaxDemand = 0
local zone, i, v
local tFlow, tReturn
-- date & time stuff
local Time = require('Time')
local now = Time()
-- load global parameters for heat source/system from user variable 'Heating control global'
local tempData = {}
if domoticz.variables('Heating control global') == nil then
domoticz.log('heatingControl: no user variable [Heating control global]! Unable to work without this.', domoticz.LOG_WARN)
return
end
tempData = json:decode(domoticz.variables('Heating control global').value)
local pCycleTime = tempData.cycleTime
local tWaterMin = tempData.heaterMinTemp
local tWaterMax = tempData.heaterMaxTemp
local pBoilerOnMin = tempData.heaterMinOntime
local iFlow = tempData.idxFlowTemp
local iReturn = tempData.idxReturnTemp
local rWarmUp = tempData.warmUpRate
local iBFSP = tempData.flowSet
if pCycleTime == nil then
domoticz.log('heatingControl: no cycleTime defined in user variable [Heating control global]! Unable to work without this.', domoticz.LOG_WARN)
return
end
if tWaterMin == nil then
domoticz.log('heatingControl: no heaterMinTemp defined in user variable [Heating control global]! Unable to work without this.', domoticz.LOG_WARN)
return
end
if tWaterMax == nil then
domoticz.log('heatingControl: no HeaterMaxTemp defined in user variable [Heating control global]! Unable to work without this.', domoticz.LOG_WARN)
return
end
if pBoilerOnMin == nil then
domoticz.log('heatingControl: no heaterMinOntime defined in user variable [Heating control global]! Unable to work without this.', domoticz.LOG_WARN)
return
end
if iFlow == nil then
domoticz.log('heatingControl: no idxFlowTemp defined in user variable [Heating control global]! Unable to work without this.', domoticz.LOG_WARN)
return
end
if iReturn == nil then
domoticz.log('heatingControl: no idxReturnTemp defined in user variable [Heating control global]! Unable to work without this.', domoticz.LOG_WARN)
return
end
if rWarmUp == nil then
domoticz.log('heatingControl: no warmUpRate defined in user variable [Heating control global]! Unable to work without this.', domoticz.LOG_WARN)
return
end
domoticz.log('Heating params: cycle time ' .. pCycleTime .. ', water min/max ' .. tWaterMin .. '/' .. tWaterMax .. ', min on time ' .. pBoilerOnMin .. ', warm-up rate ' .. rWarmUp)
-- load domoticz server parameters from user variable 'Heating control host'
tempData = {}
if domoticz.variables('Heating control host') == nil then
domoticz.log('heatingControl: no user variable [Heating control host]! Unable to work without this.', domoticz.LOG_WARN)
return
end
tempData = json:decode(domoticz.variables('Heating control host').value)
local sDomoFQDN = tempData.DomoFQDN
local sDomoPort = tempData.DomoPort
if sDomoFQDN == nil then
domoticz.log('heatingControl: no DomoFQDN defined in user variable [Heating control host]! Unable to work without this.', domoticz.LOG_WARN)
return
end
if sDomoPort == nil or tonumber(sDomoPort) < 1 or tonumber(sDomoPort) > 65535 then
domoticz.log('heatingControl: DomoPort not defined or invalid in user variable [Heating control host]! Unable to work without this.', domoticz.LOG_WARN)
return
end
-- get the current state of play with the heat source's flow and return temperatures (may be the same if it's not a wet system)
tFlow = domoticz.devices(iFlow).temperature
tReturn = domoticz.devices(iReturn).temperature
-- each zone is defined as a JSON string in a user variable, with this format: {sensor: 5, setpoint: 1; valve: 8; incRate: 0.3; decRate: 0.2; outSensor: 10; prefPHT: 30; maxPHT: 120; minDT: 10, hyst: 0.5}
-- incRate is 1000 * increase degC/min/degC water-to-zone diff; decRate is 1000 * decrease degC/min/degC of zone-outdoor diff; prefPHT, maxPHT = preferred, max pre-heat periods; minDT = minimum useful water-to-zone diff; hyst = hysteresis on target temps.
-- load heating zone data from user vars (dzVents's approach to iterating through these is a bit strange; they're not just a list)
local zoneData = {}
local junk = domoticz.variables().filter(function(v)
if string.find(v.name, 'Heating zone ') then
lognote(domoticz, 'Found a zone definition: ' .. v.name .. ' // ' .. v.value)
zoneData[v.name] = json:decode(v.value)
zoneData[v.name].name = string.sub(v.name, 13) -- remove the 'Heating zone ' prefix
zoneData[v.name].valid = true
if (zoneData[v.name].incRate ~= nil and zoneData[v.name].decRate ~= nil) then
zoneData[v.name].incRate = zoneData[v.name].incRate / 1000 -- these are stored * 1000 to avoid a load of leading zeroes for the user and over-length domoticz user variables
zoneData[v.name].decRate = zoneData[v.name].decRate / 1000
else
domoticz.log('#### Zone ' .. zoneData[v.name].name .. ' does not have valid incRate/decRate! Setting zone status to invalid.', domoticz.LOG_WARN)
zoneData[v.name].valid = false
end
end
end)
-- now, we have a simple list of objects zoneData, containing our zones
-- we can also add new elements to each entry by simply assigning - e.g. zoneData[i].newItem='hi!' - which we'll use to record more zone data and calculations as we go along
-- now, we will process each of the zones to see what heat it needs
for i,zone in pairs(zoneData) do
domoticz.log('#### Heating control: first pass processing for zone ' .. tostring(zone.name))
lognote(domoticz, ' sensorIdx=' .. tostring(zone.sensor) .. ' setpointIdx=' .. tostring(zone.setpoint) .. ' valve=' .. tostring(zone.valve) .. ' outSensorIdx=' .. tostring(zone.outSensor) .. ' sw=' .. tostring(zone.sw) )
if zone.sw ~= nil and domoticz.devices(zone.sw).state == 'Off' then
-- this zone's master switch is off
zone.valid = false
domoticz.log('#### Zone ' .. zone.name .. ' is disabled by its master switch.', domoticz.LOG_INFO)
end
-- get setpoints now, next; add them to zoneData
if zone.valid then
zone.currentSP, zone.nextSP, zone.nextTime, zone.scheduledSP = getSetpoints(domoticz, zone.setpoint, sDomoFQDN, sDomoPort)
if zone.currentSP == nil then
domoticz.log('#### Zone ' .. zone.name .. ' has no valid current setpoint! Zone status will be set to invalid.', domoticz.LOG_WARN)
zone.currentSP = 0
zone.valid = false
end
if zone.nextSP == nil or zone.nextTime == nil then
domoticz.log('#### Zone ' .. zone.name .. ' has no valid next setpoint! Zone status will be set to invalid.', domoticz.LOG_WARN)
zone.nextSP = 0
zone.nextTime = 0
zone.valid = false
end
-- get current and predicted zone temperatures; add them to zoneData
zone.currentTemp = getZoneTemperature(domoticz, zone.sensor, zone.age)
if zone.currentTemp == nil then
-- zone has no valid temperature data; we will have to ignore it
domoticz.log('#### No valid indoor temperature data for zone ' .. zone.name .. '. Will default to 999.', domoticz.LOG_WARN)
zone.currentTemp = 999
zone.valid = false
end
-- get current and predicted outdoor temperatures; add them to zoneData
zone.currentOutdoor, zone.nextOutdoor = getOutdoorTemperature(domoticz, zone.outSensor, zone.nextTime, zone.age)
if zone.currentOutdoor == nil then
-- zone has no valid temperature data; we will have to ignore it
domoticz.log('#### No valid current outdoor temperature data for zone ' .. zone.name .. '. Temperatures will be set to indoor temp. as default.', domoticz.LOG_WARN)
zone.currentOutdoor = zone.currentTemp
end
if zone.nextOutdoor == nil then
-- zone has no valid temperature data; we will have to ignore it
domoticz.log('#### No valid predicted outdoor temperature data for zone ' .. zone.name .. '. Will be set to same as current.', domoticz.LOG_WARN)
zone.nextOutdoor = zone.currentOutdoor
end
domoticz.log(' Zone ' .. zone.name .. ': currentTemp ' .. tostring(zone.currentTemp) .. ', currentSP ' .. tostring(zone.currentSP) .. ', nextSP ' .. tostring(zone.nextSP) .. ', after ' .. tostring(zone.nextTime) .. ', maxPHT ' .. tostring(zone.maxPHT) .. ', prefPHT ' .. tostring(zone.prefPHT) .. ', currentOutdoor ' .. tostring(zone.currentOutdoor) .. ', nextOutdoor ' .. tostring(zone.nextOutdoor))
-- calculate heating requirement (first pass for current SP) -> record % on time and desiredWaterTempCurrent
params = {rDecrease = zone.decRate, rIncrease = zone.incRate, tWaterMin = tWaterMin, tWaterMax = tWaterMax, tWRDiffMin = zone.minDT, hysteresis = zone.hyst, rWarmUp = rWarmUp, tFlow = tFlow, tReturn = tReturn, sName = tostring(zone.name)}
temps = {tRoomNow = zone.currentTemp, tRoomTarget = zone.currentSP, pTarget = pCycleTime, tOutNow = zone.currentOutdoor, tOutPredict = zone.currentOutdoor}
zone.waterNow, zone.heatNow = getHeatingTime (domoticz, temps, params)
if zone.waterNow < 0 or zone.heatNow < 0 then
zone.waterNow = 0
zone.heatNow = 0
end
else -- zone not valid; don't bother with it
zone.waterNow = 0
zone.heatNow = 0
end
domoticz.log(' Current SP called for heating at ' .. zone.waterNow .. 'deg for ' .. zone.heatNow .. '% of the next interval.')
-- if this setpoint requires hotter water than any before it, update the required water temperature
if zone.waterNow > tWaterMaxDemand then
tWaterMaxDemand = zone.waterNow
end
-- calculate heating requirement (first pass for next SP) -> record % on time and desiredWaterTempNow
-- if setpoint is too far away, set zoneData[i].handleNext to false, else true
-- Note; no hysteresis on future setpoints as we're not trying to ignore slight variations, we're aiming to hit a target
lognote(domoticz, 'Checking next SP heat requirement...')
if (zone.valid and zone.currentTemp ~= 999) then
if zone.nextTime > zone.maxPHT then
zone.waterNext = 0
zone.heatNext = 0
domoticz.log(' Next setpoint too far away at present, ' .. zone.nextTime .. ' mins.')
else
-- try with the lesser of time to setpoint and preferred pre-heating time
zone.PHT = math.min(zone.nextTime, zone.prefPHT)
params = {rDecrease = zone.decRate, rIncrease = zone.incRate, tWaterMin = tWaterMin, tWaterMax = tWaterMax, tWRDiffMin = zone.minDT, hysteresis = 0, rWarmUp = rWarmUp, tFlow = tFlow, tReturn = tReturn, sName = tostring(zone.name)}
temps = {tRoomNow = zone.currentTemp, tRoomTarget = zone.nextSP, pTarget = zone.PHT, tOutNow = zone.currentOutdoor, tOutPredict = zone.nextOutdoor}
zone.waterNext, zone.heatNext = getHeatingTime (domoticz, temps, params)
if zone.heatNext == 0 or zone.waterNext == 0 then
zone.waterNext = 0
zone.heatNext = 0
zone.PHT = 0
lognote(domoticz, 'No heat required')
elseif zone.heatNext > 100 then
if zone.PHT == zone.nextTime then
-- we tried for the time to setpoint and missed; all we can do is heat at 100% full-time
zone.heatNext = 100
lognote(domoticz, 'Cannot heat enough by next SP; will use 100%')
else
-- we tried with the pref. pre-heat time and missed; can we hit setpoint using a longer time?
x = zone.PHT * zone.heatNext / 100
lognote(domoticz, 'PrefPHT or time to next SP insufficient. Need ' .. x .. ' mins')
if x > zone.maxPHT then
-- can't make it within the max pre-heat time, so we have to go for 100% full-time
zone.heatNext = 100
zone.PHT = zone.maxPHT
else
-- can make it in some time between pref. and max; we'll take the shortest possible pre-heat time at 100% rather than reduce the cycle or temperature as we don't like pre-heating too early
zone.heatNext = 100
zone.PHT = x
end
lognote(domoticz, 'Preferred PHT not enough; using ' .. zone.PHT .. ' mins.')
end
--else, all's fine and lovely
end
end
if zone.PHT == nil then
zone.PHT = 0
end
domoticz.log(' Next SP wants water @ ' .. zone.waterNext .. ' / ' .. zone.heatNext .. '% for ' .. zone.PHT .. ' mins')
-- do we need to heat yet?
if zone.nextTime - zone.PHT > pCycleTime then
domoticz.log(' Zone ' .. zone.name .. ' next setpoint needs pre-heating for ' .. zone.PHT .. ' minutes; no heat needed yet as setpoint is ' .. zone.nextTime .. ' minutes away.')
zone.waterNext = 0
zone.heatNext = 0
end
-- if this setpoint requires hotter water than any before it, update the required water temperature
if zone.waterNext > tWaterMaxDemand then
tWaterMaxDemand = zone.waterNext
end
else -- temperature 999 => bad data, or zone invalidated, don't bother with it
zone.waterNext = 0
zone.heatNext = 0
zone.PHT = zone.maxPHT
lognote(domoticz, 'Bad data; zone demand zeroed!')
end -- if..zone.valid/not 999
end -- for..zoneData
if tWaterMaxDemand == nil or tWaterMaxDemand < tWaterMin then
-- no zone needed any heat
tWaterMaxDemand = 0
domoticz.log('#### Heating control: no zone requires any heat at present.')
else
-- at this point, we have tWaterMaxDemand and can recalculate those zones still in consideration using that water temperature
domoticz.log('#### Heating control: re-calculating on-times for zones that need heat, using water temperature ' .. tWaterMaxDemand)
for i,zone in pairs(zoneData) do
if zone.valid then
if zone.waterNow > 0 then -- zone needs heat
-- recalculate heating requirement (current SP) using tWaterMaxDemand
params = {rDecrease = zone.decRate, rIncrease = zone.incRate, tWaterMin = tWaterMaxDemand, tWaterMax = tWaterMaxDemand, tWRDiffMin = zone.minDT, hysteresis = zone.hyst, rWarmUp = rWarmUp, tFlow = tFlow, tReturn = tReturn, sName = tostring(zone.name)}
temps = {tRoomNow = zone.currentTemp, tRoomTarget = zone.currentSP, pTarget = pCycleTime, tOutNow = zone.currentOutdoor, tOutPredict = zone.currentOutdoor}
zone.waterNow, zone.heatNow = getHeatingTime (domoticz, temps, params)
domoticz.log('Zone ' .. zone.name .. ' current setpoint requires ' .. tostring(zone.heatNow) .. '% on-time.')
-- as this is the current setpoint, the most it can want is 100% heating; it can't have the on-time extended to help
-- if heat % exceeds 100%, the water temperature will already be at max value; we've no way to meet the target in time
if zone.heatNow > 100 then
domoticz.log(' Zone ' .. zone.name .. ' current setpoint requirement of ' .. tostring(zone.currentSP) .. ' requires more than 100% on-time; cannot be achieved within one cycle!')
zone.heatNow = 100
else
domoticz.log(' Zone ' .. zone.name .. ' current setpoint requirement of ' .. tostring(zone.currentSP) .. ' requires ' .. tostring(zone.heatNow) .. '% on-time at ' .. tWaterMaxDemand .. ' degrees')
end
end -- if.. zone.waterNow
-- recalculate heating requirement (next SP) using tWaterMaxDemand
if zone.waterNext > 0 then -- zone needs heat
params = {rDecrease = zone.decRate, rIncrease = zone.incRate, tWaterMin = tWaterMaxDemand, tWaterMax = tWaterMaxDemand, tWRDiffMin = zone.minDT, hysteresis = 0, rWarmUp = rWarmUp, tFlow = tFlow, tReturn = tReturn, sName = tostring(zone.name)}
temps = {tRoomNow = zone.currentTemp, tRoomTarget = zone.nextSP, pTarget = zone.PHT, tOutNow = zone.currentOutdoor, tOutPredict = zone.nextOutdoor}
zone.waterNext, zone.heatNext = getHeatingTime (domoticz, temps, params)
domoticz.log('Zone ' .. zone.name .. ' next setpoint requires ' .. tostring(zone.heatNow) .. '% on-time.')
-- if heat % exceeds 100%, we can't make it in the time available
if zone.heatNext > 100 then
if zone.PHT == zone.nextTime then
-- we tried for the time to setpoint and missed; all we can do is heat at 100% full-time
x = zone.heatNext
zone.heatNext = 100
else
-- we tried with the pref. pre-heat time and missed; can we hit setpoint using a longer time?
x = zone.PHT * zone.heatNext / 100
if x > zone.maxPHT then
-- can't make it within the max pre-heat time, so we have to go for 100% full-time
zone.heatNext = 100
zone.PHT = zone.maxPHT
else
-- can make it in some time between pref. and max; we'll take the shortest possible pre-heat time at 100% rather than reduce the cycle or temperature as we don't like pre-heating too early
zone.heatNext = 100
zone.PHT = x * zone.prefPHT
end
end
if x > 100 then
domoticz.log(' Zone ' .. zone.name .. ' next setpoint requirement of ' .. tostring(zone.nextSP) .. ' in ' .. (zone.nextTime) .. ' minutes requires more than 100% on-time; cannot be achieved!')
else
domoticz.log(' Zone ' .. zone.name .. ' next setpoint requirement of ' .. tostring(zone.currentSP) .. ' in ' .. tostring(zone.nextTime) .. ' minutes requires ' .. zone.heatNow .. '% on-time at ' .. tWaterMaxDemand .. ' degrees')
end
end -- if..zone.heatNext
-- if the zone doesn't need to start pre-heating yet for its next setpoint, ignore that SP for now
if zone.valid and (zone.nextTime - zone.PHT > pCycleTime) then
domoticz.log(' Zone ' .. zone.name .. ' next setpoint needs pre-heating for ' .. zone.PHT .. ' minutes; no heat needed yet as setpoint is ' .. zone.nextTime .. ' minutes away.')
zone.waterNext = 0
zone.heatNext = 0
end
end -- if.. zone.waterNext
end -- if.. zone.valid
end -- for.. zoneData
end -- if.. tWaterMaxDemand nil or low
-- we now know our heating requirements: tWaterMaxDemand is our water temperature; the on-time % for each zone valve is the max of the 'heatNow' and 'heatNext' values for all zones using that valve
-- first, form a list of valves and record the max on-time % against each valve
local valves = {}
for i,zone in pairs(zoneData) do
if zone.valid then
-- master valve
v = zone.mvalve
lognote(domoticz, ' Processing master valve for zone ' .. zone.name .. ' - heatNow = ' .. zone.heatNow .. ', heatNext = ' .. zone.heatNext .. ', valve = ' .. tostring(v))
if v ~= nil then -- zone has a master valve
if valves == nil or valves[v] == nil then -- no record for this valve yet; create one
valves[v] = {heatMax = 0, tempTarget = 0, isMaster = true}
lognote(domoticz, ' new valve record')
end
-- check current and next SPs to determine valve % on and setpoint temperature
valves[v].heatMax = math.max(valves[v].heatMax, zone.heatNow, zone.heatNext)
if zone.heatNow == 0 and zone.heatNext ~= 0 then
valves[v].tempTarget = math.max(valves[v].tempTarget, zone.nextSP)
elseif zone.heatNow ~= 0 and zone.heatNext == 0 then
valves[v].tempTarget = math.max(valves[v].tempTarget, zone.currentSP)
elseif zone.heatNow ~= 0 and zone.heatNext ~= 0 then
valves[v].tempTarget = math.max(valves[v].tempTarget, zone.currentSP, zone.nextSP)
-- else neither current nor next SP wants any heat, in which case we shouldn't have got here!
end
lognote(domoticz, ' Valve will use heatMax=' .. valves[v].heatMax .. ' and tempTarget ' .. valves[v].tempTarget)
end -- master valve handler
-- local valve
v = zone.valve
lognote(domoticz, ' Processing local valve for zone ' .. zone.name .. ' - heatNow = ' .. zone.heatNow .. ', heatNext = ' .. zone.heatNext .. ', valve = ' .. tostring(v))
if v ~= nil then -- zone has a local valve
if valves == nil or valves[v] == nil then -- no record for this valve yet; create one
valves[v] = {heatMax = 0, tempTarget = 0, isMaster = false}
lognote(domoticz, ' new valve record')
end
-- check current and next SPs to determine valve % on and setpoint temperature
valves[v].heatMax = math.max(valves[v].heatMax, zone.heatNow, zone.heatNext)
if zone.heatNow == 0 and zone.heatNext ~= 0 then
valves[v].tempTarget = math.max(valves[v].tempTarget, zone.nextSP)
elseif zone.heatNow ~= 0 and zone.heatNext == 0 then
valves[v].tempTarget = math.max(valves[v].tempTarget, zone.currentSP)
elseif zone.heatNow ~= 0 and zone.heatNext ~= 0 then
valves[v].tempTarget = math.max(valves[v].tempTarget, zone.currentSP, zone.nextSP)
-- else neither current nor next SP wants any heat, in which case we shouldn't have got here!
end
lognote(domoticz, ' Valve will use heatMax=' .. valves[v].heatMax .. ' and tempTarget ' .. valves[v].tempTarget)
end -- local valve handler
end -- zone.valid
end -- for... zoneData
if valves == nil then
domoticz.log(' Oops! We created no valve records! Exiting.', domoticz.LOG_WARN)
return
end
-- now, convert to minutes, find the lowest of the master valve on-times and adjust it if it's below the boiler's minimum on-time
local lowest = 0
local lowestidx = 0
for i,v in pairs(valves) do
v.onTime = v.heatMax * pCycleTime / 100
lognote(domoticz, ' Valve ' .. i .. ' needs to be on for ' .. tostring(v.heatMax) .. '% / ' .. v.onTime .. ' minutes for set temp ' .. v.tempTarget)
if lowest == 0 or (v.onTime < lowest and v.onTime > 0) and v.isMaster then
lowest = v.onTime
lowestidx = i
end
end -- for..valves
if lowest == 0 then -- this shouldn't happen as it's trapped earlier using the water temperatures, but just in case...
domoticz.log(' No zone requires heat.')
tWaterMaxDemand = 0
elseif lowest < pBoilerOnMin then
domoticz.log(' Overriding shortest master valve on-time of ' .. lowest .. ' for valve ' .. lowestidx .. ' to heat source minimum of ' .. pBoilerOnMin .. ' to protect heat source.')
valves[lowestidx].onTime = pBoilerOnMin
-- the remaining zones are okay as they are; we just need one zone on so that the boiler stays on for its minimum period
end
-- now we know what we need to do, let's go set the system controls to do it!
-- clear valve command queues, assert new state, queue up 'off' commands for later if needed
local valveDef, vtmp
for i,v in pairs(valves) do
-- get the valve definition
if domoticz.variables('Heating valve ' .. i) == nil then
domoticz.log('No valve definition found for ' .. i .. '. Should be user variable <Heating valve ' .. i .. '>', domoticz.LOG_WARN)
else
vtmp = domoticz.variables('Heating valve ' .. i).value
lognote(domoticz, 'Valve ' .. i .. ' / uservar value=' .. vtmp)
valveDef = json:decode(vtmp)
if v.onTime > 0 then
-- how we set the valve 'on' depends on its definition
-- valve defs have 4 attributes: idxOnOff, idxPercent, idxMode, idxSetpoint - any can be populated; we will process all that are; also 'strModeOn', 'strModeOff' for the heating mode
-- this allows for 'smart' TRVs that need telling in several different ways to do something!
-- In general, we expect to have mode + setpoint + [on/off OR percent], or just mode + setpoint; on/off control is typically 0/100% so not compatible with percent.
-- mode clause
if valveDef.idxMode ~= nil and valveDef.strModeOn ~= nil and domoticz.devices(valveDef.idxMode) ~= nil then
domoticz.log(' Setting valve ' .. i .. ' to ' .. valveDef.strModeOn)
domoticz.devices(valveDef.idxMode).cancelQueuedCommands()
if domoticz.devices(valveDef.idxMode).mode ~= valveDef.strModeOn then
domoticz.devices(valveDef.idxMode).updateMode(valveDef.strModeOn)
end
if valveDef.idxMode ~= nil and valveDef.strModeOff ~= nil then
domoticz.log(' Queued command for valve ' .. i .. ' to ' .. valveDef.strModeOff .. ' after ' .. v.onTime .. ' mins.')
domoticz.devices(valveDef.idxMode).updateMode(valveDef.strModeOff).afterMin(v.onTime)
end
end
-- percent clause
if valveDef.idxPercent ~= nil and domoticz.devices(valveDef.idxPercent) ~= nil then
domoticz.log(' Setting valve ' .. i .. ' to ' .. (100 * v.onTime / pCycleTime) .. '% open.')
domoticz.devices(valveDef.idxPercent).cancelQueuedCommands()
domoticz.devices(valveDef.idxPercent).setLevel(100 * v.onTime / pCycleTime)
-- no reset, as this setting is for the whole pCycleTime, after which the next run will amend it if need be
end
-- on/off switch clause
if valveDef.idxOnOff ~= nil and domoticz.devices(valveDef.idxOnOff) ~= nil then
domoticz.log(' Setting valve ' .. i .. ' ON.')
domoticz.devices(valveDef.idxOnOff).cancelQueuedCommands()
domoticz.devices(valveDef.idxOnOff).switchOn()
if valveDef.idxPercent == nil and v.onTime < pCycleTime then
-- we only use time modulation if we're not already using temperature modulation; else, the idxOnOff 'on' is just to activate the valve and 'off' is not used
domoticz.devices(valveDef.idxOnOff).switchOff().afterMin(v.onTime)
domoticz.log(' Queued off command for ' .. v.onTime .. ' mins.')
end
end
-- setpoint clause
if valveDef.idxSetpoint ~= nil and domoticz.devices(valveDef.idxSetpoint) ~= nil then
domoticz.log(' Valve ' .. i .. ' setpoint set to ' .. v.tempTarget)
domoticz.devices(valveDef.idxSetpoint).cancelQueuedCommands()
domoticz.devices(valveDef.idxSetpoint).updateSetPoint(v.tempTarget)
-- no need to reset the setpoint after heating, as it's still correct
end
else
domoticz.log(' Setting valve ' .. i .. ' off.')
if valveDef.idxPercent ~= nil and domoticz.devices(valveDef.idxPercent) ~= nil then
lognote(domoticz, ' Valve has a percent aspect. Setting to zero.')
domoticz.devices(valveDef.idxPercent).setLevel(0)
end
if valveDef.idxOnOff ~= nil and domoticz.devices(valveDef.idxOnOff) ~= nil and valveDef.idxPercent == nil then
lognote(domoticz, ' Valve has an on/off aspect. Switching off.')
-- we don't switch off valves that use % control, we just set the % to zero
domoticz.devices(valveDef.idxOnOff).switchOff()
end
if valveDef.idxMode ~= nil and domoticz.devices(valveDef.idxMode) ~= nil and valveDef.strModeOff ~= nil then
lognote(domoticz, ' Valve has an mode aspect. Setting to ' .. valveDef.strModeOff .. ".")
if domoticz.devices(valveDef.idxMode).mode ~= valveDef.strModeOff then
domoticz.devices(valveDef.idxMode).updateMode(valveDef.strModeOff)
end
end
-- no need to reset the setpoint as it's still correct
end -- if v.onTime
end -- if uservar not nil
end -- for..valves
-- assert boiler flow temperature
if tWaterMaxDemand >= tWaterMin and tWaterMaxDemand > 0 then
domoticz.log(' Setting boiler flow temperature to ' .. tWaterMaxDemand)
domoticz.devices(iBFSP).cancelQueuedCommands()
domoticz.devices(iBFSP).updateSetPoint(tWaterMaxDemand)
else
domoticz.devices(iBFSP).cancelQueuedCommands()
domoticz.devices(iBFSP).updateSetPoint(0) -- we set to zero when not needed, to avoid the boiler thinking it needs to keep warm
end
domoticz.log('#### Heating control: my work here is done, for now!')
end -- heatingcontrol
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-- main event code block
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
return {
on = {
timer = {
'at *:01', 'at *:16', 'at *:31', 'at *:46'
-- use of 01, 16, etc is to avoid having to use scheduled setpoints as triggers; this means that their scheduled changes are picked up 1 minute later (typically they change on 00, 30)
},
devices = {
-- the dummy setpoints used by the control panels and for scheduled changes
'CH setpoint hall',
'CH setpoint landing',
'CH setpoint kitchen',
'CH setpoint study',
'CH setpoint Myles',
'CH setpoint master bedroom',
'CH setpoint lounge',
'HW setpoint upper sensor',
-- any smart valves' 'call fror heat' switches could be included too, if they have them and we want a faster response to unexpected temperature drops
-- our own pre-lock device
'Heating algorithm pre-lock',
-- system controls
'Boiler manual / service',
'Boiler master',
'Hot water override',
'Heating mode',
'Hot water mode'
},
},
execute = function (domoticz, trig)
-- Semaphore logic:
-- if locked, do nothing (we're in the lockout period waiting for potential multiple trigger events to flush through)
-- if not locked and trigger wasn't the lock device, then lock, set unlock for 20s later, and exit (fresh trigger, or possibly a batch of triggers)
-- if not locked and trigger was the lock device or time, then run the algo. When done, leave unlocked (lock period over, time to do some work!)
-- once we're done processing, set the post-processing lock to avoid our own setpoint changes re-triggering the code
preLock = domoticz.devices('Heating algorithm pre-lock')
postLock = domoticz.devices('Heating algorithm post-lock')
-- If already locked, ignore this trigger; we'll handle its effects when the lockout ends
if preLock.state == 'On' then
domoticz.log('Heating control algorithm triggered within pre-processing lockout period. No action.')
return
end
if postLock.state == 'On' then
domoticz.log('Heating control algorithm triggered within post-processing lockout period. No action.')
return
end
-- If the trigger was a heating device, then we need to start the pre-lockout period
if trig.isDevice and trig.name ~= 'Heating algorithm pre-lock' then
preLock.switchOn()
preLock.switchOff().afterSec(20)
domoticz.log('Heating control algorithm triggered; beginning lockout period to allow potential multiple triggers to flush through.')
return
end
-- Else, the trigger was either time or it was the lock device switching off, so it's time for us to do some work
-- Check the (virtual) boiler master switches
-- Service mode switch - stops any automatic interference with the controls but doesn't turn the system off (in case the service person has turned it on!)
if domoticz.devices('Boiler manual / service').state == 'On' then
domoticz.log('#### Heating control: boiler manual / service switch is ON. No action; if you want the system off, use the Boiler master switch.')
return
end
-- Master on/off switch - this locks out the system by forcing both master valves off and thus preventing any call for heat to the boiler
-- EMS commands won't clash with this, as the only thing instructing the boiler is this algo.
if domoticz.devices('Boiler master').state ~= 'On' then
domoticz.log('#### Heating control: boiler master switch is OFF. Switching off boiler relays.')
domoticz.devices('Boiler relay - hot water').switchOff().checkFirst()
domoticz.devices('Boiler relay - heating').switchOff().checkFirst()
return
end
-- normal service; let's set the post-processing lock and run the algorithm
postLock.switchOn()
local r = heatingControl (domoticz, trig)
-- Check state of the mode switches for each of CH, HW
-- If one of these is off, we switch off the boiler signal (which closes the relevant master zone valve); this is a bit of a backup, as the algo checks the switch relevant to each zone as it goes through the list
if domoticz.devices('Heating mode').state ~= 'On' then
domoticz.log('#### Heating control: heating mode is OFF. Switching off boiler CH relay.')
domoticz.devices('Boiler relay - heating').switchOff().checkFirst()
end
if domoticz.devices('Hot water mode').state ~= 'On' then
domoticz.log('#### Heating control: hot water mode is OFF. Switching off boiler HW relay.')
domoticz.devices('Boiler relay - hot water').switchOff().checkFirst()
end
postLock.switchOff().afterSec(20)
return r
end
}