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
|
{-# LANGUAGE LambdaCase, BangPatterns #-}
module Automation.Fridge where
import Automation.Types
import Automation.Constants
import Automation.Utility
import Automation.Solar
import Automation.Dumpload
import DataUnits
import Reactive.Banana
import Reactive.Banana.Automation
import Data.Time.Clock.POSIX
import Data.Time.Calendar
import Data.Functor.Compose
import Data.Maybe
-- | Overall behavior of the fridge.
--
-- Limit changes to once every 5 minutes, to avoid frequent compressor
-- cycles (and relay noise)
--
-- Note that this means that the fridge may stay running for 5 minutes
-- after fridgeWanted would like it to stop, and so may cool down more
-- than it would like.
--
-- However, overrides take effect immediately.
fridgeBehavior :: Automation (Sensors t) Actuators (Behavior (Maybe PowerChange))
fridgeBehavior = overallBehavior fridgeWanted (Just (Minutes 5)) fridgeOverride id
fridgeWanted :: Automation (Sensors t) Actuators (Behavior (Maybe PowerChange))
fridgeWanted = fmap fromAnnotated <$> fridgeThoughts
-- | Does the fridge want to run?
--
-- Mostly full batteries are prioritized over running the fridge,
-- but the warmer the fridge is, the deeper into batteries it's
-- willing to go.
fridgeThoughts :: Automation (Sensors t) Actuators (Behavior (Annotated (Maybe PowerChange)))
fridgeThoughts = do
b <- getCompose $ calc
<$> Compose lowpowerMode
<*> Compose (sensedBehavior batteryPercentFull)
<*> Compose (averageOverMinutes 5 =<< sensedBehavior batteryVoltage)
<*> Compose (averageOverMinutes 5 =<< sensedBehavior inputWatts)
<*> Compose (sensedBehavior kwhGeneratedToday)
<*> Compose (sensedBehavior fridgeTemperature)
<*> Compose solarEstimate
<*> Compose dumpLoadGoodPower
fridgeMotorProtection b
where
calc
lowpower (Sensed battery) avgvoltage avgwatts kwhtoday
(Sensed temp) solarestimate dumploadgoodpower
| temp `belowRange` fridgeAllowedTemp = thought
["too cold, food in danger of freezing"]
(Just PowerOff)
| lowpower = thought ["low power mode"] (Just PowerOff)
| overHistory False avgwatts (<= Sensed houseIdleWatts)
&& battery `aboveRange` mostlycharged = thought
["battery is full, but solar panels appear to be shaded"]
(Just PowerOff)
| battery `aboveRange` mostlycharged
&& overHistory False avgvoltage (<= Sensed (Volts 26.7)) = thought
["battery is in float, but its voltage is getting too low"]
(Just PowerOff)
| dumploadgoodpower = thought
[ "the dump load is running and the solar panels"
, "are producing enough watts for it,"
, "so stop dumping and start cooling"
]
(Just PowerOn)
-- (The dump load is automatically turned off
-- when the fridge turns on)
| inAbsorb solarestimate = thought
[ "battery is charging in absorb stage,"
, "running will probably produce more watts"
]
(Just PowerOn)
| overHistory False avgwatts (<= Sensed fridgeWatts)
&& not (battery `aboveRange` mostlycharged) = thought
[ "solar panels not producing enough watts to"
, "run me, and the batteries are trying to"
, "charge too"
]
(Just PowerOff)
| temp `belowRange` fridgePreferredTemp
&& battery `aboveRange` mostlycharged = thought
[ "battery is mostly full; getting quite cold;"
, "keep running if I was running"
]
Nothing
| battery `aboveRange` mostlycharged = thought
[ "banking cold since the battery is mostly full" ]
(Just PowerOn)
| temp `aboveRange` fridgeAllowedTemp
&& battery `aboveRange` badlycharged = thought
[ "much too warm, running despite batteries"
, "only being a little bit charged"
]
(Just PowerOn)
| temp `aboveRange` fridgePreferredTemp
&& kwhtoday >= Sensed (KWH 0.5)
&& overHistory False avgwatts (>= Sensed (Watts 150))
&& battery `aboveRange` badlycharged = thought
[ "a little too warm; batteries only a little"
, "bit charged, but getting good power"
]
(Just PowerOn)
| battery `aboveRange` mostlycharged
&& overHistory False avgvoltage (<= Sensed (Volts 27.0)) = thought
["battery is in float, voltage is medium"]
Nothing
| temp `aboveRange` fridgePreferredTemp
&& battery `aboveRange` badlycharged = thought
[ "a little too warm, battery only a little"
, "bit charged; keep running if I am running"
, "but don't turn on otherwise"
] Nothing
| battery `belowRange` mostlycharged = thought
[ "battery is not well charged and I'm not too"
, "warm yet"
]
(Just PowerOff)
| sunnyDay solarestimate = thought
[ "banking cold on a sunny day"
, "(battery is not full yet, but should be soon)"
]
(Just PowerOn)
| kwhtoday >= Sensed (KWH 1) = thought
[ "more than 1 kWH generated today,"
, "and battery is mostly charged,"
, "so it's not too cloudy; high power mode"
]
(Just PowerOn)
| otherwise = thought
[ "not too hot, not too cold, battery is charging up;"
, "best action unclear; keep doing what I've"
, "been doing"
]
Nothing
where
thought l = Annotated (l ++ [tempdelta])
tempdelta = "(temp is " ++ showDelta showtemp delta ++
"C from " ++ fromwhat ++ ")"
showtemp (TemperatureCelsius t) = showDoublePrecision 2 t
deltaallowed = rangeDelta temp fridgeAllowedTemp
deltaideal = rangeDelta temp fridgeIdealTemp
deltafreezing = rangeDelta temp (Range 0 0)
(delta, fromwhat)
| deltaallowed /= Delta 0 = (deltaallowed, "allowed range")
| deltaideal /= Delta 0 = (deltaideal, "ideal range")
| otherwise = (deltafreezing, "freezing")
calc _lp SensorUnavailable _avgvoltage _avgwatts _kwhtoday _temp _solarestimate _ = Annotated
[ "Battery status unknown! Risking spoiling food." ]
(Just PowerOff)
-- TODO perhaps run the fridge for an hour or so a day
-- when its temperature is not known, hoping to keep
-- it close to the allowed range.
calc _lp _battery _avgvoltage _avgwatts _kwhtoday SensorUnavailable _solarestimate _ = Annotated
[ "Temperature unknown!" ]
(Just PowerOff)
-- Range where the battery is pretty well charged, but not
-- entirely full yet. Normally the fridge only turns on
-- then the battery is above this range. Once on, it's allowed
-- to keep running until the battery falls below this range.
--
-- This prioritizes charging the battery over running the fridge,
-- while avoiding turning the fridge off for every passing cloud.
mostlycharged = Range (Percentage 75) (Percentage 90)
-- When the fridge exceeds the allowed temp, it's worth draining
-- the batteries more to run it, as long as they remain above this
-- range.
badlycharged = Range (Percentage 0) (Percentage 35)
-- | Override the behavior as needed to protect the fridge's compressor
-- motor.
--
-- Starting the compressor when it's already hot from a previous run can
-- trigger the fridge's own thermal protection, and it will refuse to run.
-- But even when it doesn't, warm starts stress the compressor, which
-- has to work against higher pressure when the coolant is warm.
-- So, avoid starting when it's too warm.
--
-- Also shutdown the motor if it gets extremely hot, just in case.
--
-- Note that, if the motor sensor is not available, the fridge will
-- still run, relying on its built-in thermal protection.
fridgeMotorProtection :: Behavior (Annotated (Maybe PowerChange)) -> Automation (Sensors t) Actuators (Behavior (Annotated (Maybe PowerChange)))
fridgeMotorProtection b = getCompose $ calc
<$> Compose (pure b)
<*> Compose (sensedBehavior fridgeMotorTemperature)
where
calc v SensorUnavailable = v
calc v@(Annotated l power) (Sensed motortemp)
| motortemp >= toohot =
-- Note that the motor will need to cool down
-- to the maxstart before it starts
-- again after being stopped this way.
Annotated ("Motor is hot, not running! Otherwise, would be:" : l)
(Just PowerOff)
| power == Just PowerOn && motortemp > maxstart =
-- Nothing so it keeps running if was already
-- running, but does not power on otherwise.
Annotated (l ++ ["(motor is too warm to start, but running is ok)"])
Nothing
| otherwise = v
-- How warm is too warm to start the motor?
-- On a very hot day, say the hottest day I've ever seen here
-- (110 F / 43 C), the fridge is somewhat protected by being on
-- the porch (or perhaps in the house), but the motor could still
-- cool quite slowly. The motor runs between 40-55 C when porch
-- temps are in the 30's C. Don't want to make this depend
-- on the exterior air temp sensor, so picked 45 C as the maximum
-- temp that the motor will start at.
-- This may need later adjustment.
maxstart = TemperatureCelsius 45
-- This is outside the range that the motor is ever expected to
-- run at, so something would be very wrong if it got this hot.
-- This may need later adjustment.
toohot = TemperatureCelsius 70
-- | Fridge thoughts is triggered by many sensors, so updates often.
-- This samples it once per minute, to avoid steaming out the thoughts too
-- fast.
occasionalFridgeThoughts :: Automation (Sensors t) Actuators (Behavior (Annotated (Maybe PowerChange)))
occasionalFridgeThoughts = do
t <- fridgeThoughts
e <- rateLimitMinute 1 t
automationStepper (Annotated ["no thoughts yet"] Nothing) e
-- | Sums up the duration of the last run of the fridge. Does not
-- reset to 0 when it turns off, so that the poller can collect the data
-- without missing the final length of the run.
fridgeRunDuration :: Automation (Sensors t) Actuators (Behavior Minutes)
fridgeRunDuration = do
power <- powerSettingBehavior (PowerSetting False)
=<< rateLimitMinute 1 =<< fridgeBehavior
clock <- clockSignalBehavior currentTime
calce <- rateLimitMinute 1 $ calc <$> power <*> clock
fmap (secondsToMinutes . fst) <$> accumB initst calce
where
initst = (0, (0, False))
calc (PowerSetting True) (Just (ClockSignal now)) (runtime, (prevtime, wasrunning)) =
let !runtime' = if wasrunning
then runtime + now - prevtime
else 0
in (runtime', (now, True))
calc (PowerSetting False) _ (runtime, (prevtime, _wasrunning)) =
(runtime, (prevtime, False))
calc _ _ oldst = oldst
-- | Sums up how long the fridge has run, resetting at midnight.
--
-- This starts with the value of lastKnownFridgeRuntimeToday, so when the
-- automation is restarted, it will remember the old value it calculated,
-- as long as the poller is able to provide it.
fridgeRuntimeToday :: Automation (Sensors t) Actuators (Behavior (Hours, Maybe Day))
fridgeRuntimeToday = do
lastknown <- sensedBehavior lastKnownFridgeRuntimeToday
clock <- declock $ clockSignalBehavior currentTime
currday <- declock calendarDay
power <- powerSettingBehavior (PowerSetting False)
=<< rateLimitMinute 1 =<< fridgeBehavior
calce <- rateLimitMinute 1 $
calc <$> lastknown <*> currday <*> clock <*> power
fmap extract <$> accumB initst calce
where
declock = fmap $ fmap $ fmap (\(ClockSignal t) -> t)
initst :: (Bool, Hours, Maybe Day, Maybe POSIXTime)
initst = (False, Hours 0, Nothing, Nothing)
extract :: (Bool, Hours, Maybe Day, Maybe POSIXTime) -> (Hours, Maybe Day)
extract (_, n, day, _) = (n, day)
calc :: (Sensed (Hours, Maybe Day)) -> Maybe Day -> Maybe POSIXTime -> PowerSetting -> (Bool, Hours, Maybe Day, Maybe POSIXTime) -> (Bool, Hours, Maybe Day, Maybe POSIXTime)
calc lastknown currday clock power (seenlastknown, n, prevday, prevclock)
| isNothing currday =
(False, Hours 0, currday, clock)
| currday /= prevday =
(seenlastknown', lastknownadj, currday, clock)
| otherwise =
let elapsedtime = if power == PowerSetting True
then secondsToHours $ fromMaybe 0 $
(-) <$> clock <*> prevclock
else Hours 0
in (seenlastknown', lastknownadj + n + elapsedtime, prevday, clock)
where
seenlastknown' = seenlastknown || case lastknown of
SensorUnavailable -> False
Sensed _ -> True
lastknownadj
| seenlastknown = Hours 0
| otherwise = case lastknown of
Sensed (lastn, lastd)
| lastd == currday -> lastn
| otherwise -> Hours 0
SensorUnavailable -> Hours 0
|