test_solar_heater_scenario.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. # Copyright 2016 by MPI-SWS and Data-Ken Research.
  2. # Licensed under the Apache 2.0 License.
  3. """Implementation of the solar water heater example (with dummy sensors).
  4. In this scenario, we have a solar water heater that includes a water temperature
  5. sensor on the output pipe of the heater. There is also an actuator which
  6. controls a bypass value: if the actuator is ON, the water goes directly to the
  7. Spa, without going through the Solar heater.
  8. The Controller class below implements a state machine which looks at the data
  9. from the temperature sensor and turns on the bypass valve when the heated water
  10. is too hot. To avoid oscillations, we use the following logic:
  11. 1. If the running average of the temperature exceeds T1, turn on the bypass
  12. 2. When the running average dips below T2 (where T2<T1), then turn off the
  13. bypass.
  14. We also want to aways output an initial value for the valve upon the very first
  15. sensor reading. If the first reading is below T2, the valve should be off. For
  16. subsequent sensor events, we only output an actuator value if it has changed.
  17. When designing the graph for this application, we need to ensure that we
  18. have input determinism: for a given input sequence, only one output sequence
  19. on the actuator is possible. This can be achieved by using the dispatch()
  20. filter, which ensures that only one event is input to the controller state
  21. machine for a given sensor input.
  22. Here is the spec for out state machine:
  23. Current State | Input Event | Output Event | Next State
  24. ==============+=============+==============+=============
  25. INITIAL | between | OFF | NORMAL
  26. INITIAL | T1 | ON | TOO_HOT
  27. INITIAL | T2 | OFF | NORMAL
  28. NORMAL | T1 | ON | TOO_HOT
  29. NORMAL | T2 | NULL | NORMAL
  30. TOO_HOT | T1 | NULL | TOO_HOT
  31. TOO_HOT | T2 | OFF | NORMAL
  32. """
  33. import asyncio
  34. import unittest
  35. import thingflow.filters.transducer
  36. import thingflow.filters.where
  37. import thingflow.filters.first
  38. import thingflow.filters.dispatch
  39. from thingflow.base import *
  40. from utils import ValidationInputThing
  41. # constants
  42. T1 = 120 # too hot threshold is this in Farenheit
  43. T2 = 100 # reset threshold is this in Farenheit
  44. assert T2 < T1 # to avoid oscillations
  45. # states
  46. INITIAL = "INITIAL"
  47. NORMAL = "NORMAL"
  48. TOO_HOT ="TOO_HOT"
  49. class RunningAvg(thingflow.filters.transducer.Transducer):
  50. """Transducer that returns a running average of values
  51. of over the history interval. Note that the interval is a time period,
  52. not a number of samples.
  53. """
  54. def __init__(self, history_interval):
  55. self.history_interval = history_interval
  56. self.history = [] # first element in the list is the oldest
  57. def step(self, event):
  58. total = event.val # always include the latest
  59. cnt = 1
  60. new_start = 0
  61. for (i, old_event) in enumerate(self.history):
  62. if (event.ts-old_event.ts)<self.history_interval:
  63. total += old_event.val
  64. cnt += 1
  65. else: # the timestamp is stale
  66. new_start = i + 1 # will at least start at the next one
  67. if new_start>0:
  68. self.history = self.history[new_start:]
  69. self.history.append(event)
  70. return SensorEvent(ts=event.ts, sensor_id=event.sensor_id, val=total/cnt)
  71. def __str__(self):
  72. return 'RunningAvg(%s)' % self.history_interval
  73. class Controller(OutputThing):
  74. """Input sensor events and output actuator settings.
  75. """
  76. def __init__(self):
  77. super().__init__()
  78. self.state = INITIAL
  79. self.completed = False
  80. def _make_event(self, val):
  81. return SensorEvent(ts=time.time(), sensor_id='Controller', val=val)
  82. def on_t1_next(self, event):
  83. if self.state==NORMAL or self.state==INITIAL:
  84. self._dispatch_next(self._make_event("ON"))
  85. self.state = TOO_HOT
  86. def on_t1_completed(self):
  87. if not self.completed:
  88. self._dispatch_completed()
  89. self.completed = True
  90. def on_t1_error(self, e):
  91. pass
  92. def on_t2_next(self, event):
  93. if self.state==TOO_HOT or self.state==INITIAL:
  94. self._dispatch_next(self._make_event("OFF"))
  95. self.state = NORMAL
  96. def on_t2_completed(self):
  97. if not self.completed:
  98. self._dispatch_completed()
  99. self.completed = True
  100. def on_t2_error(self, e):
  101. pass
  102. def on_between_next(self, x):
  103. assert self.state==INITIAL, "Should only get between on the first call"
  104. self.state = NORMAL
  105. self._dispatch_next(self._make_event("OFF"))
  106. def on_between_error(self, e):
  107. pass
  108. def on_between_completed(self):
  109. pass # don't want to pass this forward, as it will happen after the first item
  110. def sensor_from_sequence(sensor_id, sequence):
  111. """Return a sensor that samples from a sequence of (ts, value) pairs.
  112. """
  113. def generator():
  114. for (ts, v) in sequence:
  115. yield SensorEvent(sensor_id, ts, v)
  116. o = IterableAsOutputThing(generator(), name='Sensor(%s)' % sensor_id)
  117. return o
  118. input_sequence = [(1, T1-5), (2, T1), (3, T1+2), (4, T1+2),
  119. (5, T2), (6, T2), (7, T2-1), (8, T2-2)]
  120. expected_sequence= ['OFF', 'ON', 'OFF']
  121. class TestSolarHeater(unittest.TestCase):
  122. def test_case(self):
  123. sensor = sensor_from_sequence(1, input_sequence)
  124. sensor.connect(print)
  125. dispatcher = sensor.transduce(RunningAvg(2)) \
  126. .dispatch([(lambda v: v[2]>=T1, 't1'),
  127. (lambda v: v[2]<=T2, 't2')])
  128. controller = Controller()
  129. dispatcher.connect(controller, port_mapping=('t1', 't1'))
  130. dispatcher.connect(controller, port_mapping=('t2', 't2'))
  131. # we only push the between message to the controller for the first
  132. # event - it is only needed for emitting an output from the initial
  133. # state.
  134. dispatcher.first().connect(controller, port_mapping=('default',
  135. 'between'))
  136. controller.connect(print)
  137. vo = ValidationInputThing(expected_sequence, self)
  138. controller.connect(vo)
  139. sensor.print_downstream()
  140. scheduler = Scheduler(asyncio.get_event_loop())
  141. scheduler.schedule_periodic(sensor, 0.5)
  142. scheduler.run_forever()
  143. self.assertTrue(vo.completed,
  144. "Schedule exited before validation observer completed")
  145. print("got to the end")
  146. if __name__ == '__main__':
  147. unittest.main()