import kivy from kivy.app import App from kivy.clock import Clock 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.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' class MainApp(App): def build(self): mainWindow = MainWindow() Clock.schedule_interval(mainWindow.update, 0.25) 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('') 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): 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)) def update(self, dt): self.update_data_sources() self.update_last_temperature() #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) # 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.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 self.do_update() 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); 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): 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 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 > 15: Logger.debug('update_output_files going ahead') 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 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) if not self.data.any(): self.data = newpoint return #Logger.debug('self.data: ' + str(self.data)) #Logger.debug('newpoint: ' + str(newpoint)) self.data = numpy.vstack((self.data, newpoint)) self.do_update() def on_reference_profile_file(self, *args, **kwargs): Logger.debug('PlotWidget: on_reference_profile_file ' + str(self.reference_profile_file)) self.load_reference_profile() self.do_update() 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' if __name__ == '__main__': dataThread = threading.Thread(None, temp_log.threaded_reader, None, ['/dev/ttyACM0', dataThreadDataQueue, dataThreadCommandQueue]) MainApp().run()