Repository: myokit/myokit (language: python)

Issue:
Improve conversion to data log in ephys formats
- ~ABF: Replace different methods with a single one with switches~
- Make sweep joining optional
- Use 0. etc naming for sweeps when not joined
- Use channel names in log
- Similar for ATF
- Similar for WCP

Full to-do in #985


Discussion / hints:
Grepping for datalog vs data_log shows very little public API using either:

```
./myokit/tests/test_datablock.py:    def test_from_data_log(self):
./myokit/tests/test_formats_wcp.py:    def test_data_log_conversion(self):
[michael@localhost myokit]$ grep "datalog" ./myokit/ -Ir
./myokit/gui/datalog_viewer.py:#        'icon-datalog-viewer.ico',
./myokit/gui/datalog_viewer.py:#        'icon-datalog-viewer-16.xpm',
./myokit/gui/datalog_viewer.py:#        'icon-datalog-viewer-24.xpm',
./myokit/gui/datalog_viewer.py:#        'icon-datalog-viewer-32.xpm',
./myokit/gui/datalog_viewer.py:#        'icon-datalog-viewer-48.xpm',
./myokit/gui/datalog_viewer.py:#        'icon-datalog-viewer-64.xpm',
./myokit/gui/datalog_viewer.py:#        'icon-datalog-viewer-96.xpm',
./myokit/gui/datalog_viewer.py:#        'icon-datalog-viewer-128.xpm',
./myokit/gui/datalog_viewer.py:#        'icon-datalog-viewer-256.xpm',
./myokit/gui/datalog_viewer.py:            '.csv': self.load_datalog,
./myokit/gui/datalog_viewer.py:            '.zip': self.load_datalog,
./myokit/gui/datalog_viewer.py:    def load_datalog(self, filename):
./myokit/_bin/install-lin/myokit-datalog-viewer.desktop:#Icon=<?=icons?>/icon-datalog-viewer.png
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-1-empty.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-2-windows.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-3-old-mac.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-4-empty-lines.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-5-semicolons.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-6-open-string.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-7-empty-lines-2.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-8-unquoted-header.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-9-double-quoted-header.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-10-just-spaces.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-11-just-a-semicolon.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-12-bad-header.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-13-header-with-empty-1.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-14-header-with-empty-2.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-15-header-with-empty-3.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-16-wrong-columns-in-data.csv')
./myokit/tests/test_datalog.py:        path = os.path.join(DIR_IO, 'datalog-17-non-float-data.csv')
./myokit/tests/test_simulation_cvodes.py:        # Tested against offline method in test_datalog.py
./myokit/__init__.py:from ._datalog import (     # noqa
./myokit/__main__.py:    place_file(path, 'myokit-datalog-viewer.desktop', True)
./myokit/__main__.py:    import myokit.gui.datalog_viewer
./myokit/__main__.py:    myokit.gui.run(myokit.gui.datalog_viewer.DataLogViewer, *filenames)
```

Public:
- myokit.DataLog (two words)
- Module myokit.gui.datalog_viewer (one word)
- DataLogViewer (two words)
- Some filenames use datalog (one word)
Sometimes just called "log":
- DataBlock1d.from_log & to_log
- DataBlock2d.from_log & to_log
- myokit.prepare_log

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 339873aac..d27a21635 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,10 +4,19 @@ This page lists the main changes made to Myokit in each release.
 
 ## Unreleased
 - Added
