Index: src/sas/sascalc/dataloader/readers/cansas_reader.py
===================================================================
--- src/sas/sascalc/dataloader/readers/cansas_reader.py	(revision 83b6408fbfaefa4819b955c697fef834d8f033f6)
+++ src/sas/sascalc/dataloader/readers/cansas_reader.py	(revision 83b6408fbfaefa4819b955c697fef834d8f033f6)
@@ -957,9 +957,9 @@
             written = written | self.write_node(pix, "z", item.pixel_size.z,
                                                 {"unit": item.pixel_size_unit})
-            written = written | self.write_node(det, "slit_length",
-                                                item.slit_length,
-                                                {"unit": item.slit_length_unit})
             if written == True:
                 self.append(pix, det)
+            self.write_node(det, "slit_length", item.slit_length,
+                {"unit": item.slit_length_unit})
+
 
     def _write_process_notes(self, datainfo, entry_node):
Index: src/sas/sascalc/file_converter/bsl_loader.py
===================================================================
--- src/sas/sascalc/file_converter/bsl_loader.py	(revision d889d80ddfa5e78c05ecced143272a7881bc10e5)
+++ src/sas/sascalc/file_converter/bsl_loader.py	(revision d889d80ddfa5e78c05ecced143272a7881bc10e5)
@@ -0,0 +1,109 @@
+from sas.sascalc.file_converter.core.bsl_loader import CLoader
+from copy import deepcopy
+import os
+import numpy as np
+
+class BSLParsingError(Exception):
+    pass
+
+class BSLLoader(CLoader):
+    """
+    Loads 2D SAS data from a BSL file.
+    CLoader is a C extension (found in c_ext/bsl_loader.c)
+
+    See http://www.diamond.ac.uk/Beamlines/Soft-Condensed-Matter/small-angle/SAXS-Software/CCP13/BSL.html
+    for more info on the BSL file format.
+    """
+
+    def __init__(self, filename):
+        """
+        Parses the BSL header file and sets instance variables apropriately
+
+        :param filename: Path to the BSL header file
+        """
+        header_file = open(filename, 'r')
+        data_info = {}
+        is_valid = True
+        err_msg = ""
+
+        [folder, filename] = os.path.split(filename)
+        # SAS data will be in file Xnn001.mdd
+        sasdata_filename = filename.replace('000.', '001.')
+        if sasdata_filename == filename:
+            err_msg = ("Invalid header filename {}.\nShould be of the format "
+                "Xnn000.XXX where X is any alphanumeric character and n is any"
+                " digit.").format(filename)
+            raise BSLParsingError(err_msg)
+
+        # First 2 lines are headers
+        header_file.readline()
+        header_file.readline()
+
+        while True:
+            metadata = header_file.readline().strip()
+            metadata = metadata.split()
+            data_filename = header_file.readline().strip()
+
+            if len(metadata) != 10:
+                is_valid = False
+                err_msg = "Invalid header file: {}".format(filename)
+                break
+
+            if data_filename != sasdata_filename:
+                last_file = (metadata[9] == '0')
+                if last_file: # Reached last file we have metadata for
+                    is_valid = False
+                    err_msg = "No metadata for {} found in header file: {}"
+                    err_msg = err_msg.format(sasdata_filename, filename)
+                    break
+                continue
+            try:
+                data_info = {
+                    'filename': os.path.join(folder, data_filename),
+                    'pixels': int(metadata[0]),
+                    'rasters': int(metadata[1]),
+                    'frames': int(metadata[2]),
+                    'swap_bytes': int(metadata[3])
+                }
+            except:
+                is_valid = False
+                err_msg = "Invalid metadata in header file for {}"
+                err_msg = err_msg.format(sasdata_filename)
+            break
+
+        header_file.close()
+        if not is_valid:
+            raise BSLParsingError(err_msg)
+
+        CLoader.__init__(self, data_info['filename'], data_info['frames'],
+            data_info['pixels'], data_info['rasters'], data_info['swap_bytes'])
+
+    def __setattr__(self, name, value):
+        if name == 'filename':
+            return self.set_filename(value)
+        elif name == 'n_frames':
+            return self.set_n_frames(value)
+        elif name == 'frame':
+            return self.set_frame(value)
+        elif name == 'n_pixels':
+            return self.set_n_pixels(value)
+        elif name == 'n_rasters':
+            return self.set_n_rasters(value)
+        elif name == 'swap_bytes':
+            return self.set_swap_bytes(value)
+        return CLoader.__setattr__(self, name, value)
+
+    def __getattr__(self, name):
+        if name == 'filename':
+            return self.get_filename()
+        elif name == 'n_frames':
+            return self.get_n_frames()
+        elif name == 'frame':
+            return self.get_frame()
+        elif name == 'n_pixels':
+            return self.get_n_pixels()
+        elif name == 'n_rasters':
+            return self.get_n_rasters()
+        elif name == 'swap_bytes':
+            return self.get_swap_bytes()
+        return CLoader.__getattr__(self, name)
Index: src/sas/sascalc/file_converter/c_ext/bsl_loader.c
===================================================================
--- src/sas/sascalc/file_converter/c_ext/bsl_loader.c	(revision dc8a5538706d3b922a54e3613c6c3c1cf0eb8ac3)
+++ src/sas/sascalc/file_converter/c_ext/bsl_loader.c	(revision dc8a5538706d3b922a54e3613c6c3c1cf0eb8ac3)
@@ -0,0 +1,291 @@
+#include <Python.h>
+#include <numpy/arrayobject.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include "structmember.h"
+#include "bsl_loader.h"
+
+typedef struct {
+    PyObject_HEAD
+    CLoader_params params;
+} CLoader;
+
+static PyObject *CLoader_new(PyTypeObject *type, PyObject *args, PyObject *kwds) {
+    CLoader *self;
+
+    self = (CLoader *)type->tp_alloc(type, 0);
+
+    return (PyObject *)self;
+}
+
+static PyObject *CLoader_init(CLoader *self, PyObject *args, PyObject *kwds) {
+    const char *filename;
+    const int n_frames;
+    const int n_pixels;
+    const int n_rasters;
+    const int swap_bytes;
+
+    if (self != NULL) {
+        if (!PyArg_ParseTuple(args, "siiii", &filename, &n_frames, &n_pixels, &n_rasters, &swap_bytes))
+            Py_RETURN_NONE;
+        if (!(self->params.filename = malloc(strlen(filename) + 1)))
+            Py_RETURN_NONE;
+        strcpy(self->params.filename, filename);
+        self->params.n_frames = n_frames;
+        self->params.n_pixels = n_pixels;
+        self->params.n_rasters = n_rasters;
+        self->params.swap_bytes = swap_bytes;
+    }
+
+    return 0;
+}
+
+static void CLoader_dealloc(CLoader *self) {
+    free(self->params.filename);
+    self->ob_type->tp_free((PyObject *)self);
+}
+
+static PyObject *to_string(CLoader *self, PyObject *params) {
+    char str[100];
+    sprintf(str,
+        "Filename: %s\nn_frames: %d\nframe: %d\nn_pixels: %d\nn_rasters: %d\nswap_bytes: %d",
+        self->params.filename,
+        self->params.n_frames,
+        self->params.frame,
+        self->params.n_pixels,
+        self->params.n_rasters,
+        self->params.swap_bytes);
+    return Py_BuildValue("s", str);
+}
+
+/*                    ----- Setters and Getters -----                        */
+
+static PyObject *get_filename(CLoader *self, PyObject *args) {
+    return Py_BuildValue("s", self->params.filename);
+}
+
+static PyObject *set_filename(CLoader *self, PyObject *args) {
+    const char *new_filename;
+    if (!PyArg_ParseTuple(args, "s", &new_filename))
+        return NULL;
+    strcpy(self->params.filename, new_filename);
+
+    return Py_BuildValue("s", self->params.filename);
+}
+
+static PyObject *get_n_frames(CLoader *self, PyObject *args) {
+    return Py_BuildValue("i", self->params.n_frames);
+}
+
+static PyObject *set_n_frames(CLoader *self, PyObject *args) {
+    int new_frames;
+    if (!PyArg_ParseTuple(args, "i", &new_frames))
+        return NULL;
+    self->params.n_frames = new_frames;
+
+    return Py_BuildValue("i", self->params.n_frames);
+}
+
+static PyObject *get_frame(CLoader *self, PyObject *args) {
+    return Py_BuildValue("i", self->params.frame);
+}
+
+static PyObject *set_frame(CLoader *self, PyObject *args) {
+    int new_frame;
+    if (!PyArg_ParseTuple(args, "i", &new_frame))
+        return NULL;
+    self->params.frame = new_frame;
+
+    return Py_BuildValue("i", self->params.frame);
+}
+
+static PyObject *get_n_pixels(CLoader *self, PyObject *args) {
+    return Py_BuildValue("i", self->params.n_pixels);
+}
+
+static PyObject *set_n_pixels(CLoader *self, PyObject *args) {
+    int new_pixels;
+    if (!PyArg_ParseTuple(args, "i", &new_pixels))
+        return NULL;
+    self->params.n_pixels = new_pixels;
+
+    return Py_BuildValue("i", self->params.n_pixels);
+}
+
+static PyObject *get_n_rasters(CLoader *self, PyObject *args) {
+    return Py_BuildValue("i", self->params.n_rasters);
+}
+
+static PyObject *set_n_rasters(CLoader *self, PyObject *args) {
+    int new_rasters;
+    if (!PyArg_ParseTuple(args, "i", &new_rasters))
+        return NULL;
+    self->params.n_rasters = new_rasters;
+
+    return Py_BuildValue("i", self->params.n_rasters);
+}
+
+static PyObject *get_swap_bytes(CLoader *self, PyObject *args) {
+    return Py_BuildValue("i", self->params.swap_bytes);
+}
+
+static PyObject *set_swap_bytes(CLoader *self, PyObject *args) {
+    int new_swap;
+    if (!PyArg_ParseTuple(args, "i", &new_swap))
+        return NULL;
+    self->params.swap_bytes = new_swap;
+
+    return Py_BuildValue("i", self->params.swap_bytes);
+}
+
+/*                        ----- Instance Methods -----                       */
+
+float reverse_float(const float in_float) {
+    // Reverse the order of the bytes of a float
+    float retval;
+    char *to_convert = (char *)&in_float;
+    char *return_float = (char *)&retval;
+
+    return_float[0] = to_convert[3];
+    return_float[1] = to_convert[2];
+    return_float[2] = to_convert[1];
+    return_float[3] = to_convert[0];
+
+    return retval;
+}
+
+static PyObject *load_data(CLoader *self, PyObject *args) {
+    int raster;
+    int pixel;
+    int frame_pos;
+    npy_intp size[2] = {self->params.n_rasters, self->params.n_pixels};
+    float cur_val;
+    FILE *input_file;
+    PyArrayObject *data;
+
+    // Create a new numpy array to store the data in
+    data = (PyArrayObject *)PyArray_SimpleNew(2, size, NPY_FLOAT);
+
+    // Attempt to open the file specified
+    input_file = fopen(self->params.filename, "rb");
+    if (!input_file) {
+        // BSL filenames are 10 characters long
+        // Filename validity checked in bsl_loader.py
+        size_t filename_start = strlen(self->params.filename) - 10;
+        char *filename = self->params.filename + filename_start;
+        char *err_msg = (char *)malloc(sizeof(char) * 32);
+
+        sprintf(err_msg, "Unable to open file: %s", filename);
+
+        PyErr_SetString(PyExc_RuntimeError, err_msg);
+        free(err_msg);
+        return NULL;
+    }
+
+    // Move the file cursor the the position where the data we're interested
+    // in begins
+    frame_pos = self->params.n_pixels * self->params.n_rasters * self->params.frame;
+    fseek(input_file, frame_pos*sizeof(float), SEEK_SET);
+
+    for (raster = 0; raster < self->params.n_rasters; raster++) {
+        for (pixel = 0; pixel < self->params.n_pixels; pixel++) {
+            // Try reading the file
+            if (fread(&cur_val, sizeof(float), 1, input_file) == 0) {
+                PyErr_SetString(PyExc_RuntimeError, "Error reading file or EOF reached.");
+                return NULL;
+            }
+
+            // Swap the order of the bytes read, if specified that we should do
+            // so in the header file
+            if (self->params.swap_bytes == 0)
+                cur_val = reverse_float(cur_val);
+
+            // Add the read value to the numpy array
+            PyArray_SETITEM(data, PyArray_GETPTR2(data, raster, pixel), PyFloat_FromDouble(cur_val));
+        }
+    }
+
+    fclose(input_file);
+
+    return Py_BuildValue("N", data);
+}
+
+/*                           ----- Class Registration -----                  */
+
+static PyMethodDef CLoader_methods[] = {
+    { "to_string", (PyCFunction)to_string, METH_VARARGS, "Print the objects params" },
+    { "get_filename", (PyCFunction)get_filename, METH_VARARGS, "Get the filename" },
+    { "set_filename", (PyCFunction)set_filename, METH_VARARGS, "Set the filename" },
+    { "get_n_frames", (PyCFunction)get_n_frames, METH_VARARGS, "Get n_frames" },
+    { "set_n_frames", (PyCFunction)set_n_frames, METH_VARARGS, "Set n_frames" },
+    { "get_frame", (PyCFunction)get_frame, METH_VARARGS, "Get the frame that will be loaded" },
+    { "set_frame", (PyCFunction)set_frame, METH_VARARGS, "Set the frame that will be loaded" },
+    { "get_n_pixels", (PyCFunction)get_n_pixels, METH_VARARGS, "Get n_pixels" },
+    { "set_n_pixels", (PyCFunction)set_n_pixels, METH_VARARGS, "Set n_pixels" },
+    { "get_n_rasters", (PyCFunction)get_n_rasters, METH_VARARGS, "Get n_rasters" },
+    { "set_n_rasters", (PyCFunction)set_n_rasters, METH_VARARGS, "Set n_rasters" },
+    { "get_swap_bytes", (PyCFunction)get_swap_bytes, METH_VARARGS, "Get swap_bytes" },
+    { "set_swap_bytes", (PyCFunction)set_swap_bytes, METH_VARARGS, "Set swap_bytes" },
+    { "load_data", (PyCFunction)load_data, METH_VARARGS, "Load the data into a numpy array" },
+    {NULL}
+};
+
+static PyMemberDef CLoader_members[] = {
+    {NULL}
+};
+
+static PyTypeObject CLoaderType = {
+    PyObject_HEAD_INIT(NULL)
+    0,                         /*ob_size*/
+    "CLoader",             /*tp_name*/
+    sizeof(CLoader),             /*tp_basicsize*/
+    0,                         /*tp_itemsize*/
+    (destructor)CLoader_dealloc, /*tp_dealloc*/
+    0,                         /*tp_print*/
+    0,                         /*tp_getattr*/
+    0,                         /*tp_setattr*/
+    0,                         /*tp_compare*/
+    0,                         /*tp_repr*/
+    0,                         /*tp_as_number*/
+    0,                         /*tp_as_sequence*/
+    0,                         /*tp_as_mapping*/
+    0,                         /*tp_hash */
+    0,                         /*tp_call*/
+    0,                         /*tp_str*/
+    0,                         /*tp_getattro*/
+    0,                         /*tp_setattro*/
+    0,                         /*tp_as_buffer*/
+    Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /*tp_flags*/
+    "CLoader objects",           /* tp_doc */
+    0,		               /* tp_traverse */
+    0,		               /* tp_clear */
+    0,		               /* tp_richcompare */
+    0,		               /* tp_weaklistoffset */
+    0,		               /* tp_iter */
+    0,		               /* tp_iternext */
+    CLoader_methods,             /* tp_methods */
+    CLoader_members,             /* tp_members */
+    0,                         /* tp_getset */
+    0,                         /* tp_base */
+    0,                         /* tp_dict */
+    0,                         /* tp_descr_get */
+    0,                         /* tp_descr_set */
+    0,                         /* tp_dictoffset */
+    (initproc)CLoader_init,      /* tp_init */
+    0,                         /* tp_alloc */
+    CLoader_new,                 /* tp_new */
+};
+
+PyMODINIT_FUNC
+initbsl_loader(void)
+{
+    PyObject *module;
+    module = Py_InitModule("bsl_loader", NULL);
+    import_array();
+
+    if (PyType_Ready(&CLoaderType) < 0)
+        return;
+
+    Py_INCREF(&CLoaderType);
+    PyModule_AddObject(module, "CLoader", (PyObject *)&CLoaderType);
+}
Index: src/sas/sascalc/file_converter/c_ext/bsl_loader.h
===================================================================
--- src/sas/sascalc/file_converter/c_ext/bsl_loader.h	(revision 535e18165fb7319948e27d8876a389fd5c738129)
+++ src/sas/sascalc/file_converter/c_ext/bsl_loader.h	(revision 535e18165fb7319948e27d8876a389fd5c738129)
@@ -0,0 +1,19 @@
+#ifndef bsl_loader_h
+#define bsl_loader_h
+
+typedef struct {
+    // File to load
+    char *filename;
+    // Number of frames in the file
+    int n_frames;
+    // Frame to load
+    int frame;
+    // Number of pixels in the file
+    int n_pixels;
+    // Number of rasters in the file
+    int n_rasters;
+    // Whether or not the bytes are in reverse order
+    int swap_bytes;
+} CLoader_params;
+
+#endif
Index: src/sas/sascalc/file_converter/cansas_writer.py
===================================================================
--- src/sas/sascalc/file_converter/cansas_writer.py	(revision 0b1a677a6bec9b3b30979ce36e64645b3028908b)
+++ src/sas/sascalc/file_converter/cansas_writer.py	(revision 0b1a677a6bec9b3b30979ce36e64645b3028908b)
@@ -0,0 +1,109 @@
+from sas.sascalc.dataloader.readers.cansas_reader import Reader as CansasReader
+from sas.sascalc.dataloader.data_info import Data1D
+
+import inspect
+
+class CansasWriter(CansasReader):
+
+    def write(self, filename, frame_data, sasentry_attrs=None):
+        """
+        Write the content of a Data1D as a CanSAS XML file
+
+        :param filename: name of the file to write
+        :param datainfo: Data1D object
+        """
+        # Create XML document
+        doc, _ = self._to_xml_doc(frame_data, sasentry_attrs)
+        # Write the file
+        file_ref = open(filename, 'w')
+        if self.encoding == None:
+            self.encoding = "UTF-8"
+        doc.write(file_ref, encoding=self.encoding,
+                  pretty_print=True, xml_declaration=True)
+        file_ref.close()
+
+
+    def _to_xml_doc(self, frame_data, sasentry_attrs=None):
+        """
+        Create an XML document to contain the content of an array of Data1Ds
+
+        :param frame_data: An array of Data1D objects
+        """
+        valid_class = all([issubclass(data.__class__, Data1D) for data in frame_data])
+        if not valid_class:
+            raise RuntimeError, ("The cansas writer expects an array of "
+                "Data1D instances")
+
+        # Get PIs and create root element
+        pi_string = self._get_pi_string()
+        # Define namespaces and create SASroot object
+        main_node = self._create_main_node()
+        # Create ElementTree, append SASroot and apply processing instructions
+        base_string = pi_string + self.to_string(main_node)
+        base_element = self.create_element_from_string(base_string)
+        doc = self.create_tree(base_element)
+        # Create SASentry Element
+        entry_node = self.create_element("SASentry", sasentry_attrs)
+        root = doc.getroot()
+        root.append(entry_node)
+
+        # Use the first element in the array for writing metadata
+        datainfo = frame_data[0]
+        # Add Title to SASentry
+        self.write_node(entry_node, "Title", datainfo.title)
+        # Add Run to SASentry
+        self._write_run_names(datainfo, entry_node)
+        # Add Data info to SASEntry
+        for data_info in frame_data:
+            self._write_data(data_info, entry_node)
+        # Transmission Spectrum Info
+        self._write_trans_spectrum(datainfo, entry_node)
+        # Sample info
+        self._write_sample_info(datainfo, entry_node)
+        # Instrument info
+        instr = self._write_instrument(datainfo, entry_node)
+        #   Source
+        self._write_source(datainfo, instr)
+        #   Collimation
+        self._write_collimation(datainfo, instr)
+        #   Detectors
+        self._write_detectors(datainfo, instr)
+        # Processes info
+        self._write_process_notes(datainfo, entry_node)
+        # Note info
+        self._write_notes(datainfo, entry_node)
+        # Return the document, and the SASentry node associated with
+        #      the data we just wrote
+
+        return doc, entry_node
+
+    def _write_data(self, datainfo, entry_node):
+        """
+        Writes the I and Q data to the XML file
+
+        :param datainfo: The Data1D object the information is coming from
+        :param entry_node: lxml node ElementTree object to be appended to
+        """
+        node = self.create_element("SASdata")
+        self.append(node, entry_node)
+
+        for i in range(len(datainfo.x)):
+            point = self.create_element("Idata")
+            node.append(point)
+            self.write_node(point, "Q", datainfo.x[i],
+                            {'unit': datainfo.x_unit})
+            if len(datainfo.y) >= i:
+                self.write_node(point, "I", datainfo.y[i],
+                                {'unit': datainfo.y_unit})
+            if datainfo.dy != None and len(datainfo.dy) > i:
+                self.write_node(point, "Idev", datainfo.dy[i],
+                                {'unit': datainfo.y_unit})
+            if datainfo.dx != None and len(datainfo.dx) > i:
+                self.write_node(point, "Qdev", datainfo.dx[i],
+                                {'unit': datainfo.x_unit})
+            if datainfo.dxw != None and len(datainfo.dxw) > i:
+                self.write_node(point, "dQw", datainfo.dxw[i],
+                                {'unit': datainfo.x_unit})
+            if datainfo.dxl != None and len(datainfo.dxl) > i:
+                self.write_node(point, "dQl", datainfo.dxl[i],
+                                {'unit': datainfo.x_unit})
Index: src/sas/sascalc/file_converter/convert_bsl_thread.py
===================================================================
--- src/sas/sascalc/file_converter/convert_bsl_thread.py	(revision 0b1a677a6bec9b3b30979ce36e64645b3028908b)
+++ src/sas/sascalc/file_converter/convert_bsl_thread.py	(revision 0b1a677a6bec9b3b30979ce36e64645b3028908b)
@@ -0,0 +1,94 @@
+import os
+import wx
+from sas.sascalc.data_util.calcthread import CalcThread
+from sas.sascalc.dataloader.data_info import Data2D
+from sas.sascalc.file_converter.red2d_writer import Red2DWriter
+
+import sys
+
+if sys.platform.count("darwin") > 0:
+    import time
+    stime = time.time()
+
+    def clock():
+        return time.time() - stime
+
+    def sleep(t):
+        return time.sleep(t)
+else:
+    from time import clock
+    from time import sleep
+
+class ConvertBSLThread(CalcThread):
+
+    def __init__(self, xy, frame_data, out_file, frame_number=None,
+        updatefn=None, completefn=None):
+        CalcThread.__init__(self, updatefn=updatefn, completefn=completefn)
+        (self.x_data, self.y_data) = xy
+        self.frame_data = frame_data
+        self.frame_number = frame_number
+        self.frame_filename = ''
+        self.out_file = out_file
+
+    def compute(self):
+        try:
+            completed = self._convert_to_red2d()
+        except Exception as e:
+            self.ready()
+            self.update(exception=e)
+            self.complete(success=False)
+            return
+
+        self.complete(success=completed)
+
+    def isquit(self):
+        """Check for interrupts.  Should be called frequently to
+        provide user responsiveness.  Also yields to other running
+        threads, which is required for good performance on OS X."""
+
+        # # Only called from within the running thread so no need to lock
+        # if self._running and self.yieldtime > 0 \
+        #     and clock() > self._time_for_nap:
+        #     sleep(self.yieldtime)
+        #     self._time_for_nap = clock() + self.worktime
+        return self._interrupting
+
+    def _convert_to_red2d(self):
+        """
+        Writes Data2D objects to Red2D .dat files. If more than one frame is
+        provided, the frame number will be appended to the filename of each
+        file written.
+
+        :param filepath: The filepath to write to
+        :param x: The x column of the data
+        :param y: The y column of the data
+        :param frame_data: A dictionary of the form frame_number: data, where
+        data is a 2D numpy array containing the intensity data
+
+        :return: True if export completed, False if export cancelled by user
+        """
+        filename = os.path.split(self.out_file)[-1]
+        filepath = os.path.split(self.out_file)[0]
+        writer = Red2DWriter()
+
+        if self.isquit():
+            return False
+
+        if self.frame_number is not None:
+            frame_filename = filename.split('.')
+            frame_filename[0] += str(self.frame_number)
+            frame_filename = '.'.join(frame_filename)
+        else:
+            frame_filename = filename
+
+        self.ready()
+        self.update(msg="Writing file: {}".format(frame_filename))
+        data_i = self.frame_data.reshape((len(self.x_data),1))
+        data_info = Data2D(data=data_i, qx_data=self.x_data, qy_data=self.y_data)
+        success = writer.write(os.path.join(filepath, frame_filename), data_info, self)
+
+        # Used by ConverterPanel.conversion_complete to notify user that file
+        # has been written (or that there was an error)
+        self.frame_filename = frame_filename
+
+        return success
Index: src/sas/sascalc/file_converter/otoko_loader.py
===================================================================
--- src/sas/sascalc/file_converter/otoko_loader.py	(revision 0b1a677a6bec9b3b30979ce36e64645b3028908b)
+++ src/sas/sascalc/file_converter/otoko_loader.py	(revision 0b1a677a6bec9b3b30979ce36e64645b3028908b)
@@ -0,0 +1,150 @@
+"""
+Here we handle loading of "OTOKO" data (for more info about this format see
+the comment in load_otoko_data).  Given the paths of header and data files, we
+aim to load the data into numpy arrays for use later.
+"""
+
+import itertools
+import os
+import struct
+import numpy as np
+
+class CStyleStruct:
+    """A nice and easy way to get "C-style struct" functionality."""
+    def __init__(self, **kwds):
+        self.__dict__.update(kwds)
+
+class OTOKOParsingError(Exception):
+    pass
+
+class OTOKOData:
+    def __init__(self, q_axis, data_axis):
+        self.q_axis = q_axis
+        self.data_axis = data_axis
+
+class OTOKOLoader(object):
+
+    def __init__(self, qaxis_path, data_path):
+        self.qaxis_path = qaxis_path
+        self.data_path = data_path
+
+    def load_otoko_data(self):
+        """
+        Loads "OTOKO" data, which is a format that stores each axis separately.
+        An axis is represented by a "header" file, which in turn will give details
+        of one or more binary files where the actual data is stored.
+
+        Given the paths of two header files, this function will load each axis in
+        turn.  If loading is successfull then an instance of the OTOKOData class
+        will be returned, else an exception will be raised.
+
+        For more information on the OTOKO file format, please see:
+        http://www.diamond.ac.uk/Home/Beamlines/small-angle/SAXS-Software/CCP13/
+        XOTOKO.html
+        """
+        q_axis    = self._load_otoko_axis(self.qaxis_path)
+        data_axis = self._load_otoko_axis(self.data_path)
+
+        return OTOKOData(q_axis, data_axis)
+
+    def _load_otoko_axis(self, header_path):
+        """
+        Loads an "OTOKO" axis, given the header file path.  Essentially, the
+        header file contains information about the data in the form of integer
+        "indicators", as well as the names of each of the binary files which are
+        assumed to be in the same directory as the header.
+        """
+        if not os.path.exists(header_path):
+            raise OTOKOParsingError("The header file %s does not exist." % header_path)
+
+        binary_file_info_list = []
+        total_frames = 0
+        header_dir = os.path.dirname(os.path.abspath(header_path))
+
+        with open(header_path, "r") as header_file:
+            lines = header_file.readlines()
+            if len(lines) < 4:
+                raise OTOKOParsingError("Expected more lines in %s." % header_path)
+
+            info = lines[0] + lines[1]
+
+            def pairwise(iterable):
+                """
+                s -> (s0,s1), (s2,s3), (s4, s5), ...
+                From http://stackoverflow.com/a/5389547/778572
+                """
+                a = iter(iterable)
+                return itertools.izip(a, a)
+
+            for indicators, filename in pairwise(lines[2:]):
+                indicators = indicators.split()
+
+                if len(indicators) != 10:
+                    raise OTOKOParsingError(
+                        "Expected 10 integer indicators on line 3 of %s." \
+                        % header_path)
+                if not all([i.isdigit() for i in indicators]):
+                    raise OTOKOParsingError(
+                        "Expected all indicators on line 3 of %s to be integers." \
+                        % header_path)
+
+                binary_file_info = CStyleStruct(
+                    # The indicators at indices 4 to 8 are always zero since they
+                    # have been reserved for future use by the format.  Also, the
+                    # "last_file" indicator seems to be there for legacy reasons,
+                    # as it doesn't appear to be something we have to bother
+                    # enforcing correct use of; we just define the last file as
+                    # being the last file in the list.
+                    file_path  = os.path.join(header_dir, filename.strip()),
+                    n_channels = int(indicators[0]),
+                    n_frames   = int(indicators[1]),
+                    dimensions = int(indicators[2]),
+                    swap_bytes = int(indicators[3]) == 0,
+                    last_file  = int(indicators[9]) == 0 # We don't use this.
+                )
+                if binary_file_info.dimensions != 1:
+                    msg = "File {} has {} dimensions, expected 1. Is it a BSL file?"
+                    raise OTOKOParsingError(msg.format(filename.strip(),
+                        binary_file_info.dimensions))
+
+                binary_file_info_list.append(binary_file_info)
+
+                total_frames += binary_file_info.n_frames
+
+        # Check that all binary files are listed in the header as having the same
+        # number of channels, since I don't think CorFunc can handle ragged data.
+        all_n_channels = [info.n_channels for info in binary_file_info_list]
+        if not all(all_n_channels[0] == c for c in all_n_channels):
+            raise OTOKOParsingError(
+                "Expected all binary files listed in %s to have the same number of channels." % header_path)
+
+        data = np.zeros(shape=(total_frames, all_n_channels[0]))
+        frames_so_far = 0
+
+        for info in binary_file_info_list:
+            if not os.path.exists(info.file_path):
+                raise OTOKOParsingError(
+                    "The data file %s does not exist." % info.file_path)
+
+            with open(info.file_path, "rb") as binary_file:
+                # Ideally we'd like to use numpy's fromfile() to read in binary
+                # data, but we are forced to roll our own float-by-float file
+                # reader because of the rules imposed on us by the file format;
+                # namely, if the swap indicator flag has been raised then the bytes
+                # of each float occur in reverse order.
+                for frame in range(info.n_frames):
+                    for channel in range(info.n_channels):
+                        b = bytes(binary_file.read(4))
+                        if info.swap_bytes:
+                            b = b[::-1] # "Extended slice" syntax, used to reverse.
+                        value = struct.unpack('f', b)[0]
+                        data[frames_so_far + frame][channel] = value
+
+                frames_so_far += info.n_frames
+
+        return CStyleStruct(
+            header_path = header_path,
+            data = data,
+            binary_file_info_list = binary_file_info_list,
+            header_info = info
+        )
Index: src/sas/sascalc/file_converter/red2d_writer.py
===================================================================
--- src/sas/sascalc/file_converter/red2d_writer.py	(revision 0b1a677a6bec9b3b30979ce36e64645b3028908b)
+++ src/sas/sascalc/file_converter/red2d_writer.py	(revision 0b1a677a6bec9b3b30979ce36e64645b3028908b)
@@ -0,0 +1,36 @@
+import os
+import time
+from sas.sascalc.dataloader.readers.red2d_reader import Reader as Red2DReader
+
+class Red2DWriter(Red2DReader):
+
+    def write(self, filename, data, thread):
+        """
+        Write to .dat
+
+        :param filename: file name to write
+        :param data: data2D
+        """
+        # Write the file
+        fd = open(filename, 'w')
+        t = time.localtime()
+        time_str = time.strftime("%H:%M on %b %d %y", t)
+
+        header_str = "Data columns are Qx - Qy - I(Qx,Qy)\n\nASCII data"
+        header_str += " created at %s \n\n" % time_str
+        # simple 2D header
+        fd.write(header_str)
+        # write qx qy I values
+        for i in range(len(data.data)):
+            if thread.isquit():
+                fd.close()
+                os.remove(filename)
+                return False
+
+            fd.write("%g  %g  %g\n" % (data.qx_data[i],
+                                        data.qy_data[i],
+                                       data.data[i]))
+
+        fd.close()
+
+        return True
Index: src/sas/sasgui/perspectives/file_converter/__init__.py
===================================================================
--- src/sas/sasgui/perspectives/file_converter/__init__.py	(revision 77d92cd296d0af12557c509f0c48df88ef5f59e9)
+++ src/sas/sasgui/perspectives/file_converter/__init__.py	(revision 77d92cd296d0af12557c509f0c48df88ef5f59e9)
@@ -0,0 +1,2 @@
+PLUGIN_ID = "File-Converter Plug-In 1.0"
+from file_converter import *
Index: src/sas/sasgui/perspectives/file_converter/converter_panel.py
===================================================================
--- src/sas/sasgui/perspectives/file_converter/converter_panel.py	(revision 18544f8dd938eb92992e39bc07b363d28ed5bf08)
+++ src/sas/sasgui/perspectives/file_converter/converter_panel.py	(revision 18544f8dd938eb92992e39bc07b363d28ed5bf08)
@@ -0,0 +1,699 @@
+"""
+This module provides a GUI for the file converter
+"""
+
+import wx
+import sys
+import os
+import numpy as np
+from wx.lib.scrolledpanel import ScrolledPanel
+from sas.sasgui.guiframe.panel_base import PanelBase
+from sas.sasgui.perspectives.calculator import calculator_widgets as widget
+from sas.sasgui.perspectives.file_converter.converter_widgets import FileInput
+from sas.sasgui.perspectives.file_converter.meta_panels import MetadataWindow
+from sas.sasgui.perspectives.file_converter.meta_panels import DetectorPanel
+from sas.sasgui.perspectives.file_converter.meta_panels import SamplePanel
+from sas.sasgui.perspectives.file_converter.meta_panels import SourcePanel
+from sas.sasgui.perspectives.file_converter.frame_select_dialog import FrameSelectDialog
+from sas.sasgui.guiframe.events import StatusEvent
+from sas.sasgui.guiframe.documentation_window import DocumentationWindow
+from sas.sasgui.guiframe.dataFitting import Data1D
+from sas.sasgui.guiframe.utils import check_float
+from sas.sascalc.file_converter.cansas_writer import CansasWriter
+from sas.sascalc.file_converter.otoko_loader import OTOKOLoader
+from sas.sascalc.file_converter.bsl_loader import BSLLoader
+from sas.sascalc.file_converter.convert_bsl_thread import ConvertBSLThread
+from sas.sascalc.dataloader.data_info import Detector
+from sas.sascalc.dataloader.data_info import Sample
+from sas.sascalc.dataloader.data_info import Source
+from sas.sascalc.dataloader.data_info import Vector
+
+# Panel size
+if sys.platform.count("win32") > 0:
+    PANEL_TOP = 0
+    _STATICBOX_WIDTH = 410
+    _BOX_WIDTH = 200
+    PANEL_SIZE = 480
+    FONT_VARIANT = 0
+else:
+    PANEL_TOP = 60
+    _STATICBOX_WIDTH = 430
+    _BOX_WIDTH = 200
+    PANEL_SIZE = 500
+    FONT_VARIANT = 1
+
+class ConverterPanel(ScrolledPanel, PanelBase):
+    """
+    This class provides the File Converter GUI
+    """
+
+    def __init__(self, parent, base=None, *args, **kwargs):
+        ScrolledPanel.__init__(self, parent, *args, **kwargs)
+        PanelBase.__init__(self)
+        self.SetupScrolling()
+        self.SetWindowVariant(variant=FONT_VARIANT)
+
+        self.base = base
+        self.parent = parent
+        self.meta_frames = []
+        self.to_convert = {}
+        self.bsl_thread = None
+
+        # GUI inputs
+        self.q_input = None
+        self.iq_input = None
+        self.output = None
+        self.radiation_input = None
+        self.convert_btn = None
+        self.metadata_section = None
+
+        self.data_type = "ascii"
+
+        # Metadata values
+        self.title = ''
+        self.run = ''
+        self.run_name = ''
+        self.instrument = ''
+        self.detector = Detector()
+        self.sample = Sample()
+        self.source = Source()
+        self.properties = ['title', 'run', 'run_name', 'instrument']
+
+        self.detector.name = ''
+        self.source.radiation = 'neutron'
+
+        self._do_layout()
+        self.SetAutoLayout(True)
+        self.Layout()
+
+    def convert_to_cansas(self, frame_data, filepath, single_file):
+        """
+        Saves an array of Data1D objects to a single CanSAS file with multiple
+        <SasData> elements, or to multiple CanSAS files, each with one
+        <SasData> element.
+
+        :param frame_data: If single_file is true, an array of Data1D objects.
+        If single_file is false, a dictionary of the form frame_number: Data1D.
+        :param filepath: Where to save the CanSAS file
+        :param single_file: If true, array is saved as a single file, if false,
+        each item in the array is saved to it's own file
+        """
+        writer = CansasWriter()
+        entry_attrs = None
+        if self.run_name != '':
+            entry_attrs = { 'name': self.run_name }
+
+        if single_file:
+            writer.write(filepath, frame_data,
+                sasentry_attrs=entry_attrs)
+        else:
+            # Folder and base filename
+            [group_path, group_name] = os.path.split(filepath)
+            ext = "." + group_name.split('.')[-1] # File extension
+            for frame_number, frame_data in frame_data.iteritems():
+                # Append frame number to base filename
+                filename = group_name.replace(ext, str(frame_number)+ext)
+                destination = os.path.join(group_path, filename)
+                writer.write(destination, [frame_data],
+                    sasentry_attrs=entry_attrs)
+
+    def extract_ascii_data(self, filename):
+        """
+        Extracts data from a single-column ASCII file
+
+        :param filename: The file to load data from
+        :return: A numpy array containing the extracted data
+        """
+        try:
+            data = np.loadtxt(filename, dtype=str)
+        except:
+            is_bsl = False
+            # Check if file is a BSL or OTOKO header file
+            f = open(filename, 'r')
+            f.readline()
+            f.readline()
+            bsl_metadata = f.readline().strip().split()
+            f.close()
+            if len(bsl_metadata) == 10:
+                msg = ("Error parsing ASII data. {} looks like a BSL or OTOKO "
+                    "header file.")
+                raise Exception(msg.format(os.path.split(filename)[-1]))
+
+        if len(data.shape) != 1:
+            msg = "Error reading {}: Only one column of data is allowed"
+            raise Exception(msg.format(filename.split('\\')[-1]))
+
+        is_float = True
+        try:
+            float(data[0])
+        except:
+            is_float = False
+
+        if not is_float:
+            end_char = data[0][-1]
+            # If lines end with comma or semi-colon, trim the last character
+            if end_char == ',' or end_char == ';':
+                data = map(lambda s: s[0:-1], data)
+            else:
+                msg = ("Error reading {}: Lines must end with a digit, comma "
+                    "or semi-colon").format(filename.split('\\')[-1])
+                raise Exception(msg)
+
+        return np.array(data, dtype=np.float32)
+
+    def extract_otoko_data(self, filename):
+        """
+        Extracts data from a 1D OTOKO file
+
+        :param filename: The OTOKO file to load the data from
+        :return: A numpy array containing the extracted data
+        """
+        loader = OTOKOLoader(self.q_input.GetPath(),
+            self.iq_input.GetPath())
+        otoko_data = loader.load_otoko_data()
+        qdata = otoko_data.q_axis.data
+        iqdata = otoko_data.data_axis.data
+        if len(qdata) > 1:
+            msg = ("Q-Axis file has multiple frames. Only 1 frame is "
+                "allowed for the Q-Axis")
+            wx.PostEvent(self.parent.manager.parent,
+                StatusEvent(status=msg, info="error"))
+            return
+        else:
+            qdata = qdata[0]
+
+        return qdata, iqdata
+
+    def extract_bsl_data(self, filename):
+        """
+        Extracts data from a 2D BSL file
+
+        :param filename: The header file to extract the data from
+        :return x_data: A 1D array containing all the x coordinates of the data
+        :return y_data: A 1D array containing all the y coordinates of the data
+        :return frame_data: A dictionary of the form frame_number: data, where
+        data is a 2D numpy array containing the intensity data
+        """
+        loader = BSLLoader(filename)
+        frames = [0]
+        should_continue = True
+
+        if loader.n_frames > 1:
+            params = self.ask_frame_range(loader.n_frames)
+            frames = params['frames']
+            if len(frames) == 0:
+                should_continue = False
+        elif loader.n_rasters == 1 and loader.n_frames == 1:
+            message = ("The selected file is an OTOKO file. Please select the "
+            "'OTOKO 1D' option if you wish to convert it.")
+            dlg = wx.MessageDialog(self,
+            message,
+            'Error!',
+            wx.OK | wx.ICON_WARNING)
+            dlg.ShowModal()
+            should_continue = False
+            dlg.Destroy()
+        else:
+            message = ("The selected data file only has 1 frame, it might be"
+                " a multi-frame OTOKO file.\nContinue conversion?")
+            dlg = wx.MessageDialog(self,
+            message,
+            'Warning!',
+            wx.YES_NO | wx.ICON_WARNING)
+            should_continue = (dlg.ShowModal() == wx.ID_YES)
+            dlg.Destroy()
+
+        if not should_continue:
+            return None, None, None
+
+        frame_data = {}
+
+        for frame in frames:
+            loader.frame = frame
+            frame_data[frame] = loader.load_data()
+
+        # TODO: Tidy this up
+        # Prepare axes values (arbitrary scale)
+        x_data = []
+        y_data = range(loader.n_pixels) * loader.n_rasters
+        for i in range(loader.n_rasters):
+            x_data += [i] * loader.n_pixels
+
+        return x_data, y_data, frame_data
+
+    def ask_frame_range(self, n_frames):
+        """
+        Display a dialog asking the user to input the range of frames they
+        would like to export
+
+        :param n_frames: How many frames the loaded data file has
+        :return: A dictionary containing the parameters input by the user
+        """
+        valid_input = False
+        is_bsl = (self.data_type == 'bsl')
+        dlg = FrameSelectDialog(n_frames, is_bsl)
+        frames = None
+        increment = None
+        single_file = True
+        while not valid_input:
+            if dlg.ShowModal() == wx.ID_OK:
+                msg = ""
+                try:
+                    first_frame = int(dlg.first_input.GetValue())
+                    last_frame = int(dlg.last_input.GetValue())
+                    increment = int(dlg.increment_input.GetValue())
+                    if not is_bsl:
+                        single_file = dlg.single_btn.GetValue()
+
+                    if last_frame < 0 or first_frame < 0:
+                        msg = "Frame values must be positive"
+                    elif increment < 1:
+                        msg = "Increment must be greater than or equal to 1"
+                    elif first_frame > last_frame:
+                        msg = "First frame must be less than last frame"
+                    elif last_frame >= n_frames:
+                        msg = "Last frame must be less than {}".format(n_frames)
+                    else:
+                        valid_input = True
+                except:
+                    valid_input = False
+                    msg = "Please enter valid integer values"
+
+                if not valid_input:
+                    wx.PostEvent(self.parent.manager.parent,
+                        StatusEvent(status=msg))
+            else:
+                return { 'frames': [], 'inc': None, 'file': single_file }
+        frames = range(first_frame, last_frame + 1, increment)
+        return { 'frames': frames, 'inc': increment, 'file': single_file }
+
+    def on_convert(self, event):
+        """Called when the Convert button is clicked"""
+        if not self.validate_inputs():
+            return
+
+        if self.bsl_thread is not None and self.bsl_thread.isrunning():
+            self.to_convert = {}
+            self.bsl_thread.stop()
+            return
+
+        self.sample.ID = self.title
+
+        try:
+            if self.data_type == 'ascii':
+                qdata = self.extract_ascii_data(self.q_input.GetPath())
+                iqdata = np.array([self.extract_ascii_data(self.iq_input.GetPath())])
+            elif self.data_type == 'otoko':
+                qdata, iqdata = self.extract_otoko_data(self.q_input.GetPath())
+            else: # self.data_type == 'bsl'
+
+                x, y, frame_data = self.extract_bsl_data(self.iq_input.GetPath())
+                if x == None and y == None and frame_data == None:
+                    # Cancelled by user
+                    return
+
+                self.to_convert = frame_data
+
+                frame_number, data = self.to_convert.popitem()
+                self.bsl_thread = ConvertBSLThread((x, y), data,
+                    self.output.GetPath(), frame_number=frame_number,
+                    updatefn=self.conversion_update,
+                    completefn=self.conversion_complete)
+                self.bsl_thread.queue()
+
+                self.convert_btn.SetLabel("Stop Conversion")
+                return
+
+        except Exception as ex:
+            msg = str(ex)
+            wx.PostEvent(self.parent.manager.parent,
+                StatusEvent(status=msg, info='error'))
+            return
+
+        frames = []
+        increment = 1
+        single_file = True
+        n_frames = iqdata.shape[0]
+        # Standard file has 3 frames: SAS, calibration and WAS
+        if n_frames > 3:
+            # File has multiple frames - ask the user which ones they want to
+            # export
+            params = self.ask_frame_range(n_frames)
+            frames = params['frames']
+            increment = params['inc']
+            single_file = params['file']
+            if frames == []: return
+        else: # Only interested in SAS data
+            frames = [0]
+
+        output_path = self.output.GetPath()
+
+        # Prepare the metadata for writing to a file
+        if self.run is None:
+            self.run = []
+        elif not isinstance(self.run, list):
+            self.run = [self.run]
+
+        if self.title is None:
+            self.title = ''
+
+        metadata = {
+            'title': self.title,
+            'run': self.run,
+            'instrument': self.instrument,
+            'detector': [self.detector],
+            'sample': self.sample,
+            'source': self.source
+        }
+
+        frame_data = {}
+        for i in frames:
+            data = Data1D(x=qdata, y=iqdata[i])
+            frame_data[i] = data
+        if single_file:
+            # Only need to set metadata on first Data1D object
+            frame_data = frame_data.values() # Don't need to know frame numbers
+            frame_data[0].filename = output_path.split('\\')[-1]
+            for key, value in metadata.iteritems():
+                setattr(frame_data[0], key, value)
+        else:
+            # Need to set metadata for all Data1D objects
+            for datainfo in frame_data.values():
+                datainfo.filename = output_path.split('\\')[-1]
+                for key, value in metadata.iteritems():
+                    setattr(datainfo, key, value)
+
+
+        self.convert_to_cansas(frame_data, output_path, single_file)
+        wx.PostEvent(self.parent.manager.parent,
+            StatusEvent(status="Conversion completed."))
+
+    def conversion_update(self, msg="", exception=None):
+        if exception is not None:
+            msg = str(exception)
+            wx.PostEvent(self.parent.manager.parent,
+                StatusEvent(status=msg, info='error'))
+        else:
+            wx.PostEvent(self.parent.manager.parent,
+                StatusEvent(status=msg))
+
+    def conversion_complete(self, success=True):
+        msg = "Conversion of {} ".format(self.bsl_thread.frame_filename)
+
+        if success:
+            msg += "completed"
+        else:
+            msg += "failed"
+        wx.PostEvent(self.parent.manager.parent,
+            StatusEvent(status=msg))
+
+        if len(self.to_convert) == 0:
+            self.convert_btn.SetLabel("Convert")
+            self.bsl_thread = None
+            wx.PostEvent(self.parent.manager.parent,
+                StatusEvent(status="Conversion finished"))
+        else:
+            n, data = self.to_convert.popitem()
+            self.bsl_thread.frame_data = data
+            self.bsl_thread.frame_number = n
+            self.bsl_thread.queue()
+
+
+    def on_help(self, event):
+        """
+        Show the File Converter documentation
+        """
+        tree_location = ("user/sasgui/perspectives/file_converter/"
+            "file_converter_help.html")
+        doc_viewer = DocumentationWindow(self, -1, tree_location,
+            "", "File Converter Help")
+
+    def validate_inputs(self):
+        msg = "You must select a"
+        if self.q_input.GetPath() == '' and self.data_type != 'bsl':
+            msg += " Q Axis input file."
+        elif self.iq_input.GetPath() == '':
+            msg += "n Intensity input file."
+        elif self.output.GetPath() == '':
+            msg += " destination for the converted file."
+        if msg != "You must select a":
+            wx.PostEvent(self.parent.manager.parent,
+                StatusEvent(status=msg, info='error'))
+            return
+
+        return True
+
+    def show_detector_window(self, event):
+        """
+        Show the window for inputting Detector metadata
+        """
+        if self.meta_frames != []:
+            for frame in self.meta_frames:
+                frame.panel.on_close()
+        detector_frame = MetadataWindow(DetectorPanel,
+            parent=self.parent.manager.parent, manager=self,
+            metadata=self.detector, title='Detector Metadata')
+        self.meta_frames.append(detector_frame)
+        self.parent.manager.put_icon(detector_frame)
+        detector_frame.Show(True)
+
+    def show_sample_window(self, event):
+        """
+        Show the window for inputting Sample metadata
+        """
+        if self.meta_frames != []:
+            for frame in self.meta_frames:
+                frame.panel.on_close()
+        sample_frame = MetadataWindow(SamplePanel,
+            parent=self.parent.manager.parent, manager=self,
+            metadata=self.sample, title='Sample Metadata')
+        self.meta_frames.append(sample_frame)
+        self.parent.manager.put_icon(sample_frame)
+        sample_frame.Show(True)
+
+    def show_source_window(self, event):
+        """
+        Show the window for inputting Source metadata
+        """
+        if self.meta_frames != []:
+            for frame in self.meta_frames:
+                frame.panel.on_close()
+        source_frame = MetadataWindow(SourcePanel,
+            parent=self.parent.manager.parent, manager=self,
+            metadata=self.source, title="Source Metadata")
+        self.meta_frames.append(source_frame)
+        self.parent.manager.put_icon(source_frame)
+        source_frame.Show(True)
+
+    def on_collapsible_pane(self, event):
+        """
+        Resize the scrollable area to fit the metadata pane when it's
+        collapsed or expanded
+        """
+        self.Freeze()
+        self.SetupScrolling()
+        self.parent.Layout()
+        self.Thaw()
+
+    def datatype_changed(self, event):
+        """
+        Update the UI and self.data_type when a data type radio button is
+        pressed
+        """
+        event.Skip()
+        dtype = event.GetEventObject().GetName()
+        self.data_type = dtype
+        if dtype == 'bsl':
+            self.q_input.SetPath("")
+            self.q_input.Disable()
+            self.radiation_input.Disable()
+            self.metadata_section.Collapse()
+            self.on_collapsible_pane(None)
+            self.metadata_section.Disable()
+            self.output.SetWildcard("IGOR/DAT 2D file in Q_map (*.dat)|*.DAT")
+        else:
+            self.q_input.Enable()
+            self.radiation_input.Enable()
+            self.metadata_section.Enable()
+            self.output.SetWildcard("CanSAS 1D (*.xml)|*.xml")
+
+    def radiationtype_changed(self, event):
+        event.Skip()
+        rtype = event.GetEventObject().GetValue().lower()
+        self.source.radiation = rtype
+
+    def metadata_changed(self, event):
+        event.Skip()
+        textbox = event.GetEventObject()
+        attr = textbox.GetName()
+        value = textbox.GetValue().strip()
+
+        setattr(self, attr, value)
+
+
+    def _do_layout(self):
+        vbox = wx.BoxSizer(wx.VERTICAL)
+
+        instructions = ("Select either single column ASCII files or 1D OTOKO "
+        "files containing the Q-Axis and Intensity-Axis data, or a 2D BSL file"
+        ", then chose where to save the converted file, and click Convert.\n"
+        "ASCII and OTOKO files will be converted to CanSAS XML, and OTKO files"
+        " to IGOR/DAT 2D Q_map files.\nCanSAS metadata can also be optionally "
+        "input below.")
+
+        instruction_label = wx.StaticText(self, -1, instructions,
+            size=(_STATICBOX_WIDTH+40, -1))
+        instruction_label.Wrap(_STATICBOX_WIDTH+40)
+        vbox.Add(instruction_label, flag=wx.TOP | wx.LEFT | wx.RIGHT, border=5)
+
+        section = wx.StaticBox(self, -1)
+        section_sizer = wx.StaticBoxSizer(section, wx.VERTICAL)
+        section_sizer.SetMinSize((_STATICBOX_WIDTH, -1))
+
+        input_grid = wx.GridBagSizer(5, 5)
+
+        y = 0
+
+        data_type_label = wx.StaticText(self, -1, "Input Format: ")
+        input_grid.Add(data_type_label, (y,0), (1,1),
+            wx.ALIGN_CENTER_VERTICAL, 5)
+        radio_sizer = wx.BoxSizer(wx.HORIZONTAL)
+        ascii_btn = wx.RadioButton(self, -1, "ASCII", name="ascii",
+            style=wx.RB_GROUP)
+        ascii_btn.Bind(wx.EVT_RADIOBUTTON, self.datatype_changed)
+        radio_sizer.Add(ascii_btn)
+        otoko_btn = wx.RadioButton(self, -1, "OTOKO 1D", name="otoko")
+        otoko_btn.Bind(wx.EVT_RADIOBUTTON, self.datatype_changed)
+        radio_sizer.Add(otoko_btn)
+        input_grid.Add(radio_sizer, (y,1), (1,1), wx.ALL, 5)
+        bsl_btn = wx.RadioButton(self, -1, "BSL 2D", name="bsl")
+        bsl_btn.Bind(wx.EVT_RADIOBUTTON, self.datatype_changed)
+        radio_sizer.Add(bsl_btn)
+        y += 1
+
+        q_label = wx.StaticText(self, -1, "Q-Axis Data: ")
+        input_grid.Add(q_label, (y,0), (1,1), wx.ALIGN_CENTER_VERTICAL, 5)
+
+        self.q_input = wx.FilePickerCtrl(self, -1,
+            size=(_STATICBOX_WIDTH-80, -1),
+            message="Chose the Q-Axis data file.",
+            style=wx.FLP_USE_TEXTCTRL | wx.FLP_CHANGE_DIR)
+        input_grid.Add(self.q_input, (y,1), (1,1), wx.ALL, 5)
+        y += 1
+
+        iq_label = wx.StaticText(self, -1, "Intensity-Axis Data: ")
+        input_grid.Add(iq_label, (y,0), (1,1), wx.ALIGN_CENTER_VERTICAL, 5)
+
+        self.iq_input = wx.FilePickerCtrl(self, -1,
+            size=(_STATICBOX_WIDTH-80, -1),
+            message="Chose the Intensity-Axis data file.",
+            style=wx.FLP_USE_TEXTCTRL | wx.FLP_CHANGE_DIR)
+        input_grid.Add(self.iq_input, (y,1), (1,1), wx.ALL, 5)
+        y += 1
+
+        radiation_label = wx.StaticText(self, -1, "Radiation Type: ")
+        input_grid.Add(radiation_label, (y,0), (1,1), wx.ALIGN_CENTER_VERTICAL, 5)
+        self.radiation_input = wx.ComboBox(self, -1,
+            choices=["Neutron", "X-Ray", "Muon", "Electron"],
+            name="radiation", style=wx.CB_READONLY, value="Neutron")
+        self.radiation_input.Bind(wx.EVT_COMBOBOX, self.radiationtype_changed)
+        input_grid.Add(self.radiation_input, (y,1), (1,1), wx.ALL, 5)
+        y += 1
+
+        output_label = wx.StaticText(self, -1, "Output File: ")
+        input_grid.Add(output_label, (y,0), (1,1), wx.ALIGN_CENTER_VERTICAL, 5)
+
+        self.output = FileInput(self,
+            wildcard="CanSAS 1D (*.xml)|*.xml")
+        input_grid.Add(self.output.GetCtrl(), (y,1), (1,1), wx.EXPAND | wx.ALL, 5)
+        y += 1
+
+        self.convert_btn = wx.Button(self, wx.ID_OK, "Stop Converstion")
+        self.convert_btn.SetLabel("Convert")
+        input_grid.Add(self.convert_btn, (y,0), (1,1), wx.ALL, 5)
+        self.convert_btn.Bind(wx.EVT_BUTTON, self.on_convert)
+
+        help_btn = wx.Button(self, -1, "HELP")
+        input_grid.Add(help_btn, (y,1), (1,1), wx.ALL, 5)
+        help_btn.Bind(wx.EVT_BUTTON, self.on_help)
+
+        section_sizer.Add(input_grid)
+
+        vbox.Add(section_sizer, flag=wx.ALL, border=5)
+
+        self.metadata_section = wx.CollapsiblePane(self, -1, "Metadata",
+            size=(_STATICBOX_WIDTH+40, -1), style=wx.WS_EX_VALIDATE_RECURSIVELY)
+        metadata_pane = self.metadata_section.GetPane()
+        metadata_grid = wx.GridBagSizer(5, 5)
+
+        self.metadata_section.Bind(wx.EVT_COLLAPSIBLEPANE_CHANGED,
+            self.on_collapsible_pane)
+
+        y = 0
+        for item in self.properties:
+            # Capitalise each word
+            label_txt = " ".join(
+                [s.capitalize() for s in item.replace('_', ' ').split(' ')])
+            if item == 'run':
+                label_txt = "Run Number"
+            label = wx.StaticText(metadata_pane, -1, label_txt,
+                style=wx.ALIGN_CENTER_VERTICAL)
+            input_box = wx.TextCtrl(metadata_pane, name=item,
+                size=(_STATICBOX_WIDTH-80, -1))
+            input_box.Bind(wx.EVT_TEXT, self.metadata_changed)
+            metadata_grid.Add(label, (y,0), (1,1),
+                wx.ALL | wx.ALIGN_CENTER_VERTICAL, 5)
+            metadata_grid.Add(input_box, (y,1), (1,2), wx.EXPAND)
+            y += 1
+
+        detector_label = wx.StaticText(metadata_pane, -1,
+            "Detector:")
+        metadata_grid.Add(detector_label, (y, 0), (1,1), wx.ALL | wx.EXPAND, 5)
+        detector_btn = wx.Button(metadata_pane, -1, "Enter Detector Metadata")
+        metadata_grid.Add(detector_btn, (y, 1), (1,1), wx.ALL | wx.EXPAND, 5)
+        detector_btn.Bind(wx.EVT_BUTTON, self.show_detector_window)
+        y += 1
+
+        sample_label = wx.StaticText(metadata_pane, -1, "Sample: ")
+        metadata_grid.Add(sample_label, (y,0), (1,1), wx.ALL | wx.EXPAND, 5)
+        sample_btn = wx.Button(metadata_pane, -1, "Enter Sample Metadata")
+        metadata_grid.Add(sample_btn, (y,1), (1,1), wx.ALL | wx.EXPAND, 5)
+        sample_btn.Bind(wx.EVT_BUTTON, self.show_sample_window)
+        y += 1
+
+        source_label = wx.StaticText(metadata_pane, -1, "Source: ")
+        metadata_grid.Add(source_label, (y,0), (1,1), wx.ALL | wx.EXPAND, 5)
+        source_btn = wx.Button(metadata_pane, -1, "Enter Source Metadata")
+        source_btn.Bind(wx.EVT_BUTTON, self.show_source_window)
+        metadata_grid.Add(source_btn, (y,1), (1,1), wx.ALL | wx.EXPAND, 5)
+        y += 1
+
+        metadata_pane.SetSizer(metadata_grid)
+
+        vbox.Add(self.metadata_section, proportion=0, flag=wx.ALL, border=5)
+
+        vbox.Fit(self)
+        self.SetSizer(vbox)
+
+class ConverterWindow(widget.CHILD_FRAME):
+    """Displays ConverterPanel"""
+
+    def __init__(self, parent=None, title='File Converter', base=None,
+        manager=None, size=(PANEL_SIZE * 1.05, PANEL_SIZE / 1.1),
+        *args, **kwargs):
+        kwargs['title'] = title
+        kwargs['size'] = size
+        widget.CHILD_FRAME.__init__(self, parent, *args, **kwargs)
+
+        self.manager = manager
+        self.panel = ConverterPanel(self, base=None)
+        self.Bind(wx.EVT_CLOSE, self.on_close)
+        self.SetPosition((wx.LEFT, PANEL_TOP))
+        self.Show(True)
+
+    def on_close(self, event):
+        if self.panel.bsl_thread.isrunning():
+            self.panel.bsl_thread.stop()
+        if self.manager is not None:
+            self.manager.converter_frame = None
+        self.Destroy()
Index: src/sas/sasgui/perspectives/file_converter/converter_widgets.py
===================================================================
--- src/sas/sasgui/perspectives/file_converter/converter_widgets.py	(revision 0e11ec7528fae0e8cd8f2e311b10e73c4b19c77b)
+++ src/sas/sasgui/perspectives/file_converter/converter_widgets.py	(revision 0e11ec7528fae0e8cd8f2e311b10e73c4b19c77b)
@@ -0,0 +1,188 @@
+"""
+This module provides some custom wx widgets for the file converter perspective
+"""
+import wx
+import os
+from sas.sascalc.dataloader.data_info import Vector
+from sas.sasgui.guiframe.utils import check_float
+
+class VectorInput(object):
+    """
+    An input field for inputting 2 (or 3) components of a vector.
+    """
+
+    def __init__(self, parent, control_name, callback=None,
+        labels=["x: ", "y: ", "z: "], z_enabled=False):
+        """
+        Create the control
+
+        :param parent: The window to add the control to
+        :param control_name: All TextCtrl names will start with control_name
+        :param callback: The function to call when the text is changed
+        :param labels: An array of labels for the TextCtrls
+        :param z_enabled: Whether or not to show the z input field
+        """
+        self.parent = parent
+        self.control_name = control_name
+        self._callback = callback
+        self._name = control_name
+
+        self.labels = labels
+        self.z_enabled = z_enabled
+        self._sizer = None
+        self._inputs = {}
+
+        self._do_layout()
+
+    def GetSizer(self):
+        """
+        Get the control's sizer
+
+        :return sizer: a wx.BoxSizer object
+        """
+        return self._sizer
+
+    def GetName(self):
+        return self._name
+
+    def GetValue(self):
+        """
+        Get the value of the vector input
+
+        :return v: A Vector object
+        """
+        v = Vector()
+        if not self.Validate(): return v
+        for direction, control in self._inputs.iteritems():
+            try:
+                value = float(control.GetValue())
+                setattr(v, direction, value)
+            except: # Text field is empty
+                pass
+
+        return v
+
+    def SetValue(self, vector):
+        """
+        Set the value of the vector input
+
+        :param vector: A Vector object
+        """
+        directions = ['x', 'y']
+        if self.z_enabled: directions.append('z')
+        for direction in directions:
+            value = getattr(vector, direction)
+            if value is None: value = ''
+            self._inputs[direction].SetValue(str(value))
+
+    def Validate(self):
+        """
+        Validate the contents of the inputs
+
+        :return all_valid: Whether or not the inputs are valid
+        :return invalid_ctrl: A control that is not valid
+            (or None if all are valid)
+        """
+        all_valid = True
+        invalid_ctrl = None
+        for control in self._inputs.values():
+            if control.GetValue() == '': continue
+            control.SetBackgroundColour(wx.WHITE)
+            control_valid = check_float(control)
+            if not control_valid:
+                all_valid = False
+                invalid_ctrl = control
+        return all_valid, invalid_ctrl
+
+
+    def _do_layout(self):
+        self._sizer = wx.BoxSizer(wx.HORIZONTAL)
+        x_label = wx.StaticText(self.parent, -1, self.labels[0],
+            style=wx.ALIGN_CENTER_VERTICAL)
+        self._sizer.Add(x_label, wx.ALIGN_CENTER_VERTICAL)
+        x_input = wx.TextCtrl(self.parent, -1,
+            name="{}_x".format(self.control_name), size=(50, -1))
+        self._sizer.Add(x_input)
+        self._inputs['x'] = x_input
+        x_input.Bind(wx.EVT_TEXT, self._callback)
+
+        self._sizer.AddSpacer((10, -1))
+
+        y_label = wx.StaticText(self.parent, -1, self.labels[1],
+            style=wx.ALIGN_CENTER_VERTICAL)
+        self._sizer.Add(y_label, wx.ALIGN_CENTER_VERTICAL)
+        y_input = wx.TextCtrl(self.parent, -1,
+            name="{}_y".format(self.control_name), size=(50, -1))
+        self._sizer.Add(y_input)
+        self._inputs['y'] = y_input
+        y_input.Bind(wx.EVT_TEXT, self._callback)
+
+        if self.z_enabled:
+            self._sizer.AddSpacer((10, -1))
+
+            z_label = wx.StaticText(self.parent, -1, self.labels[2],
+                style=wx.ALIGN_CENTER_VERTICAL)
+            self._sizer.Add(z_label, wx.ALIGN_CENTER_VERTICAL)
+            z_input = wx.TextCtrl(self.parent, -1,
+                name="{}_z".format(self.control_name), size=(50, -1))
+            self._sizer.Add(z_input)
+            self._inputs['z'] = z_input
+            z_input.Bind(wx.EVT_TEXT, self._callback)
+
+class FileInput(object):
+
+    def __init__(self, parent, wildcard=''):
+        self.parent = parent
+        self._sizer = None
+        self._text_ctrl = None
+        self._button_ctrl = None
+        self._filepath = ''
+        self._wildcard = wildcard
+
+        self._do_layout()
+
+    def GetCtrl(self):
+        return self._sizer
+
+    def GetPath(self):
+        return self._filepath
+
+    def SetWildcard(self, wildcard):
+        self._wildcard = wildcard
+
+
+    def _on_text_change(self, event):
+        event.Skip()
+        self._filepath = self._text_ctrl.GetValue()
+
+    def _on_browse(self, event):
+        event.Skip()
+        initial_path = self._filepath
+        initial_dir = os.getcwd()
+        if not os.path.isfile(initial_path):
+            initial_path = ''
+        else:
+            initial_dir = os.path.split(initial_path)[0]
+
+        file_dlg = wx.FileDialog(self.parent, defaultDir=initial_dir,
+            defaultFile=initial_path, wildcard=self._wildcard,
+            style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT)
+        if file_dlg.ShowModal() == wx.ID_CANCEL:
+            file_dlg.Destroy()
+            return
+        self._filepath = file_dlg.GetPath()
+        file_dlg.Destroy()
+        self._text_ctrl.SetValue(self._filepath)
+
+    def _do_layout(self):
+        self._sizer = wx.BoxSizer(wx.HORIZONTAL)
+
+        self._text_ctrl = wx.TextCtrl(self.parent, -1)
+        self._sizer.Add(self._text_ctrl, wx.EXPAND)
+        self._text_ctrl.Bind(wx.EVT_TEXT, self._on_text_change)
+
+        self._sizer.AddSpacer(5)
+
+        self._button_ctrl = wx.Button(self.parent, -1, "Browse")
+        self._sizer.Add(self._button_ctrl)
+        self._button_ctrl.Bind(wx.EVT_BUTTON, self._on_browse)
Index: src/sas/sasgui/perspectives/file_converter/file_converter.py
===================================================================
--- src/sas/sasgui/perspectives/file_converter/file_converter.py	(revision ba65aff97e4c9511e2e199a78137312a6adc403d)
+++ src/sas/sasgui/perspectives/file_converter/file_converter.py	(revision ba65aff97e4c9511e2e199a78137312a6adc403d)
@@ -0,0 +1,48 @@
+"""
+File Converter Plugin
+"""
+
+import logging
+from sas.sasgui.guiframe.plugin_base import PluginBase
+from sas.sasgui.perspectives.file_converter.converter_panel import ConverterWindow
+
+class Plugin(PluginBase):
+    """
+    This class defines the interface for a Plugin class
+    for File Converter perspective
+    """
+
+    def __init__(self):
+        PluginBase.__init__(self, name="File Converter")
+        logging.info("File Converter plug-in started")
+        self._sub_menu = "Tool"
+        self.converter_frame = None
+
+    def get_tools(self):
+        """
+        Returns a set of menu entries
+        """
+        help_txt = "Convert single column ASCII data to CanSAS format"
+        return [("File Converter", help_txt, self.on_file_converter)]
+
+    def on_file_converter(self, event):
+        if self.converter_frame is None:
+            frame = ConverterWindow(parent=self.parent, base=self.parent,
+                manager=self)
+            self.put_icon(frame)
+            self.converter_frame = frame
+        else:
+            self.converter_frame.Show(False)
+        self.converter_frame.Show(True)
+
+    def put_icon(self, frame):
+        """
+        Put icon in the frame title bar
+        """
+        if hasattr(frame, "IsIconized"):
+            if not frame.IsIconized():
+                try:
+                    icon = self.parent.GetIcon()
+                    frame.SetIcon(icon)
+                except:
+                    pass
Index: src/sas/sasgui/perspectives/file_converter/frame_select_dialog.py
===================================================================
--- src/sas/sasgui/perspectives/file_converter/frame_select_dialog.py	(revision ba65aff97e4c9511e2e199a78137312a6adc403d)
+++ src/sas/sasgui/perspectives/file_converter/frame_select_dialog.py	(revision ba65aff97e4c9511e2e199a78137312a6adc403d)
@@ -0,0 +1,72 @@
+import wx
+
+class FrameSelectDialog(wx.Dialog):
+    """
+    This class provides a wx.Dialog subclass for selecting which frames of a
+    multi-frame file to export
+    """
+
+    def __init__(self, n_frames, is_bsl=False):
+        wx.Dialog.__init__(self, None, title="Select Frames")
+
+        sizer = wx.GridBagSizer(10, 10)
+
+        y = 0
+        instructions = ("The file you've selected has {} frames. "
+            "Please select a subset of frames to convert to CanSAS "
+            "format").format(n_frames)
+        instructions_label = wx.StaticText(self, -1, instructions)
+        instructions_label.Wrap(self.GetSize().width - 10)
+        sizer.Add(instructions_label, (y,0), (1,2), wx.ALL, 5)
+        y += 1
+
+        first_label = wx.StaticText(self, -1,
+            "First Frame (0-{}): ".format(n_frames-1))
+        sizer.Add(first_label, (y,0), (1,1), wx.ALL, 5)
+
+        self.first_input = wx.TextCtrl(self, -1)
+        sizer.Add(self.first_input, (y,1), (1,1))
+        y += 1
+
+        last_label = wx.StaticText(self, -1,
+            "Last Frame (0-{}): ".format(n_frames-1))
+        sizer.Add(last_label, (y,0), (1,1), wx.ALL, 5)
+
+        self.last_input = wx.TextCtrl(self, -1)
+        sizer.Add(self.last_input, (y,1), (1,1))
+        y += 1
+
+        increment_label = wx.StaticText(self, -1, "Increment: ")
+        sizer.Add(increment_label, (y,0), (1,1), wx.ALL, 5)
+
+        self.increment_input = wx.TextCtrl(self, -1)
+        sizer.Add(self.increment_input, (y,1), (1,1))
+        y += 1
+
+        if not is_bsl:
+            export_label = wx.StaticText(self, -1, "Export each frame to:")
+            sizer.Add(export_label, (y,0), (1,1), wx.LEFT | wx.RIGHT | wx.TOP, 5)
+            y += 1
+
+            self.single_btn = wx.RadioButton(self, -1, label="The same file",
+                style=wx.RB_GROUP)
+            sizer.Add(self.single_btn, (y,0), (1,1),
+                wx.LEFT | wx.RIGHT | wx.BOTTOM, 5)
+
+            multiple_btn = wx.RadioButton(self, -1, label="Multiple files")
+            sizer.Add(multiple_btn, (y,1), (1,1),
+                wx.LEFT | wx.RIGHT | wx.BOTTOM, 5)
+            y += 1
+
+        done_btn = wx.Button(self, wx.ID_OK)
+        sizer.Add(done_btn, (y,0), (1,1), wx.LEFT | wx.BOTTOM, 15)
+
+        cancel_btn = wx.Button(self, wx.ID_CANCEL)
+        sizer.Add(cancel_btn, (y,1), (1,1), wx.LEFT | wx.RIGHT | wx.BOTTOM, 15)
+
+        self.SetSizer(sizer)
+
+        size = self.GetSize()
+        if not is_bsl:
+            size.height += 35
+        self.SetSize(size)
Index: src/sas/sasgui/perspectives/file_converter/media/file_converter_help.rst
===================================================================
--- src/sas/sasgui/perspectives/file_converter/media/file_converter_help.rst	(revision ebc14071f959e216a96cb974fb9d653ce1d98417)
+++ src/sas/sasgui/perspectives/file_converter/media/file_converter_help.rst	(revision ebc14071f959e216a96cb974fb9d653ce1d98417)
@@ -0,0 +1,54 @@
+.. file_converter_help.rst
+
+File Converter Tool
+===================
+
+Description
+-----------
+
+This tool converts file formats with the Q data and intensity data in separate
+files, into a single CanSAS XML file.
+
+It can also convert 2D BSL files into IGOR/DAT 2D files, which are 3 column
+ASCII files of the format :code:`Qx - Qy - I(Qx,Qy)`.
+
+The input files can be:
+
+*   Single column ASCII data, with lines that end with a digit (no delimiter),
+    comma or semi-colon.
+*   `One-Dimensional OTOKO formatted
+    <http://www.diamond.ac.uk/Beamlines/Soft-Condensed-Matter/small-angle/
+    SAXS-Software/CCP13/XOTOKO.html>`_ data files.
+*   `Two-Dimensional BSL formatted
+    <http://www.diamond.ac.uk/Beamlines/Soft-Condensed-Matter/small-angle/
+    SAXS-Software/CCP13/BSL.html>`_ data files.
+
+.. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
+
+Using the Tool
+--------------
+
+1) Select the files containing your Q-Axis and Intensity-Axis data
+2) Chose whether the files are in ASCII, OTOKO or BSL format
+3) Chose where you would like to save the converted file
+4) Optionally, input some metadata such as sample size, detector name, etc
+5) Click *Convert* to save the converted file to disk
+
+Files With Multiple Frames
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+If a BSL/OTOKO file with multiple frames is selected for the intensity-axis
+file, a dialog will appear asking which frames you would like converted. You
+may enter a start frame, end frame & increment, and all frames in that subset
+will be converted. For example: entering 0, 50 and 10 for the first frame, last
+frame, and increment respectively will convert frames 0, 10, 20, 30, 40 & 50.
+To convert a single frame, enter the same value for first frame & last frame,
+and 1 as the increment.
+
+For OTOKO files, there is also the option to output the data as multiple frames
+in a single CanSAS file, or multiple files with one frame each. The single file
+option will produce one file with multiple `<SASdata>` elements. The multiple
+file option will output a file for each frame; each file will have one
+`<SASdata>` element, and the frame number will appended to the file name.
+
+All converted BSL frames will have the frame number appended to the file name.
Index: src/sas/sasgui/perspectives/file_converter/meta_panels.py
===================================================================
--- src/sas/sasgui/perspectives/file_converter/meta_panels.py	(revision ba65aff97e4c9511e2e199a78137312a6adc403d)
+++ src/sas/sasgui/perspectives/file_converter/meta_panels.py	(revision ba65aff97e4c9511e2e199a78137312a6adc403d)
@@ -0,0 +1,419 @@
+import wx
+import sys
+from sas.sasgui.perspectives.calculator import calculator_widgets as widget
+from sas.sasgui.perspectives.file_converter.converter_widgets import VectorInput
+from wx.lib.scrolledpanel import ScrolledPanel
+from sas.sasgui.guiframe.panel_base import PanelBase
+from sas.sascalc.dataloader.data_info import Detector
+from sas.sasgui.guiframe.events import StatusEvent
+from sas.sasgui.guiframe.utils import check_float
+
+if sys.platform.count("win32") > 0:
+    PANEL_TOP = 0
+    _STATICBOX_WIDTH = 350
+    PANEL_SIZE = 440
+    FONT_VARIANT = 0
+else:
+    PANEL_TOP = 60
+    _STATICBOX_WIDTH = 380
+    PANEL_SIZE = 470
+    FONT_VARIANT = 1
+
+class MetadataPanel(ScrolledPanel, PanelBase):
+    """
+    A common base class to be extended by panels that deal with metadata input.
+    Handles input validation and passing inputted data back to ConverterPanel.
+    """
+
+    def __init__(self, parent, metadata, base=None, *args, **kwargs):
+        ScrolledPanel.__init__(self, parent, *args, **kwargs)
+        PanelBase.__init__(self)
+        self.SetupScrolling()
+        self.SetWindowVariant(variant=FONT_VARIANT)
+
+        self.base = base
+        self.parent = parent
+        self._to_validate = [] # An list of inputs that should contain floats
+        self._vectors = [] # A list of VectorInputs to be validated
+        self.metadata = metadata
+
+    def get_property_string(self, name, is_float=False):
+        value = getattr(self.metadata, name)
+        if value is None or value == []:
+            value = ''
+            is_float = False
+        if isinstance(value, list):
+            value = value[0]
+        value = str(value)
+        if is_float and not '.' in value: value += '.0'
+        return value
+
+    def on_change(self, event):
+        ctrl = event.GetEventObject()
+        value = ctrl.GetValue()
+        name = ctrl.GetName()
+        old_value = getattr(self.metadata, name)
+        if value == '': value = None
+        if isinstance(old_value, list): value = [value]
+
+        setattr(self.metadata, name, value)
+
+    def on_close(self, event=None):
+        for ctrl in self._to_validate:
+            ctrl.SetBackgroundColour(wx.WHITE)
+            if ctrl.GetValue() == '': continue
+            if not check_float(ctrl):
+                msg = "{} must be a valid float".format(
+                    ctrl.GetName().replace("_", " "))
+                wx.PostEvent(self.parent.manager.parent.manager.parent,
+                    StatusEvent(status=msg, info='error'))
+                return False
+        for vector_in in self._vectors:
+            is_valid, invalid_ctrl = vector_in.Validate()
+            if not is_valid:
+                msg = "{} must be a valid float".format(
+                    invalid_ctrl.GetName().replace("_", " "))
+                wx.PostEvent(self.parent.manager.parent.manager.parent,
+                    StatusEvent(status=msg, info='error'))
+                return False
+            setattr(self.metadata, vector_in.GetName(), vector_in.GetValue())
+        return True
+
+class DetectorPanel(MetadataPanel):
+
+    def __init__(self, parent, detector, base=None, *args, **kwargs):
+        if detector.name is None:
+            detector.name = ''
+
+        MetadataPanel.__init__(self, parent, detector, base, *args, **kwargs)
+
+        self._do_layout()
+        self.SetAutoLayout(True)
+        self.Layout()
+
+    def on_close(self, event=None):
+        if not MetadataPanel.on_close(self, event):
+            return
+
+        self.parent.manager.detector = self.metadata
+        self.parent.on_close(event)
+
+    def _do_layout(self):
+        vbox = wx.BoxSizer(wx.VERTICAL)
+
+        section = wx.StaticBox(self, -1, "Detector")
+        section_sizer = wx.StaticBoxSizer(section, wx.VERTICAL)
+        section_sizer.SetMinSize((_STATICBOX_WIDTH, -1))
+
+        input_grid = wx.GridBagSizer(5, 5)
+
+        y = 0
+        name_label = wx.StaticText(self, -1, "Name: ")
+        input_grid.Add(name_label, (y,0), (1,1), wx.ALL, 5)
+        name_input = wx.TextCtrl(self, -1, name="name")
+        input_grid.Add(name_input, (y,1), (1,1))
+        name_input.Bind(wx.EVT_TEXT, self.on_change)
+        y += 1
+
+        distance_label = wx.StaticText(self, -1,
+            "Distance (mm): ")
+        input_grid.Add(distance_label, (y, 0), (1,1), wx.ALL, 5)
+        distance_input = wx.TextCtrl(self, -1,
+            name="distance", size=(50,-1))
+        input_grid.Add(distance_input, (y,1), (1,1))
+        distance_input.Bind(wx.EVT_TEXT, self.on_change)
+        self._to_validate.append(distance_input)
+        y += 1
+
+        offset_label = wx.StaticText(self, -1, "Offset (mm): ")
+        input_grid.Add(offset_label, (y,0), (1,1), wx.ALL, 5)
+        offset_input = VectorInput(self, "offset")
+        input_grid.Add(offset_input.GetSizer(), (y,1), (1,1))
+        self._vectors.append(offset_input)
+        y += 1
+
+        orientation_label = wx.StaticText(self, -1, "Orientation (\xb0): ")
+        input_grid.Add(orientation_label, (y,0), (1,1), wx.ALL, 5)
+        orientation_input = VectorInput(self, "orientation", z_enabled=True,
+            labels=["Roll: ", "Pitch: ", "Yaw: "])
+        input_grid.Add(orientation_input.GetSizer(), (y,1), (1,1))
+        self._vectors.append(orientation_input)
+        y += 1
+
+        pixel_label = wx.StaticText(self, -1, "Pixel Size (mm): ")
+        input_grid.Add(pixel_label, (y,0), (1,1), wx.ALL, 5)
+        pixel_input = VectorInput(self, "pixel_size")
+        input_grid.Add(pixel_input.GetSizer(), (y,1), (1,1))
+        self._vectors.append(pixel_input)
+        y += 1
+
+        beam_label = wx.StaticText(self, -1, "Beam Center (mm): ")
+        input_grid.Add(beam_label, (y,0), (1,1), wx.ALL, 5)
+        beam_input = VectorInput(self, "beam_center")
+        input_grid.Add(beam_input.GetSizer(), (y,1), (1,1))
+        self._vectors.append(beam_input)
+        y += 1
+
+        slit_label = wx.StaticText(self, -1, "Slit Length (mm): ")
+        input_grid.Add(slit_label, (y,0), (1,1), wx.ALL, 5)
+        slit_input = wx.TextCtrl(self, -1, name="slit_length", size=(50,-1))
+        input_grid.Add(slit_input, (y,1), (1,1))
+        slit_input.Bind(wx.EVT_TEXT, self.on_change)
+        self._to_validate.append(slit_input)
+        y += 1
+
+        done_btn = wx.Button(self, -1, "Done")
+        input_grid.Add(done_btn, (y,0), (1,1), wx.ALL, 5)
+        done_btn.Bind(wx.EVT_BUTTON, self.on_close)
+
+        section_sizer.Add(input_grid)
+        vbox.Add(section_sizer, flag=wx.ALL, border=10)
+
+        name_input.SetValue(self.metadata.name)
+        distance = self.get_property_string("distance", is_float=True)
+        distance_input.SetValue(distance)
+        offset_input.SetValue(self.metadata.offset)
+        orientation_input.SetValue(self.metadata.orientation)
+        pixel_input.SetValue(self.metadata.pixel_size)
+        beam_input.SetValue(self.metadata.beam_center)
+        slit_len = self.get_property_string("slit_length", is_float=True)
+        slit_input.SetValue(slit_len)
+
+        vbox.Fit(self)
+        self.SetSizer(vbox)
+
+class SamplePanel(MetadataPanel):
+
+    def __init__(self, parent, sample, base=None, *args, **kwargs):
+        MetadataPanel.__init__(self, parent, sample, base, *args, **kwargs)
+        if sample.name is None:
+            sample.name = ''
+
+        self._do_layout()
+        self.SetAutoLayout(True)
+        self.Layout()
+
+    def on_close(self, event=None):
+        if not MetadataPanel.on_close(self, event):
+            return
+
+        self.parent.manager.sample = self.metadata
+        self.parent.on_close(event)
+
+    def _do_layout(self):
+        vbox = wx.BoxSizer(wx.VERTICAL)
+
+        section = wx.StaticBox(self, -1, "Sample")
+        section_sizer = wx.StaticBoxSizer(section, wx.VERTICAL)
+        section_sizer.SetMinSize((_STATICBOX_WIDTH, -1))
+
+        input_grid = wx.GridBagSizer(5, 5)
+
+        y = 0
+        name_label = wx.StaticText(self, -1, "Name: ")
+        input_grid.Add(name_label, (y,0), (1,1), wx.ALL, 5)
+        name_input = wx.TextCtrl(self, -1, name="name")
+        input_grid.Add(name_input, (y,1), (1,1))
+        name_input.Bind(wx.EVT_TEXT, self.on_change)
+        y += 1
+
+        thickness_label = wx.StaticText(self, -1, "Thickness (mm): ")
+        input_grid.Add(thickness_label, (y,0), (1,1), wx.ALL, 5)
+        thickness_input = wx.TextCtrl(self, -1, name="thickness")
+        input_grid.Add(thickness_input, (y,1), (1,1))
+        thickness_input.Bind(wx.EVT_TEXT, self.on_change)
+        self._to_validate.append(thickness_input)
+        y += 1
+
+        transmission_label = wx.StaticText(self, -1, "Transmission: ")
+        input_grid.Add(transmission_label, (y,0), (1,1), wx.ALL, 5)
+        transmission_input = wx.TextCtrl(self, -1, name="transmission")
+        input_grid.Add(transmission_input, (y,1), (1,1))
+        transmission_input.Bind(wx.EVT_TEXT, self.on_change)
+        self._to_validate.append(transmission_input)
+        y += 1
+
+        temperature_label = wx.StaticText(self, -1, "Temperature: ")
+        input_grid.Add(temperature_label, (y,0), (1,1), wx.ALL, 5)
+        temperature_input = wx.TextCtrl(self, -1, name="temperature")
+        temperature_input.Bind(wx.EVT_TEXT, self.on_change)
+        self._to_validate.append(temperature_input)
+        input_grid.Add(temperature_input, (y,1), (1,1))
+        temp_unit_label = wx.StaticText(self, -1, "Unit: ")
+        input_grid.Add(temp_unit_label, (y,2), (1,1))
+        temp_unit_input = wx.TextCtrl(self, -1, name="temperature_unit",
+            size=(50,-1))
+        temp_unit_input.Bind(wx.EVT_TEXT, self.on_change)
+        input_grid.Add(temp_unit_input, (y,3), (1,1))
+        y += 1
+
+        position_label = wx.StaticText(self, -1, "Position (mm): ")
+        input_grid.Add(position_label, (y,0), (1,1), wx.ALL, 5)
+        position_input = VectorInput(self, "position")
+        self._vectors.append(position_input)
+        input_grid.Add(position_input.GetSizer(), (y,1), (1,2))
+        y += 1
+
+        orientation_label = wx.StaticText(self, -1, "Orientation (\xb0): ")
+        input_grid.Add(orientation_label, (y,0), (1,1), wx.ALL, 5)
+        orientation_input = VectorInput(self, "orientation",
+            labels=["Roll: ", "Pitch: ", "Yaw: "], z_enabled=True)
+        self._vectors.append(orientation_input)
+        input_grid.Add(orientation_input.GetSizer(), (y,1), (1,3))
+        y += 1
+
+        details_label = wx.StaticText(self, -1, "Details: ")
+        input_grid.Add(details_label, (y,0), (1,1), wx.ALL, 5)
+        details_input = wx.TextCtrl(self, -1, name="details",
+            style=wx.TE_MULTILINE)
+        input_grid.Add(details_input, (y,1), (3,3), wx.EXPAND)
+        y += 3
+
+        name_input.SetValue(self.metadata.name)
+        thickness_input.SetValue(
+            self.get_property_string("thickness", is_float=True))
+        transmission_input.SetValue(
+            self.get_property_string("transmission", is_float=True))
+        temperature_input.SetValue(
+            self.get_property_string("temperature", is_float=True))
+        temp_unit_input.SetValue(self.get_property_string("temperature_unit"))
+        position_input.SetValue(self.metadata.position)
+        orientation_input.SetValue(self.metadata.orientation)
+        details_input.SetValue(self.get_property_string("details"))
+        details_input.Bind(wx.EVT_TEXT, self.on_change)
+
+        done_btn = wx.Button(self, -1, "Done")
+        input_grid.Add(done_btn, (y,0), (1,1), wx.ALL, 5)
+        done_btn.Bind(wx.EVT_BUTTON, self.on_close)
+
+        section_sizer.Add(input_grid)
+        vbox.Add(section_sizer, flag=wx.ALL, border=10)
+
+        vbox.Fit(self)
+        self.SetSizer(vbox)
+
+class SourcePanel(MetadataPanel):
+
+    def __init__(self, parent, source, base=None, *args, **kwargs):
+        MetadataPanel.__init__(self, parent, source, base, *args, **kwargs)
+        if source.name is None:
+            source.name = ''
+        source.wavelength_unit = 'nm'
+
+        self._do_layout()
+        self.SetAutoLayout(True)
+        self.Layout()
+
+    def on_close(self, event=None):
+        if not MetadataPanel.on_close(self, event):
+            return
+
+        self.parent.manager.source = self.metadata
+        self.parent.on_close(event)
+
+    def _do_layout(self):
+        vbox = wx.BoxSizer(wx.VERTICAL)
+
+        section = wx.StaticBox(self, -1, "Source")
+        section_sizer = wx.StaticBoxSizer(section, wx.VERTICAL)
+        section_sizer.SetMinSize((_STATICBOX_WIDTH, -1))
+
+        input_grid = wx.GridBagSizer(5, 5)
+
+        y = 0
+        name_label = wx.StaticText(self, -1, "Name: ")
+        input_grid.Add(name_label, (y,0), (1,1), wx.ALL, 5)
+        name_input = wx.TextCtrl(self, -1, name="name")
+        input_grid.Add(name_input, (y,1), (1,1))
+        name_input.Bind(wx.EVT_TEXT, self.on_change)
+        y += 1
+
+        size_label = wx.StaticText(self, -1, "Beam Size (mm): ")
+        input_grid.Add(size_label, (y,0), (1,1), wx.ALL, 5)
+        size_input = VectorInput(self, "beam_size")
+        self._vectors.append(size_input)
+        input_grid.Add(size_input.GetSizer(), (y,1), (1,1))
+        y += 1
+
+        shape_label = wx.StaticText(self, -1, "Beam Shape: ")
+        input_grid.Add(shape_label, (y,0), (1,1), wx.ALL, 5)
+        shape_input = wx.TextCtrl(self, -1, name="beam_shape")
+        shape_input.Bind(wx.EVT_TEXT, self.on_change)
+        input_grid.Add(shape_input, (y,1), (1,1))
+        y += 1
+
+        wavelength_label = wx.StaticText(self, -1, "Wavelength (nm): ")
+        input_grid.Add(wavelength_label, (y,0), (1,1), wx.ALL, 5)
+        wavelength_input = wx.TextCtrl(self, -1, name="wavelength",
+            size=(50,-1))
+        wavelength_input.Bind(wx.EVT_TEXT, self.on_change)
+        self._to_validate.append(wavelength_input)
+        input_grid.Add(wavelength_input, (y,1), (1,1))
+        y += 1
+
+        min_wavelength_label = wx.StaticText(self, -1, "Min. Wavelength (nm): ")
+        input_grid.Add(min_wavelength_label, (y,0), (1,1), wx.ALL, 5)
+        min_wavelength_input = wx.TextCtrl(self, -1, name="wavelength_min",
+            size=(50,-1))
+        min_wavelength_input.Bind(wx.EVT_TEXT, self.on_change)
+        self._to_validate.append(min_wavelength_input)
+        input_grid.Add(min_wavelength_input, (y,1), (1,1))
+        y += 1
+
+        max_wavelength_label = wx.StaticText(self, -1, "Max. Wavelength (nm): ")
+        input_grid.Add(max_wavelength_label, (y,0), (1,1), wx.ALL, 5)
+        max_wavelength_input = wx.TextCtrl(self, -1, name="wavelength_max",
+            size=(50,-1))
+        max_wavelength_input.Bind(wx.EVT_TEXT, self.on_change)
+        self._to_validate.append(max_wavelength_input)
+        input_grid.Add(max_wavelength_input, (y,1), (1,1))
+        y += 1
+
+        wavelength_spread_label = wx.StaticText(self, -1,
+            "Wavelength Spread (%): ")
+        input_grid.Add(wavelength_spread_label, (y,0), (1,1), wx.ALL, 5)
+        wavelength_spread_input = wx.TextCtrl(self, -1,
+            name="wavelength_spread", size=(50,-1))
+        wavelength_spread_input.Bind(wx.EVT_TEXT, self.on_change)
+        self._to_validate.append(wavelength_spread_input)
+        input_grid.Add(wavelength_spread_input, (y,1), (1,1))
+        y += 1
+
+        name_input.SetValue(self.get_property_string("name"))
+        size_input.SetValue(self.metadata.beam_size)
+        shape_input.SetValue(self.get_property_string("beam_shape"))
+        wavelength_input.SetValue(
+            self.get_property_string("wavelength", is_float=True))
+        min_wavelength_input.SetValue(
+            self.get_property_string("wavelength_min", is_float=True))
+        max_wavelength_input.SetValue(
+            self.get_property_string("wavelength_max", is_float=True))
+
+        done_btn = wx.Button(self, -1, "Done")
+        input_grid.Add(done_btn, (y,0), (1,1), wx.ALL, 5)
+        done_btn.Bind(wx.EVT_BUTTON, self.on_close)
+
+        section_sizer.Add(input_grid)
+        vbox.Add(section_sizer, flag=wx.ALL, border=10)
+
+        vbox.Fit(self)
+        self.SetSizer(vbox)
+
+
+class MetadataWindow(widget.CHILD_FRAME):
+
+    def __init__(self, PanelClass, parent=None, title='', base=None,
+        manager=None, size=(PANEL_SIZE, PANEL_SIZE*0.8), metadata=None,
+         *args, **kwargs):
+        kwargs['title'] = title
+        kwargs['size'] = size
+        widget.CHILD_FRAME.__init__(self, parent, *args, **kwargs)
+
+        self.manager = manager
+        self.panel = PanelClass(self, metadata, base=None)
+        self.Bind(wx.EVT_CLOSE, self.on_close)
+
+    def on_close(self, event):
+        if self.manager is not None:
+            self.manager.meta_frames.remove(self)
+        self.Destroy()
