Repository: dstl/Stone-Soup

Issue:
Read frames from real-time video streams from USB camera
Hi experts,

Thank you for providing an example of how to build a FFmpegVideoStreamReader for a camera which broadcasts its feed through a public RTSP stream. I were wondering whether I could use the component of FFmpegVideoStreamReader for reading real-time video streams from USB camera. Thank you!!

Kind regards,

Discussion:

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index f3db3df5..991e56bc 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -6,7 +6,6 @@ workflows:
       - test-36
       - test-37
       - test-38
-      - test-39
   docs:
     jobs:
       - docs
@@ -53,17 +52,13 @@ jobs:
     <<: *test-template
     docker:
       - image: circleci/python:3.8
-  test-39:
-    <<: *test-template
-    docker:
-      - image: circleci/python:3.9
   docs:
     docker:
-      - image: circleci/python:3.9
+      - image: circleci/python:3.8
     steps:
       - checkout
       - restore_cache:
-          key: v1-dependencies-test-39-{{ checksum "setup.py" }}
+          key: v1-dependencies-test-38-{{ checksum "setup.py" }}
       - run:
           name: Install Dependencies
           command: |
@@ -73,7 +68,7 @@ jobs:
       - save_cache:
           paths:
             - ./venv
-          key: v1-dependencies-test-39-{{ checksum "setup.py" }}
+          key: v1-dependencies-test-38-{{ checksum "setup.py" }}
       - run:
           name: Build Docs
           command: |
diff --git a/.codecov.yml b/.codecov.yml
deleted file mode 100644
index d960d30d..00000000
--- a/.codecov.yml
+++ /dev/null
@@ -1,10 +0,0 @@
-coverage:
-  status:
-    project:
-      default:
-        threshold: 0.1%
-codecov:
-  notify:
-    after_n_builds: 5
-comment:
-  after_n_builds: 5
diff --git a/docs/source/conf.py b/docs/source/conf.py
index c223b155..0e129c71 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -53,7 +53,8 @@ autodoc_default_options = {
     'members': None,
     'member-order': 'bysource',
 }
-autodoc_mock_imports = ['ffmpeg', 'moviepy', 'tensorflow', 'object_detection']
+autodoc_mock_imports = [
+    'ffmpeg', 'moviepy', 'tensorflow', 'object_detection', 'tensornets']
 
 autosectionlabel_prefix_document = True
 
diff --git a/docs/source/stonesoup.detector.rst b/docs/source/stonesoup.detector.rst
index f0e86d4b..f1aa5e1f 100644
--- a/docs/source/stonesoup.detector.rst
+++ b/docs/source/stonesoup.detector.rst
@@ -7,7 +7,13 @@ Detectors
 .. automodule:: stonesoup.detector.base
     :show-inheritance:
 
+TensorFlow
+----------
 .. automodule:: stonesoup.detector.tensorflow
     :show-inheritance:
 
+TensorNets
+----------
+.. automodule:: stonesoup.detector.tensornets
+    :show-inheritance:
 
diff --git a/setup.py b/setup.py
index 3e9b6f2c..c229c61f 100644
--- a/setup.py
+++ b/setup.py
@@ -33,6 +33,7 @@ setup(name='stonesoup',
               'pytest-flake8', 'pytest-cov', 'Sphinx', 'sphinx_rtd_theme',
               'setuptools>=30', 'sphinx-gallery>=0.8', 'pillow', 'folium'],
           'video': ['ffmpeg-python', 'moviepy'],
-          'tensorflow': ['tensorflow>=2.2.0']
+          'tensorflow': ['tensorflow>=2.2.0'],
+          'tensornets': ['tensorflow>=2.2.0', 'tensornets'],
       },
       )
diff --git a/stonesoup/base.py b/stonesoup/base.py
index a96801bd..c8f89137 100644
--- a/stonesoup/base.py
+++ b/stonesoup/base.py
@@ -58,7 +58,7 @@ import inspect
 import weakref
 from abc import ABCMeta
 from collections import OrderedDict
-from copy import deepcopy, copy
+from copy import deepcopy
 from types import MappingProxyType
 
 
@@ -104,6 +104,7 @@ class Property:
         Alias to :class:`inspect.Parameter.empty`
     """
     empty = inspect.Parameter.empty
+    _property_name = None
 
     def __init__(self, cls=None, *, default=inspect.Parameter.empty, doc=None,
                  readonly=False):
@@ -113,11 +114,21 @@ class Property:
         # Fix for when ":" in doc string being interpreted as type in NumpyDoc
         if doc is not None and ':' in doc:
             self.__doc__ = ": " + doc
-        self._property_name = None
         self._setter = None
         self._getter = None
         self._deleter = None
-        self.readonly = readonly
+
+        if readonly:
+            def _setter(instance, value):
+                # if the value hasn't been set before then we can set it once
+                if not hasattr(instance, self._property_name):
+                    setattr(instance, self._property_name, value)
+                else:
+                    # if the value has been set, raise an AttributeError
+                    raise AttributeError(
+                        '{} is readonly'.format(self._property_name))
+
+            self._setter = _setter
 
     def __get__(self, instance, owner):
         if instance is None:
@@ -128,14 +139,6 @@ class Property:
             return self._getter(instance)
 
     def __set__(self, instance, value):
-        if self.readonly:
-            if not hasattr(instance, self._property_name):
-                setattr(instance, self._property_name, value)
-            else:
-                # if the value has been set, raise an AttributeError
-                raise AttributeError(
-                    '{} is readonly'.format(self._property_name))
-
         if self._setter is None:
             setattr(instance, self._property_name, value)
         else:
@@ -154,21 +157,15 @@ class Property:
 
     def deleter(self, method):  # real signature unknown
         """ Descriptor to change the deleter on a property. """
-        new_property = copy(self)
-        new_property._deleter = method
-        return new_property
+        self._deleter = method
 
     def getter(self, method):  # real signature unknown
         """ Descriptor to change the getter on a property. """
-        new_property = copy(self)
-        new_property._getter = method
-        return new_property
+        self._getter = method
 
     def setter(self, method):  # real signature unknown
         """ Descriptor to change the setter on a property. """
-        new_property = copy(self)
-        new_property._setter = method
-        return new_property
+        self._setter = method
 
 
 class BaseMeta(ABCMeta):
@@ -200,49 +197,48 @@ class BaseMeta(ABCMeta):
 
         cls._subclasses = set()
         cls._properties = OrderedDict()
-        # Update subclass lists, and update properties from direct bases (in reverse order as
-        # first defined class must take precedence, and dictionary update overwrites)
-        for base_class in reversed(cls.mro()[1:]):
-            if type(base_class) is mcls:
-                base_class._subclasses.add(cls)
-                if base_class in bases:
-                    cls._properties.update(base_class._properties)
-
+        # Update subclass lists, and update properties (in reverse order)
+        for bcls in reversed(cls.mro()[1:]):
+            if type(bcls) is mcls:
+                bcls._subclasses.add(cls)
+                cls._properties.update(bcls._properties)
         for key, value in namespace.items():
             if isinstance(value, Property):
-                annotation_cls = namespace.get('__annotations__', {}).get(key, None)
+                cls._properties[key] = value
+                if '__annotations__' in namespace:
+                    annotation_cls = namespace['__annotations__'].get(key, None)
+                else:
+                    annotation_cls = None
                 if value.cls is not None and annotation_cls is not None:
                     raise ValueError(f'Type was specified both by type hint '
                                      f'({str(annotation_cls)}) and argument ({str(value.cls)}) '
                                      f'for property {key} of class {name}')
                 elif value.cls is None and annotation_cls is not None:
-                    value.cls = annotation_cls
+                    value.cls = namespace['__annotations__'][key]
+                    if isinstance(value.cls, str) and value.cls == name:
+                        value.cls = cls
                 elif value.cls is not None and annotation_cls is None:
                     # Just use value.cls in this case
                     pass
                 elif value.cls is None and annotation_cls is None:
                     raise ValueError(f'Type was not specified '
                                      f'for property {key} of class {name}')
-
-                if isinstance(value.cls, str) and value.cls == name:
-                    value.cls = cls
-
-                if not (isinstance(value.cls, type)
-                        or getattr(value.cls, '__module__', "") == 'typing'):
-                    raise ValueError(f'Invalid type specification ({str(value.cls)}) '
-                                     f'for property {key} of class {cls.__name__}')
-
-                # Finally set property.
-                cls._properties[key] = value
-
-            elif key in cls._properties:
-                # New definition of "key" which isn't a Property any more.
-                del cls._properties[key]
-
-        for prop_name in list(cls._properties):
+        cls._properties.update(
+            (key, value) for key, value in namespace.items()
+            if isinstance(value, Property))
+        for name in list(cls._properties):
+            # Remove items which are no longer properties
+            if name in namespace and not isinstance(namespace[name], Property):
+                del cls._properties[name]
+                continue
             # Optional arguments must follow mandatory
-            if cls._properties[prop_name].default is not Property.empty:
-                cls._properties.move_to_end(prop_name)
+            if cls._properties[name].default is not Property.empty:
+                cls._properties.move_to_end(name)
+        for prop_name, prop in cls._properties.items():
+            if not (isinstance(prop.cls, type) or
+                    (hasattr(prop.cls, '__module__') and prop.cls.__module__ == 'typing')):
+                raise ValueError(f'Invalid type specification ({str(prop.cls)}) '
+                                 f'for property {prop_name} of class {cls.__name__}')
 
         cls._validate_init()
         cls._generate_signature()
diff --git a/stonesoup/detector/_video.py b/stonesoup/detector/_video.py
new file mode 100644
index 00000000..55695bc6
--- /dev/null
+++ b/stonesoup/detector/_video.py
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+import threading
+from abc import abstractmethod
+from copy import copy
+
+from .base import Detector
+from ..base import Property
+from ..buffered_generator import BufferedGenerator
+
+
+class _VideoAsyncBoxDetector(Detector):
+    """Video Async Detector Abstract Class
+
+    Abstract class for use with Video based detectors, which includes an async
+    option where video frames will be dropped in order to process the latest
+    frame (applicable when processing live video).
+    """
+
+    run_async: bool = Property(
+        doc="If set to ``True``, the detector will digest frames from the reader asynchronously "
+            "and only perform detection on the last frame digested. This is suitable when the "
+            "detector is applied to readers generating a live feed (e.g. "
+            ":class:`~.FFmpegVideoStreamReader`), where real-time processing is paramount. "
+            "Defaults to ``False``",
+        default=False)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # Variables used in async mode
+        if self.run_async:
+            self._buffer = None
+            # Initialise frame capture thread
+            self._capture_thread = threading.Thread(target=self._capture)
+            self._capture_thread.daemon = True
+            self._thread_lock = threading.Lock()
+            self._capture_thread.start()
+
+    @BufferedGenerator.generator_method
+    def detections_gen(self):
+        """Returns a generator of detections for each frame.
+
+        Yields
+        ------
+        : :class:`datetime.datetime`
+            Datetime of current time step
+        : set of :class:`~.Detection`
+            Detections generated in the time step. The detection state vector is of the form
+            ``(x, y, w, h)``, where ``x, y`` denote the relative coordinates of the top-left
+            corner of the bounding box containing the object, while ``w, h`` denote the relative
+            width and height of the bounding box. Additionally, each detection carries the
+            following meta-data fields:
+
+            - ``raw_box``: The raw bounding box, as generated by TensorFlow.
+            - ``class``: A dict with keys ``id`` and ``name`` relating to the \
+              id and name of the detection class, as specified by the label map.
+            - ``score``: A float in the range ``(0, 1]`` indicating the detector's confidence
+
+        """
+        if self.run_async:
+            yield from self._detections_gen_async()
+        else:
+            yield from self._detections_gen()
+
+    def _capture(self):
+        for timestamp, frame in self.sensor:
+            self._thread_lock.acquire()
+            self._buffer = frame
+            self._thread_lock.release()
+
+    def _detections_gen(self):
+        for timestamp, frame in self.sensor:
+            detections = self._get_detections_from_frame(frame)
+            yield timestamp, detections
+
+    def _detections_gen_async(self):
+        while self._capture_thread.is_alive():
+            if self._buffer is not None:
+                self._thread_lock.acquire()
+                frame = copy(self._buffer)
+                self._buffer = None
+                self._thread_lock.release()
+
+                detections = self._get_detections_from_frame(frame)
+
+                yield frame.timestamp, detections
+
+    @abstractmethod
+    def _get_detections_from_frame(self, frame):
+        raise NotImplementedError()
diff --git a/stonesoup/detector/tensorflow.py b/stonesoup/detector/tensorflow.py
index b1a1d299..fcc9178e 100644
--- a/stonesoup/detector/tensorflow.py
+++ b/stonesoup/detector/tensorflow.py
@@ -1,4 +1,7 @@
 # -*- coding: utf-8 -*-
+from pathlib import Path
+
+import numpy as np
 try:
     import tensorflow as tf
     from object_detection.utils import label_map_util as lm_util
@@ -9,19 +12,13 @@ except ImportError as error:
         "https://tensorflow-object-detection-api-tutorial.readthedocs.io/en/latest/install.html")\
         from error
 
-import threading
-import numpy as np
-from copy import copy
-from pathlib import Path
-
-from .base import Detector
+from ._video import _VideoAsyncBoxDetector
 from ..base import Property
-from ..buffered_generator import BufferedGenerator
 from ..types.array import StateVector
 from ..types.detection import Detection
 
 
-class TensorFlowBoxObjectDetector(Detector):
+class TensorFlowBoxObjectDetector(_VideoAsyncBoxDetector):
     """TensorFlowBoxObjectDetector
 
     A box object detector that generates detections of objects in the form of bounding boxes 