-- Changed
+  - [#985](https://github.com/myokit/myokit/pull/985) The `AbfFile` and `WcpFile` now implement a shared `SweepSource` interface.
+  - [#985](https://github.com/myokit/myokit/pull/985) Added a method `AtfFile.path` that returns the full path.
+  - [#985](https://github.com/myokit/myokit/pull/985) Added a method `WcpFile.info` that returns a string version of the file's meta data, and a method `WcpFile.sample_count` that returns the number of samples per channel.
+Changed
+  - [#985](https://github.com/myokit/myokit/pull/985) The interface to the `AbfFile` class has changed significantly, and in a backwards incompatible ways. Please see the documentation for further details.
 - Deprecated
-- Removed
+  - [#985](https://github.com/myokit/myokit/pull/985) The method `AtfFile.myokit_log` is deprecated in favor of `AtfFile.log`.
+  - [#985](https://github.com/myokit/myokit/pull/985) The method `WcpFile.myokit_log` is deprecated in favor of `WcpFile.log`.
+  - [#985](https://github.com/myokit/myokit/pull/985) The methods `WcpFile.channels` and `WcpFile.records` are deprecated in favor of `WcpFile.channel_count` and `WcpFile.record_count`.
+  - [#985](https://github.com/myokit/myokit/pull/985) The method `WcpFile.plot` is deprecated in favor of `WcpFile.matplotlib_figure`.
 - Fixed
+  - [#985](https://github.com/myokit/myokit/pull/985) `AbfFile.filename` now returns the filename, not the full path.
+  - [#985](https://github.com/myokit/myokit/pull/985) `AtfFile.filename` now returns the filename, not the full path.
 
 ## [1.35.0] - 2023-06-21
 - Added
diff --git a/docs/source/api_formats/index.rst b/docs/source/api_formats/index.rst
index e0cc17565..6715b7686 100644
--- a/docs/source/api_formats/index.rst
+++ b/docs/source/api_formats/index.rst
@@ -136,3 +136,16 @@ internally to write expressions in Python format with or without NumPy support.
 
 .. autofunction:: numpy_writer
 
+Data formats
+============
+
+In addition to model and protocol formats, Myokit can read some
+electrophysiology formats. Support for advanced features is usually not
+implemented, but basic access is provided to ABF, WCP and HEKA files.
+Classes for these three formats share a common API, and make use of the
+:class`SweepSource` class shown below.
+
+.. currentmodule:: myokit.formats
+
+.. autoclass:: SweepSource
+
diff --git a/docs/source/api_index/myokit.formats.rst b/docs/source/api_index/myokit.formats.rst
index bdf3c207f..8ce4dde69 100644
--- a/docs/source/api_index/myokit.formats.rst
+++ b/docs/source/api_index/myokit.formats.rst
@@ -15,6 +15,7 @@ myokit.formats
 - :meth:`myokit.formats.register_external_ewriter`
 - :meth:`myokit.formats.register_external_importer`
 - :meth:`myokit.formats.register_external_exporter`
+- :class:`myokit.formats.SweepSource`
 - :class:`myokit.formats.TemplatedRunnableExporter`
 
 myokit.formats.ansic
diff --git a/myokit/_model_api.py b/myokit/_model_api.py
index 1d343a5c6..f60743903 100644
--- a/myokit/_model_api.py
+++ b/myokit/_model_api.py
@@ -1385,9 +1385,7 @@ def evaluate_derivatives(
     def eval_state_derivatives(
             self, state=None, inputs=None, precision=myokit.DOUBLE_PRECISION,
             ignore_errors=False):
-        """
-        Deprecated alias of :meth:`evaluate_derivatives()`.
-        """
+        """ Deprecated alias of :meth:`evaluate_derivatives()`. """
         # Deprecated since 2021-08-03
         import warnings
         warnings.warn(
diff --git a/myokit/_sim/openclsim.py b/myokit/_sim/openclsim.py
index 6964e7fa6..62cda0b2e 100644
--- a/myokit/_sim/openclsim.py
+++ b/myokit/_sim/openclsim.py
@@ -780,8 +780,8 @@ def monodomain_conductance(self, chi, k, D, dx, A=1):
         (see the argument list below for the meaning of the variables).
 
         This can be equated to Myokit's diffusion current, but only if we
-        assume **zero-flux boundary conditions**, a **regularly spaced grid**,
-        and **no spatial heterogeneity in D** (or g).
+        assume *zero-flux boundary conditions*, a *regularly spaced grid*,
+        and *no spatial heterogeneity in D* (or g).
 
         With these assumptions, we can use finite differences to find::
 
@@ -791,13 +791,13 @@ def monodomain_conductance(self, chi, k, D, dx, A=1):
         respect to unit membrane area.
         For models with currents normalized to area this is unproblematic, but
         to convert to models with unnormalized currents this means we have
-        added the further assumption that **each node contains some fixed
-        amount of membrane**, determined by an area A::
+        added the further assumption that *each node contains some fixed
+        amount of membrane*, determined by an area A::
 
             g = (1 / chi) * (k / (k + 1)) * D * (1 / dx^2) * A
 
         This equation can also be applied in two dimensions, but only if
-        **we assume that the conductivity matrix is diagonal**, in which case::
+        *we assume that the conductivity matrix is diagonal*, in which case::
 
             gx = (1 / chi) * (k / (k + 1)) * Dx * (1 / dx^2) * A
             gy = (1 / chi) * (k / (k + 1)) * Dy * (1 / dy^2) * A
diff --git a/myokit/formats/__init__.py b/myokit/formats/__init__.py
index f04dabe09..b0187414c 100644
--- a/myokit/formats/__init__.py
+++ b/myokit/formats/__init__.py
@@ -664,3 +664,76 @@ def register_external_ewriter(name, ewriter_class):
             _scan_for_internal_formats()
         _EWRITERS[name] = ewriter_class
 
+
+class SweepSource:
+    """
+    Interface for classes that provide time-series data organised into *sweeps*
+    (or *records*) and *channels* (or *traces*).
+
+    Each sweep contains the same number of channels, and each channel is a 1d
+    array of equal length.
+
+    Two common ways to represent the data are (1) overlaid::
+
+        time        n_t data points
+        sweep[0],   n_c channels, each with n_t data points
+        sweep[1],
+        ...
+        sweep[n_s]
+
+    or (2) joined into a single time series::
+
+        time                            n_s*n_t data points
+        sweep[0] + sweep[1] + ...       n_c channels, each with n_s*n_t points
+
+    The :class:`SweepSource` interface defines methods to get the number of
+    sweeps and channels, the names of the channels, and the data stored in
+    channels either as numpy arrays or in a :class:`myokit.DataLog`.
+    """
+    def channel(self, channel_id, join_sweeps=False):
+        """
+        Returns the data for a single channel, identified by integer or string
+        ``channel_id``.
+
+        With ``join_sweeps=False``, the data is returned as a tuple of numpy
+        arrays ``time, sweep_0, sweep_1, ...`` where ``time`` is the time
+        corresponding to the first sweep.
+
+        If ``join_sweeps=True`` the sweeps are joined together, and a tuple
+        ``time, values`` is returned.
+        """
+        raise NotImplementedError
+
+    def channel_count(self):
+        """ Returns the number of channels. """
+        raise NotImplementedError
+
+    def channel_names(self):
+        """ Returns a list of names for each channel. """
+        raise NotImplementedError
+
+    def log(self, join_sweeps=False, use_names=False, channels=None):
+        """
+        Returns a :class:`myokit.DataLog` containing the data from all
+        channels or the subset specified by ``channels``.
+
+        Sweeps can be joined together using ``join_sweeps=True``, and log
+        entries can use systematic names (``use_names=False``) or names
+        specified in the original source (``use_names=True``).
+
+        If ``join_sweeps=False``, the data for each sweep will be returned
+        separately, so that the log will have entries such as ``0.0.channel``,
+        ``1.0.channel``, and ``2.0.channel`` for the first three sweeps and the
+        first channel. Alternatively, if ``use_names=True`` and the first
+        channel is called "IN #0" its entries will be ``0.IN #0`` for the first
+        sweep, then ``1.IN #0`` etc.
+
+        To return only a subset of channels pass in a list of channel ids
+        (names or integers) as ``channels``.
+        """
+        raise NotImplementedError
+
+    def sweep_count(self):
+        """ Returns the number of sweeps. """
+        raise NotImplementedError
+
diff --git a/myokit/formats/axon/_abf.py b/myokit/formats/axon/_abf.py
index 683a07eb5..3b816d8d3 100755
--- a/myokit/formats/axon/_abf.py
+++ b/myokit/formats/axon/_abf.py
@@ -140,21 +140,23 @@
 # was also used: http://neurodata.hg.sourceforge.net/hgweb/neurodata/neurodata/
 #------------------------------------------------------------------------------
 import datetime
-import logging
 import os
 import struct
-import traceback
+import warnings
 
 import numpy as np
 
 from collections import OrderedDict
 
+import myokit
+import myokit.formats
+
 
 # Encoding for text parts of files
 _ENC = 'latin-1'
 
 
-class AbfFile:
+class AbfFile(myokit.formats.SweepSource):
     """
     Represents a read-only Axon Binary Format file (``.abf``), stored at the
     location pointed to by ``filepath``.
@@ -164,37 +166,61 @@ class AbfFile:
     assumption can be overruled by setting the ``is_protocol_file`` argument
     to either ``True`` or ``False``.
 
-    The "data" in an AbfFile is recorded (analog-to-digital) data. Any output
-    signals from the amplifier to the cell (digital-to-analog) are termed the
-    "protocol".
-
-    Data in AbfFiles is recorded in episodes called "sweeps". Each sweep
-    contains the data from all recorded channels. The number of channels is
-    constant: channel 1 in sweep 1 contains data recorded from the same source
-    as channel 1 in sweep 10.
+    Data in ABF files is recorded in *sweeps*, where each sweep contains one or
+    more *channels* with recorded (A/D) data. In addition, zero or more output
+    channels may be defined (also called "protocol" or D/A channels). Where
+    possible, the :class`AbfFile` class will convert these embedded protocols
+    to time series and include them as additional channels.
 
-    The data in an ``AbfFile`` can be read by iterating over it::
+    For example::
 
-        f = AbfFile('some_file.abf')
-        for sweep in f:
+        abf = AbfFile('some_file.abf')
+        for sweep in abf:
             for channel in sweep:
-                plt.plot(channel.times(), channel.values())
+                print(channel.name())
+            break
+
+    might show
+
+        IN 0
+        10xVm
+        Cmd 0
 
-    Similarly, protocol data can be accessed using::
+    where the first two channels are A/D channels and the final one is a
+    converted D/A channel. *Some* A/D channels (see below) can be converted to
+    :class:`myokit.Protocol` objects, using the :meth:`protocol()` method.
 
-        for sweep in f.protocol():
+    Sweeps and channels are represented by :class:`Sweep` and :class:`Channel`
+    objects respectively, and these can be used to obtain the data from a
+    file::
+
+        abf = AbfFile('some_file.abf')
+        for sweep in abf:
             for channel in sweep:
-                plt.plot(channel.times(), channel.values())
+                plot(channel.times(), channel.values())
+
+    In addition the ``AbfFile`` class implements the
+    :class`myokit.formats.SweepSource` interface.
+
+    Support notes:
+
+    - Protocol (D/A) conversion is only supported for "episodic stimulation"
+      without "user lists".
+    - Protocols with more than one sampling rate are not supported.
+    - The ABF format is not (openly) well documented, so there will be several
+      other issues and shortcomings.
 
-    Note that the number of output ("protocol") channels need not equal the
-    number of input ("data") channels.
+    Arguments:
 
-    Because each channel can have a different sampling rate, AbfFile data is
-    not one-on-one compatible with myokit Simulation logs. To obtain a
-    :class:`myokit.DataLog` version of the file's data, use:meth:`myokit_log`.
+    ``filepath``
+        The path to load the data from. Data will be read into memory
+        immediately upon construction.
+    ``is_protocol_file``
+        If set to ``True``, no attempt to read A/D data will be made and only
+        D/A "protocol" information will be read. If left at its default value
+        of ``None`` files with the extension ``.pro`` will still be recognized
+        as protocol files.
 
-    In some cases, a myokit protocol can be created from a stored stimulus
-    protocol. To do this, use the method:meth:`myokit_protocol`.
     """
     def __init__(self, filepath, is_protocol_file=None):
         # The path to the file and its basename
@@ -204,15 +230,16 @@ def __init__(self, filepath, is_protocol_file=None):
 
         # Abf format version
         self._version = None
+        self._version_str = None
 
         # Protocol info
         self._epoch_functions = None
-        self._numberOfTrials = None
-        self._trialStartToStart = None
-        self._runsPerTrial = None
-        self._runStartToStart = None
-        self._sweepsPerRun = None
-        self._sweepStartToStart = None
+        self._number_of_trials = None
+        self._trial_start_to_start = None
+        self._runs_per_trial = None
+        self._run_start_to_start = None
+        self._sweeps_per_run = None
+        self._sweep_start_to_start = None
 
         # Read as protocol file yes?
         if is_protocol_file is None:
@@ -227,308 +254,376 @@ def __init__(self, filepath, is_protocol_file=None):
         self._datetime = self._read_datetime()
 
         # Number of channels, sampling rate (Hz) and acquisition mode
+        # Note: Number of AD channels will be set to 0 if this is a
+        #       protocol-only file
+        # Note: Number of DA channels will be adjusted by _read_1_protocol
         if self._version < 2:
-            self._nc = self._header['nADCNumChannels']
-            self._rate = 1e6 / (self._header['fADCSampleInterval'] * self._nc)
+            self._nADC = int(self._header['nADCNumChannels'])
+            self._nDAC = len(self._header['sDACChannelName'])
+            self._rate = 1e6 / (
+                self._header['fADCSampleInterval'] * self._nADC)
             self._mode = self._header['nOperationMode']
         else:
-            self._nc = self._header['sections']['ADC']['length']
+            self._nADC = int(self._header['sections']['ADC']['length'])
+            self._nDAC = int(self._header['sections']['DAC']['length'])
             self._rate = 1e6 / self._header['protocol']['fADCSequenceInterval']
             self._mode = self._header['protocol']['nOperationMode']
         if self._mode not in acquisition_modes:
-            raise NotImplementedError(
-                'Unknown acquisition mode: ' + str(mode))
+            raise NotImplementedError(f'Unknown acquisition mode: {mode}')
+        if self._is_protocol_file:
+            self._nADC = 0
 
         # Conversion factors for integer data in the channels
         self._adc_factors = None
         self._adc_offsets = None
         self._set_conversion_factors()
 
-        # The protocol used (a list of sweeps)
+        # Channel names: will be set on first call to channel_names()
+        self._channel_names = None
+
+        # Read the protocol info, and attempt to reconstruct the D/A signal as
+        # as list of sweeps. MUST be called before _read_ad_data.
+        self._sweeps = []
         try:
-            self._protocol = self._read_protocol()
-        except Exception:   # pragma: no cover
+            self._read_1_protocol()
+        except Exception:  # pragma: no cover
             # This is not something we _want_ to happen, so if we have test
             # cases that trigger this error they should be resolved. At the
             # same time, if it happens to a user we want it to "sort-of work"
             # (an experimental rather than a production setting)
-            log = logging.getLogger(__name__)
-            log.warning('Unable to read protocol from ' + self._filepath)
-            log.warning(traceback.format_exc())
-            self._protocol = []
+            warnings.warn(f'Unable to read protocol from {self._filepath}')
 
-        # The measured data as a list of sweeps
-        if self._is_protocol_file:
-            self._sweeps = []
-        else:
-            self._sweeps = self._read_sweeps()
+        # Create sweeps and add the AD data
+        if self._nADC:
+            self._read_2_data()
 
-    def data_channels(self):
-        """
-        Returns the number of channels in this file's sweeps.
-        """
-        if len(self._sweeps) == 0:  # pragma: no cover
-            return 0
-        return len(self._sweeps[0])
+        # Set final number of channels
+        self._nc = self._nADC + self._nDAC
 
-    def extract_channel(self, channel=0, join=False):
-        """
-        Extracts a selected data ``channel`` and returns its data in a tuple
-        containing::
+        # Store mapping from channel name to channel index
+        self._channel_names = {}
+        for sweep in self._sweeps:
+            for i, channel in enumerate(sweep):
+                self._channel_names[channel.name()] = i
+            break
+
+    def __getitem__(self, key):
+        return self._sweeps[key]
+
+    def __iter__(self):
+        return iter(self._sweeps)
 
-             A numpy array representing time
-             A numpy array representing the first sweep
-             A numpy array representing the second sweep
-             ...
+    def __len__(self):
+        return len(self._sweeps)
 
-        An optional argument ``join=True`` can be set to join all sweeps
-        together and return just two arrays, one for time and one for data.
+    def ad_channel_count(self):
+        """ Returns the number of A/D channels in this file. """
+        return self._nADC
 
-        If no data is available, ``None`` is returned.
-        """
-        if len(self._sweeps) == 0:  # pragma: no cover
-            return None
+    def channel(self, channel_id, join_sweeps=False):
+        # Docstring in SweepSource
+        channel_id = self._channel_id(channel_id)
 
-        # Join all sweeps
-        if join:
+        if join_sweeps:
+            # Join sweeps
             time, data = [], []
-            t = np.array(self._sweeps[0][channel].times())
+            t = self._sweeps[0][channel_id].times()
             for i, sweep in enumerate(self._sweeps):
-                time.append(t + i * self._sweepStartToStart)
-                data.append(np.array(sweep[channel].values()))
+                time.append(t + i * self._sweep_start_to_start)
+                data.append(sweep[channel_id].values())
             return (np.concatenate(time), np.concatenate(data))
 
-        # Standard reading
-        data = []
-        data.append(np.array(self._sweeps[0][channel].times()))
-        for sweep in self._sweeps:
-            data.append(np.array(sweep[channel].values()))
-        return tuple(data)
+        else:
+            # Return individual sweeps
+            out = []
+            out.append(np.array(self._sweeps[0][channel_id].times()))
+            for sweep in self._sweeps:
+                out.append(np.array(sweep[channel_id].values()))
+            return tuple(out)
+
+    def channel_count(self):
+        # Docstring in SweepSource
+        return self._nADC + self._nDAC
+
+    def _channel_id(self, channel_id, must_be_da=False):
+        """ Checks an int or str channel id and returns a valid int. """
+        if len(self._sweeps) == 0:  # pragma: no cover
+            raise KeyError(f'Channel {channel_id} not found (empty file).')
 
-    def extract_channel_as_myokit_log(self, channel=0):
-        """
-        Extracts the given data channel and returns it as a myokit
-        DataLog.
+        # Handle string
+        if isinstance(channel_id, str):
+            int_id = self._channel_names[channel_id]  # Let KeyError go to user
+        else:
+            int_id = int(channel_id)  # TypeError for user
+            if int_id < 0 or int_id >= self._nc:
+                raise IndexError(f'channel_id out of range: {channel_id}')
+
+        # Check if this is a D/A protocol channel
+        if must_be_da and int_id < self._nADC:
+            raise ValueError(
+                'Given channel_id does not correspond to a D/A channel.')
+
+        return int_id
+
+    def channel_names(self):
+        # Docstring in SweepSource
+        return list(self._channel_names.keys())
+
+    def da_channel_count(self):
+        """ Returns the number of (non-empty) D/A channels in this file. """
+        return self._nDAC
 
-        The log will contain an entry "time" that contains the time vector.
-        Each sweep will be in an entry "0.sweep", "1.sweep", "2.sweep" etc.
+    def da_holding_level(self, channel_id):
         """
-        import myokit
-        log = myokit.DataLog()
-        if len(self._sweeps) == 0:  # pragma: no cover
-            return log
-        log.set_time_key('time')
-        log['time'] = np.array(self._sweeps[0][channel].times())
-        for k, sweep in enumerate(self._sweeps):
-            log['sweep', k] = np.array(sweep[channel].values())
-        return log
+        Returns the holding level used by the requested output channel of the
+        embedded protocol.
+
+        Arguments:
+
+        ``channel_id``
+            The channel to return a protocol for, as an integer index or a
+            string name. If an integer is given it must be equal to or greater
+            than the value returned by :meth:`ad_channel_count()`.
 
-    def filename(self):
         """
-        Returns this AbfFile's filename.
+        ida = self._channel_id(channel_id, must_be_da=True) - self._nADC
+        dinfo, einfo_exists, einfo = self._epoch_functions
+        return dinfo(ida, 'fDACHoldingLevel')
+
+    def da_steps(self, channel_id):
         """
-        return self._filepath
+        Returns the steps seen during a D/A protocol, for episodic stimulation
+        (not including the holding potential).
 
-    def __getitem__(self, key):
-        return self._sweeps.__getitem__(key)
+        For example, for a protocol that has holding level ``-120mV`` and
+        performs steps to ``-100mV``, then holding, then ``-80mV``, then
+        holding, and then ``-40mV`` the returned output will be::
+
+            ([-100, -80, -40])
+
+        For a more complicated protocol, where each step is followed by a step
+        down to ``-140mV``, the output would be::
+
+            ([-100, -80, -40], [-140, -140, -140])
+
+        Arguments:
+
+        ``channel_id``
+            The channel to return a protocol for, as an integer index or a
+            string name. If an integer is given it must be equal to or greater
+            than the value returned by :meth:`ad_channel_count()`.
 
-    def _get_conversion_factors(self, channel):
-        """
-        Returns the conversion factor and shift for the selected channel as a
-        tuple of floats ``(factor, shift)``.
         """
-        return self._adc_factors[channel], self._adc_offsets[channel]
+        ida = self._channel_id(channel_id, must_be_da=True) - self._nADC
+
+        # Get epoch functions
+        dinfo, einfo_exists, einfo = self._epoch_functions
+
+        # Create list of step lists
+        levels = []
+        for e in einfo(ida):
+            kind = e['type']
+            if kind not in epoch_types:
+                raise NotImplementedError(f'Unknown epoch type: {kind}')
+            if kind == EPOCH_DISABLED:
+                continue
+            elif kind == EPOCH_STEPPED:
+                levels.append([])
+            else:
+                raise NotImplementedError(
+                    'Unsupported epoch type: {epoch_types(kind)}')
+
+        # Gather steps
+        levels = tuple(levels)
+        for i in range(self._sweeps_per_run):
+            j = 0
+            for e in einfo(ida):
+                if e['type'] == EPOCH_STEPPED:
+                    levels[j].append(e['init_level'] + e['level_inc'] * i)
+                    j += 1
+        return levels
+
+    def filename(self):
+        """ Returns this AbfFile's filename. """
+        return self._filename
 
     def info(self, show_header=False):
         """
-        Returns a string with lots of info on this file.
+        Returns a multi-line string with meta data about this file.
 
         The optional argument ``show_header`` can be used to add the full
         header contents to the output.
         """
         out = []
 
-        # Show file info
+        # File info
         if self._is_protocol_file:
-            out.append('Axon Protocol File: ' + self._filename)
+            out.append(f'Axon Protocol File: {self._filename}')
         else:
-            out.append('Axon Binary File: ' + self._filename)
-        out.append('ABF Format version ' + str(self._version))
-        out.append('Recorded on: ' + str(self._datetime))
+            out.append(f'Axon Binary File: {self._filename}')
+        out.append(f'ABF Format version {self._version_str}')
+        out.append(f'Recorded on: {self._datetime}')
 
-        # Show protocol info
+        # AProtocol info
         out.append(
-            'Acquisition mode: ' + str(self._mode) + ': '
-            + acquisition_modes[self._mode])
-        if self._numberOfTrials:
+            f'Acquisition mode: {self._mode}: {acquisition_modes[self._mode]}')
+        if self._number_of_trials:
             out.append(
-                'Protocol set for ' + str(self._numberOfTrials)
-                + ' trials, spaced ' + str(self._trialStartToStart)
-                + 's apart.')
+                f'Protocol set for {self._number_of_trials} trials,'
+                f' spaced {self._trial_start_to_start}s apart.')
             out.append(
-                '    with ' + str(self._runsPerTrial)
-                + ' runs per trial, spaced ' + str(self._runStartToStart)
-                + 's apart.')
+                f'    with {self._runs_per_trial} runs per trial,'
+                f' spaced {self._run_start_to_start}s apart.')
             out.append(
-                '     and ' + str(self._sweepsPerRun)
-                + ' sweeps per run, spaced ' + str(self._sweepStartToStart)
-                + 's apart.')
+                f'     and {self._sweeps_per_run} sweeps per run,'
+                f' spaced {self._sweep_start_to_start}s apart.')
         else:   # pragma: no cover
             out.append('Protocol data could not be determined.')
-        out.append('Sampling rate: ' + str(self._rate) + ' Hz')
+        out.append(f'Sampling rate: {self._rate} Hz')
 
-        # Show channel info
+        # Channel info
         if self._sweeps:
-            for i, c in enumerate(self._sweeps[0]._channels):
-                out.append('Channel ' + str(i) + ': "' + c._name + '"')
+            # A/D channels
+            for i, c in enumerate(self._sweeps[0]._channels[:self._nADC]):
+                out.append(f'A/D Channel {i}: "{c._name}"')
                 if c._type:  # pragma: no cover
                     # Cover pragma: Don't have appropriate test file
-                    out.append('  Type: ' + type_mode_names[c._type])
-                out.append('  Unit: ' + c._unit.strip())
+                    out.append(f'  Type: {type_mode_names[c._type]}')
+                out.append(f'  Unit: {c._unit.strip()}')
                 if c._lopass:
-                    out.append('  Low-pass filter: ' + str(c._lopass) + ' Hz')
+                    out.append(f'  Low-pass filter: {c._lopass} Hz')
                 if c._cm:
-                    out.append('  Cm (telegraphed): ' + str(c._cm) + ' pF')
+                    out.append(f'  Cm (telegraphed): {c._cm} pF')
                 if c._rs:   # pragma: no cover
                     # Cover pragma: Don't have appropriate test file
-                    out.append('  Rs (telegraphed): ' + str(c._rs))
-
-        # Methods
-        def show_dict(name, d, tab=''):
-            m = max(0, 38 - len(tab) - int(0.1 + len(name) / 2))
-            out.append(tab + '-' * m + '  ' + name + '  ' + '-' * m)
-            for n, v in d.items():
-                n = str(n)
-                if type(v) == OrderedDict:
-                    show_dict(n, v, tab + '  ')
-                elif type(v) == list:
-                    show_list(n, v, tab)
-                else:
-                    out.append(tab + n + ': ' + str(v))
-            m = max(0, 80 - 2 * len(tab))
-            out.append(tab + m * '-')
-
-        def show_list(name, d, tab=''):
-            for index, item in enumerate(d):
-                n = name + '[' + str(index) + ']'
-                if type(item) == OrderedDict:
-                    show_dict(n, item, tab)
-                elif type(item) == list:    # pragma: no cover
+                    out.append(f'  Rs (telegraphed): {c._rs}')
+            # Reconstructed D/A channels
+            for i, c in enumerate(self._sweeps[0]._channels[self._nADC:]):
+                out.append(f'D/A Channel {i}: "{c._name}"')
+                if c._type:  # pragma: no cover
                     # Cover pragma: Don't have appropriate test file
-                    show_list(n, item, tab)
-                else:
-                    out.append(tab + n + ': ' + str(item))
+                    out.append('  Type: {type_mode_names[c._type]}')
+                out.append(f'  Unit: {c._unit.strip()}')
 
         # Show full header info
         if show_header:
             if self.strings:
-                show_dict('Strings', {'strings': self.strings})
-            show_dict('file header', self._header)
+                dict_to_string(out, 'Strings', {'strings': self.strings})
+            dict_to_string(out, 'file header', self._header)
+
         return '\n'.join(out)
 
+    def log(self, join_sweeps=False, use_names=False, channels=None):
+        # Docstring in SweepSource
+
+        # Select channels
+        if channels is None:
+            channels = list(range(self._nc))
+        else:
+            channels = [self._channel_id(i) for i in channels]
+
+        # Create log
+        log = myokit.DataLog()
+
+        # No data? Then return. Do this after channel check to tell user about
+        # invalid channel ids.
+        ns = len(self._sweeps)
+        if ns == 0:  # pragma: no cover
+            return log
+
+        # Get channel names
+        if use_names:
+            channel_names = [c.name() for c in self._sweeps[0]]
+        else:
+            channel_names = [f'{i}.channel' for i in range(self._nc)]
+        channel_names = [channel_names[i] for i in channels]
+
+        # Populate log
+        if join_sweeps:
+            # Join sweeps
+
+            # Set time
+            t = np.array(self._sweeps[0][0].times())
+            time = [t + i * self._sweep_start_to_start for i in range(ns)]
+            log['time'] = np.concatenate(time)
+
+            # Set channels
+            for channel, name in zip(channels, channel_names):
+                log[name] = np.concatenate(
+                    [sweep[channel].values() for sweep in self._sweeps])
+
+        else:
+            # Return individual sweeps
+            log['time'] = np.array(self._sweeps[0][0].times())
+            for i, sweep in enumerate(self._sweeps):
+                for channel, name in zip(channels, channel_names):
+                    log[name, i] = sweep[channel].values()
+
+        log.set_time_key('time')
+        return log
+
     def matplotlib_figure(self):
-        """
-        Creates and returns a matplotlib figure of this abf file's contents.
-        """
+        """ Creates and returns a matplotlib figure with this file's data. """
         import matplotlib.pyplot as plt
         f = plt.figure()
         plt.suptitle(self.filename())
 
-        # Show data channel
+        # Plot AD channels
         ax = plt.subplot(2, 1, 1)
         ax.set_title('Measured data')
         times = None
-        for sweep in self:
-            for channel in sweep:
+        for sweep in self._sweeps:
+            for channel in sweep[:self._nADC]:
                 if times is None:
                     times = channel.times()
                 plt.plot(times, channel.values())
 
-        # Show protocol channels
-        n = self.protocol_channels()
+        # Plot DA channels
+        n = self._nDAC
         ax = [plt.subplot(2, n, n + 1 + i) for i in range(n)]
-
-        for sweep in self.protocol():
-            times = None
-            for i, channel in enumerate(sweep):
-                if times is None:
-                    times = channel.times()
+        for sweep in self._sweeps:
+            for i, channel in enumerate(sweep[self._nADC:]):
                 ax[i].set_title(channel.name())
                 ax[i].plot(times, channel.values())
+
         return f
 
-    def myokit_log(self):
-        """
-        Converts the data in this ABF file to a:class:`myokit.DataLog` with an
-        entry for every channel. All sweeps will be joined together into a
-        single time series.
-
-        The log will contain an entry "time" that contains the time vector.
-        Channels will be stored using "0.ad", "1.ad" etc. for the recorded
-        (analog-to-digital) channels and "0.da", "1.da" etc. for the output
-        (digital-to-analog) channels.
+    def path(self):
+        """ Returns the path to the underlying ABF file. """
+        return self._filepath
+
+    def protocol(self, channel_id=None, ms=True):
         """
-        import myokit
-        log = myokit.DataLog()
-        if self._sweeps:
-            # Gather parts of time and channel vectors
-            time = []
-            ad_channels = []
-            da_channels = []
-            for i in range(self.data_channels()):
-                ad_channels.append([])
-            for i in range(self.protocol_channels()):
-                da_channels.append([])
-
-            # Add ad channels
-            for sweep in self:
-                for channel in sweep:
-                    time.append(channel.times())
-                    break
-                for i, channel in enumerate(sweep):
-                    ad_channels[i].append(channel.values())
+        Returns the embedded protocol from the D/A channel identified by
+        ``channel_id`` as a :class:`myokit.Protocol`.
 
-            # Add da channels
-            for sweep in self.protocol():
-                for i, channel in enumerate(sweep):
-                    da_channels[i].append(channel.values())
+        Only episodic stimulation is supported, without "user lists".
 
-            # Combine into time series, store in log
-            log['time'] = np.concatenate(time)
-            log.set_time_key('time')
-            for i, channel in enumerate(ad_channels):
-                log['ad', i] = np.concatenate(channel)
-            for i, channel in enumerate(da_channels):
-                log['da', i] = np.concatenate(channel)
+        By default all times are converted to milliseconds, but this can be
+        disabled by setting ``ms=False``.
 
-        return log
-
-    def myokit_protocol(self, channel=None, ms=True):
-        """
-        Returns a single channel from an embedded protocol as a
-        :class:`myokit.Protocol`. The channel to return is specified by setting
-        ``channel`` to the correct index.
+        Arguments:
 
-        Only works for episodic stimulation, without user lists.
+        ``channel``
+            The channel index to return (as an integer).
+        ``ms``
+            Set to ``False`` to disable conversion to milliseconds.
 
-        By default, all times are converted to milliseconds. To disable this
-        function, set ``ms=False``.
         """
-        import myokit
-
         # Only episodic stimulation is supported.
         if self._mode != ACMODE_EPISODIC_STIMULATION:  # pragma: no cover
-            return myokit.Protocol()
+            raise NotImplementedError(
+                'Only episodic stimulation is supported.')
 
-        # Check channel
-        if channel is None:
-            channel = 0
+        # Get D/A channel index
+        if channel_id is None:
+            ida = self._nADC
         else:
-            channel = int(channel)
+            ida = self._channel_id(channel_id, must_be_da=True) - self._nADC
 
         # User lists are not supported
         if self._version < 2:  # pragma: no cover
-            if self._header['nULEnable'][channel]:
+            if self._header['nULEnable'][ida]:
                 raise NotImplementedError('User lists are not supported.')
         else:   # pragma: no cover
             for userlist in self._header['listUserListInfo']:
@@ -540,23 +635,16 @@ def myokit_protocol(self, channel=None, ms=True):
         # Create protocol
         p = myokit.Protocol()
 
-        # Get epoch functions set by _read_protocol
         dinfo, einfo_exists, einfo = self._epoch_functions
         start = 0
         next_start = 0
         f = 1e3 if ms else 1
-        for iSweep in range(self._sweepsPerRun):
-
-            if not einfo_exists(channel):   # pragma: no cover
-                # Not sure if this can happen, if so, would need to update code
-                raise Exception('Missing protocol data')
-
-            for e in einfo(channel):
+        for i_sweep in range(self._sweeps_per_run):
+            for e in einfo(ida):
                 kind = e['type']
 
                 if kind not in epoch_types:
-                    raise NotImplementedError(
-                        'Unknown epoch type: ' + str(kind))
+                    raise NotImplementedError(f'Unknown epoch type: {kind}')
 
                 if kind == EPOCH_DISABLED:
                     continue
@@ -565,9 +653,9 @@ def myokit_protocol(self, channel=None, ms=True):
                     # Event at step
                     dur = f * e['init_duration'] / self._rate
                     inc = f * e['duration_inc'] / self._rate
-                    e_level = e['init_level'] + e['level_inc'] * iSweep
+                    e_level = e['init_level'] + e['level_inc'] * i_sweep
                     e_start = start
-                    e_length = dur + iSweep * inc
+                    e_length = dur + i_sweep * inc
                     p.schedule(e_level, e_start, e_length)
                     start += e_length
 
@@ -576,8 +664,8 @@ def myokit_protocol(self, channel=None, ms=True):
                         'Usupported epoch type: ' + epoch_types(kind))
 
             # Event at holding potential
-            next_start += f * self._sweepStartToStart
-            e_level = dinfo(channel, 'fDACHoldingLevel')
+            next_start += f * self._sweep_start_to_start
+            e_level = dinfo(ida, 'fDACHoldingLevel')
             e_start = start
             e_length = next_start - start
             p.schedule(e_level, e_start, e_length)
@@ -585,93 +673,8 @@ def myokit_protocol(self, channel=None, ms=True):
 
         return p
 
-    def protocol_channels(self):
-        """
-        Returns the number of channels in this file's protocol.
-        """
-        if self._version < 2:
-            return len(self._header['sDACChannelName'])
-        else:
-            return int(self._header['sections']['DAC']['length'])
-
-    def protocol_holding_level(self, channel=0):
-        """
-        Returns the holding level used by the requested output channel of the
-        embedded protocol.
-        """
-        dinfo, einfo_exists, einfo = self._epoch_functions
-        return dinfo(channel, 'fDACHoldingLevel')
-
-    def protocol_steps(self, channel=0):
-        """
-        For a stepped protocol, this function returns a tuple of lists of the
-        successive values (not including the holding value).
-
-        For example, for a protocol that has holding value ``-120mV`` and
-        performs steps to ``-100mV``, ``-80mV``, and ``-40mV`` the returned
-        output will be::
-
-            ([-100, -80, -40])
-
-        For a more complicated protocol, where each step is followed by a step
-        down to ``-140mV``, the output would be::
-
-            ([-100, -80, -40], [-140, -140, -140])
-
-
-        """
-        # Get epoch functions set by _read_protocol
-        dinfo, einfo_exists, einfo = self._epoch_functions
-        if not einfo_exists(channel):  # pragma: no cover
-            # Not sure if this can happen, if so need to update code.
-            raise Exception('Missing protocol data')
-
-        # Create list of step lists
-        levels = []
-        for e in einfo(channel):
-            kind = e['type']
-            if kind not in epoch_types:
-                raise NotImplementedError('Unknown epoch type: ' + str(kind))
-            if kind == EPOCH_DISABLED:
-                continue
-            elif kind == EPOCH_STEPPED:
-                levels.append([])
-            else:
-                raise NotImplementedError(
-                    'Unsupported epoch type: ' + epoch_types(kind))
-
-        # Gather steps
-        levels = tuple(levels)
-        for i in range(self._sweepsPerRun):
-            j = 0
-            for e in einfo(channel):
-                if e['type'] == EPOCH_STEPPED:
-                    levels[j].append(e['init_level'] + e['level_inc'] * i)
-                    j += 1
-        return levels
-
-    def __iter__(self):
-        """
-        Returns an iterator over all sweeps
-        """
-        return iter(self._sweeps)
-
-    def __len__(self):
-        """
-        Returns the number of sweeps in this file.
-        """
-        return len(self._sweeps)
-
-    def protocol(self):
-        """
-        Returns an interator over the protocol data.
-        """
-        return iter(self._protocol)
-
     def _read_datetime(self):
-        """
-        Reads the date/time this file was recorded
-        """
+        """ Reads the date/time this file was recorded """
         # Get date and time
         if self._version < 2:
             t1 = str(self._header['lFileStartDate'])
@@ -692,14 +695,10 @@ def _read_datetime(self):
         return datetime.datetime(YY, MM, DD, hh, mm, ss, ms)
 
     def _read_header(self):
-        """
-        Reads the file's header.
-        """
+        """ Reads the file's header. """
 
         def read_f(f, form, offset=None):
-            """
-            Read and unpack a file section using the given format ``form``.
-            """
+            """ Read and unpack a file section in the format ``form``. """
             form = str(form)
             if offset is not None:
                 f.seek(offset)
@@ -741,8 +740,7 @@ def ups(val):
             elif sig == 'ABF2':
                 version = 2
             else:
-                raise NotImplementedError(
-                    'Unknown ABF Format "' + str(sig) + '".')
+                raise NotImplementedError('Unknown ABF Format "{sig}".')
 
             # Gather header fields
             header = OrderedDict()
@@ -751,11 +749,12 @@ def ups(val):
 
             # Get uniform file version number
             if version < 2:
-                self._version = (
-                    np.round(header['fFileVersionNumber'] * 100) / 100)
+                self._version = np.round(header['fFileVersionNumber'], 5)
+                self._version_str = str(self._version)
             else:
-                n = header['fFileVersionNumber']
-                self._version = n[3] + 0.1 * n[2] + 0.01 * n[1] + 0.001 * n[0]
+                v = header['fFileVersionNumber']
+                self._version = v[3]
+                self._version_str = '.'.join([str(v) for v in reversed(v)])
 
             # Get file start time in seconds
             if version < 2:
@@ -897,20 +896,24 @@ def ups(val):
 
         return header
 
-    def _read_protocol(self):
+    def _read_1_protocol(self):
         """
-        Reads the protocol stored in the ABF file and converts it to an analog
-        signal.
+        Reads the protocols stored in the ABF file and converts it to an analog
+        signal if possible.
 
         Only works for episodic stimulation, without any user lists.
 
         The resulting analog signal has the same size as the recorded signals,
-        so not the full length of the protocol! This is different from the
-        values returned by the Myokit
+        so not the full length of the protocol!
         """
         # Only episodic stimulation is supported.
         if self._mode != ACMODE_EPISODIC_STIMULATION:  # pragma: no cover
-            return []
+            warnings.warn(
+                'Unsupported acquisition method '
+                + acquisition_modes[self._mode] + '; unable to read D/A'
+                ' channels.')
+            self._nDAC = 0
+            return
 
         # Start reading
         h = self._header
@@ -919,12 +922,12 @@ def _read_protocol(self):
         if self._version < 2:
 
             # Before version 2: Sections are fixed length, locations absolute
-            self._numberOfTrials = h['lNumberOfTrials']
-            self._trialStartToStart = h['fTrialStartToStart']
-            self._runsPerTrial = h['lRunsPerTrial']
-            self._runStartToStart = h['fRunStartToStart']
-            self._sweepsPerRun = h['lSweepsPerRun']
-            self._sweepStartToStart = h['fEpisodeStartToStart']
+            self._number_of_trials = h['lNumberOfTrials']
+            self._trial_start_to_start = h['fTrialStartToStart']
+            self._runs_per_trial = h['lRunsPerTrial']
+            self._run_start_to_start = h['fRunStartToStart']
+            self._sweeps_per_run = h['lSweepsPerRun']
+            self._sweep_start_to_start = h['fEpisodeStartToStart']
 
             # Number of samples in a channel for each sweep
             # (Only works for fixed-length, high-speed-osc or episodic)
@@ -956,12 +959,12 @@ def einfo(index):
 
             # Trials, runs, sweeps
             # (According to the manual, there should only be 1 trial!)
-            self._numberOfTrials = p['lNumberOfTrials']
-            self._trialStartToStart = p['fTrialStartToStart']
-            self._runsPerTrial = p['lRunsPerTrial']
-            self._runStartToStart = p['fRunStartToStart']
-            self._sweepsPerRun = p['lSweepsPerRun']
-            self._sweepStartToStart = p['fSweepStartToStart']
+            self._number_of_trials = p['lNumberOfTrials']
+            self._trial_start_to_start = p['fTrialStartToStart']
+            self._runs_per_trial = p['lRunsPerTrial']
+            self._run_start_to_start = p['fRunStartToStart']
+            self._sweeps_per_run = p['lSweepsPerRun']
+            self._sweep_start_to_start = p['fSweepStartToStart']
 
             # Number of samples in a channel in a single sweep
             nSam = p['lNumSamplesPerEpisode'] // h['sections']['ADC']['length']
@@ -985,58 +988,77 @@ def einfo(index):
             self._epoch_functions = (dinfo, einfo_exists, einfo)
 
         # If sweepStartToStart == 0, we set it to the duration of a sweep
-        if self._sweepStartToStart == 0:    # pragma: no cover
-            self._sweepStartToStart = nSam / self._rate
+        if self._sweep_start_to_start == 0:    # pragma: no cover
+            self._sweep_start_to_start = nSam / self._rate
 
         # Step 2: Generate analog signals corresponding to the waveforms
         # suggested by the 'epochs' in the protocol
+
         # User lists are not supported
+        user_lists = False
         if self._version < 2:   # pragma: no cover
-            if any(self._header['nULEnable']):
-                return []
-        else:   # pragma: no cover
+            user_lists = any(self._header['nULEnable'])
+        else:  # pragma: no cover
             for userlist in self._header['listUserListInfo']:
                 if 'nULEnable' in userlist and userlist['nULEnable']:
-                    return []
+                    user_lists = True
+                    break
                 if 'nConditEnable' in userlist and userlist['nConditEnable']:
-                    return []
-        sweeps = []
+                    user_lists = True
+                    break
+        if user_lists:  # pragma: no cover
+            warnings.warn(
+                'Unsupported acquisition method: episodic with user lists;'
+                ' unable to read D/A channels.')
+            self._nDAC = 0
+            return
+
+        # Number of ADC channels
+        nADC = self._nADC
+
+        # Number of used DAC channels
+        channels = []
+        for iDAC in range(self._nDAC):
+            if einfo_exists(iDAC):
+                if any(e['type'] != EPOCH_DISABLED for e in einfo(iDAC)):
+                    channels.append(iDAC)
+        self._nDAC = nDAC = len(channels)
+
+        # Number of sweeps
+        if self._is_protocol_file:
+            n_sweeps = self._sweeps_per_run
+        else:
+            n_sweeps = h['lActualSweeps']
 
-        # Number of DAC channels = number of channels that can be used
-        #  to output a stimulation
-        nDac = self.protocol_channels()
+        # Read
         start = 0
-        for iSweep in range(h['lActualSweeps']):
-            sweep = Sweep(nDac)
+        for i_sweep in range(n_sweeps):
+            sweep = Sweep(nADC + nDAC)
 
             # Create channels for this sweep
-            for iDac in range(nDac):
+            i_channel = nADC
+            for iDAC in channels:
+
+                # Convert, where possible
                 c = Channel(self)
-                c._name = dinfo(iDac, 'sDACChannelName').strip()
-                c._unit = dinfo(iDac, 'sDACChannelUnits').strip()
+                c._name = dinfo(iDAC, 'sDACChannelName').strip()
+                c._unit = dinfo(iDAC, 'sDACChannelUnits').strip()
                 if self._version < 2:
-                    c._numb = iDac
+                    c._numb = iDAC
                 else:
-                    c._numb = int(dinfo(iDac, 'lDACChannelNameIndex'))
-                c._data = np.ones(nSam) * dinfo(iDac, 'fDACHoldingLevel')
+                    c._numb = int(dinfo(iDAC, 'lDACChannelNameIndex'))
+                c._data = np.ones(nSam) * dinfo(iDAC, 'fDACHoldingLevel')
                 c._rate = self._rate
                 c._start = start
-                sweep[iDac] = c
-
-                # No stimulation info for this channel? Then continue
-                if not einfo_exists(iDac):
-                    continue
+                sweep[i_channel] = c
+                i_channel += 1
 
                 # Save last sample index
                 i_last = int(nSam * 15625 / 1e6)  # TODO: What's this?
 
                 # For each 'epoch' in the stimulation signal
-                for e in einfo(iDac):
+                for e in einfo(iDAC):
                     kind = e['type']
-                    if kind not in epoch_types:
-                        raise NotImplementedError(
-                            'Unknown epoch type: ' + str(kind))
-
                     if kind == EPOCH_DISABLED:
 
                         continue
@@ -1046,12 +1068,12 @@ def einfo(index):
                         dur = e['init_duration']
                         inc = e['duration_inc']
                         i1 = i_last
-                        i2 = i_last + dur + iSweep * inc
+                        i2 = i_last + dur + i_sweep * inc
                         if i2 > nSam:
                             # The protocol may extend beyond the number of
                             # samples in the recording
                             i2 = nSam
-                        level = e['init_level'] + e['level_inc'] * iSweep
+                        level = e['init_level'] + e['level_inc'] * i_sweep
                         c._data[i1:i2] = level * np.ones(len(range(i2 - i1)))
                         i_last += dur
                         if i_last > nSam:
@@ -1061,22 +1083,19 @@ def einfo(index):
 
                     else:  # pragma: no cover
 
-                        log = logging.getLogger(__name__)
-                        log.warning(
+                        warnings.warn(
                             'Unsupported epoch type: ' + epoch_types(kind))
                         continue
 
-            sweeps.append(sweep)
-            start += self._sweepStartToStart
-        return sweeps
+            self._sweeps.append(sweep)
+            start += self._sweep_start_to_start
+
+    def _read_2_data(self):
+        """ Reads the A/D data and appends it to the list of sweeps. """
 
-    def _read_sweeps(self):
-        """
-        Reads the data from an ABF file and returns a list of sweeps
-        """
         header = self._header
         version = self._version
-        nc = self._nc
+        nc = self._nADC
 
         # Sampling rate is constant for all sweeps and channels
         # TODO: This won't work for 2-rate protocols
@@ -1106,28 +1125,32 @@ def _read_sweeps(self):
             o = header['sections']['SynchArray']['index'] * BLOCKSIZE
         if n > 0:
             dt = [(str('offset'), str('i4')), (str('len'), str('i4'))]
-            sdata = np.memmap(self._filepath, dt, 'r', shape=(n), offset=o)
+            sdata = np.memmap(self._filepath, dt, 'r', shape=(n,), offset=o)
         else:   # pragma: no cover
             # Cover pragma: Don't have appropriate test file
             sdata = np.empty((1), dt)
             sdata[0]['len'] = data.size
             sdata[0]['offset'] = 0
 
-        # Get data
-        pos = 0
+        # Number of sweeps must equal n
+        if n != header['lActualSweeps']:
+            raise NotImplementedError(
+                'Unable to read file with different sizes per sweep.')
 
-        # Data structure
-        sweeps = []
+        # Sweeps should have been created by read_protocol, but if the protocol
+        # type was unsupported they might not have been
+        if len(self._sweeps) == 0:  # pragma: no cover
+            self._sweeps = [Sweep(nc) for i in range(n)]
 
         # Time-offset at start of sweep
         start = sdata[0]['offset'] / rate
-        for j in range(sdata.size):
 
-            # Create a new sweep
-            sweep = Sweep(nc)
+        # Get data
+        pos = 0
+        for sweep, sdat in zip(self._sweeps, sdata):
 
             # Get the number of data points
-            size = sdata[j]['len']
+            size = sdat['len']
 
             # Calculate the correct size for variable-length event mode
             if self._mode == ACMODE_VARIABLE_LENGTH_EVENTS:  # pragma: no cover
@@ -1148,9 +1171,8 @@ def _read_sweeps(self):
             if header['nDataFormat'] == 0:
                 # Data given as integers? Convert to floating point
                 for i in range(nc):
-                    factor, offset = self._get_conversion_factors(i)
-                    part[:, i] *= factor
-                    part[:, i] += offset
+                    part[:, i] *= self._adc_factors[i]
+                    part[:, i] += self._adc_offsets[i]
 
             # Create channel
             if self._mode != ACMODE_EPISODIC_STIMULATION:  # pragma: no cover
@@ -1161,8 +1183,8 @@ def _read_sweeps(self):
                 c = Channel(self)
                 c._data = part[:, i]
                 if version < 2:
-                    c._name = str(header['sADCChannelName'][i])
-                    c._unit = str(header['sADCUnits'][i])
+                    c._name = str(header['sADCChannelName'][i]).strip()
+                    c._unit = str(header['sADCUnits'][i]).strip()
                     c._numb = int(header['nADCPtoLChannelMap'][i])
 
                     # Get telegraphed info
@@ -1188,8 +1210,10 @@ def get(field):
 
                 else:
 
-                    c._name = str(header['listADCInfo'][i]['ADCChNames'])
-                    c._unit = str(header['listADCInfo'][i]['ADCChUnits'])
+                    c._name = str(header['listADCInfo'][i]['ADCChNames']
+                                  ).strip()
+                    c._unit = str(header['listADCInfo'][i]['ADCChUnits']
+                                  ).strip()
                     c._numb = int(header['listADCInfo'][i]['nADCNum'])
 
                     # Get telegraphed info
@@ -1218,12 +1242,7 @@ def get(field):
 
             if self._mode == ACMODE_EPISODIC_STIMULATION:
                 # Increase time according to sweeps in episodic stim. mode
-                start += self._sweepStartToStart
-
-            # Store sweep
-            sweeps.append(sweep)
-
-        return sweeps
+                start += self._sweep_start_to_start
 
     def _set_conversion_factors(self):
         """
@@ -1234,7 +1253,7 @@ def _set_conversion_factors(self):
         self._adc_offsets = []
         h = self._header
         if self._version < 2:
-            for i in range(self._nc):
+            for i in range(self._nADC):
                 # Multiplier
                 f = (
                     h['fInstrumentScaleFactor'][i]
@@ -1269,7 +1288,7 @@ def _set_conversion_factors(self):
 
             a = h['listADCInfo']
             p = h['protocol']
-            for i in range(self._nc):
+            for i in range(self._nADC):
                 # Multiplier
                 f = (
                     a[i]['fInstrumentScaleFactor']
@@ -1302,12 +1321,20 @@ def _set_conversion_factors(self):
                 # Set final offset
                 self._adc_offsets.append(s)
 
+    def sweep_count(self):
+        # See parent for docstring
+        return len(self._sweeps)
+
+    def version(self):
+        """ Returns a string representation of this file's version number. """
+        return self._version_str
+
 
 class Sweep:
     """
-    Represents a single sweep (also called an 'episode')
+    Represents a single sweep (also called an *episode*).
 
-    A sweep is represented as a fixed-size list of channels.
+    Each sweep contains a number of :class:`channels<Channel>`.
     """
     def __init__(self, n):
         super().__init__()
@@ -1337,7 +1364,7 @@ class Channel:
     """
     Represents an analog signal for a single channel.
 
-    To obtain this channel's formatted data, use times() and trace()
+    To obtain this channel's data, use :meth:`times` and :meth:`trace`.
     """
     def __init__(self, parent_file):
         super().__init__()
@@ -1375,38 +1402,63 @@ def __init__(self, parent_file):
         # file with 2 channels can have indices 0 and 3.
 
     def name(self):
-        """
-        Returns the name set for this channel.
-        """
+        """ Returns the name set for this channel. """
         return self._name
 
     def number(self):
         """
-        Returns the channel index used by pClamp. Note that this does not
-        necessarily equal its index in the Python sweep data!
+        Returns the channel index used by pClamp.
+
+        Note that this does not necessarily equal its index in the Python sweep
+        data.
         """
         return self._numb
 
     def __str__(self):
-        return 'Channel(' + str(self._numb) + ' "' + str(self._name) \
-            + '"); ' + str(len(self._data)) + ' points sampled at ' \
-            + str(self._rate) + 'Hz, starts at t=' + str(self._start)
+        return (
+            f'Channel({self._numb} "{self._name}"); {len(self._data)}'
+            f' points sampled at {self._rate}Hz, starts at t={self._start}.')
 
     def times(self):
-        """
-        Returns a copy of the values on the time axis.
-        """
+        """ Returns a copy of the values on the time axis. """
         n = len(self._data)
         f = 1.0 / self._rate
         return np.arange(self._start, self._start + n * f, f)[0:n]
 
     def values(self):
-        """
-        Returns a copy of the values on the data axis.
-        """
+        """ Returns a copy of the values on the data axis. """
         return np.array(self._data, copy=True)
 
 
+def dict_to_string(out, name, d, tab=''):
+    """ Used by AbfFile.info(). """
+    m = max(0, 38 - len(tab) - int(0.1 + len(name) / 2))
+    out.append(f'{tab}{"-" * m}  {name}  {"-" * m}')
+    for n, v in d.items():
+        n = str(n)
+        if type(v) == OrderedDict:
+            dict_to_string(out, n, v, f'{tab}  ')
+        elif type(v) == list:
+            list_to_string(out, n, v, tab)
+        else:
+            out.append(f'{tab}{n}: {v}')
+    m = max(0, 80 - 2 * len(tab))
+    out.append(f'{tab}{m * "-"}')
+
+
+def list_to_string(out, name, d, tab=''):
+    """ Used by AbfFile.info(). """
+    for index, item in enumerate(d):
+        n = f'{name}[{index}]'
+        if type(item) == OrderedDict:
+            dict_to_string(out, n, item, tab)
+        elif type(item) == list:    # pragma: no cover
+            # Cover pragma: Don't have appropriate test file
+            list_to_string(out, n, item, tab)
+        else:
+            out.append(f'{tab}{n}: {item}')
+
+
 # Some python struct types:
 #   f   float
 #   h   short
diff --git a/myokit/formats/axon/_atf.py b/myokit/formats/axon/_atf.py
index e5fdc96f0..5f1a13acd 100644
--- a/myokit/formats/axon/_atf.py
+++ b/myokit/formats/axon/_atf.py
@@ -11,6 +11,7 @@
 
 from collections import OrderedDict
 
+import myokit
 
 # Format used for any text
 _ENC = 'ascii'
@@ -30,11 +31,11 @@ class AtfFile:
     """
     def __init__(self, filename):
         # The path to the file and its basename
-        self._file = os.path.abspath(filename)
+        self._path = os.path.abspath(filename)
         self._filename = os.path.basename(filename)
 
         # A version string
-        self._version = None
+        self._version_str = None
 
         # A (multi-line) string containing meta-data found in this file
         self._meta = None
@@ -45,56 +46,35 @@ def __init__(self, filename):
         # Read data
         self._read()
 
-    def filename(self):
-        """
-        Returns this ATF's filename.
-        """
-        return self._file
-
     def __getitem__(self, key):
         return self._data.__getitem__(key)
 
     def __iter__(self):
-        """
-        Iterates over all data arrays in this ATF file.
-        """
         return iter(self._data)
 
-    def items(self):
-        """
-        Iterates over all key-value pairs in this ATF file's data.
-        """
-        return iter(self._data.items())
-
-    def keys(self):
-        """
-        Iterates over all keys in this ATF file's data.
-        """
-        return iter(self._data.keys())
-
-    def values(self):
-        """
-        Iterates over all values in this ATF file's data.
-        """
-        return iter(self._data.values())
-
     def __len__(self):
-        """
-        Returns the number of records in this file.
-        """
         return len(self._data)
 
+    def filename(self):
+        """ Returns this ATF's filename. """
+        return self._filename
+
     def info(self):
-        """
-        Returns the header/meta data found in this file.
-        """
+        """ Returns this ATF's header/meta data. """
         return self._meta
 
-    def myokit_log(self):
+    def items(self):
+        """ Returns an iterator over all ``(key, value)`` pairs. """
+        return iter(self._data.items())
+
+    def keys(self):
+        """ Returns an iterator over all keys in this ATF. """
+        return iter(self._data.keys())
+
+    def log(self):
         """
         Returns this file's time series data as a :class:`myokit.DataLog`.
         """
-        import myokit
         log = myokit.DataLog()
         if len(self._data) > 0:
             log.set_time_key(next(iter(self._data.keys())))
@@ -102,17 +82,27 @@ def myokit_log(self):
             log[k] = v
         return log
 
+    def myokit_log(self):
+        """ Deprecated alias of :meth:`log`. """
+        # Deprecated since 2023-06-22
+        import warnings
+        warnings.warn(
+            'The method `myokit_log` is deprecated. Please use `log` instead.')
+        return self.log()
+
+    def path(self):
+        """ Returns the path to this ATF file. """
+        return self._path
+
     def _read(self):
-        """
-        Reads the data in the file.
-        """
-        with open(self._file, 'rb') as f:
+        """ Reads the data in this file. """
+        with open(self._path, 'rb') as f:
             # Check version
             line = f.readline().decode(_ENC)
             line_index = 1
             if line[:3] != 'ATF':
                 raise Exception('Unrecognised file type.')
-            self._version = line[3:].strip()
+            self._version_str = line[3:].strip()
 
             # Read number of header lines, number of fields
             line = f.readline().decode(_ENC)
@@ -131,9 +121,8 @@ def _read(self):
                 line_index += 1
                 if line[0] != '"' or line[-1] != '"':
                     raise Exception(
-                        'Invalid header on line ' + str(line_index)
-                        + ': expecting lines wrapped in double quotation'
-                        ' marks "like this".')
+                        f'Invalid header on line f{line_index}: expecting '
+                        f' lines wrapped in double quotes ("like this").')
                 line = line[1:-1].strip()
                 raw.append(line)
                 if key_value_pairs:
@@ -170,8 +159,8 @@ def _read(self):
             delims = delims[1:-1]
             if len(delims) + 1 != nf:
                 raise Exception(
-                    'Unable to parse column headers: Expected ' + str(nf)
-                    + ' headers, found ' + str(len(delims) + 1) + '.')
+                    f'Unable to parse column headers: Expected {nf} headers,'
+                    f' found {len(delims) + 1}.')
             commas = ',' in delims[0]
             for delim in delims:
                 if commas != (',' in delim):
@@ -193,8 +182,8 @@ def _read(self):
             if len(keys) != nf:     # pragma: no cover
                 # This should have been picked up above
                 raise Exception(
-                    'Unable to parse column headers: Expected ' + str(nf)
-                    + ' headers, found ' + str(len(keys)) + '.')
+                    f'Unable to parse column headers: Expected {nf} headers,'
+                    f' found {len(keys)}.')
 
             # Read data
             data = []
@@ -209,18 +198,19 @@ def _read(self):
                 vals = line.split(sep)
                 if len(vals) != nf:
                     raise Exception(
-                        'Invalid data on line ' + str(line_index)
-                        + ': expecting ' + str(nf) + ' fields, found'
-                        + ' ' + str(len(vals)) + '.')
+                        f'Invalid data on line f{line_index}: expecting {nf}'
+                        f' fields, found {len(vals)}.')
                 vals = [float(x) for x in vals]
                 for k, d in enumerate(vals):
                     data[k].append(d)
 
+    def values(self):
+        """ Returns an iterator over all values in this ATF. """
+        return iter(self._data.values())
+
     def version(self):
-        """
-        Returns the file type version of this ATF file (as a string).
-        """
-        return self._version
+        """ Returns a string representation of this file's version number. """
+        return self._version_str
 
 
 def load_atf(filename):
@@ -228,7 +218,7 @@ def load_atf(filename):
     Reads an ATF file and returns its data as a :class:`myokit.DataLog`.
     """
     filename = os.path.expanduser(filename)
-    return AtfFile(filename).myokit_log()
+    return AtfFile(filename).log()
 
 
 def save_atf(log, filename, fields=None):
@@ -306,12 +296,12 @@ def save_atf(log, filename, fields=None):
         f.write(('ATF 1.0' + eol).encode(_ENC))
 
         # Write number of header lines, number of fields
-        f.write((str(nh) + delim + str(nf) + eol).encode(_ENC))
+        f.write(f'{nh}{delim}{nf}{eol}'.encode(_ENC))
         for k, v in header:
-            f.write(('"' + str(k) + '=' + str(v) + '"' + eol).encode(_ENC))
+            f.write(f'"{k}={v}"{eol}'.encode(_ENC))
 
         # Write field names
-        f.write((delim.join(['"' + k + '"' for k in keys]) + eol).encode(_ENC))
+        f.write((delim.join([f'"{k}"' for k in keys]) + eol).encode(_ENC))
 
         # Write data
         for i in range(nd):
diff --git a/myokit/formats/axon/_importer.py b/myokit/formats/axon/_importer.py
index af74fb206..630a9c47c 100644
--- a/myokit/formats/axon/_importer.py
+++ b/myokit/formats/axon/_importer.py
@@ -25,4 +25,4 @@ def protocol(self, filename, channel=None):
         """
         from myokit.formats.axon import AbfFile
         abf = AbfFile(filename)
-        return abf.myokit_protocol(channel)
+        return abf.protocol(channel)
diff --git a/myokit/formats/wcp/_wcp.py b/myokit/formats/wcp/_wcp.py
index 278f1c5fb..f0983faa8 100755
--- a/myokit/formats/wcp/_wcp.py
+++ b/myokit/formats/wcp/_wcp.py
@@ -16,131 +16,133 @@
 
 class WcpFile:
     """
-    Represents a read-only WinWCP file (``.wcp``), stored at the location
-    pointed to by ``filepath``.
+    Represents a read-only WinWCP file (``.wcp``), stored at ``path``.
 
     Only files in the newer file format version 9 can be read. This version of
     the format was introduced in 2010. New versions of WinWCP can read older
     files and will convert them to the new format automatically when opened.
 
     WinWCP is a tool for recording electrophysiological data written by John
-    Dempster of Strathclyde University.
+    Dempster of Strathclyde University. For more information, see
+    https://documentation.help/WinWCP-V5.3.8/IDH_Topic750.htm
 
     WinWCP files contain a number of records ``NR``, each containing data from
     ``NC`` channels. Every channel has the same length, ``NP`` samples.
     Sampling happens at a fixed sampling rate.
     """
-    def __init__(self, filepath):
+    def __init__(self, path):
         # The path to the file and its basename
-        filepath = str(filepath)
-        self._filepath = os.path.abspath(filepath)
-        self._filename = os.path.basename(filepath)
+        path = str(path)
+        self._path = os.path.abspath(path)
+        self._filename = os.path.basename(path)
+
+        # Header info, per file, per record, and per channel
+        self._header = {}
+        self._header_raw = {}
+        self._record_headers = []
+        self._channel_headers = []
 
         # Records
         self._records = None
-        self._channel_names = None
         self._nr = None     # Records in file
         self._nc = None     # Channels per record
         self._np = None     # Samples per channel
-        #self._dt = None     # Sampling interval
+        self._dt = None     # Sampling interval (s)
 
         # Time signal
         self._time = None
 
+        # Version string
+        self._version_str = None
+
         # Read the file
-        with open(filepath, 'rb') as f:
+        with open(path, 'rb') as f:
             self._read(f)
 
     def _read(self, f):
-        """
-        Reads the file header & data.
-        """
+        """ Reads the file header & data. """
         # Header size is between 1024 and 16380, depending on number of
         # channels in the file following:
         #   n = (int((n_channels - 1)/8) + 1) * 1024
         # Read first part of header, determine version and number of channels
         # in the file
-        data = f.read(1024)
-        h = [x.strip().split(b'=') for x in data.split(b'\n')]
+        data = f.read(1024).decode(_ENC)
+        h = [x.strip().split('=') for x in data.split('\n')]
         h = dict([(x[0].lower(), x[1]) for x in h if len(x) == 2])
-        if int(h[b'ver']) != 9:
+        if int(h['ver']) != 9:
             raise NotImplementedError(
                 'Only able to read format version 9. Given file is in format'
-                ' version ' + str(h[b'ver']))
+                ' version ' + h['ver'])
+        self._version_str = h['ver']
 
         # Get header size
         try:
             # Get number of 512 byte sectors in header
-            #header_size = 512 * int(h[b'nbh'])
+            #header_size = 512 * int(h['nbh'])
             # Seems to be size in bytes!
-            header_size = int(h[b'nbh'])
+            header_size = int(h['nbh'])
         except KeyError:    # pragma: no cover
             # Calculate header size based on number of channels
-            header_size = (int((int(h[b'nc']) - 1) / 8) + 1) * 1024
+            header_size = (int((int(h['nc']) - 1) / 8) + 1) * 1024
 
         # Read remaining header data
         if header_size > 1024:  # pragma: no cover
-            data += f.read(header_size - 1024)
-            h = [x.strip().split(b'=') for x in data.split(b'\n')]
+            data += f.read(header_size - 1024).decode(_ENC)
+            h = [x.strip().split('=') for x in data.split('\n')]
             h = dict([(x[0].lower(), x[1]) for x in h if len(x) == 2])
 
         # Tidy up read data
-        header = {}
-        header_raw = {}
         for k, v in h.items():
             # Convert to appropriate data type
             try:
                 t = HEADER_FIELDS[k]
                 if t == float:
                     # Allow for windows locale stuff
-                    v = v.replace(b',', b'.')
-                header[k] = t(v)
+                    v = v.replace(',', '.')
+                self._header[k] = t(v)
             except KeyError:
-                header_raw[k] = v
+                self._header_raw[k] = v
 
         # Convert time
-        # No, don't. It's in different formats depending on... the version?
+        # No, don't. It's in different formats depending on... user locale?
         # if 'ctime' in header:
-        #    print(header[b'ctime'])
-        #    ctime = time.strptime(header[b'ctime'], "%d/%m/%Y %H:%M:%S")
-        #    header[b'ctime'] = time.strftime('%Y-%m-%d %H:%M:%S', ctime)
+        #    print(header['ctime'])
+        #    ctime = time.strptime(header['ctime'], "%d/%m/%Y %H:%M:%S")
+        #    header['ctime'] = time.strftime('%Y-%m-%d %H:%M:%S', ctime)
 
         # Get vital fields from header
-        # Records in file
-        self._nr = header[b'nr']
-
-        # Channels per record
-        self._nc = header[b'nc']
+        self._dt = self._header['dt']       # Sampling interval
+        self._nr = self._header['nr']       # Records in file
+        self._nc = self._header['nc']       # Channels per record
         try:
-            # Samples per channel
-            self._np = header[b'np']
+            self._np = self._header['np']   # Samples per channel
         except KeyError:
-            self._np = (header[b'nbd'] * 512) // (2 * self._nc)
+            self._np = (self._header['nbd'] * 512) // (2 * self._nc)
 
-        # Get channel specific fields
-        channel_headers = []
+        # Get channel-specific fields
         self._channel_names = []
+        self._channel_name_map = {}
         for i in range(self._nc):
-            j = str(i).encode(_ENC)
             c = {}
             for k, t in HEADER_CHANNEL_FIELDS.items():
-                c[k] = t(h[k + j])
-            channel_headers.append(c)
-            self._channel_names.append(c[b'yn'].decode(_ENC))
+                c[k] = t(h[k + str(i)])
+            self._channel_headers.append(c)
+            self._channel_names.append(c['yn'])
+            self._channel_name_map[c['yn']] = i
 
         # Analysis block size and data block size
         # Data is stored as 16 bit integers (little-endian)
         try:
-            rab_size = 512 * header[b'nba']
+            rab_size = 512 * self._header['nba']
         except KeyError:    # pragma: no cover
             rab_size = header_size
         try:
-            rdb_size = 512 * header[b'nbd']
+            rdb_size = 512 * self._header['nbd']
         except KeyError:    # pragma: no cover
             rdb_size = 2 * self._nc * self._np
 
         # Maximum A/D sample value at vmax
-        adcmax = header[b'adcmax']
+        adcmax = self._header['adcmax']
 
         # Read data records
         records = []
@@ -150,27 +152,36 @@ def _read(self, f):
             f.seek(offset)
 
             # Status of signal (Accepted or rejected, as string)
-            rstatus = f.read(8)
+            rstatus = f.read(8).decode(_ENC)
 
             # Type of recording, as string
-            rtype = f.read(4)
+            rtype = f.read(4).decode(_ENC)
 
-            # Group number (float set by the user)
+            # Leak subtraction group number (float set by the user)
             group_number = struct.unpack(str('<f'), f.read(4))[0]
 
             # Time of recording, as float, not sure how to interpret
             rtime = struct.unpack(str('<f'), f.read(4))[0]
 
             # Sampling interval: pretty sure this should be the same as the
-            # file wide one in header[b'dt']
-            rint = struct.unpack(str('<f'), f.read(4))[0]
+            # file wide one in header['dt']
+            rint = round(struct.unpack(str('<f'), f.read(4))[0], 6)
 
             # Maximum positive limit of A/D converter voltage range
             vmax = struct.unpack(
                 str('<' + 'f' * self._nc), f.read(4 * self._nc))
 
             # String marker set by user
-            marker = f.read(16)
+            marker = f.read(16).decode(_ENC).strip('\x00')
+
+            # Store
+            self._record_headers.append({
+                'status': rstatus,
+                'type': rtype,
+                'rtime': rtime,
+                'dt': rint,
+                'marker': marker,
+            })
 
             # Delete unused
             del rstatus, rtype, group_number, rtime, rint, marker
@@ -180,18 +191,16 @@ def _read(self, f):
 
             # Get data from data block
             data = np.memmap(
-                self._filepath, np.dtype('<i2'), 'r',
+                self._path, np.dtype('<i2'), 'r',
                 shape=(self._np, self._nc),
                 offset=offset,
             )
 
             # Separate channels and apply scaling
-            record = []
-            for j in range(self._nc):
-                h = channel_headers[j]
-                s = float(vmax[j]) / float(adcmax) / float(h[b'yg'])
-                d = np.array(data[:, h[b'yo']].astype('f4') * s)
-                record.append(d)
+            record = [
+                vmx / (adcmax * h['yg']) * data[:, h['yo']].astype('f4')
+                for h, vmx in zip(self._channel_headers, vmax)]
+
             records.append(record)
 
             # Increase offset beyong data block
@@ -200,77 +209,215 @@ def _read(self, f):
         self._records = records
 
         # Create time signal
-        self._time = np.arange(self._np) * header[b'dt']
+        self._time = np.arange(self._np) * self._dt
+
+    def __getitem__(self, key):
+        return self._records[key]
+
+    def __iter__(self):
+        return iter(self._records)
+
+    def __len__(self):
+        return self._nr
+
+    def channel(self, channel_id, join_sweeps=False):
+        # Docstring in SweepSource
+        channel_id = self._channel_id(channel_id)
+
+        if join_sweeps:
+            # Join sweeps
+            time, data = [], []
+            for r, h in zip(self._records, self._record_headers):
+                time.append(self._time + h['rtime'])
+                data.append(r[channel_id])
+            return (np.concatenate(time), np.concatenate(data))
+
+        else:
+            # Return individual sweeps
+            out = []
+            out.append(np.array(self._time))
+            for r in self._records:
+                out.append(np.array(r[channel_id]))
+            return tuple(out)
 
     def channels(self):
-        """
-        Returns the number of channels in this file.
-        """
+        """ Deprecated alias of :meth:`channel_count`. """
+        # Deprecated since 2023-06-22
+        import warnings
+        warnings.warn(
+            'The method `channels` is deprecated. Please use'
+            ' WcpFile.channel_count() instead.')
+
+        return self._nc
+
+    def channel_count(self):
+        # Docstring in SweepSource
         return self._nc
 
+    def _channel_id(self, channel_id):
+        """ Checks an int or str channel id and returns a valid int. """
+        if self._nr == 0:  # pragma: no cover
+            raise KeyError(f'Channel {channel_id} not found (empty file).')
+
+        # Handle string
+        if isinstance(channel_id, str):
+            return self._channel_name_map[channel_id]  # Pass KeyError to user
+
+        int_id = int(channel_id)  # TypeError for user
+        if int_id < 0 or int_id >= self._nc:
+            raise IndexError(f'channel_id out of range: {channel_id}')
+        return int_id
+
     def channel_names(self):
-        """
-        Returns the names of the channels in this file.
-        """
+        # Docstring in SweepSource
         return list(self._channel_names)
 
     def filename(self):
-        """
-        Returns the current file's name.
-        """
+        """ Returns this file's name. """
         return self._filename
 
-    def myokit_log(self):
-        """
-        Creates and returns a:class:`myokit.DataLog` containing all the
-        data from this file.
-
-        Each channel is stored under its own name, with an index indicating
-        the record it was from. Time is stored under ``time``.
-        """
+    def info(self):
+        """ Returns a multi-line string with meta data about this file. """
+        h = self._header
+        out = []
+
+        # Add file info
+        out.append(f'WinWCP file: {self._filename}')
+        out.append(f'WinWCP Format version {self._version_str}')
+        out.append(f'Recorded on: {h["rtime"]}')
+
+        # Basic records info
+        out.append(f'  Number of records: {self._nr}')
+        out.append(f'  Channels per record: {self._nc}')
+        out.append(f'  Samples per channel: {self._np}')
+        out.append(f'  Sampling interval: {self._dt} s')
+        if 'tu' in h:  # pragma: no cover
+            out.append(f'Time units: {h["tu"]}')
+
+        # Channel info
+        for c in self._channel_headers:
+            out.append(f'A/D channel: {c["yn"]}')
+            out.append(f'  Unit: {c["yu"]}')
+
+        # Record info
+        out.append('Records: Type, Status, Sampling Interval, Start, Marker')
+        for i, r in enumerate(self._record_headers):
+            out.append(f'Record {i}: {r["type"]}, {r["status"]}, {r["dt"]},'
+                       f' {r["rtime"]}, "{r["marker"]}"')
+
+        return '\n'.join(out)
+
+    def log(self, join_sweeps=False, use_names=False, channels=None):
+        # Docstring in SweepSource
+
+        # Select channels
+        if channels is None:
+            channels = list(range(self._nc))
+        else:
+            channels = [self._channel_id(i) for i in channels]
+
+        # Create log
         log = myokit.DataLog()
+
+        # No data? Then return. Do this after channel check to tell user about
+        # invalid channel ids.
+        if self._nr == 0:  # pragma: no cover
+            return log
+
+        # Get channel names
+        channel_names = self._channel_names
+        if not use_names:
+            channel_names = [f'{i}.channel' for i in range(self._nc)]
+        channel_names = [channel_names[i] for i in channels]
+
+        # Populate log
+        if join_sweeps:
+            # Join sweeps
+            time = []
+            for h in self._record_headers:
+                time.append(self._time + h['rtime'])
+            log['time'] = np.concatenate(time)
+            for c, name in zip(channels, channel_names):
+                log[name] = np.concatenate([r[c] for r in self._records])
+
+        else:
+            # Return individual sweeps
+            log['time'] = np.array(self._time)
+            for i, record in enumerate(self._records):
+                for channel, name in zip(channels, channel_names):
+                    log[name, i] = record[channel]
+
         log.set_time_key('time')
-        log['time'] = np.array(self._time)
-        for i, record in enumerate(self._records):
-            for j, data in enumerate(record):
-                name = self._channel_names[j]
-                log[name, i] = np.array(data)
         return log
 
-    def path(self):
+    def matplotlib_figure(self):
+        """ Creates and returns a matplotlib figure with this file's data. """
+        import matplotlib.pyplot as plt
+        f = plt.figure()
+        axes = [f.add_subplot(self._nc, 1, 1 + i) for i in range(self._nc)]
+        for record in self._records:
+            for ax, channel in zip(axes, record):
+                ax.plot(self._time, channel)
+        return f
+
+    def myokit_log(self):
         """
-        Returns the path to the currently opened file.
+        Deprecated method. Please use ``WcpFile.log(use_names=True)`` instead.
         """
-        return self._filepath
+        # Deprecated since 2023-06-22
+        import warnings
+        warnings.warn(
+            'The method `myokit_log` is deprecated. Please use'
+            ' WcpFile.log(use_names=True) instead.')
+
+    def path(self):
+        """ Returns the path to this file. """
+        return self._path
 
     def plot(self):
         """
-        Creates matplotlib plots of all data in this file.
+        Deprecated method, please use :meth:`matplotlib_figure()` instead.
+
+        Creates and shows a matplotlib figure of all data in this file.
         """
+        # Deprecated since 2023-06-22
+        import warnings
+        warnings.warn(
+            'The method `plot` is deprecated. Please use'
+            ' WcpFile.matplotlib_figure() instead.')
+
         import matplotlib.pyplot as plt
-        for record in self._records:
-            plt.figure()
-            for k, channel in enumerate(record):
-                plt.subplot(self._nc, 1, 1 + k)
-                plt.plot(self._time, channel)
+        self.matplotlib_figure()
         plt.show()
 
-    def records(self):
+    def record_count(self):
         """
         Returns the number of records in this file.
+
+        Alias of :meth:`sweep_count`.
         """
         return self._nr
 
-    #def sampling_interval(self):
-    #    """
-    #    Returns the sampling interval used in this file.
-    #    """
-    #    return self._dt
+    def records(self):
+        """ Deprecated alias of :meth:`count_records`. """
+        # Deprecated since 2023-06-22
+        import warnings
+        warnings.warn(
+            'The method `records` is deprecated. Please use'
+            ' WcpFile.record_count() instead.')
+
+        return self._nr
+
+    def sample_count(self):
+        """ Returns the number of samples in each channel. """
+        return self._np
+
+    def sweep_count(self):
+        # Docstring in SweepSource
+        return self._nr
 
     def times(self):
-        """
-        Returns the time points sampled at.
-        """
+        """ Returns the time points sampled at. """
         return np.array(self._time)
 
     def values(self, record, channel):
@@ -280,31 +427,36 @@ def values(self, record, channel):
         """
         return self._records[record][channel]
 
+    def version(self):
+        """ Returns this file's version, as a string. """
+        return self._version_str
+
 
 HEADER_FIELDS = {
-    b'ver': int,        # WCP data file format version number
-    b'ctime': bytes,    # Create date/time
-    b'nc': int,         # No. of channels per record
-    b'nr': int,         # No. of records in the file.
-    b'nbh': int,        # No. of 512 byte sectors in file header block
-    b'nba': int,        # No. of 512 byte sectors in a record analysis block
-    b'nbd': int,        # No. of 512 byte sectors in a record data block
-    b'ad': float,       # A/D converter input voltage range (V)
-    b'adcmax': int,     # Maximum A/D sample value
-    b'np': int,         # No. of A/D samples per channel
-    b'dt': float,       # A/D sampling interval (s)
-    b'nz': int,         # No. of samples averaged to calculate a zero level.
-    b'tu': bytes,       # Time units
-    b'id': bytes,       # Experiment identification line
+    'ver': int,        # WCP data file format version number
+    'ctime': str,      # Create date/time
+    'rtime': str,      # Start of recording time
+    'nc': int,         # No. of channels per record
+    'nr': int,         # No. of records in the file.
+    'nbh': int,        # No. of 512 byte sectors in file header block
+    'nba': int,        # No. of 512 byte sectors in a record analysis block
+    'nbd': int,        # No. of 512 byte sectors in a record data block
+    'ad': float,       # A/D converter input voltage range (V)
+    'adcmax': int,     # Maximum A/D sample value
+    'np': int,         # No. of A/D samples per channel
+    'dt': float,       # A/D sampling interval (s)
+    'nz': int,         # No. of samples averaged to calculate a zero level.
+    'tu': str,         # Time units
+    'id': str,         # Experiment identification line
 }
 
 
 HEADER_CHANNEL_FIELDS = {
-    b'yn': bytes,       # Channel name
-    b'yu': bytes,       # Channel units
-    b'yg': float,       # Channel gain factor mV/units
-    b'yz': int,         # Channel zero level (A/D bits)
-    b'yo': int,         # Channel offset into sample group in data block
-    b'yr': int,         # ADCZeroAt, probably for old files
+    'yn': str,         # Channel name
+    'yu': str,         # Channel units
+    'yg': float,       # Channel gain factor mV/units
+    'yz': int,         # Channel zero level (A/D bits)
+    'yo': int,         # Channel offset into sample group in data block
+    'yr': int,         # ADCZeroAt, probably for old files
 }
 #TODO: Find out if we need to do something with yz and yg
diff --git a/myokit/gui/datalog_viewer.py b/myokit/gui/datalog_viewer.py
index 86c4d4ba7..30db8492d 100755
--- a/myokit/gui/datalog_viewer.py
+++ b/myokit/gui/datalog_viewer.py
@@ -184,6 +184,18 @@ def action_close(self, index):
         gc.collect()
         del tab
 
+    def action_first_var(self):
+        """ Select the first variable in the current file. """
+        tab = self._tabs.currentWidget()
+        if tab:
+            tab.first()
+
+    def action_last_var(self):
+        """ Select the last variable in the current file. """
+        tab = self._tabs.currentWidget()
+        if tab:
+            tab.last()
+
     def action_license(self):
         """
         Displays this program's licensing information.
@@ -295,6 +307,8 @@ def create_menu(self):
         self._tool_prev_file.triggered.connect(self.action_prev_file)
         self._tool_prev_file.setEnabled(False)
         self._menu_view.addAction(self._tool_prev_file)
+        # View > ----
+        self._menu_view.addSeparator()
         # View > Next variable
         self._tool_next_var = QtGui.QAction('Next variable', self)
         self._tool_next_var.setShortcut('PgDown')
@@ -309,6 +323,20 @@ def create_menu(self):
         self._tool_prev_var.triggered.connect(self.action_prev_var)
         self._tool_prev_var.setEnabled(False)
         self._menu_view.addAction(self._tool_prev_var)
+        # View > First variable
+        self._tool_first_var = QtGui.QAction('First variable', self)
+        self._tool_first_var.setShortcut('Home')
+        self._tool_first_var.setStatusTip('Show the first variable')
+        self._tool_first_var.triggered.connect(self.action_first_var)
+        self._tool_first_var.setEnabled(False)
+        self._menu_view.addAction(self._tool_first_var)
+        # View > Last var
+        self._tool_last_var = QtGui.QAction('Last variable', self)
+        self._tool_last_var.setShortcut('End')
+        self._tool_last_var.setStatusTip('Show the last variable')
+        self._tool_last_var.triggered.connect(self.action_last_var)
+        self._tool_last_var.setEnabled(False)
+        self._menu_view.addAction(self._tool_last_var)
         # Help menu
         self._menu_help = self._menu.addMenu('&Help')
         # Help > About
@@ -506,9 +534,13 @@ def fileTabChangeEvent(self, index):
         if index >= 0:
             tab = self._tabs.widget(index)
             if tab.count() > 1:
-                self._tool_prev_var.setEnabled(True)
+                self._tool_first_var.setEnabled(True)
+                self._tool_last_var.setEnabled(True)
                 self._tool_next_var.setEnabled(True)
+                self._tool_prev_var.setEnabled(True)
                 return
+        self._tool_first_var.setEnabled(False)
+        self._tool_last_var.setEnabled(False)
         self._tool_prev_var.setEnabled(False)
         self._tool_next_var.setEnabled(False)
 
@@ -517,10 +549,16 @@ class TabWidget(QtWidgets.QTabWidget):
     """
     Tab widget that can move up and down when asked.
     """
+    def first(self):
+        """ Select the first widget. """
+        self.setCurrentIndex(0)
+
+    def last(self):
+        """ Select the last widget. """
+        self.setCurrentIndex(self.count() - 1)
+
     def next(self):
-        """
-        Select the next widget.
-        """
+        """ Select the next widget. """
         n = self.count()
         if n < 2:
             return
@@ -528,9 +566,7 @@ def next(self):
         self.setCurrentIndex(0 if i >= n else i)
 
     def previous(self):
-        """
-        Select the previous widget.
-        """
+        """ Select the previous widget. """
         n = self.count()
         if n < 2:
             return
@@ -549,12 +585,9 @@ def __init__(self, parent, abf):
         self._abf = abf
         self._figures = []
         self._axes = []
-        for i in range(self._abf.data_channels()):
+        for i in range(self._abf.channel_count()):
             tab, name = self.create_graph_tab(i)
             self.addTab(tab, name)
-        for i in range(self._abf.protocol_channels()):
-            tab, name = self.create_protocol_tab(i)
-            self.addTab(tab, name)
         self.addTab(self.create_info_tab(), 'Info')
         del self._abf
 
@@ -563,6 +596,7 @@ def create_graph_tab(self, channel):
         Creates a widget displaying the main data.
         """
         widget = QtWidgets.QWidget(self)
+
         # Create figure
         figure = matplotlib.figure.Figure()
         figure.suptitle(self._abf.filename())
@@ -570,45 +604,16 @@ def create_graph_tab(self, channel):
         canvas.setParent(widget)
         axes = figure.add_subplot(1, 1, 1)
         toolbar = backend.NavigationToolbar2QT(canvas, widget)
+
         # Draw lines
-        name = 'AD(' + str(channel) + ')'   # Default if no data is present
+        name = f'Channel {channel}'  # Default if no data is present
         times = None
         for i, sweep in enumerate(self._abf):
             if times is None:
-                name = 'AD' + str(sweep[channel].number()) + ': ' \
-                    + sweep[channel].name()
+                name = sweep[channel].name()
                 times = sweep[channel].times()
             axes.plot(times, sweep[channel].values())
-        # Create a layout
-        vbox = QtWidgets.QVBoxLayout()
-        vbox.addWidget(canvas)
-        vbox.addWidget(toolbar)
-        widget.setLayout(vbox)
-        self._figures.append(figure)
-        self._axes.append(axes)
-        return widget, name
 
-    def create_protocol_tab(self, channel):
-        """
-        Creates a widget displaying a stored D/A signal.
-        """
-        widget = QtWidgets.QWidget(self)
-        # Create figure
-        figure = matplotlib.figure.Figure()
-        figure.suptitle(self._abf.filename())
-        canvas = backend.FigureCanvasQTAgg(figure)
-        canvas.setParent(widget)
-        axes = figure.add_subplot(1, 1, 1)
-        toolbar = backend.NavigationToolbar2QT(canvas, widget)
-        # Draw lines
-        name = 'DA(' + str(channel) + ')'   # Default if no data is present
-        times = None
-        for i, sweep in enumerate(self._abf.protocol()):
-            if times is None:
-                name = 'DA' + str(sweep[channel].number()) + ': ' \
-                    + sweep[channel].name()
-                times = sweep[channel].times()
-            axes.plot(times, sweep[channel].values())
         # Create a layout
         vbox = QtWidgets.QVBoxLayout()
         vbox.addWidget(canvas)
@@ -619,18 +624,14 @@ def create_protocol_tab(self, channel):
         return widget, name
 
     def create_info_tab(self):
-        """
-        Creates a tab displaying information about the file.
-        """
+        """ Creates a tab displaying information about the file. """
         widget = QtWidgets.QTextEdit(self)
         widget.setText(self._abf.info(show_header=True))
         widget.setReadOnly(True)
         return widget
 
     def deleteLater(self):
-        """
-        Deletes this tab (later).
-        """
+        """ Deletes this tab (later). """
         for figure in self._figures:
             figure.clear()
         for axes in self._axes:
@@ -690,18 +691,14 @@ def create_graph_tab(self, time, key):
         return widget
 
     def create_info_tab(self):
-        """
-        Creates a tab displaying information about the file.
-        """
+        """ Creates a tab displaying information about the file. """
         widget = QtWidgets.QTextEdit(self)
         widget.setText(self._atf.info())
         widget.setReadOnly(True)
         return widget
 
     def deleteLater(self):
-        """
-        Deletes this tab (later).
-        """
+        """ Deletes this tab (later). """
         for figure in self._figures:
             figure.clear()
         for axes in self._axes:
@@ -767,9 +764,7 @@ def __init__(self, parent, log, filename):
             self.addTab(self.create_graph_tab(k, groups.get(k)), k)
 
     def create_graph_tab(self, key, indices=None):
-        """
-        Creates a widget displaying the data stored under ``key``.
-        """
+        """ Creates a widget displaying the data stored under ``key``. """
         widget = QtWidgets.QWidget(self)
 
         # Create figure
@@ -991,15 +986,14 @@ def __init__(self, parent, wcp):
         self._wcp = wcp
         self._figures = []
         self._axes = []
-        for i in range(self._wcp.records()):
+        for i in range(self._wcp.record_count()):
             self.addTab(self.create_graph_tab(i), 'Record ' + str(i))
         del self._wcp
 
     def create_graph_tab(self, record):
-        """
-        Creates a widget displaying the data in record i
-        """
+        """ Creates a widget displaying the data in the given ``record``. """
         widget = QtWidgets.QWidget(self)
+
         # Create figure
         figure = matplotlib.figure.Figure()
         figure.suptitle(self._wcp.filename())
@@ -1007,12 +1001,14 @@ def create_graph_tab(self, record):
         canvas.setParent(widget)
         axes = figure.add_subplot(1, 1, 1)
         toolbar = backend.NavigationToolbar2QT(canvas, widget)
+
         # Draw lines
-        for i in range(self._wcp.channels()):
+        for i in range(self._wcp.channel_count()):
             axes.plot(
                 np.array(self._wcp.times(), copy=True),
                 np.array(self._wcp.values(record, i), copy=True),
             )
+
         # Create a layout
         vbox = QtWidgets.QVBoxLayout()
         vbox.addWidget(canvas)
@@ -1022,10 +1018,15 @@ def create_graph_tab(self, record):
         self._axes.append(axes)
         return widget
 
+    def create_info_tab(self):
+        """ Creates a tab displaying information about the file. """
+        widget = QtWidgets.QTextEdit(self)
+        widget.setText(self._wcp.info())
+        widget.setReadOnly(True)
+        return widget
+
     def deleteLater(self):
-        """
-        Deletes this tab (later).
-        """
+        """ Deletes this tab (later). """
         for figure in self._figures:
             figure.clear()
         for axes in self._axes:


Test patch:
diff --git a/myokit/tests/test_datalog.py b/myokit/tests/test_datalog.py
index 1c2edf979..e5375be61 100755
--- a/myokit/tests/test_datalog.py
+++ b/myokit/tests/test_datalog.py
@@ -2189,7 +2189,8 @@ def test_regularize(self):
             self.assertTrue(np.abs(np.exp(y) - e['values'][i]) < 0.02)
 
         # test setting tmin and tmax
-        e = d.regularize(dt=0.5, tmin=0.4, tmax=2.6)
+        with WarningCollector() as w:
+            e = d.regularize(dt=0.5, tmin=0.4, tmax=2.6)
         self.assertEqual(len(e['time']), 5)
         x = np.array([0.4, 0.9, 1.4, 1.9, 2.4])
         self.assertTrue(np.all(e['time'] == x))
diff --git a/myokit/tests/test_formats_axon.py b/myokit/tests/test_formats_axon.py
index 9fcc37b9c..386171553 100755
--- a/myokit/tests/test_formats_axon.py
+++ b/myokit/tests/test_formats_axon.py
@@ -13,7 +13,83 @@
 import myokit
 import myokit.formats.axon as axon
 
-from myokit.tests import TemporaryDirectory, DIR_FORMATS
+from myokit.tests import TemporaryDirectory, DIR_FORMATS, WarningCollector
+
+
+V1_INFO = '''
+Axon Binary File: abf-v1.abf
+ABF Format version 1.65
+Recorded on: 2014-11-14 12:52:29.389999
+Acquisition mode: 5: Episodic stimulation mode
+Protocol set for 1 trials, spaced 0.0s apart.
+    with 1 runs per trial, spaced 0.0s apart.
+     and 9 sweeps per run, spaced 0.5s apart.
+Sampling rate: 10000.0 Hz
+A/D Channel 0: "IN 0"
+  Unit: pA
+D/A Channel 0: "OUT 0"
+  Unit: mV
+'''.strip()
+
+V1_PROTO_INFO = '''
+Axon Protocol File: abf-protocol.pro
+ABF Format version 1.65
+Recorded on: 2005-06-17 14:33:02.160000
+Acquisition mode: 5: Episodic stimulation mode
+Protocol set for 1 trials, spaced 0.0s apart.
+    with 1 runs per trial, spaced 0.0s apart.
+     and 30 sweeps per run, spaced 5.0s apart.
+Sampling rate: 20000.0 Hz
+D/A Channel 0: "Cmd 0"
+  Unit: mV
+'''.strip()
+
+V1_PROTOCOL = '''
+[[protocol]]
+# Level  Start    Length   Period   Multiplier
+-100.0   0.0      100.0    0.0      0
+0.0      100.0    400.0    0.0      0
+-80.0    500.0    100.0    0.0      0
+0.0      600.0    400.0    0.0      0
+-60.0    1000.0   100.0    0.0      0
+0.0      1100.0   400.0    0.0      0
+-40.0    1500.0   100.0    0.0      0
+0.0      1600.0   400.0    0.0      0
+-20.0    2000.0   100.0    0.0      0
+0.0      2100.0   400.0    0.0      0
+0.0      2500.0   100.0    0.0      0
+0.0      2600.0   400.0    0.0      0
+20.0     3000.0   100.0    0.0      0
+0.0      3100.0   400.0    0.0      0
+40.0     3500.0   100.0    0.0      0
+0.0      3600.0   400.0    0.0      0
+60.0     4000.0   100.0    0.0      0
+0.0      4100.0   400.0    0.0      0
+'''.strip()
+
+V2_INFO = '''
+Axon Binary File: abf-v2.abf
+ABF Format version 2.0.0.0
+Recorded on: 2014-10-01 14:03:55.980999
+Acquisition mode: 5: Episodic stimulation mode
+Protocol set for 1 trials, spaced 0.0s apart.
+    with 1 runs per trial, spaced 0.0s apart.
+     and 1 sweeps per run, spaced 5.0s apart.
+Sampling rate: 10000.0 Hz
+A/D Channel 0: "IN 0"
+  Unit: pA
+  Low-pass filter: 10000.0 Hz
+  Cm (telegraphed): 6.34765625 pF
+D/A Channel 0: "Cmd 0"
+  Unit: mV
+'''.strip()
+
+V2_PROTOCOL = '''
+[[protocol]]
+# Level  Start    Length   Period   Multiplier
+12.0     0.0      1000.0   0.0      0
+-120.0   1000.0   4000.0   0.0      0
+'''.strip()
 
 
 class AbfTest(unittest.TestCase):
@@ -27,33 +103,63 @@ def test_read_v1(self):
         # Load file
         path = os.path.join(DIR_FORMATS, 'abf-v1.abf')
         abf = axon.AbfFile(path)
-        self.assertEqual(abf.filename(), path)
+        self.assertEqual(abf.path(), path)
+        self.assertEqual(abf.filename(), 'abf-v1.abf')
+        self.assertIsInstance(abf, myokit.formats.SweepSource)
 
-        # Check version info
+        # Check getting info
         self.assertIn('version 1.65', abf.info())
-        self.assertEqual(
-            abf.info(),
-            'Axon Binary File: abf-v1.abf\n'
-            'ABF Format version 1.65\n'
-            'Recorded on: 2014-11-14 12:52:29.389999\n'
-            'Acquisition mode: 5: Episodic stimulation mode\n'
-            'Protocol set for 1 trials, spaced 0.0s apart.\n'
-            '    with 1 runs per trial, spaced 0.0s apart.\n'
-            '     and 9 sweeps per run, spaced 0.5s apart.\n'
-            'Sampling rate: 10000.0 Hz\n'
-            'Channel 0: "IN 0      "\n'
-            '  Unit: pA'
-        )
+        self.maxDiff = None
+        self.assertEqual(abf.info(), V1_INFO)
+
         # Test getting full header runs without crashing
         abf.info(True)
 
+        # Get version
+        self.assertEqual(abf.version(), '1.65')
+
         # Test len returns number of sweeps
         self.assertEqual(len(abf), 9)
 
-        # Test data access
-        self.assertEqual(abf.data_channels(), 1)    # 1 data channel
-        x = abf.extract_channel(0)
-        self.assertEqual(len(x), 1 + len(abf))      # sweeps + time
+        # Test access to A/D channels via native API
+        self.assertEqual(abf.ad_channel_count(), 1)    # 1 data channel
+        self.assertIsInstance(abf[0], axon.Sweep)
+        self.assertIsInstance(abf[0][0], axon.Channel)
+        self.assertEqual(abf.da_channel_count(), 1)    # 1 D/A channel
+        self.assertIsInstance(abf[0][1], axon.Channel)
+        self.assertEqual(len([s for s in abf]), 9)
+        self.assertEqual(len(abf[0]), abf.channel_count())
+
+        # Test conversion to Myokit protocol
+        p = abf.protocol(1)
+        self.assertEqual(len(p), 18)
+        self.assertEqual(p.code(), V1_PROTOCOL)
+
+        # Test other D/A channel methods
+        self.assertEqual(abf.da_holding_level(1), 0)
+        self.assertEqual(len(abf.da_steps(1)[0]), 9)
+        self.assertEqual(
+            list(abf.da_steps(1)[0]),
+            [-100, -80, -60, -40, -20, 0, 20, 40, 60])
+
+        # Test Channel methods
+        channel = abf[0][0]
+        self.assertIsInstance(channel.number(), int)
+        self.assertEqual(channel.name(), 'IN 0')
+        self.assertEqual(str(channel),
+                         'Channel(0 "IN 0"); 5000 points sampled at 10000.0Hz,'
+                         ' starts at t=0.0.')
+        self.assertEqual(len(channel.times()), len(channel.values()))
+        self.assertFalse(np.all(channel.times() == channel.values()))
+
+        # Test SweepSource interface
+        self.assertEqual(abf.sweep_count(), 9)
+        self.assertEqual(abf.channel_count(), 2)
+        self.assertEqual(abf.channel_names(), ['IN 0', 'OUT 0'])
+
+        # Channel without joining
+        x = abf.channel(0)
+        self.assertEqual(len(x), 1 + len(abf))
         self.assertEqual(len(x[0]), len(x[1]))
         self.assertEqual(len(x[0]), len(x[2]))
         self.assertEqual(len(x[0]), len(x[3]))
@@ -63,84 +169,108 @@ def test_read_v1(self):
         self.assertEqual(len(x[0]), len(x[7]))
         self.assertEqual(len(x[0]), len(x[8]))
         self.assertEqual(len(x[0]), len(x[9]))
-        y = abf.extract_channel_as_myokit_log(0)
-        self.assertEqual(len(y), 1 + len(abf))      # sweeps + time
-        z = abf.myokit_log()
-        self.assertEqual(len(z), 6)     # time + channel + 4 protocol channels
-        sweep = abf[0]
-        self.assertEqual(len(sweep), 1)     # 1 channel in sweep
-        channel = sweep[0]
-        self.assertIsInstance(channel.number(), int)
-        self.assertIn('Channel', str(channel))
-        self.assertEqual(len(abf) * len(channel.times()), len(z.time()))
-        self.assertEqual(len(abf) * len(channel.values()), len(z.time()))
-
-        # Test reading of sweeps as one long array
-        x, y = abf.extract_channel(0, join=True)
-        z = abf.extract_channel(0)
-        self.assertEqual(len(x), len(y))
-        self.assertEqual(len(x), len(abf) * len(z[0]))
-        self.assertTrue(np.all(x[1:] > x[:-1]))
-
-        # Test protocol extraction
-        self.assertEqual(abf.protocol_channels(), 4)    # 4 protocol channels
-        p = abf.myokit_protocol()
-        self.assertEqual(len(p), 18)    # 18 steps in this protocol
-        self.assertEqual(abf.protocol_holding_level(0), 0)
-        p = abf.myokit_protocol(0)
-        self.assertEqual(len(p), 18)
 
-    def test_read_v2(self):
-        # Test reading a version 2 file.
-
-        # Load file
-        path = os.path.join(DIR_FORMATS, 'abf-v2.abf')
-        abf = axon.AbfFile(path)
-
-        # Check version info
-        self.assertIn('version 2.0', abf.info())
-        self.assertEqual(
-            abf.info(),
-            'Axon Binary File: abf-v2.abf\n'
-            'ABF Format version 2.0\n'
-            'Recorded on: 2014-10-01 14:03:55.980999\n'
-            'Acquisition mode: 5: Episodic stimulation mode\n'
-            'Protocol set for 1 trials, spaced 0.0s apart.\n'
-            '    with 1 runs per trial, spaced 0.0s apart.\n'
-            '     and 1 sweeps per run, spaced 5.0s apart.\n'
-            'Sampling rate: 10000.0 Hz\n'
-            'Channel 0: "IN 0"\n'
-            '  Unit: pA\n'
-            '  Low-pass filter: 10000.0 Hz\n'
-            '  Cm (telegraphed): 6.34765625 pF')
-        # Test getting full header runs without crashing
-        abf.info(True)
-
-        # Test len returns number of sweeps
-        self.assertEqual(len(abf), 1)
-
-        # Test data access
-        self.assertEqual(abf.data_channels(), 1)    # 1 data channel
-        x = abf.extract_channel(0)
-        self.assertEqual(len(x), 1 + len(abf))      # sweeps + time
-        self.assertEqual(len(x[0]), len(x[1]))
-        y = abf.extract_channel_as_myokit_log(0)
-        self.assertEqual(len(y), 1 + len(abf))      # sweeps + time
-        z = abf.myokit_log()
-        self.assertEqual(len(z), 6)     # time + channel + 4 protocol channels
-        sweep = abf[0]
-        self.assertEqual(len(sweep), 1)     # 1 channel in sweep
-        channel = sweep[0]
-        self.assertEqual(len(abf) * len(channel.times()), len(z.time()))
-        self.assertEqual(len(abf) * len(channel.values()), len(z.time()))
-
-        # Test protocol extraction
-        self.assertEqual(abf.protocol_channels(), 4)    # 4 protocol channels
-        p = abf.myokit_protocol()
-        self.assertEqual(len(p), 2)     # 2 steps in this protocol
-        self.assertEqual(abf.protocol_holding_level(0), -120)
-        p = abf.myokit_protocol(0)
-        self.assertEqual(len(p), 2)
+        # Channel with joining
+        y = abf.channel(0, join_sweeps=True)
+        self.assertEqual(len(y), 2)
+        self.assertEqual(len(y[0]), 9 * len(x[0]))
+        self.assertEqual(len(y[0]), len(y[1]))
+
+        # Channel doesn't exist
+        self.assertRaises(IndexError, abf.channel, -1)
+        self.assertRaises(IndexError, abf.channel, 2)
+        self.assertRaises(KeyError, abf.channel, 'Tom')
+
+        # Conversion to data log without joining
+        x = abf.log()
+        k = list(x.keys())
+        self.assertEqual(len(x), 1 + 2 * 9)
+        self.assertIn('time', x)
+        self.assertIn('0.0.channel', k)
+        self.assertIn('1.0.channel', k)
+        self.assertIn('2.0.channel', k)
+        self.assertIn('3.0.channel', k)
+        self.assertIn('4.0.channel', k)
+        self.assertIn('5.0.channel', k)
+        self.assertIn('6.0.channel', k)
+        self.assertIn('7.0.channel', k)
+        self.assertIn('8.0.channel', k)
+        self.assertIn('0.1.channel', k)
+        self.assertIn('1.1.channel', k)
+        self.assertIn('2.1.channel', k)
+        self.assertIn('3.1.channel', k)
+        self.assertIn('4.1.channel', k)
+        self.assertIn('5.1.channel', k)
+        self.assertIn('6.1.channel', k)
+        self.assertIn('7.1.channel', k)
+        self.assertIn('8.1.channel', k)
+        self.assertEqual(len(x['time']), len(x['5.0.channel']))
+        self.assertEqual(len(x['7.1.channel']), len(x['5.0.channel']))
+
+        x = abf.log(use_names=True)
+        k = list(x.keys())
+        self.assertEqual(len(x), 1 + 2 * 9)
+        self.assertIn('time', k)
+        self.assertIn('0.IN 0', k)
+        self.assertIn('1.IN 0', k)
+        self.assertIn('2.IN 0', k)
+        self.assertIn('3.IN 0', k)
+        self.assertIn('4.IN 0', k)
+        self.assertIn('5.IN 0', k)
+        self.assertIn('6.IN 0', k)
+        self.assertIn('7.IN 0', k)
+        self.assertIn('8.IN 0', k)
+        self.assertIn('0.OUT 0', k)
+        self.assertIn('1.OUT 0', k)
+        self.assertIn('2.OUT 0', k)
+        self.assertIn('3.OUT 0', k)
+        self.assertIn('4.OUT 0', k)
+        self.assertIn('5.OUT 0', k)
+        self.assertIn('6.OUT 0', k)
+        self.assertIn('7.OUT 0', k)
+        self.assertIn('8.OUT 0', k)
+
+        y = abf.log(use_names=True, channels=[1])
+        k = list(y.keys())
+        self.assertEqual(len(y), 1 + 9)
+        self.assertIn('time', y)
+        self.assertIn('0.OUT 0', y)
+        x = abf.log(use_names=True, channels=['OUT 0'])
+        y = list(x.keys())
+        self.assertEqual(len(y), 1 + 9)
+        self.assertIn('time', y)
+        self.assertIn('8.OUT 0', y)
+        x = abf.log(use_names=True, channels=['IN 0', 'OUT 0'])
+        y = list(x.keys())
+        self.assertEqual(len(y), 1 + 2 * 9)
+        self.assertIn('time', y)
+        self.assertIn('0.OUT 0', y)
+        self.assertIn('2.IN 0', y)
+        self.assertEqual(len(x['time']), len(x['2.IN 0']))
+
+        # Conversion to data log with joining
+        y = abf.log(join_sweeps=True)
+        self.assertEqual(len(y), 3)
+        self.assertIn('time', y.keys())
+        self.assertIn('0.channel', y.keys())
+        self.assertIn('1.channel', y.keys())
+        self.assertEqual(len(y['time']), 9 * len(x['time']))
+        self.assertEqual(len(y['time']), len(y['0.channel']))
+        self.assertEqual(len(y['time']), len(y['1.channel']))
+
+        y = abf.log(join_sweeps=True, use_names=True)
+        self.assertEqual(len(y), 3)
+        self.assertIn('time', y.keys())
+        self.assertIn('IN 0', y.keys())
+        self.assertIn('OUT 0', y.keys())
+        self.assertEqual(len(y['time']), 9 * len(x['time']))
+        self.assertEqual(len(y['time']), len(y['IN 0']))
+        self.assertEqual(len(y['time']), len(y['OUT 0']))
+
+        y = abf.log(join_sweeps=True, use_names=True, channels=['IN 0'])
+        self.assertEqual(len(y), 2)
+        self.assertIn('time', y.keys())
+        self.assertIn('IN 0', y.keys())
 
     def test_read_protocol_v1(self):
         # Test reading a v1 protocol file.
@@ -151,36 +281,160 @@ def test_read_protocol_v1(self):
 
         # Check version info
         self.assertIn('version 1.65', abf.info())
-        self.assertEqual(
-            abf.info(),
-            'Axon Protocol File: abf-protocol.pro\n'
-            'ABF Format version 1.65\n'
-            'Recorded on: 2005-06-17 14:33:02.160000\n'
-            'Acquisition mode: 5: Episodic stimulation mode\n'
-            'Protocol set for 1 trials, spaced 0.0s apart.\n'
-            '    with 1 runs per trial, spaced 0.0s apart.\n'
-            '     and 30 sweeps per run, spaced 5.0s apart.\n'
-            'Sampling rate: 20000.0 Hz'
-        )
-        # Test getting full header runs without crashing
-        abf.info(True)
+        self.assertEqual(abf.info(), V1_PROTO_INFO)
+        abf.info(True)  # Test getting full header runs without crashing
+        self.assertEqual(abf.channel_count(), 1)
+        self.assertEqual(abf.ad_channel_count(), 0)
+        self.assertEqual(abf.da_channel_count(), 1)
+        self.assertEqual(abf.channel_names(), ['Cmd 0'])
+        #self.assertEqual(len(abf.log(join_sweeps=True)), 2)
 
         # Load, force as protocol
         path = os.path.join(DIR_FORMATS, 'abf-protocol.pro')
         abf = axon.AbfFile(path, is_protocol_file=True)
+        self.assertEqual(abf.channel_count(), 1)
+        self.assertEqual(abf.ad_channel_count(), 0)
+        self.assertEqual(abf.da_channel_count(), 1)
 
         # Check version info
         self.assertIn('version 1.65', abf.info())
         self.assertIn('Axon Protocol File', abf.info())
 
         # Test protocol extraction
-        p = abf.myokit_protocol()
+        p = abf.protocol()
         self.assertEqual(len(p), 60)
 
-        # Test step extraction
-        p = abf.protocol_steps()
-        self.assertEqual(len(p), 1)
-        self.assertEqual(len(p[0]), 30)
+    def test_read_v2(self):
+        # Test reading a version 2 file.
+
+        # Load file
+        path = os.path.join(DIR_FORMATS, 'abf-v2.abf')
+        abf = axon.AbfFile(path)
+        self.assertEqual(abf.path(), path)
+        self.assertEqual(abf.filename(), 'abf-v2.abf')
+
+        # Check getting info
+        self.assertIn('version 2.0', abf.info())
+        self.maxDiff = None
+        self.assertEqual(abf.info(), V2_INFO)
+
+        # Test getting full header runs without crashing
+        abf.info(True)
+
+        # Get version
+        self.assertEqual(abf.version(), '2.0.0.0')
+
+        # Test len returns number of sweeps
+        self.assertEqual(len(abf), 1)
+
+        # Test access to A/D channels via native API
+        self.assertEqual(abf.ad_channel_count(), 1)    # 1 data channel
+        self.assertIsInstance(abf[0], axon.Sweep)
+        self.assertIsInstance(abf[0][0], axon.Channel)
+        self.assertEqual(abf.da_channel_count(), 1)    # 1 D/A channel
+        self.assertIsInstance(abf[0][1], axon.Channel)
+        self.assertEqual(len([s for s in abf]), 1)
+        self.assertEqual(len(abf[0]), abf.channel_count())
+
+        # Test conversion to Myokit protocol
+        p = abf.protocol(1)
+        self.assertEqual(len(p), 2)
+        self.assertEqual(p.code(), V2_PROTOCOL)
+        self.assertRaises(ValueError, abf.protocol, 0)  # Not a D/A channel
+
+        # Test other D/A channel methods
+        self.assertEqual(abf.da_holding_level(1), -120)
+        self.assertEqual(len(abf.da_steps(1)[0]), 1)
+        self.assertEqual(abf.da_steps(1)[0], [12])
+
+        # Test Channel methods
+        channel = abf[0][0]
+        self.assertIsInstance(channel.number(), int)
+        self.assertEqual(channel.name(), 'IN 0')
+        self.assertEqual(str(channel),
+                         'Channel(0 "IN 0"); 3100 points sampled at 10000.0Hz,'
+                         ' starts at t=76.5501.')
+        self.assertEqual(len(channel.times()), len(channel.values()))
+        self.assertFalse(np.all(channel.times() == channel.values()))
+
+        # Test SweepSource interface
+        self.assertEqual(abf.sweep_count(), 1)
+        self.assertEqual(abf.channel_count(), 2)
+        self.assertEqual(abf.channel_names(), ['IN 0', 'Cmd 0'])
+
+        # Channel without joining
+        x = abf.channel(0)
+        self.assertEqual(len(x), 1 + len(abf))
+        self.assertEqual(len(x[0]), len(x[1]))
+
+        # Channel with joining
+        y = abf.channel(0, join_sweeps=True)
+        self.assertEqual(len(y), 2)
+        self.assertEqual(len(y[0]), len(x[0]))
+        self.assertEqual(len(y[0]), len(y[1]))
+
+        # Conversion to data log without joining
+        x = abf.log()
+        k = list(x.keys())
+        self.assertEqual(len(x), 3)
+        self.assertIn('time', x)
+        self.assertIn('0.0.channel', k)
+        self.assertIn('0.1.channel', k)
+        self.assertEqual(len(x['time']), len(x['0.0.channel']))
+        self.assertEqual(len(x['time']), len(x['0.1.channel']))
+        self.assertTrue(np.all(x['time'] == abf.channel(0)[0]))
+        self.assertTrue(np.all(x['0.0.channel'] == abf.channel(0)[1]))
+
+        x = abf.log(use_names=True)
+        k = list(x.keys())
+        self.assertEqual(len(x), 3)
+        self.assertIn('time', k)
+        self.assertIn('0.IN 0', k)
+        self.assertIn('0.Cmd 0', k)
+
+        y = abf.log(use_names=True, channels=[1])
+        k = list(y.keys())
+        self.assertEqual(len(y), 2)
+        self.assertIn('time', y)
+        self.assertIn('0.Cmd 0', y)
+        x = abf.log(use_names=True, channels=['Cmd 0'])
+        y = list(x.keys())
+        self.assertEqual(len(y), 2)
+        self.assertIn('time', y)
+        self.assertIn('0.Cmd 0', y)
+        x = abf.log(use_names=True, channels=[0, 'Cmd 0'])
+        y = list(x.keys())
+        self.assertEqual(len(y), 3)
+        self.assertIn('time', y)
+        self.assertIn('0.Cmd 0', y)
+        self.assertIn('0.IN 0', y)
+        self.assertEqual(len(x['time']), len(x['0.IN 0']))
+
+        # Conversion to data log with joining
+        y = abf.log(join_sweeps=True)
+        self.assertEqual(len(y), 3)
+        self.assertIn('time', y.keys())
+        self.assertIn('0.channel', y.keys())
+        self.assertIn('1.channel', y.keys())
+        self.assertEqual(len(y['time']), 1 * len(x['time']))
+        self.assertEqual(len(y['time']), len(y['0.channel']))
+        self.assertEqual(len(y['time']), len(y['1.channel']))
+        self.assertTrue(np.all(y['time'] == abf.channel(0, True)[0]))
+        self.assertTrue(np.all(y['0.channel'] == abf.channel(0, True)[1]))
+
+        y = abf.log(join_sweeps=True, use_names=True)
+        self.assertEqual(len(y), 3)
+        self.assertIn('time', y.keys())
+        self.assertIn('IN 0', y.keys())
+        self.assertIn('Cmd 0', y.keys())
+        self.assertEqual(len(y['time']), 1 * len(x['time']))
+        self.assertEqual(len(y['time']), len(y['IN 0']))
+        self.assertEqual(len(y['time']), len(y['Cmd 0']))
+
+        y = abf.log(join_sweeps=True, use_names=True, channels=['IN 0'])
+        self.assertEqual(len(y), 2)
+        self.assertIn('time', y.keys())
+        self.assertIn('IN 0', y.keys())
 
     def test_matplotlib_figure(self):
         # Test figure drawing method (doesn't inspect output).
@@ -189,7 +443,8 @@ def test_matplotlib_figure(self):
         matplotlib.use('template')
         path = os.path.join(DIR_FORMATS, 'abf-v1.abf')
         abf = axon.AbfFile(path)
-        abf.matplotlib_figure()
+        f = abf.matplotlib_figure()
+        self.assertIsInstance(f, matplotlib.figure.Figure)
 
 
 class AtfTest(unittest.TestCase):
@@ -219,6 +474,15 @@ def test_write_read(self):
             for k, v in log.items():
                 self.assertTrue(np.all(v == log2[k]))
 
+            # Deprecated method
+            with WarningCollector() as w:
+                log3 = axon.AtfFile(path).myokit_log()
+            self.assertIn('deprecated', w.text())
+            self.assertEqual(len(log), len(log3))
+            self.assertEqual(set(log.keys()), set(log3.keys()))
+            for k, v in log.items():
+                self.assertTrue(np.all(v == log3[k]))
+
             # Write selected fields
             axon.save_atf(log, path, fields=['time', 'sint'])
             log2 = axon.load_atf(path)
@@ -272,7 +536,7 @@ def test_write_read(self):
                 f.write('1\t10\t20\n')
                 f.write('2\t30\t40\n')
             self.assertRaisesRegex(
-                Exception, 'double quotation', axon.load_atf, path)
+                Exception, 'double quotes', axon.load_atf, path)
 
             # Bad column headers
             with open(path, 'w') as f:
@@ -363,8 +627,9 @@ def test_accessors(self):
             # Read atf file
             atf = myokit.formats.axon.AtfFile(path)
 
-            # Test filename()
-            self.assertEqual(atf.filename(), path)
+            # Test filename() and path()
+            self.assertEqual(atf.path(), path)
+            self.assertEqual(atf.filename(), 'test.atf')
 
             # Test iter and getitem
             self.assertEqual(len(list(iter(atf))), 3)
diff --git a/myokit/tests/test_formats_wcp.py b/myokit/tests/test_formats_wcp.py
index 3d7998d4e..95a01afbb 100755
--- a/myokit/tests/test_formats_wcp.py
+++ b/myokit/tests/test_formats_wcp.py
@@ -12,7 +12,34 @@
 
 import myokit.formats.wcp as wcp
 
-from myokit.tests import DIR_FORMATS
+from myokit.tests import DIR_FORMATS, WarningCollector
+
+
+INFO = '''
+WinWCP file: wcp-file.wcp
+WinWCP Format version 9
+Recorded on: 21/11/2014 14:18:28
+  Number of records: 11
+  Channels per record: 2
+  Samples per channel: 256
+  Sampling interval: 0.001 s
+A/D channel: Im
+  Unit: pA
+A/D channel: Vm
+  Unit: mV
+Records: Type, Status, Sampling Interval, Start, Marker
+Record 0: TEST, ACCEPTED, 0.001, 0.0, ""
+Record 1: TEST, ACCEPTED, 0.001, 0.5, ""
+Record 2: TEST, ACCEPTED, 0.001, 1.0, ""
+Record 3: TEST, ACCEPTED, 0.001, 1.5, ""
+Record 4: TEST, ACCEPTED, 0.001, 2.0615234375, ""
+Record 5: TEST, ACCEPTED, 0.001, 3.0615234375, ""
+Record 6: TEST, ACCEPTED, 0.001, 3.5615234375, ""
+Record 7: TEST, ACCEPTED, 0.001, 4.0615234375, ""
+Record 8: TEST, ACCEPTED, 0.001, 4.5615234375, ""
+Record 9: TEST, ACCEPTED, 0.001, 5.125, ""
+Record 10: TEST, ACCEPTED, 0.001, 5.625, ""
+'''.strip()
 
 
 class WcpTest(unittest.TestCase):
@@ -27,39 +54,126 @@ def test_data_file(self):
         fname = 'wcp-file.wcp'
         path = os.path.join(DIR_FORMATS, fname)
         w = wcp.WcpFile(path)
+        self.assertEqual(w.version(), '9')
 
-        self.assertEqual(w.channels(), 2)
-        self.assertEqual(w.channel_names(), ['Im', 'Vm'])
         self.assertEqual(w.filename(), fname)
         self.assertEqual(w.path(), path)
-        self.assertEqual(w.records(), 11)
-        #print(w.sampling_interval())
+        self.assertEqual(w.record_count(), 11)
+        self.assertEqual(w.sample_count(), 256)
         self.assertEqual(len(w.times()), 256)
         self.assertEqual(len(w.values(0, 0)), 256)
 
-    def test_data_log_conversion(self):
-        # Test conversion to a data log.
+        # Test meta data
+        self.maxDiff = None
+        self.assertEqual(w.info(), INFO)
 
-        w = wcp.WcpFile(os.path.join(DIR_FORMATS, 'wcp-file.wcp'))
-        d = w.myokit_log()
-        keys = ['time']
-        for cn in w.channel_names():
-            for i in range(w.records()):
-                keys.append(str(i) + '.' + cn)
-        self.assertEqual(set(d.keys()), set(keys))
-        self.assertEqual(len(d.time()), 256)
-        self.assertTrue(np.all(d.time() == w.times()))
-
-    def test_plot_method(self):
-        # Test the plot method.
+        # Test Sequence interface
+        self.assertEqual(len(w), w.record_count())
+        self.assertEqual(np.array(w[0]).shape, (2, 256))
+        self.assertEqual(len([r for r in w]), w.record_count())
+
+        # Test SweepSource interface
+        self.assertEqual(w.channel_count(), 2)
+        self.assertEqual(w.channel_names(), ['Im', 'Vm'])
+        self.assertEqual(w.sweep_count(), w.record_count())
+
+        # Test SweepSource.channel()
+        # Without joining
+        x = w.channel(0)
+        y = w.channel(1)
+        self.assertEqual(len(x), len(y), w.sweep_count())
+        self.assertEqual(len(x[0]), w.sample_count())
+        self.assertEqual(len(x[1]), w.sample_count())
+        self.assertEqual(len(x[2]), w.sample_count())
+        self.assertTrue(np.all(x[1] == w.channel('Im')[1]))
+        self.assertTrue(np.all(x[1] != w.channel('Vm')[1]))
+        self.assertTrue(np.all(y[1] == w.channel('Vm')[1]))
+
+        # With joining
+        x = w.channel(0, join_sweeps=True)
+        y = w.channel(1, join_sweeps=True)
+        self.assertEqual(len(x), len(y), 2)
+        self.assertEqual(len(x[0]), w.sample_count() * w.sweep_count())
+        self.assertEqual(len(x[0]), len(x[1]))
+        self.assertEqual(len(x[0]), len(y[0]), len(y[1]))
+        self.assertTrue(np.all(x[1] == w.channel('Im', True)[1]))
+        self.assertTrue(np.all(x[1] != w.channel('Vm', True)[1]))
+        self.assertTrue(np.all(y[1] == w.channel('Vm', True)[1]))
+
+        # Channel doesn't exist
+        self.assertRaises(IndexError, w.channel, -1)
+        self.assertRaises(IndexError, w.channel, 2)
+        self.assertRaises(KeyError, w.channel, 'Tom')
+
+        # Conversion to log
+        # Without joining
+        d = w.log()
+        k = ['time']
+        for r in range(w.record_count()):
+            for c in range(w.channel_count()):
+                k.append(f'{r}.{c}.channel')
+        self.assertEqual(set(k), set(d.keys()))
+        self.assertTrue(np.all(d['time'] == w.channel(0)[0]))
+        self.assertTrue(np.all(d['1.0.channel'] == w.channel(0)[2]))
 
+        # Without joining, use names
+        d = w.log(use_names=True)
+        k = ['time']
+        for c in w.channel_names():
+            for r in range(w.record_count()):
+                k.append(f'{r}.{c}')
+        self.assertEqual(set(k), set(d.keys()))
+        self.assertTrue(np.all(d['time'] == w.channel(0)[0]))
+        self.assertTrue(np.all(d['1.Im'] == w.channel(0)[2]))
+
+        # With joining
+        d = w.log(join_sweeps=True)
+        self.assertEqual(list(d.keys()), ['time', '0.channel', '1.channel'])
+        self.assertTrue(np.all(d['time'] == w.channel(0, True)[0]))
+        self.assertTrue(np.all(d['0.channel'] == w.channel(0, True)[1]))
+
+        # With joining, use names
+        d = w.log(join_sweeps=True, use_names=True)
+        self.assertEqual(list(d.keys()), ['time', 'Im', 'Vm'])
+        self.assertTrue(np.all(d['time'] == w.channel(0, True)[0]))
+        self.assertTrue(np.all(d['Vm'] == w.channel(1, True)[1]))
+
+        # Selected channels
+        d = w.log(join_sweeps=True, use_names=True, channels=['Im'])
+        self.assertEqual(list(d.keys()), ['time', 'Im'])
+        d = w.log(join_sweeps=True, use_names=True, channels=[1])
+        self.assertEqual(list(d.keys()), ['time', 'Vm'])
+
+        # Deprecated methods
+        with WarningCollector() as c:
+            self.assertEqual(w.records(), w.record_count())
+        self.assertIn('deprecated', c.text())
+        with WarningCollector() as c:
+            self.assertEqual(w.channels(), w.channel_count())
+        self.assertIn('deprecated', c.text())
+        with WarningCollector() as c:
+            e = w.myokit_log()
+        self.assertIn('deprecated', c.text())
+
+    def test_figure_method(self):
+        # Tests matplotlib_figure
         # Select matplotlib backend that doesn't require a screen
         import matplotlib
         matplotlib.use('template')
+        w = wcp.WcpFile(os.path.join(DIR_FORMATS, 'wcp-file.wcp'))
+        f = w.matplotlib_figure()
+        self.assertIsInstance(f, matplotlib.figure.Figure)
 
-        # Load and create plots
+    def test_figure_method_deprecated(self):
+        # Tests matplotlib_figure
+        # Select matplotlib backend that doesn't require a screen
+        import matplotlib
+        matplotlib.use('template')
         w = wcp.WcpFile(os.path.join(DIR_FORMATS, 'wcp-file.wcp'))
-        w.plot()
+        with WarningCollector() as c:
+            f = w.plot()
+        self.assertIn('deprecated', c.text())
+        self.assertIsNone(f, matplotlib.figure.Figure)
 
 
 if __name__ == '__main__':