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 import kivy.graphics.texture from kivy.graphics.texture import Texture from kivy.logger import Logger from kivy.properties import BooleanProperty, DictProperty, ListProperty, NumericProperty, ObjectProperty, StringProperty from kivy.uix.boxlayout import BoxLayout from kivy.uix.button import Button from kivy.uix.dropdown import DropDown from kivy.uix.floatlayout import FloatLayout from kivy.uix.gridlayout import GridLayout from kivy.uix.image import Image from kivy.uix.label import Label from kivy.uix.modalview import ModalView from kivy.uix.popup import Popup 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 import matplotlib.pyplot import numpy import os import os.path import pygame.image import Queue import cStringIO import temp_log import time import threading dataThread = None 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.5) return mainWindow def on_stop(self): global dataThreadCommandQueue dataThreadCommandQueue.put_nowait('stop') def on_start(self): global dataThread dataThread.start() class MainWindow(FloatLayout): """Main Window class""" dataSource = StringProperty(defaultSerialPort) dataSources = ListProperty(temp_log.list_serial_ports()) lastTemperature = NumericProperty(-1000.) lastTime = NumericProperty(-1.) recordingState = StringProperty('') state = StringProperty('running') lastStatus = DictProperty({}) image_output_file = StringProperty('') raw_data_output_file = StringProperty('') reference_profile_file = StringProperty('') lastData = ListProperty([]) def __init__(self, **kwargs): super(MainWindow, self).__init__(**kwargs) # self.ids is available here; use it's references to connect bindings Logger.debug('MainWindow IDs: ' + str(self.ids)) self.modal_view = None #self.ids['serial_chooser_dropdown'].bind(on_serial_port_change=self.serial_port_changed) def update_last_temperature(self): start = time.time() 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. 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') Logger.debug('update_last_temperature: Took ' + str(time.time() - start) + ' seconds to retrieve ' + str(len(data)) + ' items. ' + str(dataThreadDataQueue.qsize()) + ' items remain in queue') 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())) def update_data_sources(self): self.dataSources = temp_log.list_serial_ports() def on_dataSources(self, instance, value): Logger.debug('dataSources changed to: ' + str(self.dataSources)) children = self.children[:] def serial_port_changed(self, *args): Logger.debug('MainWindow: Received on_serial_port_changed: ' + str(args)) Logger.debug('MainWindow: Current dataSource: ' + str(self.dataSource)) if args: new_port = args[0] if new_port == self.dataSource: Logger.debug('MainWindow: Selected port matches current port, no change') elif new_port in self.dataSources: self.dataSource = new_port else: self.dataSource = '' def on_dataSource(self, instance, value): # Stop the current data thread, if any. Logger.debug('MainWindow: stopping dataThread') global dataThread, dataThreadDataQueue, dataThreadCommandQueue dataThreadCommandQueue.put('stop') # This could cause the UI to hang until the thread is joined, if ever. dataThread.join() # Start a new data thread using the selected data source if value: Logger.debug('MainWindow: Starting new data thread on ' + str(value)) # For some reason it seems necessary to create new Queue objects. dataThreadCommandQueue = Queue.Queue() dataThreadDataQueue = Queue.Queue() dataThread = threading.Thread(None, temp_log.threaded_reader, None, [value, dataThreadDataQueue, dataThreadCommandQueue]) dataThread.start() def set_state(self, new_state): self.state = new_state def initiate_clear_dialog(self): if self.modal_view is None: self.modal_view = YesNoModalView(id='startstop_modal_dialog') self.modal_view.process_callback = self.process_clear_dialog self.add_widget(self.modal_view) def process_clear_dialog(self, *args, **kwargs): Logger.debug('StatusWidget: process_dialog args: ' + str(args)) if args and args[0]: self.ids.mainplot.clear_data() self.image_output_file = '' self.raw_data_output_file = '' self.remove_widget(self.modal_view) self.modal_view = None def choose_output_file(self, *args, **kwargs): Logger.debug('Choose Output File pressed') output_file_dialog = OutputFileDialog(cancel = self.dismiss_popup, set_output_file = self.set_output_file) self.create_popup(title="Choose outputfile", content = output_file_dialog) def set_output_file(self, path, file_name, *args, **kwargs): #Logger.debug('Setouput file: ', str((path, file_name, args, kwargs))) # If we're oaky with writing path + file_name + (.csv|.png) files = { 'image_file' : os.path.join(path, file_name + '.png'), 'raw_data_file' : os.path.join(path, file_name + '.csv'), } errors = {} has_errors = False for k, file_name in files.iteritems(): errors[k] = self.validate_output_file(file_name) if errors[k]: has_errors = True if not has_errors: Logger.debug('MainWindow: Set output files: ' + str(files)) self.raw_data_output_file = files['raw_data_file'] self.image_output_file = files['image_file'] self.dismiss_popup() else: # @TODO UI Feedback in the popup dialog Logger.debug('MainWindow: Set output file errors: ' + str(errors)) def validate_output_file(self, file_name): errors = [] # File doesn't exist, parent dir is writable exists = os.path.exists(file_name) # Note, the access check isn't for security, but usability here. access = os.access(os.path.dirname(file_name), os.W_OK) Logger.debug('MainWindow: validate_ouput_file file: ' + str(file_name)) Logger.debug('MainWindow: validate_output_file stat: ' + str(exists)) Logger.debug('MainWindow: validate_output_file parent dir write access: ' + str(access)) if exists: errors.append('File already exists') if not access: errors.append('No write access to parent directory') return errors def create_popup(self, *args, **kwargs): self._popup = Popup(*args, **kwargs) self._popup.open() def dismiss_popup(self, *args, **kwargs): self._popup.dismiss() def clear_output_files(self): self.raw_data_output_file = '' self.image_output_file = '' def on_raw_data_output_file(self, *args, **kwargs): if self.raw_data_output_file: root = self.raw_data_output_file.rsplit('.', 1) Logger.debug('MainWindow: ' + str(root)) self.ids.output_file_selected.text = root[0] + '[.csv|.png]' else: self.ids.output_file_selected.text = 'No file Chosen' def select_reference_profile(self, *args, **kwargs): dialog = SelectReferenceProfileDialog(output = self.set_reference_profile, cancel = self.dismiss_popup) self.create_popup(title='Reference Profile Selection', content = dialog) def set_reference_profile(self, *args, **kwargs): Logger.debug('MainWindow: reference profile kwargs ' + str(kwargs)) ref_file = '' if kwargs['file']: ref_file = kwargs['file'][0] if not os.path.isfile(ref_file): # Not a valid path/file return self.reference_profile_file = ref_file self.dismiss_popup() def on_reference_profile_file(self, *kargs, **kwargs): if not self.reference_profile_file: self.ids.reference_profile_selected.text = 'No profile selected' else: self.ids.reference_profile_selected.text = self.reference_profile_file class OutputFileDialog(FloatLayout): def __init__(self, *args, **kwargs): super(OutputFileDialog, self).__init__(*args, **kwargs) self.cancel = kwargs['cancel'] self.set_output = kwargs['set_output_file'] def cancel(self, *args, **kwargs): self.cancel(*args, **kwargs) def set_output(self, *args, **kwargs): self.set_output(*args, **kwargs) class SelectReferenceProfileDialog(FloatLayout): def __init__(self, *args, **kwargs): super(SelectReferenceProfileDialog, self).__init__(*args, **kwargs) self.cancel = kwargs['cancel'] self.output = kwargs['output'] def cancel(self, *args, **kwargs): self.cancel(*args, **kwargs) def set_output(self, *args, **kwargs): self.output(*args, **kwargs) class SerialPortButton(Spinner): __events__ = ('on_serial_port_changed',) noValueText = StringProperty('No serial port selected') def __init__(self, **kwargs): super(SerialPortButton, self).__init__(**kwargs) self.text = '/dev/ttyACM0' self.register_event_type('on_serial_port_changed') self.values = temp_log.list_serial_ports() def on_text(self, instance, value): if value == self.noValueText: value = '' Logger.debug('SerialPortDropdown: ' + 'selected with args ' + str(value)) self.dispatch('on_serial_port_changed', value) def on_values(self, instance, value): if self.text not in self.values: self.text = self.noValueText def on_serial_port_changed(self, *args, **kwargs): pass class MyLabel(Label): def __init__(self, **kwargs): super(MyLabel, self).__init__(**kwargs) def on_temperature_change(self, value): if not value: value = 'n/a' self.text = 'Current temperature: ' + str(value) class StatusWidget(BoxLayout): def __init__(self, **kwargs): super(StatusWidget, self).__init__(**kwargs) self.states = ['running', 'paused'] self.change_state_labels = {'running' : 'Pause', 'paused' : 'Resume'} self.modal_view = None def change_state(self): current_state = self.states.index(self.state) new_state = (current_state + 1) % len(self.states) self.state = self.states[new_state] def on_state(self, instance, value): Logger.debug('StatusWidget: new state ' + str(self.state)) class PlotWidget(Image): image_output_file = StringProperty('') raw_data_output_file = StringProperty('') reference_profile_file = StringProperty('') # @TODO Include a way to change these properties. show_betaglucan = BooleanProperty(True) show_protease = BooleanProperty(True) show_betaamylase = BooleanProperty(True) 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 # accessed on march 21, 2015 # x_vertices = ((x, y), width, height, color, legend_string) betaglucan_vertices = ((0, 35), 12000, 10, '#57ff14', 'Betaglucanase') protease_vertices = ((0,45), 12000, 10, '#f3ff14', 'Protease / Peptidase') betaamylase_vertices = ((0,55), 12000, 10.6, '#ffc014', 'Beta Amylase') alphaamylase_vertices = ((0, 67.7), 12000, 4.5, '#ff6614', 'Alpha Amylase') def __init__(self, **kwargs): super(PlotWidget, self).__init__(**kwargs) self.imageThread = None self.data = numpy.array([], dtype=float); self.lastTime = 0 self.lastTemperature = 0 self.lastSave = 0 self.figure = matplotlib.pyplot.figure() self.plot_axes = self.figure.add_subplot(1, 1, 1) self.plot_axes.hold(False) self.plot_axes.set_ylabel('Temperature (deg C)') self.plot_axes.set_xlabel('Time') self.plot_axes.set_title('Recorded Temperature') self.image_data = None self._image_raw_data = None self.reference_profile = None Clock.schedule_interval(self.do_update, self.image_update_interval) Clock.schedule_once(self.do_update) def on_image_update_interval(self, *args): # Reschedule the update function. Clock.unschedule(self.do_update, True) Clock.schedule_interval(self.do_update, self.image_update_interval) def clear_data(self): self.update_output_files(True) self.data = numpy.array([], dtype=float); self.reference_profile_file = '' self.reference_profile = None self.texture = None self.image_output_file = '' self.raw_data_output_file = '' self.do_update() def do_update(self, *args): 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 if force or time.time() - self.lastSave > self.file_write_interval: self.update_raw_data_output_file() self.update_image_output_file() self.lastSave = time.time() def update_raw_data_output_file(self): if self.raw_data_output_file and self.data.any(): numpy.savetxt(self.raw_data_output_file, self.data, delimiter=',') def update_image_output_file(self, image_data = None): if not image_data: image_data = self._image_raw_data if self.image_output_file and image_data: f = open(self.image_output_file, 'w') if f: Logger.debug('Updated image output file') f.write(image_data) f.close() else: Logger.debug('update_image_output_file: Unable to open file ' + self.image_output_file) 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 = extra_data return #Logger.debug('self.data: ' + str(self.data)) #Logger.debug('newpoint: ' + str(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() def load_reference_profile(self): if not self.reference_profile_file: return self.reference_profile = numpy.loadtxt(self.reference_profile_file, delimiter=',') Logger.debug('PlotWidget: reference_profile: ' + str(self.reference_profile)) class YesNoModalView(Popup): def process(self, result, *args, **kwargs): Logger.debug('YesNoModalView: ' + str(result) + ' ' + str(args)) Logger.debug('YesNoModalView: callback ' + str(self.process_callback)) if self.process_callback: self.process_callback(result, *args, **kwargs) self.dismiss() class StatusBar(BoxLayout): def update_status(self, status): Logger.debug('StatusBar: received status ' + str(status)) Logger.debug('StatusBar: ids available ' + str(self.ids)) if 'message' in status: self.ids.status_message.text = status['message'] if 'status' in status: current_status = status['status'] if current_status == 'ok': self.ids.status_text.text = 'Running' self.ids.status_image.source = './images/ok.png' if not self.ids.status_message.text: self.ids.status_message.text = "Everything's perfectly all right now. We're fine. We're all fine here now, thank you. How are you?" elif current_status == 'error': self.ids.status_text.text = 'ERROR' self.ids.status_image.source = './images/error.png' elif current_status == 'paused': self.ids.status_text.text = 'Paused' self.ids.status_image.source = './images/warning.png' else: self.ids.status_text.text = 'Unknown Status: ' + current_status 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()