@@ -56,14 +53,6 @@ class TensorFlowBoxObjectDetector(Detector):
         doc="Path to label map (``*.pbtxt`` file). This is the file that contains mapping of "
             "object/class ids to meaningful names")
 
-    run_async: bool = Property(
-        doc="If set to ``True``, the detector will digest frames from the reader asynchronously "
-            "and only perform detection on the last frame digested. This is suitable when the "
-            "detector is applied to readers generating a live feed (e.g. "
-            ":class:`~.FFmpegVideoStreamReader`), where real-time processing is paramount. "
-            "Defaults to ``False``",
-        default=False)
-
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
@@ -81,64 +70,6 @@ class TensorFlowBoxObjectDetector(Detector):
         self.category_index = lm_util.create_category_index_from_labelmap(self.labels_path,
                                                                           use_display_name=True)
 
-        # Variables used in async mode
-        if self.run_async:
-            self._buffer = None
-            # Initialise frame capture thread
-            self._capture_thread = threading.Thread(target=self._capture)
-            self._capture_thread.daemon = True
-            self._thread_lock = threading.Lock()
-            self._capture_thread.start()
-
-    @BufferedGenerator.generator_method
-    def detections_gen(self):
-        """Returns a generator of detections for each frame.
-
-        Yields
-        ------
-        : :class:`datetime.datetime`
-            Datetime of current time step
-        : set of :class:`~.Detection`
-            Detections generated in the time step. The detection state vector is of the form
-            ``(x, y, w, h)``, where ``x, y`` denote the relative coordinates of the top-left
-            corner of the bounding box containing the object, while ``w, h`` denote the relative
-            width and height of the bounding box. Additionally, each detection carries the
-            following meta-data fields:
-
-            - ``raw_box``: The raw bounding box, as generated by TensorFlow.
-            - ``class``: A dict with keys ``id`` and ``name`` relating to the \
-              id and name of the detection class, as specified by the label map.
-            - ``score``: A float in the range ``(0, 1]`` indicating the detector's confidence
-
-        """
-        if self.run_async:
-            yield from self._detections_gen_async()
-        else:
-            yield from self._detections_gen()
-
-    def _capture(self):
-        for timestamp, frame in self.sensor:
-            self._thread_lock.acquire()
-            self._buffer = frame
-            self._thread_lock.release()
-
-    def _detections_gen(self):
-        for timestamp, frame in self.sensor:
-            detections = self._get_detections_from_frame(frame)
-            yield timestamp, detections
-
-    def _detections_gen_async(self):
-        while self._capture_thread.is_alive():
-            if self._buffer is not None:
-                self._thread_lock.acquire()
-                frame = copy(self._buffer)
-                self._buffer = None
-                self._thread_lock.release()
-
-                detections = self._get_detections_from_frame(frame)
-
-                yield frame.timestamp, detections
-
     def _get_detections_from_frame(self, frame):
         # The input needs to be a tensor, convert it using `tf.convert_to_tensor`.
         input_tensor = tf.convert_to_tensor(frame.pixels)
diff --git a/stonesoup/detector/tensornets.py b/stonesoup/detector/tensornets.py
new file mode 100644
index 00000000..127c5da2
--- /dev/null
+++ b/stonesoup/detector/tensornets.py
@@ -0,0 +1,109 @@
+# -*- coding: utf-8 -*-
+import enum
+
+import numpy as np
+try:
+    import tensorflow as tf
+
+    try:
+        _tf_version = int(tf.__version__.split('.')[0])
+    except TypeError:
+        # Occurs with Sphinx due to mock imports
+        _tf_version = 1
+    if _tf_version > 1:
+        import tensorflow.compat.v1 as tf
+        tf.disable_v2_behavior()
+
+    import tensornets
+    from tensornets import datasets
+except ImportError as error:
+    raise ImportError(
+        "Usage of 'stonesoup.detector.tensornets' requires that the optional"
+        "package dependencies 'tensorflow' and 'tensornets' are installed.") \
+        from error
+
+from ._video import _VideoAsyncBoxDetector
+from ..base import Property
+from ..types.detection import Detection
+
+
+class Networks(enum.Enum):
+    """TensorNet pre-trained networks, supported by :class:`TensorNetsObjectDetector`
+
+    See TensorNets documentation for more information on these networks.
+    """
+    def __repr__(self):
+        # Suppress value, as not important
+        return '<%s.%s>' % (self.__class__.__name__, self.name)
+
+    def _generate_next_value_(name, start, count, last_values):
+        # Use name to grab function from tensornets
+        return getattr(tensornets, name)
+
+    YOLOv2VOC = enum.auto()  #: YOLOv2 trained against PASCAL VOC dataset
+    YOLOv2COCO = enum.auto()  #: YOLOv2 tranined against COCO dataset
+    TinyYOLOv2VOC = enum.auto()  #: TinyYOLOv2 trained against PASCAL VOC dataset
+    TinyYOLOv2COCO = enum.auto()  #: TinyYOLOv2 trained against COCO dataset
+    YOLOv3VOC = enum.auto()  #: YOLOv3 trained against PASCAL VOC dataset
+    YOLOv3COCO = enum.auto()  #: YOLOv3 tranined against COCO dataset
+
+
+class TensorNetsBoxObjectDetector(_VideoAsyncBoxDetector):
+    """TensorNets Object Detection class
+
+    This uses pre-trained networks from TensorNets for object detection in video
+    frames. Supported networks are listed in :class:`Networks`.
+    """
+    net: Networks = Property(doc="TensorNet network to use for object detection")
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.net = Networks(self.net)  # Ensure Networks enum
+        self._inputs = tf.placeholder(tf.float32, [None, 416, 416, 3])
+        self._model = self.net.value(self._inputs)
+        self._session = tf.Session()
+
+        self._session.run(self._model.pretrained())
+
+    @property
+    def class_names(self):
+        if 'VOC' in self.net.name:
+            class_names = datasets.voc.classnames
+        elif 'COCO' in self.net.name:
+            class_names = datasets.coco.classnames
+        else:
+            raise NotImplementedError("Unsupported network {!r}".format(self.net))
+        return class_names
+
+    def _run(self, image):
+        if 'YOLOv2' in self.net.name:
+            fetches = self._model
+        elif 'YOLOv3' in self.net.name:
+            fetches = self._model.preds
+        else:
+            raise NotImplementedError("Unsupported network {!r}".format(self.net))
+        return self._session.run(fetches, {self._inputs: self._model.preprocess(image)})
+
+    def _get_detections_from_frame(self, frame):
+        image_np_expanded = np.expand_dims(frame.pixels, axis=0)
+        preds = self._run(image_np_expanded)
+        boxes = self._model.get_boxes(preds, frame.pixels.shape[:2])
+
+        detections = set()
+        for class_id, (class_name, class_boxes) in enumerate(zip(self.class_names, boxes)):
+            for box in class_boxes:
+                metadata = {
+                    "raw_box": box,
+                    "class": {'name': class_name, 'id': class_id},
+                    "class_name": class_name,
+                    "class_id": class_id,
+                    "score": box[-1],
+                }
+                # Transform box to be in format (x, y, w, h)
+                detection = Detection(
+                    [box[0], box[1], box[2] - box[0], box[3] - box[1]],
+                    timestamp=frame.timestamp,
+                    metadata=metadata)
+                detections.add(detection)
+
+        return detections
diff --git a/stonesoup/metricgenerator/tracktotruthmetrics.py b/stonesoup/metricgenerator/tracktotruthmetrics.py
index 4935f8db..87897ddf 100644
--- a/stonesoup/metricgenerator/tracktotruthmetrics.py
+++ b/stonesoup/metricgenerator/tracktotruthmetrics.py
@@ -1,6 +1,5 @@
 # -*- coding: utf-8 -*-
 import datetime
-import warnings
 from operator import attrgetter
 
 import numpy as np
@@ -13,7 +12,7 @@ from ..types.time import TimeRange
 class SIAPMetrics(MetricGenerator):
     """SIAP Metrics
 
-    Computes the Single Integrated Air Picture (SIAP) metrics as defined by the
+    Computes the Single Integrated Air Picture (SAIP) metrics as defined by the
     Systems Engineering Task Force. The implementation provided here is
     derived from [1] and focuses on providing the SIAP attribute measures.
 
@@ -49,16 +48,6 @@ class SIAPMetrics(MetricGenerator):
                    self.LS(manager)]
         return metrics
 
-    @staticmethod
-    def _warn_no_truth(manager):
-        if len(manager.groundtruth_paths) == 0:
-            warnings.warn("No truth to generate SIAP Metric", stacklevel=2)
-
-    @staticmethod
-    def _warn_no_tracks(manager):
-        if len(manager.tracks) == 0:
-            warnings.warn("No tracks to generate SIAP Metric", stacklevel=2)
-
     def C_single_time(self, manager, timestamp):
         r"""SIAP metric C at a specific time
 
