Repository: mgrady3/PLEASE (language: python)

Issue:
Update base data models to use Traits
The core models used to represent LEEM and LEED images, as well as other models that can be sufficiently decoupled from the UI code, should be updated to use `traits`.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index e8f7d89e..d3b9fee7 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -34,10 +34,7 @@ jobs:
         poetry install
     - name: Lint with flake8 inside the development environment
       run: |
-        # stop the build if there are Python syntax errors or undefined names
-        poetry run flake8 ./please --count --select=E9,F63,F7,F82 --show-source --statistics
-        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
-        poetry run flake8 ./please --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+        poetry run flake8 ./please
     - name: Execute unittests in the development environment
       run: |
         poetry run python -m unittest
diff --git a/please/constants.py b/please/constants.py
new file mode 100644
index 00000000..ac6e8a72
--- /dev/null
+++ b/please/constants.py
@@ -0,0 +1,36 @@
+""" This module contains constants used by various model code
+"""
+
+# TODO: add support for ARPES data and potentially other types
+# See GH Issue: #11
+SUPPORTED_EXPERIMENT_TYPES = {
+    'LEEM',
+    'LEED',
+}
+
+SUPPORTED_DATA_TYPES = {
+    'Image',
+    'Raw',
+}
+
+SUPPORTED_IMAGE_FORMATS = {
+    'PNG',
+    'TIFF',
+}
+
+SUPPORTED_RAW_FORMATS = {
+    'DAT',
+}
+
+ENDIAN_STRINGS = {
+    'L',  # Little-endian data
+    'M',  # Big-endian data
+}
+
+EXTERNAL_PARAMETERS = {
+    'Energy',
+    'Temperature',
+    'Time'
+}
+
+BITS_PER_BYTE = 8
diff --git a/please/exceptions.py b/please/exceptions.py
new file mode 100644
index 00000000..f4256f0d
--- /dev/null
+++ b/please/exceptions.py
@@ -0,0 +1,13 @@
+""" This module contains custom exceptions that may be raised during I/O
+tasks such as attempting to read data from disk.
+"""
+
+
+class PLEASEIOError(Exception):
+    """ Base class for I/O error in the PLEASE application code """
+    pass
+
+
+class UnsupportedDataType(PLEASEIOError):
+    """ Error due to attempt to load an unsupported data format """
+    pass
diff --git a/please/io/__init__.py b/please/io/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/please/io/readers.py b/please/io/readers.py
new file mode 100644
index 00000000..edec147e
--- /dev/null
+++ b/please/io/readers.py
@@ -0,0 +1,127 @@
+""" This module contains I/O code for reading data from supported formats
+such as raw .dat files as well as common image types like TIFF and PNG.
+"""
+import pathlib
+
+import numpy as np
+from PIL import Image
+
+from please.constants import (
+    SUPPORTED_IMAGE_FORMATS, SUPPORTED_RAW_FORMATS,
+)
+from please.constants import BITS_PER_BYTE
+from please.exceptions import UnsupportedDataType
+
+
+def read_image_data(file_path: str) -> np.ndarray:
+    """ Generate a 2d numpy of image data from an image file
+
+    Parameters
+    ----------
+    file_path : str
+        Path to the image file to read into a numpy array
+
+    Returns
+    -------
+    data : NDArray
+        2D array of image data
+    """
+    ext = pathlib.Path(file_path).suffix
+    if ext:
+        try:
+            ext = ext.split('.')[1].upper()
+        except IndexError:
+            pass
+    if ext not in SUPPORTED_IMAGE_FORMATS:
+        raise UnsupportedDataType(
+            f'The file, {file_path}, could not be loaded because the data'
+            ' format is not supported.'
+        )
+
+    return np.array(Image.open(file_path).convert('L'))
+
+
+def read_raw_data(
+        file_path: str,
+        height: int,
+        width: int,
+        bits_per_pixel: int,
+        byteorder: str
+) -> np.ndarray:
+    """ Generate a 2d numpy of image data from a raw data (.dat) file
+
+    Parameters
+    ----------
+    file_path : str
+        Path to the data file to read into a numpy array
+
+    Returns
+    -------
+    data : NDArray
+        2D array of image data
+
+    Notes
+    -----
+    This function performs a "dumb" parse of the file to extract the image
+    data. It requires knowledge of the expected image characteristics, namely
+    height, width, bits per pixel, and byte ordering i.e. endianness. With this
+    knowledge, the header can be safely discarded and only the image data
+    retained.
+    """
+    if bits_per_pixel % BITS_PER_BYTE != 0:
+        raise ValueError(
+            f"Unsupported bit-depth: {bits_per_pixel}."
+            f" Must be positive integer multiple of {BITS_PER_BYTE}."
+        )
+    ext = pathlib.Path(file_path).suffix
+    if ext:
+        try:
+            ext = ext.split('.')[1].upper()
+        except IndexError:
+            pass
+    if ext not in SUPPORTED_RAW_FORMATS:
+        raise UnsupportedDataType(
+            f'The file, {file_path}, could not be loaded because the data'
+            ' format is not supported.'
+        )
+
+    # Extract the image data from the file contents
+    with open(file_path, 'rb') as f:
+        file_data = f.read()
+
+    file_length_in_bytes = len(file_data)
+    image_data_in_bytes = height * width * int(bits_per_pixel/BITS_PER_BYTE)
+    header_length_in_bytes = file_length_in_bytes - image_data_in_bytes
+    if header_length_in_bytes < 0:
+        raise ValueError(
+            f"Can not read raw data file, {file_path}."
+            " Image parameters do not match the data file."
+        )
+    image_data = file_data[header_length_in_bytes:]
+    format_string = _get_dtype_string(bits_per_pixel, byteorder)
+    return np.frombuffer(image_data, format_string).reshape((height, width))
+
+
+def _get_dtype_string(bits: int, byteorder: str) -> str:
+    """ Generate a numpy compatable string indicating the array dtype
+
+    Parameters
+    ----------
+    bits : int
+        Number of bits per pixel in the image data
+
+    byteorder: str
+        String indicating endianness, "L" for little-endian, "B" for big.
+
+    Returns
+    -------
+    format_string : str
+        numpy compatable dtype string for formatting image data
+    """
+    if bits not in {8, 16}:
+        raise ValueError(f"Unsupported bit size: {bits}.")
+    if byteorder not in {'L', 'B'}:
+        raise ValueError(f"Unsupported byteorder: {byteorder}.")
+    endian = '<' if byteorder == 'L' else '>'
+    bitstring = 'u1' if bits == 8 else 'u2'
+    return endian + bitstring
diff --git a/please/io/utils.py b/please/io/utils.py
new file mode 100644
index 00000000..16a7cfdc
--- /dev/null
+++ b/please/io/utils.py
@@ -0,0 +1,35 @@
+""" This module contains helper functions for common tasks encountered during
+image/data file I/O
+"""
+
+import pathlib
+
+from please.constants import SUPPORTED_IMAGE_FORMATS, SUPPORTED_RAW_FORMATS
+
+
+def get_file_extension(path: str) -> str:
+    """ Get the extension of an image/data file
+
+    Parameters
+    ----------
+    path : str
+        String indicating path to the file
+
+    Returns
+    -------
+    ext : str
+        Extension of the file
+    """
+    return pathlib.Path(path).suffix
+
+
+def is_image_file(path: str) -> bool:
+    """ Is the file at the specified path a supported image type? """
+    ext = get_file_extension(path).upper()
+    return ext.strip('.') in SUPPORTED_IMAGE_FORMATS
+
+
+def is_raw_file(path: str) -> bool:
+    """ Is the file at the specified path a supported raw data type? """
+    ext = get_file_extension(path).upper()
+    return ext.strip('.') in SUPPORTED_RAW_FORMATS
diff --git a/please/model/__init__.py b/please/model/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/please/model/experiment.py b/please/model/experiment.py
new file mode 100644
index 00000000..e78a2c59
--- /dev/null
+++ b/please/model/experiment.py
@@ -0,0 +1,10 @@
+""" This module contains an implementation of the IEMExperiment base class,
+representing a collection of Electron Microscopy images forming a spectroscopic
+data set.
+"""
+
+from .interfaces import IEMExperiment
+
+
+class Experiment(IEMExperiment):
+    pass
diff --git a/please/model/image.py b/please/model/image.py
new file mode 100644
index 00000000..2f21b8c5
--- /dev/null
+++ b/please/model/image.py
@@ -0,0 +1,20 @@
+""" This module contains an implementation of the base IEMImage class, used
+to represent Electron Microscopy image data for common formats such as
+LEEM/LEED data.
+"""
+
+from .interfaces import IEMImage
+
+
+class LEEMImage(IEMImage):
+
+    def __init__(self, **traits):
+        super().__init__(**traits)
+        self.image_type = 'LEEM'
+
+
+class LEEDImage(IEMImage):
+
+    def __init__(self, **traits):
+        super().__init__(**traits)
+        self.image_type = 'LEED'
diff --git a/please/model/interfaces.py b/please/model/interfaces.py
new file mode 100644
index 00000000..3094c588
--- /dev/null
+++ b/please/model/interfaces.py
@@ -0,0 +1,93 @@
+""" This module defines base classes to represent Electron Microscope data and
+experiments concering various EM data
+"""
+
+from traits.api import (
+    ABCHasStrictTraits, Array, Enum, Int, Property, Str, Tuple,
+)
+
+from please.constants import (
+    ENDIAN_STRINGS, EXTERNAL_PARAMETERS, SUPPORTED_DATA_TYPES,
+    SUPPORTED_EXPERIMENT_TYPES,
+)
+
+
+class IEMImage(ABCHasStrictTraits):
+    """ Interface for an Electron Microscope Image Model """
+
+    #: Image height in pixels
+    height = Property(Int, depends_on='shape')
+
+    #: Image width in pixels
+    width = Property(Int, depends_on='shape')
+
+    # Tuple defining data shape in (height, width) format
+    shape = Property(Tuple(Int, Int), depends_on='data')
+
+    #: Numpy-like Array of Image data with shape (height, width)
+    data = Array(shape=(None, None))
+
+    #: String indicating the type of data this image represents
+    image_type = Enum('LEEM', SUPPORTED_EXPERIMENT_TYPES)
+
+    def _get_shape(self):
+        """ Get the image shape in (height, width) format. """
+        return self.data.shape
+
+    def _get_height(self):
+        """ Get the image height in pixels. """
+        return self.shape[0]
+
+    def _get_width(self):
+        """ Get the image width in pixels. """
+        return self.shape[1]
+
+
+class IEMExperiment(ABCHasStrictTraits):
+    """ Interface definition for an Electron Microscope Experiment.
+
+    Whereas the IEMImage model provides an interface for a single electron
+    microscope image object, the IEMExperiment interface represents a
+    collection of Electron Microscope images coupled with one or more
+    external paramters. Together this collection of data represents a
+    spectroscopic data set.
+
+    Examples
+    --------
+    A stack of Electron Microscope images collected from the same sample region
+    each at a different value of incident electron Energy, parameterized by
+    the voltage, V, forms an I(V) data set. Here the data of interest is how
+    the image intensity, I, changes as a function of the external parameter, V.
+    """
+
+    #: Name identifying this experiment
+    name = Str
+
+    #: String indicating the type of this experiment
+    #: See please.constants.SUPPORTED_DATA_TYPES
+    #: Default: 'LEEM'
+    experiment_type = Enum('LEEM', SUPPORTED_EXPERIMENT_TYPES)
+
+    #: String indicating the type of data for this experiment
+    data_type = Enum('RAW', SUPPORTED_DATA_TYPES)
+
+    #: String indicating the file extension for the data for this experiment
+    file_format = Str
+
+    #: String indicating the absolute path to the directory containing data
+    data_path = Str
+
+    #: Integer indicating the number of bits per pixel in each data file
+    bit_size = Int
+
+    #: String indicating the byte-ordering for data for this experiment
+    byte_order = Enum('L', ENDIAN_STRINGS)
+
+    #: Tuple of integers indicating the height, width for each image
+    image_parameters = Tuple()
+
+    #: External parameter defining the fundamental relationship for this data
+    external_parameter_name = Enum('Energy', EXTERNAL_PARAMETERS)
+
+    #: Numeric data for external parameter
+    external_parameter = Array()
diff --git a/please/ui/__init__.py b/please/ui/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/poetry.lock b/poetry.lock
index 198bd43e..3593cbb1 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -827,6 +827,19 @@ ipython-genutils = "*"
 [package.extras]
 test = ["pytest"]
 
