temperature-logger-interface/main.py

679 lines
26 KiB
Python

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(list())
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' : last_point['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()