@@ -137,12 +126,8 @@ class SIAPMetrics(MetricGenerator):
         """
 
         timestamps = manager.list_timestamps()
-        try:
-            C = self._jt_sum(manager, timestamps) / self._j_sum(
-                manager, timestamps)
-        except ZeroDivisionError:
-            self._warn_no_truth(manager)
-            C = 0
+        C = self._jt_sum(manager, timestamps) / self._j_sum(
+            manager, timestamps)
         return TimeRangeMetric(
             title="SIAP C",
             value=C,
@@ -180,12 +165,8 @@ class SIAPMetrics(MetricGenerator):
         """
 
         timestamps = manager.list_timestamps()
-        try:
-            A = self._na_sum(manager, timestamps) / self._jt_sum(manager, timestamps)
-        except ZeroDivisionError:
-            self._warn_no_truth(manager)
-            self._warn_no_tracks(manager)
-            A = 1
+        A = self._na_sum(manager, timestamps) / self._jt_sum(
+            manager, timestamps)
         return TimeRangeMetric(
             title="SIAP A",
             value=A,
@@ -226,11 +207,7 @@ class SIAPMetrics(MetricGenerator):
             self._n_t(manager, timestamp) - self._na_t(manager, timestamp)
             for timestamp in timestamps)
 
-        try:
-            S = numerator / self._n_sum(manager, timestamps)
-        except ZeroDivisionError:
-            self._warn_no_tracks(manager)
-            S = 0
+        S = numerator / self._n_sum(manager, timestamps)
         return TimeRangeMetric(
             title="SIAP S",
             value=S,
@@ -257,8 +234,6 @@ class SIAPMetrics(MetricGenerator):
         r = self._r(manager)
         if r == 0:
             value = np.inf
-            self._warn_no_truth(manager)
-            self._warn_no_tracks(manager)
         else:
             value = 1 / r
 
@@ -302,14 +277,9 @@ class SIAPMetrics(MetricGenerator):
                           for truth in manager.groundtruth_paths)
 
         timestamps = manager.list_timestamps()
-        try:
-            LS = numerator / denominator
-        except ZeroDivisionError:
-            self._warn_no_truth(manager)
-            LS = 0
         return TimeRangeMetric(
             title="SIAP LS",
-            value=LS,
+            value=numerator / denominator,
             time_range=TimeRange(min(timestamps), max(timestamps)),
             generator=self)
 
@@ -601,11 +571,7 @@ class SIAPMetrics(MetricGenerator):
                         for truth in manager.groundtruth_paths)
         denominator = sum(self._tt_j(manager, truth).total_seconds()
                           for truth in manager.groundtruth_paths)
-        try:
-            return numerator / denominator
-        except ZeroDivisionError:
-            # No truth or tracks
-            return 0
+        return numerator / denominator
 
     def _t_j(self, truth):
         """Total time truth exists for
diff --git a/stonesoup/models/measurement/nonlinear.py b/stonesoup/models/measurement/nonlinear.py
index 526de430..d76a9903 100644
--- a/stonesoup/models/measurement/nonlinear.py
+++ b/stonesoup/models/measurement/nonlinear.py
@@ -138,7 +138,7 @@ class NonLinearGaussianMeasurement(MeasurementModel, NonLinearModel, GaussianMod
         """
 
         theta_x = -self.rotation_offset[0, 0]
-        theta_y = self.rotation_offset[1, 0]
+        theta_y = -self.rotation_offset[1, 0]
         theta_z = -self.rotation_offset[2, 0]
 
         return rotz(theta_z)@roty(theta_y)@rotx(theta_x)
diff --git a/stonesoup/platform/base.py b/stonesoup/platform/base.py
index 3512cc99..2300467c 100644
--- a/stonesoup/platform/base.py
+++ b/stonesoup/platform/base.py
@@ -123,15 +123,15 @@ class Platform(StateMutableSequence, ABC):
         return None if value is None else tuple(value)
 
     @sensors.getter
-    def sensors(self):
+    def get_sensors(self):
         return self._tuple_or_none(self._property_sensors)
 
     @mounting_offsets.getter
-    def mounting_offsets(self):
+    def get_mounting_offsets(self):
         return self._tuple_or_none(self._property_mounting_offsets)
 
     @rotation_offsets.getter
-    def rotation_offsets(self):
+    def get_rotation_offsets(self):
         return self._tuple_or_none(self._property_rotation_offsets)
 
     @property
@@ -522,33 +522,17 @@ class MultiTransitionMovingPlatform(MovingPlatform):
             # TypeError: (timestamp or prior.timestamp) is None
             return
 
-        temp_state = self.state
         while time_interval != 0:
             if time_interval >= self.current_interval:
-                temp_state = State(
-                    state_vector=self.transition_model.function(
-                        state=temp_state,
-                        noise=True,
-                        time_interval=self.current_interval,
-                        **kwargs),
-                    timestamp=timestamp
-                )
+                super().move(timestamp=self.state.timestamp+self.current_interval, **kwargs)
                 time_interval -= self.current_interval
                 self.transition_index = (self.transition_index + 1) % len(self.transition_models)
                 self.current_interval = self.transition_times[self.transition_index]
 
             else:
-                temp_state = State(
-                    state_vector=self.transition_model.function(
-                        state=temp_state,
-                        noise=True,
-                        time_interval=time_interval,
-                        **kwargs),
-                    timestamp=timestamp
-                )
+                super().move(timestamp=self.state.timestamp+time_interval, **kwargs)
                 self.current_interval -= time_interval
                 time_interval = 0
-        self.states.append(temp_state)
 
 
 def _get_rotation_matrix(vel: StateVector) -> np.ndarray:
diff --git a/stonesoup/reader/video.py b/stonesoup/reader/video.py
index a069ce09..b3025367 100644
--- a/stonesoup/reader/video.py
+++ b/stonesoup/reader/video.py
@@ -5,8 +5,13 @@ This is a collection of video readers for Stone Soup, allowing quick reading
 of video data/streams.
 """
 
-from abc import abstractmethod
 import datetime
+import threading
+from abc import abstractmethod
+from queue import Queue
+from typing import Mapping, Tuple, Sequence, Any
+from urllib.parse import ParseResult
+
 import numpy as np
 try:
     import ffmpeg
@@ -19,15 +24,13 @@ except ImportError as error:
         "'python -m pip install stonesoup[video]'")\
         from error
 
-import threading
-from queue import Queue
 
-from ..base import Property
-from ..buffered_generator import BufferedGenerator
-from ..types.sensordata import ImageFrame
 from .base import SensorDataReader
 from .file import FileReader
 from .url import UrlReader
+from ..base import Property
+from ..buffered_generator import BufferedGenerator
+from ..types.sensordata import ImageFrame
 
 
 class FrameReader(SensorDataReader):
@@ -151,50 +154,72 @@ class FFmpegVideoStreamReader(UrlReader, FrameReader):
     .. _ffmpeg-python: https://github.com/kkroening/ffmpeg-python
     .. _FFmpeg: https://www.ffmpeg.org/download.html
 
-    """  # noqa:E501
-
-    buffer_size = Property(int,
-                           doc="Size of the frame buffer. The frame "
-                               "buffer is used to cache frames in cases "
-                               "where the stream generates frames faster "
-                               "than they are ingested by the reader. "
-                               "If `buffer_size` is less than or equal to "
-                               "zero, the buffer size is infinite.",
-                           default=1)
-
-    input_opts = Property(dict,
-                          doc="FFmpeg input options, provided in the form of "
-                              "a dictionary, whose keys correspond to option "
-                              "names. (e.g. ``{'fflags': 'nobuffer'}``). "
-                              "The default is ``{}``.",
-                          default={})
-    output_opts = Property(dict,
-                           doc="FFmpeg output options, provided in the form "
-                               "of a dictionary, whose keys correspond to "
-                               "option names. The default is "
-                               "``{'f': 'rawvideo', 'pix_fmt': 'rgb24'}``.",
-                           default={'f': 'rawvideo', 'pix_fmt': 'rgb24'})
+    """
+
+    url: ParseResult = Property(
+        doc="Input source to read video stream from, passed as input url argument. This can "
+            "include any valid FFmpeg input e.g. rtsp URL, device name when using 'dshow'/'v4l2'")
+    buffer_size: int = Property(
+        default=1,
+        doc="Size of the frame buffer. The frame buffer is used to cache frames in cases where "
+            "the stream generates frames faster than they are ingested by the reader. If "
+            "`buffer_size` is less than or equal to zero, the buffer size is infinite.")
+    input_opts: Mapping[str, str] = Property(
+        default=None,
+        doc="FFmpeg input options, provided in the form of a dictionary, whose keys correspond to "
+            "option names. (e.g. ``{'fflags': 'nobuffer'}``). The default is ``{}``.")
+    output_opts: Mapping[str, str] = Property(
+        default=None,
+        doc="FFmpeg output options, provided in the form of a dictionary, whose keys correspond "
+            "to option names. The default is ``{'f': 'rawvideo', 'pix_fmt': 'rgb24'}``.")
+    filters: Sequence[Tuple[str, Sequence[Any], Mapping[Any, Any]]] = Property(
+        default=None,
+        doc="FFmpeg filters, provided in the form of a list of filter name, sequence of "
+            "arguments, mapping of key/value pairs (e.g. ``[('scale', ('320', '240'), {})]``). "
+            "Default `None` where no filter will be applied. Note that :attr:`frame_size` may "
+            "need to be set in when video size changed by filter.")
+    frame_size: Tuple[int, int] = Property(
+        default=None,
+        doc="Tuple of frame width and height. Default `None` where it will be detected using "
+            "`ffprobe` against the input, but this may yield wrong width/height (e.g. when "
+            "filters are applied), and such this option can be used to override.")
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
+        if self.input_opts is None:
+            self.input_opts = {}
+        if self.output_opts is None:
+            self.output_opts = {'f': 'rawvideo', 'pix_fmt': 'rgb24'}
+        if self.filters is None:
+            self.filters = []
 
         self.buffer = Queue(maxsize=self.buffer_size)
 
+        if self.frame_size is not None:
+            self._stream_info = {
+                'width': self.frame_size[0],
+                'height': self.frame_size[1]}
+        else:
+            # Probe stream information
+            self._stream_info = next(
+                s
+                for s in ffmpeg.probe(self.url.geturl(), **self.input_opts)['streams']
+                if s['codec_type'] == 'video')
+
         # Initialise stream
+        self.stream = ffmpeg.input(self.url.geturl(), **self.input_opts)
+        for filter_ in self.filters:
+            filter_name, filter_args, filter_kwargs = filter_
+            self.stream = self.stream.filter(
+                filter_name, *filter_args, **filter_kwargs
+            )
         self.stream = (
-            ffmpeg
-            .input(self.url.geturl(), **self.input_opts)
+            self.stream
             .output('pipe:', **self.output_opts)
             .global_args('-y', '-loglevel', 'panic')
             .run_async(pipe_stdout=True)
         )
 
-        # Probe stream information
-        self._stream_info = next(
-            s for s
-            in ffmpeg.probe(self.url.geturl())['streams']
-            if s['codec_type'] == 'video')
-
         # Initialise capture thread
         self._capture_thread = threading.Thread(target=self._run)
         self._capture_thread.daemon = True
diff --git a/stonesoup/sensor/passive.py b/stonesoup/sensor/passive.py
index 5e529f10..aaf355dd 100644
--- a/stonesoup/sensor/passive.py
+++ b/stonesoup/sensor/passive.py
@@ -7,7 +7,7 @@ from ..base import Property
 from ..models.measurement.nonlinear import CartesianToElevationBearing
 from ..sensor.sensor import Sensor
 from ..types.array import CovarianceMatrix
-from ..types.detection import TrueDetection
+from ..types.detection import Detection
 from ..types.groundtruth import GroundTruthState
 
 
@@ -34,7 +34,7 @@ class PassiveElevationBearing(Sensor):
             :class:`~.CartesianToElevationBearing` model")
 
     def measure(self, ground_truths: Set[GroundTruthState], noise: Union[np.ndarray, bool] = True,
-                **kwargs) -> Set[TrueDetection]:
+                **kwargs) -> Set[Detection]:
         """Generate a measurement for a given state
 
         Parameters