+[[package]]
+name = "traits"
+version = "6.2.0"
+description = "Observable typed attributes for Python classes"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+docs = ["enthought-sphinx-theme", "Sphinx (>=2.1.0,!=3.2.0)"]
+examples = ["numpy", "pillow"]
+test = ["cython", "flake8", "flake8-ets", "mypy", "numpy", "pyface", "pyside2", "setuptools", "Sphinx (>=2.1.0,!=3.2.0)", "traitsui"]
+
 [[package]]
 name = "typing-extensions"
 version = "3.10.0.0"
@@ -877,7 +890,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt
 [metadata]
 lock-version = "1.1"
 python-versions = ">=3.7,<3.10"
-content-hash = "99888dad1bf80d99797a842e61185746119ef06cd91085da88c11c74eb8af677"
+content-hash = "381b4071ed07935d6c498912660ddb723511b0473f3bdf4a063129ad76526b86"
 
 [metadata.files]
 appnope = [
@@ -1453,6 +1466,29 @@ traitlets = [
     {file = "traitlets-5.0.5-py3-none-any.whl", hash = "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426"},
     {file = "traitlets-5.0.5.tar.gz", hash = "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396"},
 ]
+traits = [
+    {file = "traits-6.2.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:7f5569ae105e9b6814027143f0f863a9ae92513f71cef509dab39709ca3f1503"},
+    {file = "traits-6.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0a27b4a836907b893b747c16bd6395d85e31d145e4464183b1eb8a281294165e"},
+    {file = "traits-6.2.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:9fb6c7bdb95e7750a8a5c3ff09f5b51db0306a2fea5d108ecfdb800756fc6a6e"},
+    {file = "traits-6.2.0-cp36-cp36m-win32.whl", hash = "sha256:e6a59af6da6f2bb8326d548be7f3f36d923134fa76b8ce99569cc0444b40b936"},
+    {file = "traits-6.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:edb475bee3c84e90c198d1fbbaed8de74caf75327907b8df79246dcdef96db88"},
+    {file = "traits-6.2.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:157b47dd3d1b5855c17f7e5fa5bdc76b553f26e8e671625128beb1d11c546f7c"},
+    {file = "traits-6.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d68f6713f4cf53ff5db7f9670764682f8ed4cb79661b82506f268e1cd4e76e4"},
+    {file = "traits-6.2.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:2934ce9c02d233d04cffd97d166f832da526a881f49989237c43d2a65e715095"},
+    {file = "traits-6.2.0-cp37-cp37m-win32.whl", hash = "sha256:a600c53ecbcd0fcc7942d21218e2eeb232d20db466bbb0fd04f4ac3a9134ea9e"},
+    {file = "traits-6.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3c2e3fdc8f9b50055a6793e98f402ff79386ac0a0b1db50f662df54b5331d182"},
+    {file = "traits-6.2.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8b5f6df6ce01ed9d12cab53b33fdfee52af220e7be55a2614764d7d889bed510"},
+    {file = "traits-6.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:f42dd271169a6354ddbe1dc7b907a893a22ef594f9670dcb35b13979b6141804"},
+    {file = "traits-6.2.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:5e161352cf1fea7248f09e68bbb8c8ca6996482c8657f83da19a9e461829b0c3"},
+    {file = "traits-6.2.0-cp38-cp38-win32.whl", hash = "sha256:1077a97cb2f89a713b2bafd6d7800b2e623629e742e35bad96fd0d808b7011cd"},
+    {file = "traits-6.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:9caf7edcde0d000c96971a719091f69d5f00fd6f04d9515f6b7ed20342b9032d"},
+    {file = "traits-6.2.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:71e78dec4ffe1ae0073fa1d96983a9a0fa43249f679c1cb8e3354d4ed6cd4d7c"},
+    {file = "traits-6.2.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:ad91cf6f8c5452febbc0087b6dd2c20581bdce695f52619826df9b345a02324b"},
+    {file = "traits-6.2.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:7e080c5916d99bd9980a30cd6b4798c7b966d98f090c907dbf243cf5b3548899"},
+    {file = "traits-6.2.0-cp39-cp39-win32.whl", hash = "sha256:9788017f02ba194591db84fce28eb83fbd910dd784cb98ae558c3305c9702914"},
+    {file = "traits-6.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:6d8bad9e61a6c7eb282d6d60c6158fbfe9b22c6857a793affdf8b75a50fcd373"},
+    {file = "traits-6.2.0.tar.gz", hash = "sha256:16fa1518b0778fd53bf0547e6a562b1787bf68c8f6b7995a13bd1902529fdb0c"},
+]
 typing-extensions = [
     {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"},
     {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"},
diff --git a/pyproject.toml b/pyproject.toml
index 470ca4d6..832f9892 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,6 +14,7 @@ PyQt6 = "^6.1.0"
 PyYAML = "^5.4.1"
 pyqtgraph = "^0.12.1"
 pendulum = "^2.1.2"
+traits = "^6.2.0"
 
 [tool.poetry.dev-dependencies]
 ipython = "^7.23.1"
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 00000000..d68df92b
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,2 @@
+[flake8]
+exclude = Tests,TestData,source
\ No newline at end of file


Test patch:
diff --git a/please/io/tests/__init__.py b/please/io/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/please/io/tests/data/20141023_01_100.dat b/please/io/tests/data/20141023_01_100.dat
new file mode 100755
index 00000000..9c1900af
Binary files /dev/null and b/please/io/tests/data/20141023_01_100.dat differ
diff --git a/please/io/tests/data/20141023_01_100.png b/please/io/tests/data/20141023_01_100.png
new file mode 100644
index 00000000..79f293fa
Binary files /dev/null and b/please/io/tests/data/20141023_01_100.png differ
diff --git a/please/io/tests/test_readers.py b/please/io/tests/test_readers.py
new file mode 100644
index 00000000..1c92a617
--- /dev/null
+++ b/please/io/tests/test_readers.py
@@ -0,0 +1,137 @@
+""" Unittests for image I/O """
+
+import os
+import pkg_resources
+from unittest import TestCase
+
+import numpy as np
+
+from please.io.readers import read_image_data, read_raw_data, _get_dtype_string
+
+
+class TestImageFileIO(TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.raw_data_file = pkg_resources.resource_filename(
+            'please.io.tests',
+            os.path.join('data', '20141023_01_100.dat')
+        )
+        cls.img_file = pkg_resources.resource_filename(
+            'please.io.tests',
+            os.path.join('data', '20141023_01_100.png')
+        )
+
+    def test__get_dtype_string(self):
+        # Given
+        bits = 8
+        byteorder = 'L'
+        expected = '<u1'
+
+        # When
+        format_string = _get_dtype_string(bits, byteorder)
+
+        # Then
+        self.assertEqual(format_string, expected)
+
+        # Given
+        bits = 8
+        byteorder = 'B'
+        expected = '>u1'
+
+        # When
+        format_string = _get_dtype_string(bits, byteorder)
+
+        # Then
+        self.assertEqual(format_string, expected)
+
+        # Given
+        bits = 16
+        byteorder = 'L'
+        expected = '<u2'
+
+        # When
+        format_string = _get_dtype_string(bits, byteorder)
+
+        # Then
+        self.assertEqual(format_string, expected)
+
+        # Given
+        bits = 16
+        byteorder = 'B'
+        expected = '>u2'
+
+        # When
+        format_string = _get_dtype_string(bits, byteorder)
+
+        # Then
+        self.assertEqual(format_string, expected)
+
+    def test__get_dtype_string_raises_value_error(self):
+        # Given
+        bits = 32
+        byteorder = 'L'
+
+        # When
+        with self.assertRaisesRegex(ValueError, 'Unsupported bit size'):
+            _get_dtype_string(bits, byteorder)
+
+        # Given
+        bits = 16
+        byteorder = 'ABC123'
+
+        # When
+        with self.assertRaisesRegex(ValueError, 'Unsupported byteorder'):
+            _get_dtype_string(bits, byteorder)
+
+    def test_read_image_data(self):
+        # Given
+        expected_shape = (600, 592)
+
+        # When
+        data = read_image_data(self.img_file)
+
+        # Then
+        self.assertIsInstance(data, np.ndarray)
+        self.assertEqual(data.shape, expected_shape)
+
+    def test_read_raw_data(self):
+        # Given
+        bits_per_pixel = 16
+        byteorder = 'L'
+        height = 600
+        width = 592
+        expected_shape = (height, width)
+
+        # When
+        data = read_raw_data(
+            self.raw_data_file,
+            height=height,
+            width=width,
+            bits_per_pixel=bits_per_pixel,
+            byteorder=byteorder
+        )
+
+        # Then
+        self.assertIsInstance(data, np.ndarray)
+        self.assertEqual(data.shape, expected_shape)
+
+    def test_read_raw_data_raises_for_bad_params(self):
+        # Given
+        bits_per_pixel = 16
+        byteorder = 'L'
+        height = 6000  # Wrong height
+        width = 5920  # Wrong width
+        expected_error = " Image parameters do not match the data file."
+        # When
+        with self.assertRaises(ValueError) as ctx:
+            read_raw_data(
+                self.raw_data_file,
+                height=height,
+                width=width,
+                bits_per_pixel=bits_per_pixel,
+                byteorder=byteorder
+            )
+
+        # Then
+        self.assertIn(expected_error, str(ctx.exception))
diff --git a/please/io/tests/test_utils.py b/please/io/tests/test_utils.py
new file mode 100644
index 00000000..953561f6
--- /dev/null
+++ b/please/io/tests/test_utils.py
@@ -0,0 +1,62 @@
+""" Unit tests for File I/O helper functions """
+
+import os
+import pkg_resources
+from unittest import TestCase
+
+from please.io.utils import get_file_extension, is_image_file, is_raw_file
+
+
+class TestIOUtils(TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.raw_data_file = pkg_resources.resource_filename(
+            'please.io.tests',
+            os.path.join('data', '20141023_01_100.dat')
+        )
+        cls.img_file = pkg_resources.resource_filename(
+            'please.io.tests',
+            os.path.join('data', '20141023_01_100.png')
+        )
+        cls.no_extension = '/home/user/some_other_file'
+
+    def test_get_file_extension(self):
+        # Given
+        expected = '.dat'
+
+        # When
+        ext = get_file_extension(self.raw_data_file)
+
+        # Then
+        self.assertEqual(ext, expected)
+
+        # Given
+        expected = '.png'
+
+        # When
+        ext = get_file_extension(self.img_file)
+
+        # Then
+        self.assertEqual(ext, expected)
+
+        # Given
+        expected = ''
+
+        # When
+        ext = get_file_extension(self.no_extension)
+
+        # Then
+        self.assertEqual(ext, expected)
+
+    def test_is_image_file(self):
+        # Then
+        self.assertTrue(is_image_file(self.img_file))
+        self.assertFalse(is_image_file(self.raw_data_file))
+        self.assertFalse(is_image_file(self.no_extension))
+
+    def test_is_raw_file(self):
+        # Then
+        self.assertTrue(is_raw_file(self.raw_data_file))
+        self.assertFalse(is_raw_file(self.img_file))
+        self.assertFalse(is_raw_file(self.no_extension))
diff --git a/please/model/tests/__init__.py b/please/model/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/please/model/tests/test_images.py b/please/model/tests/test_images.py
new file mode 100644
index 00000000..bf6b02b3
--- /dev/null
+++ b/please/model/tests/test_images.py
@@ -0,0 +1,34 @@
+""" Unit tests for image models """
+
+from unittest import TestCase
+
+import numpy as np
+
+from please.model.image import LEEDImage, LEEMImage
+
+
+class TestIEMImage(TestCase):
+
+    def setUp(self):
+        self.height = 600
+        self.width = 592
+        self.fake_data = np.zeros((self.height, self.width))
+        self.leem = LEEMImage(data=self.fake_data)
+        self.leed = LEEDImage(data=self.fake_data)
+        self.images = [self.leed, self.leem]
+
+    def test__get_width(self):
+        for img in self.images:
+            self.assertEqual(img.width, self.width)
+
+    def test__get_height(self):
+        for img in self.images:
+            self.assertEqual(img.height, self.height)
+
+    def test__get_shape(self):
+        for img in self.images:
+            self.assertEqual(img.shape, (self.height, self.width))
+
+    def test_image_type(self):
+        self.assertEqual(self.leem.image_type, 'LEEM')
+        self.assertEqual(self.leed.image_type, 'LEED')