From c6e7aefb9630f7520ead662044ddfa29a15a3a30 Mon Sep 17 00:00:00 2001 From: Kienan Stewart Date: Wed, 1 Apr 2015 23:06:03 -0400 Subject: [PATCH] Data is now pulled from sensor queue in batches, no limit to size. Temperature queue check frequency reduced. Image plotting now happens in a background thread. --- main.kv | 4 +- main.py | 336 ++++++++++++++++++++++++++++++++------------------------ 2 files changed, 195 insertions(+), 145 deletions(-) diff --git a/main.kv b/main.kv index ca30e83..086d400 100644 --- a/main.kv +++ b/main.kv @@ -89,9 +89,8 @@ serial_chooser_button: serial_chooser_button on_dataSources: serial_chooser_button.values = self.dataSources on_lastTemperature: current_temperature.on_temperature_change(self.lastTemperature) - on_lastTemperature: mainplot.on_lastTemperature(self.lastTemperature) - on_lastTime: mainplot.on_lastTime(self.lastTime) on_lastStatus: status_bar.update_status(self.lastStatus) + on_lastData: mainplot.update_data(self.lastData) raw_data_output_file: '' image_output_file: '' on_raw_data_output_file: mainplot.raw_data_output_file = self.raw_data_output_file @@ -173,4 +172,5 @@ StatusBar: size_hint_y: 0.1 id: status_bar + MainWindow: \ No newline at end of file diff --git a/main.py b/main.py index 7319717..b5a7069 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,10 @@ +import io import kivy from kivy.app import App from kivy.clock import Clock import kivy.core.image +import kivy.config +import kivy.core.image from kivy.core.image import ImageData from kivy.core.image.img_pil import ImageLoaderPIL from kivy.core.image.img_pygame import ImageLoaderPygame @@ -22,6 +25,7 @@ from kivy.uix.spinner import Spinner import kivy.lang import matplotlib matplotlib.use('Agg') +import matplotlib.backends.backend_agg import matplotlib.image import matplotlib.lines import matplotlib.patches @@ -41,11 +45,13 @@ dataThreadDataQueue = Queue.Queue() dataThreadCommandQueue = Queue.Queue() defaultSerialPort = '/dev/ttyACM0' +kivy.config.Config.set('modules', 'monitor', '') + class MainApp(App): def build(self): mainWindow = MainWindow() - Clock.schedule_interval(mainWindow.update, 0.25) + Clock.schedule_interval(mainWindow.update, 0.5) return mainWindow @@ -71,6 +77,8 @@ class MainWindow(FloatLayout): image_output_file = StringProperty('') raw_data_output_file = StringProperty('') reference_profile_file = StringProperty('') + lastData = ListProperty([]) + def __init__(self, **kwargs): super(MainWindow, self).__init__(**kwargs) @@ -83,38 +91,39 @@ class MainWindow(FloatLayout): def update_last_temperature(self): global dataThreadDataQueue # This breaks if there are more updates in the queue than the frequency of update_last_temperature - it pulls old data out of the queue, and not the most recent. - try: - data = dataThreadDataQueue.get_nowait() - if data is not None: - Logger.debug('Data: ' + str(data)) - if self.state != 'paused': - if 'data' in data and 'temperature' in data['data']: - self.lastStatus = {'status' : 'ok', 'message' : ''} - self.lastTemperature = float(data['data']['temperature']) - if 'time' in data: - self.lastTime = float(data['time']) - if 'exception' in data: - self.lastStatus = {'status' : 'error', 'message' : data['exception']} - else: - self.lastStatus = {'status' : 'paused', 'message' : 'Data reception halted by user'} - Logger.debug('MainWindow: state set to ' + str(self.state) + ' : ignoring data') - if dataThreadDataQueue.qsize(): - Logger.debug('Queue Size: ' + str(dataThreadDataQueue.qsize())) - except Queue.Empty: - pass - - - def on_lastTemperature(self, instance, value): - Logger.debug('lastTemperature has changed to: ' + str(value)) - - - def on_lastTime(self, instance, value): - Logger.debug('lastTime has changed to: ' + str(value)) - + done = False + data = [] + while not done: + try: + data.append(dataThreadDataQueue.get_nowait()) + except Queue.Empty: + done = True + if data: + if len(data) > 1: + Logger.debug('update_last_temperature: ' + str(len(data)) + ' items retrieved from queue') + last_point = data[-1] + if self.state == 'paused': + # Try to update the last temperature, but not to the plotwidget, + # when the data reception is halted. + self.lastStatus = {'status' : 'paused', 'message' : 'Data receiption halted by user'} + if 'data' in last_point and 'temperature' in last_point['data']: + self.lastTemperature = float(last_point['data']['temperature']) + return + if 'data' in last_point and 'temperature' in last_point['data']: + self.lastStatus = {'status' : 'ok', 'message' : ''} + self.lastTemperature = float(last_point['data']['temperature']) + if 'time' in last_point: + self.lastTime = float(last_point['time']) + if 'exception' in last_point: + self.lastStatus = {'status' : 'error', 'message' : data['exception']} + self.lastData = data + if dataThreadDataQueue.qsize(): + Logger.debug('update_last_temperature: ' + str(dataThreadDataQueue.qsize()) + ' items left in queue after ' + str(len(data)) + ' items were taken out') def update(self, dt): self.update_data_sources() self.update_last_temperature() + Logger.debug('MainWindow: FPS ' + str(kivy.clock.Clock.get_fps())) #Logger.debug('MainWindow: dataThread ' + str(dataThread.name) + ' status: ' + str(dataThread.is_alive())) @@ -378,7 +387,8 @@ class PlotWidget(Image): show_alphaamylase = BooleanProperty(True) file_write_interval = NumericProperty(15) image_update_interval = NumericProperty(5) - + buildingImage = BooleanProperty(False) + imageDataQueue = ObjectProperty(None, allownone = True) # Common data that we should only instantiate once # Source : http://www.howtobrew.com/section3/chapter14-1.html @@ -392,6 +402,7 @@ class PlotWidget(Image): def __init__(self, **kwargs): super(PlotWidget, self).__init__(**kwargs) + self.imageThread = None self.data = numpy.array([], dtype=float); self.lastTime = 0 self.lastTemperature = 0 @@ -406,7 +417,7 @@ class PlotWidget(Image): self._image_raw_data = None self.reference_profile = None Clock.schedule_interval(self.do_update, self.image_update_interval) - self.do_update() + Clock.schedule_once(self.do_update) def on_image_update_interval(self, *args): @@ -415,34 +426,6 @@ class PlotWidget(Image): Clock.schedule_interval(self.do_update, self.image_update_interval) - def update_patches(self, tmax = None): - items = [ - (self.show_betaglucan, self.betaglucan_vertices), - (self.show_protease, self.protease_vertices), - (self.show_betaamylase, self.betaamylase_vertices), - (self.show_alphaamylase, self.alphaamylase_vertices) - ] - legend_handles = [] - legend_strings = [] - for show, config in items: - color = '0.5' # default color. - if show: - origin, width, height, color, name = config - if tmax is not None: - width = tmax - p = self.plot_axes.add_patch( - matplotlib.patches.Rectangle(origin, width, height, - fc = color, visible = True, - fill = True) - ) - legend_handles.append(p) - legend_strings.append(name) - # Disabled for the moment - self.figure.legend(legend_handles, legend_strings, 'lower right', - title = 'Enzyme Activity Zones') - return legend_handles - - def clear_data(self): self.update_output_files(True) self.data = numpy.array([], dtype=float); @@ -455,79 +438,49 @@ class PlotWidget(Image): def do_update(self, *args): - image_data = self.to_image_data() - # We can't use ImageLoaders since they assume it's a file on disk. - # This replicates code from ImageLoaderPygame.load() and ImageLoaderBase.populate() - try: - im = pygame.image.load(image_data) - except: - Logger.warning('Image: Unable to load image from data') - raise - fmt = '' - if im.get_bytesize() == 3: - fmt = 'rgb' - elif im.get_bytesize() == 4: - fmt = 'rgba' - data = pygame.image.tostring(im, fmt.upper()) - self.image_data = ImageData(im.get_width(), im.get_height(), fmt, data) - self.texture = Texture.create_from_data(self.image_data) - self.texture.flip_vertical() - # Update output files, if any - # @TODO Add in an update interval so the disk isn't hammered on every single update - self.update_output_files() - - - def to_image_data(self): - # Add in polygons for hilightning particular temperature ranges of interest. - plot_args = [] - t = None - time_max = 60 - temp_max = 80 - handles = [] - if self.data.any(): - #data = numpy.copy(self.data) - data = self.data.transpose() - t, temperature = numpy.split(data, 2, axis = 0) - if t[0][-1] > time_max: - time_max = t[0][-1] - #if numpy.nanmax(temperature[0]) > temp_max: - # temp_max = numpy.nanmax(temperature[0]) - plot_args.extend((t[0], temperature[0], 'b')) - handles.append('Recorded Temperature Profile') - if self.reference_profile is not None and self.reference_profile.any(): - #reference_data = numpy.copy(self.reference_profile) - reference_data = self.reference_profile.transpose() - t2, reference_temperature = numpy.split(reference_data, 2, axis = 0) - # Add t[0] to all reference points to make it fit on the current graph - t2 = numpy.add(t2, t[0][0]) - if t2[0][-1] > time_max: - time_max = t2[0][-1] - #if numpy.nanmax(reference_temperature[0]) > temp_max: - # temp_max = numpy.nanmax(reference_temperature[0]) - plot_args.extend((t2[0], reference_temperature[0], 'r')) - handles.append('Reference Temperature Profile') - lines = self.plot_axes.plot(*plot_args) - # Patches must be changed after the axes are plotted. - self.update_patches() - # Disabled the legend for the moment. - # @TODO Make a small subplot next to the main plot into which legends may be placed. - #self.plot_axes.legend(lines, handles, 'lower right', title = 'Temperature Profiles') - # Set a default in case no data is plotted. This may allow patches to show, - # and it will look a little nicer before recording starts. - #self.plot_axes.set_xlim(0, time_max) - self.plot_axes.set_ylim(0, temp_max) - image_data = cStringIO.StringIO() - self.figure.savefig(image_data, format = 'png') - image_data.seek(0) - self._image_raw_data = image_data.getvalue() - return image_data + if self.buildingImage: + # Check end condition, retrieve data, clean-up + if self.imageThread.isAlive(): + return + self.imageThread.join(0.01) + image_data = None + try: + image_data = self.imageDataQueue.get_nowait() + except Queue.Empty: + pass + if image_data: + image = image_data['kivy_image'] + self.texture = image.texture + self._image_raw_data = image_data['raw_image_data'] + self.imageThread = None + self.imageDataQueue = None + # @TODO self.update_output_files() should may be a scheduled task + self.update_output_files() + self.buildingImage = False + return + self.buildingImage = True + self.imageDataQueue = Queue.Queue() + settings = { + 'patches' : { + 'betaglucan' : { 'show' : self.show_betaglucan, + 'vertices' : self.betaglucan_vertices}, + 'protease' : { 'show' : self.show_protease, + 'vertices' : self.protease_vertices}, + 'betaamylase' : { 'show' : self.show_betaamylase, + 'vertices' : self.betaamylase_vertices}, + 'alphaamylase' : { 'show' : self.show_alphaamylase, + 'vertices' : self.alphaamylase_vertices}, + } + } + self.imageThread = threading.Thread(None, threaded_image_builder, None, + [self.data, self.reference_profile, + self.imageDataQueue, settings]) + self.imageThread.start() def update_output_files(self, force = False): # Do this only once every 15 seconds to avoid hitting the disk frequently - Logger.debug('update_output_files called') if force or time.time() - self.lastSave > self.file_write_interval: - Logger.debug('update_output_files going ahead') self.update_raw_data_output_file() self.update_image_output_file() self.lastSave = time.time() @@ -551,31 +504,24 @@ class PlotWidget(Image): Logger.debug('update_image_output_file: Unable to open file ' + self.image_output_file) - def on_lastTemperature(self, value): - self.lastTemperature = value - - - def on_lastTime(self, value): - self.lastTime = value - self.update_data() - - - def update_data(self): - if self.lastTime == 0 or self.lastTemperature == 0: - return - newpoint = numpy.array([(self.lastTime, self.lastTemperature)], dtype=float, ndmin = 2) + def update_data(self, data = list(), *args): + points = [] + for point in data: + if 'data' in point and 'time' in point and 'temperature' in point['data']: + points.append(numpy.array([(point['time'], point['data']['temperature'])], + dtype=float, ndmin =2)) + extra_data = numpy.vstack(tuple(points)) if not self.data.any(): - self.data = newpoint + self.data = extra_data return #Logger.debug('self.data: ' + str(self.data)) #Logger.debug('newpoint: ' + str(newpoint)) - self.data = numpy.vstack((self.data, newpoint)) + self.data = numpy.vstack((self.data, extra_data)) def on_reference_profile_file(self, *args, **kwargs): Logger.debug('PlotWidget: on_reference_profile_file ' + str(self.reference_profile_file)) self.load_reference_profile() - Clock.schedule_once(self.do_update) def load_reference_profile(self): @@ -621,6 +567,110 @@ class StatusBar(BoxLayout): self.ids.status_image.source = './images/warning.png' +def threaded_image_builder(data, reference_profile, dataQueue, settings = dict()): + figure, plot_axes = init_plot(settings) + start = time.time() + image_data = create_graph(figure, plot_axes, data, reference_profile, settings) + Logger.debug('threaded_image_builder: Took ' + str(time.time() - start) + ' seconds to build image data') + return_data = dict() + start = time.time() + image_data_raw = image_data.getvalue() + image_bytes = io.BytesIO(image_data_raw) + return_data['raw_image_data'] = image_data_raw + return_data['kivy_image'] = kivy.core.image.Image(image_bytes, ext = 'png') + Logger.debug('threaded_image_builder: Took ' + str(time.time() - start) + ' seconds to manipulate image data') + dataQueue.put_nowait(return_data) + + +def init_plot(settings = dict()): + figure = matplotlib.pyplot.figure() + plot_axes = figure.add_subplot(1, 1, 1) + plot_axes.hold(False) + plot_axes.set_ylabel('Temperature (deg C)') + plot_axes.set_xlabel('Time') + plot_axes.set_title('Recorded Temperature') + return figure, plot_axes + + +def create_graph(figure, plot_axes, data, reference_profile, settings = dict()): + # Add in polygons for hilightning particular temperature ranges of interest. + plot_args = [] + t = None + time_max = 60 + temp_max = 80 + handles = [] + if data.any(): + #data = numpy.copy(self.data) + data = data.transpose() + t, temperature = numpy.split(data, 2, axis = 0) + if t[0][-1] > time_max: + time_max = t[0][-1] + #if numpy.nanmax(temperature[0]) > temp_max: + # temp_max = numpy.nanmax(temperature[0]) + plot_args.extend((t[0], temperature[0], 'b')) + handles.append('Recorded Temperature Profile') + if reference_profile is not None and reference_profile.any(): + #reference_data = numpy.copy(self.reference_profile) + reference_data = reference_profile.transpose() + t2, reference_temperature = numpy.split(reference_data, 2, axis = 0) + # Add t[0] to all reference points to make it fit on the current graph + t2 = numpy.add(t2, t[0][0]) + if t2[0][-1] > time_max: + time_max = t2[0][-1] + #if numpy.nanmax(reference_temperature[0]) > temp_max: + # temp_max = numpy.nanmax(reference_temperature[0]) + plot_args.extend((t2[0], reference_temperature[0], 'r')) + handles.append('Reference Temperature Profile') + lines = plot_axes.plot(*plot_args) + # Patches must be changed after the axes are plotted. + graph_update_patches(figure, plot_axes, time_max, settings) + # Disabled the legend for the moment. + # @TODO Make a small subplot next to the main plot into which legends may be placed. + #self.plot_axes.legend(lines, handles, 'lower right', title = 'Temperature Profiles') + # Set a default in case no data is plotted. This may allow patches to show, + # and it will look a little nicer before recording starts. + #self.plot_axes.set_xlim(0, time_max) + plot_axes.set_ylim(0, temp_max) + image_data = cStringIO.StringIO() + # Save a 640x480 image, see http://stackoverflow.com/questions/13714454/specifying-and-saving-a-figure-with-exact-size-in-pixels + #canvas = matplotlib.backends.backend_agg.FigureCanvasAgg(figure) + #canvas.draw() + #renderer = canvas.get_renderer() + #raw_data = renderer.tostring_argb() + #size = canvas.get_width_height() + figure.savefig(image_data, format = 'png', dpi = 96, figsize = (640/96, 480/96)) + #image_data.seek(0) + return image_data + #return raw_data, size + + +def graph_update_patches(figure, plot_axes, time_max, settings): + if 'patches' in settings: + settings = settings['patches'] + items = list() + for patch_id, patch_settings in settings.iteritems(): + items.append((patch_settings['show'], patch_settings['vertices'])) + legend_handles = [] + legend_strings = [] + for show, config in items: + color = '0.5' # default color. + if show: + origin, width, height, color, name = config + if time_max is not None: + width = time_max + p = plot_axes.add_patch( + matplotlib.patches.Rectangle(origin, width, height, + fc = color, visible = True, + fill = True) + ) + legend_handles.append(p) + legend_strings.append(name) + # Disabled for the moment + figure.legend(legend_handles, legend_strings, 'lower right', + title = 'Enzyme Activity Zones') + return legend_handles + + if __name__ == '__main__': dataThread = threading.Thread(None, temp_log.threaded_reader, None, ['/dev/ttyACM0', dataThreadDataQueue, dataThreadCommandQueue]) MainApp().run()