@@ -48,11 +48,10 @@ class PassiveElevationBearing(Sensor):
 
         Returns
         -------
-        Set[:class:`~.TrueDetection`]
+        Set[:class:`~.Detection`]
             A set of measurements generated from the given states. The timestamps of the
             measurements are set equal to that of the corresponding states that they were
-            calculated from. Each measurement stores the ground truth path that it was produced
-            from.
+            calculated from.
         """
 
         measurement_model = CartesianToElevationBearing(
@@ -64,11 +63,11 @@ class PassiveElevationBearing(Sensor):
 
         detections = set()
         for truth in ground_truths:
+
             measurement_vector = measurement_model.function(truth, noise=noise, **kwargs)
-            detection = TrueDetection(measurement_vector,
-                                      measurement_model=measurement_model,
-                                      timestamp=truth.timestamp,
-                                      groundtruth_path=truth)
+            detection = Detection(measurement_vector,
+                                  measurement_model=measurement_model,
+                                  timestamp=truth.timestamp)
             detections.add(detection)
 
         return detections
diff --git a/stonesoup/sensor/radar/radar.py b/stonesoup/sensor/radar/radar.py
index 6247ffdc..d55ba795 100644
--- a/stonesoup/sensor/radar/radar.py
+++ b/stonesoup/sensor/radar/radar.py
@@ -16,7 +16,7 @@ from ...models.measurement.nonlinear import \
      CartesianToBearingRangeRate, CartesianToElevationBearingRangeRate)
 from ...sensor.sensor import Sensor
 from ...types.array import CovarianceMatrix
-from ...types.detection import TrueDetection
+from ...types.detection import Detection
 from ...types.groundtruth import GroundTruthState
 from ...types.numeric import Probability
 from ...types.state import State, StateVector
@@ -45,7 +45,7 @@ class RadarBearingRange(Sensor):
             ":class:`~.CartesianToBearingRange` model")
 
     def measure(self, ground_truths: Set[GroundTruthState], noise: Union[np.ndarray, bool] = True,
-                **kwargs) -> Set[TrueDetection]:
+                **kwargs) -> Set[Detection]:
         """Generate a measurement for a given state
 
         Parameters
@@ -59,11 +59,10 @@ class RadarBearingRange(Sensor):
 
         Returns
         -------
-        Set[:class:`~.TrueDetection`]
+        Set[:class:`~.Detection`]
             A set of measurements generated from the given states. The timestamps of the
             measurements are set equal to that of the corresponding states that they were
-            calculated from. Each measurement stores the ground truth path that it was produced
-            from.
+            calculated from.
         """
         measurement_model = CartesianToBearingRange(
             ndim_state=self.ndim_state,
@@ -75,10 +74,9 @@ class RadarBearingRange(Sensor):
         detections = set()
         for truth in ground_truths:
             measurement_vector = measurement_model.function(truth, noise=noise, **kwargs)
-            detection = TrueDetection(measurement_vector,
-                                      measurement_model=measurement_model,
-                                      timestamp=truth.timestamp,
-                                      groundtruth_path=truth)
+            detection = Detection(measurement_vector,
+                                  measurement_model=measurement_model,
+                                  timestamp=truth.timestamp)
             detections.add(detection)
 
         return detections
@@ -110,7 +108,7 @@ class RadarRotatingBearingRange(RadarBearingRange):
     fov_angle: float = Property(doc="The radar field of view (FOV) angle (in radians).")
 
     def measure(self, ground_truths: Set[GroundTruthState], noise: Union[np.ndarray, bool] = True,
-                **kwargs) -> Set[TrueDetection]:
+                **kwargs) -> Set[Detection]:
         """Generate a measurement for a given state
 
         Parameters
@@ -124,11 +122,10 @@ class RadarRotatingBearingRange(RadarBearingRange):
 
         Returns
         -------
-        Set[:class:`~.TrueDetection`]
+        Set[:class:`~.Detection`]
             A set of measurements generated from the given states. If a state falls in the sensor's
             field of view, a measurement is added. The timestamps of the measurements are set equal
-            to that of the corresponding states that they were calculated from. Each measurement
-            stores the ground truth path that it was produced from.
+            to that of the corresponding states that they were calculated from.
         """
 
         # Read timestamp from ground truth
@@ -136,7 +133,8 @@ class RadarRotatingBearingRange(RadarBearingRange):
 
         # Rotate the radar antenna and compute new heading
         self.rotate(timestamp)
-        antenna_heading = self.orientation[2, 0] + self.dwell_center.state_vector[0, 0]
+        antenna_heading = self.orientation[2, 0] + \
+            self.dwell_center.state_vector[0, 0]
 
         # Set rotation offset of underlying measurement model
         rot_offset = \
@@ -177,10 +175,9 @@ class RadarRotatingBearingRange(RadarBearingRange):
             # Else add measurement
             measurement_vector += measurement_noise  # Add noise
 
-            detection = TrueDetection(measurement_vector,
-                                      measurement_model=measurement_model,
-                                      timestamp=truth.timestamp,
-                                      groundtruth_path=truth)
+            detection = Detection(measurement_vector,
+                                  measurement_model=measurement_model,
+                                  timestamp=truth.timestamp)
             detections.add(detection)
 
         return detections
@@ -225,7 +222,7 @@ class RadarElevationBearingRange(RadarBearingRange):
             ":class:`~.CartesianToElevationBearingRange` model")
 
     def measure(self, ground_truths: Set[GroundTruthState], noise: Union[np.ndarray, bool] = True,
-                **kwargs) -> Set[TrueDetection]:
+                **kwargs) -> Set[Detection]:
         """Generate a measurement for a given state
 
         Parameters
@@ -239,11 +236,10 @@ class RadarElevationBearingRange(RadarBearingRange):
 
         Returns
         -------
-        Set[:class:`~.TrueDetection`]
+        Set[:class:`~.Detection`]
             A set of measurements generated from the given states. The timestamps of the
             measurements are set equal to that of the corresponding states that they were
-            calculated from. Each measurement stores the ground truth path that it was produced
-            from.
+            calculated from.
         """
         measurement_model = CartesianToElevationBearingRange(
             ndim_state=self.ndim_state,
@@ -255,10 +251,9 @@ class RadarElevationBearingRange(RadarBearingRange):
         detections = set()
         for truth in ground_truths:
             measurement_vector = measurement_model.function(truth, noise=noise, **kwargs)
-            detection = TrueDetection(measurement_vector,
-                                      measurement_model=measurement_model,
-                                      timestamp=truth.timestamp,
-                                      groundtruth_path=truth)
+            detection = Detection(measurement_vector,
+                                  measurement_model=measurement_model,
+                                  timestamp=truth.timestamp)
             detections.add(detection)
 
         return detections
@@ -289,7 +284,7 @@ class RadarBearingRangeRate(RadarBearingRange):
             ":class:`~.CartesianToBearingRangeRate` model")
 
     def measure(self, ground_truths: Set[GroundTruthState], noise: Union[np.ndarray, bool] = True,
-                **kwargs) -> Set[TrueDetection]:
+                **kwargs) -> Set[Detection]:
         """Generate a measurement for a given state
 
         Parameters
@@ -303,11 +298,10 @@ class RadarBearingRangeRate(RadarBearingRange):
 
         Returns
         -------
-        Set[:class:`~.TrueDetection`]
+        Set[:class:`~.Detection`]
             A set of measurements generated from the given states. The timestamps of the
             measurements are set equal to that of the corresponding states that they were
-            calculated from. Each measurement stores the ground truth path that it was produced
-            from.
+            calculated from.
         """
         measurement_model = CartesianToBearingRangeRate(
             ndim_state=self.ndim_state,
@@ -321,10 +315,9 @@ class RadarBearingRangeRate(RadarBearingRange):
         detections = set()
         for truth in ground_truths:
             measurement_vector = measurement_model.function(truth, noise=noise, **kwargs)
-            detection = TrueDetection(measurement_vector,
-                                      measurement_model=measurement_model,
-                                      timestamp=truth.timestamp,
-                                      groundtruth_path=truth)
+            detection = Detection(measurement_vector,
+                                  measurement_model=measurement_model,
+                                  timestamp=truth.timestamp)
             detections.add(detection)
 
         return detections
@@ -354,7 +347,7 @@ class RadarElevationBearingRangeRate(RadarBearingRangeRate):
             ":class:`~.CartesianToElevationBearingRangeRate` model")
 
     def measure(self, ground_truths: Set[GroundTruthState], noise: Union[np.ndarray, bool] = True,
-                **kwargs) -> Set[TrueDetection]:
+                **kwargs) -> Set[Detection]:
         """Generate a measurement for a given state
 
         Parameters
@@ -368,11 +361,10 @@ class RadarElevationBearingRangeRate(RadarBearingRangeRate):
 
         Returns
         -------
-        Set[:class:`~.TrueDetection`]
+        Set[:class:`~.Detection`]
             A set of measurements generated from the given states. The timestamps of the
             measurements are set equal to that of the corresponding states that they were
-            calculated from. Each measurement stores the ground truth path that it was produced
-            from.
+            calculated from.
         """
         measurement_model = CartesianToElevationBearingRangeRate(
             ndim_state=self.ndim_state,
@@ -386,10 +378,9 @@ class RadarElevationBearingRangeRate(RadarBearingRangeRate):
         detections = set()
         for truth in ground_truths:
             measurement_vector = measurement_model.function(truth, noise=noise, **kwargs)
-            detection = TrueDetection(measurement_vector,
-                                      measurement_model=measurement_model,
-                                      timestamp=truth.timestamp,
-                                      groundtruth_path=truth)
+            detection = Detection(measurement_vector,
+                                  measurement_model=measurement_model,
+                                  timestamp=truth.timestamp)
             detections.add(detection)
 
         return detections
@@ -646,10 +637,11 @@ class AESARadar(Sensor):
         det_prob = 0.5 * erfc(
             (-np.log(self.probability_false_alarm)) ** 0.5 - (
                     snr + 1 / 2) ** 0.5)
-        return det_prob, snr, rcs, directed_power, 10 * np.log10(spoiled_gain), spoiled_width
+        return det_prob, snr, rcs, directed_power,\
+            10 * np.log10(spoiled_gain), spoiled_width
 
     def measure(self, ground_truths: Set[GroundTruthState], noise: Union[np.ndarray, bool] = True,
-                **kwargs) -> Set[TrueDetection]:
+                **kwargs) -> Set[Detection]:
         """Generate a measurement for a given state
 
         Parameters
@@ -663,11 +655,10 @@ class AESARadar(Sensor):
 
         Returns
         -------
-        Set[:class:`~.TrueDetection`]
+        Set[:class:`~.Detection`]
             A set of measurements generated from the given states. If np.random.rand() is less than
             the probability of detection a measurement is added. The timestamps of the measurements
