solar_heater_scenario.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. """Implementation of a solar water heater example (with dummy sensors).
  2. In this scenario, we have a solar water heater that includes a water temperature
  3. sensor on the output pipe of the heater. There is also an actuator which
  4. controls a bypass valve: if the actuator is ON, the hot water is redirected to a
  5. Spa, instead of going to the house. The spa is acting as a heat sink, taking
  6. up the extra heat.
  7. The Controller class below implements a state machine which looks at the data
  8. from the temperature sensor and turns on the bypass valve when the heated water
  9. is too hot. To avoid oscillations, we use the following logic:
  10. 1. If the running average of the temperature exceeds T_high, turn on the bypass
  11. 2. When the running average dips below T_low (where T_low<T_high), then turn
  12. off the bypass.
  13. We also want to aways output an initial value for the valve upon the very first
  14. sensor reading. If the first reading is below T_low, the valve should be off. For
  15. subsequent sensor events, we only output an actuator value if it has changed.
  16. When designing the data flow for this application, we need to ensure that we
  17. have input determinism: for a given input sequence, only one output sequence
  18. on the actuator is possible. This can be achieved by using the dispatch()
  19. filter, which ensures that only one event is input to the controller state
  20. machine for a given sensor input.
  21. Here is the spec for out state machine:
  22. Current State | Input Event | Output Event | Next State
  23. ==============+=============+==============+=============
  24. INITIAL | between | OFF | NORMAL
  25. INITIAL | T_high | ON | TOO_HOT
  26. INITIAL | T_low | OFF | NORMAL
  27. NORMAL | T_high | ON | TOO_HOT
  28. NORMAL | between | NULL | NORMAL
  29. NORMAL | T_low | NULL | NORMAL
  30. TOO_HOT | T_high | NULL | TOO_HOT
  31. TOO_HOT | T_low | OFF | NORMAL
  32. TOO_HOT | between | NULL | TOO_HOT
  33. """
  34. import asyncio
  35. import time
  36. import random
  37. random.seed()
  38. import thingflow.filters.transducer
  39. import thingflow.filters.where
  40. import thingflow.filters.first
  41. import thingflow.filters.dispatch
  42. from thingflow.base import OutputThing, Scheduler, SensorEvent, SensorAsOutputThing,\
  43. InputThing, FatalError
  44. # constants
  45. T_high = 110 # too hot threshold is this in Farenheit
  46. T_low = 100 # reset threshold is this in Farenheit
  47. assert T_low < T_high # to avoid oscillations
  48. # states
  49. INITIAL = "INITIAL"
  50. NORMAL = "NORMAL"
  51. TOO_HOT ="TOO_HOT"
  52. class DummyTempSensor:
  53. """Instead of a real temperature sensor, we define one that outputs
  54. values provided as a list when it is created.
  55. """
  56. def __init__(self, sensor_id, values):
  57. self.sensor_id = sensor_id
  58. def generator():
  59. for v in values:
  60. yield v
  61. self.generator = generator()
  62. def sample(self):
  63. return self.generator.__next__()
  64. def __repr__(self):
  65. return 'DummyTempSensor(%s)' % self.sensor_id
  66. class RunningAvg(thingflow.filters.transducer.Transducer):
  67. """Transducer that returns a running average of values
  68. of over the history interval. Note that the interval is a time period,
  69. not a number of samples.
  70. """
  71. def __init__(self, history_interval):
  72. self.history_interval = history_interval
  73. self.history = [] # first element in the list is the oldest
  74. def step(self, event):
  75. total = event.val # always include the latest
  76. cnt = 1
  77. new_start = 0
  78. for (i, old_event) in enumerate(self.history):
  79. if (event.ts-old_event.ts)<self.history_interval:
  80. total += old_event.val
  81. cnt += 1
  82. else: # the timestamp is stale
  83. new_start = i + 1 # will at least start at the next one
  84. if new_start>0:
  85. self.history = self.history[new_start:]
  86. self.history.append(event)
  87. return SensorEvent(ts=event.ts, sensor_id=event.sensor_id, val=total/cnt)
  88. def __repr__(self):
  89. return 'RunningAvg(%s)' % self.history_interval
  90. class Controller(OutputThing):
  91. """Input sensor events and output actuator settings.
  92. """
  93. def __init__(self):
  94. super().__init__()
  95. self.state = INITIAL
  96. self.completed = False
  97. def _make_event(self, val):
  98. return SensorEvent(ts=time.time(), sensor_id='Controller', val=val)
  99. def on_t_high_next(self, event):
  100. if self.state==NORMAL or self.state==INITIAL:
  101. self._dispatch_next(self._make_event("ON"))
  102. self.state = TOO_HOT
  103. def on_t_high_completed(self):
  104. if not self.completed:
  105. self._dispatch_completed()
  106. self.completed = True
  107. def on_t_high_error(self, e):
  108. pass
  109. def on_t_low_next(self, event):
  110. if self.state==TOO_HOT or self.state==INITIAL:
  111. self._dispatch_next(self._make_event("OFF"))
  112. self.state = NORMAL
  113. def on_t_low_completed(self):
  114. if not self.completed:
  115. self._dispatch_completed()
  116. self.completed = True
  117. def on_t_low_error(self, e):
  118. pass
  119. def on_between_next(self, x):
  120. if self.state==INITIAL:
  121. self.state = NORMAL
  122. self._dispatch_next(self._make_event("OFF"))
  123. else:
  124. pass # stay in current state
  125. def on_between_error(self, e):
  126. pass
  127. def on_between_completed(self):
  128. pass # don't want to pass this forward, as it will happen after the first item
  129. def __repr__(self):
  130. return 'Controller'
  131. class BypassValveActuator(InputThing):
  132. def on_next(self, x):
  133. if x.val=='ON':
  134. print("Turning ON!")
  135. elif x.val=='OFF':
  136. print("Turning OFF!")
  137. else:
  138. raise FatalError("Unexpected event value for actuator: %s" % x.val)
  139. def __repr__(self):
  140. return 'BypassValveActuator'
  141. # The values we will use for the sensor
  142. input_sequence = [i for i in range(T_low-2, T_high+4)] + \
  143. [i for i in range(T_high+2, T_low-6, -2)] + \
  144. [i for i in range(T_low-4, T_high+4, 2)] + \
  145. [i for i in range(T_high+2, T_low-6, -2)]
  146. # Add some random noise to our sequence
  147. for i in range(len(input_sequence)):
  148. input_sequence[i] = round(random.gauss(input_sequence[i], 2), 1)
  149. def run_example():
  150. sensor = SensorAsOutputThing(DummyTempSensor('temp-1', input_sequence))
  151. sensor.output() # let us see the raw values
  152. dispatcher = sensor.transduce(RunningAvg(4))\
  153. .passthrough(lambda evt:
  154. print("Running avg temp: %s" %
  155. round(evt.val, 2))) \
  156. .dispatch([(lambda v: v[2]>=T_high, 't_high'),
  157. (lambda v: v[2]<=T_low, 't_low')])
  158. controller = Controller()
  159. dispatcher.connect(controller, port_mapping=('t_high', 't_high'))
  160. dispatcher.connect(controller, port_mapping=('t_low', 't_low'))
  161. dispatcher.connect(controller, port_mapping=('default', 'between'))
  162. controller.connect(BypassValveActuator())
  163. sensor.print_downstream()
  164. scheduler = Scheduler(asyncio.get_event_loop())
  165. scheduler.schedule_periodic(sensor, 0.5)
  166. scheduler.run_forever()
  167. print("got to the end")
  168. if __name__ == '__main__':
  169. run_example()