-            are set equal to that of the corresponding states that they were calculated from. Each
-            measurement stores the ground truth path that it was produced from.
+            are set equal to that of the corresponding states that they were calculated from.
         """
 
         detections = set()
@@ -682,10 +673,9 @@ class AESARadar(Sensor):
             if np.random.rand() <= det_prob:
                 measured_pos = measurement_model.function(truth, noise=noise)
 
-                detection = TrueDetection(measured_pos,
-                                          timestamp=truth.timestamp,
-                                          measurement_model=measurement_model,
-                                          groundtruth_path=truth)
+                detection = Detection(measured_pos,
+                                      timestamp=truth.timestamp,
+                                      measurement_model=measurement_model)
                 detections.add(detection)
 
         return detections
diff --git a/stonesoup/serialise.py b/stonesoup/serialise.py
index be4c92d8..328318f2 100644
--- a/stonesoup/serialise.py
+++ b/stonesoup/serialise.py
@@ -98,7 +98,7 @@ class YAML:
         return stream.getvalue()
 
     def dump_all(self, documents, stream, **kwargs):
-        return self._yaml.dump_all(documents, stream, **kwargs)
+        return self._yaml.dump_all(self, documents, stream, **kwargs)
 
     def load(self, stream):
         return self._yaml.load(stream)
diff --git a/stonesoup/simulator/transition.py b/stonesoup/simulator/transition.py
index 74cdce81..529a1adb 100644
--- a/stonesoup/simulator/transition.py
+++ b/stonesoup/simulator/transition.py
@@ -132,12 +132,12 @@ def create_smooth_transition_models(initial_state, x_coords, y_coords, times, tu
         if d > 0:  # if platform is not already at target coord, add linear acceleration model
 
             try:
-                accel_model = Point2PointConstantAcceleration(state=deepcopy(state),
+                accel_model = Point2PointConstantAcceleration(state=state,
                                                               destination=(x_coord, y_coord),
                                                               duration=timedelta(seconds=t2))
             except OvershootError:
                 # if linear accel leads to overshoot, apply model to stop at target coord instead
-                accel_model = Point2PointStop(state=deepcopy(state),
+                accel_model = Point2PointStop(state=state,
                                               destination=(x_coord, y_coord))
             state.state_vector = accel_model.function(state=state,
                                                       time_interval=timedelta(seconds=t2))
diff --git a/stonesoup/types/state.py b/stonesoup/types/state.py
index 9b4ae86a..cfa5f72f 100644
--- a/stonesoup/types/state.py
+++ b/stonesoup/types/state.py
@@ -130,9 +130,7 @@ class GaussianState(State):
     covar: CovarianceMatrix = Property(doc='Covariance matrix of state.')
 
     def __init__(self, state_vector, covar, *args, **kwargs):
-        # Don't cast away subtype of covar if not necessary
-        if not isinstance(covar, CovarianceMatrix):
-            covar = CovarianceMatrix(covar)
+        covar = CovarianceMatrix(covar)
         super().__init__(state_vector, covar, *args, **kwargs)
         if self.state_vector.shape[0] != self.covar.shape[0]:
             raise ValueError(
@@ -187,48 +185,6 @@ class WeightedGaussianState(GaussianState):
     """
     weight: Probability = Property(default=0, doc="Weight of the Gaussian State.")
 
-    @property
-    def gaussian_state(self):
-        """The Gaussian state."""
-        return GaussianState(self.state_vector,
-                             self.covar,
-                             timestamp=self.timestamp)
-
-    @classmethod
-    def from_gaussian_state(cls, gaussian_state, *args, copy=True, **kwargs):
-        r"""
-        Returns a WeightedGaussianState instance based on the gaussian_state.
-
-        Parameters
-        ----------
-        gaussian_state : :class:`~.GaussianState`
-            The guassian_state used to create the new WeightedGaussianState.
-        \*args : See main :class:`~.WeightedGaussianState`
-            args are passed to :class:`~.WeightedGaussianState` __init__()
-        copy : Boolean, optional
-            If True, the WeightedGaussianState is created with copies of the elements
-            of gaussian_state. The default is True.
-        \*\*kwargs : See main :class:`~.WeightedGaussianState`
-            kwargs are passed to :class:`~.WeightedGaussianState` __init__()
-
-        Returns
-        -------
-        :class:`~.WeightedGaussianState`
-            Instance of WeightedGaussianState.
-        """
-        state_vector = gaussian_state.state_vector
-        covar = gaussian_state.covar
-        timestamp = gaussian_state.timestamp
-        if copy:
-            state_vector = state_vector.copy()
-            covar = covar.copy()
-        return cls(
-            state_vector=state_vector,
-            covar=covar,
-            timestamp=timestamp,
-            *args, **kwargs
-        )
-
 
 class TaggedWeightedGaussianState(WeightedGaussianState):
     """Tagged Weighted Gaussian State Type


Test patch:
diff --git a/stonesoup/metricgenerator/tests/test_tracktotruthmetrics.py b/stonesoup/metricgenerator/tests/test_tracktotruthmetrics.py
index 6fc0c9ea..e7a3df90 100644
--- a/stonesoup/metricgenerator/tests/test_tracktotruthmetrics.py
+++ b/stonesoup/metricgenerator/tests/test_tracktotruthmetrics.py
@@ -1,7 +1,5 @@
 import datetime
 
-import pytest
-
 from ..tracktotruthmetrics import SIAPMetrics
 from ...types.association import TimeRangeAssociation, AssociationSet
 from ...types.track import Track
@@ -273,6 +271,7 @@ def test_compute_metric():
     track9 = Track(
         states=[State([[9]], timestamp=tstart + datetime.timedelta(seconds=i))
                 for i in range(35, 40)])
+    manager.groundtruth_paths = truth
     manager.tracks = {track1, track2, track3, track4, track5, track6, track7,
                       track8, track9}
     manager.groundtruth_paths = {truth}
@@ -331,77 +330,3 @@ def test_compute_metric():
     assert ls.time_range.start_timestamp == tstart
     assert ls.time_range.end_timestamp == tend
     assert ls.generator == generator
-
-
-def test_no_truth_divide_by_zero():
-    manager = SimpleManager()
-    generator = SIAPMetrics()
-    # Create truth, tracks and associations, same as test_nu_j
-    tstart = datetime.datetime.now()
-    track1 = Track(
-        states=[State([[1]], timestamp=tstart + datetime.timedelta(seconds=i))
-                for i in range(3)])
-    track2 = Track(
-        states=[State([[2]], timestamp=tstart + datetime.timedelta(seconds=i))
-                for i in range(5, 10)])
-    track3 = Track(
-        states=[State([[3]], timestamp=tstart + datetime.timedelta(seconds=i))
-                for i in range(7, 15)])
-    track4 = Track(
-        states=[State([[4]], timestamp=tstart + datetime.timedelta(seconds=i))
-                for i in range(13, 20)])
-    track5 = Track(
-        states=[State([[5]], timestamp=tstart + datetime.timedelta(seconds=i))
-                for i in range(18, 28)])
-    track6 = Track(
-        states=[State([[6]], timestamp=tstart + datetime.timedelta(seconds=i))
-                for i in range(22, 26)])
-    track7 = Track(
-        states=[State([[7]], timestamp=tstart + datetime.timedelta(seconds=i))
-                for i in range(30, 40)])
-    track8 = Track(
-        states=[State([[8]], timestamp=tstart + datetime.timedelta(seconds=i))
-                for i in range(30, 35)])
-    track9 = Track(
-        states=[State([[9]], timestamp=tstart + datetime.timedelta(seconds=i))
-                for i in range(35, 40)])
-    manager.tracks = {track1, track2, track3, track4, track5, track6, track7,
-                      track8, track9}
-    manager.groundtruth_paths = set()
-    associations = {TimeRangeAssociation({track}, time_range=TimeRange(
-        start_timestamp=min([state.timestamp for state in track.states]),
-        end_timestamp=max([state.timestamp for state in track.states])))
-                    for track in manager.tracks}
-    manager.association_set = AssociationSet(associations)
-
-    with pytest.warns(UserWarning) as warning:
-        metrics = generator.compute_metric(manager)
-
-    assert warning[0].message.args[0] == "No truth to generate SIAP Metric"
-
-    assert len(metrics) == 5
-
-
-def test_no_track_divide_by_zero():
-    manager = SimpleManager()
-    generator = SIAPMetrics()
-    # Create truth, tracks and associations, same as test_nu_j
-    tstart = datetime.datetime.now()
-    truth = GroundTruthPath(states=[
-        GroundTruthState([[1]], timestamp=tstart + datetime.timedelta(
-            seconds=i))
-        for i in range(40)])
-    manager.tracks = set()
-    manager.groundtruth_paths = {truth}
-    associations = {TimeRangeAssociation({truth}, time_range=TimeRange(
-        start_timestamp=min([state.timestamp for state in track.states]),
-        end_timestamp=max([state.timestamp for state in track.states])))
-                    for track in manager.tracks}
-    manager.association_set = AssociationSet(associations)
-
-    with pytest.warns(UserWarning) as warning:
-        metrics = generator.compute_metric(manager)
-
-    assert warning[0].message.args[0] == "No tracks to generate SIAP Metric"
-
-    assert len(metrics) == 5
diff --git a/stonesoup/models/measurement/tests/test_models.py b/stonesoup/models/measurement/tests/test_models.py
index e3defa13..152d607d 100644
--- a/stonesoup/models/measurement/tests/test_models.py
+++ b/stonesoup/models/measurement/tests/test_models.py
@@ -55,7 +55,6 @@ def h3d(state_vector, pos_map,  translation_offset, rotation_offset):
 
     # Get rotation matrix
     theta_x, theta_y, theta_z = - rotation_offset[:, 0]
-    theta_y = - theta_y
 
     rotation_matrix = rotz(theta_z) @ roty(theta_y) @ rotx(theta_x)
     xyz_rot = rotation_matrix @ xyz
@@ -279,71 +278,6 @@ def test_models(h, ModelClass, state_vec, R,
         cov=R)
 
 
-position_measurement_sets = [((0, 1, 0, 0, 0, 0), (1, 0, 0, 0, 0, 0),
-                              (0, 0, 1, -1)),
-                             ((0, 0, -50, 0.25, 0, 0), (0, 0, 130, -0.25, 0, 0),
-                              (0, 0, 180, -0.5)),
-                             ((0, 0, 0, 1, 0, 0), (10, 0, 10, 0, 0, 0),
-                              (0, -np.pi/4, np.sqrt(200), -1/np.sqrt(2))),
-                             ((0, 1, 0, 0, 0, 0), (10, 0, 10, 0, 0, 0),
-                              (0, np.pi / 4, np.sqrt(200), -1/np.sqrt(2))),
-                             ((0, 1, 0, 1, 0, 0), (10, 0, 10, 0, 0, 0),
-                              (0, 0, np.sqrt(200), -np.sqrt(2))),
-                             ((0, 1, 0, 0, 0, 0), (10, 0, 0, 0, 10, 0),
-                              (np.pi/4, 0, np.sqrt(200), -1/np.sqrt(2))),
-                             ((1, 1, 0, 0, 1, 0), (10, 0, 0, 0, 10, 0),
-                              (np.pi/4, 0, np.sqrt(81*2), -1/np.sqrt(2))),
-                             ((-1, 1, 0, 0, -1, 0), (10, 0, 0, 0, 10, 0),
-                              (np.pi/4, 0, np.sqrt(121*2), -1/np.sqrt(2))),
-                             ((0, 1, 0, 0, 0, 0.5), (10, 0, 0, 0, 5, 0),
-                              (0, 0, np.sqrt(125), -np.sqrt(1.25))),
-                             ((0, 1, 0, 0, 0, 0), (10, 0, 0, 0, 5, 0),
-                              (np.arctan(0.5), 0, np.sqrt(125), - np.cos(np.arctan(5/10)))),
-                             ((0, 1, 0, 0, 0, 1.2), (10, 0, 0, 0, 12, 0),
-                              (0, 0, np.sqrt(244), -np.sqrt(2.44))),
-                             ((0, 1, 0, 0, 0, 0), (10, 0, 0, 0, 12, 0),
-                              (np.arctan(1.2), 0, np.sqrt(244), -np.cos(np.arctan(12/10)))),
-                             ((0, 1, 0, 0, 0, 1), (10, 0, 0, 0, 10, 0),
-                              (0, 0, np.sqrt(200), -np.sqrt(2))),
-                             ((0, 1, 0, 0, 0, 1), (10, 0, 0, 0, 0, 0),
-                              (-np.pi/4, 0, 10, -1)),
-                             ((0, 1, 0, 0, 0, 0), (10, 0, 0, 0, -10, 0),
-                              (-np.pi / 4, 0, np.sqrt(200), -1 / np.sqrt(2))),
-                             ((0, 0, 0, 0, 0, 1), (0, 0, 0, 0, 10, 0),
-                              (0, 0, 10, -1)),
-                             ]
-
-
-@pytest.mark.parametrize('sensor_state, target_state, expected_measurement',
-                         position_measurement_sets)
-@pytest.mark.parametrize('model_class, measure_mapping, use_velocity',
-                         [(CartesianToElevationBearing, [0, 1], False),
-                          (CartesianToElevationBearingRange, [0, 1, 2], False),
-                          (CartesianToElevationBearingRangeRate, [0, 1, 2, 3], True)])
-def test_model_predictions(sensor_state, target_state, expected_measurement, model_class,
-                           measure_mapping, use_velocity):
-    sensor_state = StateVector(sensor_state)
-    target_state = State(StateVector(target_state), timestamp=None)
-    expected_measurement = StateVector([Elevation(expected_measurement[0]),
-                                        Bearing(expected_measurement[1]),
-                                        expected_measurement[2],  # range
-                                        expected_measurement[3]])  # range rate
-    pos_mapping = [0, 2, 4]
-    vel_mapping = [1, 3, 5]
-    sensor_velocity = sensor_state[vel_mapping]
-    _, bearing, elevation = cart2sphere(*sensor_velocity)
-    orientation = StateVector([0, elevation, bearing])
-    model = model_class(ndim_state=6,
-                        translation_offset=sensor_state[pos_mapping],
-                        rotation_offset=orientation,
-                        mapping=pos_mapping,
-                        noise_covar=np.eye(len(expected_measurement)))
-    if use_velocity:
-        model.velocity = sensor_velocity
-    actual_measurement = model.function(target_state, noise=False)
-    assert np.allclose(actual_measurement, expected_measurement[measure_mapping])
-
-
 def test_angle_pdf():
     model = CartesianToBearingRange(ndim_state=2,
                                     mapping=(0, 1),
diff --git a/stonesoup/platform/tests/test_platform_base.py b/stonesoup/platform/tests/test_platform_base.py
index 26b22c0a..023471b6 100644
--- a/stonesoup/platform/tests/test_platform_base.py
+++ b/stonesoup/platform/tests/test_platform_base.py
@@ -563,7 +563,6 @@ def test_multi_transition():
 
     time += datetime.timedelta(seconds=7)
     platform.move(timestamp=time)
-    assert len(platform) == 5
     x, y = platform.position[0], platform.position[1]
     # Platform initially moves horizontally
     assert x > px
@@ -574,7 +573,6 @@ def test_multi_transition():
 
     time += datetime.timedelta(seconds=10)
     platform.move(timestamp=time)
-    assert len(platform) == 6
     x, y = platform.position[0], platform.position[1]
     # Platform starts turning left to 45 degrees
     assert x > px
@@ -584,7 +582,6 @@ def test_multi_transition():
 
     time += datetime.timedelta(seconds=10)
     platform.move(timestamp=time)
-    assert len(platform) == 7
     x, y = platform.position[0], platform.position[1]
     px, py = x, y
     # Platform turned left to 90 degrees
@@ -593,7 +590,6 @@ def test_multi_transition():
 
     time += datetime.timedelta(seconds=10)
     platform.move(timestamp=time)
-    assert len(platform) == 8
     x, y = platform.position[0], platform.position[1]
     # Platform travelling vertically up
     assert np.allclose(x, px, atol=1e-6)
@@ -612,7 +608,6 @@ def test_multi_transition():
 
     time += datetime.timedelta(seconds=20)
     platform.move(timestamp=time)
-    assert len(platform) == 9
     # Platform turned left by 90 degrees (now travelling in -x direction)
     px, py = platform.position[0], platform.position[1]
     # Next transition is right-turn
@@ -620,7 +615,6 @@ def test_multi_transition():
 
     time += datetime.timedelta(seconds=10)
     platform.move(timestamp=time)
-    assert len(platform) == 10
     x, y = platform.position[0], platform.position[1]
     px, py = x, y
     # Next transition straight-on, travelling vertically up again
@@ -628,7 +622,6 @@ def test_multi_transition():
 
     time += datetime.timedelta(seconds=10)
     platform.move(timestamp=time)
-    assert len(platform) == 11
     x, y = platform.position[0], platform.position[1]
     # Platform travelled vertically up
     assert np.allclose(x, px, atol=1e-6)
diff --git a/stonesoup/sensor/radar/tests/test_radar.py b/stonesoup/sensor/radar/tests/test_radar.py
index 89c05390..bcf96b6d 100644
--- a/stonesoup/sensor/radar/tests/test_radar.py
+++ b/stonesoup/sensor/radar/tests/test_radar.py
@@ -1,34 +1,33 @@
 # -*- coding: utf-8 -*-
 import datetime
-
-import numpy as np
 import pytest
 from pytest import approx
+import numpy as np
 
-from stonesoup.types.detection import TrueDetection
-from ..beam_pattern import StationaryBeam
-from ..beam_shape import Beam2DGaussian
-from ..radar import RadarBearingRange, RadarElevationBearingRange, RadarRotatingBearingRange, \
-    AESARadar, RadarRasterScanBearingRange, RadarBearingRangeRate, RadarElevationBearingRangeRate
 from ....functions import rotz, rotx, roty, cart2sphere
-from ....models.measurement.linear import LinearGaussian
 from ....types.angle import Bearing, Elevation
 from ....types.array import StateVector, CovarianceMatrix
-from ....types.groundtruth import GroundTruthState, GroundTruthPath
 from ....types.state import State
+from ....types.groundtruth import GroundTruthState
+from ..radar import RadarBearingRange, RadarElevationBearingRange, RadarRotatingBearingRange, \
+    AESARadar, RadarRasterScanBearingRange, RadarBearingRangeRate, RadarElevationBearingRangeRate
+from ..beam_pattern import StationaryBeam
+from ..beam_shape import Beam2DGaussian
+from ....models.measurement.linear import LinearGaussian
 
 
 def h2d(state, pos_map, translation_offset, rotation_offset):
+
     xyz = StateVector([[state.state_vector[pos_map[0], 0] - translation_offset[0, 0]],
-                       [state.state_vector[pos_map[1], 0] - translation_offset[1, 0]],
-                       [0]])
+                      [state.state_vector[pos_map[1], 0] - translation_offset[1, 0]],
+                      [0]])
 
     # Get rotation matrix
     theta_z = -rotation_offset[2, 0]
     theta_y = -rotation_offset[1, 0]
     theta_x = -rotation_offset[0, 0]
 
-    rotation_matrix = rotz(theta_z) @ roty(theta_y) @ rotx(theta_x)
+    rotation_matrix = rotz(theta_z)@roty(theta_y)@rotx(theta_x)
 
     xyz_rot = rotation_matrix @ xyz
     x = xyz_rot[0, 0]
@@ -42,6 +41,7 @@ def h2d(state, pos_map, translation_offset, rotation_offset):
 
 
 def h3d(state, pos_map, translation_offset, rotation_offset):
+
     xyz = state.state_vector[pos_map, :] - translation_offset
 
     # Get rotation matrix
@@ -69,7 +69,7 @@ def h3d(state, pos_map, translation_offset, rotation_offset):
                           [0, 0.1]]),  # noise_covar
                 StateVector([[1], [1]]),  # position
                 np.array([[200], [10]])  # target
-        ),
+         ),
         (
                 h3d,  # h
                 RadarElevationBearingRange,  # sensorclass
@@ -93,10 +93,9 @@ def test_simple_radar(h, sensorclass, ndim_state, pos_mapping, noise_covar, posi
 
     assert (np.equal(radar.position, position).all())
 
-    target_state = GroundTruthState(target, timestamp=datetime.datetime.now())
-    target_truth = GroundTruthPath([target_state])
-
-    truth = {target_truth}
+    truth = set()
+    target_state = State(target, timestamp=datetime.datetime.now())
+    truth.add(target_state)
 
     # Generate a noiseless measurement for the given target
     measurement = radar.measure(truth, noise=False)
@@ -109,15 +108,8 @@ def test_simple_radar(h, sensorclass, ndim_state, pos_mapping, noise_covar, posi
                                                  translation_offset=position,
                                                  rotation_offset=radar.orientation)).all())
 
-    # Assert is TrueDetection type
-    assert isinstance(measurement, TrueDetection)
-    assert measurement.groundtruth_path is target_truth
-    assert isinstance(measurement.groundtruth_path, GroundTruthPath)
-
-    target2_state = GroundTruthState(target, timestamp=datetime.datetime.now())
-    target2_truth = GroundTruthPath([target2_state])
-
-    truth.add(target2_truth)
+    target_state = State(target, timestamp=datetime.datetime.now())
+    truth.add(target_state)
 
     # Generate a noiseless measurement for each of the given target states
     measurements = radar.measure(truth)
@@ -125,16 +117,12 @@ def test_simple_radar(h, sensorclass, ndim_state, pos_mapping, noise_covar, posi
     # Two measurements for 2 truth states
     assert len(measurements) == 2
 
-    # Measurements store ground truth paths
-    for measurement in measurements:
-        assert measurement.groundtruth_path in truth
-        assert isinstance(measurement.groundtruth_path, GroundTruthPath)
-
 
 def h2d_rr(state, pos_map, vel_map, translation_offset, rotation_offset, velocity):
+
     xyz = StateVector([[state.state_vector[pos_map[0], 0] - translation_offset[0, 0]],
-                       [state.state_vector[pos_map[1], 0] - translation_offset[1, 0]],
-                       [0]])
+                      [state.state_vector[pos_map[1], 0] - translation_offset[1, 0]],
+                      [0]])
 
     # Get rotation matrix
     theta_z = - rotation_offset[2, 0]
@@ -159,6 +147,7 @@ def h2d_rr(state, pos_map, vel_map, translation_offset, rotation_offset, velocit
 
 
 def h3d_rr(state, pos_map, vel_map, translation_offset, rotation_offset, velocity):
+
     xyz = state.state_vector[pos_map, :] - translation_offset
 
     # Get rotation matrix
@@ -166,7 +155,7 @@ def h3d_rr(state, pos_map, vel_map, translation_offset, rotation_offset, velocit
     theta_y = - rotation_offset[1, 0]
     theta_x = - rotation_offset[0, 0]
 
-    rotation_matrix = rotz(theta_z) @ roty(theta_y) @ rotx(theta_x)
+    rotation_matrix = rotz(theta_z)@roty(theta_y)@rotx(theta_x)
     xyz_rot = rotation_matrix @ xyz
 
     rho, phi, theta = cart2sphere(*xyz_rot)
@@ -193,7 +182,7 @@ def h3d_rr(state, pos_map, vel_map, translation_offset, rotation_offset, velocit
                           [0, 0.015, 0],
                           [0, 0, 10]]),  # noise_covar
                 StateVector([[100], [0], [0]])  # position
-        ),
+         ),
         (
                 h3d_rr,
                 RadarElevationBearingRangeRate,
@@ -209,6 +198,7 @@ def h3d_rr(state, pos_map, vel_map, translation_offset, rotation_offset, velocit
     ids=["RadarBearingRangeRate", "RadarElevationBearingRangeRate"]
 )
 def test_range_rate_radar(h, sensorclass, pos_mapping, vel_mapping, noise_covar, position):
+
     # Instantiate the rotating radar
     radar = sensorclass(ndim_state=6,
                         position_mapping=pos_mapping,
@@ -218,10 +208,10 @@ def test_range_rate_radar(h, sensorclass, pos_mapping, vel_mapping, noise_covar,
 
     assert (np.equal(radar.position, position).all())
 
-    target_state = GroundTruthState(np.array([[200], [10], [0], [0], [0], [0]]),
-                                    timestamp=datetime.datetime.now())
-    target_truth = GroundTruthPath([target_state])
-    truth = {target_truth}
+    truth = set()
+    target_state = State(np.array([[200], [10], [0], [0], [0], [0]]),
+                         timestamp=datetime.datetime.now())
+    truth.add(target_state)
 
     # Generate a noiseless measurement for the given target
     measurement = radar.measure(truth, noise=False)
@@ -236,16 +226,9 @@ def test_range_rate_radar(h, sensorclass, pos_mapping, vel_mapping, noise_covar,
                                                  rotation_offset=radar.orientation,
                                                  velocity=radar.velocity)).all())
 
-    # Assert is TrueDetection type
-    assert isinstance(measurement, TrueDetection)
-    assert measurement.groundtruth_path is target_truth
-    assert isinstance(measurement.groundtruth_path, GroundTruthPath)
-
-    target2_state = GroundTruthState(np.array([[200], [10], [0], [0], [0], [0]]),
-                                     timestamp=datetime.datetime.now())
-    target2_truth = GroundTruthPath([target2_state])
-
-    truth.add(target2_truth)
+    target_state = State(np.array([[200], [10], [0], [0], [0], [0]]),
+                         timestamp=datetime.datetime.now())
+    truth.add(target_state)
 
     # Generate a noiseless measurement for each of the given target states
     measurements = radar.measure(truth)
@@ -253,12 +236,9 @@ def test_range_rate_radar(h, sensorclass, pos_mapping, vel_mapping, noise_covar,
     # Two measurements for 2 truth states
     assert len(measurements) == 2
 
-    # Measurements store ground truth paths
-    for measurement in measurements:
-        assert measurement.groundtruth_path in truth
-
 
 def test_rotating_radar():
+
     # Input arguments
     # TODO: pytest parametarization
     timestamp = datetime.datetime.now()
@@ -273,14 +253,13 @@ def test_rotating_radar():
     # The radar antenna is facing opposite the radar orientation
     dwell_center = State(StateVector([[-np.pi]]),
                          timestamp=timestamp)
-    rpm = 20  # 20 Rotations Per Minute
-    max_range = 100  # Max range of 100m
-    fov_angle = np.pi / 3  # FOV angle of pi/3
-
-    target_state = GroundTruthState(radar_position + np.array([[5], [5]]), timestamp=timestamp)
-    target_truth = GroundTruthPath([target_state])
+    rpm = 20            # 20 Rotations Per Minute
+    max_range = 100     # Max range of 100m
+    fov_angle = np.pi/3       # FOV angle of pi/3
 
-    truth = {target_truth}
+    truth = set()
+    target_state = State(radar_position + np.array([[5], [5]]), timestamp=timestamp)
+    truth.add(target_state)
 
     measurement_mapping = np.array([0, 1])
 
@@ -296,7 +275,7 @@ def test_rotating_radar():
                                       fov_angle=fov_angle)
 
     # Assert that the object has been correctly initialised
-    assert (np.equal(radar.position, radar_position).all())
+    assert(np.equal(radar.position, radar_position).all())
 
     # Generate a noiseless measurement for the given target
     measurement = radar.measure(truth, noise=False)
@@ -307,10 +286,9 @@ def test_rotating_radar():
     # Rotate radar such that the target is in FOV
     timestamp = timestamp + datetime.timedelta(seconds=0.5)
 
-    target_state = GroundTruthState(radar_position + np.array([[5], [5]]), timestamp=timestamp)
-    target_truth = GroundTruthPath([target_state])
-
-    truth = {target_truth}
+    truth = set()
+    target_state = State(radar_position + np.array([[5], [5]]), timestamp=timestamp)
+    truth.add(target_state)
 
     measurement = radar.measure(truth, noise=False)
     measurement = next(iter(measurement))
@@ -318,35 +296,23 @@ def test_rotating_radar():
     eval_m = h2d(target_state,
                  measurement_mapping,
                  radar.position,
-                 radar.orientation + [[0],
-                                      [0],
-                                      [radar.dwell_center.state_vector[0, 0]]])
+                 radar.orientation+[[0],
+                                    [0],
+                                    [radar.dwell_center.state_vector[0, 0]]])
 
     # Assert correction of generated measurement
-    assert (measurement.timestamp == target_state.timestamp)
-    assert (np.equal(measurement.state_vector, eval_m).all())
-
-    # Assert is TrueDetection type
-    assert isinstance(measurement, TrueDetection)
-    assert measurement.groundtruth_path is target_truth
-    assert isinstance(measurement.groundtruth_path, GroundTruthPath)
+    assert(measurement.timestamp == target_state.timestamp)
+    assert(np.equal(measurement.state_vector, eval_m).all())
 
-    target2_state = GroundTruthState(radar_position + np.array([[4], [4]]), timestamp=timestamp)
-    target2_truth = GroundTruthPath([target2_state])
-
-    truth.add(target2_truth)
+    target_state = State(radar_position + np.array([[4], [4]]), timestamp=timestamp)
+    truth.add(target_state)
 
     # Generate a noiseless measurement for each of the given target states
-    measurements = radar.measure(truth, noise=False)
+    measurements = radar.measure(truth)
 
     # Two measurements for 2 truth states
     assert len(measurements) == 2
 
-    # Measurements store ground truth paths
-    for measurement in measurements:
-        assert measurement.groundtruth_path in truth
-        assert isinstance(measurement.groundtruth_path, GroundTruthPath)
-
 
 def test_raster_scan_radar():
     # Input arguments
@@ -369,10 +335,9 @@ def test_raster_scan_radar():
     # This will be mean the dwell center will reach at the limits -pi/2 and
     # pi/2. As the edge of the beam will reach the full FOV
 
-    target_state = GroundTruthState(radar_position + np.array([[-5], [5]]), timestamp=timestamp)
-    target_truth = GroundTruthPath([target_state])
-
-    truth = {target_truth}
+    truth = set()
+    target_state = State(radar_position + np.array([[-5], [5]]), timestamp=timestamp)
+    truth.add(target_state)
 
     measurement_mapping = np.array([0, 1])
 
@@ -400,10 +365,9 @@ def test_raster_scan_radar():
     # Rotate radar
     timestamp = timestamp + datetime.timedelta(seconds=0.5)
 
-    target_state = GroundTruthState(radar_position + np.array([[-5], [5]]), timestamp=timestamp)
-    target_truth = GroundTruthPath([target_state])
-
-    truth = {target_truth}
+    truth = set()
+    target_state = State(radar_position + np.array([[-5], [5]]), timestamp=timestamp)
+    truth.add(target_state)
 
     measurement = radar.measure(truth, noise=False)
 
@@ -413,10 +377,9 @@ def test_raster_scan_radar():
     # Rotate radar such that the target is in FOV
     timestamp = timestamp + datetime.timedelta(seconds=1.0)
 
-    target_state = GroundTruthState(radar_position + np.array([[-5], [5]]), timestamp=timestamp)
-    target_truth = GroundTruthPath([target_state])
-
-    truth = {target_truth}
+    truth = set()
+    target_state = State(radar_position + np.array([[-5], [5]]), timestamp=timestamp)
+    truth.add(target_state)
 
     measurement = radar.measure(truth, noise=False)
     measurement = next(iter(measurement))
@@ -432,11 +395,6 @@ def test_raster_scan_radar():
     assert measurement.timestamp == target_state.timestamp
     assert np.array_equal(measurement.state_vector, eval_m)
 
-    # Assert is TrueDetection type
-    assert isinstance(measurement, TrueDetection)
-    assert measurement.groundtruth_path is target_truth
-    assert isinstance(measurement.groundtruth_path, GroundTruthPath)
-
 
 def test_aesaradar():
     target = State([75e3, 0, 10e3, 0, 20e3, 0], timestamp=datetime.datetime.now())
@@ -489,8 +447,8 @@ def test_swer(repeats=10000):
                       beam_transition_model=StationaryBeam(
                           centre=[np.deg2rad(15), np.deg2rad(20)]),
                       measurement_model=None,
-                      position=StateVector([0.0] * 3),
-                      orientation=StateVector([0.0] * 3))
+                      position=StateVector([0.0]*3),
+                      orientation=StateVector([0.0]*3))
     # populate list of random rcs
     for i in range(0, repeats):
         list_rcs[i] = radar.gen_probability(target)[2]
@@ -524,10 +482,9 @@ def test_detection():
                           mapping=[0, 1, 2],
                           ndim_state=3))
 
-    target_state = GroundTruthState([50e3, 10e3, 20e3], timestamp=datetime.datetime.now())
-    target_truth = GroundTruthPath([target_state])
-
-    truth = {target_truth}
+    truth = set()
+    target = State([50e3, 10e3, 20e3], timestamp=datetime.datetime.now())
+    truth.add(target)
 
     measurement = radar.measure(truth)
     measurement = next(iter(measurement))  # Get measurement from set
@@ -535,15 +492,8 @@ def test_detection():
     assert np.allclose(measurement.state_vector,
                        StateVector([50e3, 10e3, 20e3]), atol=5)
 
-    # Assert is TrueDetection type
-    assert isinstance(measurement, TrueDetection)
-    assert measurement.groundtruth_path is target_truth
-    assert isinstance(measurement.groundtruth_path, GroundTruthPath)
-
-    target2_state = GroundTruthState([50e3, 10e3, 20e3], timestamp=datetime.datetime.now())
-    target2_truth = GroundTruthPath([target2_state])
-
-    truth.add(target2_truth)
+    target = State([50e3, 10e3, 20e3], timestamp=datetime.datetime.now())
+    truth.add(target)
 
     # Generate a noiseless measurement for each of the given target states
     measurements = radar.measure(truth)
@@ -551,17 +501,11 @@ def test_detection():
     # Two measurements for 2 truth states
     assert len(measurements) == 2
 
-    # Measurements store ground truth paths
-    for measurement in measurements:
-        assert measurement.groundtruth_path in truth
-        assert isinstance(measurement.groundtruth_path, GroundTruthPath)
-
 
 def test_failed_detect():
-    target_state = GroundTruthState([75e3, 0, 10e3, 0, 20e3, 0], timestamp=datetime.datetime.now())
-    target_truth = GroundTruthPath([target_state])
-
-    truth = {target_truth}
+    truth = set()
+    target = State([75e3, 0, 10e3, 0, 20e3, 0], timestamp=datetime.datetime.now())
+    truth.add(target)
 
     radar = AESARadar(antenna_gain=30,
                       position_mapping=[0, 2, 4],
@@ -596,7 +540,7 @@ def test_target_rcs():
 
     radar = AESARadar(antenna_gain=36,
                       position_mapping=[0, 1, 2],
-                      position=StateVector([0.0] * 3),
+                      position=StateVector([0.0]*3),
                       orientation=StateVector([0.0] * 3),
                       frequency=10e9,
                       number_pulses=10,
diff --git a/stonesoup/sensor/tests/test_passive.py b/stonesoup/sensor/tests/test_passive.py
index 06e87931..4ae57f48 100644
--- a/stonesoup/sensor/tests/test_passive.py
+++ b/stonesoup/sensor/tests/test_passive.py
@@ -3,27 +3,24 @@ import datetime
 
 import numpy as np
 
-from stonesoup.types.detection import TrueDetection
-from ..passive import PassiveElevationBearing
 from ...functions import cart2angles, rotx, roty, rotz
 from ...types.array import StateVector, CovarianceMatrix
-from ...types.groundtruth import GroundTruthState, GroundTruthPath
+from ...types.state import State
+from ..passive import PassiveElevationBearing
 
 
 def test_passive_sensor():
     # Input arguments
     # TODO: pytest parametarization
     noise_covar = CovarianceMatrix([[np.deg2rad(0.015), 0],
-                                    [0, np.deg2rad(0.1)]])
+                                   [0, np.deg2rad(0.1)]])
     detector_position = StateVector([1, 1, 0])
     detector_orientation = StateVector([0, 0, 0])
-
-    target_state = GroundTruthState(detector_position + np.array([[1], [1], [0]]),
-                                    timestamp=datetime.datetime.now())
-    target_truth = GroundTruthPath([target_state])
-
-    truth = {target_truth}
-
+    truth = set()
+    target_state = State(detector_position +
+                         np.array([[1], [1], [0]]),
+                         timestamp=datetime.datetime.now())
+    truth.add(target_state)
     measurement_mapping = np.array([0, 1, 2])
 
     # Create a radar object
@@ -61,24 +58,13 @@ def test_passive_sensor():
     assert (np.equal(measurement.state_vector,
                      StateVector([theta, phi])).all())
 
-    # Assert is TrueDetection type
-    assert isinstance(measurement, TrueDetection)
-    assert measurement.groundtruth_path is target_truth
-    assert isinstance(measurement.groundtruth_path, GroundTruthPath)
-
-    target2_state = GroundTruthState(detector_position + np.array([[-1], [-1], [0]]),
-                                     timestamp=datetime.datetime.now())
-    target2_truth = GroundTruthPath([target2_state])
-
-    truth.add(target2_truth)
+    target_state = State(detector_position +
+                         np.array([[-1], [-1], [0]]),
+                         timestamp=datetime.datetime.now())
+    truth.add(target_state)
 
     # Generate a noiseless measurement for each of the given target states
     measurements = detector.measure(truth, noise=False)
 
     # Two measurements for 2 truth states
     assert len(measurements) == 2
-
-    # Measurements store ground truth paths
-    for measurement in measurements:
-        assert measurement.groundtruth_path in truth
-        assert isinstance(measurement.groundtruth_path, GroundTruthPath)
diff --git a/stonesoup/tests/test_declarative.py b/stonesoup/tests/test_declarative.py
index f1086f03..a2a126ad 100644
--- a/stonesoup/tests/test_declarative.py
+++ b/stonesoup/tests/test_declarative.py
@@ -24,16 +24,6 @@ def test_subclass_remove_property(base):
     assert _TestSubclassRemoveProperty("2").property_a == 2
 
 
-def test_sub_subclass_remove_property(base):
-    class _TestSubclassRemoveProperty(base):
-        property_a = 2
-    assert _TestSubclassRemoveProperty("2").property_a == 2
-
-    class _TestSubSubclassRemoveProperty(_TestSubclassRemoveProperty):
-        pass
-    assert _TestSubSubclassRemoveProperty("2").property_a == 2
-
-
 def test_init_unordered(base):
     with pytest.raises(TypeError):
         class _TestUnordered(base):
@@ -103,7 +93,7 @@ def test_readonly_with_setter():
         readonly_property_no_default: float = Property()
 
         @readonly_property_default.setter
-        def readonly_property_default(self, value):
+        def set_readonly_default(self, value):
             # assign on first write only
             if not hasattr(self, '_property_readonly_property_default'):
                 self._property_readonly_property_default = value
@@ -113,7 +103,7 @@ def test_readonly_with_setter():
                 raise AttributeError
 
         @readonly_property_no_default.setter
-        def readonly_property_no_default(self, value):
+        def set_readonly_no_default(self, value):
             # assign on first write only
             if not hasattr(self, '_property_readonly_property_no_default'):
                 self._property_readonly_property_no_default = value
@@ -148,7 +138,7 @@ def test_basic_setter():
         times_two: float = Property(default=5)
 
         @times_two.setter
-        def times_two(self, value):
+        def set_times_two(self, value):
             self._property_times_two = value * 2
 
     test_object = TestSetter(times_two=10)
@@ -170,13 +160,13 @@ def test_basic_getter():
         base_value: float = Property(default=5)
 
         @times_two.getter
-        def times_two(self):
+        def get_times_two(self):
             return self.base_value * 2
 
         # Force the saved value to always be None. Again, don't do this: use a
         # python property
         @times_two.setter
-        def times_two(self, value):
+        def set_times_two(self, value):
             if value is not None:
                 raise ValueError
             self._property_times_two = value
@@ -217,65 +207,6 @@ def test_readonly():
         test_object_default.readonly_property_default = 20
 
 
-def test_readonly_subclass():
-
-    class TestParent(Base):
-        readonly_property_default: float = Property(default=0, readonly=True)
-        readonly_property_no_default: float = Property(readonly=True)
-
-    class TestReadonly(TestParent):
-        pass
-
-    test_object = TestReadonly(readonly_property_default=10,
-                               readonly_property_no_default=20)
-
-    # first test read
-    assert test_object.readonly_property_default == 10
-    assert test_object.readonly_property_no_default == 20
-
-    # then test that write raises an exception
-    with pytest.raises(AttributeError):
-        test_object.readonly_property_default = 20
-    with pytest.raises(AttributeError):
-        test_object.readonly_property_no_default = 10
-
-    # now test when the default is used
-    test_object_default = TestReadonly(readonly_property_no_default=20)
-    assert test_object_default.readonly_property_default == 0
-    with pytest.raises(AttributeError):
-        test_object_default.readonly_property_default = 20
-
-
-def test_readonly_with_getter():
-
-    class TestReadonly(Base):
-        readonly_property_default: float = Property(default=0, readonly=True)
-        readonly_property_no_default: float = Property(readonly=True)
-
-        @readonly_property_default.getter
-        def readonly_property_default(self):
-            return self._property_readonly_property_default
-
-    test_object = TestReadonly(readonly_property_default=10,
-                               readonly_property_no_default=20)
-
-    # first test read
-    assert test_object.readonly_property_default == 10
-    assert test_object.readonly_property_no_default == 20
-
-    # then test that write raises an exception
-    with pytest.raises(AttributeError):
-        test_object.readonly_property_default = 20
-    with pytest.raises(AttributeError):
-        test_object.readonly_property_no_default = 10
-
-    # now test when the default is used
-    test_object_default = TestReadonly(readonly_property_no_default=20)
-    assert test_object_default.readonly_property_default == 0
-    with pytest.raises(AttributeError):
-        test_object_default.readonly_property_default = 20
-
-
 def test_type_hint_checking():
     """ Check that errors are raised for some common type hint errors """
     # no error
diff --git a/stonesoup/tests/test_serialise.py b/stonesoup/tests/test_serialise.py
index 9d91f926..233a4fd6 100644
--- a/stonesoup/tests/test_serialise.py
+++ b/stonesoup/tests/test_serialise.py
@@ -276,26 +276,3 @@ def test_sensor_serialisation(serialised_file):
     sensor = serialised_file.load(serialised_str)
     assert np.allclose(sensor.position, pos)
     assert np.allclose(sensor.orientation, orientation)
-
-
-def test_dump(tmpdir, serialised_file):
-    data = [1, 2, 3]
-    with open(tmpdir.join('dump_file.yml'), 'w') as yaml_file:
-        serialised_file.dump(data, yaml_file)
-
-    with open(tmpdir.join('dump_file.yml')) as yaml_file:
-        read_data = serialised_file.load(yaml_file)
-
-    assert read_data == data
-
-
-def test_dump_all(tmpdir, serialised_file):
-    documents = [[i, i + 1, i + 2] for i in range(5)]
-    with open(tmpdir.join('dump_file.yml'), 'w') as yaml_file:
-        serialised_file.dump_all(documents, yaml_file)
-
-    with open(tmpdir.join('dump_file.yml')) as yaml_file:
-        read_documents = serialised_file.load_all(yaml_file)
-
-        for read_document, document in zip(read_documents, documents):
-            assert read_document == document
diff --git a/stonesoup/types/tests/test_state.py b/stonesoup/types/tests/test_state.py
index c524ba5d..ff64a0f4 100644
--- a/stonesoup/types/tests/test_state.py
+++ b/stonesoup/types/tests/test_state.py
@@ -99,33 +99,9 @@ def test_weighted_gaussian_state():
     mean = StateVector([[1], [2], [3], [4]])  # 4D
     covar = CovarianceMatrix(np.diag([1, 2, 3]))  # 3D
     weight = 0.3
-    timestamp = datetime.datetime.now()
     with pytest.raises(ValueError):
-        WeightedGaussianState(mean, covar, timestamp, weight)
-
-    # Test initialization using a GuassianState
-    mean = StateVector([[1], [2], [3], [4]])  # 4D
-    covar = CovarianceMatrix(np.diag([1, 2, 3, 4]))
-    weight = 0.3
-    gs = GaussianState(mean, covar, timestamp=timestamp)
-    wgs = WeightedGaussianState.from_gaussian_state(gaussian_state=gs, weight=weight)
-    assert np.array_equal(gs.state_vector, wgs.state_vector)
-    assert np.array_equal(gs.covar, wgs.covar)
-    assert gs.timestamp == wgs.timestamp
-    assert weight == wgs.weight
-    assert wgs.state_vector is not gs.state_vector
-    assert wgs.covar is not gs.covar
-
-    # Test copy flag
-    wgs = WeightedGaussianState.from_gaussian_state(gaussian_state=gs, copy=False)
-    assert wgs.state_vector is gs.state_vector
-    assert wgs.covar is gs.covar
-
-    # Test gaussian_state property
-    gs2 = wgs.gaussian_state
-    assert np.array_equal(gs.state_vector, gs2.state_vector)
-    assert np.array_equal(gs.covar, gs2.covar)
-    assert gs.timestamp == gs2.timestamp
+        a = WeightedGaussianState(mean, covar, weight)
+        assert a.weight == weight
 
 
 def test_particlestate():